Last active
September 8, 2026 17:30
-
-
Save kumagi/025178707efa2381c5b79247bbe040c1 to your computer and use it in GitHub Desktop.
派手エフェクト・テトリス(HTML 1枚) Worked for 55m 38s
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"> | |
| <meta name="color-scheme" content="dark"> | |
| <title>派手エフェクト・テトリス</title> | |
| <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect x='1' y='1' width='6' height='6' fill='%2300e5ff'/><rect x='9' y='1' width='6' height='6' fill='%23ffd166'/><rect x='1' y='9' width='6' height='6' fill='%23b64dff'/><rect x='9' y='9' width='6' height='6' fill='%23ff3d5a'/></svg>"> | |
| <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=DotGothic16&family=Orbitron:wght@500;700;900&display=swap" rel="stylesheet"> | |
| <style> | |
| :root{ --accent:#00e5ff; --pw:176px; --pulse:0; } | |
| *{ margin:0; padding:0; box-sizing:border-box; } | |
| html,body{ height:100%; overflow:hidden; background:#04050c; color:#eef2ff; | |
| font-family:'DotGothic16','Hiragino Kaku Gothic ProN',monospace; | |
| user-select:none; -webkit-user-select:none; touch-action:manipulation; overscroll-behavior:none; } | |
| canvas{ display:block; } | |
| button{ font-family:inherit; } | |
| #bgfx,#fxCv{ position:fixed; inset:0; width:100vw; height:100vh; pointer-events:none; } | |
| #fxCv{ z-index:30; } | |
| #flash{ position:fixed; inset:0; pointer-events:none; z-index:25; background:#fff; opacity:0; mix-blend-mode:screen; } | |
| #stage{ position:fixed; inset:0; display:flex; align-items:center; justify-content:center; gap:14px; z-index:10; will-change:transform; } | |
| .panel{ width:var(--pw); display:flex; flex-direction:column; gap:8px; | |
| background:rgba(8,10,22,.68); border:1px solid color-mix(in srgb,var(--accent) 35%,transparent); | |
| border-radius:10px; padding:10px; | |
| box-shadow:0 0 18px color-mix(in srgb,var(--accent) 16%,transparent), inset 0 0 24px rgba(0,0,0,.5); } | |
| .panel h2{ font-family:Orbitron,sans-serif; font-size:10px; font-weight:700; letter-spacing:.24em; | |
| color:color-mix(in srgb,var(--accent) 75%,#fff); opacity:.9; } | |
| .panel canvas{ margin:0 auto; } | |
| #fieldBox{ position:relative; padding:7px; border-radius:12px; background:rgba(6,8,18,.78); | |
| border:1px solid color-mix(in srgb,var(--accent) 55%,transparent); | |
| box-shadow:0 0 calc(16px + var(--pulse)*36px) color-mix(in srgb,var(--accent) 45%,transparent), inset 0 0 30px rgba(0,0,0,.6); | |
| transition:border-color .4s; } | |
| #fieldBox.danger{ animation:danger .45s ease-in-out infinite alternate; } | |
| @keyframes danger{ | |
| from{ border-color:#ff5540; box-shadow:0 0 18px rgba(255,50,40,.4), inset 0 0 30px rgba(120,0,0,.4); } | |
| to{ border-color:#ffaa66; box-shadow:0 0 38px rgba(255,70,50,.85), inset 0 0 44px rgba(170,0,0,.55); } } | |
| .pstats{ display:flex; flex-direction:column; gap:4px; } | |
| .prow{ display:flex; justify-content:space-between; align-items:baseline; font-size:12px; opacity:.92; } | |
| .prow b{ font-family:Orbitron,sans-serif; font-weight:700; color:#fff; } | |
| #scoreV{ font-family:Orbitron,sans-serif; font-weight:900; font-size:23px; color:#fff; | |
| text-shadow:0 0 14px var(--accent); text-align:right; line-height:1.15; } | |
| #themeV{ font-size:12px; color:#fff; } | |
| #themeBarWrap{ height:5px; background:rgba(255,255,255,.12); border-radius:3px; overflow:hidden; } | |
| #themeBar{ height:100%; width:0%; border-radius:3px; background:linear-gradient(90deg,var(--accent),#fff); transition:width .25s; } | |
| #themeNext{ font-size:10px; opacity:.6; } | |
| #comboBadge,#b2bBadge{ display:none; text-align:center; font-family:Orbitron,sans-serif; font-weight:700; | |
| font-size:12px; padding:5px 4px; border-radius:6px; letter-spacing:.1em; } | |
| #comboBadge{ color:#0b0b16; background:linear-gradient(90deg,#4dffe0,#4da6ff); box-shadow:0 0 14px rgba(70,220,255,.55); } | |
| #b2bBadge{ color:#1a1200; background:linear-gradient(90deg,#ffd166,#ff9d00); box-shadow:0 0 14px rgba(255,190,60,.55); } | |
| #keysHint{ font-size:10px; line-height:1.75; opacity:.55; margin-top:auto; } | |
| #screen{ position:fixed; inset:0; z-index:40; display:none; align-items:center; justify-content:center; | |
| background:rgba(3,4,10,.55); backdrop-filter:blur(3px); } | |
| .scr{ display:none; flex-direction:column; align-items:center; gap:14px; text-align:center; | |
| background:rgba(5,7,15,.82); border:1px solid color-mix(in srgb,var(--accent) 32%,transparent); | |
| border-radius:16px; padding:26px 32px; max-width:92vw; max-height:92vh; overflow:auto; | |
| box-shadow:0 0 70px rgba(0,0,0,.65), 0 0 30px color-mix(in srgb,var(--accent) 14%,transparent); } | |
| .logo{ font-size:clamp(24px,6vw,42px); color:#fff; letter-spacing:.04em; | |
| text-shadow:0 0 10px #55ddff, 0 0 30px #cc55ff, 3px 3px 0 #10203a; } | |
| .logo-sub{ font-family:Orbitron,sans-serif; letter-spacing:.5em; font-size:11px; color:var(--accent); } | |
| .desc{ font-size:12px; line-height:1.9; opacity:.8; } | |
| .lvl-sel{ display:flex; align-items:center; gap:10px; font-size:14px; } | |
| .lvl-sel b{ font-family:Orbitron,sans-serif; font-size:20px; min-width:2ch; color:#fff; text-shadow:0 0 10px var(--accent); } | |
| .btn{ cursor:pointer; color:#fff; background:rgba(20,28,58,.85); border:1px solid color-mix(in srgb,var(--accent) 60%,transparent); | |
| border-radius:9px; padding:9px 22px; font-size:15px; letter-spacing:.08em; | |
| box-shadow:0 0 14px color-mix(in srgb,var(--accent) 25%,transparent); transition:.15s; } | |
| .btn:hover{ background:color-mix(in srgb,var(--accent) 26%,#141c3a); box-shadow:0 0 22px color-mix(in srgb,var(--accent) 55%,transparent); } | |
| .btn.big{ font-size:18px; padding:12px 40px; } | |
| .btn.mini{ padding:4px 12px; font-size:14px; } | |
| .keys{ font-size:11px; line-height:2; opacity:.6; } | |
| .stgrid{ display:grid; grid-template-columns:auto auto; gap:5px 22px; font-size:13px; text-align:left; } | |
| .stgrid span{ opacity:.6; } | |
| .stgrid b{ font-family:Orbitron,sans-serif; color:#fff; text-align:right; } | |
| #ovScore{ font-family:Orbitron,sans-serif; font-weight:900; font-size:34px; color:#fff; text-shadow:0 0 16px var(--accent); } | |
| .go-title{ font-family:Orbitron,sans-serif; font-weight:900; font-size:30px; letter-spacing:.3em; color:#fff; text-shadow:0 0 18px var(--accent); } | |
| #touchUI{ position:fixed; left:0; right:0; bottom:8px; z-index:35; display:none; | |
| justify-content:space-between; padding:0 12px; pointer-events:none; } | |
| .tgroup{ display:flex; gap:10px; align-items:flex-end; pointer-events:auto; } | |
| .tbtn{ width:56px; height:56px; border-radius:50%; border:1px solid rgba(160,190,255,.4); | |
| background:rgba(14,18,38,.6); color:#dfe8ff; font-size:20px; touch-action:none; | |
| box-shadow:0 0 12px rgba(40,80,200,.35); backdrop-filter:blur(2px); } | |
| .tbtn:active{ background:rgba(60,90,190,.55); } | |
| .tbtn.small{ width:48px; height:48px; font-size:11px; border-radius:24px; } | |
| @media (pointer:coarse){ #touchUI{ display:flex; } } | |
| @media (max-width:640px){ | |
| .panel{ padding:7px; gap:6px; } | |
| #keysHint{ display:none; } | |
| #scoreV{ font-size:17px; } | |
| .panel h2{ font-size:8px; } | |
| #themeV,#themeNext{ font-size:9px; } | |
| .prow{ font-size:10px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="bgfx"></canvas> | |
| <div id="stage"> | |
| <aside class="panel" id="panelL"> | |
| <h2>HOLD</h2> | |
| <canvas id="holdCv"></canvas> | |
| <div class="pstats"> | |
| <div class="prow"><span>TIME</span><b id="timeV">0:00</b></div> | |
| <div class="prow"><span>PIECES</span><b id="piecesV">0</b></div> | |
| </div> | |
| <div id="keysHint"> | |
| ←→:移動 ↓:ソフトDROP<br> | |
| SPACE:ハードDROP<br> | |
| ↑/X:右回転 Z:左回転<br> | |
| C:ホールド P:ポーズ<br> | |
| M:サウンド | |
| </div> | |
| </aside> | |
| <div id="fieldBox"><canvas id="fieldCv"></canvas></div> | |
| <aside class="panel" id="panelR"> | |
| <h2>NEXT</h2> | |
| <canvas id="nextCv"></canvas> | |
| <h2>SCORE</h2> | |
| <div id="scoreV">0</div> | |
| <div class="prow"><span>LEVEL</span><b id="levelV">1</b></div> | |
| <div class="prow"><span>LINES</span><b id="linesV">0</b></div> | |
| <h2>EFFECT</h2> | |
| <div id="themeV">SPARK ・ スパーク</div> | |
| <div id="themeBarWrap"><div id="themeBar"></div></div> | |
| <div id="themeNext">あと 10 ラインで変化</div> | |
| <div id="comboBadge">COMBO ×2</div> | |
| <div id="b2bBadge">BACK-TO-BACK</div> | |
| </aside> | |
| </div> | |
| <canvas id="fxCv"></canvas> | |
| <div id="flash"></div> | |
| <div id="screen"> | |
| <div class="scr" id="scrTitle"> | |
| <div class="logo">派手エフェクト・テトリス</div> | |
| <div class="logo-sub">HYPER EFFECT TETRIS</div> | |
| <div class="desc"> | |
| テトリスガイドライン準拠(SRS回転・ホールド・ゴースト・Tスピン・B2B対応)<br> | |
| <b>10ライン消すごとにエフェクトが進化!</b> 消去・Tスピン・テトリスで画面が爆発。<br> | |
| サウンドはすべてコードでリアルタイム合成。 | |
| </div> | |
| <div class="lvl-sel"> | |
| 開始レベル | |
| <button class="btn mini" id="lvlDown">◀</button> | |
| <b id="lvlV">1</b> | |
| <button class="btn mini" id="lvlUp">▶</button> | |
| </div> | |
| <button class="btn big" id="startBtn">スタート(ENTER)</button> | |
| <div class="keys"> | |
| ←→:移動 / ↓:ソフトドロップ / SPACE:ハードドロップ<br> | |
| ↑・X:右回転 / Z:左回転 / C:ホールド / P:ポーズ / M:サウンド | |
| </div> | |
| </div> | |
| <div class="scr" id="scrPause"> | |
| <div class="go-title">PAUSE</div> | |
| <div class="desc">P または ENTER で再開</div> | |
| <button class="btn" id="resumeBtn">再開</button> | |
| <button class="btn" id="quitBtn">タイトルへ</button> | |
| </div> | |
| <div class="scr" id="scrOver"> | |
| <div class="go-title">GAME OVER</div> | |
| <div id="ovScore">0</div> | |
| <div class="stgrid"> | |
| <span>LINES</span><b id="ovLines">0</b> | |
| <span>LEVEL</span><b id="ovLevel">1</b> | |
| <span>TIME</span><b id="ovTime">0:00</b> | |
| <span>PIECES</span><b id="ovPieces">0</b> | |
| <span>T-SPIN</span><b id="ovTspin">0</b> | |
| <span>TETRIS</span><b id="ovTetris">0</b> | |
| <span>MAX COMBO</span><b id="ovCombo">0</b> | |
| </div> | |
| <button class="btn big" id="retryBtn">リトライ(ENTER)</button> | |
| <button class="btn" id="titleBtn">タイトルへ</button> | |
| </div> | |
| </div> | |
| <div id="touchUI"> | |
| <div class="tgroup"> | |
| <button class="tbtn" id="tLeft">◀</button> | |
| <button class="tbtn" id="tRight">▶</button> | |
| </div> | |
| <div class="tgroup"> | |
| <button class="tbtn small" id="tHold">HOLD</button> | |
| <button class="tbtn" id="tRot">⟳</button> | |
| <button class="tbtn" id="tDown">▼</button> | |
| <button class="tbtn" id="tDrop">⤓</button> | |
| </div> | |
| </div> | |
| <script> | |
| (()=>{ 'use strict'; | |
| /* ================= utils ================= */ | |
| const TAU=Math.PI*2; | |
| const clamp=(v,a,b)=>v<a?a:v>b?b:v; | |
| const lerp=(a,b,t)=>a+(b-a)*t; | |
| const rand=(a=1,b)=>b===undefined?Math.random()*a:a+Math.random()*(b-a); | |
| const irand=(a,b)=>Math.floor(rand(a,b+1)); | |
| const choice=a=>a[Math.floor(Math.random()*a.length)]; | |
| const 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; }; | |
| const easeOutCubic=t=>1-Math.pow(1-t,3); | |
| const backOut=t=>{ const c1=1.70158,c3=c1+1; return 1+c3*Math.pow(t-1,3)+c1*Math.pow(t-1,2); }; | |
| const colorA=(hex,a)=>{ const n=parseInt(hex.slice(1),16); return `rgba(${n>>16&255},${n>>8&255},${n&255},${a})`; }; | |
| const shade=(hex,t)=>{ // t>0:白へ t<0:黒へ | |
| const n=parseInt(hex.slice(1),16); let r=n>>16&255,g=n>>8&255,b=n&255; | |
| const m=t<0?0:255,k=Math.abs(t); | |
| r=Math.round(lerp(r,m,k)); g=Math.round(lerp(g,m,k)); b=Math.round(lerp(b,m,k)); | |
| return `rgb(${r},${g},${b})`; }; | |
| const midiF=m=>440*Math.pow(2,(m-69)/12); | |
| function rr(g,x,y,w,h,r){ r=Math.min(r,w/2,h/2); | |
| g.beginPath(); g.moveTo(x+r,y); g.arcTo(x+w,y,x+w,y+h,r); g.arcTo(x+w,y+h,x,y+h,r); | |
| g.arcTo(x,y+h,x,y,r); g.arcTo(x,y,x+w,y,r); g.closePath(); } | |
| /* ================= DOM ================= */ | |
| const $=id=>document.getElementById(id); | |
| const bgfx=$('bgfx'), fxCv=$('fxCv'), fieldCv=$('fieldCv'), holdCv=$('holdCv'), nextCv=$('nextCv'); | |
| const bctx=bgfx.getContext('2d'), xctx=fxCv.getContext('2d'), fctx=fieldCv.getContext('2d'); | |
| const nctx=nextCv.getContext('2d'), hctx=holdCv.getContext('2d'); | |
| const stageEl=$('stage'), fieldBox=$('fieldBox'), flashEl=$('flash'), screenEl=$('screen'); | |
| const scoreV=$('scoreV'), levelV=$('levelV'), linesV=$('linesV'), timeV=$('timeV'), piecesV=$('piecesV'); | |
| const themeV=$('themeV'), themeBar=$('themeBar'), themeNext=$('themeNext'); | |
| const comboBadge=$('comboBadge'), b2bBadge=$('b2bBadge'); | |
| const scrTitle=$('scrTitle'), scrPause=$('scrPause'), scrOver=$('scrOver'); | |
| /* ================= constants ================= */ | |
| const COLS=10, ROWS=22, HIDDEN=2, VISIBLE=20; | |
| const LOCK_DELAY=0.5, MAX_RESETS=15, DAS=0.15, ARR=0.033, CLEAR_DUR=0.30; | |
| const TYPE_ID={I:1,J:2,L:3,O:4,S:5,T:6,Z:7}; | |
| const COLORS=[null,'#00e5ff','#3a6cff','#ff9100','#ffd500','#33d17a','#b64dff','#ff3d5a']; | |
| // SRS: 出現配置から4回転分のセルを事前計算(y下向き) | |
| const BASE={ | |
| I:{n:4,c:[[0,1],[1,1],[2,1],[3,1]]}, | |
| O:{n:4,c:[[1,0],[2,0],[1,1],[2,1]]}, | |
| T:{n:3,c:[[1,0],[0,1],[1,1],[2,1]]}, | |
| S:{n:3,c:[[1,0],[2,0],[0,1],[1,1]]}, | |
| Z:{n:3,c:[[0,0],[1,0],[1,1],[2,1]]}, | |
| J:{n:3,c:[[0,0],[0,1],[1,1],[2,1]]}, | |
| L:{n:3,c:[[2,0],[0,1],[1,1],[2,1]]}, | |
| }; | |
| const ROT={}; | |
| for(const t in BASE){ | |
| const {n,c}=BASE[t]; const rots=[c]; | |
| for(let r=1;r<4;r++) rots.push(rots[r-1].map(([x,y])=>[n-1-y,x])); | |
| ROT[t]= t==='O' ? [c,c,c,c] : rots; | |
| } | |
| // SRS壁キック表(y上正で定義 → 適用時にy反転) | |
| const KICKS_JLSTZ={ | |
| '01':[[0,0],[-1,0],[-1,1],[0,-2],[-1,-2]], '10':[[0,0],[1,0],[1,-1],[0,2],[1,2]], | |
| '12':[[0,0],[1,0],[1,-1],[0,2],[1,2]], '21':[[0,0],[-1,0],[-1,1],[0,-2],[-1,-2]], | |
| '23':[[0,0],[1,0],[1,1],[0,-2],[1,-2]], '32':[[0,0],[-1,0],[-1,-1],[0,2],[-1,2]], | |
| '30':[[0,0],[-1,0],[-1,-1],[0,2],[-1,2]],'03':[[0,0],[1,0],[1,1],[0,-2],[1,-2]], | |
| }; | |
| const KICKS_I={ | |
| '01':[[0,0],[-2,0],[1,0],[-2,-1],[1,2]], '10':[[0,0],[2,0],[-1,0],[2,1],[-1,-2]], | |
| '12':[[0,0],[-1,0],[2,0],[-1,2],[2,-1]], '21':[[0,0],[1,0],[-2,0],[1,-2],[-2,1]], | |
| '23':[[0,0],[2,0],[-1,0],[2,1],[-1,-2]], '32':[[0,0],[-2,0],[1,0],[-2,-1],[1,2]], | |
| '30':[[0,0],[1,0],[-2,0],[1,-2],[-2,1]], '03':[[0,0],[-1,0],[2,0],[-1,2],[2,-1]], | |
| }; | |
| /* ================= effect themes (10ラインごとに切替) ================= */ | |
| const THEMES=[ | |
| { name:'SPARK', jp:'スパーク', cols:['#ffffff','#ffe9a8','#ffd166'], flash:'#ffffff', shake:1.0, mix:['spark','spark','ring'], bg:[[220,32,7],[224,36,12]] }, | |
| { name:'NEON', jp:'ネオン', cols:['#ff2df7','#00f0ff','#a6ff00','#ffe600'], flash:'#ff2df7', shake:1.15, mix:['sq','sq','ring','spark'], bg:[[288,45,8],[192,60,10]] }, | |
| { name:'BLAZE', jp:'ブレイズ', cols:['#ff4d00','#ffb300','#ff2200','#ffe08a'], flash:'#ff6a00', shake:1.3, mix:['ember','ember','smoke','spark'], bg:[[18,62,8],[352,55,10]] }, | |
| { name:'HANABI', jp:'ハナビ', cols:null, flash:'#ffffff', shake:1.25, rain:true, mix:['rocket','star','spark','ring'], bg:[[328,42,8],[258,50,11]] }, | |
| { name:'RAINBOW',jp:'レインボー', cols:null, flash:'#ffffff', shake:1.2, rain:true, mix:['conf','conf','star','ring'], bg:[[208,55,9],[132,45,9]] }, | |
| { name:'NOVA', jp:'スーパーノヴァ',cols:['#cfe0ff','#ffffff','#9db4ff','#e0c3ff'], flash:'#cfe0ff', shake:1.5, mix:['warp','ray','ring','spark'], bg:[[232,50,8],[266,56,12]] }, | |
| ]; | |
| const themeColor=th=> th.rain ? `hsl(${irand(0,360)},100%,62%)` : choice(th.cols); | |
| /* ================= audio ================= */ | |
| const AudioSys={ | |
| ctx:null, master:null, sfx:null, music:null, noiseBuf:null, delaySend:null, muted:false, | |
| init(){ | |
| if(this.ctx) return; | |
| const C=window.AudioContext||window.webkitAudioContext; if(!C) return; | |
| const ctx=this.ctx=new C(); | |
| const master=this.master=ctx.createGain(); master.gain.value=this.muted?0:.85; | |
| const comp=ctx.createDynamicsCompressor(); | |
| master.connect(comp); comp.connect(ctx.destination); | |
| const sfx=this.sfx=ctx.createGain(); sfx.gain.value=.9; sfx.connect(master); | |
| const music=this.music=ctx.createGain(); music.gain.value=.5; music.connect(master); | |
| const nb=ctx.createBuffer(1,ctx.sampleRate,ctx.sampleRate); | |
| const d=nb.getChannelData(0); for(let i=0;i<d.length;i++) d[i]=Math.random()*2-1; | |
| this.noiseBuf=nb; | |
| const dl=ctx.createDelay(1); dl.delayTime.value=.23; | |
| const fb=ctx.createGain(); fb.gain.value=.3; | |
| const wet=ctx.createGain(); wet.gain.value=.16; | |
| dl.connect(fb); fb.connect(dl); dl.connect(wet); wet.connect(music); | |
| this.delaySend=dl; | |
| setInterval(()=>BGM.schedule(),25); | |
| }, | |
| resume(){ if(this.ctx&&this.ctx.state==='suspended') this.ctx.resume(); }, | |
| t(){ return this.ctx?this.ctx.currentTime:0; }, | |
| toggleMute(){ this.muted=!this.muted; if(this.master) this.master.gain.value=this.muted?0:.85; return this.muted; }, | |
| }; | |
| function beep(t0,f,dur,o={}){ | |
| const ctx=AudioSys.ctx; if(!ctx||!t0&&t0!==0) return; | |
| const {type='square',vol=.2,slide=0,out=AudioSys.sfx}=o; | |
| const osc=ctx.createOscillator(), g=ctx.createGain(); | |
| osc.type=type; osc.frequency.setValueAtTime(Math.max(20,f),t0); | |
| if(slide) osc.frequency.exponentialRampToValueAtTime(Math.max(25,slide),t0+dur); | |
| g.gain.setValueAtTime(0,t0); | |
| g.gain.linearRampToValueAtTime(vol,t0+.006); | |
| g.gain.exponentialRampToValueAtTime(.0001,t0+dur); | |
| osc.connect(g); g.connect(out); osc.start(t0); osc.stop(t0+dur+.03); | |
| } | |
| function noise(t0,dur,o={}){ | |
| const ctx=AudioSys.ctx; if(!ctx) return; | |
| const {vol=.3,hp=0,lp=0,slideLp=0,out=AudioSys.sfx}=o; | |
| const s=ctx.createBufferSource(); s.buffer=AudioSys.noiseBuf; s.loop=true; | |
| let node=s; | |
| if(hp){ const f=ctx.createBiquadFilter(); f.type='highpass'; f.frequency.value=hp; node.connect(f); node=f; } | |
| if(lp||slideLp){ const f=ctx.createBiquadFilter(); f.type='lowpass'; f.frequency.setValueAtTime(lp||18000,t0); | |
| if(slideLp) f.frequency.exponentialRampToValueAtTime(slideLp,t0+dur); node.connect(f); node=f; } | |
| const g=ctx.createGain(); g.gain.setValueAtTime(vol,t0); g.gain.exponentialRampToValueAtTime(.0001,t0+dur); | |
| node.connect(g); g.connect(out); s.start(t0); s.stop(t0+dur+.03); | |
| } | |
| const now=()=>AudioSys.t()+.001; | |
| const SFX={ | |
| move(){ beep(now(),220,.035,{type:'square',vol:.07}); }, | |
| rotate(){ beep(now(),320,.06,{vol:.11,slide:520}); }, | |
| tick(){ beep(now(),150,.025,{type:'triangle',vol:.05}); }, | |
| hard(){ noise(now(),.16,{vol:.5,lp:900,slideLp:110}); beep(now(),85,.13,{type:'sine',vol:.5,slide:38}); }, | |
| lock(){ beep(now(),140,.05,{type:'triangle',vol:.18,slide:85}); }, | |
| hold(){ noise(now(),.09,{vol:.16,hp:500}); beep(now(),300,.09,{vol:.07,slide:640}); }, | |
| clear(n,b2b){ | |
| const t=now(), base=440*Math.pow(2,n*2/12), scale=[0,4,7,12,16,19,24]; | |
| for(let i=0;i<3+n;i++) beep(t+i*.055,base*Math.pow(2,scale[i]/12),.18,{vol:.2}); | |
| if(n>=4){ noise(t,.5,{vol:.35,hp:1800}); beep(t,110,.42,{type:'sawtooth',vol:.22,slide:55}); } | |
| if(b2b) for(let i=0;i<5;i++) beep(t+.28+i*.05,1400+i*260,.09,{type:'sine',vol:.1}); | |
| }, | |
| tspin(){ const t=now(), scale=[0,3,7,10,12,15]; | |
| for(let i=0;i<6;i++) beep(t+i*.045,587*Math.pow(2,scale[i]/12),.14,{vol:.16}); | |
| beep(t,880,.25,{type:'sine',vol:.08,slide:3000}); }, | |
| combo(c){ beep(now(),480*Math.pow(2,Math.min(c,10)/12),.09,{vol:.13,slide:700}); }, | |
| levelup(){ const t=now(); | |
| [72,76,79,84].forEach((m,i)=>beep(t+i*.09,midiF(m),.2,{vol:.2})); | |
| noise(t,.35,{vol:.15,hp:900}); }, | |
| over(){ const t=now(); | |
| [64,60,57,52].forEach((m,i)=>beep(t+i*.22,midiF(m),.45,{type:'triangle',vol:.24})); }, | |
| ready(){ beep(now(),660,.12,{vol:.18}); }, | |
| go(){ const t=now(); beep(t,880,.2,{vol:.22}); beep(t,1318,.22,{vol:.14}); }, | |
| pause(){ beep(now(),392,.09,{vol:.15}); }, | |
| pop(){ noise(now(),.12,{vol:.12,hp:1200}); }, | |
| }; | |
| /* ---------- BGM: コロベイニキ(民謡)をステップシーケンサで合成 ---------- */ | |
| const LEAD=[ | |
| [76,1],[71,.5],[72,.5],[74,1],[72,.5],[71,.5], | |
| [69,1],[69,.5],[72,.5],[76,1],[74,.5],[72,.5], | |
| [71,1.5],[72,.5],[74,1],[76,1], | |
| [72,1],[69,1],[69,2], | |
| [0,.5],[74,1],[77,.5],[81,1],[79,.5],[77,.5], | |
| [76,1.5],[72,.5],[76,1],[74,.5],[72,.5], | |
| [71,1],[71,.5],[72,.5],[74,1],[76,1], | |
| [72,1],[69,1],[69,1],[0,1], | |
| [76,1],[72,1],[74,1],[71,1], | |
| [72,1],[69,1],[68,1],[71,1], | |
| [76,1],[72,1],[74,1],[71,1], | |
| [72,1],[76,1],[81,1],[81,1], | |
| [80,2],[0,2], | |
| ]; | |
| const BASS_BARS=[40,45,47,45,50,48,47,45, 45,40,45,45,40]; // 小節ごとのルート音 | |
| const TOTAL_STEPS=52*4; | |
| const leadMap={}, bassMap={}; | |
| { | |
| let s=0; | |
| for(const [m,b] of LEAD){ if(m) leadMap[s]={m,b}; s+=Math.round(b*4); } | |
| BASS_BARS.forEach((root,bar)=>{ for(let k=0;k<8;k++) bassMap[bar*16+k*2]= k%2? root+12 : root; }); | |
| } | |
| const bpm=()=>clamp(138+(G.level-1)*3,138,192); | |
| const BGM={ | |
| playing:false, step:0, nextT:0, | |
| startNew(){ this.step=0; this.resume(); }, | |
| resume(){ if(!AudioSys.ctx) return; this.playing=true; this.nextT=AudioSys.t()+.1; }, | |
| stop(){ this.playing=false; }, | |
| schedule(){ | |
| if(!this.playing||!AudioSys.ctx||AudioSys.ctx.state!=='running') return; | |
| const ctx=AudioSys.ctx; | |
| let guard=0; | |
| while(this.nextT<ctx.currentTime+.15&&guard++<64){ | |
| this.playStep(this.step%TOTAL_STEPS,this.nextT); | |
| this.nextT+=(60/bpm())/4; this.step++; | |
| } | |
| }, | |
| playStep(s,t){ | |
| const spb=60/bpm(), st=spb/4; | |
| const le=leadMap[s]; | |
| if(le) this.leadNote(midiF(le.m),t,le.b*spb); | |
| const b=bassMap[s]; | |
| if(b) this.bassNote(midiF(b),t,st*1.8); | |
| if(s%2===0) this.hat(t,s%4===0); | |
| if(s%4===0&&AudioSys.ctx){ const d=Math.max(0,(t-AudioSys.ctx.currentTime)*1000); | |
| setTimeout(()=>{ FX.pulse=1; },d); } | |
| }, | |
| leadNote(f,t,dur){ | |
| const ctx=AudioSys.ctx; | |
| const g=ctx.createGain(); | |
| g.gain.setValueAtTime(0,t); | |
| g.gain.linearRampToValueAtTime(.13,t+.012); | |
| g.gain.setTargetAtTime(.07,t+.012,.06); | |
| g.gain.setTargetAtTime(.0001,t+Math.max(.02,dur-.04),.02); | |
| const fl=ctx.createBiquadFilter(); fl.type='lowpass'; fl.frequency.value=2600; fl.Q.value=.7; | |
| fl.connect(g); | |
| for(const det of [-5,5]){ | |
| const o=ctx.createOscillator(); o.type='square'; o.frequency.value=f; o.detune.value=det; | |
| o.connect(fl); o.start(t); o.stop(t+dur+.08); | |
| } | |
| g.connect(AudioSys.music); g.connect(AudioSys.delaySend); | |
| }, | |
| bassNote(f,t,dur){ | |
| const ctx=AudioSys.ctx; | |
| const o=ctx.createOscillator(); o.type='triangle'; o.frequency.value=f; | |
| const g=ctx.createGain(); | |
| g.gain.setValueAtTime(0,t); g.gain.linearRampToValueAtTime(.23,t+.01); | |
| g.gain.setTargetAtTime(.0001,t+dur*.7,.05); | |
| o.connect(g); g.connect(AudioSys.music); o.start(t); o.stop(t+dur+.1); | |
| }, | |
| hat(t,acc){ | |
| const ctx=AudioSys.ctx; | |
| const s=ctx.createBufferSource(); s.buffer=AudioSys.noiseBuf; s.loop=true; | |
| const f=ctx.createBiquadFilter(); f.type='highpass'; f.frequency.value=6500; | |
| const g=ctx.createGain(); | |
| g.gain.setValueAtTime(acc?.05:.028,t); g.gain.exponentialRampToValueAtTime(.0001,t+.045); | |
| s.connect(f); f.connect(g); g.connect(AudioSys.music); s.start(t); s.stop(t+.06); | |
| }, | |
| }; | |
| /* ================= game state ================= */ | |
| const G={ | |
| grid:[], cur:null, hold:null, canHold:true, bag:[], queue:[], | |
| score:0, lines:0, level:1, startLevel:1, combo:-1, b2b:false, | |
| state:'TITLE', prevState:'PLAYING', readyT:0, goShown:false, overAt:0, | |
| dropAcc:0, lockT:0, lockResets:0, lowestY:0, grounded:false, | |
| lastRotate:false, lastKick:0, | |
| dasDir:0, dasT:0, dasArr:0, leftHeld:false, rightHeld:false, softHeld:false, | |
| clearAnim:null, | |
| stats:{pieces:0,tetrises:0,tspins:0,maxCombo:0,time:0}, | |
| }; | |
| let dispScore=0, lastScoreShown=-1, lastTickAt=0; | |
| function emptyGrid(){ return Array.from({length:ROWS},()=>new Array(COLS).fill(0)); } | |
| function gravitySec(lv){ const l=Math.min(lv,20); return Math.max(Math.pow(.8-(l-1)*.007,l-1),.0008); } | |
| function collide(cells,px,py){ | |
| for(const [cx,cy] of cells){ | |
| const x=px+cx, y=py+cy; | |
| if(x<0||x>=COLS||y>=ROWS) return true; | |
| if(y>=0&&G.grid[y][x]) return true; | |
| } | |
| return false; | |
| } | |
| const curCells=()=>ROT[G.cur.type][G.cur.rot]; | |
| const isGrounded=()=>G.cur&&collide(curCells(),G.cur.x,G.cur.y+1); | |
| function ghostYOf(cur){ | |
| let y=cur.y; const cells=ROT[cur.type][cur.rot]; | |
| while(!collide(cells,cur.x,y+1)) y++; | |
| return y; | |
| } | |
| function refillQueue(){ | |
| while(G.queue.length<7){ | |
| if(!G.bag.length) G.bag=shuffle(['I','J','L','O','S','T','Z']); | |
| G.queue.push(G.bag.pop()); | |
| } | |
| } | |
| function spawnPiece(t){ | |
| G.cur={type:t,rot:0,x:3,y:0}; | |
| G.dropAcc=0; G.lockT=0; G.lockResets=0; G.lowestY=0; G.lastRotate=false; G.grounded=false; | |
| if(collide(ROT[t][0],3,0)){ G.grounded=true; gameOver(); } // block out | |
| } | |
| function spawnFromQueue(){ | |
| refillQueue(); | |
| spawnPiece(G.queue.shift()); | |
| refillQueue(); | |
| G.canHold=true; | |
| } | |
| function resetGame(){ | |
| G.grid=emptyGrid(); | |
| G.bag=[]; G.queue=[]; G.hold=null; G.canHold=true; | |
| G.score=0; G.lines=0; G.level=G.startLevel; G.combo=-1; G.b2b=false; | |
| G.clearAnim=null; G.cur=null; G.dasDir=0; G.softHeld=false; | |
| G.readyT=0; G.goShown=false; | |
| G.stats={pieces:0,tetrises:0,tspins:0,maxCombo:0,time:0}; | |
| dispScore=0; lastScoreShown=-1; | |
| FX.parts.length=0; FX.themeIdx=0; | |
| } | |
| function start(){ | |
| resetGame(); | |
| showScreen(null); | |
| G.state='READY'; G.readyT=0; | |
| FX.popup('READY',{color:'#7de7ff',size:1.2}); | |
| SFX.ready(); | |
| applyTheme(false); | |
| } | |
| function applyTheme(announce){ | |
| const th=THEMES[FX.themeIdx]; | |
| const accent=th.main||(th.cols&&th.cols[0])||'#ffffff'; | |
| document.documentElement.style.setProperty('--accent',accent); | |
| FX.bgTgt=th.bg; | |
| themeV.textContent=`${th.name} ・ ${th.jp}`; | |
| if(announce){ | |
| FX.popup('LEVEL '+G.level,{color:'#ffffff',size:1.1,dy:0}); | |
| FX.popup('エフェクト変化: '+th.jp+'!',{color:accent,size:.8,dy:46,sub:th.name}); | |
| SFX.levelup(); | |
| FX.themeBurst(th); | |
| } | |
| } | |
| /* ---------- input actions ---------- */ | |
| function pressDir(d){ G.dasDir=d; G.dasT=0; G.dasArr=0; tryShift(d); } | |
| function releaseDir(d){ | |
| if(G.dasDir===d){ | |
| if(d===-1&&G.rightHeld) pressDir(1); | |
| else if(d===1&&G.leftHeld) pressDir(-1); | |
| else G.dasDir=0; | |
| } | |
| } | |
| function tryShift(dx){ | |
| const cur=G.cur; if(!cur||G.state!=='PLAYING') return false; | |
| if(collide(curCells(),cur.x+dx,cur.y)) return false; | |
| cur.x+=dx; G.lastRotate=false; | |
| SFX.move(); | |
| if(isGrounded()) lockReset(); | |
| return true; | |
| } | |
| function rotatePiece(dir){ | |
| const cur=G.cur; if(!cur||G.state!=='PLAYING') return; | |
| if(cur.type==='O'){ SFX.rotate(); return; } | |
| const from=cur.rot, to=(from+dir+4)%4; | |
| const table=(cur.type==='I'?KICKS_I:KICKS_JLSTZ)[''+from+to]; | |
| const cells=ROT[cur.type][to]; | |
| for(let i=0;i<table.length;i++){ | |
| const nx=cur.x+table[i][0], ny=cur.y-table[i][1]; // キック表はy上正 | |
| if(!collide(cells,nx,ny)){ | |
| cur.rot=to; cur.x=nx; cur.y=ny; | |
| G.lastRotate=true; G.lastKick=i; | |
| SFX.rotate(); | |
| if(isGrounded()) lockReset(); else G.lockT=0; | |
| return; | |
| } | |
| } | |
| } | |
| function lockReset(){ | |
| if(G.lockResets<MAX_RESETS){ G.lockT=0; G.lockResets++; } | |
| else G.lockT=LOCK_DELAY; | |
| } | |
| function fallStep(soft){ | |
| const cur=G.cur; if(!cur) return false; | |
| if(collide(curCells(),cur.x,cur.y+1)) return false; | |
| cur.y++; G.lastRotate=false; | |
| if(cur.y>G.lowestY){ G.lowestY=cur.y; G.lockResets=0; G.lockT=0; } | |
| if(soft){ | |
| G.score++; | |
| const t=performance.now(); | |
| if(t-lastTickAt>45){ SFX.tick(); lastTickAt=t; } | |
| } | |
| return true; | |
| } | |
| function hardDrop(){ | |
| const cur=G.cur; if(!cur||G.state!=='PLAYING') return; | |
| let d=0; while(fallStep(false)) d++; | |
| G.score+=d*2; | |
| FX.hardDropTrail(cur,d); | |
| FX.addShake(1.4); | |
| SFX.hard(); | |
| lockPiece(); | |
| } | |
| function doHold(){ | |
| if(!G.canHold||!G.cur||G.state!=='PLAYING') return; | |
| SFX.hold(); | |
| const t=G.cur.type; | |
| if(G.hold){ const h=G.hold; G.hold=t; spawnPiece(h); } | |
| else { G.hold=t; spawnFromQueue(); } | |
| G.canHold=false; | |
| } | |
| function togglePause(){ | |
| if(G.state==='PLAYING'||G.state==='CLEARING'){ | |
| G.prevState=G.state; G.state='PAUSED'; | |
| BGM.stop(); SFX.pause(); showScreen('pause'); | |
| } else if(G.state==='PAUSED'){ | |
| G.state=G.prevState; showScreen(null); | |
| if(G.level>0) BGM.resume(); | |
| } | |
| } | |
| function gameOver(){ | |
| G.state='OVER'; G.overAt=performance.now(); | |
| BGM.stop(); SFX.over(); | |
| FX.gameOverBurst(); | |
| $('ovScore').textContent=G.score.toLocaleString(); | |
| $('ovLines').textContent=G.lines; | |
| $('ovLevel').textContent=G.level; | |
| $('ovTime').textContent=fmtTime(G.stats.time); | |
| $('ovPieces').textContent=G.stats.pieces; | |
| $('ovTspin').textContent=G.stats.tspins; | |
| $('ovTetris').textContent=G.stats.tetrises; | |
| $('ovCombo').textContent=Math.max(0,G.stats.maxCombo); | |
| showScreen('over'); | |
| } | |
| const fmtTime=s=>`${Math.floor(s/60)}:${String(Math.floor(s%60)).padStart(2,'0')}`; | |
| /* ---------- lock / clear / scoring ---------- */ | |
| function detectTspin(){ | |
| const cur=G.cur; | |
| if(!cur||cur.type!=='T'||!G.lastRotate) return null; | |
| const cx=cur.x+1, cy=cur.y+1; | |
| const occ=(x,y)=>x<0||x>=COLS||y>=ROWS||(y>=0&&G.grid[y][x]); | |
| const c=[[-1,-1],[1,-1],[1,1],[-1,1]].map(([dx,dy])=>occ(cx+dx,cy+dy)?1:0); | |
| if(c[0]+c[1]+c[2]+c[3]<3) return null; | |
| const frontIdx={0:[0,1],1:[1,2],2:[2,3],3:[3,0]}[cur.rot]; | |
| const mini=(c[frontIdx[0]]+c[frontIdx[1]])<2&&G.lastKick<4; // 4番キックはminiをfullに昇格 | |
| return {mini}; | |
| } | |
| function lockPiece(){ | |
| const cur=G.cur; if(!cur) return; | |
| let allHidden=true; | |
| for(const [cx,cy] of curCells()){ | |
| const x=cur.x+cx, y=cur.y+cy; | |
| if(y>=0) G.grid[y][x]=TYPE_ID[cur.type]; | |
| if(y>=HIDDEN) allHidden=false; | |
| } | |
| G.stats.pieces++; | |
| FX.lockPuff(cur); | |
| SFX.lock(); | |
| const ts=detectTspin(); | |
| G.cur=null; | |
| if(allHidden){ gameOver(); return; } // lock out | |
| resolveLock(ts); | |
| } | |
| function scoreClear(n,ts){ | |
| let pts,label; | |
| if(ts){ | |
| pts=ts.mini?[0,200,400,400][n]:[400,800,1200,1600][n]; | |
| label=(ts.mini?'T-SPIN MINI':'T-SPIN')+['',' SINGLE',' DOUBLE',' TRIPLE'][n]; | |
| G.stats.tspins++; | |
| } else { | |
| pts=[0,100,300,500,800][n]; | |
| label=['','SINGLE','DOUBLE','TRIPLE','TETRIS!'][n]; | |
| if(n===4) G.stats.tetrises++; | |
| } | |
| const difficult=n===4||!!ts; | |
| let b2bApplied=false; | |
| if(difficult&&G.b2b){ pts=Math.floor(pts*1.5); b2bApplied=true; } | |
| G.b2b=difficult; | |
| G.score+=pts*G.level; | |
| G.combo++; | |
| if(G.combo>=1){ | |
| G.score+=50*G.combo*G.level; | |
| G.stats.maxCombo=Math.max(G.stats.maxCombo,G.combo); | |
| } | |
| G.lines+=n; | |
| const nl=Math.min(G.startLevel+Math.floor(G.lines/10),29); | |
| if(nl>G.level) G.level=nl; | |
| const ti=Math.floor(G.lines/10)%THEMES.length; | |
| if(ti!==FX.themeIdx){ FX.themeIdx=ti; applyTheme(true); } | |
| return {label,b2bApplied}; | |
| } | |
| function resolveLock(ts){ | |
| const rows=[]; | |
| for(let y=0;y<ROWS;y++){ | |
| let full=true; | |
| for(let x=0;x<COLS;x++) if(!G.grid[y][x]){ full=false; break; } | |
| if(full) rows.push(y); | |
| } | |
| if(rows.length){ | |
| const n=rows.length; | |
| const info=scoreClear(n,ts); | |
| G.clearAnim={rows,t:0}; G.prevState='PLAYING'; G.state='CLEARING'; | |
| FX.lineClear(rows,n,ts,info); | |
| SFX.clear(n,info.b2bApplied); | |
| if(ts) SFX.tspin(); | |
| if(G.combo>=1) SFX.combo(G.combo); | |
| } else { | |
| if(ts){ | |
| G.score+=(ts.mini?100:400)*G.level; | |
| FX.popup(ts.mini?'T-SPIN MINI':'T-SPIN',{color:'#e07dff',size:.9}); | |
| FX.tspinBurst(ts.mini); | |
| SFX.tspin(); | |
| } | |
| G.combo=-1; | |
| spawnFromQueue(); | |
| } | |
| } | |
| /* ================= FX (particle / shake / flash / bg) ================= */ | |
| const FX={ | |
| parts:[], shake:0, flash:0, flashColor:'#fff', pulse:0, hitstop:0, | |
| themeIdx:0, bgCur:[[220,32,7],[224,36,12]], bgTgt:[[220,32,7],[224,36,12]], | |
| fr:null, ambT:0, attT:0, frozen:false, | |
| add(p){ if(this.parts.length<900) this.parts.push(p); }, | |
| refreshRect(){ const r=fieldCv.getBoundingClientRect(); if(r.width>0) this.fr=r; }, | |
| addShake(a){ this.shake=Math.min(30,this.shake+a); }, | |
| addFlash(a,color){ this.flash=Math.min(.85,Math.max(this.flash,a)); this.flashColor=color||'#ffffff'; }, | |
| popup(text,o={}){ | |
| this.refreshRect(); | |
| const fr=this.fr; if(!fr) return; | |
| this.add({type:'text',text,x:fr.left+fr.width*.5,y:fr.top+fr.height*.24+(o.dy||0), | |
| ttl:1.05,t:0,vy:-30,size:o.size||1,color:o.color||'#fff',sub:o.sub||null}); | |
| }, | |
| ring(x,y,color,r1,ttl=.5){ this.add({type:'ring',x,y,r0:4,r1,ttl,t:0,color,lw:3.5}); }, | |
| spawnTheme(x,y,th,pow=1){ | |
| const color=themeColor(th); | |
| switch(choice(th.mix)){ | |
| case 'spark': this.add({type:'spark',x,y,vx:rand(-260,260)*pow,vy:rand(-340,60)*pow,g:640,drag:1.6,ttl:rand(.35,.8),t:0,color,w:rand(1.5,3)}); break; | |
| case 'sq': this.add({type:'sq',x,y,vx:rand(-200,200)*pow,vy:rand(-300,40)*pow,g:430,drag:.7,vr:rand(-9,9),rot:rand(TAU),s:rand(3,7)*pow,ttl:rand(.4,.9),t:0,color}); break; | |
| case 'ember': this.add({type:'ember',x,y,vx:rand(-70,70),vy:rand(-170,-40),g:-130,drag:.6,ttl:rand(.5,1.1),t:0,color,r:rand(1.5,3.2)}); break; | |
| case 'smoke': this.add({type:'smoke',x,y,vx:rand(-30,30),vy:rand(-60,-10),g:-30,s:rand(6,14),grow:20,ttl:rand(.7,1.3),t:0}); break; | |
| case 'ring': this.ring(x,y,color,rand(30,90)*pow); break; | |
| case 'star': this.add({type:'star',x,y,vx:rand(-240,240)*pow,vy:rand(-320,60)*pow,g:400,drag:.8,vr:rand(-7,7),rot:rand(TAU),s:rand(4,9),ttl:rand(.5,1),t:0,color}); break; | |
| case 'conf': this.add({type:'conf',x,y,vx:rand(-220,220)*pow,vy:rand(-320,20)*pow,g:340,drag:.8,vr:rand(-10,10),rot:rand(TAU),w:rand(3,6),h:rand(5,9),phase:rand(TAU),ttl:rand(.9,1.6),t:0,color}); break; | |
| case 'rocket': this.add({type:'rocket',x,y:Math.max(y,innerHeight*.6),vx:rand(-60,60),vy:rand(-540,-400),g:170,ttl:rand(.55,.9),t:0,color,px:x,py:y}); break; | |
| case 'warp': { const ang=rand(TAU); this.add({type:'warp',x,y,vx:Math.cos(ang)*rand(500,900),vy:Math.sin(ang)*rand(500,900),drag:.4,len:rand(40,90),ttl:rand(.3,.5),t:0,color:'#dfe8ff',w:rand(1,2.5),ang}); break; } | |
| case 'ray': { const ang=rand(TAU); this.add({type:'ray',x,y,ang,r1:rand(180,320)*pow,ttl:.38,t:0,color,w:rand(2,4)}); break; } | |
| } | |
| }, | |
| lineClear(rows,n,ts,info){ | |
| this.refreshRect(); | |
| const th=THEMES[this.themeIdx], fr=this.fr; | |
| if(!fr) return; | |
| const cw=fr.width/COLS, ch=fr.height/VISIBLE; | |
| const pow=1+Math.floor(G.lines/60)*.25; | |
| const perCell=n>=4?3:2; | |
| for(const ry of rows){ | |
| const wy=fr.top+(ry-HIDDEN+.5)*ch; | |
| for(let x=0;x<COLS;x++){ | |
| const wx=fr.left+(x+.5)*cw; | |
| for(let i=0;i<perCell;i++) this.spawnTheme(wx+rand(-cw/3,cw/3),wy+rand(-ch/3,ch/3),th,pow); | |
| } | |
| this.add({type:'wipe',x:fr.left+fr.width/2,y:wy,w:fr.width*1.18,h:ch*.95,ttl:.24,t:0}); | |
| if(Math.random()<.85) this.ring(fr.left+rand(fr.width),wy,themeColor(th),cw*(3.5+n*1.6),.45); | |
| } | |
| const cx=fr.left+fr.width/2, cy=fr.top+fr.height/2; | |
| if(n>=4){ | |
| for(let i=0;i<26;i++){ | |
| const ang=i/26*TAU+rand(.12); | |
| this.add({type:'ray',x:cx,y:cy,ang,r1:rand(260,430),ttl:.4,t:0,color:themeColor(th),w:rand(2,4.5)}); | |
| } | |
| this.ring(cx,cy,'#ffffff',Math.max(fr.width,fr.height)*.85,.6); | |
| this.ring(cx,cy,themeColor(th),Math.max(fr.width,fr.height)*.65,.5); | |
| this.hitstop=.13; | |
| for(let i=0;i<3;i++) this.spawnTheme(fr.left+rand(fr.width),fr.bottom-10,th,pow*1.4); | |
| } | |
| if(th.name==='HANABI') for(let i=0;i<2+n;i++) this.spawnTheme(fr.left+rand(fr.width),fr.bottom,th,pow*1.3); | |
| if(th.name==='NOVA') for(let i=0;i<14;i++) this.spawnTheme(fr.left+rand(fr.width),fr.top+rand(fr.height),th,pow); | |
| this.addShake((2.5+n*2.6)*th.shake*(ts?1.3:1)); | |
| this.addFlash(n>=4?.5:.10+.08*n,th.flash); | |
| this.pulse=1; | |
| this.popup(info.label,{color:ts?'#e07dff':n===4?'#ffd166':'#ffffff',size:n===4?1.35:1,dy:0}); | |
| if(info.b2bApplied) this.popup('BACK-TO-BACK ×1.5',{color:'#ffd166',size:.62,dy:42}); | |
| if(G.combo>=1) this.popup(G.combo+' COMBO',{color:`hsl(${(G.combo*42)%360},100%,72%)`,size:.62,dy:78}); | |
| }, | |
| tspinBurst(mini){ | |
| const fr=this.fr, cur=G.cur; | |
| if(!fr) return; | |
| const cw=fr.width/COLS, ch=fr.height/VISIBLE; | |
| const x=cur?fr.left+(cur.x+1.5)*cw:fr.left+fr.width/2; | |
| const y=cur?fr.top+(cur.y+1.5-HIDDEN)*ch:fr.top+fr.height/3; | |
| this.ring(x,y,'#e07dff',mini?60:110,.5); | |
| for(let i=0;i<14;i++) this.add({type:'spark',x,y,vx:Math.cos(i/14*TAU)*rand(120,220),vy:Math.sin(i/14*TAU)*rand(120,220),g:60,drag:2,ttl:rand(.3,.6),t:0,color:'#c76dff',w:rand(1.5,2.5)}); | |
| }, | |
| hardDropTrail(cur,dist){ | |
| const fr=this.fr; if(!fr||dist<2) return; | |
| const cw=fr.width/COLS, ch=fr.height/VISIBLE; | |
| const cells=curCells(); | |
| const startY=cur.y-dist; | |
| const cols={}; | |
| for(const [cx,cy] of cells) if(!(cx in cols)||cy<cols[cx]) cols[cx]=cy; | |
| for(const cx in cols){ | |
| const x=+cx; | |
| this.add({type:'trail',x:fr.left+(x+.5)*cw,y0:fr.top+(startY+cols[cx]-HIDDEN)*ch, | |
| y1:fr.top+(cur.y+cols[cx]+1-HIDDEN)*ch,w:cw*.85,ttl:.22,t:0,color:COLORS[TYPE_ID[cur.type]]}); | |
| } | |
| }, | |
| lockPuff(cur){ | |
| const fr=this.fr; if(!fr) return; | |
| const cw=fr.width/COLS, ch=fr.height/VISIBLE; | |
| const cells=curCells(); | |
| for(let i=0;i<5;i++){ | |
| const [cx,cy]=choice(cells); | |
| this.add({type:'spark',x:fr.left+(cur.x+cx+.5)*cw,y:fr.top+(cur.y+cy+.5-HIDDEN)*ch, | |
| vx:rand(-90,90),vy:rand(-70,10),g:300,drag:2.5,ttl:rand(.2,.4),t:0,color:'#cfd8ff',w:1.5}); | |
| } | |
| }, | |
| themeBurst(th){ | |
| const fr=this.fr; | |
| this.addFlash(.4,th.flash); | |
| this.addShake(9*th.shake); | |
| if(fr){ | |
| for(let i=0;i<36;i++) this.spawnTheme(fr.left+rand(fr.width),fr.top+rand(fr.height),th,1.2); | |
| this.ring(fr.left+fr.width/2,fr.top+fr.height/2,'#ffffff',Math.max(fr.width,fr.height)*.9,.7); | |
| for(let i=0;i<3;i++) this.spawnTheme(fr.left+rand(fr.width),fr.bottom,th,1.5); | |
| } | |
| }, | |
| gameOverBurst(){ | |
| const fr=this.fr; if(!fr) return; | |
| for(let i=0;i<40;i++){ | |
| this.add({type:'spark',x:fr.left+rand(fr.width),y:fr.top+rand(fr.height), | |
| vx:rand(-60,60),vy:rand(-160,-20),g:260,drag:1.2,ttl:rand(.6,1.4),t:0,color:choice(['#8a93b8','#5c6488','#b8bfd8']),w:rand(1,2.5)}); | |
| } | |
| this.addShake(6); this.addFlash(.25,'#8899ff'); | |
| }, | |
| update(dt){ | |
| for(const [i,c] of [[0,0],[1,1]]) this.bgCur[i]=[lerp(this.bgCur[i][0],this.bgTgt[i][0],Math.min(1,dt*1.6)), | |
| lerp(this.bgCur[i][1],this.bgTgt[i][1],Math.min(1,dt*1.6)), lerp(this.bgCur[i][2],this.bgTgt[i][2],Math.min(1,dt*1.6))]; | |
| this.shake=Math.max(0,this.shake-(this.shake*5+3)*dt); | |
| this.flash=Math.max(0,this.flash-3*dt); | |
| this.pulse*=Math.exp(-5*dt); | |
| if(this.frozen) return; | |
| for(let i=this.parts.length-1;i>=0;i--){ | |
| const p=this.parts[i]; | |
| p.t+=dt; | |
| if(p.type==='rocket'&&p.t>=p.ttl){ // 打ち上げ花火が破裂 | |
| const n=irand(26,40); | |
| for(let k=0;k<n;k++){ | |
| const ang=k/n*TAU+rand(.15), sp=rand(120,300); | |
| const star=Math.random()<.4; | |
| this.add({type:star?'star':'spark',x:p.x,y:p.y,vx:Math.cos(ang)*sp,vy:Math.sin(ang)*sp, | |
| g:star?260:170,drag:.9,vr:rand(-7,7),rot:rand(TAU),s:rand(3,6),ttl:rand(.5,1),t:0, | |
| color:Math.random()<.75?p.color:themeColor(THEMES[3]),w:rand(1.5,3)}); | |
| } | |
| this.ring(p.x,p.y,p.color,rand(50,90),.4); | |
| SFX.pop(); | |
| this.parts.splice(i,1); | |
| continue; | |
| } | |
| if(p.t>=p.ttl){ this.parts.splice(i,1); continue; } | |
| if(p.drag){ const d=Math.exp(-p.drag*dt); p.vx*=d; p.vy*=d; } | |
| if(p.g) p.vy+=p.g*dt; | |
| p.x+=(p.vx||0)*dt; p.y+=(p.vy||0)*dt; | |
| if(p.vr) p.rot+=p.vr*dt; | |
| if(p.type==='conf') p.x+=Math.sin(p.t*8+p.phase)*40*dt; | |
| } | |
| // 常時くらいの環境パーティクル | |
| this.ambT-=dt; | |
| if(this.ambT<=0&&(G.state==='PLAYING'||G.state==='READY'||G.state==='CLEARING')){ | |
| this.ambT=.45; | |
| const fr=this.fr; | |
| if(fr&&this.parts.length<500){ | |
| const th=THEMES[this.themeIdx]; | |
| this.spawnTheme(fr.left+rand(fr.width),fr.top+fr.height*rand(.6,1),th,.6); | |
| } | |
| } | |
| }, | |
| }; | |
| /* ================= rendering ================= */ | |
| let CS=30, MC=16, dpr=1, SPR=[null], STARS=[]; | |
| function sizeCv(cv,w,h){ | |
| cv.style.width=w+'px'; cv.style.height=h+'px'; | |
| cv.width=Math.round(w*dpr); cv.height=Math.round(h*dpr); | |
| } | |
| function makeBlock(color){ | |
| const s=Math.ceil(CS*dpr); | |
| const cv=document.createElement('canvas'); cv.width=s; cv.height=s; | |
| const g=cv.getContext('2d'); | |
| const r=Math.max(2,s*.16); | |
| g.shadowColor=color; g.shadowBlur=s*.3; g.fillStyle=color; | |
| rr(g,s*.16,s*.16,s*.68,s*.68,r); g.fill(); g.fill(); | |
| g.shadowBlur=0; | |
| const grad=g.createLinearGradient(0,0,0,s); | |
| grad.addColorStop(0,shade(color,.42)); grad.addColorStop(.45,color); grad.addColorStop(1,shade(color,-.28)); | |
| g.fillStyle=grad; | |
| rr(g,s*.06,s*.06,s*.88,s*.88,r); g.fill(); | |
| g.strokeStyle=shade(color,.55); g.lineWidth=Math.max(1,s*.045); | |
| rr(g,s*.1,s*.1,s*.8,s*.8,r*.8); g.stroke(); | |
| g.fillStyle='rgba(255,255,255,.2)'; | |
| rr(g,s*.15,s*.12,s*.7,s*.3,r*.7); g.fill(); | |
| return cv; | |
| } | |
| function buildSprites(){ | |
| SPR=[null]; | |
| for(let i=1;i<=7;i++) SPR.push(makeBlock(COLORS[i])); | |
| } | |
| function layout(){ | |
| dpr=Math.min(window.devicePixelRatio||1,2); | |
| const w=innerWidth, h=innerHeight, narrow=w<640; | |
| const pw=narrow?66:176; | |
| document.documentElement.style.setProperty('--pw',pw+'px'); | |
| CS=clamp(Math.floor(Math.min((h-(narrow?230:80))/VISIBLE,(w-pw*2-40)/COLS)),12,narrow?24:34); | |
| MC=Math.max(7,Math.round(CS*.55)); | |
| sizeCv(fieldCv,COLS*CS,VISIBLE*CS); | |
| sizeCv(holdCv,MC*5,MC*3); | |
| sizeCv(nextCv,MC*5,5*(MC*3+6)); | |
| sizeCv(bgfx,w,h); | |
| sizeCv(fxCv,w,h); | |
| buildSprites(); | |
| STARS=Array.from({length:130},()=>({x:rand(w),y:rand(h),r:rand(.5,1.8),sp:rand(4,26),tw:rand(TAU)})); | |
| } | |
| function drawMini(g,type,cx,cy,mc,alpha=1){ | |
| const cells=ROT[type][0]; | |
| let minx=9,maxx=-9,miny=9,maxy=-9; | |
| for(const [x,y] of cells){ minx=Math.min(minx,x); maxx=Math.max(maxx,x); miny=Math.min(miny,y); maxy=Math.max(maxy,y); } | |
| const w=(maxx-minx+1)*mc, h=(maxy-miny+1)*mc; | |
| g.globalAlpha=alpha; | |
| for(const [x,y] of cells) g.drawImage(SPR[TYPE_ID[type]],cx-w/2+(x-minx)*mc,cy-h/2+(y-miny)*mc,mc,mc); | |
| g.globalAlpha=1; | |
| } | |
| function drawNext(){ | |
| nctx.setTransform(dpr,0,0,dpr,0,0); | |
| const w=nextCv.width/dpr, h=nextCv.height/dpr; | |
| nctx.clearRect(0,0,w,h); | |
| const slotH=MC*3+6; | |
| for(let i=0;i<5&&i<G.queue.length;i++) | |
| drawMini(nctx,G.queue[i],w/2,slotH*i+slotH/2,MC,i===0?1:.75); | |
| } | |
| function drawHold(){ | |
| hctx.setTransform(dpr,0,0,dpr,0,0); | |
| const w=holdCv.width/dpr, h=holdCv.height/dpr; | |
| hctx.clearRect(0,0,w,h); | |
| if(G.hold) drawMini(hctx,G.hold,w/2,h/2,MC,G.canHold?1:.35); | |
| } | |
| function drawField(){ | |
| fctx.setTransform(dpr,0,0,dpr,0,0); | |
| const W=COLS*CS, H=VISIBLE*CS; | |
| fctx.clearRect(0,0,W,H); | |
| const grd=fctx.createLinearGradient(0,0,0,H); | |
| grd.addColorStop(0,'rgba(12,16,34,.92)'); grd.addColorStop(1,'rgba(4,6,14,.96)'); | |
| fctx.fillStyle=grd; fctx.fillRect(0,0,W,H); | |
| fctx.strokeStyle='rgba(140,170,255,.07)'; fctx.lineWidth=1; | |
| fctx.beginPath(); | |
| for(let x=1;x<COLS;x++){ fctx.moveTo(x*CS+.5,0); fctx.lineTo(x*CS+.5,H); } | |
| for(let y=1;y<VISIBLE;y++){ fctx.moveTo(0,y*CS+.5); fctx.lineTo(W,y*CS+.5); } | |
| fctx.stroke(); | |
| const clr=G.clearAnim; | |
| for(let y=HIDDEN;y<ROWS;y++){ | |
| const isClr=clr&&clr.rows.includes(y); | |
| for(let x=0;x<COLS;x++){ | |
| const v=G.grid[y][x]; | |
| if(v) fctx.drawImage(SPR[v],x*CS,(y-HIDDEN)*CS,CS,CS); | |
| } | |
| if(isClr){ | |
| const a=.3+.42*Math.abs(Math.sin(clr.t*38)); | |
| fctx.fillStyle=`rgba(255,255,255,${a})`; | |
| fctx.fillRect(0,(y-HIDDEN)*CS,W,CS); | |
| } | |
| } | |
| const cur=G.cur; | |
| if(cur&&G.state==='PLAYING'){ | |
| const cells=curCells(), col=COLORS[TYPE_ID[cur.type]]; | |
| const gy=ghostYOf(cur); | |
| if(gy>cur.y){ | |
| fctx.fillStyle=colorA(col,.12); fctx.strokeStyle=colorA(col,.55); fctx.lineWidth=1.5; | |
| for(const [cx,cy] of cells){ | |
| const px=(cur.x+cx)*CS, py=(gy+cy-HIDDEN)*CS; | |
| if(gy+cy<HIDDEN) continue; | |
| fctx.fillRect(px+1,py+1,CS-2,CS-2); | |
| fctx.strokeRect(px+1.5,py+1.5,CS-3,CS-3); | |
| } | |
| } | |
| for(const [cx,cy] of cells){ | |
| const py=(cur.y+cy-HIDDEN)*CS; | |
| if(cur.y+cy>=HIDDEN) fctx.drawImage(SPR[TYPE_ID[cur.type]],(cur.x+cx)*CS,py,CS,CS); | |
| } | |
| if(G.grounded&&G.lockT>0){ | |
| fctx.fillStyle=`rgba(255,255,255,${Math.min(.45,G.lockT/LOCK_DELAY*.45)})`; | |
| for(const [cx,cy] of cells){ | |
| if(cur.y+cy<HIDDEN) continue; | |
| fctx.fillRect((cur.x+cx)*CS,(cur.y+cy-HIDDEN)*CS,CS,CS); | |
| } | |
| } | |
| } | |
| } | |
| const BLEND={smoke:'source-over',conf:'source-over',text:'source-over',trail:'source-over',wipe:'lighter'}; | |
| function drawFX(){ | |
| xctx.setTransform(dpr,0,0,dpr,0,0); | |
| xctx.clearRect(0,0,innerWidth,innerHeight); | |
| for(const p of FX.parts){ | |
| const q=clamp(p.t/p.ttl,0,1); | |
| xctx.globalCompositeOperation=BLEND[p.type]||'lighter'; | |
| switch(p.type){ | |
| case 'spark':{ | |
| xctx.strokeStyle=p.color; xctx.globalAlpha=1-q; xctx.lineWidth=p.w; | |
| xctx.beginPath(); xctx.moveTo(p.x,p.y); xctx.lineTo(p.x-p.vx*.03,p.y-p.vy*.03); xctx.stroke(); | |
| break; } | |
| case 'sq':{ | |
| xctx.save(); xctx.translate(p.x,p.y); xctx.rotate(p.rot||0); | |
| xctx.globalAlpha=(1-q)*.9; xctx.fillStyle=p.color; | |
| xctx.fillRect(-p.s/2,-p.s/2,p.s,p.s); | |
| xctx.globalAlpha=(1-q)*.3; xctx.fillRect(-p.s,-p.s,p.s*2,p.s*2); | |
| xctx.restore(); | |
| break; } | |
| case 'ember':{ | |
| xctx.globalAlpha=(1-q)*(Math.random()*.4+.6); | |
| xctx.fillStyle=p.color; | |
| xctx.beginPath(); xctx.arc(p.x,p.y,p.r,0,TAU); xctx.fill(); | |
| break; } | |
| case 'smoke':{ | |
| xctx.globalAlpha=.22*(1-q); | |
| xctx.fillStyle='rgb(165,172,198)'; | |
| xctx.beginPath(); xctx.arc(p.x,p.y,p.s+p.grow*p.t,0,TAU); xctx.fill(); | |
| break; } | |
| case 'ring':{ | |
| xctx.globalAlpha=1-q; xctx.strokeStyle=p.color; | |
| xctx.lineWidth=(1-q)*p.lw+.5; | |
| xctx.beginPath(); xctx.arc(p.x,p.y,lerp(p.r0,p.r1,easeOutCubic(q)),0,TAU); xctx.stroke(); | |
| break; } | |
| case 'star':{ | |
| xctx.save(); xctx.translate(p.x,p.y); xctx.rotate(p.rot||0); | |
| xctx.globalAlpha=1-q; xctx.fillStyle=p.color; | |
| xctx.beginPath(); | |
| for(let i=0;i<10;i++){ | |
| const a=i*Math.PI/5-Math.PI/2, r2=i%2?p.s*.45:p.s; | |
| const px=Math.cos(a)*r2, py=Math.sin(a)*r2; | |
| i?xctx.lineTo(px,py):xctx.moveTo(px,py); | |
| } | |
| xctx.closePath(); xctx.fill(); | |
| xctx.restore(); | |
| break; } | |
| case 'conf':{ | |
| xctx.save(); xctx.translate(p.x,p.y); xctx.rotate(p.rot||0); | |
| xctx.globalAlpha=1-q*q; xctx.fillStyle=p.color; | |
| xctx.fillRect(-p.w/2,-p.h/2,p.w,p.h); | |
| xctx.restore(); | |
| break; } | |
| case 'rocket':{ | |
| xctx.globalAlpha=1; | |
| xctx.strokeStyle=p.color; xctx.lineWidth=2; | |
| xctx.beginPath(); xctx.moveTo(p.px,p.py); xctx.lineTo(p.x,p.y); xctx.stroke(); | |
| xctx.fillStyle='#fff'; | |
| xctx.beginPath(); xctx.arc(p.x,p.y,2.2,0,TAU); xctx.fill(); | |
| p.px=p.x; p.py=p.y; | |
| break; } | |
| case 'warp':{ | |
| xctx.globalAlpha=1-q; xctx.strokeStyle=p.color; xctx.lineWidth=p.w; | |
| xctx.beginPath(); xctx.moveTo(p.x,p.y); | |
| xctx.lineTo(p.x-Math.cos(p.ang)*p.len*(1-q*.4),p.y-Math.sin(p.ang)*p.len*(1-q*.4)); | |
| xctx.stroke(); | |
| break; } | |
| case 'ray':{ | |
| xctx.globalAlpha=1-q; xctx.strokeStyle=p.color; xctx.lineWidth=(1-q)*p.w+.5; | |
| xctx.beginPath(); | |
| xctx.moveTo(p.x+Math.cos(p.ang)*p.r1*q*.25,p.y+Math.sin(p.ang)*p.r1*q*.25); | |
| xctx.lineTo(p.x+Math.cos(p.ang)*p.r1*q,p.y+Math.sin(p.ang)*p.r1*q); | |
| xctx.stroke(); | |
| break; } | |
| case 'wipe':{ | |
| if(!Number.isFinite(p.x+p.w)) break; | |
| xctx.globalAlpha=(1-q)*.85; | |
| const g2=xctx.createLinearGradient(p.x-p.w/2,0,p.x+p.w/2,0); | |
| g2.addColorStop(0,'rgba(255,255,255,0)'); | |
| g2.addColorStop(.5,'rgba(255,255,255,.95)'); | |
| g2.addColorStop(1,'rgba(255,255,255,0)'); | |
| xctx.fillStyle=g2; | |
| xctx.fillRect(p.x-p.w/2,p.y-p.h/2*(1-q),p.w,p.h*(1-q)); | |
| break; } | |
| case 'trail':{ | |
| xctx.globalAlpha=(1-q)*.4; xctx.fillStyle=p.color; | |
| const yy=lerp(p.y0,p.y1,easeOutCubic(q)); | |
| xctx.fillRect(p.x-p.w/2,yy,p.w,p.y1-yy); | |
| break; } | |
| case 'text':{ | |
| const sc=p.t<.12?backOut(p.t/.12):1; | |
| xctx.globalAlpha=q>.7?1-(q-.7)/.3:1; | |
| xctx.save(); xctx.translate(p.x,p.y); xctx.scale(sc,sc); | |
| xctx.textAlign='center'; xctx.textBaseline='middle'; | |
| xctx.font=`900 ${Math.round(30*p.size)}px Orbitron,'DotGothic16',sans-serif`; | |
| xctx.lineWidth=6; xctx.strokeStyle='rgba(0,0,0,.65)'; | |
| xctx.strokeText(p.text,0,0); | |
| xctx.shadowColor=p.color; xctx.shadowBlur=18; | |
| xctx.fillStyle=p.color; xctx.fillText(p.text,0,0); | |
| xctx.shadowBlur=0; | |
| if(p.sub){ | |
| xctx.font=`14px 'DotGothic16',sans-serif`; | |
| xctx.fillStyle='#fff'; | |
| xctx.fillText(p.sub,0,24*p.size+8); | |
| } | |
| xctx.restore(); | |
| break; } | |
| } | |
| } | |
| xctx.globalAlpha=1; | |
| xctx.globalCompositeOperation='source-over'; | |
| } | |
| function drawBG(dt){ | |
| const w=innerWidth, h=innerHeight; | |
| const L=arr=>`hsl(${arr[0]},${arr[1]}%,${Math.min(17,arr[2]+FX.pulse*6)}%)`; | |
| const g=bctx.createLinearGradient(0,0,0,h); | |
| g.addColorStop(0,L(FX.bgCur[0])); g.addColorStop(1,L(FX.bgCur[1])); | |
| bctx.setTransform(dpr,0,0,dpr,0,0); | |
| bctx.fillStyle=g; bctx.fillRect(0,0,w,h); | |
| bctx.fillStyle='#fff'; | |
| for(const s of STARS){ | |
| s.y+=s.sp*dt*(1+FX.pulse*3); | |
| if(s.y>h) s.y-=h; | |
| bctx.globalAlpha=.25+.25*Math.sin(s.tw+performance.now()/700); | |
| bctx.fillRect(s.x,s.y,s.r,s.r); | |
| } | |
| bctx.globalAlpha=1; | |
| const rg=bctx.createRadialGradient(w/2,h/2,Math.min(w,h)*.3,w/2,h/2,Math.max(w,h)*.75); | |
| rg.addColorStop(0,'rgba(0,0,0,0)'); rg.addColorStop(1,'rgba(0,0,0,.6)'); | |
| bctx.fillStyle=rg; bctx.fillRect(0,0,w,h); | |
| } | |
| let lastHudScore=-1,lastHudLevel=-1,lastHudLines=-1,lastHudTime=-1,lastHudPieces=-1; | |
| function updateHUD(dt){ | |
| dispScore+=(G.score-dispScore)*Math.min(1,dt*10); | |
| if(Math.abs(G.score-dispScore)<1) dispScore=G.score; | |
| const s=Math.floor(dispScore); | |
| if(s!==lastHudScore){ scoreV.textContent=s.toLocaleString(); lastHudScore=s; } | |
| if(G.level!==lastHudLevel){ levelV.textContent=G.level; lastHudLevel=G.level; } | |
| if(G.lines!==lastHudLines){ | |
| linesV.textContent=G.lines; lastHudLines=G.lines; | |
| const toNext=10-(G.lines%10); | |
| themeNext.textContent=`あと ${toNext} ラインで変化`; | |
| themeBar.style.width=`${(G.lines%10)*10}%`; | |
| } | |
| if(G.stats.pieces!==lastHudPieces){ piecesV.textContent=G.stats.pieces; lastHudPieces=G.stats.pieces; } | |
| const tSec=Math.floor(G.stats.time); | |
| if(tSec!==lastHudTime){ timeV.textContent=fmtTime(G.stats.time); lastHudTime=tSec; } | |
| comboBadge.style.display=G.combo>=1?'block':'none'; | |
| if(G.combo>=1) comboBadge.textContent='COMBO ×'+G.combo; | |
| b2bBadge.style.display=G.b2b?'block':'none'; | |
| fieldBox.style.setProperty('--pulse',FX.pulse.toFixed(3)); | |
| let danger=false; | |
| for(let y=HIDDEN;y<HIDDEN+6;y++) if(G.grid[y].some(v=>v)){ danger=true; break; } | |
| fieldBox.classList.toggle('danger',danger&&(G.state==='PLAYING'||G.state==='CLEARING')); | |
| } | |
| function showScreen(name){ | |
| screenEl.style.display=name?'flex':'none'; | |
| scrTitle.style.display=name==='title'?'flex':'none'; | |
| scrPause.style.display=name==='pause'?'flex':'none'; | |
| scrOver.style.display=name==='over'?'flex':'none'; | |
| } | |
| /* ================= main loop ================= */ | |
| let last=performance.now(); | |
| function frame(nowT){ | |
| requestAnimationFrame(frame); | |
| let dt=Math.min((nowT-last)/1000,.1); | |
| last=nowT; | |
| let sdt=dt; | |
| if(FX.hitstop>0){ FX.hitstop-=dt; sdt=dt*.12; } // テトリス時のヒットストップ | |
| update(sdt,dt); | |
| render(dt); | |
| } | |
| function update(dt,rawDt){ | |
| switch(G.state){ | |
| case 'TITLE': | |
| FX.attT-=rawDt; | |
| if(FX.attT<=0){ | |
| FX.attT=rand(.6,1.3); | |
| FX.add({type:'rocket',x:rand(innerWidth*.15,innerWidth*.85),y:innerHeight*.92, | |
| vx:rand(-50,50),vy:rand(-560,-440),g:170,ttl:rand(.55,.9),t:0, | |
| color:themeColor(choice(THEMES)),px:0,py:0}); | |
| } | |
| break; | |
| case 'READY': | |
| G.readyT+=rawDt; | |
| if(!G.goShown&&G.readyT>=.8){ G.goShown=true; FX.popup('GO!!',{color:'#fff',size:1.5,dy:40}); SFX.go(); } | |
| if(G.readyT>=1.25){ G.state='PLAYING'; spawnFromQueue(); BGM.startNew(); } | |
| break; | |
| case 'PLAYING':{ | |
| if(G.dasDir){ | |
| G.dasT+=dt; | |
| if(G.dasT>=DAS){ | |
| G.dasArr+=dt; | |
| while(G.dasArr>=ARR){ G.dasArr-=ARR; if(!tryShift(G.dasDir)) break; } | |
| } | |
| } | |
| const g=gravitySec(G.level)/(G.softHeld?20:1); | |
| G.dropAcc+=dt; | |
| let guard=0; | |
| while(G.dropAcc>=g&&guard++<40){ | |
| G.dropAcc-=g; | |
| if(!fallStep(G.softHeld)){ G.dropAcc=0; break; } | |
| } | |
| G.grounded=isGrounded(); | |
| if(G.grounded){ G.lockT+=dt; if(G.lockT>=LOCK_DELAY) lockPiece(); } | |
| else G.lockT=0; | |
| G.stats.time+=dt; | |
| break; } | |
| case 'CLEARING': | |
| G.clearAnim.t+=dt; | |
| G.stats.time+=dt; | |
| if(G.clearAnim.t>=CLEAR_DUR){ | |
| const rs=new Set(G.clearAnim.rows); | |
| G.grid=G.grid.filter((_,i)=>!rs.has(i)); | |
| while(G.grid.length<ROWS) G.grid.unshift(new Array(COLS).fill(0)); | |
| G.clearAnim=null; | |
| G.state='PLAYING'; | |
| spawnFromQueue(); | |
| } | |
| break; | |
| } | |
| FX.update(rawDt); | |
| } | |
| function render(dt){ | |
| FX.fr=fieldCv.getBoundingClientRect(); | |
| drawBG(dt); | |
| drawField(); | |
| drawNext(); | |
| drawHold(); | |
| drawFX(); | |
| updateHUD(dt); | |
| if(FX.shake>0){ | |
| const a=FX.shake; | |
| stageEl.style.transform=`translate(${rand(-a,a)}px,${rand(-a,a)}px) rotate(${rand(-a,a)*.06}deg)`; | |
| } else stageEl.style.transform=''; | |
| flashEl.style.background=FX.flashColor; | |
| flashEl.style.opacity=FX.flash; | |
| } | |
| /* ================= input ================= */ | |
| window.addEventListener('keydown',e=>{ | |
| AudioSys.init(); AudioSys.resume(); | |
| const k=e.code; | |
| if(['ArrowLeft','ArrowRight','ArrowDown','ArrowUp','Space'].includes(k)) e.preventDefault(); | |
| if(e.repeat) return; | |
| switch(k){ | |
| case 'ArrowLeft': G.leftHeld=true; pressDir(-1); break; | |
| case 'ArrowRight': G.rightHeld=true; pressDir(1); break; | |
| case 'ArrowDown': G.softHeld=true; break; | |
| case 'ArrowUp': case 'KeyX': rotatePiece(1); break; | |
| case 'KeyZ': rotatePiece(-1); break; | |
| case 'Space': | |
| if(G.state==='TITLE'||G.state==='OVER') tryStart(); | |
| else if(G.state==='PAUSED') togglePause(); | |
| else hardDrop(); | |
| break; | |
| case 'KeyC': case 'ShiftLeft': case 'ShiftRight': doHold(); break; | |
| case 'KeyP': case 'Escape': if(G.state!=='TITLE'&&G.state!=='OVER') togglePause(); break; | |
| case 'KeyM': FX.popup(AudioSys.toggleMute()?'SOUND OFF':'SOUND ON',{color:'#9fb4ff',size:.7,dy:120}); break; | |
| case 'Enter': | |
| if(G.state==='TITLE'||G.state==='OVER') tryStart(); | |
| else if(G.state==='PAUSED') togglePause(); | |
| break; | |
| } | |
| }); | |
| window.addEventListener('keyup',e=>{ | |
| switch(e.code){ | |
| case 'ArrowLeft': G.leftHeld=false; releaseDir(-1); break; | |
| case 'ArrowRight': G.rightHeld=false; releaseDir(1); break; | |
| case 'ArrowDown': G.softHeld=false; break; | |
| } | |
| }); | |
| function tryStart(){ | |
| if(G.state==='OVER'&&performance.now()-G.overAt<600) return; | |
| start(); | |
| } | |
| document.addEventListener('visibilitychange',()=>{ | |
| if(document.hidden&&(G.state==='PLAYING'||G.state==='CLEARING')) togglePause(); | |
| }); | |
| window.addEventListener('blur',()=>{ | |
| if(G.state==='PLAYING'||G.state==='CLEARING') togglePause(); | |
| }); | |
| window.addEventListener('resize',layout); | |
| window.addEventListener('contextmenu',e=>{ if(e.target.closest('.tbtn')) e.preventDefault(); }); | |
| $('startBtn').addEventListener('click',()=>{ AudioSys.init(); AudioSys.resume(); tryStart(); }); | |
| $('retryBtn').addEventListener('click',()=>{ AudioSys.resume(); tryStart(); }); | |
| $('titleBtn').addEventListener('click',()=>{ toTitle(); }); | |
| $('resumeBtn').addEventListener('click',()=>togglePause()); | |
| $('quitBtn').addEventListener('click',()=>toTitle()); | |
| $('lvlDown').addEventListener('click',()=>{ G.startLevel=clamp(G.startLevel-1,1,15); $('lvlV').textContent=G.startLevel; }); | |
| $('lvlUp').addEventListener('click',()=>{ G.startLevel=clamp(G.startLevel+1,1,15); $('lvlV').textContent=G.startLevel; }); | |
| function toTitle(){ | |
| BGM.stop(); resetGame(); | |
| G.state='TITLE'; showScreen('title'); | |
| } | |
| // タッチボタン | |
| function bindHold(el,down,up){ | |
| el.addEventListener('pointerdown',e=>{ e.preventDefault(); AudioSys.init(); AudioSys.resume(); down(); }); | |
| if(up) ['pointerup','pointercancel','pointerleave'].forEach(ev=>el.addEventListener(ev,up)); | |
| } | |
| bindHold($('tLeft'),()=>{ G.leftHeld=true; pressDir(-1); },()=>{ G.leftHeld=false; releaseDir(-1); }); | |
| bindHold($('tRight'),()=>{ G.rightHeld=true; pressDir(1); },()=>{ G.rightHeld=false; releaseDir(1); }); | |
| bindHold($('tDown'),()=>{ G.softHeld=true; },()=>{ G.softHeld=false; }); | |
| bindHold($('tRot'),()=>rotatePiece(1)); | |
| bindHold($('tDrop'),()=>hardDrop()); | |
| bindHold($('tHold'),()=>doHold()); | |
| /* ================= debug hook (動作検証用) ================= */ | |
| window.TETRIS_DEBUG={ | |
| G,FX,THEMES, | |
| start, hardDrop, rotate:()=>rotatePiece(1), move:d=>tryShift(d), | |
| fill:h=>{ for(let y=ROWS-h;y<ROWS;y++) for(let x=0;x<COLS;x++) G.grid[y][x]=x===4?0:irand(1,7); }, | |
| clearLines:n=>{ | |
| for(let y=ROWS-n;y<ROWS;y++) for(let x=0;x<COLS;x++) G.grid[y][x]=irand(1,7); | |
| if(G.state==='PLAYING'){ G.cur=null; resolveLock(null); } | |
| }, | |
| freeze:v=>{ FX.frozen=v!==false; }, | |
| step:(dt=.016)=>{ update(dt,dt); render(dt); }, | |
| pump:(n=60,dt=.016)=>{ for(let i=0;i<n;i++) update(dt,dt); render(dt); }, | |
| lockNow:()=>lockPiece(), | |
| tspinSetup:()=>{ // T-SPIN MINI SINGLE の再現ボード | |
| G.grid=emptyGrid(); | |
| for(let x=0;x<COLS;x++){ if(x!==4) G.grid[ROWS-1][x]=irand(1,7); } | |
| for(let x=0;x<COLS;x++){ if(x<3||x>5) G.grid[ROWS-2][x]=irand(1,7); } | |
| for(let x=0;x<COLS;x++){ if(x<3||x>4) G.grid[ROWS-3][x]=irand(1,7); } | |
| spawnPiece('T'); | |
| Object.assign(G.cur,{rot:2,x:3,y:ROWS-3}); | |
| G.lastRotate=true; G.lastKick=0; | |
| }, | |
| burst:()=>FX.lineClear([ROWS-1,ROWS-2,ROWS-3,ROWS-4],4,null,{label:'TETRIS!',b2bApplied:true}), | |
| give:t=>spawnPiece(t), | |
| over:()=>gameOver(), | |
| }; | |
| /* ================= boot ================= */ | |
| if(document.fonts&&document.fonts.load){ | |
| document.fonts.load("900 40px Orbitron"); document.fonts.load("14px 'DotGothic16'"); | |
| } | |
| resetGame(); | |
| layout(); | |
| applyTheme(false); | |
| showScreen('title'); | |
| requestAnimationFrame(frame); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment