Skrawl / index.html
ausername-12345
Make web app mobile-friendly: responsive CSS, touch drawing, safe areas, keyboard handling
d162052
Raw
History Blame Contribute Delete
59.6 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
<title>Skrawl — Draw & Guess</title>
<link href="https://fonts.googleapis.com/css2?family=Fredoka+One&family=Nunito:wght@400;600;700;800&display=swap" rel="stylesheet"/>
<script src="/socket.io/socket.io.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.21.0/dist/tf.min.js"></script>
<style>
:root {
--ink: #1a1a2e; --paper: #f5f0e8; --cream: #ede8d8;
--accent: #ff6b35; --accent2: #4ecdc4; --accent3: #ffe66d;
--green: #22c55e; --red: #ef4444;
--shadow: 3px 3px 0px var(--ink); --shadow-lg: 5px 5px 0px var(--ink);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Nunito', sans-serif; background: var(--paper); color: var(--ink); min-height: 100vh;
padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom);
background-image: radial-gradient(circle at 20% 20%, rgba(78,205,196,.12) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(255,107,53,.12) 0%, transparent 50%),
repeating-linear-gradient(0deg, transparent, transparent 39px, rgba(0,0,0,.04) 39px, rgba(0,0,0,.04) 40px),
repeating-linear-gradient(90deg, transparent, transparent 39px, rgba(0,0,0,.04) 39px, rgba(0,0,0,.04) 40px); }
h1,h2,h3,.logo { font-family: 'Fredoka One', cursive; }
.screen { display: none; min-height: 100vh; }
.screen.active { display: flex; }
#lobby-screen { flex-direction: column; align-items: center; justify-content: center; padding: 2rem; gap: 2rem; }
.logo-block { text-align: center; }
.logo-block h1 { font-size: clamp(3rem,8vw,5rem); color: var(--ink); letter-spacing: 2px;
text-shadow: 4px 4px 0 var(--accent), 8px 8px 0 var(--accent2); line-height: 1; }
.logo-block p { font-size: 1rem; font-weight: 700; color: var(--accent); letter-spacing: 3px; text-transform: uppercase; margin-top: .5rem; }
.card { background: white; border: 3px solid var(--ink); border-radius: 16px; box-shadow: var(--shadow-lg); padding: 2rem; width: 100%; max-width: 440px; }
.tabs { display: flex; border: 3px solid var(--ink); border-radius: 10px; overflow: hidden; margin-bottom: 1.5rem; }
.tab { flex: 1; padding: .6rem; background: white; border: none; font-family: 'Fredoka One',cursive; font-size: 1rem; cursor: pointer; transition: all .15s; }
.tab.active { background: var(--ink); color: white; }
.input-group { margin-bottom: 1rem; }
.input-group label { display: block; font-weight: 800; font-size: .85rem; text-transform: uppercase; letter-spacing: 1px; margin-bottom: .4rem; }
.input-group input, .input-group select { width: 100%; padding: .75rem 1rem; border: 3px solid var(--ink); border-radius: 10px; font-family: 'Nunito',sans-serif; font-size: 1rem; font-weight: 700; background: var(--paper); outline: none; transition: box-shadow .15s; }
.input-group input:focus, .input-group select:focus { box-shadow: var(--shadow); }
.btn { padding: .75rem 1.5rem; border: 3px solid var(--ink); border-radius: 10px; font-family: 'Fredoka One',cursive; font-size: 1.1rem; cursor: pointer; transition: transform .1s, box-shadow .1s; box-shadow: var(--shadow); letter-spacing: .5px; }
.btn:hover { transform: translate(-1px,-1px); box-shadow: 4px 4px 0 var(--ink); }
.btn:active { transform: translate(2px,2px); box-shadow: 1px 1px 0 var(--ink); }
.btn-primary { background: var(--accent); color: white; width: 100%; }
.btn-accent2 { background: var(--accent2); color: var(--ink); }
.btn:disabled { opacity: .5; cursor: not-allowed; transform: none; box-shadow: var(--shadow); }
.btn:disabled:hover { transform: none; box-shadow: var(--shadow); }
.error-msg { background: #fee2e2; border: 2px solid var(--red); border-radius: 8px; padding: .6rem 1rem; font-weight: 700; font-size: .9rem; color: var(--red); margin-bottom: 1rem; display: none; }
.error-msg.show { display: block; }
#waiting-screen { flex-direction: column; align-items: center; justify-content: center; padding: 2rem; gap: 1.5rem; }
.room-code-display { background: var(--accent3); border: 3px solid var(--ink); border-radius: 16px; box-shadow: var(--shadow-lg); padding: 1.5rem 2rem; text-align: center; }
.room-code-display p { font-weight: 800; font-size: .85rem; text-transform: uppercase; letter-spacing: 2px; }
.room-code-display .code { font-family: 'Fredoka One',cursive; font-size: 3rem; letter-spacing: 8px; color: var(--ink); }
/* ── TEAM LOBBY ──────────────────────────────────────────────────── */
#waiting-players { display: flex; gap: 1rem; width: 100%; max-width: 700px; justify-content: center; align-items: flex-start; }
.team-section { flex: 1; border: 3px solid var(--ink); border-radius: 16px; padding: 1rem; background: white; box-shadow: var(--shadow); min-width: 0; }
.team-section h3 { font-family: 'Fredoka One',cursive; font-size: 1rem; margin-bottom: .75rem; text-align: center; }
.team-red { border-color: #ef4444; background: #fef2f2; }
.team-red h3 { color: #dc2626; }
.team-blue { border-color: #3b82f6; background: #eff6ff; }
.team-blue h3 { color: #2563eb; }
.team-players { display: flex; flex-direction: column; gap: .5rem; }
.player-card { display: flex; align-items: center; gap: .4rem; padding: .5rem .65rem; border: 2px solid var(--ink); border-radius: 10px; background: white; box-shadow: var(--shadow); }
.player-card .avatar { width: 32px; height: 32px; border-radius: 50%; border: 2px solid var(--ink); display: flex; align-items: center; justify-content: center; font-size: 1rem; flex-shrink: 0; background: var(--paper); }
.player-card .pname { font-weight: 700; font-size: .88rem; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.player-card .badges { display: flex; gap: 3px; align-items: center; flex-shrink: 0; }
.host-badge { font-size: .6rem; background: var(--accent3); border: 1.5px solid var(--ink); border-radius: 4px; padding: 1px 5px; font-weight: 800; line-height: 1.4; }
.role-badge { font-size: .9rem; line-height: 1; }
.ai-badge { font-size: .9rem; line-height: 1; }
.switch-team-btn { padding: .2rem .45rem; border: 2px solid var(--ink); border-radius: 6px; background: var(--paper); font-family: 'Nunito',sans-serif; font-size: .65rem; font-weight: 700; cursor: pointer; transition: all .1s; flex-shrink: 0; }
.switch-team-btn:hover { background: var(--accent2); color: white; border-color: var(--accent2); }
/* ── HOST SETTINGS ───────────────────────────────────────────────── */
.settings-panel { width: 100%; max-width: 700px; }
.settings-panel .card { max-width: 100%; padding: 1.25rem 1.5rem; }
.settings-panel .card h3 { font-size: 1.1rem; margin-bottom: .75rem; }
.settings-row { display: flex; align-items: center; justify-content: space-between; padding: .7rem 0; border-bottom: 1px solid rgba(0,0,0,.08); gap: 1rem; }
.settings-row:last-child { border-bottom: none; }
.settings-label { font-weight: 800; font-size: .95rem; }
.settings-desc { font-size: .78rem; color: rgba(0,0,0,.5); margin-top: .1rem; }
.toggle-switch { position: relative; display: inline-flex; align-items: center; cursor: pointer; flex-shrink: 0; }
.toggle-track { width: 46px; height: 24px; background: #ccc; border-radius: 12px; border: 2px solid var(--ink); transition: background .2s; position: relative; }
.toggle-track.on { background: var(--accent2); }
.toggle-thumb { width: 18px; height: 18px; background: white; border-radius: 50%; border: 2px solid var(--ink); position: absolute; top: 1px; left: 1px; transition: left .2s; }
.toggle-track.on .toggle-thumb { left: 23px; }
.toggle-switch.disabled { opacity: .3; cursor: not-allowed; pointer-events: none; }
/* ── TEAM SCORES (in-game) ───────────────────────────────────────── */
.team-score-bar { display: flex; gap: 0; border-bottom: 3px solid var(--ink); flex-shrink: 0; }
.team-score { flex: 1; text-align: center; font-family: 'Fredoka One',cursive; font-size: 1.15rem; padding: .5rem .75rem; }
.team-score-red { background: #fef2f2; color: #dc2626; border-right: 1.5px solid var(--ink); }
.team-score-blue { background: #eff6ff; color: #2563eb; }
#game-screen { flex-direction: column; height: 100vh; overflow: hidden; }
.game-header { background: var(--ink); color: white; padding: .6rem 1rem; display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
.game-header .logo-sm { font-family: 'Fredoka One',cursive; font-size: 1.4rem; color: var(--accent3); }
.round-info { font-weight: 800; font-size: .9rem; opacity: .8; }
.timer-ring { width: 48px; height: 48px; position: relative; }
.timer-ring svg { transform: rotate(-90deg); }
.timer-ring .bg { fill: none; stroke: rgba(255,255,255,.2); stroke-width: 4; }
.timer-ring .progress { fill: none; stroke: var(--accent3); stroke-width: 4; stroke-linecap: round; transition: stroke-dashoffset 1s linear, stroke .3s; }
.timer-ring .num { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: 'Fredoka One',cursive; font-size: 1rem; color: white; }
.game-body { display: flex; flex: 1; overflow: hidden; }
.scoreboard { width: 170px; background: white; border-right: 3px solid var(--ink); display: flex; flex-direction: column; overflow-y: auto; flex-shrink: 0; }
.scoreboard h3 { padding: .75rem; font-size: .85rem; text-transform: uppercase; letter-spacing: 1px; border-bottom: 2px solid var(--ink); background: var(--cream); }
.score-entry { display: flex; align-items: center; padding: .6rem .75rem; gap: .5rem; border-bottom: 1.5px solid rgba(0,0,0,.07); }
.score-entry .rank { font-family: 'Fredoka One',cursive; font-size: 1rem; width: 20px; color: rgba(0,0,0,.3); }
.score-entry .sname { font-weight: 700; font-size: .85rem; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.score-entry .pts { font-family: 'Fredoka One',cursive; font-size: .9rem; color: var(--accent); }
.canvas-area { flex: 1; display: flex; flex-direction: column; background: var(--paper); overflow: hidden; }
.word-bar { padding: .5rem 1rem; background: var(--cream); border-bottom: 2px solid rgba(0,0,0,.1); display: flex; align-items: center; justify-content: center; gap: 1rem; min-height: 44px; flex-shrink: 0; }
.word-display { font-family: 'Fredoka One',cursive; font-size: 1.4rem; letter-spacing: 4px; color: var(--ink); }
.drawing-label { font-size: .75rem; font-weight: 800; text-transform: uppercase; letter-spacing: 1.5px; color: rgba(0,0,0,.4); }
.quickdraw-bar { display: none; align-items: center; gap: 10px; padding: 4px 12px; background: #1a1a2e; color: white; min-height: 34px; flex-shrink: 0; }
.quickdraw-bar .qd-icon { font-size: .85rem; white-space: nowrap; font-weight: 700; }
.qd-grid { display: flex; gap: 10px; flex: 1; align-items: center; }
.qd-item { display: flex; align-items: center; gap: 4px; flex: 1; min-width: 0; }
.qd-item .qd-label { font-size: .78rem; font-weight: 700; white-space: nowrap; min-width: 55px; overflow: hidden; text-overflow: ellipsis; }
.qd-item .qd-bar-wrap { flex: 1; height: 8px; background: rgba(255,255,255,.15); border-radius: 4px; overflow: hidden; }
.qd-item .qd-bar { height: 100%; border-radius: 4px; background: #4ecdc4; transition: width .4s ease; }
.qd-item:nth-child(1) .qd-bar { background: #4ecdc4; }
.qd-item:nth-child(2) .qd-bar { background: #eab308; }
.qd-item:nth-child(3) .qd-bar { background: #ef4444; }
#canvas-wrapper { flex: 1; display: flex; align-items: center; justify-content: center; padding: .75rem; overflow: hidden; }
#canvas { background: white; border: 3px solid var(--ink); border-radius: 12px; box-shadow: var(--shadow-lg); cursor: crosshair; max-width: 100%; max-height: 100%; touch-action: manipulation; }
#canvas.no-draw { cursor: default; }
.toolbar { padding: .5rem .75rem; background: white; border-top: 2px solid rgba(0,0,0,.1); display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; flex-shrink: 0; }
.colors { display: flex; gap: 4px; flex-wrap: wrap; }
.color-swatch { width: 26px; height: 26px; border-radius: 6px; border: 2.5px solid transparent; cursor: pointer; transition: transform .1s, border-color .1s; }
.color-swatch:hover { transform: scale(1.15); }
.color-swatch.active { border-color: var(--ink); transform: scale(1.15); }
.color-input-wrap { position: relative; }
.color-input-wrap input[type=color] { position: absolute; opacity: 0; width: 26px; height: 26px; cursor: pointer; }
.color-custom { width: 26px; height: 26px; border-radius: 6px; border: 2.5px solid var(--ink); cursor: pointer; background: conic-gradient(red,yellow,lime,cyan,blue,magenta,red); transition: transform .1s; }
.color-custom:hover { transform: scale(1.15); }
.divider { width: 2px; height: 28px; background: rgba(0,0,0,.15); border-radius: 2px; margin: 0 2px; }
.brush-sizes { display: flex; align-items: center; gap: 6px; }
.brush-btn { border: 2.5px solid var(--ink); border-radius: 50%; background: white; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background .1s; flex-shrink: 0; }
.brush-btn:hover,.brush-btn.active { background: var(--ink); }
.brush-btn:hover .dot,.brush-btn.active .dot { background: white; }
.brush-btn .dot { border-radius: 50%; background: var(--ink); }
.brush-btn[data-size="4"] { width: 26px; height: 26px; } .brush-btn[data-size="4"] .dot { width: 4px; height: 4px; }
.brush-btn[data-size="8"] { width: 30px; height: 30px; } .brush-btn[data-size="8"] .dot { width: 8px; height: 8px; }
.brush-btn[data-size="16"] { width: 34px; height: 34px; } .brush-btn[data-size="16"] .dot { width: 14px; height: 14px; }
.brush-btn[data-size="32"] { width: 38px; height: 38px; } .brush-btn[data-size="32"] .dot { width: 22px; height: 22px; }
.tool-btn { padding: .3rem .6rem; border: 2.5px solid var(--ink); border-radius: 8px; background: white; cursor: pointer; font-size: 1rem; transition: background .1s; display: flex; align-items: center; gap: 4px; }
.tool-btn:hover { background: var(--cream); }
.tool-btn.active { background: var(--ink); color: white; }
.tool-btn.eraser-btn span { font-size: .75rem; font-family: 'Nunito',sans-serif; font-weight: 700; }
.chat-panel { width: 220px; background: white; border-left: 3px solid var(--ink); display: flex; flex-direction: column; flex-shrink: 0; }
.chat-panel h3 { padding: .75rem; font-size: .85rem; text-transform: uppercase; letter-spacing: 1px; border-bottom: 2px solid var(--ink); background: var(--cream); }
.chat-messages { flex: 1; overflow-y: auto; padding: .5rem; display: flex; flex-direction: column; gap: .3rem; }
.chat-msg { font-size: .82rem; line-height: 1.4; padding: .35rem .5rem; border-radius: 8px; word-break: break-word; }
.chat-msg .name { font-weight: 800; }
.chat-msg.correct { background: rgba(34,197,94,.15); border: 1.5px solid rgba(34,197,94,.4); font-weight: 700; color: #166534; }
.chat-msg.close { background: rgba(255,230,109,.3); border: 1.5px solid rgba(255,200,0,.4); }
.chat-msg.system { color: rgba(0,0,0,.4); font-style: italic; text-align: center; font-size: .75rem; }
.chat-msg.mine { background: rgba(78,205,196,.1); }
.chat-input-row { display: flex; border-top: 2px solid var(--ink); }
.chat-input-row input { flex: 1; padding: .6rem .75rem; border: none; outline: none; font-family: 'Nunito',sans-serif; font-size: .85rem; font-weight: 600; background: transparent; }
.chat-input-row button { padding: .6rem .75rem; background: var(--accent); color: white; border: none; border-left: 2px solid var(--ink); font-family: 'Fredoka One',cursive; font-size: .9rem; cursor: pointer; }
.chat-input-row button:hover { background: #e55b2a; }
.overlay { position: fixed; inset: 0; background: rgba(26,26,46,.85); display: none; align-items: center; justify-content: center; z-index: 100; backdrop-filter: blur(4px); }
.overlay.show { display: flex; }
.overlay-card { background: white; border: 3px solid var(--ink); border-radius: 20px; box-shadow: 8px 8px 0 var(--ink); padding: 2rem; max-width: 500px; width: 90%; text-align: center; animation: pop .3s cubic-bezier(.34,1.56,.64,1); }
@keyframes pop { from{transform:scale(.7);opacity:0} to{transform:scale(1);opacity:1} }
.overlay-card h2 { font-size: 2rem; margin-bottom: .5rem; }
.overlay-card > p { color: rgba(0,0,0,.6); margin-bottom: 1.5rem; }
.word-choices { display: flex; gap: .75rem; flex-direction: column; }
.word-choice-row { display: flex; align-items: stretch; gap: .5rem; }
.word-choice-btn { flex: 1; padding: .9rem; border: 3px solid var(--ink); border-radius: 12px; background: var(--paper); font-family: 'Fredoka One',cursive; font-size: 1.3rem; cursor: pointer; transition: all .15s; box-shadow: var(--shadow); }
.word-choice-btn:hover { background: var(--accent3); transform: translate(-2px,-2px); box-shadow: 5px 5px 0 var(--ink); }
.word-help-btn { width: 44px; flex-shrink: 0; border: 3px solid var(--ink); border-radius: 12px; background: var(--accent2); font-family: 'Fredoka One',cursive; font-size: 1.1rem; cursor: pointer; transition: all .15s; box-shadow: var(--shadow); display: flex; align-items: center; justify-content: center; }
.word-help-btn:hover { background: #3ab8b0; transform: translate(-2px,-2px); box-shadow: 5px 5px 0 var(--ink); }
.word-help-btn.active { background: var(--ink); color: white; }
.word-definition { display: none; text-align: left; font-size: .85rem; font-weight: 600; color: #444; background: #f0faf9; border: 2px solid var(--accent2); border-radius: 10px; padding: .6rem .8rem; line-height: 1.5; margin-top: -.25rem; }
.word-definition.show { display: block; }
.round-reveal h2 { font-size: 1.5rem; }
.big-word { font-family: 'Fredoka One',cursive; font-size: 2.5rem; color: var(--accent); letter-spacing: 2px; margin: .5rem 0 1rem; }
.final-scores { display: flex; flex-direction: column; gap: .5rem; margin-bottom: 1.5rem; }
.final-score-row { display: flex; align-items: center; gap: .75rem; padding: .6rem 1rem; border: 2px solid var(--ink); border-radius: 10px; background: var(--paper); }
.final-score-row.first { background: var(--accent3); }
.final-score-row .pos { font-family: 'Fredoka One',cursive; font-size: 1.5rem; width: 36px; text-align: center; }
.final-score-row .fname { font-weight: 800; flex: 1; }
.final-score-row .fpts { font-family: 'Fredoka One',cursive; font-size: 1.2rem; color: var(--accent); }
.play-again-section { display: flex; flex-direction: column; gap: .5rem; align-items: center; }
.waiting-replay-msg { font-weight: 700; color: rgba(0,0,0,.5); font-size: .95rem; }
.toast { position: fixed; top: 70px; left: 50%; transform: translateX(-50%) translateY(-20px); background: var(--ink); color: white; padding: .6rem 1.2rem; border-radius: 100px; font-weight: 800; font-size: .9rem; opacity: 0; transition: all .3s; z-index: 200; pointer-events: none; white-space: nowrap; }
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.disabled-toolbar { opacity: .3; pointer-events: none; }
@media(max-width:700px){.scoreboard{width:110px}.chat-panel{width:160px}#waiting-players{flex-direction:column}}
@media(max-width:768px){
.game-body{flex-direction:column}
.scoreboard{display:none}
.chat-panel{width:100%;border-left:none;border-top:3px solid var(--ink);max-height:35vh}
.canvas-area{flex:1}
.game-header .logo-sm{font-size:1.1rem}
.round-info{font-size:.8rem}
.word-display{font-size:1.1rem;letter-spacing:2px}
.drawing-label{font-size:.65rem}
.btn{padding:.85rem 1.5rem;font-size:1rem;min-height:44px}
.color-swatch{width:30px;height:30px}
.tool-btn{min-height:44px;min-width:44px;justify-content:center}
.eraser-btn span{font-size:.7rem}
.chat-input-row input,.chat-input-row button{min-height:44px}
.chat-input-row input{font-size:16px}
#canvas-wrapper{padding:.4rem}
.timer-ring{width:36px;height:36px}
.timer-ring svg{width:36px;height:36px}
.overlay-card{padding:1.5rem;width:95%}
.overlay-card h2{font-size:1.4rem}
.word-choice-btn{font-size:1rem;padding:.7rem}
.big-word{font-size:1.8rem}
.card{padding:1.25rem}
.toast{font-size:.8rem;padding:.5rem 1rem;top:60px}
}
@media(max-width:480px){
.chat-panel{max-height:30vh}
.chat-panel h3{font-size:.75rem;padding:.5rem .6rem}
.chat-msg{font-size:.75rem}
.word-display{font-size:1rem;letter-spacing:1px}
.game-header{padding:.4rem .6rem}
.game-header .logo-sm{font-size:1rem}
.round-info{font-size:.7rem}
.timer-ring{width:30px;height:30px}
.timer-ring svg{width:30px;height:30px}
.timer-ring .num{font-size:.8rem}
#canvas-wrapper{padding:.25rem}
.divider{display:none}
.settings-desc{font-size:.7rem}
.color-swatch{width:34px;height:34px}
.chat-input-row input{font-size:16px}
.word-display{letter-spacing:1px}
.word-bar{min-height:38px;padding:.3rem .6rem}
.brush-sizes{gap:4px}
.quickdraw-bar{display:none!important}
.toolbar{gap:.3rem;padding:.35rem .4rem}
}
</style>
</head>
<body>
<!-- ── LOBBY ─────────────────────────────────────────────────────────────── -->
<div id="lobby-screen" class="screen active">
<div class="logo-block">
<h1>Skrawl</h1>
<p>Draw · Guess · Repeat</p>
</div>
<div class="card">
<div class="tabs">
<button class="tab active" onclick="switchTab('create')">Create Room</button>
<button class="tab" onclick="switchTab('join')">Join Room</button>
</div>
<div class="error-msg" id="lobby-error"></div>
<div id="create-tab">
<div class="input-group"><label>Your Name</label><input id="create-name" type="text" placeholder="e.g. coolkid99" maxlength="16"/></div>
<div class="input-group"><label>Rounds</label>
<select id="round-count">
<option value="2">2 rounds</option><option value="3" selected>3 rounds</option>
<option value="4">4 rounds</option><option value="5">5 rounds</option>
</select>
</div>
<button class="btn btn-primary" onclick="createRoom()">Create Room 🎨</button>
</div>
<div id="join-tab" style="display:none">
<div class="input-group"><label>Your Name</label><input id="join-name" type="text" placeholder="e.g. artmaster" maxlength="16"/></div>
<div class="input-group"><label>Room Code</label><input id="join-code" type="text" placeholder="e.g. ABC123" maxlength="6" style="text-transform:uppercase"/></div>
<button class="btn btn-primary" onclick="joinRoom()">Join Room 🚀</button>
</div>
</div>
</div>
<!-- ── WAITING ROOM ───────────────────────────────────────────────────────── -->
<div id="waiting-screen" class="screen">
<div class="room-code-display">
<p>Room Code</p>
<div class="code" id="display-room-code">------</div>
<p style="margin-top:.25rem;font-size:.75rem;opacity:.7">Share this with friends</p>
</div>
<div id="waiting-players"></div>
<!-- Host Settings -->
<div class="settings-panel" id="settings-panel" style="display:none">
<div class="card">
<h3>⚙️ Game Settings</h3>
<div class="settings-row">
<div>
<div class="settings-label">Teams</div>
<div class="settings-desc">Team up! 4 or 6 players only</div>
</div>
<label class="toggle-switch" id="teams-toggle">
<div class="toggle-track" id="teams-track"><div class="toggle-thumb"></div></div>
</label>
</div>
<div class="settings-row">
<div>
<div class="settings-label">AI Player (beta)</div>
<div class="settings-desc">Add a bot opponent</div>
</div>
<label class="toggle-switch" id="ai-toggle">
<div class="toggle-track" id="ai-track"><div class="toggle-thumb"></div></div>
</label>
</div>
</div>
</div>
<div style="display:flex;gap:1rem;align-items:center">
<button class="btn btn-accent2" id="start-btn" onclick="startGame()" style="display:none">Start Game! 🎮</button>
<p id="waiting-msg" style="font-weight:700;color:rgba(0,0,0,.5);font-size:.9rem">Waiting for host to start…</p>
</div>
</div>
<!-- ── GAME SCREEN ────────────────────────────────────────────────────────── -->
<div id="game-screen" class="screen">
<div class="game-header">
<span class="logo-sm">Skrawl</span>
<span class="round-info" id="round-label">Round 1</span>
<div class="timer-ring">
<svg width="48" height="48" viewBox="0 0 48 48">
<circle class="bg" cx="24" cy="24" r="20"/>
<circle class="progress" id="timer-arc" cx="24" cy="24" r="20" stroke-dasharray="125.6" stroke-dashoffset="0"/>
</svg>
<div class="num" id="timer-num">35</div>
</div>
</div>
<!-- Team score bar (visible only in teams mode) -->
<div class="team-score-bar" id="team-score-bar" style="display:none">
<div class="team-score team-score-red">🔴 <span id="team-red-score">0</span></div>
<div class="team-score team-score-blue">🔵 <span id="team-blue-score">0</span></div>
</div>
<div class="game-body">
<div class="scoreboard"><h3>Players</h3><div id="score-list"></div></div>
<div class="canvas-area">
<div class="word-bar">
<span class="drawing-label" id="drawing-label">Waiting…</span>
<span class="word-display" id="word-display"></span>
</div>
<div class="quickdraw-bar" id="quickdraw-bar">
<span class="qd-icon">🤖 QuickDraw</span>
<div class="qd-grid" id="qd-grid">
<div class="qd-item"><span class="qd-label"></span><div class="qd-bar-wrap"><div class="qd-bar" style="width:0%"></div></div></div>
<div class="qd-item"><span class="qd-label"></span><div class="qd-bar-wrap"><div class="qd-bar" style="width:0%"></div></div></div>
<div class="qd-item"><span class="qd-label"></span><div class="qd-bar-wrap"><div class="qd-bar" style="width:0%"></div></div></div>
</div>
</div>
<div id="canvas-wrapper"><canvas id="canvas" width="700" height="480"></canvas></div>
<div class="toolbar" id="toolbar">
<div class="colors" id="color-swatches"></div>
<div class="color-input-wrap">
<div class="color-custom" title="Custom colour"></div>
<input type="color" id="custom-color" value="#ff6b35" onchange="setCustomColor(this.value)"/>
</div>
<div class="divider"></div>
<div class="brush-sizes">
<button class="brush-btn" data-size="4" onclick="setSize(4)"><div class="dot"></div></button>
<button class="brush-btn active" data-size="8" onclick="setSize(8)"><div class="dot"></div></button>
<button class="brush-btn" data-size="16" onclick="setSize(16)"><div class="dot"></div></button>
<button class="brush-btn" data-size="32" onclick="setSize(32)"><div class="dot"></div></button>
</div>
<div class="divider"></div>
<button class="tool-btn eraser-btn" id="eraser-btn" onclick="toggleEraser()">🧹 <span>Eraser</span></button>
<button class="tool-btn" onclick="clearCanvas()">🗑️</button>
</div>
</div>
<div class="chat-panel">
<h3>Guesses 💬</h3>
<div class="chat-messages" id="chat-messages"></div>
<div class="chat-input-row">
<input id="guess-input" type="text" placeholder="Type your guess…" maxlength="50" onkeydown="if(event.key==='Enter')sendGuess()"/>
<button onclick="sendGuess()"></button>
</div>
</div>
</div>
</div>
<!-- ── OVERLAY: Choose Word ───────────────────────────────────────────────── -->
<div class="overlay" id="choose-overlay">
<div class="overlay-card">
<h2>Your turn to draw! ✏️</h2>
<p>Pick a word — tap <strong>?</strong> to see its definition</p>
<div class="word-choices" id="word-choices"></div>
</div>
</div>
<!-- ── OVERLAY: Round End ─────────────────────────────────────────────────── -->
<div class="overlay" id="round-end-overlay">
<div class="overlay-card round-reveal">
<h2>Round Over!</h2>
<div>The word was</div>
<div class="big-word" id="round-end-word"></div>
<div id="round-end-team-scores" style="display:none;margin-bottom:.75rem;font-family:'Fredoka One',cursive;font-size:1.1rem"></div>
<div id="round-end-scores"></div>
</div>
</div>
<!-- ── OVERLAY: Game Over ─────────────────────────────────────────────────── -->
<div class="overlay" id="gameover-overlay">
<div class="overlay-card">
<h2>🏆 Game Over!</h2>
<div id="gameover-team-winner" style="display:none;font-family:'Fredoka One',cursive;font-size:1.5rem;margin-bottom:.5rem"></div>
<p>Final Standings</p>
<div class="final-scores" id="final-scores"></div>
<div class="play-again-section">
<button class="btn btn-primary" id="play-again-btn" style="display:none" onclick="playAgain()">Play Again 🔄</button>
<p class="waiting-replay-msg" id="waiting-replay-msg" style="display:none">Waiting for host to start a new game…</p>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const COLORS = ["#1a1a2e","#ffffff","#ef4444","#f97316","#eab308","#22c55e","#3b82f6","#a855f7","#ec4899","#06b6d4","#84cc16","#f59e0b","#6366f1","#14b8a6","#8b5cf6","#64748b"];
const AVATARS = ["🐱","🦊","🐸","🐼","🦋","🦄","🐯","🐧","🦁","🐨","🦖","🐙"];
let socket, myId, myRoom, myIsHost = false;
let isDrawer = false, drawing = false, erasing = false;
let color = "#1a1a2e", size = 8;
let lastX, lastY;
let timerInterval, timerTotal = 35, timerLeft = 35;
let currentDrawerName = "";
let currentDrawerId = null;
let myTeam = null;
let teamsMode = false;
let aiEnabled = false;
let teamScores = { red: 0, blue: 0 };
let playerCount = 0;
let humanCount = 0;
let isMod = false;
function getUID() {
let uid = localStorage.getItem("skrawl_uid");
if (!uid) { uid = Math.random().toString(36).slice(2) + Date.now().toString(36); localStorage.setItem("skrawl_uid", uid); }
return uid;
}
// ── CANVAS ────────────────────────────────────────────────────────────────
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const strokes = [];
function getPos(e) {
const r = canvas.getBoundingClientRect();
const sx = canvas.width / r.width, sy = canvas.height / r.height;
const s = e.touches ? e.touches[0] : e;
return { x: (s.clientX - r.left) * sx, y: (s.clientY - r.top) * sy };
}
function startDraw(e) {
if (!isDrawer) return;
drawing = true;
const {x,y} = getPos(e); lastX = x; lastY = y;
}
function doDraw(e) {
if (!drawing || !isDrawer) return;
e.preventDefault();
const {x,y} = getPos(e);
const c = erasing ? "#ffffff" : color;
const s = erasing ? size * 3 : size;
ctx.lineWidth = s; ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.strokeStyle = c;
ctx.beginPath(); ctx.moveTo(lastX, lastY); ctx.lineTo(x, y); ctx.stroke();
strokes.push({x1:lastX, y1:lastY, x2:x, y2:y, color:c, size:s});
socket.emit("draw", {x1:lastX, y1:lastY, x2:x, y2:y, color:c, size:s});
lastX = x; lastY = y;
}
function endDraw() { drawing = false; }
function replayStroke(s) {
strokes.push(s);
ctx.lineWidth=s.size; ctx.lineCap="round"; ctx.lineJoin="round"; ctx.strokeStyle=s.color;
ctx.beginPath(); ctx.moveTo(s.x1,s.y1); ctx.lineTo(s.x2,s.y2); ctx.stroke();
}
canvas.addEventListener("mousedown", startDraw);
canvas.addEventListener("mousemove", doDraw);
canvas.addEventListener("mouseup", endDraw);
canvas.addEventListener("mouseleave",endDraw);
canvas.addEventListener("touchstart", e=>{ e.preventDefault(); startDraw(e); }, {passive:false});
canvas.addEventListener("touchmove", e=>{ e.preventDefault(); doDraw(e); }, {passive:false});
canvas.addEventListener("touchend", endDraw);
function clearCanvas(silent=false) {
strokes.length = 0;
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!silent && isDrawer) socket.emit("clear_canvas");
}
// ── SOCKET INIT ───────────────────────────────────────────────────────────
function initSocket() {
socket = io();
socket.on("room_created", ({roomId, playerId, settings}) => { myId=playerId; myRoom=roomId; myIsHost=true; document.getElementById("display-room-code").textContent=roomId; showScreen("waiting-screen"); if (settings) { teamsMode = settings.teamsMode; aiEnabled = settings.aiEnabled; } });
socket.on("room_joined", ({roomId, playerId}) => { myId=playerId; myRoom=roomId; document.getElementById("display-room-code").textContent=roomId; showScreen("waiting-screen"); });
socket.on("error", ({msg}) => showError(msg));
socket.on("player_list", ({players}) => updatePlayerList(players));
socket.on("game_started", () => { showScreen("game-screen"); document.getElementById("team-score-bar").style.display = teamsMode ? "flex" : "none"; gameRunning = true; if (aiEnabled) loadQuickDraw(); });
socket.on("room_settings", ({settings}) => {
teamsMode = settings.teamsMode;
aiEnabled = settings.aiEnabled;
if (aiEnabled && gameRunning) loadQuickDraw();
updateSettingsUI();
});
socket.on("team_scores", ({red, blue}) => {
teamScores.red = red;
teamScores.blue = blue;
updateTeamScoreDisplay();
});
socket.on("round_start", ({drawer, drawerName}) => {
isDrawer = (drawer === myId); currentDrawerName = drawerName; currentDrawerId = drawer;
clearCanvas(true); updateDrawingLabel(); stopTimer();
});
socket.on("choose_word", ({words, hints}) => {
const cont = document.getElementById("word-choices");
cont.innerHTML = "";
words.forEach((w, i) => {
const wrapper = document.createElement("div");
const row = document.createElement("div"); row.className = "word-choice-row";
const btn = document.createElement("button"); btn.className = "word-choice-btn"; btn.textContent = w;
btn.onclick = () => { socket.emit("word_chosen", {word:w}); document.getElementById("choose-overlay").classList.remove("show"); };
const helpBtn = document.createElement("button"); helpBtn.className = "word-help-btn"; helpBtn.textContent = "?";
const defBox = document.createElement("div"); defBox.className = "word-definition"; defBox.textContent = hints[i] || "";
helpBtn.onclick = () => {
const open = defBox.classList.contains("show");
cont.querySelectorAll(".word-definition").forEach(d => d.classList.remove("show"));
cont.querySelectorAll(".word-help-btn").forEach(b => b.classList.remove("active"));
if (!open) { defBox.classList.add("show"); helpBtn.classList.add("active"); }
};
row.appendChild(btn); row.appendChild(helpBtn);
wrapper.appendChild(row); wrapper.appendChild(defBox); cont.appendChild(wrapper);
});
document.getElementById("choose-overlay").classList.add("show");
});
socket.on("drawing_phase", ({drawer, drawerName, hint, timeLimit, round}) => {
document.getElementById("choose-overlay").classList.remove("show");
isDrawer = (drawer === myId); currentDrawerName = drawerName; currentDrawerId = drawer; timerTotal = timeLimit;
if (round) document.getElementById("round-label").textContent = `Round ${round}`;
updateDrawingLabel(); setWordDisplay(hint); setToolbarEnabled(isDrawer); startTimer(timeLimit);
if (!isDrawer && qdModel) startQD(null);
});
socket.on("your_word", ({word}) => { setWordDisplay(word, true); if (qdModel) startQD(word); });
socket.on("hint_update", ({hint}) => { if (!isDrawer) setWordDisplay(hint); });
socket.on("draw", (data) => replayStroke(data));
socket.on("clear_canvas",() => { strokes.length = 0; ctx.clearRect(0,0,canvas.width,canvas.height); });
socket.on("chat_message", ({playerId, playerName, text, isSystem, isClose}) =>
addChat(playerName, text, {system:isSystem, close:isClose, mine:playerId===myId}));
socket.on("correct_guess", ({playerId, playerName, points, scores, teamScores: ts}) => {
addChat(null, `🎉 ${playerName} guessed it! (+${points} pts)`, {correct:true});
updateScores(scores);
if (ts) { teamScores = ts; updateTeamScoreDisplay(); }
if (playerId === myId) showToast(`You got it! +${points} pts 🎉`);
});
socket.on("score_update", ({scores, teamScores: ts}) => {
updateScores(scores);
if (ts) { teamScores = ts; updateTeamScoreDisplay(); }
});
socket.on("round_end", ({word, scores, teamScores: ts}) => {
stopQD(); stopTimer();
document.getElementById("round-end-word").textContent = word;
if (ts) { teamScores = ts; showRoundEndTeamScores(); }
showRoundEndScores(scores);
document.getElementById("round-end-overlay").classList.add("show");
setTimeout(() => document.getElementById("round-end-overlay").classList.remove("show"), 3800);
updateScores(scores);
});
socket.on("game_over", ({scores, teamScores: ts}) => {
stopQD(); stopTimer();
if (ts) { teamScores = ts; showGameOverTeamWinner(); }
showFinalScores(scores);
document.getElementById("play-again-btn").style.display = myIsHost ? "block" : "none";
document.getElementById("waiting-replay-msg").style.display = myIsHost ? "none" : "block";
document.getElementById("gameover-overlay").classList.add("show");
});
socket.on("game_restarted", ({players}) => {
stopQD(); gameRunning = true;
document.getElementById("gameover-overlay").classList.remove("show");
document.getElementById("round-end-overlay").classList.remove("show");
document.getElementById("choose-overlay").classList.remove("show");
clearCanvas(true); updateScores(players);
document.getElementById("round-label").textContent = "Round 1";
document.getElementById("word-display").textContent = "";
document.getElementById("drawing-label").textContent = "Waiting…";
document.getElementById("chat-messages").innerHTML = "";
stopTimer(); isDrawer = false; currentDrawerId = null;
teamScores = { red: 0, blue: 0 };
updateTeamScoreDisplay();
document.getElementById("team-score-bar").style.display = "none";
showToast("New game starting! 🎉");
});
socket.on("kicked", () => {
showToast("You were removed.");
setTimeout(() => location.reload(), 2000);
});
socket.on("mod_granted", () => {
isMod = true;
showToast("🔑 Moderator authenticated! Use /kick <name> and /msg <text> in chat.");
});
}
// ── GAME FUNCTIONS ────────────────────────────────────────────────────────
function switchTab(tab) {
document.querySelectorAll(".tab").forEach((t,i) => t.classList.toggle("active", (tab==="create"&&i===0)||(tab==="join"&&i===1)));
document.getElementById("create-tab").style.display = tab==="create" ? "block" : "none";
document.getElementById("join-tab").style.display = tab==="join" ? "block" : "none";
}
function showError(msg) {
const el = document.getElementById("lobby-error");
el.textContent = msg; el.classList.add("show");
setTimeout(() => el.classList.remove("show"), 3000);
}
function createRoom() {
const name = document.getElementById("create-name").value.trim();
const rounds = parseInt(document.getElementById("round-count").value);
if (!name) return showError("Enter your name!");
initSocket();
socket.emit("create_room", {name, rounds, uid: getUID()});
}
function joinRoom() {
const name = document.getElementById("join-name").value.trim();
const code = document.getElementById("join-code").value.trim().toUpperCase();
if (!name) return showError("Enter your name!");
if (!code) return showError("Enter a room code!");
initSocket();
socket.emit("join_room", {name, roomId: code, uid: getUID()});
}
function startGame() { socket.emit("start_game"); }
function playAgain() { socket.emit("play_again"); }
function showScreen(id) {
document.querySelectorAll(".screen").forEach(s => s.classList.remove("active"));
document.getElementById(id).classList.add("active");
}
// ── LOBBY ─────────────────────────────────────────────────────────────────
function createPlayerCard(p, idx) {
const card = document.createElement("div");
card.className = "player-card";
const avatar = document.createElement("div");
avatar.className = "avatar";
avatar.textContent = AVATARS[idx % AVATARS.length];
card.appendChild(avatar);
const name = document.createElement("span");
name.className = "pname";
name.textContent = p.name;
card.appendChild(name);
const badges = document.createElement("div");
badges.className = "badges";
if (p.isHost) {
const hb = document.createElement("span");
hb.className = "host-badge"; hb.textContent = "HOST";
badges.appendChild(hb);
}
if (currentDrawerId) {
const rb = document.createElement("span");
rb.className = "role-badge";
rb.textContent = p.id === currentDrawerId ? "👨‍🎨" : "🙈";
badges.appendChild(rb);
}
if (p.isAI) {
const ab = document.createElement("span");
ab.className = "ai-badge"; ab.textContent = "🤖";
badges.appendChild(ab);
}
card.appendChild(badges);
if (teamsMode) {
const canSwitch = myIsHost || p.id === myId;
if (canSwitch) {
const sw = document.createElement("button");
sw.className = "switch-team-btn";
sw.textContent = "Switch";
sw.onclick = (e) => { e.stopPropagation(); socket.emit("switch_team", { playerId: p.id }); };
card.appendChild(sw);
}
}
return card;
}
function updatePlayerList(players) {
const container = document.getElementById("waiting-players");
container.innerHTML = "";
playerCount = players.length;
humanCount = players.filter(p => !p.isAI).length;
const me = players.find(p => p.id === myId);
myIsHost = me?.isHost || false;
myTeam = me?.team || null;
document.getElementById("start-btn").style.display = myIsHost ? "block" : "none";
document.getElementById("waiting-msg").style.display = myIsHost ? "none" : "block";
document.getElementById("settings-panel").style.display = myIsHost ? "block" : "none";
if (teamsMode) {
const redTeam = players.filter(p => p.team === 'red');
const blueTeam = players.filter(p => p.team === 'blue');
const redSec = document.createElement("div");
redSec.className = "team-section team-red";
redSec.innerHTML = `<h3>🔴 Red Team <span style="font-size:.8rem;opacity:.6">${redTeam.length}</span></h3>`;
const redList = document.createElement("div"); redList.className = "team-players";
redTeam.forEach(p => redList.appendChild(createPlayerCard(p, players.indexOf(p))));
redSec.appendChild(redList);
container.appendChild(redSec);
const blueSec = document.createElement("div");
blueSec.className = "team-section team-blue";
blueSec.innerHTML = `<h3>🔵 Blue Team <span style="font-size:.8rem;opacity:.6">${blueTeam.length}</span></h3>`;
const blueList = document.createElement("div"); blueList.className = "team-players";
blueTeam.forEach(p => blueList.appendChild(createPlayerCard(p, players.indexOf(p))));
blueSec.appendChild(blueList);
container.appendChild(blueSec);
} else {
const sec = document.createElement("div");
sec.className = "team-section";
sec.innerHTML = `<h3>👥 Players <span style="font-size:.8rem;opacity:.6">${players.length}/6</span></h3>`;
const list = document.createElement("div"); list.className = "team-players";
players.forEach((p, i) => list.appendChild(createPlayerCard(p, i)));
sec.appendChild(list);
container.appendChild(sec);
}
updateSettingsUI();
updateStartButton();
updateScores(players.map(p => ({...p, score: p.score || 0})));
}
function updateStartButton() {
const btn = document.getElementById("start-btn");
const count = playerCount;
if (teamsMode) {
if (count < 4) {
btn.disabled = true;
btn.textContent = `Need 4 players for teams (${count}/4)`;
} else {
btn.disabled = false;
btn.textContent = "Start Game! 🎮";
}
} else {
if (count < 2) {
btn.disabled = true;
btn.textContent = `Need at least 2 players (${count}/2)`;
} else {
btn.disabled = false;
btn.textContent = "Start Game! 🎮";
}
}
}
function toggleSetting(setting, value) {
socket.emit("toggle_setting", {setting, value});
}
function updateSettingsUI() {
const teamsTrack = document.getElementById("teams-track");
const teamsToggle = document.getElementById("teams-toggle");
const aiTrack = document.getElementById("ai-track");
const aiToggle = document.getElementById("ai-toggle");
teamsTrack.classList.toggle("on", teamsMode);
if (aiTrack) aiTrack.classList.toggle("on", aiEnabled);
const validTeamCount = playerCount === 4 || playerCount === 6;
if (!validTeamCount && !teamsMode) {
teamsToggle.classList.add("disabled");
teamsToggle.title = "Available with 4 or 6 players";
} else {
teamsToggle.classList.remove("disabled");
teamsToggle.title = "";
}
if (aiToggle) {
if (humanCount > 1) {
aiToggle.classList.add("disabled");
aiToggle.title = "Only available with 1 human player";
} else {
aiToggle.classList.remove("disabled");
aiToggle.title = "";
}
}
}
document.getElementById("teams-toggle").addEventListener("click", function() {
if (this.classList.contains("disabled")) return;
toggleSetting("teamsMode", !teamsMode);
});
document.getElementById("ai-toggle").addEventListener("click", function() {
if (this.classList.contains("disabled")) return;
toggleSetting("aiEnabled", !aiEnabled);
});
function toggleEraser() {
erasing = !erasing;
document.getElementById("eraser-btn").classList.toggle("active", erasing);
}
function setSize(s) {
size = s; erasing = false;
document.getElementById("eraser-btn").classList.remove("active");
document.querySelectorAll(".brush-btn").forEach(b => b.classList.toggle("active", parseInt(b.dataset.size)===s));
}
function setCustomColor(c) {
color = c; erasing = false;
document.querySelectorAll(".color-swatch").forEach(s => s.classList.remove("active"));
}
function setToolbarEnabled(enabled) {
document.getElementById("toolbar").classList.toggle("disabled-toolbar", !enabled);
canvas.classList.toggle("no-draw", !enabled);
}
// Build colour swatches
const swatchCont = document.getElementById("color-swatches");
COLORS.forEach(c => {
const div = document.createElement("div");
div.className = "color-swatch" + (c==="#1a1a2e"?" active":"");
div.style.background = c;
if (c==="#ffffff") div.style.border="2.5px solid #ccc";
div.onclick = () => {
color = c; erasing = false;
document.querySelectorAll(".color-swatch").forEach(s => s.classList.remove("active"));
div.classList.add("active");
document.getElementById("eraser-btn").classList.remove("active");
};
swatchCont.appendChild(div);
});
// ── TIMER ─────────────────────────────────────────────────────────────────
function startTimer(total) {
timerLeft = total; timerTotal = total; stopTimer(); updateTimerUI();
timerInterval = setInterval(() => { timerLeft--; updateTimerUI(); if (timerLeft<=0) stopTimer(); }, 1000);
}
function stopTimer() { clearInterval(timerInterval); }
function updateTimerUI() {
document.getElementById("timer-num").textContent = Math.max(0, timerLeft);
const r=20, circ=2*Math.PI*r, arc=document.getElementById("timer-arc");
arc.style.strokeDashoffset = circ * (1 - timerLeft/timerTotal);
arc.style.stroke = timerLeft>20 ? "var(--accent3)" : timerLeft>10 ? "#f97316" : "#ef4444";
}
// ── WORD / LABEL ──────────────────────────────────────────────────────────
function setWordDisplay(hint, isActual=false) {
const el = document.getElementById("word-display");
el.textContent = hint;
el.style.letterSpacing = isActual ? "3px" : "6px";
}
function updateDrawingLabel() {
document.getElementById("drawing-label").textContent = isDrawer ? "You're drawing!" : `${currentDrawerName} is drawing`;
}
// ── SCORES ────────────────────────────────────────────────────────────────
function updateScores(scores) {
const sorted = [...scores].sort((a,b)=>b.score-a.score);
document.getElementById("score-list").innerHTML = sorted.map((p,i) => `
<div class="score-entry">
<span class="rank">${i+1}</span>
<span class="sname">${p.name}${p.id===myId?" (you)":""}</span>
<span class="pts">${p.score}</span>
</div>`).join("");
}
function updateTeamScoreDisplay() {
document.getElementById("team-red-score").textContent = teamScores.red;
document.getElementById("team-blue-score").textContent = teamScores.blue;
}
function showRoundEndTeamScores() {
const el = document.getElementById("round-end-team-scores");
el.style.display = "block";
el.innerHTML = `🔴 Red: ${teamScores.red} &nbsp;&nbsp;🔵 Blue: ${teamScores.blue}`;
}
function showGameOverTeamWinner() {
const el = document.getElementById("gameover-team-winner");
el.style.display = "block";
if (teamScores.red > teamScores.blue) {
el.innerHTML = "🏆 🔴 Red Team Wins!";
el.style.color = "#dc2626";
} else if (teamScores.blue > teamScores.red) {
el.innerHTML = "🏆 🔵 Blue Team Wins!";
el.style.color = "#2563eb";
} else {
el.innerHTML = "🤝 It's a Tie!";
el.style.color = "var(--ink)";
}
}
function showRoundEndScores(scores) {
const sorted = [...scores].sort((a,b)=>b.score-a.score);
document.getElementById("round-end-scores").innerHTML = sorted.slice(0,3).map((p,i)=>`
<div style="display:flex;justify-content:space-between;padding:.3rem 0;font-weight:700">
<span>${["🥇","🥈","🥉"][i]} ${p.name}</span>
<span style="color:var(--accent)">${p.score} pts</span>
</div>`).join("");
}
function showFinalScores(scores) {
document.getElementById("final-scores").innerHTML = scores.map((p,i)=>`
<div class="final-score-row ${i===0?"first":""}">
<span class="pos">${["🥇","🥈","🥉","4","5","6"][i]}</span>
<span class="fname">${p.name}</span>
<span class="fpts">${p.score}</span>
</div>`).join("");
}
// ── CHAT ──────────────────────────────────────────────────────────────────
function addChat(name, text, opts={}) {
const box = document.getElementById("chat-messages");
const div = document.createElement("div");
div.className = "chat-msg";
if (opts.correct) div.classList.add("correct");
else if (opts.close) div.classList.add("close");
else if (opts.system) div.classList.add("system");
else if (opts.mine) div.classList.add("mine");
div.innerHTML = (name && !opts.correct) ? `<span class="name">${name}:</span> ${text}` : text;
box.appendChild(div);
box.scrollTop = box.scrollHeight;
}
function sendGuess() {
const input = document.getElementById("guess-input");
const text = input.value.trim();
if (!text) return;
input.value = "";
socket.emit("guess", {text});
}
// ── TOAST ─────────────────────────────────────────────────────────────────
function showToast(msg) {
const t = document.getElementById("toast");
t.textContent = msg; t.classList.add("show");
setTimeout(() => t.classList.remove("show"), 2500);
}
// ── RESIZE CANVAS ─────────────────────────────────────────────────────────
function resizeCanvas() {
const w = document.getElementById("canvas-wrapper");
if (!w) return;
const mw=w.clientWidth-24, mh=w.clientHeight-24, asp=700/480;
let cw=mw, ch=mw/asp;
if (ch>mh) { ch=mh; cw=mh*asp; }
canvas.style.width=cw+"px"; canvas.style.height=ch+"px";
}
window.addEventListener("resize", resizeCanvas);
setTimeout(resizeCanvas, 100);
// ── MOBILE KEYBOARD ──────────────────────────────────────────────────────
document.getElementById("guess-input").addEventListener("focus", function() {
setTimeout(() => this.scrollIntoView({ behavior: "smooth", block: "center" }), 300);
});
// ── QUICKDRAW AI RECOGNITION ─────────────────────────────────────────────
const QD_MODEL_URL = "/quickdraw_model/model.json";
const QD_LABELS_URL = "/quickdraw_model/class_names.txt";
let qdModel = null, qdLabels = [], qdTimer = null, qdActive = false, gameRunning = false;
let qdCurrentWord = null, qdRecognized = false;
let qdPredictionHistory = [];
async function loadQuickDraw(retries = 3) {
if (qdModel) return;
for (let attempt = 1; attempt <= retries; attempt++) {
try {
if (!window.tf) { console.warn("TF.js not loaded"); return; }
qdModel = await tf.loadLayersModel(QD_MODEL_URL);
const resp = await fetch(QD_LABELS_URL);
qdLabels = (await resp.text()).trim().split("\n").map(l => l.trim());
console.log("🤖 QuickDraw ready:", qdLabels.length, "classes");
return;
} catch (e) {
console.warn(`QuickDraw load attempt ${attempt}/${retries} failed:`, e);
if (attempt < retries) await new Promise(r => setTimeout(r, 2000));
}
}
}
function renderStrokesToBuffer(targetSize) {
const scale = targetSize / 700;
const hi = document.createElement("canvas");
hi.width = targetSize; hi.height = Math.round(targetSize * 480 / 700);
const hc = hi.getContext("2d");
hc.fillStyle = "#fff"; hc.fillRect(0, 0, hi.width, hi.height);
hc.lineCap = "round"; hc.lineJoin = "round";
hc.lineWidth = Math.max(2, Math.round(3 * scale));
hc.strokeStyle = "#000";
for (const s of strokes) {
if (s.color === "#ffffff") continue;
hc.beginPath();
hc.moveTo(s.x1 * scale, s.y1 * scale);
hc.lineTo(s.x2 * scale, s.y2 * scale);
hc.stroke();
}
const lo = document.createElement("canvas");
lo.width = 28; lo.height = 28;
const lc = lo.getContext("2d");
lc.imageSmoothingEnabled = true;
lc.imageSmoothingQuality = "high";
lc.drawImage(hi, 0, 0, 28, 28);
return lc.getImageData(0, 0, 28, 28);
}
async function runQuickDrawPrediction() {
if (!qdModel || !qdActive) return;
if (qdRecognized && qdCurrentWord) return;
const imgData = renderStrokesToBuffer(196);
const pixels = new Float32Array(784);
for (let i = 0; i < 784; i++) {
const idx = i * 4;
pixels[i] = 1 - (imgData.data[idx] + imgData.data[idx+1] + imgData.data[idx+2]) / 765;
}
const input = tf.tensor4d(pixels, [1, 28, 28, 1]);
const output = qdModel.predict(input);
const probs = await output.data();
input.dispose(); output.dispose();
const indexed = Array.from(probs).map((p, i) => ({ label: qdLabels[i], p }));
indexed.sort((a, b) => b.p - a.p);
const top5 = indexed.slice(0, 5);
// Temporal smoothing: average with last 2 frames
qdPredictionHistory.push(top5);
if (qdPredictionHistory.length > 3) qdPredictionHistory.shift();
const smoothed = {};
for (const frame of qdPredictionHistory) {
for (let j = 0; j < frame.length; j++) {
const r = frame[j];
smoothed[r.label] = (smoothed[r.label] || 0) + r.p * (1 - j * 0.15);
}
}
const smoothedArr = Object.entries(smoothed)
.map(([label, score]) => ({ label, p: score / qdPredictionHistory.length }))
.sort((a, b) => b.p - a.p)
.slice(0, 5);
// Normalize so top is 100%
const maxP = smoothedArr.length > 0 ? smoothedArr[0].p : 1;
for (const r of smoothedArr) r.p = r.p / maxP;
updateQDPredictions(smoothedArr);
if (qdCurrentWord) {
const cleanWord = qdCurrentWord.toLowerCase().trim();
const match = smoothedArr.find(r => r.label.trim().toLowerCase() === cleanWord);
if (match && match.p > 0.35 && qdPredictionHistory.length >= 2) {
qdRecognized = true;
addChat(null, `🤖 QuickDraw sees <strong>${cleanWord}</strong> (${Math.round(match.p * 100)}% sure)`, { system: true });
}
}
}
function updateQDPredictions(predictions) {
const grid = document.getElementById("qd-grid");
if (!grid) return;
for (let i = 0; i < 3; i++) {
const item = grid.children[i];
if (!item) continue;
if (i < predictions.length) {
const r = predictions[i];
item.querySelector(".qd-label").textContent = r.label.replace(/_/g, " ");
item.querySelector(".qd-bar").style.width = Math.round(r.p * 100) + "%";
} else {
item.querySelector(".qd-label").textContent = "—";
item.querySelector(".qd-bar").style.width = "0%";
}
}
}
function startQD(word) {
qdActive = true;
qdRecognized = false;
qdPredictionHistory = [];
qdCurrentWord = word || null;
const bar = document.getElementById("quickdraw-bar");
if (bar) bar.style.display = "flex";
if (qdTimer) clearInterval(qdTimer);
runQuickDrawPrediction();
qdTimer = setInterval(runQuickDrawPrediction, 3000);
}
function stopQD() {
qdActive = false;
qdCurrentWord = null;
qdRecognized = false;
qdPredictionHistory = [];
if (qdTimer) { clearInterval(qdTimer); qdTimer = null; }
const bar = document.getElementById("quickdraw-bar");
if (bar) bar.style.display = "none";
const grid = document.getElementById("qd-grid");
if (grid) {
for (const child of grid.children) {
child.querySelector(".qd-label").textContent = "—";
child.querySelector(".qd-bar").style.width = "0%";
}
}
}
</script>
</body>
</html>