対戦できるぷよぷよ
0 文書情報
版 1.0
作成日 2026-02-21
対象 提示された1ファイルHTMLに含まれる落下パズルを拡張し,2人対戦,おじゃまぷよ,おじゃま高速落下,プレイヤー操作の高速落下を追加するための要件定義。
本文は,提示コード内の設計方針を引き継ぎ,HTML単体で完結する実装を前提にする。
1 案件名
HTML 1ファイル完結 落下パズル対戦 拡張版
2人対戦モード追加
おじゃまぷよ実装
連鎖で相手におじゃま送信
高速落下操作追加
おじゃま落下の高速化追加
2 目的
2.1 体験目標
同一PCのキーボードで2人が同時操作し,落下中の2個組を操作して盤面に積み,4個以上の連結消去と連鎖を発生させ,連鎖に応じたおじゃまぷよを相手盤面に降らせて勝敗が決まる体験を成立させる。
操作の手応えとして,移動,回転,接地,消去,おじゃま受信などのイベントに効果音を割り当てる。
高速落下は,操作対象の落下速度を上げる手段として実装し,対戦中の判断と技量が反映されるようにする。
おじゃまぷよの落下は,対戦テンポを悪化させないために高速で処理できるようにする。
2.2 学習目標
盤面配列,操作対象,判定関数の分離を維持したまま,2盤面同時更新と相互干渉を安全に追加する構成を示す。
おじゃま送信と相殺,おじゃま落下のキュー処理を,状態遷移として整理する。
3 対象範囲
3.1 対象
単一HTMLファイル内で完結する2人対戦モードの追加。
おじゃまぷよの生成,降下,消去連動処理。
高速落下操作。
描画とUIの拡張。
音声のイベント追加。
3.2 対象外
オンライン対戦,マッチング,リプレイ,観戦。
画像アセット,外部音源ファイル。
多人数,AI対戦。
原作と同一の数値テーブル完全再現。
スマホのタッチ操作最適化。
ネットワーク同期や遅延補償。
4 動作前提と禁止事項
4.1 端末
OS Windows 10 11 または macOS
ブラウザ Chrome最新版を基準。Firefox Edgeでも動作すること。
入力 キーボード必須。2人同時のキー押下を前提にする。
4.2 起動方法
ローカルで index.html を開くだけで起動する。サーバ起動は不要。
4.3 禁止事項
外部画像の読み込み禁止。
外部音源ファイルの読み込み禁止。
外部フォント読み込み禁止。
外部ライブラリ読み込み禁止。
CDN参照禁止。
DevToolsのNetworkで外部リクエストが0であること。
5 画面仕様
5.1 レイアウト方針
Canvasは1枚で,左にP1盤面,右にP2盤面を並べて表示する。盤面の右側または下側に共通UIを置く。
盤面が見切れないように,Canvas幅は盤面2つ分と余白を含める。
5.2 盤面サイズ
列数 COLS = 6
行数 ROWS = 13
描画行数 VISIBLE_ROWS = 12
セルサイズ SZ は既存と同様に定義し,2盤面表示に合わせてCanvas寸法を決める。
例
SZ = 50 のとき,盤面1つの幅は 650 = 300。
2盤面で 600。中央仕切りと余白を含めて Canvas width = 650〜720 程度。
高さは VISIBLE_ROWSSZ で 12*50 = 600。
5.3 座標変換
盤面座標 x,y を Canvas 座標へ変換する処理は共通関数にまとめる。
下に積む表現を維持するため,yの反転を行う。
盤面座標からピクセル中心座標への例
px = boardOriginX + x*SZ + SZ/2
py = canvasHeight - (y+1)*SZ + SZ/2
boardOriginX はP1とP2で異なる。P1は左端基準,P2は右盤面の開始位置基準。
5.4 UI
各プレイヤーごとに表示する。
Score,Chain,Pending Garbage,Top Out 状態。
Audio は共通UIとして On Off,BGM,SFX,FX を置く。
2人同時操作の視認性のため,P1とP2でUI色を変える。
6 ゲームモードと状態
6.1 モード
VS 2人対戦が標準。
TRAIN 1人練習は任意で残す。実装の簡略のため,VS固定でもよい。
6.2 ゲーム状態
INIT 初期化
PLAY 両者操作可能
RESOLVE 消去,落下,おじゃま降下などの盤面処理を行う
WIN 勝者表示
PAUSE 任意
6.3 勝敗
次のいずれかで決着。
相手が出現位置付近で新規ぷよを置けず,盤面外へはみ出す状態になった場合,その相手が敗北。
時間切れは対象外とする。
7 操作仕様
7.1 P1キー割り当て例
左 A
右 D
回転右 W
回転左 Q 任意
ソフトドロップ S 落下速度上昇
ハードドロップ Space 最下段まで即時落下 任意
高速落下 S と同義でよい。実装上はソフトドロップを高速落下として扱う。
開始 Enter 共通
音声開始は任意のキー入力でもよいが,初回入力でAudioContextを開始する。
7.2 P2キー割り当て例
左 ArrowLeft
右 ArrowRight
回転右 ArrowUp
回転左 Slash など 任意
ソフトドロップ ArrowDown
ハードドロップ ShiftRight 任意
7.3 入力処理
keydownで押下状態をtrue。keyupでfalse。
毎フレーム押下状態を参照して移動や回転を行う。
2人分の入力を独立に処理し,同一フレーム内で両者が動いても整合が崩れない更新順序を採用する。
左右同時押しの扱いは各プレイヤーで統一する。推奨は最後に押された方向を優先。
7.4 キーリピート
左右移動は連続入力が必要になるため,押しっぱなしで一定間隔で移動するDAS方式を推奨する。
最小仕様では,毎フレーム移動判定でもよいが,速度が速すぎる場合は repeatIntervalFrames を導入する。
8 盤面データと操作対象
8.1 盤面配列
各プレイヤーは独立した盤面を持つ。
field1[ROWS][COLS]
field2[ROWS][COLS]
0 は空。1..N は色ぷよ。
おじゃまぷよは別の値を割り当てる。例 9。
0 空
1..5 色ぷよ
9 おじゃま
8.2 操作対象
各プレイヤーは落下中の2個組を持つ。
player1 = { x, y, c1, c2, r, dropCounter, lockCounter }
player2 = { x, y, c1, c2, r, dropCounter, lockCounter }
x,y はc1の盤面座標。c2は2個目色。rは回転状態。
2個目相対位置は既存の dr 配列を共通利用する。
dr = [[0,1],[1,0],[0,-1],[-1,0]];
8.3 出現位置
出現xは中央寄りの固定。例 x=2。
出現yは上部余裕行を使う。既存コードが y=11 を使う場合,盤面の定義と描画反転の整合を維持しつつ,出現時に盤面外を許容するかどうかを明確化する。
推奨は,ROWS=13 のうち y=12 を隠し行として扱い,描画は y=0..11 に限定する。
8.4 次ぷよ
対戦では公平性のため,両者の次ぷよ列を同一にする方式を推奨する。
実装例
グローバルな乱数シードから NextQueue を生成し,両者は同一queueから同じ順に引く。
ただし,片方が敗北して停止した場合の扱いを簡単にするため,各プレイヤーが同一初期seedから独立に生成してもよい。この場合でも生成規則が同一であれば公平性は維持できる。
9 移動 回転 判定関数
9.1 判定関数の一元化
移動と回転の可否は,各盤面ごとに canMove 関数へ集約する。
canMove(field, nx, ny, nr)
判定はc1とc2の両方について行う。
盤面外判定とセル空判定を同じ場所に置く。
9.2 盤面内判定
0 <= x < COLS
0 <= y < ROWS
ただし出現直後の隠し行を使うなら,上側だけ一時的に許容する設計もある。最小仕様ではROWS内に収める。
9.3 セル空判定
field[y][x] === 0
おじゃまセル 9 は空ではない。
9.4 回転
rotate(field, player, dir) は nr を更新して canMove を呼ぶ。
失敗した場合,左右へ1マスずらす壁蹴りを試す。
壁蹴り候補は dir に応じて固定順序にする。
例
1 その場回転
2 x+1 で再判定
3 x-1 で再判定
4 失敗なら回転しない
10 落下 固定 重力処理
10.1 落下タイミング
プレイヤーは dropCounter を持ち,一定フレームごとに y を1下げる処理を行う。
ここで「下げる」は盤面座標系の定義に従う。提示コードが y増加で上方向に描画される実装であるため,落下方向とyの増減が混乱しやすい。対戦拡張では次を固定する。
盤面配列の y は下段が0,上に向かって増える。
落下は y を減らす。
描画は py = canvasHeight - (y+1)*SZ + SZ/2 を用いる。
この定義に統一できない場合は,既存の y 定義を維持し,落下方向は現行コードに合わせる。どちらでもよいが混在を禁止する。
10.2 ソフトドロップ 高速落下
ソフトドロップを高速落下として実装する。押下中は落下間隔を短くする。
例
通常落下 dropInterval = 30 frames
高速落下 dropIntervalFast = 2 frames
押下中は dropCounter を増やしやすくするか,落下ステップ回数を増やす。
仕様
高速落下中も衝突判定は1ステップごとに行い,すり抜けを禁止する。
高速落下中に固定した場合,固定処理は通常と同一の順序で行う。
10.3 ハードドロップ 任意
実装する場合,最下段まで一気に落とし,その直後に固定する。
落下距離分だけスコア加点するのは任意。対戦の読み合いが必要なら加点しないほうが簡単。
10.4 固定
下に動けなくなったとき lockCounter を増やし,短い猶予後に固定する方式を推奨する。
最小仕様では即時固定でもよい。
推奨例
lockDelay = 12 frames
接地中に左右移動や回転があれば lockCounter を0へ戻す。
10.5 固定処理 handleLock
盤面へ c1 と c2 を書き込み,操作対象を消す。
次に重力 applyGravity を実行し,消去判定へ進む。
対戦では,盤面処理が終わるまで次の操作対象を出さない方式が分かりやすい。
したがって固定後は RESOLVE 状態へ遷移し,消去と落下とおじゃま処理が完了後に spawn する。
10.6 重力 applyGravity
各列について,下から詰め直す方式を推奨する。反復より分かりやすく高速。
for x in 0..COLS-1
writeY = 0
for y in 0..ROWS-1
if field[y][x] != 0 then
tmp = field[y][x]
field[y][x] = 0
field[writeY][x] = tmp
writeY++
この方式なら,盤面が小さくても処理が確定し,反復回数に依存しない。
10.7 落下の高速化
消去後の盤面落下は,見た目の演出として段階的に落ちてもよいが,対戦テンポを重視する場合は即時反映でよい。
要件として「おじゃま落下の高速化」を含むため,少なくともおじゃま降下中に長いアニメーション待ちを入れない。
11 消去判定と連鎖
11.1 消去探索 findMatches
盤面ごとに実行する。
上下左右の連結成分を探索し,同色かつ値1..5のみを対象にする。おじゃま9は探索対象に含めない。
探索は BFS か DFS。未訪問管理を行う。
11.2 消去条件
同色連結数が4以上で消去対象。
消去対象セルの集合を eraseSet として返す。
11.3 おじゃま連動消去
消去が発生したターンで,消去対象セルに隣接するおじゃま9を同時に消す。
隣接は上下左右のみ。
これにより,おじゃまは単独では消えず,隣接消去で崩れる挙動になる。
11.4 連鎖 processResolve
盤面ごとに,次のループを行う。
1 findMatches
2 消去がなければ終了
3 消去があれば chain++
4 消去実行
5 applyGravity
6 再び 1 へ
11.5 スコア
最小仕様
score += erasedCount * 10 * chain
対戦では攻撃量に使うため,攻撃用の別計算を持ってもよい。
12 2人対戦の更新順序
12.1 原則
2盤面は同一フレームで更新する。
片方の消去がもう片方の消去判定へ直接影響することはない。影響は「おじゃま送信」だけ。
12.2 フレーム内順序 推奨
1 両者の入力更新
2 両者の落下更新
3 両者の固定判定
4 固定が起きた盤面を RESOLVE へ
5 RESOLVE 中の盤面は,消去と落下を完了させる
6 この RESOLVE の結果で発生した攻撃量を計算し,相手の pendingGarbage へ加算
7 おじゃま降下タイミングに達している場合,相手盤面へおじゃまを実際に落とす
8 両者とも操作対象が存在しないかつ盤面処理が完了したら spawn
9 描画
両者が同フレームで攻撃を出す場合があるため,送信と受信は同一段階で処理し,相殺を行う。
13 おじゃまぷよの仕様
13.1 表現
盤面セル値 9 をおじゃまとする。色は固定の灰色。
消去連動は前述の隣接消去のみ。
13.2 送信の発生条件
連鎖が発生したとき,相手へおじゃまを送る。
「連鎖なしで4消し」でも chain=1 として送信が発生する。
13.3 攻撃量計算
原作再現ではなく,調整可能なテーブル方式を採用する。
攻撃量は次の要素から作る。
A 連鎖数 chain
B 消去個数 erasedCount 合計
C 同時消しの塊数 groupCount 任意
D 高速落下ボーナス 任意
最小仕様の例
attack = floor( erasedCountTotal / 4 ) * chain
例
4個消し chain1 なら attack=1
8個消し chain1 なら attack=2
4個消し chain3 なら attack=3
調整しやすい方式
chainBonusTable = [0,0,1,2,4,6,9,13,18] のような配列を持ち,
attack = floor(erasedCountTotal / 4) + chainBonusTable[chain]
とする。上限は任意。
13.4 相殺
両者が同時に攻撃を出した場合,pendingGarbage を相殺する。
例
P1 attack=5,P2 attack=3
差分2だけが相手へ残る。
実装
各プレイヤーに outgoingAttack を持たせ,両者のRESOLVEが完了したフレームで
net1 = max(0, attack1 - attack2)
net2 = max(0, attack2 - attack1)
として,net を相手 pendingGarbage に加算する。
13.5 受信キュー
各プレイヤーは pendingGarbage を整数で持つ。単位はおじゃま1個。
pendingGarbage1
pendingGarbage2
降下は即時ではなく,次の固定後に降らせる方式を推奨する。
理由は,操作中に突然降って操作不能になる状況を避けるため。
13.6 降下タイミング
推奨
相手が自分のぷよを固定し,その盤面の RESOLVE が開始される直前,または RESOLVE 終了直後に降らせる。
最小仕様としては,spawn 前に必ず降らせるでよい。
こうすると,おじゃまが積まれた状態で次の操作対象が出現するため,対処が可能。
13.7 降下方法
pendingGarbage を列に配分して盤面へ配置する。
おじゃまは上から降るが,実装は「空いている最上段へ配置し,applyGravityで落とす」でもよい。
配分方式例
1 COLS=6 に対し,1段6個の単位で埋める
2 余りは左から順に置くか,疑似乱数で列を散らす
3 同一seedで列散らしを行うとデバッグしやすい
例
dropGarbage(field, amount) は次を行う。
a 空セルが存在するか検査し,なければ top out
b amount を 6個単位に分割し,段ごとに配置
c 配置は y=ROWS-1 側から置き,最後に applyGravity で落とす
13.8 おじゃま落下の高速化
おじゃま降下はテンポを悪化させやすい。次を要件とする。
おじゃま配置は1フレームで盤面へ反映する。
降下演出を入れる場合でも,1段ずつの長い待ちを入れない。
必要なら,おじゃま降下中は落下演出のステップを増やし,最短数フレームで完了させる。
14 高速落下の追加要件
14.1 操作対象の高速落下
ソフトドロップを高速落下として扱う。押下中は落下間隔を大幅に短縮する。
既存の自動落下と合流させるため,dropCounter方式を維持し,閾値だけ切り替える。
高速落下中も左右移動と回転は可能。ただし落下が速いほど猶予が減るため,操作難度が上がる。
14.2 ハードドロップ 任意
押下で最下段へ一気に落とし,固定へ進む。
対戦テンポを上げたい場合に有効。
実装するなら,固定後の RESOLVE とおじゃま降下の順序を壊さないこと。
14.3 落下中ぷよの高速落下
消去後の浮遊ぷよや,おじゃま降下後の浮遊ぷよの落下は,applyGravityで即時確定させることで「高速落下」扱いにできる。
演出を残す場合は,落下アニメを最短で終わらせるため,1フレームあたり複数セル落下させるステップ方式を採用する。
15 描画仕様
15.1 描画順
背景
P1盤面グリッド
P1固定ぷよ
P1操作対象
P1おじゃま予告表示 任意
P2盤面グリッド
P2固定ぷよ
P2操作対象
UI
勝敗表示
15.2 色
色ぷよは 1..5 を色配列で引く。
おじゃま 9 は固定灰色。
消去中のフラッシュ演出は任意。入れる場合は eraseSet を保持し数フレーム点滅。
15.3 盤面の仕切り
中央に線を引き,盤面の境界を明確化する。
15.4 デバッグ表示
トグルで各盤面のAABBや探索状態を表示できると検証が楽になる。
最小仕様ではセル境界線と pendingGarbage 数表示だけでもよい。
16 音声仕様
16.1 開始条件
初回のユーザ操作を起点に AudioContext を生成し resume する。
どちらのプレイヤーのキーでも開始してよい。
音声がOFFの場合は生成しないか,生成しても出力ゲインを0にする。
16.2 BGM
既存の簡易BGM方式を維持し,対戦開始時に再生する。
WIN状態ではフェードアウトするか停止。
16.3 SFX
追加イベント
P1回転,P2回転
おじゃま送信
おじゃま受信キュー増加
おじゃま実降下
高速落下開始中の落下音は頻度が高すぎる場合があるため,連続再生を間引く。例 6フレームに1回だけ鳴らす。
16.4 FX
既存のFXスライダがある場合,BGM系に適用し,SFXはdry寄りにするなどの整理を行う。
対戦では情報量が増えるため,FXが過剰に濁らない初期値を推奨する。
17 データ構造 提案
17.1 PlayerState
PlayerState = {
id: 1 or 2,
field: number[ROWS][COLS],
active: { x,y,c1,c2,r, dropCounter, lockCounter } or null,
nextQueue: [{c1,c2}, ...],
score: number,
chain: number,
pendingGarbage: number,
state: "PLAY" or "RESOLVE" or "DEAD",
das: { leftHold, rightHold, leftTimer, rightTimer } // 任意
}
17.2 GameState
GameState = {
mode: "VS",
state: "INIT"|"PLAY"|"WIN",
winner: 0|1|2,
audio: { enabled, ctx, masterGain, ... },
rngSeed: number
}
18 処理フロー 詳細
18.1 spawn
active が null のプレイヤーに対し,次ぷよを取り出して出現位置へ配置する。
canMove 判定で置けなければ top out で敗北。
18.2 updatePLAY
入力処理 → 移動回転 → 自然落下 → 高速落下 → 接地判定 → lockDelay → 固定へ
18.3 updateRESOLVE
消去探索 → 消去 → スコアと攻撃量計算 → おじゃま相殺処理に必要な値を保持 → applyGravity → 連鎖継続判定
消去がなくなったら RESOLVE 終了。
終了直前または終了直後に,相手からの pendingGarbage を盤面へ落とすタイミングを判定し,必要なら dropGarbage を実行。
その後 spawn へ。
18.4 同時相殺
両者が RESOLVE を終えたフレームで,attack1 と attack2 を相殺して pendingGarbage を更新する。
実装簡略のため,RESOLVEを同時に終えるとは限らない。そこで次を採用する。
各プレイヤーは resolveAttackBuffer を持ち,RESOLVEが完了した時点で attack をbufferへ保存する。
両者のbufferが埋まったフレームで相殺し,両者bufferを0へ戻す。
これにより,片方が先にRESOLVE完了しても相殺整合が保たれる。
19 受け入れ条件
19.1 起動
index.html のダブルクリックで起動できる。
外部リクエストが0。
19.2 対戦
P1とP2が同時に操作できる。
両者が独立に落下,移動,回転できる。
どちらかが top out したら勝敗表示へ遷移する。
19.3 消去と連鎖
4個以上連結で消える。
連鎖が成立する。
19.4 おじゃま
連鎖または消去で攻撃量が計算され,相手の pendingGarbage が増える。
相殺が成立する。
pendingGarbage が盤面へ降り,おじゃま9として積まれる。
消去が起きたとき,隣接するおじゃまが同時に消える。
19.5 高速落下
ソフトドロップ操作で落下速度が上がる。
高速落下中に壁抜けやすり抜けが起きない。
おじゃま降下は短時間で完了し,対戦が止まったように見えない。
19.6 音声
初回入力を起点に音が鳴る。
対戦イベントで効果音が鳴る。
Audio OFF が機能する。
20 試験項目 例
試験01 同時入力
手順 P1が左移動,P2が右移動を同時に押し続ける
期待 両者の操作対象が独立に移動する
試験02 高速落下
手順 P1がソフトドロップを押下したまま落下させる
期待 通常より短時間で接地し固定へ進む
試験03 連鎖攻撃
手順 P1で2連鎖以上を作る
期待 P2 pendingGarbage が増える
試験04 相殺
手順 同一タイミングでP1が攻撃5,P2が攻撃3になる状況を作る
期待 差分2だけが相手へ残る
試験05 おじゃま降下
手順 P2 pendingGarbage を 10 以上にし,P2の次固定後に降下させる
期待 盤面へおじゃまが積まれ,処理が短時間で終わる
試験06 おじゃま連動消去
手順 色ぷよ消去が起きる位置に隣接するおじゃまを置く
期待 消去と同時に隣接おじゃまが消える
試験07 勝敗
手順 片方の盤面を上まで積み,spawn不能にする
期待 勝者が表示され,操作が停止する
21 実装時の注意点
21.1 盤面座標の定義を1つに固定する
落下方向と y増減と描画反転が混ざると,衝突判定と重力が崩れる。最初に定義を固定し,すべてその定義へ合わせる。
21.2 2盤面の変数混同を防ぐ
field をグローバル1個にせず,PlayerStateへ内包する。
canMove や findMatches は field を引数で受ける。
21.3 RESOLVE中の入力
片方がRESOLVE中でも,もう片方はPLAYを続けられる設計にするかどうかを決める。
最小仕様は「各盤面が独立にRESOLVEへ入れる」。そのため,ゲーム全体は常にPLAYでも,プレイヤー単位で状態を持つのが安全。
21.4 おじゃま降下で時間を取らない
落下演出を入れすぎると対戦が間延びする。1フレーム反映か,数フレーム以内に完了する方式を選ぶ。
falling-puzzle-battle.html
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>落下パズル対戦 拡張版 1ファイル</title>
<style>
:root{
--bg:#0b0f16;
--panel:#121a27;
--grid:#1f2b3f;
--text:#e6eefc;
--muted:#93a4c7;
--p1:#66e6ff;
--p2:#ffb15c;
--bad:#ff5c5c;
--ok:#5cff94;
}
html,body{height:100%; margin:0; background:var(--bg); color:var(--text); font-family:system-ui, -apple-system, Segoe UI, Roboto, sans-serif;}
.wrap{max-width:1200px; margin:0 auto; padding:12px;}
.row{display:flex; gap:12px; flex-wrap:wrap; align-items:flex-start;}
canvas{background:linear-gradient(180deg, #0b0f16 0%, #070a10 100%); border:1px solid #24314a; border-radius:10px;}
.panel{
background:var(--panel); border:1px solid #24314a; border-radius:10px;
padding:10px 12px; min-width:280px; flex:1;
}
.panel h2{margin:0 0 8px 0; font-size:14px; font-weight:700; color:var(--muted);}
.kv{display:grid; grid-template-columns:120px 1fr; gap:6px 10px; font-size:13px; line-height:1.4;}
.kv .k{color:var(--muted);}
.btnrow{display:flex; gap:8px; flex-wrap:wrap; margin-top:10px;}
button{
background:#1a2740; color:var(--text); border:1px solid #2b3b5e; border-radius:8px;
padding:8px 10px; cursor:pointer; font-weight:600;
}
button:hover{filter:brightness(1.08);}
.small{font-size:12px; color:var(--muted); line-height:1.5;}
.controls{display:grid; grid-template-columns:1fr; gap:10px;}
.sliderrow{display:grid; grid-template-columns:120px 1fr 42px; gap:8px; align-items:center; font-size:13px;}
input[type="range"]{width:100%;}
label{user-select:none;}
.tag{display:inline-block; padding:2px 6px; border:1px solid #2b3b5e; border-radius:999px; font-size:12px; color:var(--muted);}
.p1{color:var(--p1);}
.p2{color:var(--p2);}
.warn{color:var(--bad);}
.ok{color:var(--ok);}
.mono{font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;}
</style>
</head>
<body>
<div class="wrap">
<div class="row">
<canvas id="cv" width="920" height="720" aria-label="game canvas"></canvas>
<div class="panel">
<h2>操作と設定</h2>
<div class="controls">
<div class="kv">
<div class="k"><span class="tag p1">P1</span></div><div>A 左 / D 右 / W 回転 / S 高速落下 / Space ハード落下 / Enter 開始</div>
<div class="k"><span class="tag p2">P2</span></div><div>← 左 / → 右 / ↑ 回転 / ↓ 高速落下 / RightShift ハード落下 / Enter 開始</div>
<div class="k">共通</div><div>R 再開 / P 一時停止 / M 音の切替</div>
</div>
<div class="kv">
<div class="k">音</div>
<div>
<label><input id="audioEnabled" type="checkbox" checked /> 有効</label>
<div class="small">初回キー入力で音が開始される。ブラウザの自動再生対策のため。</div>
</div>
</div>
<div class="sliderrow">
<div class="k">BGM</div>
<input id="bgmVol" type="range" min="0" max="1" step="0.01" value="0.25" />
<div class="mono" id="bgmVolV">0.25</div>
</div>
<div class="sliderrow">
<div class="k">SFX</div>
<input id="sfxVol" type="range" min="0" max="1" step="0.01" value="0.55" />
<div class="mono" id="sfxVolV">0.55</div>
</div>
<div class="btnrow">
<button id="btnStart">開始 Enter</button>
<button id="btnRestart">再開 R</button>
<button id="btnPause">一時停止 P</button>
</div>
<div class="small">
<div>盤面 6×13。表示は上 1 行を隠し行として扱う。</div>
<div>4 個以上の同色連結が消去。消去に隣接するおじゃまは同時に消去。</div>
<div>攻撃量 例: attack = floor( totalErasedColor / 4 ) * chain。</div>
<div>おじゃまは次の出現直前にまとめて落とす。落下は即時確定。</div>
<div class="mono">外部読み込みなし。1 ファイルで完結。</div>
</div>
</div>
</div>
</div>
</div>
<script>
(() => {
'use strict';
// =========================
// 定数
// =========================
const COLS = 6;
const ROWS = 13;
const VISIBLE_ROWS = 12;
const SZ = 44;
const GAP = 26;
const MARGIN_X = 18;
const TOP_UI_H = 96;
const BOTTOM_PAD = 18;
const DROP_INTERVAL = 30; // frames
const DROP_INTERVAL_FAST = 2; // frames
const LOCK_DELAY = 12; // frames
const DAS_DELAY = 10; // frames
const DAS_REPEAT = 2; // frames
const COLORS = {
0: '#00000000',
1: '#ff5c7a',
2: '#5cff94',
3: '#66e6ff',
4: '#ffd166',
5: '#b28dff',
9: '#9aa3b5'
};
const STROKES = {
1: '#ffb2c0',
2: '#b7ffd0',
3: '#c0f6ff',
4: '#ffe7ad',
5: '#e3d5ff',
9: '#d2d7e2'
};
// 回転状態: c2 の相対位置
const dr = [[0,1],[1,0],[0,-1],[-1,0]];
// =========================
// 入力
// =========================
const keysDown = Object.create(null);
let justPressed = Object.create(null);
function onKeyDown(e){
const code = e.code;
if (!keysDown[code]) justPressed[code] = true;
keysDown[code] = true;
// 音開始トリガ
audio.ensureStart();
// 同時押し優先方向の更新
if (code === P1.keys.left) P1.das.lastDir = -1;
if (code === P1.keys.right) P1.das.lastDir = 1;
if (code === P2.keys.left) P2.das.lastDir = -1;
if (code === P2.keys.right) P2.das.lastDir = 1;
// ブラウザのスクロール抑止
if (code.startsWith('Arrow') || code === 'Space') e.preventDefault();
}
function onKeyUp(e){
keysDown[e.code] = false;
if (e.code.startsWith('Arrow') || e.code === 'Space') e.preventDefault();
}
window.addEventListener('keydown', onKeyDown, {passive:false});
window.addEventListener('keyup', onKeyUp, {passive:false});
function isDown(code){ return !!keysDown[code]; }
function isPress(code){ return !!justPressed[code]; }
// =========================
// 乱数
// =========================
function XorShift32(seed){
let x = seed >>> 0;
return {
nextU32(){
x ^= (x << 13) >>> 0;
x ^= (x >>> 17) >>> 0;
x ^= (x << 5) >>> 0;
return x >>> 0;
},
nextInt(n){
return (this.nextU32() % n) | 0;
},
nextColor(){
return 1 + this.nextInt(5);
}
};
}
// =========================
// 音
// =========================
const audio = (() => {
let ctx = null;
let master = null;
let bgmGain = null;
let sfxGain = null;
let bgmTimer = null;
let started = false;
let enabled = true;
let bgmVol = 0.25;
let sfxVol = 0.55;
function ensureStart(){
if (!enabled) return;
if (started) return;
started = true;
try{
ctx = new (window.AudioContext || window.webkitAudioContext)();
master = ctx.createGain();
bgmGain = ctx.createGain();
sfxGain = ctx.createGain();
master.gain.value = 0.9;
bgmGain.gain.value = bgmVol;
sfxGain.gain.value = sfxVol;
bgmGain.connect(master);
sfxGain.connect(master);
master.connect(ctx.destination);
if (ctx.state === 'suspended') ctx.resume().catch(()=>{});
startBGM();
}catch(_e){
// 音無しでも進行
}
}
function setEnabled(v){
enabled = !!v;
if (!enabled){
stopBGM();
}else{
ensureStart();
startBGM();
}
}
function setBgmVol(v){
bgmVol = clamp01(v);
if (bgmGain) bgmGain.gain.value = bgmVol;
}
function setSfxVol(v){
sfxVol = clamp01(v);
if (sfxGain) sfxGain.gain.value = sfxVol;
}
function clamp01(x){ return Math.max(0, Math.min(1, +x || 0)); }
function beep(freq, dur, type='sine', gain=0.12){
if (!enabled || !ctx || !sfxGain) return;
const t0 = ctx.currentTime;
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, t0);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(Math.max(0.0002, gain), t0 + 0.01);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
osc.connect(g);
g.connect(sfxGain);
osc.start(t0);
osc.stop(t0 + dur + 0.02);
}
function noiseClick(dur, gain=0.08){
if (!enabled || !ctx || !sfxGain) return;
const sr = ctx.sampleRate;
const len = Math.max(1, (dur * sr) | 0);
const buf = ctx.createBuffer(1, len, sr);
const data = buf.getChannelData(0);
for (let i=0;i<len;i++){
// 簡易ノイズ + 減衰
const env = 1 - i / len;
data[i] = (Math.random()*2 - 1) * env;
}
const src = ctx.createBufferSource();
const g = ctx.createGain();
g.gain.value = gain;
src.buffer = buf;
src.connect(g);
g.connect(sfxGain);
src.start();
}
function startBGM(){
if (!enabled || !ctx || !bgmGain) return;
if (bgmTimer) return;
// 簡易アルペジオ
const bpm = 126;
const stepSec = 60 / bpm / 2;
const notes = [220, 277.18, 329.63, 440, 329.63, 277.18];
let idx = 0;
bgmTimer = setInterval(() => {
if (!enabled || !ctx) return;
const f = notes[idx % notes.length];
idx++;
const t0 = ctx.currentTime;
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(f, t0);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(Math.max(0.0002, 0.06), t0 + 0.01);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + stepSec*0.95);
osc.connect(g);
g.connect(bgmGain);
osc.start(t0);
osc.stop(t0 + stepSec);
}, stepSec * 1000);
}
function stopBGM(){
if (bgmTimer){
clearInterval(bgmTimer);
bgmTimer = null;
}
}
// SFX プリセット
const sfx = {
move(){ beep(520, 0.04, 'square', 0.05); },
rotate(){ beep(740, 0.05, 'square', 0.06); },
lock(){ noiseClick(0.05, 0.06); },
erase(){ beep(980, 0.08, 'sine', 0.08); },
chain(){ beep(1240, 0.09, 'sine', 0.09); },
send(){ beep(640, 0.07, 'sawtooth', 0.07); },
receive(){ beep(260, 0.07, 'sawtooth', 0.07); },
garbageDrop(){ noiseClick(0.08, 0.08); },
win(){ beep(880, 0.15, 'triangle', 0.10); beep(1174.66, 0.18, 'triangle', 0.10); },
lose(){ beep(220, 0.20, 'triangle', 0.10); }
};
return { ensureStart, setEnabled, setBgmVol, setSfxVol, sfx };
})();
// =========================
// 盤面ユーティリティ
// =========================
function makeField(){
const f = new Array(ROWS);
for (let y=0;y<ROWS;y++){
f[y] = new Array(COLS).fill(0);
}
return f;
}
function inBounds(x,y){
return (x>=0 && x<COLS && y>=0 && y<ROWS);
}
function canMove(field, x, y, r, c1, c2){
const x1 = x, y1 = y;
const x2 = x + dr[r][0], y2 = y + dr[r][1];
if (!inBounds(x1,y1) || !inBounds(x2,y2)) return false;
if (field[y1][x1] !== 0) return false;
if (field[y2][x2] !== 0) return false;
return true;
}
function writeActive(field, a){
const x1 = a.x, y1 = a.y;
const x2 = a.x + dr[a.r][0], y2 = a.y + dr[a.r][1];
if (inBounds(x1,y1)) field[y1][x1] = a.c1;
if (inBounds(x2,y2)) field[y2][x2] = a.c2;
}
function applyGravity(field){
for (let x=0;x<COLS;x++){
let writeY = 0;
for (let y=0;y<ROWS;y++){
const v = field[y][x];
if (v !== 0){
if (writeY !== y){
field[y][x] = 0;
field[writeY][x] = v;
}
writeY++;
}
}
for (let y=writeY;y<ROWS;y++){
field[y][x] = 0;
}
}
}
function findMatchesAndGarbage(field){
const visited = new Array(ROWS);
for (let y=0;y<ROWS;y++) visited[y] = new Array(COLS).fill(false);
const erase = new Set();
let coloredCount = 0;
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
for (let y=0;y<ROWS;y++){
for (let x=0;x<COLS;x++){
const v = field[y][x];
if (v < 1 || v > 5) continue;
if (visited[y][x]) continue;
// BFS
const qx = [x];
const qy = [y];
visited[y][x] = true;
const cells = [];
for (let qi=0; qi<qx.length; qi++){
const cx = qx[qi], cy = qy[qi];
cells.push([cx,cy]);
for (let di=0; di<4; di++){
const nx = cx + dirs[di][0];
const ny = cy + dirs[di][1];
if (!inBounds(nx,ny)) continue;
if (visited[ny][nx]) continue;
if (field[ny][nx] !== v) continue;
visited[ny][nx] = true;
qx.push(nx); qy.push(ny);
}
}
if (cells.length >= 4){
for (const [cx,cy] of cells){
erase.add(cy*COLS + cx);
}
}
}
}
if (erase.size === 0) return null;
// 色消去数
for (const key of erase){
const x = key % COLS;
const y = (key / COLS) | 0;
if (field[y][x] >= 1 && field[y][x] <= 5) coloredCount++;
}
// 隣接おじゃま
const extra = [];
for (const key of erase){
const x = key % COLS;
const y = (key / COLS) | 0;
for (let di=0; di<4; di++){
const nx = x + dirs[di][0];
const ny = y + dirs[di][1];
if (!inBounds(nx,ny)) continue;
if (field[ny][nx] === 9){
extra.push(ny*COLS + nx);
}
}
}
for (const k of extra) erase.add(k);
return { erase, coloredCount, totalCount: erase.size };
}
function eraseCells(field, eraseSet){
for (const key of eraseSet){
const x = key % COLS;
const y = (key / COLS) | 0;
field[y][x] = 0;
}
}
function rotateWithKick(field, a, dir){
const nr = (a.r + dir + 4) % 4;
if (canMove(field, a.x, a.y, nr, a.c1, a.c2)){
a.r = nr;
return true;
}
// 壁蹴り
if (canMove(field, a.x + 1, a.y, nr, a.c1, a.c2)){
a.x += 1; a.r = nr;
return true;
}
if (canMove(field, a.x - 1, a.y, nr, a.c1, a.c2)){
a.x -= 1; a.r = nr;
return true;
}
return false;
}
// =========================
// おじゃま
// =========================
function dropGarbage(field, amount, rng){
let left = amount | 0;
if (left <= 0) return true;
for (let iter=0; iter<9999 && left>0; iter++){
const want = Math.min(COLS, left);
// 列の候補をシャッフル
const cols = [];
for (let x=0;x<COLS;x++) cols.push(x);
for (let i=cols.length-1;i>0;i--){
const j = rng.nextInt(i+1);
const t = cols[i]; cols[i] = cols[j]; cols[j] = t;
}
let placed = 0;
for (let ci=0; ci<cols.length && placed<want; ci++){
const x = cols[ci];
let yTop = -1;
for (let y=ROWS-1;y>=0;y--){
if (field[y][x] === 0){ yTop = y; break; }
}
if (yTop < 0) continue;
field[yTop][x] = 9;
placed++;
}
if (placed < want){
// 置ききれない
return false;
}
left -= placed;
}
applyGravity(field);
return true;
}
// =========================
// プレイヤー状態
// =========================
function makePlayer(id, seed, keys){
return {
id,
field: makeField(),
active: null,
score: 0,
chain: 0,
pendingGarbage: 0,
state: 'PLAY', // PLAY / DEAD
needGarbageDrop: false,
// 攻撃バッファ
attackReady: false,
attackValue: 0,
// 入力
keys,
das: { dir: 0, holdFrames: 0, lastDir: 0 },
rng: XorShift32(seed >>> 0),
// SFX 間引き
fallSfxCooldown: 0
};
}
const P1_KEYS = {
left: 'KeyA',
right:'KeyD',
rotR:'KeyW',
rotL:'KeyQ',
soft:'KeyS',
hard:'Space'
};
const P2_KEYS = {
left: 'ArrowLeft',
right:'ArrowRight',
rotR:'ArrowUp',
rotL:'Slash',
soft:'ArrowDown',
hard:'ShiftRight'
};
let seedBase = (Date.now() ^ 0x9e3779b9) >>> 0;
let P1 = makePlayer(1, seedBase, P1_KEYS);
let P2 = makePlayer(2, seedBase, P2_KEYS);
// =========================
// ゲーム状態
// =========================
const game = {
state: 'INIT', // INIT / PLAY / PAUSE / WIN
winner: 0
};
function resetAll(newSeed){
seedBase = (newSeed >>> 0);
P1 = makePlayer(1, seedBase, P1_KEYS);
P2 = makePlayer(2, seedBase, P2_KEYS);
game.state = 'PLAY';
game.winner = 0;
spawnIfNeeded(P1);
spawnIfNeeded(P2);
}
function spawnIfNeeded(p){
if (p.state === 'DEAD') return;
if (p.active !== null) return;
// おじゃまを出現直前に落とす
if (p.needGarbageDrop){
if (p.pendingGarbage > 0){
const ok = dropGarbage(p.field, p.pendingGarbage, p.rng);
audio.sfx.garbageDrop();
p.pendingGarbage = 0;
if (!ok){
setDead(p);
return;
}
}
p.needGarbageDrop = false;
}
// 出現
const c1 = p.rng.nextColor();
const c2 = p.rng.nextColor();
const a = {
x: 2,
y: ROWS - 2, // 11
c1, c2,
r: 0,
dropCounter: 0,
lockCounter: 0
};
if (!canMove(p.field, a.x, a.y, a.r, a.c1, a.c2)){
setDead(p);
return;
}
p.active = a;
p.chain = 0;
}
function setDead(p){
p.state = 'DEAD';
p.active = null;
// 勝敗
const other = (p.id === 1) ? P2 : P1;
if (other.state !== 'DEAD'){
game.state = 'WIN';
game.winner = other.id;
audio.sfx.win();
audio.sfx.lose();
}else{
game.state = 'WIN';
game.winner = 0;
}
}
function computeAttack(totalErasedColor, chain){
if (totalErasedColor <= 0 || chain <= 0) return 0;
return Math.floor(totalErasedColor / 4) * chain;
}
function resolveBoard(p){
let chain = 0;
let totalErasedColor = 0;
let scoreAdd = 0;
while (true){
const m = findMatchesAndGarbage(p.field);
if (!m) break;
chain++;
totalErasedColor += m.coloredCount;
// スコア: 消去総数に連鎖係数
scoreAdd += m.totalCount * 10 * chain;
eraseCells(p.field, m.erase);
applyGravity(p.field);
if (chain === 1) audio.sfx.erase();
else audio.sfx.chain();
}
p.chain = chain;
p.score += scoreAdd;
const attack = computeAttack(totalErasedColor, chain);
p.attackReady = true;
p.attackValue = attack;
return { chain, totalErasedColor, scoreAdd, attack };
}
function settleAttacks(){
if (!P1.attackReady || !P2.attackReady) return;
const a1 = P1.attackValue | 0;
const a2 = P2.attackValue | 0;
const net1 = Math.max(0, a1 - a2);
const net2 = Math.max(0, a2 - a1);
if (net1 > 0){
P2.pendingGarbage += net1;
audio.sfx.send();
audio.sfx.receive();
}
if (net2 > 0){
P1.pendingGarbage += net2;
audio.sfx.send();
audio.sfx.receive();
}
P1.attackReady = false; P1.attackValue = 0;
P2.attackReady = false; P2.attackValue = 0;
}
// =========================
// プレイヤー更新
// =========================
function updatePlayer(p){
if (game.state !== 'PLAY') return;
if (p.state === 'DEAD') return;
spawnIfNeeded(p);
const a = p.active;
if (!a) return;
let movedOrRotated = false;
// 左右移動 (DAS)
const left = isDown(p.keys.left);
const right = isDown(p.keys.right);
let dir = 0;
if (left && !right) dir = -1;
else if (right && !left) dir = 1;
else if (left && right) dir = p.das.lastDir;
else dir = 0;
if (dir === 0){
p.das.dir = 0;
p.das.holdFrames = 0;
}else{
if (p.das.dir !== dir){
p.das.dir = dir;
p.das.holdFrames = 0;
if (tryMoveX(p, dir)) movedOrRotated = true;
}else{
p.das.holdFrames++;
if (p.das.holdFrames === DAS_DELAY ||
(p.das.holdFrames > DAS_DELAY && ((p.das.holdFrames - DAS_DELAY) % DAS_REPEAT === 0))){
if (tryMoveX(p, dir)) movedOrRotated = true;
}
}
}
// 回転
if (isPress(p.keys.rotR)){
if (rotateWithKick(p.field, a, +1)){ movedOrRotated = true; audio.sfx.rotate(); }
}
if (isPress(p.keys.rotL)){
if (rotateWithKick(p.field, a, -1)){ movedOrRotated = true; audio.sfx.rotate(); }
}
// ハード落下
if (isPress(p.keys.hard)){
while (canMove(p.field, a.x, a.y - 1, a.r, a.c1, a.c2)){
a.y -= 1;
}
// 即固定
lockNow(p);
return;
}
// 落下
const fast = isDown(p.keys.soft);
const interval = fast ? DROP_INTERVAL_FAST : DROP_INTERVAL;
a.dropCounter++;
// 高速落下中の効果音は間引き
if (fast){
if (p.fallSfxCooldown <= 0){
audio.sfx.move();
p.fallSfxCooldown = 6;
}else{
p.fallSfxCooldown--;
}
}else{
p.fallSfxCooldown = 0;
}
if (a.dropCounter >= interval){
a.dropCounter = 0;
// 1 ステップ落下
if (canMove(p.field, a.x, a.y - 1, a.r, a.c1, a.c2)){
a.y -= 1;
// 浮いているのでロック猶予リセット
a.lockCounter = 0;
}else{
// 接地
a.lockCounter++;
if (a.lockCounter >= LOCK_DELAY){
lockNow(p);
return;
}
}
}else{
// 接地中に操作があった場合はロック猶予リセット
if (!canMove(p.field, a.x, a.y - 1, a.r, a.c1, a.c2) && movedOrRotated){
a.lockCounter = 0;
}
}
}
function tryMoveX(p, dx){
const a = p.active;
if (!a) return false;
if (canMove(p.field, a.x + dx, a.y, a.r, a.c1, a.c2)){
a.x += dx;
audio.sfx.move();
return true;
}
return false;
}
function lockNow(p){
const a = p.active;
if (!a) return;
writeActive(p.field, a);
p.active = null;
audio.sfx.lock();
// 盤面処理は即時確定
resolveBoard(p);
// 次の出現直前に受信分を落とす
p.needGarbageDrop = true;
// 相殺は両者のバッファが揃った時点で行う
settleAttacks();
// 次の出現は次フレームでも良いが、ここでは即時でも可
spawnIfNeeded(p);
// 相手も相殺後に pending が増えた可能性があるので、相手側は spawn 時に処理される
}
// =========================
// 描画
// =========================
const cv = document.getElementById('cv');
const g = cv.getContext('2d');
function fitCanvas(){
// 見た目サイズは CSS で canvas 属性値を使う
const boardW = COLS * SZ;
const boardH = VISIBLE_ROWS * SZ;
const w = MARGIN_X*2 + boardW*2 + GAP;
const h = TOP_UI_H + boardH + BOTTOM_PAD;
cv.width = Math.floor(w * devicePixelRatio);
cv.height = Math.floor(h * devicePixelRatio);
cv.style.width = w + 'px';
cv.style.height = h + 'px';
g.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
}
window.addEventListener('resize', fitCanvas);
fitCanvas();
function cellToPixel(originX, x, y){
// y=0 が下
const px = originX + x*SZ + SZ/2;
const py = TOP_UI_H + (VISIBLE_ROWS - 1 - y)*SZ + SZ/2;
return [px, py];
}
function drawDisc(px, py, r, fill, stroke){
g.beginPath();
g.arc(px, py, r, 0, Math.PI*2);
g.fillStyle = fill;
g.fill();
g.lineWidth = 2;
g.strokeStyle = stroke;
g.stroke();
// ハイライト
g.beginPath();
g.arc(px - r*0.28, py - r*0.28, r*0.28, 0, Math.PI*2);
g.fillStyle = 'rgba(255,255,255,0.22)';
g.fill();
}
function drawBoard(p, originX, labelColor){
const boardW = COLS * SZ;
const boardH = VISIBLE_ROWS * SZ;
// 枠
g.fillStyle = 'rgba(18,26,39,0.72)';
g.fillRect(originX-8, TOP_UI_H-8, boardW+16, boardH+16);
g.strokeStyle = 'rgba(43,59,94,1)';
g.lineWidth = 2;
g.strokeRect(originX-8, TOP_UI_H-8, boardW+16, boardH+16);
// グリッド
g.strokeStyle = 'rgba(31,43,63,1)';
g.lineWidth = 1;
for (let x=0;x<=COLS;x++){
const xx = originX + x*SZ;
g.beginPath();
g.moveTo(xx, TOP_UI_H);
g.lineTo(xx, TOP_UI_H + boardH);
g.stroke();
}
for (let y=0;y<=VISIBLE_ROWS;y++){
const yy = TOP_UI_H + y*SZ;
g.beginPath();
g.moveTo(originX, yy);
g.lineTo(originX + boardW, yy);
g.stroke();
}
// 固定ぷよ
for (let y=0;y<VISIBLE_ROWS;y++){
for (let x=0;x<COLS;x++){
const v = p.field[y][x];
if (v === 0) continue;
const [px,py] = cellToPixel(originX, x, y);
drawDisc(px, py, SZ*0.44, COLORS[v], STROKES[v] || '#ffffff');
}
}
// 操作対象
if (p.active){
const a = p.active;
const x1 = a.x, y1 = a.y;
const x2 = a.x + dr[a.r][0], y2 = a.y + dr[a.r][1];
if (y1 >= 0 && y1 < VISIBLE_ROWS){
const [px,py] = cellToPixel(originX, x1, y1);
drawDisc(px, py, SZ*0.44, COLORS[a.c1], STROKES[a.c1]);
}
if (y2 >= 0 && y2 < VISIBLE_ROWS){
const [px,py] = cellToPixel(originX, x2, y2);
drawDisc(px, py, SZ*0.44, COLORS[a.c2], STROKES[a.c2]);
}
}
// UI テキスト
g.fillStyle = labelColor;
g.font = '700 16px system-ui, sans-serif';
g.fillText(`P${p.id}`, originX, 26);
g.font = '12px system-ui, sans-serif';
g.fillStyle = 'rgba(230,238,252,0.92)';
g.fillText(`Score ${p.score}`, originX, 46);
g.fillText(`Chain ${p.chain}`, originX, 64);
const pg = p.pendingGarbage | 0;
g.fillStyle = pg > 0 ? 'rgba(255,177,92,0.95)' : 'rgba(147,164,199,0.95)';
g.fillText(`Pending ${pg}`, originX, 82);
if (p.state === 'DEAD'){
g.fillStyle = 'rgba(255,92,92,0.95)';
g.font = '800 22px system-ui, sans-serif';
g.fillText('TOP OUT', originX + 20, TOP_UI_H + 140);
}
}
function drawScene(){
const boardW = COLS * SZ;
const origin1 = MARGIN_X;
const origin2 = MARGIN_X + boardW + GAP;
// 背景
g.clearRect(0,0,cv.width,cv.height);
g.fillStyle = 'rgba(0,0,0,0)';
g.fillRect(0,0,cv.width,cv.height);
// タイトル行
g.fillStyle = 'rgba(230,238,252,0.92)';
g.font = '700 16px system-ui, sans-serif';
g.fillText('落下パズル対戦', MARGIN_X, 18);
g.font = '12px system-ui, sans-serif';
g.fillStyle = 'rgba(147,164,199,0.95)';
const st = game.state;
const stTxt = (st === 'INIT') ? 'INIT' : (st === 'PLAY') ? 'PLAY' : (st === 'PAUSE') ? 'PAUSE' : 'WIN';
g.fillText(`State ${stTxt}`, MARGIN_X + 160, 18);
// 中央仕切り
g.strokeStyle = 'rgba(43,59,94,1)';
g.lineWidth = 3;
const midX = MARGIN_X + boardW + GAP/2;
g.beginPath();
g.moveTo(midX, TOP_UI_H - 12);
g.lineTo(midX, TOP_UI_H + VISIBLE_ROWS*SZ + 12);
g.stroke();
drawBoard(P1, origin1, 'rgba(102,230,255,0.95)');
drawBoard(P2, origin2, 'rgba(255,177,92,0.95)');
// 勝敗表示
if (game.state === 'INIT'){
overlayText('Enter で開始', 'rgba(230,238,252,0.95)');
}else if (game.state === 'PAUSE'){
overlayText('PAUSE', 'rgba(230,238,252,0.95)');
}else if (game.state === 'WIN'){
const msg = (game.winner === 0) ? 'DRAW' : `P${game.winner} WIN`;
overlayText(msg, 'rgba(230,238,252,0.95)');
}
}
function overlayText(text, color){
const w = parseInt(cv.style.width,10) || 920;
const h = parseInt(cv.style.height,10) || 720;
g.fillStyle = 'rgba(0,0,0,0.55)';
g.fillRect(0, TOP_UI_H + 140, w, 140);
g.fillStyle = color;
g.font = '900 40px system-ui, sans-serif';
g.textAlign = 'center';
g.fillText(text, w/2, TOP_UI_H + 225);
g.textAlign = 'left';
}
// =========================
// ループ
// =========================
function tick(){
// 状態遷移
if (isPress('Enter')){
if (game.state === 'INIT' || game.state === 'WIN'){
resetAll((Date.now() ^ 0x7f4a7c15) >>> 0);
}else if (game.state === 'PAUSE'){
game.state = 'PLAY';
}
}
if (isPress('KeyR')){
resetAll((Date.now() ^ 0x51ed270b) >>> 0);
}
if (isPress('KeyP')){
if (game.state === 'PLAY') game.state = 'PAUSE';
else if (game.state === 'PAUSE') game.state = 'PLAY';
}
if (isPress('KeyM')){
const cb = document.getElementById('audioEnabled');
cb.checked = !cb.checked;
audio.setEnabled(cb.checked);
}
if (game.state === 'PLAY'){
updatePlayer(P1);
updatePlayer(P2);
// 相殺の取りこぼし防止
settleAttacks();
// 勝敗判定
if (P1.state === 'DEAD' && P2.state !== 'DEAD'){
game.state = 'WIN'; game.winner = 2;
}else if (P2.state === 'DEAD' && P1.state !== 'DEAD'){
game.state = 'WIN'; game.winner = 1;
}else if (P1.state === 'DEAD' && P2.state === 'DEAD'){
game.state = 'WIN'; game.winner = 0;
}
}
drawScene();
// 1 フレームだけ有効
justPressed = Object.create(null);
requestAnimationFrame(tick);
}
// =========================
// UI
// =========================
const cbAudio = document.getElementById('audioEnabled');
const bgmVol = document.getElementById('bgmVol');
const sfxVol = document.getElementById('sfxVol');
const bgmVolV = document.getElementById('bgmVolV');
const sfxVolV = document.getElementById('sfxVolV');
cbAudio.addEventListener('change', () => audio.setEnabled(cbAudio.checked));
bgmVol.addEventListener('input', () => {
bgmVolV.textContent = (+bgmVol.value).toFixed(2);
audio.setBgmVol(+bgmVol.value);
});
sfxVol.addEventListener('input', () => {
sfxVolV.textContent = (+sfxVol.value).toFixed(2);
audio.setSfxVol(+sfxVol.value);
});
document.getElementById('btnStart').addEventListener('click', () => {
audio.ensureStart();
resetAll((Date.now() ^ 0x7f4a7c15) >>> 0);
});
document.getElementById('btnRestart').addEventListener('click', () => {
audio.ensureStart();
resetAll((Date.now() ^ 0x51ed270b) >>> 0);
});
document.getElementById('btnPause').addEventListener('click', () => {
if (game.state === 'PLAY') game.state = 'PAUSE';
else if (game.state === 'PAUSE') game.state = 'PLAY';
});
// 初期状態
game.state = 'INIT';
drawScene();
requestAnimationFrame(tick);
})();
</script>
</body>
</html>
Discussion