Created
September 12, 2026 16:34
-
-
Save kumagi/74e4d5251b40c646ce17c7412301f479 to your computer and use it in GitHub Desktop.
Grok4.6 Extra High, worked for 29m 41s
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 BLAST — 爆裂テトリス</title> | |
| <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect fill='%236cf0ff' x='4' y='12' width='24' height='8'/%3E%3C/svg%3E" /> | |
| <link rel="preconnect" href="https://fonts.googleapis.com" /> | |
| <link rel="preconnect" href="https://cdnjs.cloudflare.com" /> | |
| <link rel="preconnect" href="https://cdn.jsdelivr.net" /> | |
| <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;900&family=Rajdhani:wght@500;600;700&display=swap" rel="stylesheet" /> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r134/three.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js"></script> | |
| <style> | |
| :root { | |
| --bg: #05030c; | |
| --panel: rgba(8, 10, 24, 0.72); | |
| --line: rgba(255, 255, 255, 0.12); | |
| --text: #f4f7ff; | |
| --muted: #9aa3c7; | |
| --accent: #6cf0ff; | |
| --accent2: #ff4de3; | |
| --warn: #ffd166; | |
| --cell: 32px; | |
| --tier: #ffd166; | |
| } | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| html, body { | |
| height: 100%; | |
| background: var(--bg); | |
| color: var(--text); | |
| font-family: Rajdhani, "Segoe UI", sans-serif; | |
| overflow: hidden; | |
| user-select: none; | |
| touch-action: none; | |
| } | |
| body.tier-0 { --tier: #ffd166; --accent: #ffb020; --accent2: #ff6b35; } | |
| body.tier-1 { --tier: #6cf0ff; --accent: #5ce1ff; --accent2: #ff4de3; } | |
| body.tier-2 { --tier: #ff7a18; --accent: #ffb347; --accent2: #ff3b3b; } | |
| body.tier-3 { --tier: #39ff14; --accent: #d0ff3d; --accent2: #ff00aa; } | |
| body.tier-4 { --tier: #f4fbff; --accent: #7df0ff; --accent2: #4d7cff; } | |
| body.tier-5 { --tier: #b388ff; --accent: #7a5cff; --accent2: #66e0ff; } | |
| body.tier-6 { --tier: #9bfff5; --accent: #64f0c8; --accent2: #c77dff; } | |
| body.tier-7 { --tier: #ff3d81; --accent: #ff2bd6; --accent2: #3d0a2a; } | |
| body.tier-8 { --tier: #ff6b6b; --accent: #ffe066; --accent2: #74c0fc; } | |
| body.tier-9 { --tier: #ffffff; --accent: #ff4de3; --accent2: #6cf0ff; } | |
| #bg { | |
| position: fixed; inset: 0; z-index: 0; | |
| background: | |
| radial-gradient(1200px 700px at 50% -10%, color-mix(in srgb, var(--accent) 22%, transparent), transparent 55%), | |
| radial-gradient(900px 600px at 110% 80%, color-mix(in srgb, var(--accent2) 18%, transparent), transparent 50%), | |
| radial-gradient(800px 500px at -10% 70%, color-mix(in srgb, var(--tier) 12%, transparent), transparent 50%), | |
| linear-gradient(180deg, #070510 0%, #05030c 40%, #0a0618 100%); | |
| transition: background 0.6s ease; | |
| } | |
| #bg::after { | |
| content: ""; | |
| position: absolute; inset: 0; | |
| background-image: | |
| linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px), | |
| linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px); | |
| background-size: 48px 48px; | |
| mask-image: radial-gradient(circle at 50% 40%, #000 20%, transparent 75%); | |
| animation: gridDrift 18s linear infinite; | |
| pointer-events: none; | |
| } | |
| @keyframes gridDrift { to { background-position: 48px 48px; } } | |
| #fx2d, #fx3d { | |
| position: fixed; inset: 0; z-index: 8; | |
| pointer-events: none; | |
| } | |
| #fx2d { z-index: 9; mix-blend-mode: screen; } | |
| #fx3d { z-index: 8; } | |
| #flash, #chroma, #scan { | |
| position: fixed; inset: 0; pointer-events: none; z-index: 12; | |
| } | |
| #flash { background: #fff; opacity: 0; mix-blend-mode: overlay; } | |
| #chroma { opacity: 0; } | |
| #scan { | |
| z-index: 40; | |
| background: repeating-linear-gradient( | |
| to bottom, | |
| rgba(0,0,0,0.08) 0px, | |
| rgba(0,0,0,0.08) 1px, | |
| transparent 2px, | |
| transparent 4px | |
| ); | |
| opacity: 0.25; | |
| animation: scan 8s linear infinite; | |
| } | |
| @keyframes scan { to { background-position: 0 24px; } } | |
| #app { | |
| position: relative; z-index: 5; | |
| height: 100%; | |
| display: flex; flex-direction: column; align-items: center; justify-content: center; | |
| padding: 12px 12px 84px; | |
| } | |
| #stage { | |
| display: flex; flex-direction: column; align-items: center; gap: 10px; | |
| will-change: transform, filter; | |
| } | |
| header#top { | |
| text-align: center; letter-spacing: 0.22em; | |
| } | |
| header#top h1 { | |
| font-family: Orbitron, sans-serif; | |
| font-weight: 900; | |
| font-size: clamp(18px, 3.2vw, 34px); | |
| background: linear-gradient(90deg, var(--accent), #fff, var(--accent2)); | |
| background-size: 200% 100%; | |
| -webkit-background-clip: text; background-clip: text; color: transparent; | |
| animation: shine 6s linear infinite; | |
| filter: drop-shadow(0 0 12px color-mix(in srgb, var(--accent) 50%, transparent)); | |
| } | |
| header#top p { | |
| font-size: 12px; color: var(--muted); letter-spacing: 0.42em; margin-top: 2px; | |
| } | |
| @keyframes shine { to { background-position: 200% 0; } } | |
| #layout { | |
| display: flex; align-items: stretch; justify-content: center; gap: 14px; | |
| } | |
| .side { | |
| width: 148px; | |
| display: flex; flex-direction: column; gap: 10px; | |
| } | |
| .panel { | |
| background: var(--panel); | |
| border: 1px solid var(--line); | |
| border-radius: 14px; | |
| padding: 10px 12px; | |
| backdrop-filter: blur(12px); | |
| box-shadow: 0 0 0 1px rgba(255,255,255,0.03), 0 12px 40px rgba(0,0,0,0.35); | |
| } | |
| .panel h3 { | |
| font-family: Orbitron, sans-serif; | |
| font-size: 10px; letter-spacing: 0.28em; color: var(--muted); | |
| margin-bottom: 6px; | |
| } | |
| #hold, #next { display: block; margin: 0 auto; } | |
| .stat { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; } | |
| .stat span { color: var(--muted); font-size: 12px; letter-spacing: 0.16em; } | |
| .stat b, #score { | |
| font-family: Orbitron, sans-serif; font-size: 18px; color: #fff; | |
| display: inline-block; | |
| } | |
| #score { font-size: 20px; } | |
| #fxPanel { overflow: hidden; position: relative; } | |
| #fxPanel::before { | |
| content: ""; position: absolute; inset: 0; | |
| background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 18%, transparent), transparent 55%); | |
| pointer-events: none; | |
| } | |
| #fxName { | |
| font-family: Orbitron, sans-serif; font-size: 13px; font-weight: 700; | |
| color: var(--tier); letter-spacing: 0.08em; position: relative; | |
| text-shadow: 0 0 12px color-mix(in srgb, var(--tier) 70%, transparent); | |
| } | |
| #fxJp { font-size: 12px; color: var(--muted); position: relative; } | |
| .meter { | |
| height: 6px; background: rgba(255,255,255,0.08); border-radius: 99px; margin: 8px 0 4px; | |
| overflow: hidden; position: relative; | |
| } | |
| #fxMeter { | |
| height: 100%; width: 0%; | |
| background: linear-gradient(90deg, var(--accent), var(--accent2)); | |
| box-shadow: 0 0 12px var(--accent); | |
| transition: width 0.25s ease; | |
| } | |
| #fxHint { font-size: 11px; color: var(--muted); letter-spacing: 0.08em; position: relative; } | |
| #field-wrap { | |
| position: relative; | |
| padding: 10px; | |
| border-radius: 18px; | |
| background: rgba(0,0,0,0.45); | |
| border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent); | |
| box-shadow: | |
| 0 0 0 1px rgba(255,255,255,0.05), | |
| 0 0 40px color-mix(in srgb, var(--accent) 25%, transparent), | |
| inset 0 0 40px rgba(0,0,0,0.5); | |
| } | |
| #field-wrap.danger { | |
| border-color: #ff4060; | |
| box-shadow: 0 0 40px rgba(255, 40, 80, 0.45), inset 0 0 40px rgba(80,0,20,0.4); | |
| } | |
| #field { display: block; border-radius: 8px; background: #07060f; } | |
| #pop { | |
| position: absolute; inset: 0; display: flex; flex-direction: column; | |
| align-items: center; justify-content: center; pointer-events: none; | |
| font-family: Orbitron, sans-serif; text-align: center; | |
| } | |
| #pop .line { | |
| font-size: clamp(22px, 4vw, 42px); font-weight: 900; | |
| letter-spacing: 0.08em; opacity: 0; transform: scale(0.6); | |
| text-shadow: 0 0 24px currentColor, 0 4px 0 rgba(0,0,0,0.4); | |
| } | |
| #b2b { | |
| position: absolute; top: 16px; left: 50%; transform: translateX(-50%); | |
| font-family: Orbitron, sans-serif; font-size: 12px; letter-spacing: 0.28em; | |
| color: var(--warn); opacity: 0; text-shadow: 0 0 12px var(--warn); | |
| } | |
| .overlay { | |
| position: fixed; inset: 0; z-index: 30; | |
| display: flex; align-items: center; justify-content: center; | |
| background: rgba(4, 2, 12, 0.72); | |
| backdrop-filter: blur(10px); | |
| } | |
| .overlay.hidden { display: none; } | |
| .card { | |
| width: min(520px, 92vw); | |
| background: linear-gradient(180deg, rgba(20,16,40,0.92), rgba(8,6,20,0.92)); | |
| border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); | |
| border-radius: 20px; | |
| padding: 28px 28px 22px; | |
| box-shadow: 0 20px 80px rgba(0,0,0,0.5), 0 0 50px color-mix(in srgb, var(--accent) 20%, transparent); | |
| text-align: center; | |
| } | |
| .card h2 { | |
| font-family: Orbitron, sans-serif; font-weight: 900; font-size: 28px; letter-spacing: 0.18em; | |
| background: linear-gradient(90deg, var(--accent), #fff, var(--accent2)); | |
| -webkit-background-clip: text; background-clip: text; color: transparent; | |
| } | |
| .card .sub { color: var(--muted); margin: 6px 0 18px; letter-spacing: 0.22em; font-size: 13px; } | |
| .keys { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 16px; text-align: left; margin: 12px 0 18px; font-size: 14px; color: #d5dbf5; } | |
| .keys b { | |
| font-family: Orbitron, sans-serif; font-size: 11px; color: var(--accent); | |
| letter-spacing: 0.08em; margin-right: 6px; | |
| } | |
| .btn { | |
| cursor: pointer; border: 0; border-radius: 999px; | |
| padding: 12px 28px; font-family: Orbitron, sans-serif; font-weight: 700; | |
| letter-spacing: 0.18em; color: #05030c; | |
| background: linear-gradient(90deg, var(--accent), var(--accent2)); | |
| box-shadow: 0 0 24px color-mix(in srgb, var(--accent) 50%, transparent); | |
| } | |
| .btn.ghost { | |
| margin-top: 10px; background: transparent; color: var(--text); | |
| border: 1px solid var(--line); box-shadow: none; | |
| } | |
| #overStats { margin: 12px 0 16px; font-size: 16px; color: #dce3ff; } | |
| #overStats div { display: flex; justify-content: space-between; padding: 4px 12px; } | |
| #dock { | |
| position: fixed; right: 16px; top: 16px; z-index: 35; | |
| display: flex; gap: 8px; | |
| } | |
| .iconbtn { | |
| width: 40px; height: 40px; border-radius: 12px; border: 1px solid var(--line); | |
| background: var(--panel); color: var(--text); cursor: pointer; | |
| font-family: Orbitron, sans-serif; font-size: 12px; | |
| } | |
| #touch { | |
| display: none; position: fixed; left: 0; right: 0; bottom: 8px; z-index: 28; | |
| padding: 0 8px; gap: 8px; | |
| grid-template-columns: 1fr 1.1fr 1fr; | |
| } | |
| #touch .col { display: grid; gap: 8px; } | |
| #touch button { | |
| height: 48px; border-radius: 12px; border: 1px solid var(--line); | |
| background: rgba(10,12,28,0.7); color: #fff; font-family: Orbitron, sans-serif; | |
| font-size: 12px; letter-spacing: 0.08em; | |
| } | |
| @media (pointer: coarse), (max-width: 820px) { | |
| #touch { display: grid; } | |
| .side { width: 108px; } | |
| .keys { grid-template-columns: 1fr; } | |
| #app { padding-bottom: 170px; } | |
| } | |
| #comboFloat { | |
| position: absolute; right: 14px; top: 28%; | |
| font-family: Orbitron, sans-serif; font-weight: 900; font-size: 16px; | |
| color: var(--accent2); opacity: 0; | |
| text-shadow: 0 0 16px var(--accent2); | |
| } | |
| </style> | |
| </head> | |
| <body class="tier-0"> | |
| <div id="bg"></div> | |
| <canvas id="fx3d"></canvas> | |
| <canvas id="fx2d"></canvas> | |
| <div id="flash"></div> | |
| <div id="chroma"></div> | |
| <div id="scan"></div> | |
| <div id="dock"> | |
| <button class="iconbtn" id="muteBtn" title="ミュート">BGM</button> | |
| <button class="iconbtn" id="pauseBtn" title="ポーズ">II</button> | |
| </div> | |
| <div id="app"> | |
| <div id="stage"> | |
| <header id="top"> | |
| <h1>HYPER BLAST</h1> | |
| <p>GUIDELINE TETRIS</p> | |
| </header> | |
| <div id="layout"> | |
| <aside class="side"> | |
| <div class="panel"> | |
| <h3>HOLD</h3> | |
| <canvas id="hold" width="120" height="80"></canvas> | |
| </div> | |
| <div class="panel"> | |
| <div class="stat"><span>SCORE</span><b id="score">0</b></div> | |
| <div class="stat"><span>LINES</span><b id="lines">0</b></div> | |
| <div class="stat"><span>LEVEL</span><b id="level">1</b></div> | |
| <div class="stat"><span>BEST</span><b id="best">0</b></div> | |
| </div> | |
| <div class="panel" id="fxPanel"> | |
| <h3>EFFECT</h3> | |
| <div id="fxName">SPARK BURST</div> | |
| <div id="fxJp">スパークバースト</div> | |
| <div class="meter"><div id="fxMeter"></div></div> | |
| <div id="fxHint">NEXT IN <span id="fxNext">10</span> LINES</div> | |
| </div> | |
| </aside> | |
| <div id="field-wrap"> | |
| <canvas id="field"></canvas> | |
| <div id="b2b">BACK-TO-BACK</div> | |
| <div id="pop"></div> | |
| <div id="comboFloat"></div> | |
| </div> | |
| <aside class="side"> | |
| <div class="panel"> | |
| <h3>NEXT</h3> | |
| <canvas id="next" width="120" height="320"></canvas> | |
| </div> | |
| </aside> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="title" class="overlay"> | |
| <div class="card"> | |
| <h2>HYPER BLAST</h2> | |
| <div class="sub">爆裂テトリス · TETRIS GUIDELINE</div> | |
| <p style="color:#c9d0f0;margin-bottom:8px;line-height:1.5">ラインを消すたびに画面が爆発する。10ラインごとにエフェクトが進化する。</p> | |
| <div class="keys"> | |
| <div><b>← →</b> 移動</div><div><b>↓</b> ソフトドロップ</div> | |
| <div><b>SPACE</b> ハードドロップ</div><div><b>↑ / X</b> 右回転</div> | |
| <div><b>Z / CTRL</b> 左回転</div><div><b>A</b> 180°回転</div> | |
| <div><b>C / SHIFT</b> ホールド</div><div><b>ESC / P</b> ポーズ</div> | |
| </div> | |
| <button class="btn" id="startBtn">START</button> | |
| </div> | |
| </div> | |
| <div id="pause" class="overlay hidden"> | |
| <div class="card"> | |
| <h2>PAUSED</h2> | |
| <div class="sub">一時停止</div> | |
| <button class="btn" id="resumeBtn">RESUME</button> | |
| <br /> | |
| <button class="btn ghost" id="retryPause">RESTART</button> | |
| </div> | |
| </div> | |
| <div id="over" class="overlay hidden"> | |
| <div class="card"> | |
| <h2>TOP OUT</h2> | |
| <div class="sub">GAME OVER</div> | |
| <div id="overStats"></div> | |
| <button class="btn" id="retryBtn">AGAIN</button> | |
| </div> | |
| </div> | |
| <div id="touch"> | |
| <div class="col"> | |
| <button data-act="hold">HOLD</button> | |
| <button data-act="ccw">Z 左回転</button> | |
| <button data-act="left">←</button> | |
| </div> | |
| <div class="col"> | |
| <button data-act="hard">HARD DROP</button> | |
| <button data-act="soft">SOFT ↓</button> | |
| </div> | |
| <div class="col"> | |
| <button data-act="cw">X 右回転</button> | |
| <button data-act="180">180</button> | |
| <button data-act="right">→</button> | |
| </div> | |
| </div> | |
| <script> | |
| (function () { | |
| "use strict"; | |
| const COLS = 10, VISIBLE = 20, BUFFER = 20, ROWS = 40; | |
| const NEXT_N = 5, DAS = 0.167, ARR = 0.033, LOCK = 0.5, LOCK_MAX = 15; | |
| const ARE = 0.08, CLEAR_TIME = 0.38; | |
| const SPAWN_X = 3, SPAWN_Y = 18; | |
| const COLORS = { | |
| I: "#41e3f0", J: "#3b6bff", L: "#ff9a32", O: "#ffe14a", | |
| S: "#3dff7a", Z: "#ff3b5c", T: "#d45cff" | |
| }; | |
| const TYPES = ["I", "J", "L", "O", "S", "T", "Z"]; | |
| const SHAPES = { | |
| I: [ | |
| [[0,1],[1,1],[2,1],[3,1]], | |
| [[2,0],[2,1],[2,2],[2,3]], | |
| [[0,2],[1,2],[2,2],[3,2]], | |
| [[1,0],[1,1],[1,2],[1,3]] | |
| ], | |
| J: [ | |
| [[0,0],[0,1],[1,1],[2,1]], | |
| [[1,0],[2,0],[1,1],[1,2]], | |
| [[0,1],[1,1],[2,1],[2,2]], | |
| [[1,0],[1,1],[0,2],[1,2]] | |
| ], | |
| L: [ | |
| [[2,0],[0,1],[1,1],[2,1]], | |
| [[1,0],[1,1],[1,2],[2,2]], | |
| [[0,1],[1,1],[2,1],[0,2]], | |
| [[0,0],[1,0],[1,1],[1,2]] | |
| ], | |
| O: [ | |
| [[1,0],[2,0],[1,1],[2,1]], | |
| [[1,0],[2,0],[1,1],[2,1]], | |
| [[1,0],[2,0],[1,1],[2,1]], | |
| [[1,0],[2,0],[1,1],[2,1]] | |
| ], | |
| S: [ | |
| [[1,0],[2,0],[0,1],[1,1]], | |
| [[1,0],[1,1],[2,1],[2,2]], | |
| [[1,1],[2,1],[0,2],[1,2]], | |
| [[0,0],[0,1],[1,1],[1,2]] | |
| ], | |
| Z: [ | |
| [[0,0],[1,0],[1,1],[2,1]], | |
| [[2,0],[1,1],[2,1],[1,2]], | |
| [[0,1],[1,1],[1,2],[2,2]], | |
| [[1,0],[0,1],[1,1],[0,2]] | |
| ], | |
| T: [ | |
| [[1,0],[0,1],[1,1],[2,1]], | |
| [[1,0],[1,1],[2,1],[1,2]], | |
| [[0,1],[1,1],[2,1],[1,2]], | |
| [[1,0],[0,1],[1,1],[1,2]] | |
| ] | |
| }; | |
| // Kick tables: (x, y) with Y positive UP (Guideline / Tetris Wiki) | |
| const KICKS_JLSTZ = { | |
| "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 KICKS_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 KICKS_180 = { | |
| "0-2": [[0,0],[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1]], | |
| "2-0": [[0,0],[-1,0],[1,0],[0,-1],[0,1],[-1,-1],[1,-1]], | |
| "1-3": [[0,0],[0,1],[0,-1],[1,0],[-1,0],[1,1],[-1,1]], | |
| "3-1": [[0,0],[0,-1],[0,1],[-1,0],[1,0],[-1,-1],[1,-1]] | |
| }; | |
| const KICKS_I_180 = { | |
| "0-2": [[0,0],[0,1],[0,-1],[-1,0],[1,0],[-2,0],[2,0]], | |
| "2-0": [[0,0],[0,-1],[0,1],[1,0],[-1,0],[2,0],[-2,0]], | |
| "1-3": [[0,0],[1,0],[-1,0],[0,1],[0,-1],[2,0],[-2,0]], | |
| "3-1": [[0,0],[-1,0],[1,0],[0,-1],[0,1],[-2,0],[2,0]] | |
| }; | |
| const TIERS = [ | |
| { id: "spark", name: "SPARK BURST", jp: "スパークバースト" }, | |
| { id: "neon", name: "NEON SHOCK", jp: "ネオンショック" }, | |
| { id: "fire", name: "FIRE STORM", jp: "ファイアストーム" }, | |
| { id: "glitch", name: "GLITCH BREAK", jp: "グリッチブレイク" }, | |
| { id: "thunder", name: "THUNDERBOLT", jp: "サンダーボルト" }, | |
| { id: "galaxy", name: "GALAXY CORE", jp: "ギャラクシーコア" }, | |
| { id: "crystal", name: "CRYSTAL SHARD", jp: "クリスタル" }, | |
| { id: "void", name: "VOID COLLAPSE", jp: "ヴォイドコラプス" }, | |
| { id: "rainbow", name: "RAINBOW TSUNAMI",jp: "レインボー津波" }, | |
| { id: "over", name: "OVERDRIVE", jp: "オーバードライブ" } | |
| ]; | |
| const $ = (id) => document.getElementById(id); | |
| const field = $("field"), holdC = $("hold"), nextC = $("next"), fx2d = $("fx2d"); | |
| const ctx = field.getContext("2d"); | |
| const hctx = holdC.getContext("2d"); | |
| const nctx = nextC.getContext("2d"); | |
| const fctx = fx2d.getContext("2d"); | |
| const keys = Object.create(null); | |
| const G = { | |
| mode: "title", | |
| board: newBoard(), | |
| piece: null, | |
| hold: null, | |
| canHold: true, | |
| queue: [], | |
| score: 0, lines: 0, level: 1, | |
| combo: -1, b2b: false, | |
| fall: 0, lockT: 0, lockResets: 0, lowestY: 0, | |
| lastRotate: false, lastKick: 0, | |
| dasDir: 0, dasT: 0, arrT: 0, | |
| clearT: 0, clearRows: [], areT: 0, | |
| timeScale: 1, | |
| high: Number(localStorage.getItem("hb_best") || 0) | |
| }; | |
| $("best").textContent = String(G.high); | |
| function emptyRow() { return Array(COLS).fill(0); } | |
| function newBoard() { return Array.from({ length: ROWS }, emptyRow); } | |
| function shuffle(a) { | |
| for (let i = a.length - 1; i > 0; i--) { | |
| const j = (Math.random() * (i + 1)) | 0; | |
| [a[i], a[j]] = [a[j], a[i]]; | |
| } | |
| return a; | |
| } | |
| function fillBag() { G.queue.push(...shuffle(TYPES.slice())); } | |
| function ensureQ() { while (G.queue.length < 14) fillBag(); } | |
| function cellsOf(p) { return SHAPES[p.type][p.r].map(([x, y]) => [p.x + x, p.y + y]); } | |
| function occupied(x, y) { | |
| if (x < 0 || x >= COLS || y >= ROWS) return true; | |
| if (y < 0) return false; | |
| return G.board[y][x] !== 0; | |
| } | |
| function valid(p) { return cellsOf(p).every(([x, y]) => !occupied(x, y)); } | |
| function clone(p) { return { type: p.type, x: p.x, y: p.y, r: p.r }; } | |
| function gravitySec(level) { | |
| const t = Math.pow(0.8 - (level - 1) * 0.007, level - 1); | |
| return Math.max(t, 1 / 1200); | |
| } | |
| function tierIndex() { return Math.min(9, (G.lines / 10) | 0); } | |
| function tierOf() { return TIERS[tierIndex()]; } | |
| function addScore(n) { | |
| G.score += n; | |
| $("score").textContent = String(G.score); | |
| if (typeof gsap !== "undefined") { | |
| gsap.fromTo("#score", { scale: 1.12 }, { scale: 1, duration: 0.2, ease: "back.out(3)" }); | |
| } | |
| } | |
| function showPop(text, color, sub) { | |
| const pop = $("pop"); | |
| pop.innerHTML = ""; | |
| const el = document.createElement("div"); | |
| el.className = "line"; | |
| el.style.color = color || "#fff"; | |
| el.textContent = text; | |
| pop.appendChild(el); | |
| if (sub) { | |
| const s = document.createElement("div"); | |
| s.className = "line"; | |
| s.style.fontSize = "16px"; | |
| s.style.color = "#ffe08a"; | |
| s.textContent = sub; | |
| pop.appendChild(s); | |
| } | |
| if (typeof gsap !== "undefined") { | |
| gsap.fromTo(el, { opacity: 0, scale: 0.5, y: 16 }, { opacity: 1, scale: 1, y: 0, duration: 0.18, ease: "back.out(3)" }); | |
| gsap.to(el, { opacity: 0, y: -28, scale: 1.08, duration: 0.45, delay: 0.45, ease: "power2.in" }); | |
| } else { | |
| el.style.opacity = "1"; | |
| setTimeout(() => { el.style.opacity = "0"; }, 700); | |
| } | |
| } | |
| function updateHud() { | |
| $("lines").textContent = String(G.lines); | |
| $("level").textContent = String(G.level); | |
| const t = tierOf(); | |
| $("fxName").textContent = t.name; | |
| $("fxJp").textContent = t.jp; | |
| const into = G.lines % 10; | |
| $("fxMeter").style.width = (into / 10 * 100) + "%"; | |
| $("fxNext").textContent = String(10 - into); | |
| document.body.className = "tier-" + tierIndex(); | |
| } | |
| function spawn(type) { | |
| ensureQ(); | |
| const p = { type: type || G.queue.shift(), x: SPAWN_X, y: SPAWN_Y, r: 0 }; | |
| if (keys.hold && G.canHold) { | |
| keys.hold = false; | |
| doHold(p); | |
| return; | |
| } | |
| if (keys.rotCW) tryRotate(p, 1); | |
| else if (keys.rotCCW) tryRotate(p, -1); | |
| else if (keys.rot180) tryRotate(p, 2); | |
| if (!valid(p)) { gameOver(); return; } | |
| const down = clone(p); down.y++; | |
| if (valid(down)) p.y++; | |
| G.piece = p; | |
| G.canHold = true; | |
| G.fall = 0; | |
| G.lockT = 0; | |
| G.lockResets = 0; | |
| G.lowestY = p.y; | |
| G.lastRotate = false; | |
| G.lastKick = 0; | |
| drawPreviews(); | |
| if (G.dasDir && G.dasT >= DAS) tryMove(G.dasDir, 0); | |
| } | |
| function doHold(from) { | |
| if (!G.canHold) return; | |
| const cur = from || G.piece; | |
| if (!cur) return; | |
| G.canHold = false; | |
| const swap = G.hold; | |
| G.hold = cur.type; | |
| G.piece = null; | |
| spawn(swap || undefined); | |
| G.canHold = false; | |
| SFX.hold(); | |
| drawPreviews(); | |
| } | |
| function tryMove(dx, dy) { | |
| if (!G.piece || G.mode !== "playing") return false; | |
| const n = clone(G.piece); | |
| n.x += dx; n.y += dy; | |
| if (!valid(n)) return false; | |
| G.piece.x = n.x; G.piece.y = n.y; | |
| G.lastRotate = false; | |
| onShiftOrRotate(); | |
| if (dy > 0 && G.piece.y > G.lowestY) { | |
| G.lowestY = G.piece.y; | |
| G.lockResets = 0; | |
| } | |
| return true; | |
| } | |
| function onShiftOrRotate() { | |
| if (!G.piece) return; | |
| const grounded = !canDown(); | |
| if (grounded) { | |
| if (G.lockResets < LOCK_MAX) { | |
| G.lockT = 0; | |
| G.lockResets++; | |
| } | |
| } else G.lockT = 0; | |
| } | |
| function canDown() { | |
| const n = clone(G.piece); n.y++; | |
| return valid(n); | |
| } | |
| function tryRotate(p, dir) { | |
| const src = p || G.piece; | |
| if (!src) return false; | |
| if (src.type === "O" && dir !== 0) { | |
| src.r = (src.r + dir + 4) % 4; | |
| if (p === G.piece) { G.lastRotate = true; G.lastKick = 0; onShiftOrRotate(); } | |
| return true; | |
| } | |
| const from = src.r; | |
| const to = (from + dir + 4) % 4; | |
| let table; | |
| if (dir === 2 || dir === -2) { | |
| table = (src.type === "I" ? KICKS_I_180 : KICKS_180)[from + "-" + to] || [[0, 0]]; | |
| } else { | |
| table = (src.type === "I" ? KICKS_I : KICKS_JLSTZ)[from + "-" + to] || [[0, 0]]; | |
| } | |
| for (let i = 0; i < table.length; i++) { | |
| const [kx, ky] = table[i]; | |
| const n = { type: src.type, x: src.x + kx, y: src.y - ky, r: to }; | |
| if (valid(n)) { | |
| src.x = n.x; src.y = n.y; src.r = to; | |
| if (p === G.piece || !p) { | |
| G.lastRotate = true; | |
| G.lastKick = i; | |
| onShiftOrRotate(); | |
| if (G.piece && G.piece.y > G.lowestY) { | |
| G.lowestY = G.piece.y; | |
| G.lockResets = 0; | |
| } | |
| } | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| function tSpinInfo() { | |
| const p = G.piece; | |
| if (!p || p.type !== "T" || !G.lastRotate) return { spin: false, mini: false }; | |
| const cx = p.x + 1, cy = p.y + 1; | |
| const corners = [[cx - 1, cy - 1], [cx + 1, cy - 1], [cx - 1, cy + 1], [cx + 1, cy + 1]]; | |
| const filled = corners.map(([x, y]) => occupied(x, y)); | |
| const count = filled.filter(Boolean).length; | |
| if (count < 3) return { spin: false, mini: false }; | |
| let front; | |
| if (p.r === 0) front = [filled[0], filled[1]]; | |
| else if (p.r === 1) front = [filled[1], filled[3]]; | |
| else if (p.r === 2) front = [filled[2], filled[3]]; | |
| else front = [filled[0], filled[2]]; | |
| const frontCount = front.filter(Boolean).length; | |
| const mini = frontCount < 2 && G.lastKick !== 4; | |
| return { spin: true, mini }; | |
| } | |
| function hardDrop() { | |
| if (!G.piece || G.mode !== "playing") return; | |
| let dist = 0; | |
| while (canDown()) { G.piece.y++; dist++; } | |
| if (dist) addScore(dist * 2); | |
| G.lastRotate = false; | |
| FX.impact(); | |
| SFX.hard(); | |
| lockPiece(); | |
| } | |
| function lockPiece() { | |
| const p = G.piece; | |
| if (!p) return; | |
| const spin = tSpinInfo(); | |
| const locked = cellsOf(p); | |
| for (const [x, y] of locked) { | |
| if (y >= 0 && y < ROWS && x >= 0 && x < COLS) G.board[y][x] = p.type; | |
| } | |
| const hiddenOnly = locked.every(([, y]) => y < BUFFER); | |
| const rows = []; | |
| for (let y = 0; y < ROWS; y++) { | |
| if (G.board[y].every((c) => c)) rows.push(y); | |
| } | |
| const n = rows.length; | |
| let action = 0; | |
| let difficult = false; | |
| const names = ["", "SINGLE", "DOUBLE", "TRIPLE", "TETRIS"]; | |
| if (spin.spin) { | |
| action = spin.mini ? ([100, 200, 400][n] || 0) : ([400, 800, 1200, 1600][n] || 0); | |
| if (n > 0) difficult = true; | |
| } else { | |
| action = [0, 100, 300, 500, 800][n] || 0; | |
| if (n === 4) difficult = true; | |
| } | |
| if (n > 0) G.combo++; | |
| else G.combo = -1; | |
| let b2bNow = false; | |
| if (n > 0) { | |
| if (difficult) { | |
| if (G.b2b) { action = Math.floor(action * 1.5); b2bNow = true; } | |
| G.b2b = true; | |
| } else G.b2b = false; | |
| } | |
| const after = compactPreview(rows); | |
| const pc = n > 0 && after.every((row) => row.every((c) => !c)); | |
| let gained = action * G.level; | |
| if (n > 0 && G.combo > 0) gained += 50 * G.combo * G.level; | |
| if (pc) gained += [0, 800, 1200, 1800, 2000][n] * G.level; | |
| if (gained) addScore(gained); | |
| if (hiddenOnly && n === 0) { gameOver(); return; } | |
| const prevTier = tierIndex(); | |
| if (n > 0) { | |
| G.lines += n; | |
| G.level = Math.min(20, 1 + ((G.lines / 10) | 0)); | |
| updateHud(); | |
| const cells = []; | |
| for (const y of rows) { | |
| for (let x = 0; x < COLS; x++) { | |
| cells.push({ x, y, type: G.board[y][x] }); | |
| } | |
| } | |
| const info = { | |
| n, spin: spin.spin, mini: spin.mini, pc, b2b: b2bNow, | |
| combo: G.combo, cells, tetris: n === 4 && !spin.spin | |
| }; | |
| FX.lineClear(info); | |
| SFX.clear(info); | |
| let label = names[n]; | |
| let color = "#fff"; | |
| if (spin.spin) { | |
| label = (spin.mini ? "MINI T-SPIN " : "T-SPIN ") + (names[n] || ""); | |
| color = "#e9a6ff"; | |
| } | |
| if (n === 4 && !spin.spin) { label = "TETRIS"; color = "#6cf0ff"; } | |
| if (pc) { label = "ALL CLEAR"; color = "#ffe566"; } | |
| showPop(label.trim(), color, b2bNow ? "BACK-TO-BACK" : (G.combo > 0 ? "COMBO " + G.combo : "")); | |
| $("b2b").style.opacity = G.b2b && b2bNow ? "1" : "0"; | |
| if (G.combo > 0) { | |
| $("comboFloat").textContent = "COMBO " + G.combo; | |
| $("comboFloat").style.opacity = "1"; | |
| } else $("comboFloat").style.opacity = "0"; | |
| if (info.tetris || pc) G.timeScale = 0.28; | |
| G.mode = "clearing"; | |
| G.clearT = CLEAR_TIME; | |
| G.clearRows = rows; | |
| G.piece = null; | |
| if (tierIndex() !== prevTier) { | |
| setTimeout(() => { | |
| showPop(tierOf().name, "var(--tier)", "EFFECT CHANGE"); | |
| SFX.level(); | |
| FX.tierChange(); | |
| }, 220); | |
| } | |
| } else { | |
| SFX.lock(); | |
| FX.lockSparks(locked, p.type); | |
| G.piece = null; | |
| G.mode = "are"; | |
| G.areT = ARE; | |
| } | |
| } | |
| function compactPreview(rows) { | |
| const skip = new Set(rows); | |
| const kept = G.board.filter((_, y) => !skip.has(y)); | |
| while (kept.length < ROWS) kept.unshift(emptyRow()); | |
| return kept; | |
| } | |
| function applyClear() { | |
| G.board = compactPreview(G.clearRows); | |
| G.clearRows = []; | |
| G.mode = "are"; | |
| G.areT = ARE; | |
| G.timeScale = 1; | |
| } | |
| function gameOver() { | |
| G.mode = "over"; | |
| G.piece = null; | |
| SFX.over(); | |
| FX.over(); | |
| if (G.score > G.high) { | |
| G.high = G.score; | |
| localStorage.setItem("hb_best", String(G.high)); | |
| $("best").textContent = String(G.high); | |
| } | |
| $("overStats").innerHTML = | |
| "<div><span>SCORE</span><b>" + G.score + "</b></div>" + | |
| "<div><span>LINES</span><b>" + G.lines + "</b></div>" + | |
| "<div><span>LEVEL</span><b>" + G.level + "</b></div>" + | |
| "<div><span>BEST</span><b>" + G.high + "</b></div>"; | |
| $("over").classList.remove("hidden"); | |
| } | |
| function resetGame() { | |
| particles.length = 0; | |
| rings.length = 0; | |
| bolts.length = 0; | |
| flash = 0; chroma = 0; shock = 0; | |
| G.board = newBoard(); | |
| G.queue = []; | |
| fillBag(); fillBag(); | |
| G.hold = null; | |
| G.canHold = true; | |
| G.score = 0; G.lines = 0; G.level = 1; | |
| G.combo = -1; G.b2b = false; | |
| G.timeScale = 1; | |
| G.piece = null; | |
| $("score").textContent = "0"; | |
| $("b2b").style.opacity = "0"; | |
| $("comboFloat").style.opacity = "0"; | |
| $("over").classList.add("hidden"); | |
| $("pause").classList.add("hidden"); | |
| $("title").classList.add("hidden"); | |
| updateHud(); | |
| G.mode = "playing"; | |
| spawn(); | |
| Audio.start(); | |
| } | |
| function pauseToggle() { | |
| if (G.mode === "playing" || G.mode === "clearing" || G.mode === "are") { | |
| G._resume = G.mode; | |
| G.mode = "paused"; | |
| $("pause").classList.remove("hidden"); | |
| Audio.duck(true); | |
| } else if (G.mode === "paused") { | |
| G.mode = G._resume || "playing"; | |
| $("pause").classList.add("hidden"); | |
| Audio.duck(false); | |
| } | |
| } | |
| /* -------------------- AUDIO -------------------- */ | |
| const Audio = { | |
| ctx: null, master: null, music: null, sfx: null, filt: null, | |
| muted: false, musicOn: true, step: 0, nextT: 0, started: false, | |
| start() { | |
| if (!this.ctx) { | |
| const C = window.AudioContext || window.webkitAudioContext; | |
| this.ctx = new C(); | |
| this.master = this.ctx.createGain(); | |
| this.master.gain.value = 0.85; | |
| const comp = this.ctx.createDynamicsCompressor(); | |
| comp.threshold.value = -18; comp.ratio.value = 6; | |
| this.music = this.ctx.createGain(); this.music.gain.value = 0.22; | |
| this.filt = this.ctx.createBiquadFilter(); | |
| this.filt.type = "lowpass"; this.filt.frequency.value = 1800; | |
| this.sfx = this.ctx.createGain(); this.sfx.gain.value = 0.5; | |
| this.music.connect(this.filt); this.filt.connect(this.master); | |
| this.sfx.connect(this.master); this.master.connect(comp); comp.connect(this.ctx.destination); | |
| this.noise = this.makeNoise(); | |
| } | |
| this.ctx.resume(); | |
| this.started = true; | |
| this.nextT = this.ctx.currentTime + 0.05; | |
| }, | |
| makeNoise() { | |
| const n = this.ctx.createBuffer(1, this.ctx.sampleRate * 1.5, this.ctx.sampleRate); | |
| const d = n.getChannelData(0); | |
| for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; | |
| return n; | |
| }, | |
| tone(freq, dur, type, when, gain, slide, filtFreq) { | |
| if (!this.ctx || this.muted) return; | |
| const t = (when == null || when === 0) ? this.ctx.currentTime : when; | |
| const o = this.ctx.createOscillator(); | |
| const g = this.ctx.createGain(); | |
| o.type = type || "square"; | |
| o.frequency.setValueAtTime(freq, t); | |
| if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(20, slide), t + dur); | |
| g.gain.setValueAtTime(0.0001, t); | |
| g.gain.exponentialRampToValueAtTime(gain || 0.12, t + 0.01); | |
| g.gain.exponentialRampToValueAtTime(0.0001, t + dur); | |
| if (filtFreq) { | |
| const f = this.ctx.createBiquadFilter(); | |
| f.type = "lowpass"; f.frequency.value = filtFreq; | |
| o.connect(f); f.connect(g); | |
| } else o.connect(g); | |
| g.connect(this.sfx); | |
| o.start(t); o.stop(t + dur + 0.02); | |
| }, | |
| noiseBurst(dur, when, gain, freq) { | |
| if (!this.ctx || this.muted || !this.noise) return; | |
| const t = (when == null || when === 0) ? this.ctx.currentTime : when; | |
| const src = this.ctx.createBufferSource(); | |
| src.buffer = this.noise; | |
| const f = this.ctx.createBiquadFilter(); | |
| f.type = "bandpass"; f.frequency.value = freq || 1200; f.Q.value = 0.6; | |
| const g = this.ctx.createGain(); | |
| g.gain.setValueAtTime(gain || 0.3, t); | |
| g.gain.exponentialRampToValueAtTime(0.0001, t + dur); | |
| src.connect(f); f.connect(g); g.connect(this.sfx); | |
| src.start(t); src.stop(t + dur); | |
| }, | |
| sched() { | |
| if (!this.started || !this.musicOn || this.muted || !this.ctx) return; | |
| if (G.mode === "paused" || G.mode === "title") return; | |
| const bpm = 132 + tierIndex() * 5 + Math.min(8, G.level); | |
| const stepDur = 60 / bpm / 4; | |
| const now = this.ctx.currentTime; | |
| while (this.nextT < now + 0.12) { | |
| this.playStep(this.step, this.nextT); | |
| this.step = (this.step + 1) % 32; | |
| this.nextT += stepDur; | |
| } | |
| }, | |
| playStep(s, t) { | |
| const bass = [ | |
| 45,45,0,45, 45,0,48,45, 43,43,0,43, 43,0,46,43, | |
| 41,41,0,41, 41,0,43,41, 40,40,38,40, 43,41,40,38 | |
| ]; | |
| const mel = [ | |
| 76,0,79,81, 76,0,74,76, 72,0,74,76, 79,76,74,72, | |
| 69,0,72,74, 76,74,72,69, 67,69,71,72, 74,72,71,67 | |
| ]; | |
| const arp = [88, 81, 84, 76, 79, 72, 76, 69]; | |
| const midi = (m) => 440 * Math.pow(2, (m - 69) / 12); | |
| const env = (osc, g, dur, gain) => { | |
| g.gain.setValueAtTime(0.0001, t); | |
| g.gain.exponentialRampToValueAtTime(gain, t + 0.01); | |
| g.gain.exponentialRampToValueAtTime(0.0001, t + dur); | |
| osc.start(t); osc.stop(t + dur); | |
| }; | |
| if (bass[s]) { | |
| const o = this.ctx.createOscillator(); const g = this.ctx.createGain(); | |
| o.type = "triangle"; o.frequency.value = midi(bass[s]); | |
| o.connect(g); g.connect(this.music); env(o, g, 0.18, 0.28); | |
| } | |
| if (mel[s]) { | |
| const o = this.ctx.createOscillator(); const g = this.ctx.createGain(); | |
| o.type = "square"; o.frequency.value = midi(mel[s]); | |
| o.connect(g); g.connect(this.music); env(o, g, 0.14, 0.12); | |
| } | |
| if (tierIndex() >= 3 && s % 2 === 0) { | |
| const o = this.ctx.createOscillator(); const g = this.ctx.createGain(); | |
| o.type = "square"; o.frequency.value = midi(arp[(s / 2) % arp.length]); | |
| o.connect(g); g.connect(this.music); env(o, g, 0.08, 0.05); | |
| } | |
| if (s % 8 === 0) this.kick(t); | |
| if (s % 8 === 4) this.snare(t); | |
| if (s % 2 === 0) this.hat(t, s % 4 === 0 ? 0.04 : 0.025); | |
| }, | |
| kick(t) { | |
| const o = this.ctx.createOscillator(); const g = this.ctx.createGain(); | |
| o.type = "sine"; o.frequency.setValueAtTime(140, t); o.frequency.exponentialRampToValueAtTime(40, t + 0.12); | |
| g.gain.setValueAtTime(0.5, t); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.14); | |
| o.connect(g); g.connect(this.music); o.start(t); o.stop(t + 0.15); | |
| }, | |
| snare(t) { this.noiseBurst(0.12, t, 0.18, 1800); this.tone(180, 0.08, "triangle", t, 0.08, 80); }, | |
| hat(t, g) { this.noiseBurst(0.04, t, g, 8000); }, | |
| duck(on) { | |
| if (!this.music) return; | |
| this.music.gain.cancelScheduledValues(this.ctx.currentTime); | |
| this.music.gain.linearRampToValueAtTime(on ? 0.04 : 0.22, this.ctx.currentTime + 0.08); | |
| } | |
| }; | |
| const SFX = { | |
| move() { Audio.tone(620, 0.03, "square", 0, 0.05); }, | |
| rotate() { Audio.tone(420, 0.05, "square", 0, 0.07, 880); }, | |
| hold() { Audio.tone(300, 0.08, "triangle", 0, 0.08, 520); }, | |
| lock() { Audio.tone(160, 0.08, "sine", 0, 0.1, 70); Audio.noiseBurst(0.06, 0, 0.08, 600); }, | |
| hard() { Audio.tone(90, 0.12, "sine", 0, 0.16, 40); Audio.noiseBurst(0.1, 0, 0.16, 400); }, | |
| level() { | |
| [523, 659, 784, 1046].forEach((f, i) => Audio.tone(f, 0.12, "square", Audio.ctx.currentTime + i * 0.07, 0.12)); | |
| }, | |
| over() { | |
| [392, 349, 311, 262, 196].forEach((f, i) => Audio.tone(f, 0.22, "sawtooth", Audio.ctx.currentTime + i * 0.12, 0.12, f * 0.7)); | |
| }, | |
| clear(info) { | |
| if (!Audio.ctx) return; | |
| const t0 = Audio.ctx.currentTime; | |
| Audio.music.gain.setTargetAtTime(0.08, t0, 0.02); | |
| Audio.music.gain.setTargetAtTime(0.22, t0 + 0.25, 0.08); | |
| Audio.noiseBurst(0.18 + info.n * 0.05, t0, 0.22 + info.n * 0.08, 900 + info.n * 400); | |
| Audio.tone(120, 0.2, "sine", t0, 0.2, 50); | |
| if (info.spin) { | |
| Audio.tone(480, 0.18, "sawtooth", t0, 0.14, 1400); | |
| Audio.tone(720, 0.22, "square", t0 + 0.05, 0.1, 1800); | |
| } | |
| if (info.tetris) { | |
| [523, 659, 784, 1046, 1318].forEach((f, i) => Audio.tone(f, 0.16, "square", t0 + 0.04 * i, 0.14)); | |
| } else { | |
| for (let i = 0; i < info.n; i++) Audio.tone(400 + i * 160, 0.1, "square", t0 + i * 0.045, 0.1); | |
| } | |
| if (info.pc) { | |
| [523, 659, 784, 1046, 1318, 1568].forEach((f, i) => Audio.tone(f, 0.28, "triangle", t0 + 0.05 * i, 0.16)); | |
| } | |
| } | |
| }; | |
| /* -------------------- FX -------------------- */ | |
| const particles = []; | |
| const rings = []; | |
| const bolts = []; | |
| let shock = 0, flash = 0, chroma = 0, hue = 0; | |
| function burst(x, y, color, n, extra) { | |
| extra = extra || {}; | |
| for (let i = 0; i < n; i++) { | |
| const a = extra.angle != null ? extra.angle + (Math.random() - 0.5) * (extra.spread || 1.2) : Math.random() * Math.PI * 2; | |
| const sp = (extra.spd || 80) * (0.4 + Math.random()); | |
| particles.push({ | |
| x, y, | |
| vx: Math.cos(a) * sp + (extra.vx || 0), | |
| vy: Math.sin(a) * sp + (extra.vy || 0), | |
| life: extra.life || (0.45 + Math.random() * 0.55), | |
| max: extra.life || 1, | |
| size: extra.size || (2 + Math.random() * 5), | |
| color, | |
| type: extra.type || "spark", | |
| rot: Math.random() * 6, | |
| vr: (Math.random() - 0.5) * 12, | |
| g: extra.g != null ? extra.g : 420, | |
| drag: extra.drag || 1.8, | |
| hue: extra.hue | |
| }); | |
| } | |
| } | |
| function cellScreen(cx, cy) { | |
| const r = field.getBoundingClientRect(); | |
| const cell = r.height / VISIBLE; | |
| return { x: r.left + (cx + 0.5) * cell, y: r.top + (cy - BUFFER + 0.5) * cell, cell }; | |
| } | |
| const FX = { | |
| lineClear(info) { | |
| const t = tierIndex(); | |
| const power = 1 + info.n * 0.7 + (info.tetris ? 1.4 : 0) + (info.spin ? 0.9 : 0) + (info.pc ? 2.2 : 0) + (info.b2b ? 0.4 : 0); | |
| shake(10 * power); | |
| flash = Math.min(1, 0.18 * power); | |
| chroma = Math.min(1, 0.35 * power); | |
| shock = 1; | |
| const wrap = $("field-wrap"); | |
| if (typeof gsap !== "undefined") { | |
| gsap.fromTo(wrap, { scale: 1 }, { scale: 1 + 0.018 * power, duration: 0.08, yoyo: true, repeat: 1, ease: "power2.out" }); | |
| } | |
| for (const c of info.cells) { | |
| const s = cellScreen(c.x, c.y); | |
| this.minoBurst(s.x, s.y, COLORS[c.type] || "#fff", t, power, s.cell); | |
| } | |
| const mid = cellScreen(4.5, BUFFER + 10); | |
| this.tierBurst(t, mid.x, mid.y, power, info); | |
| if (info.spin) this.spiral(mid.x, mid.y, "#d45cff", 28); | |
| if (info.pc) this.nova(mid.x, mid.y); | |
| if ((info.tetris || info.pc) && typeof confetti === "function") { | |
| const origin = { x: mid.x / window.innerWidth, y: mid.y / window.innerHeight }; | |
| confetti({ particleCount: info.pc ? 180 : 110, spread: 80, origin, scalar: 0.9, colors: ["#6cf0ff","#ff4de3","#ffe14a","#3dff7a","#ff3b5c"] }); | |
| } | |
| World3.burst(power, t); | |
| }, | |
| minoBurst(x, y, color, t, power, cell) { | |
| const n = 10 + ((power * 6) | 0); | |
| burst(x, y, color, n, { spd: 140 + t * 20, type: t >= 6 ? "shard" : "spark", size: 3 + cell * 0.08 }); | |
| burst(x, y, "#fff", 4, { spd: 90, size: 2, g: 80, type: "spark" }); | |
| rings.push({ x, y, r: 4, vr: 280 + t * 20, life: 0.35, lw: 3, color }); | |
| }, | |
| tierBurst(t, x, y, power, info) { | |
| const id = TIERS[t].id; | |
| if (id === "spark" || id === "over") { | |
| burst(x, y, "#ffd166", 40 * power, { spd: 220, type: "spark", g: 200 }); | |
| } | |
| if (id === "neon" || id === "over") { | |
| for (let i = 0; i < 3; i++) rings.push({ x, y, r: 10 + i * 8, vr: 420 - i * 40, life: 0.5, lw: 6 - i, color: i % 2 ? "#6cf0ff" : "#ff4de3" }); | |
| burst(x, y, "#6cf0ff", 30, { spd: 300, g: 0, drag: 1.2, type: "spark" }); | |
| } | |
| if (id === "fire" || id === "over") { | |
| burst(x, y, "#ff7a18", 50 * power, { spd: 160, type: "ember", g: -80, drag: 0.8 }); | |
| burst(x, y, "#ff3b3b", 24, { spd: 120, type: "ember", g: -40 }); | |
| } | |
| if (id === "glitch" || id === "over") { | |
| burst(x, y, "#39ff14", 20, { type: "glyph", spd: 90, g: 60 }); | |
| burst(x, y, "#ff00aa", 20, { type: "shard", spd: 260, g: 0 }); | |
| chroma = 1; | |
| } | |
| if (id === "thunder" || id === "over") { | |
| for (let i = 0; i < 6 + info.n; i++) { | |
| bolts.push({ | |
| x1: x + (Math.random() - 0.5) * 80, | |
| y1: 0, | |
| x2: x + (Math.random() - 0.5) * 160, | |
| y2: y + (Math.random() - 0.5) * 80, | |
| life: 0.18 + Math.random() * 0.12, | |
| w: 1 + Math.random() * 3 | |
| }); | |
| } | |
| flash = Math.max(flash, 0.55); | |
| } | |
| if (id === "galaxy" || id === "over") { | |
| for (let i = 0; i < 36; i++) { | |
| const a = (i / 36) * Math.PI * 2; | |
| burst(x, y, i % 2 ? "#b388ff" : "#66e0ff", 1, { | |
| angle: a, spread: 0.05, spd: 40 + (i % 6) * 18, type: "star", g: 0, drag: 0.4, life: 1.1 | |
| }); | |
| } | |
| } | |
| if (id === "crystal" || id === "over") { | |
| burst(x, y, "#9bfff5", 28 * power, { type: "shard", spd: 180, g: 260, size: 8 }); | |
| burst(x, y, "#c77dff", 12, { type: "shard", spd: 220, g: 180, size: 10 }); | |
| } | |
| if (id === "void") { | |
| burst(x, y, "#ff3d81", 40, { spd: 40, g: 0, drag: -1.6, type: "spark", life: 0.7 }); | |
| rings.push({ x, y, r: 200, vr: -380, life: 0.55, lw: 8, color: "#ff2bd6" }); | |
| } | |
| if (id === "rainbow" || id === "over") { | |
| for (let i = 0; i < 48; i++) burst(x, y, "hsl(" + (i * 8) + ",100%,60%)", 1, { spd: 240, type: "star", g: 120, hue: i * 8 }); | |
| } | |
| if (id === "over") { | |
| rings.push({ x, y, r: 8, vr: 700, life: 0.55, lw: 10, color: "#fff" }); | |
| } | |
| }, | |
| spiral(x, y, color, n) { | |
| for (let i = 0; i < n; i++) { | |
| const a = (i / n) * Math.PI * 2; | |
| burst(x, y, color, 1, { angle: a, spread: 0, spd: 60 + i * 4, type: "star", g: 0, drag: 0.6, life: 0.8 }); | |
| } | |
| }, | |
| nova(x, y) { | |
| flash = 0.85; | |
| for (let i = 0; i < 5; i++) rings.push({ x, y, r: 6 + i * 12, vr: 500 - i * 40, life: 0.7, lw: 7 - i, color: "#ffe566" }); | |
| burst(x, y, "#fff", 80, { spd: 340, g: 40, type: "spark" }); | |
| }, | |
| impact() { | |
| const p = G.piece; if (!p) return; | |
| const s = cellScreen(p.x + 1.5, p.y + 1.5); | |
| rings.push({ x: s.x, y: s.y, r: 6, vr: 380, life: 0.22, lw: 3, color: COLORS[p.type] }); | |
| shake(4); | |
| }, | |
| lockSparks(cells, type) { | |
| for (const [x, y] of cells) { | |
| if (y < BUFFER) continue; | |
| const s = cellScreen(x, y); | |
| burst(s.x, s.y, COLORS[type], 3, { spd: 40, size: 2, g: 80, life: 0.3 }); | |
| } | |
| }, | |
| tierChange() { | |
| flash = 0.4; chroma = 0.6; World3.burst(2.2, tierIndex()); | |
| const r = field.getBoundingClientRect(); | |
| rings.push({ x: r.left + r.width / 2, y: r.top + r.height / 2, r: 20, vr: 520, life: 0.6, lw: 8, color: getComputedStyle(document.body).getPropertyValue("--tier") }); | |
| }, | |
| over() { | |
| shake(22); flash = 0.7; | |
| const r = field.getBoundingClientRect(); | |
| burst(r.left + r.width / 2, r.top + r.height / 2, "#ff3b5c", 90, { spd: 280, type: "shard" }); | |
| }, | |
| titleTick(dt) { | |
| if (Math.random() < 0.2) { | |
| burst(Math.random() * innerWidth, -10, COLORS[TYPES[(Math.random() * 7) | 0]], 1, { | |
| spd: 10, vx: (Math.random() - 0.5) * 20, vy: 80 + Math.random() * 90, g: 30, type: "shard", size: 8, life: 3 | |
| }); | |
| } | |
| } | |
| }; | |
| function shake(mag) { | |
| const st = $("stage"); | |
| if (typeof gsap !== "undefined") { | |
| gsap.killTweensOf(st); | |
| gsap.fromTo(st, { x: 0, y: 0 }, { | |
| duration: 0.28, | |
| keyframes: [ | |
| { x: mag, y: -mag * 0.35, duration: 0.04 }, | |
| { x: -mag * 0.85, y: mag * 0.4, duration: 0.05 }, | |
| { x: mag * 0.45, y: -mag * 0.2, duration: 0.06 }, | |
| { x: 0, y: 0, duration: 0.12 } | |
| ], | |
| ease: "none" | |
| }); | |
| } | |
| } | |
| const World3 = { | |
| ok: false, renderer: null, scene: null, cam: null, pts: null, pos: null, vel: null, life: null, n: 0, | |
| init() { | |
| if (typeof THREE === "undefined") return; | |
| const c = $("fx3d"); | |
| this.renderer = new THREE.WebGLRenderer({ canvas: c, alpha: true, antialias: true }); | |
| this.renderer.setPixelRatio(Math.min(2, devicePixelRatio || 1)); | |
| this.renderer.setSize(innerWidth, innerHeight, false); | |
| this.renderer.setClearColor(0x000000, 0); | |
| this.scene = new THREE.Scene(); | |
| this.cam = new THREE.PerspectiveCamera(55, innerWidth / innerHeight, 0.1, 20); | |
| this.cam.position.z = 4; | |
| const N = 500; | |
| this.pos = new Float32Array(N * 3); | |
| this.vel = new Float32Array(N * 3); | |
| this.life = new Float32Array(N); | |
| const geo = new THREE.BufferGeometry(); | |
| geo.setAttribute("position", new THREE.BufferAttribute(this.pos, 3)); | |
| geo.setDrawRange(0, 0); | |
| const mat = new THREE.PointsMaterial({ | |
| size: 0.07, color: 0xffffff, transparent: true, opacity: 0.9, | |
| blending: THREE.AdditiveBlending, depthWrite: false | |
| }); | |
| this.pts = new THREE.Points(geo, mat); | |
| this.scene.add(this.pts); | |
| this.ok = true; | |
| }, | |
| burst(power, t) { | |
| if (!this.ok) return; | |
| const col = new THREE.Color(getComputedStyle(document.body).getPropertyValue("--accent").trim() || "#6cf0ff"); | |
| this.pts.material.color = col; | |
| const n = Math.min(500, 80 + ((power * 60) | 0)); | |
| for (let i = 0; i < n; i++) { | |
| const i3 = i * 3; | |
| this.pos[i3] = (Math.random() - 0.5) * 0.4; | |
| this.pos[i3 + 1] = (Math.random() - 0.5) * 0.3; | |
| this.pos[i3 + 2] = (Math.random() - 0.5) * 0.2; | |
| const s = 1.2 + Math.random() * 2.8 * power; | |
| const a = Math.random() * Math.PI * 2; | |
| const b = Math.random() * Math.PI; | |
| this.vel[i3] = Math.cos(a) * Math.sin(b) * s; | |
| this.vel[i3 + 1] = Math.cos(b) * s; | |
| this.vel[i3 + 2] = Math.sin(a) * Math.sin(b) * s; | |
| this.life[i] = 0.7 + Math.random() * 0.5; | |
| } | |
| this.n = n; | |
| this.pts.geometry.setDrawRange(0, n); | |
| this.pts.geometry.attributes.position.needsUpdate = true; | |
| }, | |
| update(dt) { | |
| if (!this.ok) return; | |
| for (let i = 0; i < this.n; i++) { | |
| if (this.life[i] <= 0) continue; | |
| this.life[i] -= dt; | |
| const i3 = i * 3; | |
| this.vel[i3 + 1] -= 1.6 * dt; | |
| this.pos[i3] += this.vel[i3] * dt; | |
| this.pos[i3 + 1] += this.vel[i3 + 1] * dt; | |
| this.pos[i3 + 2] += this.vel[i3 + 2] * dt; | |
| } | |
| this.pts.rotation.y += dt * 0.15; | |
| this.pts.geometry.attributes.position.needsUpdate = true; | |
| this.pts.material.opacity = this.n ? 0.85 : 0; | |
| this.renderer.render(this.scene, this.cam); | |
| }, | |
| resize() { | |
| if (!this.ok) return; | |
| this.cam.aspect = innerWidth / innerHeight; | |
| this.cam.updateProjectionMatrix(); | |
| this.renderer.setSize(innerWidth, innerHeight, false); | |
| } | |
| }; | |
| function updateFX(dt) { | |
| flash *= Math.pow(0.001, dt); | |
| chroma *= Math.pow(0.008, dt); | |
| shock = Math.max(0, shock - dt * 3); | |
| hue += dt * 40 * (tierIndex() >= 8 ? 3 : 1); | |
| $("flash").style.opacity = String(flash); | |
| const ch = $("chroma"); | |
| ch.style.opacity = String(chroma); | |
| ch.style.background = chroma > 0.02 | |
| ? "linear-gradient(90deg, rgba(255,0,80," + (chroma * 0.35) + "), transparent 40%, rgba(0,220,255," + (chroma * 0.35) + "))" | |
| : "transparent"; | |
| if (G.mode === "title") FX.titleTick(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; } | |
| p.vx *= Math.max(0, 1 - p.drag * dt); | |
| p.vy *= Math.max(0, 1 - p.drag * dt); | |
| p.vy += p.g * dt; | |
| p.x += p.vx * dt; | |
| p.y += p.vy * dt; | |
| p.rot += p.vr * dt; | |
| if (p.hue != null) p.color = "hsl(" + ((p.hue + hue) % 360) + ",100%,60%)"; | |
| } | |
| for (let i = rings.length - 1; i >= 0; i--) { | |
| const r = rings[i]; | |
| r.life -= dt; r.r += r.vr * dt; | |
| if (r.life <= 0) rings.splice(i, 1); | |
| } | |
| for (let i = bolts.length - 1; i >= 0; i--) { | |
| bolts[i].life -= dt; | |
| if (bolts[i].life <= 0) bolts.splice(i, 1); | |
| } | |
| } | |
| function drawBolt(ctx, x1, y1, x2, y2, w) { | |
| ctx.strokeStyle = "#eef9ff"; | |
| ctx.lineWidth = w; | |
| ctx.beginPath(); | |
| ctx.moveTo(x1, y1); | |
| const steps = 10; | |
| for (let i = 1; i <= steps; i++) { | |
| const t = i / steps; | |
| ctx.lineTo( | |
| x1 + (x2 - x1) * t + (Math.random() - 0.5) * 28 * (1 - Math.abs(0.5 - t) * 2), | |
| y1 + (y2 - y1) * t + (Math.random() - 0.5) * 18 | |
| ); | |
| } | |
| ctx.stroke(); | |
| ctx.strokeStyle = "rgba(120,200,255,0.5)"; | |
| ctx.lineWidth = w * 3; | |
| ctx.stroke(); | |
| } | |
| function renderFX() { | |
| const w = fx2d.width, h = fx2d.height; | |
| fctx.setTransform(1, 0, 0, 1, 0, 0); | |
| fctx.clearRect(0, 0, w, h); | |
| const dpr = Math.min(2, devicePixelRatio || 1); | |
| fctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| fctx.globalCompositeOperation = "lighter"; | |
| for (const p of particles) { | |
| const a = Math.max(0, p.life / (p.max || 1)); | |
| fctx.save(); | |
| fctx.translate(p.x, p.y); | |
| fctx.rotate(p.rot); | |
| fctx.globalAlpha = a; | |
| fctx.fillStyle = p.color; | |
| if (p.type === "shard") { | |
| fctx.beginPath(); | |
| fctx.moveTo(0, -p.size); fctx.lineTo(p.size * 0.6, p.size); fctx.lineTo(-p.size * 0.6, p.size); | |
| fctx.closePath(); fctx.fill(); | |
| } else if (p.type === "star") { | |
| fctx.beginPath(); | |
| for (let i = 0; i < 4; i++) { | |
| const ang = i * Math.PI / 2; | |
| fctx.lineTo(Math.cos(ang) * p.size, Math.sin(ang) * p.size); | |
| fctx.lineTo(Math.cos(ang + 0.4) * p.size * 0.3, Math.sin(ang + 0.4) * p.size * 0.3); | |
| } | |
| fctx.fill(); | |
| } else if (p.type === "glyph") { | |
| fctx.font = p.size * 2 + "px monospace"; | |
| fctx.fillText(Math.random() > 0.5 ? "1" : "0", 0, 0); | |
| } else if (p.type === "ember") { | |
| fctx.beginPath(); fctx.arc(0, 0, p.size, 0, 7); fctx.fill(); | |
| fctx.fillStyle = "rgba(255,220,150,0.5)"; | |
| fctx.beginPath(); fctx.arc(0, 0, p.size * 0.45, 0, 7); fctx.fill(); | |
| } else { | |
| fctx.beginPath(); fctx.arc(0, 0, p.size, 0, 7); fctx.fill(); | |
| } | |
| fctx.restore(); | |
| } | |
| for (const r of rings) { | |
| fctx.globalAlpha = Math.max(0, r.life * 1.4); | |
| fctx.strokeStyle = r.color; | |
| fctx.lineWidth = r.lw; | |
| fctx.beginPath(); fctx.arc(r.x, r.y, Math.max(0, r.r), 0, 7); fctx.stroke(); | |
| } | |
| for (const b of bolts) { | |
| fctx.globalAlpha = Math.max(0, b.life * 5); | |
| drawBolt(fctx, b.x1, b.y1, b.x2, b.y2, b.w); | |
| } | |
| fctx.globalAlpha = 1; | |
| fctx.globalCompositeOperation = "source-over"; | |
| } | |
| /* -------------------- RENDER BOARD -------------------- */ | |
| let cell = 32, dpr = 1, fieldW = 320, fieldH = 640; | |
| function layout() { | |
| dpr = Math.min(2, devicePixelRatio || 1); | |
| const maxH = Math.min(innerHeight * (matchMedia("(pointer:coarse)").matches ? 0.52 : 0.72), 680); | |
| cell = Math.max(18, Math.floor(maxH / VISIBLE)); | |
| fieldW = cell * COLS; | |
| fieldH = cell * VISIBLE; | |
| field.width = fieldW * dpr; field.height = fieldH * dpr; | |
| field.style.width = fieldW + "px"; field.style.height = fieldH + "px"; | |
| ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| fx2d.width = innerWidth * dpr; fx2d.height = innerHeight * dpr; | |
| fx2d.style.width = innerWidth + "px"; fx2d.style.height = innerHeight + "px"; | |
| World3.resize(); | |
| drawPreviews(); | |
| } | |
| function roundRect(c, x, y, w, h, r) { | |
| const rr = Math.min(r, w / 2, h / 2); | |
| c.beginPath(); | |
| c.moveTo(x + rr, y); | |
| c.arcTo(x + w, y, x + w, y + h, rr); | |
| c.arcTo(x + w, y + h, x, y + h, rr); | |
| c.arcTo(x, y + h, x, y, rr); | |
| c.arcTo(x, y, x + w, y, rr); | |
| c.closePath(); | |
| } | |
| function drawMino(c, x, y, s, color, ghost, flashRow) { | |
| const pad = s * 0.06; | |
| const xx = x + pad, yy = y + pad, ss = s - pad * 2, r = Math.max(2, ss * 0.16); | |
| c.save(); | |
| c.shadowBlur = 0; | |
| if (ghost) { | |
| c.globalAlpha = 0.2; | |
| c.strokeStyle = color; | |
| c.lineWidth = Math.max(1.5, s * 0.06); | |
| roundRect(c, xx, yy, ss, ss, r); | |
| c.stroke(); | |
| c.globalAlpha = 0.1; | |
| c.fillStyle = color; | |
| c.fill(); | |
| c.restore(); | |
| return; | |
| } | |
| if (flashRow) { | |
| c.fillStyle = "#ffffff"; | |
| roundRect(c, xx - 1, yy - 1, ss + 2, ss + 2, r); | |
| c.fill(); | |
| c.restore(); | |
| return; | |
| } | |
| c.globalAlpha = 0.35; | |
| c.fillStyle = color; | |
| roundRect(c, xx - s * 0.06, yy - s * 0.06, ss + s * 0.12, ss + s * 0.12, r + 2); | |
| c.fill(); | |
| c.globalAlpha = 1; | |
| const g = c.createLinearGradient(xx, yy, xx, yy + ss); | |
| g.addColorStop(0, shade(color, 1.35)); | |
| g.addColorStop(0.42, color); | |
| g.addColorStop(1, shade(color, 0.52)); | |
| c.fillStyle = g; | |
| roundRect(c, xx, yy, ss, ss, r); | |
| c.fill(); | |
| c.fillStyle = "rgba(255,255,255,0.38)"; | |
| roundRect(c, xx + ss * 0.12, yy + ss * 0.1, ss * 0.76, ss * 0.28, r); | |
| c.fill(); | |
| c.strokeStyle = "rgba(0,0,0,0.38)"; | |
| c.lineWidth = 1; | |
| roundRect(c, xx, yy, ss, ss, r); | |
| c.stroke(); | |
| c.restore(); | |
| } | |
| function shade(hex, m) { | |
| const n = parseInt(hex.slice(1), 16); | |
| let r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; | |
| r = Math.max(0, Math.min(255, r * m)); | |
| g = Math.max(0, Math.min(255, g * m)); | |
| b = Math.max(0, Math.min(255, b * m)); | |
| return "rgb(" + (r | 0) + "," + (g | 0) + "," + (b | 0) + ")"; | |
| } | |
| function ghostY() { | |
| if (!G.piece) return 0; | |
| const n = clone(G.piece); | |
| while (true) { | |
| n.y++; | |
| if (!valid(n)) { n.y--; return n.y; } | |
| } | |
| } | |
| function renderBoard() { | |
| ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| ctx.globalAlpha = 1; | |
| ctx.shadowBlur = 0; | |
| ctx.clearRect(0, 0, fieldW, fieldH); | |
| ctx.fillStyle = "#07060f"; | |
| ctx.fillRect(0, 0, fieldW, fieldH); | |
| for (let y = 0; y < VISIBLE; y++) { | |
| for (let x = 0; x < COLS; x++) { | |
| ctx.fillStyle = (x + y) % 2 ? "rgba(255,255,255,0.03)" : "rgba(255,255,255,0.055)"; | |
| ctx.fillRect(x * cell, y * cell, cell, cell); | |
| } | |
| } | |
| const danger = G.board.slice(BUFFER, BUFFER + 4).some((row) => row && row.some(Boolean)); | |
| $("field-wrap").classList.toggle("danger", danger); | |
| for (let y = BUFFER; y < ROWS; y++) { | |
| const row = G.board[y]; | |
| if (!row) continue; | |
| for (let x = 0; x < COLS; x++) { | |
| const t = row[x]; | |
| if (!t || !COLORS[t]) continue; | |
| const flashRow = G.mode === "clearing" && G.clearRows.includes(y) && ((performance.now() / 50) | 0) % 2 === 0; | |
| ctx.fillStyle = flashRow ? "#ffffff" : COLORS[t]; | |
| ctx.fillRect(x * cell + 1, (y - BUFFER) * cell + 1, cell - 2, cell - 2); | |
| drawMino(ctx, x * cell, (y - BUFFER) * cell, cell, COLORS[t], false, flashRow); | |
| } | |
| } | |
| if (G.piece && G.mode !== "clearing") { | |
| const gy = ghostY(); | |
| if (gy !== G.piece.y) { | |
| for (const [x, y] of cellsOf({ type: G.piece.type, x: G.piece.x, y: gy, r: G.piece.r })) { | |
| if (y < BUFFER) continue; | |
| drawMino(ctx, x * cell, (y - BUFFER) * cell, cell, COLORS[G.piece.type], true, false); | |
| } | |
| } | |
| const lockFlash = !canDown() && ((G.lockT / LOCK) > 0.55) && ((performance.now() / 80) | 0) % 2 === 0; | |
| for (const [x, y] of cellsOf(G.piece)) { | |
| if (y < BUFFER) continue; | |
| ctx.fillStyle = COLORS[G.piece.type]; | |
| ctx.fillRect(x * cell + 1, (y - BUFFER) * cell + 1, cell - 2, cell - 2); | |
| drawMino(ctx, x * cell, (y - BUFFER) * cell, cell, COLORS[G.piece.type], false, lockFlash); | |
| } | |
| } | |
| } | |
| function drawMini(c, type, ox, oy, s) { | |
| if (!type || !SHAPES[type]) return; | |
| const sh = SHAPES[type][0]; | |
| let minX = 9, minY = 9, maxX = 0, maxY = 0; | |
| for (const [x, y] of sh) { | |
| minX = Math.min(minX, x); minY = Math.min(minY, y); | |
| maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); | |
| } | |
| const w = maxX - minX + 1, h = maxY - minY + 1; | |
| const bx = ox - (w * s) / 2, by = oy - (h * s) / 2; | |
| for (const [x, y] of sh) drawMino(c, bx + (x - minX) * s, by + (y - minY) * s, s, COLORS[type], false, false); | |
| } | |
| function drawPreviews() { | |
| hctx.setTransform(1, 0, 0, 1, 0, 0); | |
| hctx.clearRect(0, 0, holdC.width, holdC.height); | |
| drawMini(hctx, G.hold, holdC.width / 2, holdC.height / 2, 16); | |
| nctx.setTransform(1, 0, 0, 1, 0, 0); | |
| nctx.clearRect(0, 0, nextC.width, nextC.height); | |
| ensureQ(); | |
| for (let i = 0; i < NEXT_N; i++) { | |
| drawMini(nctx, G.queue[i], nextC.width / 2, 28 + i * 62, i === 0 ? 16 : 14); | |
| } | |
| } | |
| /* -------------------- UPDATE -------------------- */ | |
| function update(dt) { | |
| Audio.sched(); | |
| updateFX(dt); | |
| if (G.timeScale < 0.99) G.timeScale = Math.min(1, G.timeScale + dt * 2.2); | |
| const gdt = dt * G.timeScale; | |
| const left = keys.left, right = keys.right; | |
| let dir = 0; | |
| if (left && !right) dir = -1; | |
| else if (right && !left) dir = 1; | |
| if (dir !== G.dasDir) { G.dasDir = dir; G.dasT = 0; G.arrT = 0; } | |
| if (dir) { | |
| G.dasT += dt; | |
| if (G.mode === "playing" && G.piece && G.dasT >= DAS) { | |
| G.arrT += dt; | |
| while (G.arrT >= ARR) { | |
| G.arrT -= ARR; | |
| if (tryMove(dir, 0)) SFX.move(); | |
| else break; | |
| } | |
| } | |
| } | |
| if (G.mode === "clearing") { | |
| G.clearT -= gdt; | |
| if (G.clearT <= 0) applyClear(); | |
| return; | |
| } | |
| if (G.mode === "are") { | |
| G.areT -= gdt; | |
| if (G.areT <= 0) { G.mode = "playing"; spawn(); } | |
| return; | |
| } | |
| if (G.mode !== "playing" || !G.piece) return; | |
| const grav = gravitySec(G.level); | |
| const soft = keys.down ? Math.min(grav / 20, 1 / 20) : grav; | |
| G.fall += gdt; | |
| while (G.fall >= soft) { | |
| G.fall -= soft; | |
| if (canDown()) { | |
| G.piece.y++; | |
| G.lastRotate = false; | |
| if (G.piece.y > G.lowestY) { G.lowestY = G.piece.y; G.lockResets = 0; } | |
| if (keys.down) addScore(1); | |
| } else { | |
| G.fall = 0; | |
| break; | |
| } | |
| } | |
| if (!canDown()) { | |
| G.lockT += gdt; | |
| if (G.lockT >= LOCK) lockPiece(); | |
| } | |
| } | |
| /* -------------------- INPUT -------------------- */ | |
| function onDown(code, repeat) { | |
| if (G.mode === "title") { resetGame(); return; } | |
| if (code === "Escape" || code === "KeyP") { pauseToggle(); return; } | |
| if (G.mode === "over" && (code === "Space" || code === "Enter")) { resetGame(); return; } | |
| if (G.mode === "paused") return; | |
| if (G.mode !== "playing" && G.mode !== "are" && G.mode !== "clearing") return; | |
| if (code === "ArrowLeft" || code === "KeyJ") { | |
| if (!repeat && tryMove(-1, 0)) SFX.move(); | |
| keys.left = true; | |
| } else if (code === "ArrowRight" || code === "KeyL") { | |
| if (!repeat && tryMove(1, 0)) SFX.move(); | |
| keys.right = true; | |
| } else if (code === "ArrowDown" || code === "KeyK") { | |
| keys.down = true; | |
| } else if (code === "Space") { | |
| if (!repeat) hardDrop(); | |
| } else if (code === "ArrowUp" || code === "KeyX" || code === "KeyW") { | |
| if (!repeat && tryRotate(G.piece, 1)) SFX.rotate(); | |
| keys.rotCW = true; | |
| } else if (code === "KeyZ" || code === "ControlLeft" || code === "ControlRight") { | |
| if (!repeat && tryRotate(G.piece, -1)) SFX.rotate(); | |
| keys.rotCCW = true; | |
| } else if (code === "KeyA") { | |
| if (!repeat && tryRotate(G.piece, 2)) SFX.rotate(); | |
| keys.rot180 = true; | |
| } else if (code === "KeyC" || code === "ShiftLeft" || code === "ShiftRight") { | |
| if (!repeat && G.mode === "playing") doHold(); | |
| keys.hold = true; | |
| } | |
| } | |
| function onUp(code) { | |
| if (code === "ArrowLeft" || code === "KeyJ") keys.left = false; | |
| if (code === "ArrowRight" || code === "KeyL") keys.right = false; | |
| if (code === "ArrowDown" || code === "KeyK") keys.down = false; | |
| if (code === "ArrowUp" || code === "KeyX" || code === "KeyW") keys.rotCW = false; | |
| if (code === "KeyZ" || code === "ControlLeft" || code === "ControlRight") keys.rotCCW = false; | |
| if (code === "KeyA") keys.rot180 = false; | |
| if (code === "KeyC" || code === "ShiftLeft" || code === "ShiftRight") keys.hold = false; | |
| } | |
| window.addEventListener("keydown", (e) => { | |
| const watch = ["ArrowLeft","ArrowRight","ArrowDown","ArrowUp","Space","KeyZ","KeyX","KeyA","KeyC","KeyP","Escape","ShiftLeft","ShiftRight","ControlLeft","ControlRight","KeyW","KeyJ","KeyK","KeyL","Enter"]; | |
| if (watch.includes(e.code)) e.preventDefault(); | |
| onDown(e.code, e.repeat); | |
| }); | |
| window.addEventListener("keyup", (e) => onUp(e.code)); | |
| window.addEventListener("blur", () => { for (const k of Object.keys(keys)) keys[k] = false; }); | |
| document.addEventListener("visibilitychange", () => { | |
| if (document.hidden && (G.mode === "playing" || G.mode === "clearing" || G.mode === "are")) pauseToggle(); | |
| }); | |
| $("startBtn").onclick = () => resetGame(); | |
| $("resumeBtn").onclick = () => pauseToggle(); | |
| $("retryBtn").onclick = () => resetGame(); | |
| $("retryPause").onclick = () => resetGame(); | |
| $("pauseBtn").onclick = () => { | |
| if (G.mode === "title") return; | |
| pauseToggle(); | |
| }; | |
| $("muteBtn").onclick = () => { | |
| Audio.muted = !Audio.muted; | |
| Audio.musicOn = !Audio.muted; | |
| $("muteBtn").textContent = Audio.muted ? "MUTE" : "BGM"; | |
| if (Audio.master) Audio.master.gain.value = Audio.muted ? 0 : 0.85; | |
| }; | |
| const touchHeld = Object.create(null); | |
| $("touch").addEventListener("pointerdown", (e) => { | |
| const btn = e.target.closest("button"); if (!btn) return; | |
| e.preventDefault(); | |
| const act = btn.dataset.act; | |
| if (G.mode === "title") { resetGame(); return; } | |
| if (act === "left") { onDown("ArrowLeft", false); touchHeld.left = true; } | |
| if (act === "right") { onDown("ArrowRight", false); touchHeld.right = true; } | |
| if (act === "soft") { onDown("ArrowDown", false); } | |
| if (act === "hard") onDown("Space", false); | |
| if (act === "cw") onDown("KeyX", false); | |
| if (act === "ccw") onDown("KeyZ", false); | |
| if (act === "180") onDown("KeyA", false); | |
| if (act === "hold") onDown("KeyC", false); | |
| }); | |
| const endTouch = () => { | |
| onUp("ArrowLeft"); onUp("ArrowRight"); onUp("ArrowDown"); | |
| onUp("KeyX"); onUp("KeyZ"); onUp("KeyA"); onUp("KeyC"); | |
| }; | |
| window.addEventListener("pointerup", endTouch); | |
| window.addEventListener("pointercancel", endTouch); | |
| /* -------------------- LOOP -------------------- */ | |
| let last = performance.now(); | |
| function frame(now) { | |
| const dt = Math.min(0.05, (now - last) / 1000); | |
| last = now; | |
| update(dt); | |
| renderBoard(); | |
| renderFX(); | |
| World3.update(dt); | |
| requestAnimationFrame(frame); | |
| } | |
| window.addEventListener("resize", layout); | |
| layout(); | |
| World3.init(); | |
| requestAnimationFrame(frame); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment