このメモについて

trisblocksAI DEVELOP で、自分で書いた AI にプレイさせるための取り決めを全部並べたものです。

「まず動かすところまで」は ① いちばん短いサンプル にあります。こちらは手元に置いて引く用なので、上から順に読む必要はありません。


つなぎ方

AI DEVELOP の ファイルをえらぶ で、自分の HTML を指定します。ゲームの中の隔離された枠 (sandbox="allow-scripts") で動き、BroadcastChannel('tb-ai') はゲーム側が橋渡しします。 アップロードは要りません。

my-ai.html をブラウザで直接開いてもつながりません。BroadcastChannel は同じオリジンのページ同士にしか 届かないためです。かならず「ファイルをえらぶ」から指定してください。


やり取りの流れ

ゲームから出るものはすべて from:'game' を持ちます。AI から出すものは from:'ai' にしてください。 ゲームは from'game' 以外のものを AI の発言として扱います。

向き中身いつ
AI → ゲーム{ from:'ai', type:'hello' }起動した時に1回。名乗り
ゲーム → AI{ type:'hello', meta }名乗り・pongready への返事。盤面の決まりごとを渡す
ゲーム → AI{ type:'ping' }AI DEVELOP の画面にいる間、350ms ごと
AI → ゲーム{ from:'ai', type:'pong' }呼びかけへの返事。これで「はじめる」が押せるようになる
ゲーム → AI{ type:'state', f, me, opp }対戦中、毎フレーム (60/秒)。1回およそ 860 バイト
AI → ゲーム{ from:'ai', act:[ ... ] }いつ送ってもよい。次のフレームの頭で反映される
ゲーム → AI{ type:'bye' }タイトルへ戻った時

meta — 盤面の決まりごと

hello に1回だけ乗ってきます。3セルミノは独自の形なので、向きごとの相対座標や回転の蹴り表もそのまま渡します。これだけで配置を列挙できます。

キー意味
w / h7 / 15見えている盤面の 幅 / 高さ
hid2いちばん上の、フィールド外の行数。ミノはここに出る
rows17hid + hboard はこの行数ぶん来る
fps601秒あたりのフレーム数
piecesstring[6]["I","J","L","S","Z","A"]
actionsstring[7]送れる操作の名前
shapesobjectshapes[ミノ][向き] = 中心からの相対座標 3つ。向きは 0〜3 (時計回り)
spawnobjectspawn[ミノ] = {x,y}。出現したときの中心の位置
kicksobject回転の蹴り表 (後述)
spinobjectスピンの成立条件と火力 (後述)
charsobject[]選べるキャラクター [{id,name}]
charstringいま選ばれているキャラクターの id
nextLen6見せている NEXT の数
softRows1['soft'] を押しているあいだ、1フレームに落ちる段数
softHoldtrue押しっぱなしで落ち続けるか
lineAtk[0,0,1,3]ふつうに消した時の基本火力 (添字 = 消した行数)
comboAtknumber[9]連続で消した時の上乗せ
btbPct25BtB の上乗せ(%)。端数切り捨て
perfectAtk8全消しの火力。他と足し合わせない
garbMax41回の設置でせり上がる最大行数
orbF27相手の攻撃が飛んでくるのにかかるフレーム数 (0.45秒)
holdF60届いてから実際にせり上がるまで (1秒)。この間もまだ相殺できる

state — 毎フレームの盤面

    {
      type: 'state', from: 'game',
      f: 1234,              // フレーム番号
      applied: 87,          // ゲームがこれまでに反映した操作の数
      mode: 'cpu',
      me:  { ... },         // 自分
      opp: { ... }          // 相手
    }

applied は、ゲームが受け取って実際に反映した操作の通算数です。 送った操作が効いたかどうかは、盤面の見た目ではなくこの数で判るので、 「送ったのに動いていない」と早合点して同じ操作を送り直さずに済みます。 たとえば 4 個送ったあと applied が 4 増えていれば、その 4 個は届いています。

me / opp の中身

キー意味
boardstring(119)積まれているブロックだけ。落下中のミノは入らない
curobject / null落下中のミノ {t,x,y,r,cells,ghostY}。設置演出中などは null
holdstring / null手持ちのミノ
holdUsedboolこのミノでもうホールドを使ったか
nextstring[6]次に来る順番
linesnumber消した行数の合計
piecesnumber置いたミノの数。これが増えたら次のミノ
combonumber連続で消した回数。つながっていない時は -1
btbbool次にスピンか3ライン消しを決めると BtB が乗るか
aliveboolまだ生きているか
groundedbool床または積みに接しているか
lockLeftnumber固定されるまでの残りフレーム。接地していない時は -1
garbagearray[{n, in}] = n 行が in フレーム後にせり上がる
garbageTotalnumbergarbagen の合計

opp.full を必ず見てください。 CPU 戦の相手は中身まで見えるので full:true で上と同じものが全部入ります。通信対戦の相手は送られてくる見た目しか無いため full:false になり、board落下中のミノが焼き込まれた状態で来ます。curnullnextgarbage は空です。


盤面の読み方

board は 17段 × 7列 = 119文字の16進数です。左上から右へ、行ごとに並びます。y は下へ行くほど大きくなります。

    index = y * 7 + x        // 0 ≦ x < 7,  0 ≦ y < 17
    cell  = parseInt(board[index], 16)
  • y = 0〜1 … フィールドの外。ミノはここに出ます。ここに積みが残ったまま次が出るとゲームオーバー
  • y = 2〜16 … 見えている15段。y=16 がいちばん下

マスの数字

01234567
IJLSZAおじゃま

何のミノだったかを気にしないなら、0 か否かだけ見れば足ります。

cur の座標

cur.cells盤面での絶対座標で、3つのマスがそのまま入っています。出たばかりのミノは y負になることがあります — 判定に使うときは範囲外を必ず弾いてください。cur.ghostY はそのまま落とした時の cur.y です。

    // cells を自分で組み立てる場合
    const off = meta.shapes[cur.t][cur.r];   // [[dx,dy] x3]
    const cells = off.map(([dx, dy]) => [cur.x + dx, cur.y + dy]);

入力

操作は7つです。left right soft hard cw ccw hold。書き方は3通りあり、混ぜてもかまいません。

書き方意味
['left'] / 'left'押してすぐ離す = 1回ぶん。ふつうはこれで足りる
['left', 1]押したままにする。そのまま置くと横溜め (DAS) が効く
['left', 0]離す
{a:'left', d:1}上と同じ。d を省くと1回ぶん
    // 1フレームぶんをまとめて送ってよい。 この順に反映される
    CH.postMessage({ from:'ai', act: [ ['cw'], ['left'], ['left'], ['hard'] ] });

1フレームで反映するのは 64個までです。それを大きく超えて溜まると、追いつけないと見なして列ごと捨てます。


キャラクターと名前

盤面の背景になるキャラクターと、盤面の下に出る名前は、AI 側から指定できます。どちらもどのメッセージに添えてもかまいません。

やり方いつ効くか
{ from:'ai', type:'pong', char:'c3' }呼びかけへの返事に添える。次に始める試合から
{ from:'ai', type:'char', id:'c3' }いつ送ってもよい。試合中に送ると次の試合から
{ from:'ai', type:'pong', name:'わたしのAI' }名前。16文字まで
window.tbChar('c3') / window.tbName('わたしのAI')ページ内の AI 用

選べる id は meta.chars に一覧で入っています。知らない id は黙って無視され、いま選ばれているものが残ります。名乗らなかった場合は「あなたの AI」と出ます。


スピン

回転の蹴り表がそのまま meta.kicks に入っています。ゲーム本体と同じ表なので、AI 側でスピンを再現できます。スピンで消すと火力が跳ね上がり、BtB も続きます。

    // 回した先を探す。 先頭から順に試して、最初に入った所へ回る
    const tbl = meta.kicks[t][dir > 0 ? 'cw' : 'ccw'][r];   // r = 回す前の向き
    const nr  = (r + (dir > 0 ? 1 : 3)) & 3;
    for (let i = 0; i < tbl.length; i++) {
      const nx = x + tbl[i][0], ny = y + tbl[i][1];
      if (fits(board, meta.shapes[t][nr], nx, ny)) return { x: nx, y: ny, r: nr };
    }

1〜5番目はテトリスガイドラインの SRS そのままです。6番目は3セルミノ用に足した独自の逃がし先で、1〜5 が全部通らなかった時だけ試されます。

成立の条件 (meta.spin)

キー意味
corners = 3最後の操作が回転で、回転軸のまわり4隅のうち3つ以上が埋まっていれば成立。壁と床も埋まり扱い
aPiece = 'A' / aSides = 2A だけは形の都合で3隅に届かないので別扱い。背中側を除く3方向のうち2つ以上 (下で説明します)
atk = [0,2,4,6]スピンで消した時の基本火力。ふつうの lineAtk と置き換わる
aTriple = 8A のスピンで3ライン消しだけ、6ではなく8
afterHardDropハードドロップの猶予中に回せるか。プレイヤーの設定で変わるので毎回この値を見てください

A の「背中側」とは

A は 回転軸そのもの + その斜め2マス という形で、斜めの2マスは必ず同じ側に寄っています。 そのため4隅のうち2つを自分自身で埋めてしまい、3隅ルールでは絶対に成立しません。そこで A だけは、 隅ではなく 回転軸の上下左右 を見ます。

背中側 = 斜め2マスが向いている側の、ちょうど反対の方向です。A がそちらに何も持っていない側で、 向きごとに決まっています。ここは形の都合でまず埋まらないので数えません。 残る3方向のうち 2つ以上が埋まっていれば成立します。壁と床も埋まり扱いです。

      r0 背中=上     r1 背中=右     r2 背中=下     r3 背中=左
       . . .          X ? .          X ? X          . ? X
       ? A ?          ? A .          ? A ?          . A ?
       X ? X          X ? .          . . .          . ? X

      A = 回転軸 (ここも A のセル) / X = A のセル / ? = 見る3マス
向き斜め2マスの側背中側 (見ない)見る3方向
r = 0下・左・右
r = 1上・下・左
r = 2上・左・右
r = 3上・下・右

たとえば r = 0 なら、軸の真上がどれだけ埋まっていても成立には関係ありません。 軸の下・左・右のうち2つが埋まっていれば、そこで A スピンになります。


['soft'] の効き方に注意

['soft']送ったそのフレームでは落ちません。押している状態になるだけで、 実際に落ちるのは次のフレームからです。押しているあいだ、1フレームに softRows 段ずつ落ちます。

そのため「['soft'] を2つ送って、同じフレームで cur.y を見る」やり方で効きを測ると、 必ず 0 段になり「効かない」と誤判定します。実測せず meta.softRows を見てください。


横や下へ動かすとスピンの印は消えます。 回転で立ち、左右移動と落下で降ろされます。afterHardDrop が true なら「hard → 回転 → hard」で、落としきった所から潜り込ませて決められます。


対戦相手として投稿する

自分の AI を対戦相手の一覧に載せることもできます。載ったものは、AI DEVELOP と CPU MATCH の両方から選べます。

アップロード画面もログインもありません。AI のファイルをメールで送ってもらい、こちらで置きます。

置けるファイルは2種類

拡張子動き方
.html自分用に作った AI をそのまま送れます。 手元で動かしていたファイルを一文字も変えずに置けます。隔離した枠 (sandbox="allow-scripts") の中で動き、BroadcastChannel はゲーム側が橋渡しします
.jsWeb Worker として動きます。相手専用に書き下ろす場合はこちら

どちらもページの中身 (DOM・Cookie・localStorage) には触れられません。渡すのは盤面だけ、返ってくるのは操作の名前だけです。

.js で書く場合は、BroadcastChannel の代わりに self.onmessage / self.postMessage を使います。それ以外は同じです。

    var META = null;

    self.onmessage = function (ev) {
      var m = ev.data;
      if (!m) return;
      if (m.type === 'hello') { META = m.meta; return; }      // 最初に1回
      if (m.type !== 'state' || !META) return;                // 毎フレーム

      var acts = think(m, META);                              // ここに自分の AI
      if (acts && acts.length) self.postMessage({ act: acts });
    };

ping は飛んできません。相手側は対戦が始まる時にこちらから起こすので、名乗る必要がないためです。


つまずきやすい所

  • 返事は待ちません。 入力は届いた時点の次のフレームで反映されるので、実際には 1〜2フレーム遅れて入ります。そのぶん AI が重くても画面は止まりません
  • 1ミノにつき1回だけ考えるなら me.pieces を見てください。 cur.t は同じミノが続くと変わらないので目印になりません
  • おじゃまは2段階です。 orbF のあいだ飛んでいて、着いてから holdF だけ溜まり、それからせり上がります。garbage[].in はその残り合計なので、0 になる前に消せば相殺できます
  • from を必ず付けてください。 付け忘れたメッセージは AI に届きません
  • file:// では投稿された AI を読み込めません。 ブラウザが fetch を禁じているためです。ローカルで試すときは簡易サーバ越しに開いてください
  • https から ws://localhost へはつなげません。 外部プロセスの AI を WebSocket でつなぐ場合は、ゲームも http://localhost から開いてください