1846 lines
64 KiB
HTML
1846 lines
64 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);
|
|
}
|
|
|
|
/* ══════════════════════════════
|
|
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>
|
|
</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ř. "Král dává soupeři 3 karty" nebo "Osmičky mění pořadí hráčů"…"></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;
|
|
|
|
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 sendJoin() {
|
|
joinPending = true;
|
|
ws.send(JSON.stringify({ type:'join', player_id:myId, player_name:myName, game_id:gameId }));
|
|
}
|
|
|
|
function switchToTable() {
|
|
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()));
|
|
}
|
|
|
|
// ── 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
// ── 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();
|
|
}
|
|
|
|
connect();
|
|
checkUrlJoin();
|
|
</script>
|
|
</body>
|
|
</html>
|