ausername-12345
Make web app mobile-friendly: responsive CSS, touch drawing, safe areas, keyboard handling
d162052 | <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} 🔵 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> | |