prsi/web/static/index.html
Pavel Flegr ff5d6ab91d asd
2026-05-07 19:10:23 +02:00

1979 lines
69 KiB
HTML

<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Prší</title>
<style>
/* ── Reset & Base ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--felt: #1a6b35;
--felt-d: #14522a;
--felt-l: #22894a;
--gold: #f0c040;
--red: #c0392b;
--black: #1a1a2e;
--card-w: 72px;
--card-h: 108px;
--radius: 10px;
--shadow: 0 4px 20px rgba(0,0,0,.5);
}
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: var(--felt);
background-image:
radial-gradient(ellipse at 50% 50%, var(--felt-l) 0%, var(--felt-d) 100%);
color: #fff;
min-height: 100vh;
overflow-x: hidden;
}
/* ══════════════════════════════
LOBBY
══════════════════════════════ */
#lobby {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
}
.lobby-card {
background: rgba(0,0,0,.45);
backdrop-filter: blur(10px);
border: 1px solid rgba(255,255,255,.12);
border-radius: 20px;
padding: 40px 48px;
width: 100%;
max-width: 420px;
box-shadow: var(--shadow);
text-align: center;
}
.lobby-title {
font-size: 3.5rem;
font-weight: 900;
letter-spacing: 4px;
color: var(--gold);
text-shadow: 0 2px 12px rgba(0,0,0,.6);
margin-bottom: 4px;
}
.lobby-subtitle {
font-size: .85rem;
color: rgba(255,255,255,.5);
margin-bottom: 32px;
letter-spacing: 2px;
text-transform: uppercase;
}
.input-group {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
}
.input-group input {
padding: 12px 16px;
font-size: 1rem;
border-radius: 10px;
border: 1px solid rgba(255,255,255,.2);
background: rgba(255,255,255,.1);
color: #fff;
outline: none;
transition: border-color .2s;
}
.input-group input::placeholder { color: rgba(255,255,255,.4); }
.input-group input:focus { border-color: var(--gold); }
.btn {
padding: 12px 24px;
font-size: 1rem;
font-weight: 700;
border: none;
border-radius: 10px;
cursor: pointer;
transition: transform .1s, opacity .2s;
}
.btn:active { transform: scale(.97); }
.btn-primary {
background: var(--gold);
color: #1a1a1a;
width: 100%;
}
.btn-primary:hover { opacity: .9; }
.btn-secondary {
background: rgba(255,255,255,.12);
color: #fff;
border: 1px solid rgba(255,255,255,.2);
width: 100%;
}
.btn-secondary:hover { background: rgba(255,255,255,.2); }
.divider {
display: flex;
align-items: center;
gap: 10px;
margin: 20px 0;
color: rgba(255,255,255,.4);
font-size: .8rem;
text-transform: uppercase;
letter-spacing: 1px;
}
.divider::before, .divider::after {
content: '';
flex: 1;
height: 1px;
background: rgba(255,255,255,.15);
}
.lobby-list-wrap {
margin-top: 16px;
text-align: left;
}
.lobby-list-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.lobby-list-title {
font-size: .72rem;
letter-spacing: 1px;
text-transform: uppercase;
color: rgba(255,255,255,.55);
}
.btn-refresh-lobbies {
background: rgba(255,255,255,.1);
border: 1px solid rgba(255,255,255,.18);
color: rgba(255,255,255,.85);
border-radius: 8px;
padding: 5px 9px;
font-size: .72rem;
cursor: pointer;
}
.btn-refresh-lobbies:hover { background: rgba(255,255,255,.18); }
#lobby-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 210px;
overflow-y: auto;
padding-right: 2px;
}
.lobby-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: rgba(255,255,255,.06);
border: 1px solid rgba(255,255,255,.12);
border-radius: 10px;
padding: 10px 12px;
}
.lobby-id { font-family: ui-monospace, Menlo, Consolas, monospace; color: var(--gold); font-size: .86rem; }
.lobby-meta { font-size: .72rem; color: rgba(255,255,255,.6); margin-top: 2px; }
.btn-join-lobby {
background: var(--gold);
color: #1a1a1a;
border: none;
border-radius: 8px;
padding: 7px 10px;
font-size: .78rem;
font-weight: 700;
cursor: pointer;
}
.btn-join-lobby:hover { opacity: .9; }
.lobby-empty {
font-size: .78rem;
color: rgba(255,255,255,.55);
background: rgba(255,255,255,.04);
border: 1px dashed rgba(255,255,255,.15);
border-radius: 10px;
padding: 12px;
text-align: center;
}
/* ══════════════════════════════
TABLE LAYOUT
══════════════════════════════ */
#table {
display: none;
flex-direction: column;
height: 100vh;
max-width: 900px;
margin: 0 auto;
padding: 12px;
gap: 10px;
}
/* Top bar */
#topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: rgba(0,0,0,.3);
border-radius: 12px;
flex-shrink: 0;
}
#game-id-display {
font-size: .75rem;
color: rgba(255,255,255,.5);
}
#game-id-display span {
color: var(--gold);
font-weight: 700;
cursor: pointer;
}
#game-id-display span:hover { text-decoration: underline; }
.btn-view-script {
background: rgba(255,255,255,.12);
color: rgba(255,255,255,.9);
border: 1px solid rgba(255,255,255,.2);
border-radius: 999px;
padding: 6px 12px;
font-size: .72rem;
font-weight: 700;
letter-spacing: .3px;
cursor: pointer;
transition: background .15s, border-color .15s;
}
.btn-view-script:hover {
background: rgba(255,255,255,.18);
border-color: rgba(255,255,255,.35);
}
#status-badge {
padding: 4px 14px;
border-radius: 20px;
font-size: .85rem;
font-weight: 600;
background: rgba(255,255,255,.15);
}
#status-badge.myturn { background: var(--gold); color: #1a1a1a; animation: pulse 1.2s infinite; }
#status-badge.ended { background: #c0392b; }
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(240,192,64,.5); }
50% { box-shadow: 0 0 0 8px rgba(240,192,64,0); }
}
/* Opponents row */
#opponents {
display: flex;
justify-content: center;
gap: 16px;
flex-shrink: 0;
}
.opponent {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.opponent-name {
font-size: .75rem;
color: rgba(255,255,255,.7);
font-weight: 600;
}
.opponent-cards {
display: flex;
}
.mini-card {
width: 24px;
height: 36px;
background: #1565c0;
background-image: repeating-linear-gradient(
45deg,
transparent,
transparent 4px,
rgba(255,255,255,.06) 4px,
rgba(255,255,255,.06) 8px
);
border-radius: 3px;
border: 1px solid rgba(255,255,255,.2);
margin-left: -10px;
box-shadow: 1px 1px 3px rgba(0,0,0,.4);
}
.mini-card:first-child { margin-left: 0; }
.card-count-badge {
font-size: .7rem;
background: rgba(0,0,0,.4);
border-radius: 10px;
padding: 1px 8px;
}
/* Center play area */
#play-area {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 32px;
position: relative;
}
.pile-label {
position: absolute;
top: -18px;
font-size: .7rem;
color: rgba(255,255,255,.45);
text-transform: uppercase;
letter-spacing: 1px;
}
.pile-slot {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
/* Playing card */
.card {
width: var(--card-w);
height: var(--card-h);
background: #fff;
border-radius: 8px;
border: 1px solid rgba(0,0,0,.15);
box-shadow: 0 6px 18px rgba(0,0,0,.5);
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 5px 6px;
cursor: default;
position: relative;
user-select: none;
transition: transform .2s, box-shadow .2s;
}
.card.playable {
cursor: pointer;
}
.card.playable:hover {
transform: translateY(-12px) scale(1.06);
box-shadow: 0 16px 32px rgba(0,0,0,.6);
border-color: var(--gold);
z-index: 10;
}
.card.playable:hover::after {
content: 'Hrát';
position: absolute;
bottom: -24px;
left: 50%;
transform: translateX(-50%);
font-size: .65rem;
color: var(--gold);
white-space: nowrap;
font-weight: 700;
}
.card.red { color: var(--red); }
.card.black { color: #1a1a2e; }
.card-corner { font-size: .7rem; font-weight: 800; line-height: 1; }
.card-corner-bot { transform: rotate(180deg); }
.card-center { font-size: 2rem; text-align: center; }
/* Deck (draw pile) */
.deck-stack {
position: relative;
width: var(--card-w);
height: var(--card-h);
cursor: pointer;
}
.deck-stack .deck-back {
position: absolute;
width: var(--card-w);
height: var(--card-h);
background: #1565c0;
background-image: repeating-linear-gradient(
45deg,
transparent,
transparent 6px,
rgba(255,255,255,.07) 6px,
rgba(255,255,255,.07) 12px
);
border-radius: 8px;
border: 1px solid rgba(255,255,255,.2);
box-shadow: 0 6px 18px rgba(0,0,0,.5);
transition: transform .2s, box-shadow .2s;
}
.deck-stack .deck-back:nth-child(1) { top: 0; left: 0; }
.deck-stack .deck-back:nth-child(2) { top: -2px; left: -2px; }
.deck-stack .deck-back:nth-child(3) { top: -4px; left: -4px; }
.deck-stack:hover .deck-back { transform: scale(1.04); box-shadow: 0 10px 28px rgba(0,0,0,.6); }
.deck-stack .deck-label {
position: absolute;
bottom: -22px;
width: 100%;
text-align: center;
font-size: .65rem;
color: rgba(255,255,255,.55);
text-transform: uppercase;
letter-spacing: 1px;
}
/* Active suit indicator */
#suit-indicator {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
#suit-indicator .suit-icon {
font-size: 2rem;
filter: drop-shadow(0 2px 4px rgba(0,0,0,.5));
}
#suit-indicator .suit-label {
font-size: .65rem;
color: rgba(255,255,255,.5);
text-transform: uppercase;
letter-spacing: 1px;
}
/* Penalty badge */
#penalty-badge {
display: none;
position: absolute;
top: -10px;
right: -10px;
background: var(--red);
color: #fff;
font-size: .7rem;
font-weight: 800;
width: 26px;
height: 26px;
border-radius: 50%;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(0,0,0,.4);
z-index: 5;
}
/* Player hand area */
#hand-area {
flex-shrink: 0;
}
#hand-label {
font-size: .7rem;
color: rgba(255,255,255,.45);
text-align: center;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 6px;
}
#my-hand {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 6px;
padding: 8px;
background: rgba(0,0,0,.2);
border-radius: 14px;
min-height: 130px;
align-items: center;
}
/* Bottom bar */
#bottombar {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
flex-shrink: 0;
}
.btn-action {
padding: 10px 28px;
font-size: .9rem;
font-weight: 700;
border: none;
border-radius: 10px;
cursor: pointer;
transition: transform .1s, opacity .2s;
}
.btn-action:active { transform: scale(.97); }
.btn-action:disabled { opacity: .4; cursor: not-allowed; }
.btn-start { background: var(--gold); color: #1a1a1a; }
.btn-start:hover:not(:disabled) { opacity: .9; }
.btn-draw { background: rgba(255,255,255,.15); color: #fff; border: 1px solid rgba(255,255,255,.25); }
.btn-draw:hover:not(:disabled) { background: rgba(255,255,255,.25); }
/* ══════════════════════════════
SUIT PICKER MODAL
══════════════════════════════ */
#suit-modal {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,.65);
backdrop-filter: blur(6px);
z-index: 100;
align-items: center;
justify-content: center;
}
#suit-modal.open { display: flex; }
.suit-picker {
background: rgba(30,30,30,.95);
border: 1px solid rgba(255,255,255,.15);
border-radius: 20px;
padding: 32px 40px;
text-align: center;
box-shadow: var(--shadow);
}
.suit-picker h2 { margin-bottom: 24px; font-size: 1.2rem; color: var(--gold); }
.suit-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.suit-btn {
padding: 16px 24px;
border-radius: 12px;
border: 2px solid transparent;
font-size: 2rem;
cursor: pointer;
background: rgba(255,255,255,.08);
transition: background .15s, border-color .15s, transform .1s;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.suit-btn span { font-size: .75rem; color: rgba(255,255,255,.6); }
.suit-btn:hover { background: rgba(255,255,255,.2); border-color: var(--gold); transform: scale(1.05); }
.suit-btn.red-suit { color: var(--red); }
.suit-btn.black-suit { color: #e0e0e0; }
/* ══════════════════════════════
TOAST NOTIFICATIONS
══════════════════════════════ */
#toast-container {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 8px;
z-index: 200;
pointer-events: none;
}
.toast {
background: rgba(0,0,0,.85);
color: #fff;
padding: 10px 20px;
border-radius: 30px;
font-size: .85rem;
font-weight: 600;
border-left: 3px solid var(--gold);
animation: toastIn .3s ease, toastOut .4s ease 2.6s forwards;
white-space: nowrap;
}
.toast.error { border-left-color: var(--red); }
@keyframes toastIn { from { opacity:0; transform: translateY(-10px); } to { opacity:1; transform: translateY(0); } }
@keyframes toastOut { from { opacity:1; } to { opacity:0; transform: translateY(-10px); } }
/* ══════════════════════════════
MULTI-CARD SELECTION BAR
══════════════════════════════ */
#selection-bar {
display: none;
position: fixed;
bottom: 48px;
left: 50%;
transform: translateX(-50%);
background: rgba(10,10,10,.92);
border: 1px solid var(--gold);
border-radius: 40px;
padding: 8px 10px 8px 18px;
display: none;
align-items: center;
gap: 10px;
z-index: 60;
box-shadow: 0 4px 24px rgba(0,0,0,.6);
white-space: nowrap;
}
#selection-bar.open { display: flex; }
#selection-bar .sel-label { font-size: .8rem; color: rgba(255,255,255,.6); }
#selection-bar .sel-count { font-weight: 900; color: var(--gold); }
.btn-play-sel {
padding: 7px 18px;
background: var(--gold);
color: #1a1a1a;
font-weight: 700;
font-size: .85rem;
border: none;
border-radius: 30px;
cursor: pointer;
transition: opacity .15s;
}
.btn-play-sel:hover { opacity: .9; }
.btn-cancel-sel {
padding: 7px 14px;
background: rgba(255,255,255,.1);
color: rgba(255,255,255,.6);
font-size: .8rem;
border: none;
border-radius: 30px;
cursor: pointer;
transition: background .15s;
}
.btn-cancel-sel:hover { background: rgba(255,255,255,.18); }
/* selected card highlight */
.card.selected {
border-color: var(--gold) !important;
box-shadow: 0 0 0 3px rgba(240,192,64,.45), 0 8px 20px rgba(0,0,0,.5);
transform: translateY(-14px) scale(1.06);
z-index: 10;
}
#rules-ticker {
display: none;
position: fixed;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,.6);
border: 1px solid rgba(255,255,255,.15);
border-radius: 20px;
padding: 6px 18px;
font-size: .75rem;
color: rgba(255,255,255,.7);
max-width: 90vw;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
z-index: 50;
}
#rules-ticker span { color: var(--gold); font-weight: 700; }
/* ══════════════════════════════
WINNER OVERLAY
══════════════════════════════ */
#winner-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,.75);
backdrop-filter: blur(10px);
z-index: 150;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 0;
overflow-y: auto;
padding: 20px;
}
#winner-overlay.open { display: flex; }
#current-script-modal {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,.72);
backdrop-filter: blur(8px);
z-index: 140;
align-items: center;
justify-content: center;
padding: 16px;
}
#current-script-modal.open { display: flex; }
.current-script-card {
width: min(960px, 96vw);
max-height: 86vh;
display: flex;
flex-direction: column;
background: rgba(15,15,15,.96);
border: 1px solid rgba(255,255,255,.14);
border-radius: 16px;
box-shadow: var(--shadow);
overflow: hidden;
}
.current-script-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-bottom: 1px solid rgba(255,255,255,.08);
background: rgba(255,255,255,.02);
}
.current-script-title {
font-size: .78rem;
text-transform: uppercase;
letter-spacing: 1.2px;
color: var(--gold);
font-weight: 800;
}
.current-script-controls {
display: flex;
align-items: center;
gap: 8px;
}
.btn-script-mini {
background: rgba(255,255,255,.1);
color: #fff;
border: 1px solid rgba(255,255,255,.2);
border-radius: 8px;
padding: 6px 10px;
font-size: .72rem;
cursor: pointer;
}
.btn-script-mini:hover { background: rgba(255,255,255,.16); }
.current-script-meta {
font-size: .72rem;
color: rgba(255,255,255,.6);
}
#current-script-pre {
margin: 0;
flex: 1;
overflow: auto;
padding: 12px;
background: #0d1117;
color: #e6edf3;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
font-size: .84rem;
line-height: 1.45;
white-space: pre;
}
#current-script-pre .kw { color: #ff7b72; }
#current-script-pre .fn { color: #d2a8ff; }
#current-script-pre .str { color: #a5d6ff; }
#current-script-pre .num { color: #79c0ff; }
#current-script-pre .cmt { color: #8b949e; font-style: italic; }
#current-script-pre .op { color: #ff7b72; }
#current-script-pre .pun { color: #e6edf3; }
#current-script-pre .id { color: #e6edf3; }
.winner-card {
background: rgba(20,20,20,.9);
border: 1px solid rgba(255,255,255,.12);
border-radius: 24px;
padding: 36px 40px;
width: 100%;
max-width: 560px;
box-shadow: var(--shadow);
text-align: center;
}
.winner-trophy { font-size: 4rem; margin-bottom: 8px; }
.winner-title { font-size: 2rem; font-weight: 900; color: var(--gold); margin-bottom: 4px; }
.winner-sub { color: rgba(255,255,255,.5); font-size: .85rem; margin-bottom: 28px; }
/* Rule proposal section (winner only) */
#rule-section { margin-top: 24px; text-align: left; }
.rule-section-heading {
font-size: .7rem;
text-transform: uppercase;
letter-spacing: 2px;
color: var(--gold);
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 6px;
}
.rule-section-heading::after {
content: '';
flex: 1;
height: 1px;
background: rgba(255,255,255,.1);
}
#rule-prompt-input {
width: 100%;
padding: 12px 14px;
background: rgba(255,255,255,.07);
border: 1px solid rgba(255,255,255,.2);
border-radius: 10px;
color: #fff;
font-size: .95rem;
resize: none;
outline: none;
transition: border-color .2s;
margin-bottom: 10px;
}
#rule-prompt-input::placeholder { color: rgba(255,255,255,.3); }
#rule-prompt-input:focus { border-color: var(--gold); }
.btn-generate {
width: 100%;
padding: 11px;
background: var(--gold);
color: #1a1a1a;
font-weight: 700;
font-size: .95rem;
border: none;
border-radius: 10px;
cursor: pointer;
transition: opacity .2s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn-generate:hover:not(:disabled) { opacity: .9; }
.btn-generate:disabled { opacity: .45; cursor: not-allowed; }
/* Script preview */
#script-preview-wrap {
display: none;
margin-top: 16px;
background: #0d1117;
border: 1px solid rgba(255,255,255,.1);
border-radius: 10px;
overflow: hidden;
}
#script-preview-wrap.open { display: block; }
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
background: rgba(255,255,255,.04);
border-bottom: 1px solid rgba(255,255,255,.07);
font-size: .7rem;
color: rgba(255,255,255,.4);
text-transform: uppercase;
letter-spacing: 1px;
}
.preview-header button {
background: none;
border: none;
color: rgba(255,255,255,.35);
cursor: pointer;
font-size: .75rem;
padding: 2px 6px;
border-radius: 4px;
transition: background .15s, color .15s;
}
.preview-header button:hover { background: rgba(255,255,255,.08); color: #fff; }
#script-preview {
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: .73rem;
line-height: 1.65;
padding: 14px 16px;
max-height: 260px;
overflow-y: auto;
overflow-x: auto;
white-space: pre;
tab-size: 4;
/* scrollbar */
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,.15) transparent;
}
#script-preview::-webkit-scrollbar { width: 6px; height: 6px; }
#script-preview::-webkit-scrollbar-track { background: transparent; }
#script-preview::-webkit-scrollbar-thumb { background: rgba(255,255,255,.15); border-radius: 3px; }
/* Syntax highlight token colours (GitHub Dark palette) */
#script-preview .kw { color: #ff7b72; } /* keywords: function, return, var, if… */
#script-preview .fn { color: #d2a8ff; } /* function names */
#script-preview .str { color: #a5d6ff; } /* string literals */
#script-preview .num { color: #79c0ff; } /* numbers */
#script-preview .cmt { color: #8b949e; font-style: italic; } /* comments */
#script-preview .op { color: #ff7b72; } /* operators === !== > < */
#script-preview .pun { color: #e6edf3; } /* punctuation: {}();, */
#script-preview .id { color: #e6edf3; } /* plain identifiers */
}
#rule-description-box {
display: none;
margin-top: 12px;
padding: 10px 14px;
border-radius: 8px;
font-size: .85rem;
line-height: 1.5;
}
#rule-description-box.open { display: block; }
#rule-description-box.accepted {
background: rgba(240,192,64,.1);
border: 1px solid rgba(240,192,64,.3);
color: rgba(255,255,255,.85);
}
#rule-description-box.accepted strong { color: var(--gold); }
#rule-description-box.rejected {
background: rgba(192,57,43,.15);
border: 1px solid rgba(192,57,43,.4);
color: rgba(255,255,255,.85);
}
#rule-description-box.rejected strong { color: #e74c3c; }
.btn-confirm-rule {
display: none;
width: 100%;
margin-top: 10px;
padding: 11px;
background: #22c55e;
color: #fff;
font-weight: 700;
font-size: .95rem;
border: none;
border-radius: 10px;
cursor: pointer;
transition: opacity .2s;
}
.btn-confirm-rule.open { display: block; }
.btn-confirm-rule:hover { opacity: .9; }
/* Active rules history (shown to all players in the overlay) */
#active-rules-list {
margin-top: 20px;
text-align: left;
}
.rule-item {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 8px 0;
border-bottom: 1px solid rgba(255,255,255,.07);
font-size: .8rem;
}
.rule-item:last-child { border-bottom: none; }
.rule-bullet {
width: 20px;
height: 20px;
min-width: 20px;
border-radius: 50%;
background: var(--gold);
color: #1a1a1a;
display: flex;
align-items: center;
justify-content: center;
font-size: .65rem;
font-weight: 900;
margin-top: 1px;
}
.rule-text { color: rgba(255,255,255,.85); }
.rule-author { color: rgba(255,255,255,.4); font-size: .7rem; margin-top: 2px; }
/* Lobby invite info */
#invite-box {
display: none;
margin-top: 20px;
padding: 16px;
background: rgba(255,255,255,.07);
border: 1px solid rgba(255,255,255,.15);
border-radius: 12px;
text-align: left;
}
#invite-box .invite-label { font-size: .7rem; color: rgba(255,255,255,.5); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 1px; }
#invite-box .invite-id {
font-size: 1.4rem;
font-weight: 900;
color: var(--gold);
letter-spacing: 4px;
cursor: pointer;
}
#invite-box .invite-hint { font-size: .75rem; color: rgba(255,255,255,.45); margin-top: 4px; }
</style>
</head>
<body>
<!-- ══ LOBBY ══ -->
<div id="lobby">
<div class="lobby-card">
<div class="lobby-title">PRŠÍ</div>
<div class="lobby-subtitle">Česká karetní hra</div>
<div class="input-group">
<input type="text" id="player-name" placeholder="Tvoje jméno" maxlength="20" autofocus>
<button class="btn btn-primary" onclick="createGame()">Vytvořit novou hru</button>
</div>
<div class="divider">nebo</div>
<div class="input-group">
<input type="text" id="game-id-input" placeholder="Kód hry (např. a1b2c3d4)" maxlength="20" style="text-transform:uppercase; letter-spacing:3px;">
<button class="btn btn-secondary" onclick="joinExisting()">Připojit se ke hře</button>
</div>
<div id="invite-box">
<div class="invite-label">Kód tvé hry — sdílej ho přátelům</div>
<div class="invite-id" id="invite-id-text" onclick="copyCode()" title="Klikni pro zkopírování"></div>
<div class="invite-hint">Klikni na kód pro zkopírování · Čekáme na další hráče…</div>
</div>
<div class="lobby-list-wrap">
<div class="lobby-list-head">
<div class="lobby-list-title">Veřejné lobby</div>
<button class="btn-refresh-lobbies" onclick="loadLobbies()">Obnovit</button>
</div>
<div id="lobby-list"></div>
</div>
</div>
</div>
<!-- ══ TABLE ══ -->
<div id="table">
<!-- Top bar -->
<div id="topbar">
<div id="game-id-display">Hra: <span id="topbar-id" onclick="copyCode()" title="Zkopírovat kód"></span></div>
<div id="status-badge">Čekání…</div>
<div id="players-summary">
<button class="btn-view-script" onclick="openCurrentScript()">Aktuální skript</button>
</div>
</div>
<!-- Opponents -->
<div id="opponents"></div>
<!-- Center: play area -->
<div id="play-area">
<!-- Draw deck -->
<div class="pile-slot">
<div class="pile-label">Balíček</div>
<div class="deck-stack" id="deck-btn" onclick="drawCard()">
<div class="deck-back"></div>
<div class="deck-back"></div>
<div class="deck-back"></div>
<div class="deck-label">Líznout / Stát</div>
</div>
</div>
<!-- Active suit -->
<div id="suit-indicator">
<div class="suit-icon" id="suit-icon"></div>
<div class="suit-label">Aktivní barva</div>
</div>
<!-- Discard pile -->
<div class="pile-slot" style="position:relative;">
<div class="pile-label">Hromádka</div>
<div id="penalty-badge">+?</div>
<div id="discard-top"></div>
</div>
</div>
<!-- My hand -->
<div id="hand-area">
<div id="hand-label">Tvoje karty</div>
<div id="my-hand"></div>
<div id="my-hand-zone"></div>
</div>
<!-- Bottom bar -->
<div id="bottombar">
<button id="start-btn" class="btn-action btn-start" onclick="startGame()" style="display:none;">Začít hru</button>
</div>
</div>
<!-- ══ CURRENT SCRIPT MODAL ══ -->
<div id="current-script-modal" onclick="closeCurrentScript(event)">
<div class="current-script-card" onclick="event.stopPropagation()">
<div class="current-script-head">
<div>
<div class="current-script-title">Aktuální pravidlový skript</div>
<div class="current-script-meta" id="current-script-meta">Načítám…</div>
</div>
<div class="current-script-controls">
<button class="btn-script-mini" onclick="refreshCurrentScript()">Obnovit</button>
<button class="btn-script-mini" onclick="closeCurrentScript()">Zavřít</button>
</div>
</div>
<pre id="current-script-pre"></pre>
</div>
</div>
<!-- ══ SUIT PICKER MODAL ══ -->
<div id="suit-modal">
<div class="suit-picker">
<h2>Vyber barvu</h2>
<div class="suit-grid">
<button class="suit-btn red-suit" onclick="pickSuit('hearts')"><span>Srdce</span></button>
<button class="suit-btn red-suit" onclick="pickSuit('diamonds')"><span>Kára</span></button>
<button class="suit-btn black-suit" onclick="pickSuit('spades')"><span>Piky</span></button>
<button class="suit-btn black-suit" onclick="pickSuit('clubs')"><span>Kříže</span></button>
</div>
</div>
</div>
<!-- ══ WINNER OVERLAY ══ -->
<div id="winner-overlay">
<div class="winner-card">
<div class="winner-trophy" id="winner-emoji">🏆</div>
<div class="winner-title" id="winner-text"></div>
<div class="winner-sub">Hra skončila</div>
<!-- Active rules history (visible to all players) -->
<div id="active-rules-list"></div>
<!-- Rule proposal (winner only) -->
<div id="rule-section" style="display:none;">
<div class="rule-section-heading">Navrhni nové pravidlo</div>
<textarea id="rule-prompt-input" rows="3"
placeholder="Např. &quot;Král dává soupeři 3 karty&quot; nebo &quot;Osmičky mění pořadí hráčů&quot;…"></textarea>
<button class="btn-generate" id="btn-generate" onclick="generateRule()">
<span id="gen-icon"></span>
<span id="gen-label">Vygenerovat pomocí AI</span>
</button>
<!-- Preview of generated script -->
<div id="script-preview-wrap">
<div class="preview-header">
<span>Vygenerovaný skript</span>
<button onclick="togglePreview()" id="preview-toggle-btn">zobrazit ▼</button>
</div>
<pre id="script-preview" style="display:none;"></pre>
</div>
<!-- Human-readable description -->
<div id="rule-description-box"></div>
<!-- Confirm button -->
<button class="btn-confirm-rule" id="btn-confirm-rule" onclick="confirmRule()">
✅ Potvrdit a přijmout pravidlo
</button>
</div>
<!-- Buttons for all players -->
<div style="display:flex; gap:10px; margin-top:20px; justify-content:center; flex-wrap:wrap;">
<button class="btn btn-primary" style="width:180px;" onclick="restartGame()">Hrát znovu</button>
<button class="btn btn-secondary" style="width:180px;" onclick="location.reload()">Opustit hru</button>
</div>
</div>
</div>
<!-- ══ MULTI-CARD SELECTION BAR ══ -->
<div id="selection-bar">
<span class="sel-label">Vybrané karty: <span class="sel-count" id="sel-count">0</span></span>
<button class="btn-play-sel" onclick="playSelected()">Zahrát výběr</button>
<button class="btn-cancel-sel" onclick="clearSelection()">✕ Zrušit</button>
</div>
<!-- Active rules ticker -->
<div id="rules-ticker"></div>
<!-- ══ ACTIVE RULES TICKER ══ -->
<!-- ══ TOAST CONTAINER ══ -->
<div id="toast-container"></div>
<script>
// ── State ──────────────────────────────────────────────────────────────
let ws;
let myId = Math.random().toString(36).substr(2, 9);
let myName = '';
let gameId = '';
let gameState = null;
let activeRules = [];
let selectedCards = []; // indices into my hand currently selected
let pendingCardIdx = null;
let pendingScript = null;
let pendingDescription = null;
let isWinner = false;
let isRestarting = false;
let restartRequestedAt = 0;
let restartFallbackTimer = null;
let joinPending = false;
let tableVisible = false;
let currentScriptOpen = false;
let lobbyPollTimer = null;
const SUIT_ICONS = { hearts:'♥', diamonds:'♦', spades:'♠', clubs:'♣' };
const SUIT_NAMES = { hearts:'Srdce', diamonds:'Kára', spades:'Piky', clubs:'Kříže' };
const SUIT_COLOR = { hearts:'red', diamonds:'red', spades:'black', clubs:'black' };
const VALUE_LABEL = { '7':'7','8':'8','9':'9','10':'10', under:'J', upper:'Q', king:'K', ace:'A' };
// ── WebSocket ──────────────────────────────────────────────────────────
function connect() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}/ws`);
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'created') {
gameId = msg.game_id;
document.getElementById('invite-box').style.display = 'block';
document.getElementById('invite-id-text').textContent = gameId.toUpperCase();
sendJoin();
} else if (msg.type === 'update') {
const wasMyTurn = gameState && isMyTurn();
gameState = msg.game;
const joined = !!(gameState.players || []).find(p => p.id === myId);
if (joinPending && joined && !tableVisible) {
switchToTable();
joinPending = false;
}
if (gameState.state !== 'ended') {
isRestarting = false;
restartRequestedAt = 0;
if (restartFallbackTimer) {
clearTimeout(restartFallbackTimer);
restartFallbackTimer = null;
}
resetEndOfGameUI();
} else if (isRestarting && Date.now() - restartRequestedAt > 1200) {
// Prevent getting stuck in "restarting" when server stays in ended state.
isRestarting = false;
}
// Clear selection when the turn changes away from us.
if (wasMyTurn && !isMyTurn()) clearSelection();
renderTable();
} else if (msg.type === 'rules_updated') {
activeRules = msg.rules || [];
renderRulesTicker();
renderActiveRulesList();
if (currentScriptOpen) refreshCurrentScript();
} else if (msg.type === 'error') {
if (joinPending) joinPending = false;
if (isRestarting) {
isRestarting = false;
restartRequestedAt = 0;
if (restartFallbackTimer) {
clearTimeout(restartFallbackTimer);
restartFallbackTimer = null;
}
renderTable();
}
toast(msg.message, true);
}
};
ws.onerror = () => toast('Spojení selhalo', true);
ws.onclose = () => { if (gameState && gameState.state !== 'ended') toast('Spojení přerušeno', true); };
}
// ── Lobby ──────────────────────────────────────────────────────────────
function createGame() {
myName = document.getElementById('player-name').value.trim() || 'Hráč';
ws.send(JSON.stringify({ type: 'create' }));
}
function joinExisting() {
myName = document.getElementById('player-name').value.trim() || 'Hráč';
gameId = document.getElementById('game-id-input').value.trim().toLowerCase();
if (!gameId) { toast('Zadej kód hry', true); return; }
sendJoin();
}
function joinLobby(id) {
myName = document.getElementById('player-name').value.trim() || 'Hráč';
gameId = String(id || '').toLowerCase();
if (!gameId) return;
document.getElementById('game-id-input').value = gameId.toUpperCase();
sendJoin();
}
function sendJoin() {
joinPending = true;
ws.send(JSON.stringify({ type:'join', player_id:myId, player_name:myName, game_id:gameId }));
}
function switchToTable() {
stopLobbyPolling();
document.getElementById('lobby').style.display = 'none';
document.getElementById('table').style.display = 'flex';
document.getElementById('topbar-id').textContent = gameId.toUpperCase();
tableVisible = true;
}
function copyCode() {
navigator.clipboard.writeText(gameId.toUpperCase()).then(() => toast('Kód zkopírován: ' + gameId.toUpperCase()));
}
async function loadLobbies() {
const list = document.getElementById('lobby-list');
if (!list) return;
try {
const resp = await fetch('/api/lobbies');
if (!resp.ok) throw new Error(resp.statusText);
const data = await resp.json();
const lobbies = data.lobbies || [];
if (lobbies.length === 0) {
list.innerHTML = `<div class="lobby-empty">Zatím žádné čekající hry</div>`;
return;
}
list.innerHTML = lobbies.map((l) => {
const players = (l.players || []).map(escHtml).join(', ');
const rules = l.has_rules ? ' · vlastní pravidla' : '';
const safeID = escHtml(String(l.game_id || '').toUpperCase());
const onclickID = String(l.game_id || '').replace(/'/g, "\\'");
return `
<div class="lobby-item">
<div>
<div class="lobby-id">${safeID}</div>
<div class="lobby-meta">${l.player_count || 0}/4 hráči${rules}${players ? ` · ${players}` : ''}</div>
</div>
<button class="btn-join-lobby" onclick="joinLobby('${onclickID}')">Připojit</button>
</div>
`;
}).join('');
} catch (err) {
list.innerHTML = `<div class="lobby-empty">Nelze načíst lobby (${escHtml(err.message)})</div>`;
}
}
function startLobbyPolling() {
stopLobbyPolling();
loadLobbies();
lobbyPollTimer = setInterval(loadLobbies, 5000);
}
function stopLobbyPolling() {
if (!lobbyPollTimer) return;
clearInterval(lobbyPollTimer);
lobbyPollTimer = null;
}
// ── Game actions ───────────────────────────────────────────────────────
function startGame() {
ws.send(JSON.stringify({ type:'start', game_id:gameId }));
}
function restartGame() {
isRestarting = true;
restartRequestedAt = Date.now();
if (restartFallbackTimer) clearTimeout(restartFallbackTimer);
restartFallbackTimer = setTimeout(() => {
if (!isRestarting) return;
isRestarting = false;
restartRequestedAt = 0;
renderTable();
}, 1800);
resetEndOfGameUI();
ws.send(JSON.stringify({ type:'start', game_id:gameId }));
}
function resetEndOfGameUI() {
document.getElementById('winner-overlay').classList.remove('open');
document.getElementById('rule-section').style.display = 'none';
document.getElementById('active-rules-list').innerHTML = '';
document.getElementById('rule-prompt-input').value = '';
document.getElementById('script-preview-wrap').classList.remove('open');
document.getElementById('script-preview').style.display = 'none';
document.getElementById('preview-toggle-btn').textContent = 'zobrazit ▼';
document.getElementById('rule-description-box').className = '';
document.getElementById('rule-description-box').innerHTML = '';
document.getElementById('btn-confirm-rule').classList.remove('open');
document.getElementById('btn-generate').disabled = false;
document.getElementById('gen-icon').textContent = '✨';
document.getElementById('gen-label').textContent = 'Vygenerovat pomocí AI';
pendingScript = null;
pendingDescription = null;
}
function drawCard() {
if (!isMyTurn()) return;
if (gameState && gameState.pending_choice) return;
clearSelection();
ws.send(JSON.stringify({ type:'draw', game_id:gameId, player_id:myId }));
}
// Toggle a card in/out of the selection.
function toggleCard(idx) {
if (!isMyTurn()) return;
if (gameState && gameState.pending_choice) return;
if (selectedCards.length === 1 && selectedCards[0] === idx) {
selectedCards = [];
} else {
selectedCards = [idx];
}
renderMyHand();
renderSelectionBar();
}
function clearSelection() {
selectedCards = [];
renderMyHand();
renderSelectionBar();
}
// Play all currently selected cards.
function playSelected() {
if (!isMyTurn() || selectedCards.length === 0) return;
const me = myPlayer();
if (!me) return;
// Single-card play only.
const hasUpper = me.hand[selectedCards[0]].value === 'upper';
if (hasUpper) {
pendingCardIdx = selectedCards[0];
document.getElementById('suit-modal').classList.add('open');
} else {
sendPlay([...selectedCards], me.hand[selectedCards[0]].suit);
}
}
function pickSuit(suit) {
document.getElementById('suit-modal').classList.remove('open');
if (pendingCardIdx !== null) {
const indices = Array.isArray(pendingCardIdx) ? pendingCardIdx : [pendingCardIdx];
sendPlay(indices, suit);
pendingCardIdx = null;
}
}
function sendPlay(indices, suit) {
ws.send(JSON.stringify({
type: 'play',
game_id: gameId,
player_id: myId,
card_indices: indices,
chosen_suit: suit
}));
clearSelection();
}
function sendChoice(choice) {
ws.send(JSON.stringify({
type: 'choice',
game_id: gameId,
player_id: myId,
choice
}));
}
async function fetchCurrentScript() {
if (!gameId) throw new Error('missing game id');
const resp = await fetch(`/api/script?game_id=${gameId}`);
if (!resp.ok) {
const text = await resp.text();
throw new Error(text || resp.statusText);
}
const data = await resp.json();
return data.script || '';
}
async function openCurrentScript() {
currentScriptOpen = true;
const modal = document.getElementById('current-script-modal');
modal.classList.add('open');
await refreshCurrentScript();
}
function closeCurrentScript(e) {
if (e && e.target && e.target.id !== 'current-script-modal') return;
currentScriptOpen = false;
document.getElementById('current-script-modal').classList.remove('open');
}
async function refreshCurrentScript() {
const pre = document.getElementById('current-script-pre');
const meta = document.getElementById('current-script-meta');
pre.textContent = 'Načítám skript…';
meta.textContent = 'Načítám…';
try {
const script = await fetchCurrentScript();
pre.innerHTML = highlight(formatJS(script));
meta.textContent = `Hra ${String(gameId || '').toUpperCase()} · ${new Date().toLocaleTimeString()}`;
} catch (err) {
pre.textContent = `Chyba: ${err.message}`;
meta.textContent = 'Skript se nepodařilo načíst';
}
}
// ── Rule generation flow ───────────────────────────────────────────────
async function generateRule() {
const prompt = document.getElementById('rule-prompt-input').value.trim();
if (!prompt) { toast('Nejdřív napiš pravidlo', true); return; }
const btn = document.getElementById('btn-generate');
const icon = document.getElementById('gen-icon');
const label = document.getElementById('gen-label');
btn.disabled = true;
icon.textContent = '⏳';
label.textContent = 'AI generuje pravidlo…';
try {
// Fetch current script from server
const scriptResp = await fetch(`/api/script?game_id=${gameId}`);
const { script: currentScript } = await scriptResp.json();
const resp = await fetch('/api/generate-rule', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
current_script: currentScript,
rule_prompt: prompt,
winner_name: myName
})
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(text || resp.statusText);
}
const data = await resp.json();
const descBox = document.getElementById('rule-description-box');
if (data.rejected) {
// Show rejection reason and let the user try again
descBox.innerHTML = `<strong>❌ Pravidlo nelze přidat:</strong> ${escHtml(data.reason)}`;
descBox.className = 'open rejected';
icon.textContent = '✨';
label.textContent = 'Zkusit jiné pravidlo';
btn.disabled = false;
return;
}
pendingScript = data.script;
pendingDescription = data.description;
// Show script preview
document.getElementById('script-preview').innerHTML = highlight(formatJS(data.script));
document.getElementById('script-preview-wrap').classList.add('open');
// Show acceptance description
descBox.innerHTML = `<strong>✅ AI popis:</strong> ${escHtml(data.description)}`;
descBox.className = 'open accepted';
// Show confirm button
document.getElementById('btn-confirm-rule').classList.add('open');
icon.textContent = '✅';
label.textContent = 'Skript vygenerován';
} catch (err) {
toast('Chyba: ' + err.message, true);
icon.textContent = '✨';
label.textContent = 'Vygenerovat pomocí AI';
btn.disabled = false;
}
}
function togglePreview() {
const pre = document.getElementById('script-preview');
const btn = document.getElementById('preview-toggle-btn');
if (pre.style.display === 'none') {
pre.style.display = 'block';
btn.textContent = 'skrýt ▲';
} else {
pre.style.display = 'none';
btn.textContent = 'zobrazit ▼';
}
}
function confirmRule() {
if (!pendingScript) return;
ws.send(JSON.stringify({
type: 'set_rule',
game_id: gameId,
player_id: myId,
script: pendingScript,
description: pendingDescription
}));
pendingScript = null;
pendingDescription = null;
document.getElementById('btn-confirm-rule').classList.remove('open');
document.getElementById('rule-description-box').className = '';
document.getElementById('script-preview-wrap').classList.remove('open');
document.getElementById('rule-prompt-input').value = '';
document.getElementById('btn-generate').disabled = false;
document.getElementById('gen-icon').textContent = '✨';
document.getElementById('gen-label').textContent = 'Vygenerovat pomocí AI';
toast('Pravidlo přijato! Platí od příští hry.');
}
// ── Helpers ────────────────────────────────────────────────────────────
function isMyTurn() {
if (!gameState || gameState.state !== 'playing') return false;
return gameState.players[gameState.current_player_index].id === myId;
}
function myPlayer() {
if (!gameState) return null;
return gameState.players.find(p => p.id === myId);
}
// ── Rendering ──────────────────────────────────────────────────────────
function renderTable() {
if (!gameState) return;
if (!isRestarting && gameState.state === 'ended' && gameState.winner) {
isWinner = gameState.winner.id === myId;
showWinnerOverlay();
} else {
// Ensure the end-of-game modal does not stay stuck open after restart.
document.getElementById('winner-overlay').classList.remove('open');
}
// Sync rule list from game state (for players who just joined)
if (gameState.active_rules) {
activeRules = gameState.active_rules;
renderRulesTicker();
}
renderStatusBadge();
renderOpponents();
renderDiscardPile();
renderSuitIndicator();
renderPenaltyBadge();
renderMyHand();
renderStartButton();
}
function showWinnerOverlay() {
document.getElementById('winner-emoji').textContent = isWinner ? '🏆' : '😔';
document.getElementById('winner-text').textContent =
isWinner ? 'Vyhrál jsi!' : (gameState.winner.name + ' vyhrál!');
document.getElementById('rule-section').style.display = isWinner ? 'block' : 'none';
renderActiveRulesList();
document.getElementById('winner-overlay').classList.add('open');
}
function renderActiveRulesList() {
const container = document.getElementById('active-rules-list');
if (!activeRules || activeRules.length === 0) {
container.innerHTML = '';
return;
}
const heading = `<div class="rule-section-heading" style="margin-bottom:10px;">Aktivní pravidla</div>`;
const items = activeRules.map((r, i) => `
<div class="rule-item">
<div class="rule-bullet">${i + 1}</div>
<div>
<div class="rule-text">${escHtml(r.description)}</div>
<div class="rule-author">navrhl: ${escHtml(r.proposed_by)}</div>
</div>
</div>`).join('');
container.innerHTML = heading + items;
}
function renderRulesTicker() {
const ticker = document.getElementById('rules-ticker');
if (!ticker) return;
if (!activeRules || activeRules.length === 0) {
ticker.style.display = 'none';
return;
}
const last = activeRules[activeRules.length - 1];
ticker.innerHTML = `<span>${activeRules.length} pravidel aktivních</span> · ${escHtml(last.description)}`;
ticker.style.display = 'block';
}
function renderStatusBadge() {
const badge = document.getElementById('status-badge');
if (gameState.state === 'waiting') {
badge.textContent = 'Čekání na hráče (' + gameState.players.length + '/4)';
badge.className = '';
} else if (gameState.state === 'ended') {
badge.textContent = 'Konec hry';
badge.className = 'ended';
} else {
const cur = gameState.players[gameState.current_player_index];
const me = cur.id === myId;
badge.textContent = me ? '🃏 Ty na tahu!' : 'Na tahu: ' + cur.name;
badge.className = me ? 'myturn' : '';
}
}
function renderOpponents() {
const others = gameState.players.filter(p => p.id !== myId);
document.getElementById('opponents').innerHTML = others.map(p => {
const cardCount = p.hand_count !== undefined ? p.hand_count : (p.hand ? p.hand.length : 0);
const isCurrent = gameState.state === 'playing' &&
gameState.players[gameState.current_player_index].id === p.id;
const miniCards = Array.from({length: Math.min(cardCount, 12)}, () =>
`<div class="mini-card"></div>`).join('');
return `
<div class="opponent" style="${isCurrent ? '' : 'opacity:.65;'}">
<div class="opponent-name">${isCurrent ? '▶ ' : ''}${escHtml(p.name)}</div>
<div class="opponent-cards">${miniCards}</div>
<div class="card-count-badge">${cardCount} karet</div>
</div>`;
}).join('');
}
function renderDiscardPile() {
const pile = gameState.discard_pile;
if (!pile || pile.length === 0) { document.getElementById('discard-top').innerHTML = ''; return; }
const top = pile[pile.length - 1];
document.getElementById('discard-top').innerHTML = buildCardHTML(top, false);
}
function renderSuitIndicator() {
const suit = gameState.active_suit;
document.getElementById('suit-icon').textContent = SUIT_ICONS[suit] || '';
document.getElementById('suit-icon').style.color =
SUIT_COLOR[suit] === 'red' ? 'var(--red)' : '#e0e0e0';
}
function renderPenaltyBadge() {
const badge = document.getElementById('penalty-badge');
if (gameState.penalty_cards > 0) {
badge.style.display = 'flex';
badge.textContent = '+' + gameState.penalty_cards;
} else {
badge.style.display = 'none';
}
}
function renderMyHand() {
const me = myPlayer();
const container = document.getElementById('my-hand');
const zone = document.getElementById('my-hand-zone');
if (!me || !me.hand) { container.innerHTML = ''; if (zone) zone.innerHTML = ''; return; }
const myTurn = isMyTurn();
const pending = gameState.pending_choice;
const blockedByChoice = pending && pending.player_id === myId;
const penaltyActive = gameState.penalty_cards > 0;
const skipActive = gameState.skip_next;
const top = gameState.discard_pile && gameState.discard_pile.length > 0
? gameState.discard_pile[gameState.discard_pile.length - 1] : null;
container.innerHTML = me.hand.map((card, i) => {
const playable = myTurn && !blockedByChoice && canPlaySingle(card, top, penaltyActive, skipActive);
const selected = selectedCards.includes(i);
return `<div onclick="toggleCard(${i})" style="display:inline-block;">${buildCardHTML(card, playable, selected)}</div>`;
}).join('');
if (!zone) return;
if (!pending) {
zone.innerHTML = '';
return;
}
const mine = pending.player_id === myId;
const options = (pending.options || []).map((o) => {
const safe = String(o || '').replace(/'/g, "\\'");
return `<button class="btn btn-primary" style="width:auto;min-width:110px;" ${mine ? '' : 'disabled'} onclick="sendChoice('${safe}')">${escHtml(o)}</button>`;
}).join('');
zone.innerHTML = `
<div style="margin-top:10px; display:flex; flex-direction:column; gap:10px; align-items:center;">
<div style="font-size:.85rem; color:rgba(255,255,255,.85)">${escHtml(pending.prompt || 'Vyber možnost')}</div>
<div style="display:flex; gap:8px; flex-wrap:wrap; justify-content:center;">${options}</div>
<div style="font-size:.75rem; color:rgba(255,255,255,.55)">${mine ? 'Vyber jednu možnost.' : 'Čeká se na volbu soupeře…'}</div>
</div>
`;
}
function renderSelectionBar() {
const bar = document.getElementById('selection-bar');
if (selectedCards.length === 0 || (gameState && gameState.pending_choice)) {
bar.classList.remove('open');
return;
}
document.getElementById('sel-count').textContent = selectedCards.length;
bar.classList.add('open');
}
function canPlaySingle(card, top, penaltyActive, skipActive) {
if (!top) return true;
if (penaltyActive) return card.value === '7';
if (skipActive) return card.value === 'ace';
if (card.value === 'upper') return true;
return card.suit === gameState.active_suit || card.value === top.value;
}
function renderStartButton() {
const btn = document.getElementById('start-btn');
if (gameState.state === 'waiting' && gameState.players.length >= 2) {
btn.style.display = 'inline-block';
} else {
btn.style.display = 'none';
}
}
function buildCardHTML(card, playable, selected = false) {
const colorClass = (card.suit === 'hearts' || card.suit === 'diamonds') ? 'red' : 'black';
const val = VALUE_LABEL[card.value] || card.value;
const icon = SUIT_ICONS[card.suit];
const cls = ['card', colorClass, playable ? 'playable' : '', selected ? 'selected' : ''].filter(Boolean).join(' ');
return `<div class="${cls}">
<div class="card-corner">${val}<br>${icon}</div>
<div class="card-center">${icon}</div>
<div class="card-corner card-corner-bot">${val}<br>${icon}</div>
</div>`;
}
// ── Toast ──────────────────────────────────────────────────────────────
function toast(msg, isError = false) {
const el = document.createElement('div');
el.className = 'toast' + (isError ? ' error' : '');
el.textContent = msg;
document.getElementById('toast-container').appendChild(el);
setTimeout(() => el.remove(), 3000);
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── JS formatter ───────────────────────────────────────────────────────
// Turns a minified/single-line JS string into readable indented source.
function formatJS(src) {
let out = '';
let depth = 0;
let i = 0;
const indent = () => ' '.repeat(depth);
// Normalise: collapse runs of whitespace (but not inside strings)
// We'll process char by char instead.
while (i < src.length) {
const ch = src[i];
if (ch === '{') {
out += ' {\n';
depth++;
out += indent();
i++;
// skip leading whitespace after {
while (i < src.length && src[i] === ' ') i++;
continue;
}
if (ch === '}') {
// trim trailing spaces on current line
out = out.trimEnd();
depth = Math.max(0, depth - 1);
out += '\n' + indent() + '}';
i++;
// peek: if followed by non-whitespace that isn't ; or , add newline
const next = src[i];
if (next && next !== ';' && next !== ',' && next !== ')') out += '\n' + indent();
continue;
}
if (ch === ';') {
out += ';\n' + indent();
i++;
while (i < src.length && src[i] === ' ') i++;
continue;
}
// Compress multiple spaces into one
if (ch === ' ' && out.length > 0 && out[out.length - 1] === ' ') {
i++;
continue;
}
out += ch;
i++;
}
// Clean up blank lines
return out.replace(/\n{3,}/g, '\n\n').trim();
}
// ── Syntax highlighter ─────────────────────────────────────────────────
// Tokenises the formatted JS and wraps tokens in <span class="..."> tags.
function highlight(code) {
const keywords = new Set([
'function','return','var','let','const','if','else','for','while',
'do','switch','case','break','continue','new','this','typeof','instanceof',
'true','false','null','undefined','of','in'
]);
let result = '';
let i = 0;
while (i < code.length) {
// Line comment
if (code[i] === '/' && code[i+1] === '/') {
let end = code.indexOf('\n', i);
if (end === -1) end = code.length;
result += `<span class="cmt">${escHtml(code.slice(i, end))}</span>`;
i = end;
continue;
}
// Block comment
if (code[i] === '/' && code[i+1] === '*') {
let end = code.indexOf('*/', i + 2);
if (end === -1) end = code.length - 2;
result += `<span class="cmt">${escHtml(code.slice(i, end + 2))}</span>`;
i = end + 2;
continue;
}
// String (single or double quote)
if (code[i] === '"' || code[i] === "'") {
const q = code[i];
let j = i + 1;
while (j < code.length && !(code[j] === q && code[j-1] !== '\\')) j++;
j++;
result += `<span class="str">${escHtml(code.slice(i, j))}</span>`;
i = j;
continue;
}
// Number
if (/[0-9]/.test(code[i]) && (i === 0 || !/[a-zA-Z_$]/.test(code[i-1]))) {
let j = i;
while (j < code.length && /[0-9.]/.test(code[j])) j++;
result += `<span class="num">${escHtml(code.slice(i, j))}</span>`;
i = j;
continue;
}
// Identifier or keyword
if (/[a-zA-Z_$]/.test(code[i])) {
let j = i;
while (j < code.length && /[a-zA-Z0-9_$]/.test(code[j])) j++;
const word = code.slice(i, j);
// Check if followed by '(' — it's a function call/definition name
let k = j;
while (k < code.length && code[k] === ' ') k++;
if (keywords.has(word)) {
result += `<span class="kw">${escHtml(word)}</span>`;
} else if (code[k] === '(') {
result += `<span class="fn">${escHtml(word)}</span>`;
} else {
result += `<span class="id">${escHtml(word)}</span>`;
}
i = j;
continue;
}
// Operators
if (/[=!<>&|+\-*/%]/.test(code[i])) {
let j = i;
while (j < code.length && /[=!<>&|+\-*/%]/.test(code[j])) j++;
result += `<span class="op">${escHtml(code.slice(i, j))}</span>`;
i = j;
continue;
}
// Punctuation / everything else
result += `<span class="pun">${escHtml(code[i])}</span>`;
i++;
}
return result;
}
// ── URL auto-fill ──────────────────────────────────────────────────────
function checkUrlJoin() {
const params = new URLSearchParams(location.search);
const code = params.get('game');
if (code) {
document.getElementById('game-id-input').value = code.toLowerCase();
} else {
startLobbyPolling();
}
}
connect();
checkUrlJoin();
</script>
</body>
</html>