Created
September 9, 2026 16:03
-
-
Save kumagi/d7aabcfbd00d0009e8eea9a6c191beb9 to your computer and use it in GitHub Desktop.
Hy4 preview 21分14秒
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="ja"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"> | |
| <title>HYPER TETRIS - 派手エフェクト・テトリス</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@500;700&family=Orbitron:wght@700;900&display=swap" rel="stylesheet"> | |
| <script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js"></script> | |
| <style> | |
| :root{ | |
| --accent:#2ee6f6; | |
| --bg0:#05060f; | |
| --bg1:#0d0a24; | |
| --panel:rgba(255,255,255,.045); | |
| --line:rgba(255,255,255,.12); | |
| --text:#e8f0ff; | |
| --dim:#8b96b8; | |
| } | |
| *{box-sizing:border-box;margin:0;padding:0} | |
| html,body{height:100%} | |
| body{ | |
| background:linear-gradient(160deg,#05060f,#0d0a24 60%,#04040c); | |
| background: | |
| radial-gradient(1200px 800px at 20% -10%, color-mix(in srgb, var(--accent) 22%, transparent), transparent 60%), | |
| radial-gradient(900px 700px at 110% 110%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 60%), | |
| linear-gradient(160deg, var(--bg0), var(--bg1) 60%, #04040c); | |
| color:var(--text); | |
| font-family:"Chakra Petch", "Hiragino Sans", system-ui, sans-serif; | |
| overflow:hidden; | |
| user-select:none; | |
| -webkit-tap-highlight-color:transparent; | |
| transition:background .6s ease; | |
| } | |
| body::before{ | |
| content:"";position:fixed;inset:0;pointer-events:none;z-index:1; | |
| background:repeating-linear-gradient(0deg, rgba(255,255,255,.035) 0 1px, transparent 1px 3px); | |
| mix-blend-mode:overlay;opacity:.5; | |
| } | |
| body::after{ | |
| content:"";position:fixed;inset:0;pointer-events:none;z-index:2; | |
| background:radial-gradient(120% 90% at 50% 50%, transparent 55%, rgba(0,0,0,.65)); | |
| } | |
| #shake{position:fixed;inset:0;z-index:3;will-change:transform} | |
| #fx{position:fixed;inset:0;z-index:60;pointer-events:none} | |
| #flash{position:fixed;inset:0;z-index:55;pointer-events:none;background:#fff;opacity:0;mix-blend-mode:screen} | |
| #app{ | |
| height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center; | |
| gap:10px;padding:10px; | |
| } | |
| header{display:flex;align-items:baseline;gap:14px;z-index:4} | |
| h1{ | |
| font-family:Orbitron, sans-serif;font-weight:900;letter-spacing:.14em;font-size:clamp(18px,3.4vw,32px); | |
| color:#fff;text-shadow:0 0 8px var(--accent),0 0 28px color-mix(in srgb, var(--accent) 70%, transparent); | |
| } | |
| header .sub{font-size:11px;letter-spacing:.32em;color:var(--dim)} | |
| header .sub b{color:var(--accent)} | |
| #game{display:flex;align-items:flex-start;gap:14px;z-index:4} | |
| .side{display:flex;flex-direction:column;gap:10px;width:clamp(84px,13vw,132px)} | |
| .panel{ | |
| background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:8px; | |
| backdrop-filter:blur(6px);box-shadow:0 0 22px rgba(0,0,0,.45), inset 0 0 22px rgba(255,255,255,.03); | |
| } | |
| .panel h2{ | |
| font-size:10px;letter-spacing:.24em;color:var(--dim);font-weight:700;margin-bottom:6px;text-align:center; | |
| } | |
| canvas.preview{display:block;width:100%;height:auto} | |
| #hold{height:clamp(46px,7vw,68px)} | |
| #next{height:clamp(150px,22vw,214px)} | |
| .stat{display:flex;justify-content:space-between;align-items:baseline;font-size:11px;padding:2px 2px;letter-spacing:.06em} | |
| .stat span{color:var(--dim)} | |
| .stat b{font-family:Orbitron,monospace;font-size:13px;font-weight:700;color:#fff;text-shadow:0 0 8px color-mix(in srgb,var(--accent) 60%,transparent)} | |
| .stat.hot b{color:var(--accent);text-shadow:0 0 12px var(--accent)} | |
| #stage{position:relative} | |
| #board{ | |
| display:block;border-radius:10px; | |
| background:linear-gradient(180deg, rgba(4,6,18,.92), rgba(8,6,24,.96)); | |
| border:2px solid #2ee6f6; | |
| border-color:color-mix(in srgb, var(--accent) 55%, transparent); | |
| box-shadow:0 0 30px rgba(46,230,246,.35), inset 0 0 40px rgba(0,0,0,.85); | |
| box-shadow:0 0 0 1px rgba(0,0,0,.6), 0 0 30px color-mix(in srgb, var(--accent) 35%, transparent), inset 0 0 40px rgba(0,0,0,.85); | |
| height:min(70vh, 660px);width:auto;aspect-ratio:1/2; | |
| } | |
| #callout{ | |
| position:absolute;left:50%;top:34%;transform:translate(-50%,-50%); | |
| font-family:Orbitron,sans-serif;font-weight:900;font-size:clamp(16px,3.4vw,30px); | |
| text-align:center;white-space:pre-line;line-height:1.15;pointer-events:none;opacity:0; | |
| } | |
| #callout.show{animation:pop 1.15s cubic-bezier(.2,1.4,.3,1) forwards} | |
| #callout .sub{display:block;font-size:.42em;letter-spacing:.3em;margin-top:.5em;opacity:.85} | |
| @keyframes pop{ | |
| 0%{opacity:0;transform:translate(-50%,-50%) scale(.4) rotate(-6deg);filter:blur(6px)} | |
| 18%{opacity:1;transform:translate(-50%,-50%) scale(1.18) rotate(2deg);filter:blur(0)} | |
| 32%{transform:translate(-50%,-50%) scale(1) rotate(0)} | |
| 75%{opacity:1;transform:translate(-50%,-58%) scale(1)} | |
| 100%{opacity:0;transform:translate(-50%,-76%) scale(.94)} | |
| } | |
| #toast{ | |
| position:fixed;top:14px;left:50%;transform:translateX(-50%);z-index:70; | |
| font-family:Orbitron,sans-serif;font-weight:700;letter-spacing:.22em;font-size:12px; | |
| padding:7px 18px;border-radius:999px;border:1px solid var(--accent); | |
| background:rgba(0,0,0,.6);opacity:0;pointer-events:none;white-space:nowrap; | |
| } | |
| #toast.show{animation:toast 2.4s ease forwards} | |
| @keyframes toast{ | |
| 0%{opacity:0;transform:translateX(-50%) translateY(-14px) scale(.9)} | |
| 12%{opacity:1;transform:translateX(-50%) translateY(0) scale(1)} | |
| 80%{opacity:1}100%{opacity:0} | |
| } | |
| #keys{font-size:10.5px;line-height:1.65;color:var(--dim)} | |
| #keys div{display:flex;justify-content:space-between;gap:6px} | |
| #keys kbd{ | |
| font-family:Orbitron,monospace;font-size:9.5px;background:rgba(255,255,255,.08); | |
| border:1px solid var(--line);border-radius:4px;padding:0 4px;color:#dfe8ff; | |
| } | |
| #touch{display:none;gap:6px;flex-wrap:wrap;justify-content:center;max-width:min(94vw,520px);z-index:4} | |
| #touch button{ | |
| font-family:Orbitron,sans-serif;font-weight:700;font-size:11px;letter-spacing:.08em; | |
| background:var(--panel);border:1px solid var(--line);color:var(--text); | |
| border-radius:10px;padding:11px 12px;flex:1 1 62px;min-width:52px; | |
| } | |
| #touch button:active{background:color-mix(in srgb, var(--accent) 30%, transparent)} | |
| #overlay{ | |
| position:fixed;inset:0;z-index:80;display:flex;align-items:center;justify-content:center; | |
| background:rgba(3,4,12,.78);backdrop-filter:blur(7px);text-align:center;padding:20px; | |
| } | |
| #overlay.hide{display:none} | |
| #overlay .box{max-width:560px} | |
| #overlay h2{ | |
| font-family:Orbitron,sans-serif;font-weight:900;font-size:clamp(26px,6vw,54px);letter-spacing:.1em; | |
| text-shadow:0 0 14px var(--accent),0 0 44px color-mix(in srgb, var(--accent) 60%, transparent); | |
| } | |
| #overlay p{margin-top:12px;color:var(--dim);font-size:13px;line-height:1.9;letter-spacing:.08em} | |
| #overlay .go{ | |
| margin-top:20px;display:inline-block;padding:12px 30px;border-radius:999px; | |
| border:1px solid var(--accent);font-family:Orbitron,sans-serif;font-weight:700;letter-spacing:.24em; | |
| font-size:13px;cursor:pointer;box-shadow:0 0 24px color-mix(in srgb,var(--accent) 40%,transparent); | |
| animation:pulse 1.6s ease-in-out infinite; | |
| } | |
| @keyframes pulse{50%{box-shadow:0 0 44px color-mix(in srgb,var(--accent) 80%,transparent)}} | |
| #result{margin-top:16px;display:none;gap:8px 18px;justify-content:center;flex-wrap:wrap;font-size:12px} | |
| #result div{color:var(--dim);letter-spacing:.1em} | |
| #result b{display:block;font-family:Orbitron,monospace;font-size:19px;color:#fff} | |
| @media (max-width:820px){ | |
| #game{gap:8px} | |
| .side{width:78px} | |
| .panel{padding:6px} | |
| #keys{display:none} | |
| #board{height:min(62vh,560px)} | |
| h1{font-size:20px} | |
| } | |
| @media (pointer:coarse){ #touch{display:flex} } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="shake"> | |
| <div id="app"> | |
| <header> | |
| <h1>HYPER TETRIS</h1> | |
| <div class="sub">GUIDELINE COMPLIANT / <b id="themeName">BURST</b> MODE</div> | |
| </header> | |
| <div id="game"> | |
| <aside class="side"> | |
| <div class="panel"><h2>HOLD</h2><canvas id="hold" class="preview"></canvas></div> | |
| <div class="panel"> | |
| <div class="stat"><span>SCORE</span><b id="sScore">0</b></div> | |
| <div class="stat"><span>LINES</span><b id="sLines">0</b></div> | |
| <div class="stat"><span>LEVEL</span><b id="sLevel">1</b></div> | |
| <div class="stat"><span>TIME</span><b id="sTime">0:00</b></div> | |
| </div> | |
| </aside> | |
| <div id="stage"> | |
| <canvas id="board"></canvas> | |
| <div id="callout"></div> | |
| </div> | |
| <aside class="side"> | |
| <div class="panel"><h2>NEXT</h2><canvas id="next" class="preview"></canvas></div> | |
| <div class="panel"> | |
| <div class="stat" id="rowCombo"><span>COMBO</span><b id="sCombo">0</b></div> | |
| <div class="stat" id="rowB2b"><span>B2B</span><b id="sB2b">-</b></div> | |
| <div class="stat"><span>PPS</span><b id="sPps">0.00</b></div> | |
| <div class="stat"><span>PIECES</span><b id="sPieces">0</b></div> | |
| <div class="stat"><span>TETRIS</span><b id="sTetris">0</b></div> | |
| <div class="stat"><span>T-SPIN</span><b id="sTspin">0</b></div> | |
| <div class="stat"><span>PC</span><b id="sPc">0</b></div> | |
| </div> | |
| <div class="panel" id="keys"> | |
| <h2>CONTROLS</h2> | |
| <div><span>MOVE</span><span><kbd>←</kbd><kbd>→</kbd></span></div> | |
| <div><span>SOFT</span><kbd>↓</kbd></div> | |
| <div><span>HARD</span><kbd>SPACE</kbd></div> | |
| <div><span>ROT</span><span><kbd>↑</kbd><kbd>X</kbd><kbd>Z</kbd></span></div> | |
| <div><span>180</span><kbd>A</kbd></div> | |
| <div><span>HOLD</span><span><kbd>SHIFT</kbd><kbd>C</kbd></span></div> | |
| <div><span>PAUSE</span><span><kbd>P</kbd><kbd>ESC</kbd></span></div> | |
| <div><span>RETRY</span><kbd>R</kbd></div> | |
| <div><span>MUTE</span><kbd>M</kbd></div> | |
| </div> | |
| </aside> | |
| </div> | |
| <div id="touch"> | |
| <button data-act="left">←</button> | |
| <button data-act="ccw">ROT-L</button> | |
| <button data-act="cw">ROT-R</button> | |
| <button data-act="right">→</button> | |
| <button data-act="hold">HOLD</button> | |
| <button data-act="down">SOFT</button> | |
| <button data-act="drop">HARD</button> | |
| <button data-act="pause">PAUSE</button> | |
| </div> | |
| </div> | |
| </div> | |
| <canvas id="fx"></canvas> | |
| <div id="flash"></div> | |
| <div id="toast"></div> | |
| <div id="overlay"> | |
| <div class="box"> | |
| <h2 id="ovTitle">HYPER TETRIS</h2> | |
| <p id="ovText"> | |
| テトリス公式ガイドライン準拠 / SRS回転・7-bag・HOLD・ゴースト<br> | |
| 10ライン消すごとに <b style="color:var(--accent)">エフェクトが進化</b> していく<br> | |
| 音声はすべてコード合成。BGMはレベルで速くなる | |
| </p> | |
| <div id="result"> | |
| <div>SCORE<b id="rScore">0</b></div> | |
| <div>LINES<b id="rLines">0</b></div> | |
| <div>LEVEL<b id="rLevel">0</b></div> | |
| <div>TIME<b id="rTime">0:00</b></div> | |
| <div>PPS<b id="rPps">0</b></div> | |
| <div>MAX COMBO<b id="rCombo">0</b></div> | |
| </div> | |
| <div class="go" id="ovGo">PRESS ENTER / TAP TO START</div> | |
| </div> | |
| </div> | |
| <script> | |
| (() => { | |
| "use strict"; | |
| const COLS = 10, ROWS = 20, BUF = 2, H = ROWS + BUF; | |
| const LOCK_DELAY = 500, MAX_LOCK_RESET = 15, DAS = 133, ARR = 33; | |
| const CLEAR_DELAY = 170; | |
| const clamp = (v, a, b) => v < a ? a : v > b ? b : v; | |
| const rand = (a, b) => a + Math.random() * (b - a); | |
| const randi = (a, b) => Math.floor(rand(a, b + 1)); | |
| const pick = a => a[Math.floor(Math.random() * a.length)]; | |
| function shuffle(a) { for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } | |
| function hexRgb(h) { const n = parseInt(h.slice(1), 16); return [n >> 16 & 255, n >> 8 & 255, n & 255]; } | |
| function shade(h, amt) { | |
| const [r, g, b] = hexRgb(h); | |
| const f = v => Math.round(amt > 0 ? v + (255 - v) * amt : v * (1 + amt)); | |
| return `rgb(${f(r)},${f(g)},${f(b)})`; | |
| } | |
| function rgba(h, a) { const [r, g, b] = hexRgb(h); return `rgba(${r},${g},${b},${a})`; } | |
| function roundRect(c, x, y, w, h, r) { | |
| r = Math.min(r, w / 2, h / 2); | |
| c.beginPath(); | |
| c.moveTo(x + r, y); | |
| c.arcTo(x + w, y, x + w, y + h, r); | |
| c.arcTo(x + w, y + h, x, y + h, r); | |
| c.arcTo(x, y + h, x, y, r); | |
| c.arcTo(x, y, x + w, y, r); | |
| c.closePath(); | |
| } | |
| const COLORS = { I: "#2ee6f6", J: "#3b6ef5", L: "#ff9f2e", O: "#ffe14d", S: "#4ee06a", T: "#c65cf5", Z: "#ff4d6d" }; | |
| const TYPES = ["I", "J", "L", "O", "S", "T", "Z"]; | |
| const SHAPES = { | |
| I: ["....", "XXXX", "....", "...."], | |
| J: ["X..", "XXX", "..."], | |
| L: ["..X", "XXX", "..."], | |
| O: ["XX", "XX"], | |
| S: [".XX", "XX.", "..."], | |
| T: [".X.", "XXX", "..."], | |
| Z: ["XX.", ".XX", "..."] | |
| }; | |
| const KICK_COMMON = { | |
| "0>1": [[0, 0], [-1, 0], [-1, 1], [0, -2], [-1, -2]], | |
| "1>0": [[0, 0], [1, 0], [1, -1], [0, 2], [1, 2]], | |
| "1>2": [[0, 0], [1, 0], [1, -1], [0, 2], [1, 2]], | |
| "2>1": [[0, 0], [-1, 0], [-1, 1], [0, -2], [-1, -2]], | |
| "2>3": [[0, 0], [1, 0], [1, 1], [0, -2], [1, -2]], | |
| "3>2": [[0, 0], [-1, 0], [-1, -1], [0, 2], [-1, 2]], | |
| "3>0": [[0, 0], [-1, 0], [-1, -1], [0, 2], [-1, 2]], | |
| "0>3": [[0, 0], [1, 0], [1, 1], [0, -2], [1, -2]] | |
| }; | |
| const KICK_I = { | |
| "0>1": [[0, 0], [-2, 0], [1, 0], [-2, -1], [1, 2]], | |
| "1>0": [[0, 0], [2, 0], [-1, 0], [2, 1], [-1, -2]], | |
| "1>2": [[0, 0], [-1, 0], [2, 0], [-1, 2], [2, -1]], | |
| "2>1": [[0, 0], [1, 0], [-2, 0], [1, -2], [-2, 1]], | |
| "2>3": [[0, 0], [2, 0], [-1, 0], [2, 1], [-1, -2]], | |
| "3>2": [[0, 0], [-2, 0], [1, 0], [-2, -1], [1, 2]], | |
| "3>0": [[0, 0], [1, 0], [-2, 0], [1, -2], [-2, 1]], | |
| "0>3": [[0, 0], [-1, 0], [2, 0], [-1, 2], [2, -1]] | |
| }; | |
| const KICK_180 = [[0, 0], [0, -1], [0, 1], [-1, 0], [1, 0], [-1, -1], [1, -1], [-1, 1], [1, 1]]; | |
| function matToCells(m) { | |
| const out = []; | |
| for (let y = 0; y < m.length; y++) for (let x = 0; x < m[y].length; x++) if (m[y][x] !== ".") out.push({ x, y }); | |
| return out; | |
| } | |
| function rotCW(m) { | |
| const n = m.length, r = []; | |
| for (let y = 0; y < n; y++) { let s = ""; for (let x = 0; x < n; x++) s += m[n - 1 - x][y]; r.push(s); } | |
| return r; | |
| } | |
| const STATES = {}; | |
| for (const t of TYPES) { | |
| let m = SHAPES[t]; | |
| STATES[t] = []; | |
| for (let i = 0; i < 4; i++) { STATES[t].push(matToCells(m)); m = rotCW(m); } | |
| } | |
| const Audio2 = { | |
| ctx: null, master: null, sfxBus: null, musBus: null, delay: null, noise: null, | |
| muted: false, ready: false, | |
| init() { | |
| if (this.ctx) { if (this.ctx.state === "suspended") this.ctx.resume(); return; } | |
| const AC = window.AudioContext || window.webkitAudioContext; | |
| if (!AC) return; | |
| const ctx = this.ctx = new AC(); | |
| const comp = ctx.createDynamicsCompressor(); | |
| comp.threshold.value = -14; comp.knee.value = 24; comp.ratio.value = 6; comp.attack.value = .004; comp.release.value = .22; | |
| this.master = ctx.createGain(); this.master.gain.value = this.muted ? 0 : .85; | |
| this.master.connect(comp); comp.connect(ctx.destination); | |
| this.sfxBus = ctx.createGain(); this.sfxBus.gain.value = .75; this.sfxBus.connect(this.master); | |
| this.musBus = ctx.createGain(); this.musBus.gain.value = .42; this.musBus.connect(this.master); | |
| const d = this.delay = ctx.createDelay(1); d.delayTime.value = .19; | |
| const fb = ctx.createGain(); fb.gain.value = .32; | |
| const dlp = ctx.createBiquadFilter(); dlp.type = "lowpass"; dlp.frequency.value = 2600; | |
| d.connect(dlp); dlp.connect(fb); fb.connect(d); d.connect(this.musBus); | |
| this.delayIn = d; | |
| const len = ctx.sampleRate * 2, buf = ctx.createBuffer(1, len, ctx.sampleRate), ch = buf.getChannelData(0); | |
| for (let i = 0; i < len; i++) ch[i] = Math.random() * 2 - 1; | |
| this.noise = buf; | |
| this.ready = true; | |
| }, | |
| now() { return this.ctx ? this.ctx.currentTime : 0; }, | |
| env(node, t, a, d, peak) { | |
| const g = node.gain; | |
| g.setValueAtTime(0.0001, t); | |
| g.linearRampToValueAtTime(peak, t + a); | |
| g.exponentialRampToValueAtTime(0.0001, t + a + d); | |
| }, | |
| tone(o) { | |
| if (!this.ready || this.muted) return; | |
| const ctx = this.ctx, t = (o.at || ctx.currentTime) + (o.delay || 0); | |
| const osc = ctx.createOscillator(); | |
| osc.type = o.type || "sine"; | |
| osc.frequency.setValueAtTime(o.freq, t); | |
| if (o.to) osc.frequency.exponentialRampToValueAtTime(Math.max(20, o.to), t + (o.slide || o.dur || .2)); | |
| if (o.detune) osc.detune.value = o.detune; | |
| const g = ctx.createGain(); | |
| let node = osc; | |
| if (o.filter) { | |
| const f = ctx.createBiquadFilter(); | |
| f.type = o.filter; f.frequency.setValueAtTime(o.cutoff || 1200, t); | |
| if (o.cutoffTo) f.frequency.exponentialRampToValueAtTime(o.cutoffTo, t + (o.dur || .2)); | |
| f.Q.value = o.q || 1; | |
| node.connect(f); node = f; | |
| } | |
| node.connect(g); | |
| g.connect(o.bus || this.sfxBus); | |
| if (o.send) { const s = ctx.createGain(); s.gain.value = o.send; g.connect(s); s.connect(this.delayIn); } | |
| this.env(g, t, o.attack != null ? o.attack : .004, o.dur || .2, o.gain != null ? o.gain : .25); | |
| osc.start(t); osc.stop(t + (o.attack || .004) + (o.dur || .2) + .05); | |
| }, | |
| hit(o) { | |
| if (!this.ready || this.muted) return; | |
| const ctx = this.ctx, t = (o.at || ctx.currentTime) + (o.delay || 0); | |
| const src = ctx.createBufferSource(); src.buffer = this.noise; src.loop = true; | |
| const f = ctx.createBiquadFilter(); | |
| f.type = o.filter || "bandpass"; f.frequency.setValueAtTime(o.freq || 2000, t); | |
| if (o.freqTo) f.frequency.exponentialRampToValueAtTime(o.freqTo, t + (o.dur || .15)); | |
| f.Q.value = o.q || 1; | |
| const g = ctx.createGain(); | |
| src.connect(f); f.connect(g); g.connect(o.bus || this.sfxBus); | |
| this.env(g, t, o.attack != null ? o.attack : .002, o.dur || .15, o.gain != null ? o.gain : .2); | |
| src.start(t); src.stop(t + (o.dur || .15) + .1); | |
| }, | |
| move() { this.tone({ freq: 420, to: 300, dur: .035, gain: .09, type: "square", filter: "lowpass", cutoff: 2400 }); }, | |
| rotate() { this.tone({ freq: 700, to: 980, dur: .06, gain: .1, type: "triangle" }); this.hit({ freq: 3200, dur: .04, gain: .05 }); }, | |
| soft() { this.tone({ freq: 200, to: 150, dur: .03, gain: .05, type: "sine" }); }, | |
| land() { this.tone({ freq: 150, to: 70, dur: .1, gain: .22, type: "triangle" }); this.hit({ freq: 900, dur: .07, gain: .1, filter: "lowpass" }); }, | |
| hard() { | |
| this.tone({ freq: 900, to: 90, dur: .2, gain: .3, type: "sawtooth", filter: "lowpass", cutoff: 4200, cutoffTo: 300 }); | |
| this.hit({ freq: 1800, freqTo: 200, dur: .22, gain: .26, filter: "lowpass" }); | |
| }, | |
| hold() { this.tone({ freq: 380, to: 760, dur: .12, gain: .13, type: "triangle" }); this.tone({ freq: 760, to: 1140, dur: .1, gain: .07, type: "sine", delay: .06 }); }, | |
| deny() { this.tone({ freq: 160, to: 110, dur: .12, gain: .16, type: "square", filter: "lowpass", cutoff: 800 }); }, | |
| clear(n, b2b) { | |
| const base = [0, 523.25, 587.33, 659.25, 783.99][n] || 523.25; | |
| const steps = [0, 4, 7, 12, 16]; | |
| for (let i = 0; i <= n && i < 5; i++) { | |
| this.tone({ | |
| freq: base * Math.pow(2, steps[i] / 12), dur: .32, gain: .17, type: "triangle", | |
| delay: i * .055, send: .3, filter: "lowpass", cutoff: 5200 | |
| }); | |
| } | |
| this.hit({ freq: 6000, freqTo: 900, dur: .3, gain: .12, filter: "highpass" }); | |
| if (b2b) this.tone({ freq: 1568, to: 2093, dur: .4, gain: .12, type: "sine", delay: .1, send: .4 }); | |
| }, | |
| tetris() { | |
| const notes = [523.25, 659.25, 783.99, 1046.5, 1318.5]; | |
| notes.forEach((f, i) => this.tone({ freq: f, dur: .5, gain: .17, type: "sawtooth", delay: i * .045, send: .35, filter: "lowpass", cutoff: 6000 })); | |
| this.tone({ freq: 110, to: 55, dur: .5, gain: .34, type: "sine" }); | |
| this.hit({ freq: 9000, freqTo: 400, dur: .6, gain: .16, filter: "bandpass", q: .7 }); | |
| }, | |
| tspin() { | |
| [880, 1174.7, 1567.98].forEach((f, i) => this.tone({ freq: f, to: f * 1.5, dur: .3, gain: .16, type: "square", delay: i * .04, send: .35, filter: "bandpass", cutoff: 2600, q: 3 })); | |
| this.hit({ freq: 400, freqTo: 5000, dur: .3, gain: .12 }); | |
| }, | |
| pc() { | |
| [659.25, 830.61, 987.77, 1318.5, 1661.2, 1975.5].forEach((f, i) => this.tone({ freq: f, dur: .7, gain: .15, type: "triangle", delay: i * .06, send: .45 })); | |
| this.hit({ freq: 12000, freqTo: 2000, dur: .8, gain: .1, filter: "highpass" }); | |
| }, | |
| combo(n) { | |
| const f = 440 * Math.pow(2, Math.min(n, 14) / 12); | |
| this.tone({ freq: f, to: f * 1.4, dur: .16, gain: .13 + Math.min(n, 8) * .012, type: "square", filter: "lowpass", cutoff: 4000, send: .25 }); | |
| }, | |
| levelUp() { | |
| [523.25, 659.25, 783.99, 1046.5].forEach((f, i) => this.tone({ freq: f, dur: .35, gain: .16, type: "triangle", delay: i * .07, send: .3 })); | |
| }, | |
| over() { | |
| [440, 392, 330, 262, 196].forEach((f, i) => this.tone({ freq: f, to: f * .7, dur: .55, gain: .2, type: "sawtooth", delay: i * .16, filter: "lowpass", cutoff: 1400 })); | |
| this.tone({ freq: 90, to: 40, dur: 1.6, gain: .25, type: "sine", delay: .3 }); | |
| }, | |
| theme() { | |
| [784, 988, 1319, 1568].forEach((f, i) => this.tone({ freq: f, dur: .3, gain: .14, type: "square", delay: i * .05, send: .4, filter: "bandpass", cutoff: 3000, q: 2 })); | |
| } | |
| }; | |
| const Music = { | |
| on: false, step: 0, nextT: 0, bpm: 118, timer: null, intensity: 1, | |
| chords: [[57, 60, 64], [53, 57, 60], [55, 60, 64], [55, 59, 62]], | |
| roots: [33, 29, 36, 31], | |
| penta: [69, 72, 74, 76, 79, 81], | |
| start() { | |
| if (!Audio2.ready || this.on) return; | |
| this.on = true; this.step = 0; this.nextT = Audio2.ctx.currentTime + .06; | |
| this.timer = setInterval(() => this.schedule(), 25); | |
| }, | |
| stop() { this.on = false; if (this.timer) clearInterval(this.timer); this.timer = null; }, | |
| setLevel(lv) { this.bpm = Math.min(196, 112 + lv * 4); this.intensity = clamp(1 + Math.floor(lv / 3), 1, 5); }, | |
| stepDur() { return 60 / this.bpm / 4; }, | |
| schedule() { | |
| if (!this.on || !Audio2.ready) return; | |
| const ctx = Audio2.ctx, dur = this.stepDur(); | |
| while (this.nextT < ctx.currentTime + .14) { | |
| this.play(this.step, this.nextT); | |
| this.nextT += dur; | |
| this.step = (this.step + 1) % 64; | |
| } | |
| }, | |
| play(s, t) { | |
| const A = Audio2, bar = Math.floor(s / 16), b = s % 16; | |
| const chord = this.chords[bar], root = this.roots[bar], I = this.intensity; | |
| const M = m => 440 * Math.pow(2, (m - 69) / 12); | |
| if (b % 4 === 0) { | |
| A.tone({ freq: 160, to: 45, dur: .22, gain: .55, type: "sine", at: t, bus: A.musBus }); | |
| A.hit({ freq: 200, dur: .03, gain: .2, filter: "lowpass", at: t, bus: A.musBus }); | |
| } | |
| if (I >= 2 && (b === 4 || b === 12)) { | |
| A.hit({ freq: 1900, dur: .13, gain: .3, filter: "bandpass", q: .9, at: t, bus: A.musBus }); | |
| A.tone({ freq: 190, to: 150, dur: .09, gain: .12, type: "triangle", at: t, bus: A.musBus }); | |
| } | |
| if (I >= 2 && b % 2 === 1) { | |
| A.hit({ freq: 9000, dur: b === 7 || b === 15 ? .12 : .035, gain: b % 4 === 3 ? .09 : .05, filter: "highpass", at: t, bus: A.musBus }); | |
| } | |
| if ([0, 3, 6, 8, 11, 14].includes(b)) { | |
| const m = root + (b === 11 || b === 14 ? 12 : 0); | |
| A.tone({ freq: M(m), dur: .2, gain: .34, type: "sawtooth", filter: "lowpass", cutoff: 380 + I * 120, q: 6, at: t, bus: A.musBus }); | |
| } | |
| if (I >= 2) { | |
| const seq = [0, 1, 2, 1]; | |
| if (I >= 4 || b % 2 === 0) { | |
| const m = chord[seq[b % 4]] + 12; | |
| A.tone({ freq: M(m), dur: .16, gain: .085, type: "square", filter: "lowpass", cutoff: 2600, q: 2, at: t, bus: A.musBus, send: .3 }); | |
| } | |
| } | |
| if (I >= 3 && b === 0) { | |
| chord.forEach((m, i) => A.tone({ freq: M(m), dur: 1.5, gain: .05, type: "sawtooth", attack: .35, filter: "lowpass", cutoff: 900, at: t, bus: A.musBus, detune: i * 6 - 6 })); | |
| } | |
| if (I >= 4 && [2, 6, 9, 13].includes(b) && Math.random() < .6) { | |
| const m = this.penta[randi(0, this.penta.length - 1)]; | |
| A.tone({ freq: M(m), dur: .3, gain: .1, type: "triangle", filter: "lowpass", cutoff: 4000, at: t, bus: A.musBus, send: .4 }); | |
| } | |
| if (S.danger && b % 2 === 0) A.hit({ freq: 12000, dur: .02, gain: .04, filter: "highpass", at: t, bus: A.musBus }); | |
| } | |
| }; | |
| const boardCanvas = document.getElementById("board"); | |
| const bctx = boardCanvas.getContext("2d"); | |
| const holdCanvas = document.getElementById("hold"); | |
| const hctx = holdCanvas.getContext("2d"); | |
| const nextCanvas = document.getElementById("next"); | |
| const nctx = nextCanvas.getContext("2d"); | |
| const fxCanvas = document.getElementById("fx"); | |
| const fctx = fxCanvas.getContext("2d"); | |
| const flashEl = document.getElementById("flash"); | |
| const calloutEl = document.getElementById("callout"); | |
| const toastEl = document.getElementById("toast"); | |
| const overlayEl = document.getElementById("overlay"); | |
| const themeNameEl = document.getElementById("themeName"); | |
| const shakeEl = document.getElementById("shake"); | |
| let confettiFire = null; | |
| let VW = 0, VH = 0, DPR = 1; | |
| function resizeCanvas(c, ctx) { | |
| const r = c.getBoundingClientRect(); | |
| c.width = Math.max(1, Math.round(r.width * DPR)); | |
| c.height = Math.max(1, Math.round(r.height * DPR)); | |
| ctx.setTransform(DPR, 0, 0, DPR, 0, 0); | |
| return r; | |
| } | |
| function resizeAll() { | |
| DPR = Math.min(2, window.devicePixelRatio || 1); | |
| VW = window.innerWidth; VH = window.innerHeight; | |
| fxCanvas.width = Math.round(VW * DPR); fxCanvas.height = Math.round(VH * DPR); | |
| fctx.setTransform(DPR, 0, 0, DPR, 0, 0); | |
| resizeCanvas(boardCanvas, bctx); | |
| resizeCanvas(holdCanvas, hctx); | |
| resizeCanvas(nextCanvas, nctx); | |
| if (window.confetti && !confettiFire) confettiFire = window.confetti.create(fxCanvas, { resize: false, useWorker: false }); | |
| } | |
| window.addEventListener("resize", resizeAll); | |
| const particles = []; | |
| const MAX_PARTICLES = 2600; | |
| let shakeAmt = 0, flashAmt = 0, flashColor = "#ffffff"; | |
| function add(p) { | |
| if (particles.length >= MAX_PARTICLES) return; | |
| p.life = p.ttl; | |
| p.rot = p.rot || 0; | |
| p.vrot = p.vrot || 0; | |
| p.grav = p.grav || 0; | |
| p.drag = p.drag == null ? 1 : p.drag; | |
| p.alpha = p.alpha == null ? 1 : p.alpha; | |
| particles.push(p); | |
| } | |
| function shake(a) { shakeAmt = Math.min(34, shakeAmt + a); } | |
| function flash(a, color) { flashAmt = Math.min(1, flashAmt + a); flashColor = color || "#ffffff"; } | |
| function updateParticles(dt) { | |
| for (let i = particles.length - 1; i >= 0; i--) { | |
| const p = particles[i]; | |
| p.life -= dt; | |
| if (p.life <= 0) { particles.splice(i, 1); continue; } | |
| if (p.kind === "orbit") { | |
| p.av += p.at * dt; | |
| p.r += p.vr * dt; | |
| p.vr *= Math.pow(0.3, dt); | |
| p.r = Math.max(0, p.r); | |
| p.x = p.cx + Math.cos(p.av) * p.r; | |
| p.y = p.cy + Math.sin(p.av) * p.r * (p.sy || 1); | |
| p.rot += p.vrot * dt; | |
| } else { | |
| p.vy += p.grav * dt; | |
| const d = Math.pow(p.drag, dt * 60); | |
| p.vx *= d; p.vy *= d; | |
| p.x += p.vx * dt; p.y += p.vy * dt; | |
| p.rot += p.vrot * dt; | |
| } | |
| } | |
| } | |
| function drawParticles() { | |
| fctx.save(); | |
| for (const p of particles) { | |
| const k = clamp(p.life / p.ttl, 0, 1); | |
| const a = p.alpha * (p.fadeIn && k > p.fadeIn ? (1 - k) / (1 - p.fadeIn) : k); | |
| fctx.globalCompositeOperation = p.add === false ? "source-over" : "lighter"; | |
| fctx.globalAlpha = clamp(a, 0, 1); | |
| switch (p.kind) { | |
| case "spark": { | |
| const sp = Math.hypot(p.vx, p.vy) || 1; | |
| const len = clamp(sp * 0.045, 3, 46); | |
| fctx.strokeStyle = p.color; fctx.lineWidth = p.size * k; fctx.lineCap = "round"; | |
| fctx.beginPath(); | |
| fctx.moveTo(p.x, p.y); | |
| fctx.lineTo(p.x - p.vx / sp * len, p.y - p.vy / sp * len); | |
| fctx.stroke(); | |
| break; | |
| } | |
| case "square": { | |
| fctx.save(); | |
| fctx.translate(p.x, p.y); fctx.rotate(p.rot); | |
| fctx.fillStyle = p.color; | |
| const s = p.size * (0.4 + 0.6 * k); | |
| fctx.fillRect(-s / 2, -s / 2 * (p.sq || 1), s, s * (p.sq || 1)); | |
| fctx.restore(); | |
| break; | |
| } | |
| case "dot": { | |
| fctx.fillStyle = p.color; | |
| fctx.beginPath(); fctx.arc(p.x, p.y, p.size * k, 0, 6.2832); fctx.fill(); | |
| break; | |
| } | |
| case "flame": { | |
| const s = p.size * (1.6 - k * 0.9); | |
| const g = fctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, s); | |
| g.addColorStop(0, `hsla(${p.hue},100%,72%,.95)`); | |
| g.addColorStop(.45, `hsla(${p.hue - 20},100%,50%,.6)`); | |
| g.addColorStop(1, "hsla(0,0%,0%,0)"); | |
| fctx.fillStyle = g; | |
| fctx.beginPath(); fctx.arc(p.x, p.y, s, 0, 6.2832); fctx.fill(); | |
| break; | |
| } | |
| case "ring": { | |
| const r = p.r0 + (p.r1 - p.r0) * (1 - k); | |
| fctx.strokeStyle = p.color; fctx.lineWidth = p.size * k + .5; | |
| fctx.beginPath(); fctx.ellipse(p.x, p.y, r, r * (p.sy || 1), 0, 0, 6.2832); fctx.stroke(); | |
| break; | |
| } | |
| case "shard": { | |
| fctx.save(); | |
| fctx.translate(p.x, p.y); fctx.rotate(p.rot); | |
| fctx.fillStyle = p.color; | |
| fctx.beginPath(); | |
| fctx.moveTo(0, -p.size); fctx.lineTo(p.size * .7, p.size * .8); fctx.lineTo(-p.size * .7, p.size * .8); | |
| fctx.closePath(); fctx.fill(); | |
| fctx.restore(); | |
| break; | |
| } | |
| case "star": { | |
| fctx.save(); | |
| fctx.translate(p.x, p.y); fctx.rotate(p.rot); | |
| const s = p.size * (0.5 + 0.5 * k), w = s * .22; | |
| fctx.fillStyle = p.color; | |
| fctx.beginPath(); | |
| fctx.moveTo(0, -s); fctx.quadraticCurveTo(w, -w, s, 0); | |
| fctx.quadraticCurveTo(w, w, 0, s); fctx.quadraticCurveTo(-w, w, -s, 0); | |
| fctx.quadraticCurveTo(-w, -w, 0, -s); | |
| fctx.fill(); | |
| fctx.restore(); | |
| break; | |
| } | |
| case "bolt": { | |
| fctx.strokeStyle = p.color; fctx.lineWidth = p.size; fctx.lineCap = "round"; | |
| fctx.beginPath(); | |
| fctx.moveTo(p.x0, p.y0); | |
| const seg = 7; | |
| for (let i = 1; i < seg; i++) { | |
| const t = i / seg; | |
| const nx = p.x0 + (p.x1 - p.x0) * t + rand(-1, 1) * p.amp * (k > .3 ? 1 : .4); | |
| const ny = p.y0 + (p.y1 - p.y0) * t + rand(-1, 1) * p.amp * (k > .3 ? 1 : .4); | |
| fctx.lineTo(nx, ny); | |
| } | |
| fctx.lineTo(p.x1, p.y1); | |
| fctx.stroke(); | |
| break; | |
| } | |
| case "glyph": { | |
| fctx.font = `700 ${p.size}px "Orbitron",monospace`; | |
| fctx.fillStyle = p.color; | |
| fctx.textAlign = "center"; fctx.textBaseline = "middle"; | |
| fctx.fillText(p.ch, p.x, p.y); | |
| break; | |
| } | |
| case "ribbon": { | |
| fctx.save(); | |
| fctx.translate(p.x, p.y); fctx.rotate(p.rot); | |
| const g = fctx.createLinearGradient(-p.size, 0, p.size, 0); | |
| g.addColorStop(0, "rgba(0,0,0,0)"); | |
| g.addColorStop(.5, p.color); | |
| g.addColorStop(1, "rgba(0,0,0,0)"); | |
| fctx.fillStyle = g; | |
| fctx.fillRect(-p.size, -p.size * 0.12, p.size * 2, p.size * 0.24); | |
| fctx.restore(); | |
| break; | |
| } | |
| case "orbit": { | |
| fctx.fillStyle = p.color; | |
| fctx.beginPath(); fctx.arc(p.x, p.y, p.size, 0, 6.2832); fctx.fill(); | |
| break; | |
| } | |
| } | |
| } | |
| fctx.restore(); | |
| } | |
| const RAINBOW = ["#ff2d6f", "#ff9f2e", "#ffe14d", "#4ee06a", "#2ee6f6", "#3b6ef5", "#c65cf5"]; | |
| const GLYPHS = "アイウエオカキクケコサシスセソテトリス0123456789ABCDEF"; | |
| function cellBurst(cells, opt) { | |
| const o = opt || {}; | |
| for (const c of cells) { | |
| const n = o.count || 8; | |
| for (let i = 0; i < n; i++) { | |
| const ang = o.dir == null ? rand(0, 6.2832) : o.dir + rand(-o.spread || -.6, o.spread || .6); | |
| const sp = rand(o.minSp || 90, o.maxSp || 460); | |
| add({ | |
| kind: o.kind || "spark", | |
| x: c.x + c.s * rand(.15, .85), y: c.y + c.s * rand(.15, .85), | |
| vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp - (o.lift || 0), | |
| grav: o.grav == null ? 900 : o.grav, | |
| drag: o.drag == null ? .965 : o.drag, | |
| ttl: rand(o.minTtl || .35, o.maxTtl || .85), | |
| size: c.s * (o.size || .16), | |
| color: o.color || c.color, | |
| vrot: rand(-14, 14), rot: rand(0, 6.28), sq: o.sq | |
| }); | |
| } | |
| } | |
| } | |
| const THEMES = [ | |
| { | |
| name: "BURST", accent: "#2ee6f6", | |
| run(cells, n) { | |
| cellBurst(cells, { count: 9, maxSp: 520, size: .18 }); | |
| const cx = cells.reduce((a, c) => a + c.x, 0) / cells.length + 14; | |
| const cy = cells.reduce((a, c) => a + c.y, 0) / cells.length + 14; | |
| for (let i = 0; i < 3; i++) add({ kind: "ring", x: cx, y: cy, r0: 6, r1: 120 + n * 46 + i * 40, ttl: .55 + i * .13, size: 5 - i, color: this.accent, sy: .55 }); | |
| for (let i = 0; i < 40 * n; i++) add({ kind: "dot", x: cx, y: cy, vx: rand(-620, 620), vy: rand(-620, 620), grav: 260, drag: .955, ttl: rand(.3, .8), size: rand(2, 5), color: pick(RAINBOW) }); | |
| flash(.16 + n * .05, this.accent); shake(6 + n * 4); | |
| } | |
| }, | |
| { | |
| name: "CONFETTI", accent: "#ff2d6f", | |
| run(cells, n) { | |
| cellBurst(cells, { count: 6, kind: "square", maxSp: 420, size: .3, sq: .5, minTtl: .7, maxTtl: 1.5, grav: 700 }); | |
| if (confettiFire) { | |
| confettiFire({ particleCount: 70 + n * 55, spread: 60 + n * 14, startVelocity: 40 + n * 6, ticks: 220, gravity: 1, scalar: 1.1, colors: RAINBOW, origin: { x: .5, y: .82 } }); | |
| confettiFire({ particleCount: 40 + n * 30, spread: 130, startVelocity: 34, ticks: 200, colors: RAINBOW, origin: { x: .5, y: .3 }, scalar: .9 }); | |
| } | |
| for (let i = 0; i < 26 * n; i++) add({ kind: "square", x: rand(0, VW), y: -20, vx: rand(-90, 90), vy: rand(120, 340), grav: 120, drag: .995, ttl: rand(1.6, 3), size: rand(7, 16), color: pick(RAINBOW), vrot: rand(-8, 8), sq: rand(.3, 1), add: false, alpha: .95 }); | |
| flash(.14 + n * .04, this.accent); shake(5 + n * 4); | |
| } | |
| }, | |
| { | |
| name: "INFERNO", accent: "#ff9f2e", | |
| run(cells, n) { | |
| for (const c of cells) { | |
| for (let i = 0; i < 5; i++) add({ kind: "flame", x: c.x + c.s * rand(0, 1), y: c.y + c.s * .5, vx: rand(-40, 40), vy: rand(-330, -130), grav: 210, drag: .97, ttl: rand(.5, 1.1), size: c.s * rand(.5, 1.1), hue: rand(14, 48) }); | |
| for (let i = 0; i < 3; i++) add({ kind: "dot", x: c.x + c.s * rand(0, 1), y: c.y + c.s * .5, vx: rand(-70, 70), vy: rand(-420, -180), grav: -60, drag: .98, ttl: rand(.6, 1.3), size: rand(1.5, 3.4), color: i ? "#ffd166" : "#fff1a8" }); | |
| } | |
| const cy = cells[0].y; | |
| for (let i = 0; i < 12 * n; i++) add({ kind: "ring", x: rand(0, VW), y: cy + rand(-20, 20), r0: 4, r1: rand(60, 200), ttl: rand(.4, .9), size: rand(2, 5), color: "#ff6b1a", sy: .3 }); | |
| flash(.2 + n * .05, "#ff7a18"); shake(7 + n * 4.5); | |
| } | |
| }, | |
| { | |
| name: "THUNDER", accent: "#c65cf5", | |
| run(cells, n) { | |
| const rows = [...new Set(cells.map(c => c.y))]; | |
| for (const y of rows) { | |
| for (let i = 0; i < 5 + n * 3; i++) { | |
| add({ | |
| kind: "bolt", x0: 0, y0: y + 14, x1: VW, y1: y + 14 + rand(-160, 160), | |
| vx: 0, vy: 0, ttl: rand(.12, .34), size: rand(1.6, 5), amp: rand(10, 46), color: i % 3 ? "#e0a3ff" : "#ffffff" | |
| }); | |
| } | |
| for (let i = 0; i < 30; i++) add({ kind: "spark", x: rand(0, VW), y: y + 14, vx: rand(-700, 700), vy: rand(-500, 500), grav: 500, drag: .94, ttl: rand(.2, .6), size: rand(2, 4), color: pick(["#ffffff", "#c65cf5", "#8f6bff"]) }); | |
| } | |
| cellBurst(cells, { count: 4, maxSp: 620, size: .14, minTtl: .2, maxTtl: .5 }); | |
| flash(.32 + n * .06, "#d8b4ff"); shake(11 + n * 4); | |
| } | |
| }, | |
| { | |
| name: "SHOCKWAVE", accent: "#ff2d6f", | |
| run(cells, n) { | |
| const cx = VW / 2, cy = cells.reduce((a, c) => a + c.y, 0) / cells.length + 14; | |
| for (let i = 0; i < 6 + n * 2; i++) add({ kind: "ring", x: cx, y: cy, r0: 8, r1: 200 + i * 90 + n * 60, ttl: .6 + i * .12, size: 8 - i * .8, color: i % 2 ? this.accent : "#ffffff" }); | |
| for (let i = 0; i < 90 * n; i++) { | |
| const a = rand(0, 6.2832), sp = rand(240, 1200); | |
| add({ kind: "shard", x: cx, y: cy, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, grav: 420, drag: .96, ttl: rand(.4, 1.1), size: rand(5, 15), color: pick(RAINBOW), vrot: rand(-18, 18) }); | |
| } | |
| cellBurst(cells, { count: 5, maxSp: 700, size: .2, grav: 200 }); | |
| flash(.24 + n * .05, "#ffffff"); shake(12 + n * 5); | |
| } | |
| }, | |
| { | |
| name: "STARDUST", accent: "#ffe14d", | |
| run(cells, n) { | |
| const cy = cells.reduce((a, c) => a + c.y, 0) / cells.length; | |
| for (const c of cells) { | |
| for (let i = 0; i < 4; i++) add({ kind: "star", x: c.x + c.s * .5, y: c.y + c.s * .5, vx: rand(-260, 260), vy: rand(-420, -60), grav: 420, drag: .96, ttl: rand(.6, 1.4), size: c.s * rand(.4, 1), color: pick(["#ffe14d", "#ffffff", "#ffd6f5", "#9be8ff"]), vrot: rand(-10, 10) }); | |
| } | |
| for (let i = 0; i < 40 * n; i++) { | |
| const t = i / (40 * n) * 6.2832 * 3; | |
| add({ | |
| kind: "orbit", cx: VW / 2, cy: cy, av: t, r: rand(20, 120), vr: rand(260, 700), at: rand(-6, 6), | |
| x: VW / 2, y: cy, ttl: rand(.7, 1.5), size: rand(2, 6), color: pick(RAINBOW), sy: .5 | |
| }); | |
| } | |
| flash(.18 + n * .04, "#fff3a0"); shake(6 + n * 4); | |
| } | |
| }, | |
| { | |
| name: "VORTEX", accent: "#4ee06a", | |
| run(cells, n) { | |
| const cx = VW / 2, cy = cells.reduce((a, c) => a + c.y, 0) / cells.length + 14; | |
| const rows = [...new Set(cells.map(c => c.x))]; | |
| for (const c of rows) { | |
| for (let i = 0; i < 10; i++) { | |
| const a = rand(0, 6.2832), r = rand(120, Math.max(220, VH * .5)); | |
| add({ | |
| kind: "orbit", cx, cy, av: a, r, vr: -rand(300, 900), at: rand(-9, 9), | |
| x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r, ttl: rand(.6, 1.1), | |
| size: rand(2, 7), color: i % 2 ? this.accent : "#d9ffd0", sy: .7 | |
| }); | |
| } | |
| } | |
| for (let i = 0; i < 4; i++) add({ kind: "ring", x: cx, y: cy, r0: 340, r1: 10, ttl: .5 + i * .1, size: 4, color: this.accent, sy: .7 }); | |
| for (let i = 0; i < 60 * n; i++) { | |
| const a = rand(0, 6.2832), sp = rand(300, 900); | |
| add({ kind: "spark", x: cx, y: cy, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp * .6, grav: 100, drag: .95, ttl: rand(.5, 1), size: rand(2, 4), color: pick(["#4ee06a", "#ffffff", "#c8ff9e"]) }); | |
| } | |
| flash(.2 + n * .04, "#7dff9b"); shake(8 + n * 4); | |
| } | |
| }, | |
| { | |
| name: "MATRIX", accent: "#7dff9b", | |
| run(cells, n) { | |
| for (const c of cells) { | |
| for (let i = 0; i < 4; i++) add({ kind: "glyph", x: c.x + c.s * .5, y: c.y + c.s * .5, vx: rand(-30, 30), vy: rand(160, 620), grav: 260, drag: .995, ttl: rand(.8, 1.8), size: rand(12, 26), color: i === 0 ? "#ffffff" : this.accent, ch: GLYPHS[Math.floor(Math.random() * GLYPHS.length)], add: false, alpha: .9 }); | |
| } | |
| for (let i = 0; i < 26 * n; i++) { | |
| const gx = rand(0, VW); | |
| for (let j = 0; j < randi(4, 12); j++) { | |
| add({ kind: "glyph", x: gx, y: -30 - j * 26, vx: 0, vy: rand(320, 800), grav: 0, drag: 1, ttl: rand(.8, 2), size: rand(12, 22), color: j === 0 ? "#eaffef" : this.accent, ch: GLYPHS[Math.floor(Math.random() * GLYPHS.length)], add: false, alpha: .85 }); | |
| } | |
| } | |
| for (let i = 0; i < 5 * n; i++) add({ kind: "ring", x: VW / 2, y: cells[0].y + 14, r0: 4, r1: VW, ttl: rand(.3, .6), size: rand(1, 3), color: this.accent, sy: .06 }); | |
| flash(.16 + n * .04, "#b6ffcb"); shake(6 + n * 3.5); | |
| } | |
| }, | |
| { | |
| name: "AURORA", accent: "#2ee6f6", | |
| run(cells, n) { | |
| const cy = cells.reduce((a, c) => a + c.y, 0) / cells.length + 14; | |
| for (let i = 0; i < 16 + n * 6; i++) { | |
| add({ | |
| kind: "ribbon", x: rand(0, VW), y: cy + rand(-120, 120), vx: rand(-160, 160), vy: rand(-140, -20), | |
| grav: -20, drag: .99, ttl: rand(.7, 1.6), size: rand(30, 120), color: pick(["#2ee6f6", "#4ee06a", "#c65cf5", "#7dff9b", "#ffffff"]), | |
| vrot: rand(-2, 2), rot: rand(-1.2, 1.2) | |
| }); | |
| } | |
| cellBurst(cells, { count: 6, maxSp: 380, size: .16, grav: -40, minTtl: .6, maxTtl: 1.4 }); | |
| for (let i = 0; i < 30 * n; i++) add({ kind: "dot", x: rand(0, VW), y: cy, vx: rand(-120, 120), vy: rand(-300, -60), grav: -80, drag: .98, ttl: rand(.8, 1.8), size: rand(1.5, 4), color: "#ffffff" }); | |
| flash(.14 + n * .04, "#8ff6ff"); shake(5 + n * 3.5); | |
| } | |
| }, | |
| { | |
| name: "PRISM", accent: "#ffffff", | |
| run(cells, n) { | |
| const cx = VW / 2, cy = cells.reduce((a, c) => a + c.y, 0) / cells.length + 14; | |
| for (let i = 0; i < 7; i++) { | |
| const col = RAINBOW[i]; | |
| for (let j = 0; j < 14 * n; j++) { | |
| const a = -1.5708 + (i - 3) * .16 + rand(-.08, .08), sp = rand(500, 1500); | |
| add({ kind: "spark", x: cx, y: cy, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, grav: 60, drag: .98, ttl: rand(.5, 1.2), size: rand(2, 6), color: col }); | |
| } | |
| } | |
| for (let i = 0; i < 5; i++) add({ kind: "ring", x: cx, y: cy, r0: 10, r1: 260 + i * 120, ttl: .5 + i * .14, size: 6, color: RAINBOW[i % 7] }); | |
| for (const c of cells) for (let i = 0; i < 3; i++) add({ kind: "star", x: c.x + c.s * .5, y: c.y + c.s * .5, vx: rand(-400, 400), vy: rand(-500, 100), grav: 600, drag: .95, ttl: rand(.4, 1), size: c.s * rand(.4, .9), color: "#ffffff", vrot: rand(-16, 16) }); | |
| flash(.4, "#ffffff"); shake(14 + n * 5); | |
| } | |
| } | |
| ]; | |
| let themeIndex = 0; | |
| const S = { | |
| board: [], cur: null, hold: null, canHold: true, bag: [], queue: [], | |
| mode: "title", score: 0, lines: 0, level: 1, combo: 0, b2b: 0, b2bChain: 0, | |
| dropT: 0, lockT: 0, resets: 0, grounded: false, soft: false, | |
| elapsed: 0, pieces: 0, tetrises: 0, tspins: 0, pcs: 0, maxCombo: 0, | |
| clearT: 0, pending: null, lastRot: false, lastKick: 0, danger: false, | |
| overT: 0, nextThemeAt: 10 | |
| }; | |
| function emptyBoard() { return Array.from({ length: H }, () => new Array(COLS).fill(null)); } | |
| function collides(type, rot, x, y) { | |
| for (const c of STATES[type][rot]) { | |
| const bx = x + c.x, by = y + c.y; | |
| if (bx < 0 || bx >= COLS || by >= H) return true; | |
| if (by >= 0 && S.board[by][bx]) return true; | |
| } | |
| return false; | |
| } | |
| function refillBag() { if (S.bag.length === 0) S.bag = shuffle(TYPES.slice()); } | |
| function nextType() { refillBag(); const t = S.bag.pop(); refillBag(); return t; } | |
| function fillQueue() { while (S.queue.length < 5) S.queue.push(nextType()); } | |
| function gravityMs(lv) { return Math.pow(0.8 - (lv - 1) * 0.007, lv - 1) * 1000; } | |
| function spawnPiece(type, noCount) { | |
| fillQueue(); | |
| const t = type || S.queue.shift(); | |
| fillQueue(); | |
| const x = t === "O" ? 4 : 3; | |
| S.cur = { type: t, rot: 0, x, y: 1 }; | |
| if (!noCount) S.pieces++; | |
| S.dropT = 0; S.lockT = 0; S.resets = 0; S.grounded = false; | |
| S.lastRot = false; S.lastKick = 0; | |
| S.canHold = true; | |
| if (collides(t, 0, x, 1)) gameOver(); | |
| } | |
| function move(dx) { | |
| if (!S.cur || S.mode !== "play") return false; | |
| if (!collides(S.cur.type, S.cur.rot, S.cur.x + dx, S.cur.y)) { | |
| S.cur.x += dx; | |
| if (S.grounded) tryResetLock(); | |
| Audio2.move(); | |
| return true; | |
| } | |
| return false; | |
| } | |
| function stepDown(fromSoft) { | |
| if (!S.cur || S.mode !== "play") return false; | |
| if (!collides(S.cur.type, S.cur.rot, S.cur.x, S.cur.y + 1)) { | |
| S.cur.y++; | |
| S.dropT = 0; | |
| S.lockT = 0; | |
| S.grounded = false; | |
| if (fromSoft) { S.score += 1; Audio2.soft(); } | |
| else S.lastRot = false; | |
| return true; | |
| } | |
| S.grounded = true; | |
| return false; | |
| } | |
| function tryResetLock() { | |
| if (S.resets < MAX_LOCK_RESET) { S.lockT = 0; S.resets++; } | |
| } | |
| function rotate(dir) { | |
| if (!S.cur || S.mode !== "play") return false; | |
| const c = S.cur; | |
| if (c.type === "O") return false; | |
| const from = c.rot; | |
| let to, table; | |
| if (dir === 2) { to = (from + 2) % 4; table = KICK_180; } | |
| else { to = (from + (dir > 0 ? 1 : 3)) % 4; table = c.type === "I" ? KICK_I : KICK_COMMON; } | |
| const key = from + ">" + to; | |
| const kicks = dir === 2 ? KICK_180 : (table[key] || [[0, 0]]); | |
| for (let i = 0; i < kicks.length; i++) { | |
| const [dx, dyUp] = kicks[i]; | |
| const nx = c.x + dx, ny = c.y - dyUp; | |
| if (!collides(c.type, to, nx, ny)) { | |
| c.rot = to; c.x = nx; c.y = ny; | |
| S.lastRot = true; S.lastKick = i; | |
| if (S.grounded) tryResetLock(); | |
| Audio2.rotate(); | |
| return true; | |
| } | |
| } | |
| Audio2.deny(); | |
| return false; | |
| } | |
| function hardDrop() { | |
| if (!S.cur || S.mode !== "play") return; | |
| let d = 0; | |
| while (!collides(S.cur.type, S.cur.rot, S.cur.x, S.cur.y + 1)) { S.cur.y++; d++; } | |
| S.score += d * 2; | |
| Audio2.hard(); | |
| const r = boardCanvas.getBoundingClientRect(); | |
| const cs = r.width / COLS; | |
| for (let i = 0; i < 12 + d; i++) { | |
| add({ | |
| kind: "spark", x: r.left + (S.cur.x + rand(0, 4)) * cs, y: r.top + (S.cur.y - BUF) * cs + rand(0, cs), | |
| vx: rand(-220, 220), vy: rand(-520, -120), grav: 900, drag: .95, ttl: rand(.15, .4), | |
| size: rand(2, 5), color: COLORS[S.cur.type] | |
| }); | |
| } | |
| shake(2 + Math.min(6, d * .25)); | |
| lockPiece(); | |
| } | |
| function doHold() { | |
| if (!S.cur || S.mode !== "play" || !S.canHold) { Audio2.deny(); return; } | |
| const t = S.cur.type; | |
| const h = S.hold; | |
| S.hold = t; | |
| Audio2.hold(); | |
| if (h) spawnPiece(h, true); | |
| else spawnPiece(); | |
| S.canHold = false; | |
| } | |
| function tspinKind() { | |
| const c = S.cur; | |
| if (!c || c.type !== "T" || !S.lastRot) return null; | |
| const cx = c.x + 1, cy = c.y + 1; | |
| const corners = [[-1, -1], [1, -1], [-1, 1], [1, 1]].map(([ox, oy]) => { | |
| const bx = cx + ox, by = cy + oy; | |
| if (bx < 0 || bx >= COLS || by >= H) return true; | |
| return by < 0 ? false : !!S.board[by][bx]; | |
| }); | |
| const FRONT = [[0, 1], [1, 3], [2, 3], [0, 2]]; | |
| const front = FRONT[c.rot], back = [0, 1, 2, 3].filter(i => !front.includes(i)); | |
| const fc = front.filter(i => corners[i]).length; | |
| const bc = back.filter(i => corners[i]).length; | |
| if (fc === 2 && bc >= 1) return "full"; | |
| if (fc === 1 && bc === 2) return S.lastKick === 4 ? "full" : "mini"; | |
| return null; | |
| } | |
| function lockPiece() { | |
| const c = S.cur; | |
| const kind = tspinKind(); | |
| const lockOut = STATES[c.type][c.rot].every(cc => c.y + cc.y < BUF); | |
| for (const cc of STATES[c.type][c.rot]) { | |
| const by = c.y + cc.y, bx = c.x + cc.x; | |
| if (by >= 0 && by < H && bx >= 0 && bx < COLS) S.board[by][bx] = c.type; | |
| } | |
| S.cur = null; | |
| Audio2.land(); | |
| const full = []; | |
| for (let y = 0; y < H; y++) if (S.board[y].every(v => v)) full.push(y); | |
| if (full.length) { | |
| const r = boardCanvas.getBoundingClientRect(); | |
| const cs = r.width / COLS; | |
| const cells = []; | |
| for (const y of full) for (let x = 0; x < COLS; x++) { | |
| cells.push({ x: r.left + x * cs, y: r.top + (y - BUF) * cs, s: cs, color: COLORS[S.board[y][x]] || "#fff" }); | |
| } | |
| const theme = THEMES[themeIndex]; | |
| theme.run(cells, full.length); | |
| S.pending = { rows: full, kind }; | |
| S.mode = "clear"; | |
| S.clearT = CLEAR_DELAY + full.length * 45; | |
| if (full.length >= 4) Audio2.tetris(); | |
| else if (kind) Audio2.tspin(); | |
| else Audio2.clear(full.length, S.b2b > 0); | |
| } else { | |
| if (kind) { S.tspins++; callout("T-SPIN"); } | |
| S.combo = 0; | |
| if (lockOut) { gameOver(); return; } | |
| spawnPiece(); | |
| } | |
| } | |
| function resolveClear() { | |
| const pending = S.pending; | |
| if (!pending) { S.mode = "play"; return; } | |
| const { rows, kind } = pending; | |
| S.pending = null; | |
| S.mode = "play"; | |
| S.clearT = 0; | |
| const n = rows.length; | |
| const keep = S.board.filter((row, y) => !rows.includes(y)); | |
| while (keep.length < H) keep.unshift(new Array(COLS).fill(null)); | |
| S.board = keep; | |
| let base = 0, label = ""; | |
| if (kind === "full") { base = [0, 800, 1200, 1600][n] || 0; label = ["T-SPIN", "T-SPIN SINGLE", "T-SPIN DOUBLE", "T-SPIN TRIPLE"][n]; } | |
| else if (kind === "mini") { base = [0, 200, 400, 600][n] || 0; label = ["T-SPIN MINI", "T-SPIN MINI SINGLE", "T-SPIN MINI DOUBLE", "T-SPIN MINI TRIPLE"][n]; } | |
| else { base = [0, 100, 300, 500, 800][n] || 0; label = ["", "SINGLE", "DOUBLE", "TRIPLE", "TETRIS"][n]; } | |
| if (kind) S.tspins++; | |
| if (n === 4) S.tetrises++; | |
| let pts = base * S.level; | |
| const difficult = n === 4 || !!kind; | |
| let b2bLabel = ""; | |
| if (difficult) { | |
| if (S.b2b > 0) { pts = Math.floor(pts * 1.5); S.b2bChain++; b2bLabel = "BACK-TO-BACK ×" + (S.b2bChain + 1); } | |
| else { S.b2b = 1; S.b2bChain = 0; } | |
| } else { S.b2b = 0; S.b2bChain = 0; } | |
| if (S.combo > 0) pts += 50 * S.combo * S.level; | |
| S.combo++; | |
| S.maxCombo = Math.max(S.maxCombo, S.combo - 1); | |
| const perfect = S.board.every(row => row.every(v => !v)); | |
| if (perfect) { | |
| S.pcs++; | |
| const pcBase = n === 4 ? (S.b2b > 0 ? 3200 : 2000) : [0, 800, 1200, 1800][n]; | |
| pts += pcBase * S.level; | |
| Audio2.pc(); | |
| flash(.5, "#ffffff"); shake(20); | |
| for (let i = 0; i < 120; i++) add({ kind: "star", x: rand(0, VW), y: rand(0, VH), vx: rand(-200, 200), vy: rand(-400, -100), grav: 400, drag: .97, ttl: rand(.8, 1.8), size: rand(6, 18), color: pick(RAINBOW), vrot: rand(-12, 12) }); | |
| label = "PERFECT CLEAR"; | |
| } | |
| S.score += pts; | |
| S.lines += n; | |
| let text = label; | |
| if (b2bLabel && !perfect) text += "\n" + b2bLabel; | |
| if (S.combo - 1 > 0) text += "\n" + (S.combo - 1) + " COMBO"; | |
| callout(text, n === 4 || perfect ? "big" : ""); | |
| if (S.combo - 1 > 0) Audio2.combo(S.combo - 1); | |
| const newLevel = Math.floor(S.lines / 10) + 1; | |
| if (newLevel > S.level) { | |
| S.level = newLevel; | |
| Music.setLevel(S.level); | |
| Audio2.levelUp(); | |
| callout("LEVEL " + S.level + "\nSPEED UP", "big"); | |
| flash(.22, THEMES[themeIndex].accent); | |
| } | |
| if (S.lines >= S.nextThemeAt) { | |
| themeIndex = (themeIndex + 1) % THEMES.length; | |
| S.nextThemeAt += 10; | |
| applyTheme(); | |
| Audio2.theme(); | |
| toast("EFFECT CHANGE → " + THEMES[themeIndex].name); | |
| flash(.3, THEMES[themeIndex].accent); | |
| shake(14); | |
| } | |
| spawnPiece(); | |
| } | |
| function applyTheme() { | |
| const t = THEMES[themeIndex]; | |
| document.documentElement.style.setProperty("--accent", t.accent); | |
| themeNameEl.textContent = t.name; | |
| } | |
| function callout(text, cls) { | |
| calloutEl.textContent = text; | |
| calloutEl.className = ""; | |
| void calloutEl.offsetWidth; | |
| calloutEl.className = "show " + (cls || ""); | |
| calloutEl.style.color = THEMES[themeIndex].accent; | |
| calloutEl.style.textShadow = `0 0 10px ${THEMES[themeIndex].accent},0 0 34px ${THEMES[themeIndex].accent}`; | |
| } | |
| let toastT = 0; | |
| function toast(text) { toastEl.textContent = text; toastEl.className = ""; void toastEl.offsetWidth; toastEl.className = "show"; } | |
| function gameOver() { | |
| S.mode = "over"; | |
| S.cur = null; | |
| S.overT = 0; | |
| Music.stop(); | |
| Audio2.over(); | |
| flash(.6, "#ff2d6f"); shake(26); | |
| for (let i = 0; i < 160; i++) { | |
| const r = boardCanvas.getBoundingClientRect(); | |
| add({ | |
| kind: "square", x: r.left + rand(0, r.width), y: r.top + rand(0, r.height), | |
| vx: rand(-400, 400), vy: rand(-700, 100), grav: 1100, drag: .98, ttl: rand(.8, 2), | |
| size: rand(6, 18), color: pick(RAINBOW), vrot: rand(-16, 16), sq: rand(.3, 1) | |
| }); | |
| } | |
| document.getElementById("rScore").textContent = S.score.toLocaleString(); | |
| document.getElementById("rLines").textContent = S.lines; | |
| document.getElementById("rLevel").textContent = S.level; | |
| document.getElementById("rTime").textContent = fmtTime(S.elapsed); | |
| document.getElementById("rPps").textContent = (S.elapsed > 0 ? S.pieces / (S.elapsed / 1000) : 0).toFixed(2); | |
| document.getElementById("rCombo").textContent = S.maxCombo; | |
| document.getElementById("result").style.display = "flex"; | |
| document.getElementById("ovTitle").textContent = "GAME OVER"; | |
| document.getElementById("ovText").innerHTML = `SCORE <b>${S.score.toLocaleString()}</b> / LINES ${S.lines} / LEVEL ${S.level}<br>PRESS ENTER OR TAP TO RETRY`; | |
| document.getElementById("ovGo").textContent = "PRESS ENTER / TAP TO RETRY"; | |
| setTimeout(() => { if (S.mode === "over") overlayEl.classList.remove("hide"); }, 900); | |
| } | |
| function resetGame() { | |
| S.board = emptyBoard(); | |
| S.hold = null; S.canHold = true; S.bag = []; S.queue = []; | |
| S.score = 0; S.lines = 0; S.level = 1; S.combo = 0; S.b2b = 0; S.b2bChain = 0; | |
| S.elapsed = 0; S.pieces = 0; S.tetrises = 0; S.tspins = 0; S.pcs = 0; S.maxCombo = 0; | |
| S.dropT = 0; S.lockT = 0; S.resets = 0; S.grounded = false; S.soft = false; | |
| S.pending = null; S.clearT = 0; S.danger = false; S.overT = 0; | |
| themeIndex = 0; S.nextThemeAt = 10; | |
| particles.length = 0; | |
| applyTheme(); | |
| fillQueue(); | |
| spawnPiece(); | |
| S.mode = "play"; | |
| Music.setLevel(1); | |
| } | |
| function startGame() { | |
| Audio2.init(); | |
| overlayEl.classList.add("hide"); | |
| document.getElementById("result").style.display = "none"; | |
| resetGame(); | |
| if (!Audio2.muted) Music.start(); | |
| } | |
| function togglePause() { | |
| if (S.mode === "play" || S.mode === "clear") { | |
| S.prevMode = S.mode; S.mode = "pause"; | |
| Audio2.tone({ freq: 300, to: 200, dur: .18, gain: .15, type: "triangle" }); | |
| document.getElementById("ovTitle").textContent = "PAUSED"; | |
| document.getElementById("ovText").innerHTML = "PRESS P / ESC OR TAP TO RESUME<br>PRESS R TO RESTART"; | |
| document.getElementById("ovGo").textContent = "TAP TO RESUME"; | |
| document.getElementById("result").style.display = "none"; | |
| overlayEl.classList.remove("hide"); | |
| } else if (S.mode === "pause") { | |
| S.mode = S.prevMode || "play"; | |
| overlayEl.classList.add("hide"); | |
| Audio2.tone({ freq: 240, to: 460, dur: .18, gain: .15, type: "triangle" }); | |
| } | |
| } | |
| function update(dt) { | |
| if (S.mode === "play") { | |
| S.elapsed += dt; | |
| handleAutoRepeat(dt); | |
| const g = Math.max(0.5, gravityMs(S.level)); | |
| S.dropT += dt * (S.soft ? 20 : 1); | |
| let steps = 0; | |
| while (S.dropT >= g && steps++ < 24) { S.dropT -= g; if (!stepDown(false)) { S.dropT = 0; break; } } | |
| if (!S.cur) { spawnPiece(); return; } | |
| if (!collides(S.cur.type, S.cur.rot, S.cur.x, S.cur.y + 1)) { | |
| S.grounded = false; | |
| } else if (S.grounded) { | |
| S.lockT += dt; | |
| if (S.lockT >= LOCK_DELAY) lockPiece(); | |
| } else { | |
| S.grounded = true; | |
| } | |
| } else if (S.mode === "clear") { | |
| S.elapsed += dt; | |
| S.clearT -= dt; | |
| if (S.clearT <= 0) resolveClear(); | |
| } else if (S.mode === "over") { | |
| S.overT += dt; | |
| } | |
| let top = H; | |
| for (let y = 0; y < H; y++) { if (S.board[y].some(v => v)) { top = y; break; } } | |
| S.danger = top <= BUF + 4; | |
| shakeAmt *= Math.pow(0.0025, dt); | |
| flashAmt *= Math.pow(0.0009, dt); | |
| if (shakeAmt < .2) shakeAmt = 0; | |
| if (flashAmt < .01) flashAmt = 0; | |
| updateParticles(dt); | |
| } | |
| function handleAutoRepeat(dt) { | |
| if (dasDir === 0) { dasT = 0; arrT = 0; return; } | |
| dasT += dt; | |
| if (dasT < DAS) return; | |
| arrT += dt; | |
| let guard = 0; | |
| while (arrT >= ARR && guard++ < 12) { arrT -= ARR; if (!move(dasDir)) break; } | |
| } | |
| function fmtTime(ms) { | |
| const s = Math.floor(ms / 1000); | |
| return Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0"); | |
| } | |
| function block(ctx, px, py, s, color, opt) { | |
| const o = opt || {}; | |
| ctx.save(); | |
| ctx.globalAlpha = o.alpha == null ? 1 : o.alpha; | |
| const g = ctx.createLinearGradient(px, py, px + s, py + s); | |
| g.addColorStop(0, shade(color, .38)); | |
| g.addColorStop(.5, color); | |
| g.addColorStop(1, shade(color, -.42)); | |
| roundRect(ctx, px + 1, py + 1, s - 2, s - 2, Math.max(2, s * .17)); | |
| ctx.fillStyle = g; | |
| if (o.glow) { ctx.shadowColor = color; ctx.shadowBlur = s * .8; } | |
| ctx.fill(); | |
| ctx.shadowBlur = 0; | |
| ctx.lineWidth = Math.max(1, s * .07); | |
| ctx.strokeStyle = shade(color, .55); | |
| ctx.stroke(); | |
| ctx.fillStyle = "rgba(255,255,255,.32)"; | |
| roundRect(ctx, px + s * .2, py + s * .17, s * .6, s * .16, s * .08); | |
| ctx.fill(); | |
| ctx.restore(); | |
| } | |
| function render() { | |
| const r = boardCanvas.getBoundingClientRect(); | |
| const cs = r.width / COLS; | |
| const w = r.width, h = r.height; | |
| bctx.clearRect(0, 0, w, h); | |
| bctx.save(); | |
| bctx.strokeStyle = "rgba(255,255,255,.055)"; | |
| bctx.lineWidth = 1; | |
| for (let x = 1; x < COLS; x++) { bctx.beginPath(); bctx.moveTo(x * cs, 0); bctx.lineTo(x * cs, h); bctx.stroke(); } | |
| for (let y = 1; y < ROWS; y++) { bctx.beginPath(); bctx.moveTo(0, y * cs); bctx.lineTo(w, y * cs); bctx.stroke(); } | |
| bctx.restore(); | |
| if (S.danger && S.mode !== "over") { | |
| bctx.save(); | |
| const a = .10 + .07 * Math.sin(performance.now() / 120); | |
| const g = bctx.createLinearGradient(0, 0, 0, h); | |
| g.addColorStop(0, `rgba(255,45,111,${a})`); | |
| g.addColorStop(.4, "rgba(255,45,111,0)"); | |
| bctx.fillStyle = g; bctx.fillRect(0, 0, w, h); | |
| bctx.restore(); | |
| } | |
| const overFade = S.mode === "over" ? clamp(S.overT / 700, 0, 1) : 0; | |
| for (let y = BUF; y < H; y++) { | |
| for (let x = 0; x < COLS; x++) { | |
| const t = S.board[y][x]; | |
| if (!t) continue; | |
| let col = COLORS[t], alpha = 1; | |
| if (overFade > 0) { | |
| const line = (y - BUF) / ROWS; | |
| const k = clamp((overFade - (1 - line) * .7) / .3, 0, 1); | |
| col = shade("#6b7392", -.2); | |
| alpha = 1 - k * .55; | |
| } | |
| block(bctx, x * cs, (y - BUF) * cs, cs, col, { alpha }); | |
| } | |
| } | |
| if (S.cur && (S.mode === "play" || S.mode === "pause")) { | |
| const c = S.cur; | |
| let gy = c.y; | |
| while (!collides(c.type, c.rot, c.x, gy + 1)) gy++; | |
| bctx.save(); | |
| bctx.globalAlpha = .3; | |
| for (const cc of STATES[c.type][c.rot]) { | |
| const by = gy + cc.y; | |
| if (by < BUF) continue; | |
| const px = (c.x + cc.x) * cs, py = (by - BUF) * cs; | |
| bctx.strokeStyle = COLORS[c.type]; | |
| bctx.lineWidth = Math.max(1.5, cs * .09); | |
| roundRect(bctx, px + 2, py + 2, cs - 4, cs - 4, cs * .16); | |
| bctx.stroke(); | |
| } | |
| bctx.restore(); | |
| const lockK = S.grounded ? clamp(S.lockT / LOCK_DELAY, 0, 1) : 0; | |
| for (const cc of STATES[c.type][c.rot]) { | |
| const by = c.y + cc.y; | |
| if (by < BUF) continue; | |
| const px = (c.x + cc.x) * cs, py = (by - BUF) * cs; | |
| block(bctx, px, py, cs, COLORS[c.type], { glow: true }); | |
| if (lockK > 0) { | |
| bctx.save(); | |
| bctx.globalAlpha = .5 * (1 - lockK); | |
| bctx.fillStyle = "#ffffff"; | |
| roundRect(bctx, px + 1, py + 1, cs - 2, cs - 2, cs * .16); | |
| bctx.fill(); | |
| bctx.restore(); | |
| } | |
| } | |
| } | |
| drawPreview(hctx, holdCanvas, [S.hold]); | |
| drawPreview(nctx, nextCanvas, S.queue.slice(0, 5)); | |
| fctx.clearRect(0, 0, VW, VH); | |
| drawParticles(); | |
| flashEl.style.background = flashColor; | |
| flashEl.style.opacity = flashAmt; | |
| if (shakeAmt > 0) { | |
| const dx = rand(-shakeAmt, shakeAmt), dy = rand(-shakeAmt, shakeAmt); | |
| shakeEl.style.transform = `translate(${dx.toFixed(2)}px,${dy.toFixed(2)}px)`; | |
| } else if (shakeEl.style.transform) { | |
| shakeEl.style.transform = ""; | |
| } | |
| updateHud(); | |
| } | |
| function drawPreview(ctx, canvas, types) { | |
| const r = canvas.getBoundingClientRect(); | |
| ctx.clearRect(0, 0, r.width, r.height); | |
| const slot = r.height / 5; | |
| types.forEach((t, i) => { | |
| if (!t) return; | |
| const cells = STATES[t][0]; | |
| const xs = cells.map(c => c.x), ys = cells.map(c => c.y); | |
| const mw = Math.max(...xs) - Math.min(...xs) + 1; | |
| const mh = Math.max(...ys) - Math.min(...ys) + 1; | |
| const s = Math.min(slot * .62, r.width / (mw + .6)); | |
| const ox = (r.width - mw * s) / 2 - Math.min(...xs) * s; | |
| const oy = i * slot + (slot - mh * s) / 2 - Math.min(...ys) * s; | |
| for (const c of cells) block(ctx, ox + c.x * s, oy + c.y * s, s, COLORS[t], { alpha: i === 0 ? 1 : .72, glow: i === 0 }); | |
| }); | |
| } | |
| const hud = {}; | |
| function setHud(id, v) { if (hud[id] !== v) { hud[id] = v; document.getElementById(id).textContent = v; } } | |
| function updateHud() { | |
| setHud("sScore", S.score.toLocaleString()); | |
| setHud("sLines", String(S.lines)); | |
| setHud("sLevel", String(S.level)); | |
| setHud("sTime", fmtTime(S.elapsed)); | |
| setHud("sCombo", S.combo > 1 ? String(S.combo - 1) : "0"); | |
| setHud("sB2b", S.b2b > 0 ? "×" + (S.b2bChain + 1) : "-"); | |
| setHud("sPps", (S.elapsed > 0 ? S.pieces / (S.elapsed / 1000) : 0).toFixed(2)); | |
| setHud("sPieces", String(S.pieces)); | |
| setHud("sTetris", String(S.tetrises)); | |
| setHud("sTspin", String(S.tspins)); | |
| setHud("sPc", String(S.pcs)); | |
| document.getElementById("rowCombo").classList.toggle("hot", S.combo > 2); | |
| document.getElementById("rowB2b").classList.toggle("hot", S.b2b > 0); | |
| } | |
| let dasDir = 0, dasT = 0, arrT = 0; | |
| const dirStack = []; | |
| const held = {}; | |
| function setDas(dir, down) { | |
| const i = dirStack.indexOf(dir); | |
| if (down) { | |
| if (i >= 0) dirStack.splice(i, 1); | |
| dirStack.push(dir); | |
| if (dasDir !== dir) { dasDir = dir; dasT = 0; arrT = 0; move(dir); } | |
| } else { | |
| if (i >= 0) dirStack.splice(i, 1); | |
| const nd = dirStack.length ? dirStack[dirStack.length - 1] : 0; | |
| if (nd !== dasDir) { dasDir = nd; dasT = 0; arrT = 0; } | |
| } | |
| } | |
| const KEYMAP = { | |
| ArrowLeft: "left", ArrowRight: "right", ArrowDown: "down", ArrowUp: "cw", | |
| KeyX: "cw", KeyZ: "ccw", ControlLeft: "ccw", ControlRight: "ccw", | |
| KeyA: "flip", Space: "drop", ShiftLeft: "hold", ShiftRight: "hold", KeyC: "hold", | |
| KeyR: "restart", KeyP: "pause", Escape: "pause", KeyM: "mute", Enter: "start" | |
| }; | |
| function action(a) { | |
| if (a === "start") { if (S.mode === "title" || S.mode === "over") startGame(); else if (S.mode === "pause") togglePause(); return; } | |
| if (a === "restart") { startGame(); return; } | |
| if (a === "pause") { togglePause(); return; } | |
| if (a === "mute") { | |
| Audio2.muted = !Audio2.muted; | |
| if (Audio2.ready) Audio2.master.gain.value = Audio2.muted ? 0 : .85; | |
| if (Audio2.muted) Music.stop(); else if (S.mode === "play" || S.mode === "clear") Music.start(); | |
| toast(Audio2.muted ? "MUTED" : "SOUND ON"); | |
| return; | |
| } | |
| if (S.mode !== "play") return; | |
| switch (a) { | |
| case "left": setDas(-1, true); break; | |
| case "right": setDas(1, true); break; | |
| case "down": S.soft = true; break; | |
| case "cw": rotate(1); break; | |
| case "ccw": rotate(-1); break; | |
| case "flip": rotate(2); break; | |
| case "drop": hardDrop(); break; | |
| case "hold": doHold(); break; | |
| } | |
| } | |
| function releaseAction(a) { | |
| if (a === "left") setDas(-1, false); | |
| else if (a === "right") setDas(1, false); | |
| else if (a === "down") S.soft = false; | |
| } | |
| window.addEventListener("keydown", e => { | |
| const a = KEYMAP[e.code]; | |
| if (!a) return; | |
| e.preventDefault(); | |
| if (e.repeat) return; | |
| if (held[e.code]) return; | |
| held[e.code] = true; | |
| action(a); | |
| }); | |
| window.addEventListener("keyup", e => { | |
| const a = KEYMAP[e.code]; | |
| if (!a) return; | |
| held[e.code] = false; | |
| releaseAction(a); | |
| }); | |
| function releaseAll() { | |
| for (const k in held) held[k] = false; | |
| S.soft = false; | |
| dirStack.length = 0; dasDir = 0; dasT = 0; arrT = 0; | |
| } | |
| window.addEventListener("blur", releaseAll); | |
| window.addEventListener("load", resizeAll); | |
| document.querySelectorAll("#touch button").forEach(btn => { | |
| const a = btn.dataset.act; | |
| const on = e => { e.preventDefault(); action(a); }; | |
| const off = e => { e.preventDefault(); releaseAction(a); }; | |
| btn.addEventListener("pointerdown", on); | |
| btn.addEventListener("pointerup", off); | |
| btn.addEventListener("pointerleave", off); | |
| btn.addEventListener("pointercancel", off); | |
| }); | |
| overlayEl.addEventListener("pointerdown", () => { | |
| if (S.mode === "title" || S.mode === "over") startGame(); | |
| else if (S.mode === "pause") togglePause(); | |
| }); | |
| document.getElementById("ovGo").addEventListener("pointerdown", e => { | |
| e.stopPropagation(); | |
| if (S.mode === "title" || S.mode === "over") startGame(); | |
| else togglePause(); | |
| }); | |
| let last = performance.now(); | |
| function loop(now) { | |
| const dt = Math.min(100, now - last); | |
| last = now; | |
| update(dt); | |
| render(); | |
| requestAnimationFrame(loop); | |
| } | |
| S.board = emptyBoard(); | |
| fillQueue(); | |
| applyTheme(); | |
| resizeAll(); | |
| requestAnimationFrame(loop); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment