このメモについて

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

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

PAYA BLOCKS・MAGI BLOCKS の一覧は、PAYA BLOCKS ② API一覧 と MAGI BLOCKS ② API一覧 にあります。


つなぎ方

AI DEVELOP で TRISBLOCKS を選び、ファイルをえらぶ で、自分の 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 }名乗り・pong・ready への返事。盤面の決まりごとを渡す
ゲーム → AI{ type:'ping' }AI DEVELOP の画面にいる間、350ms ごと
AI → ゲーム{ from:'ai', type:'pong' }呼びかけへの返事。これで「決定」が押せるようになる
ゲーム → AI{ type:'state', f, me, opp }対戦中、毎フレーム (60/秒)。1回およそ 1100 バイト
AI → ゲーム{ from:'ai', act:[ ... ] }いつ送ってもよい。次のフレームの頭で反映される
ゲーム → AI{ type:'bye' }タイトルへ戻った時

どのゲーム用かを名乗る (game)

AI は hello と pong に game を添えて、どのゲーム用かを名乗れます。名乗らなければ TRISBLOCKS 用として扱われるので、 TRISBLOCKS の AI は何も添えなくて構いません。

PAYA BLOCKS / MAGI BLOCKS 用の AI は game: 'paya' / game: 'magi' と名乗る決まりです。 いま開いているゲームと合わない AI は 「読み込み失敗」 と出て、動きません。


meta — 盤面の決まりごと

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

キー値意味
game'tris'ゲームの種別
w / h7 / 15見えている盤面の 幅 / 高さ
hid2いちばん上の、フィールド外の行数。ミノはここに出る
rows17hid + h。board はこの行数ぶん来る
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
nextLen3見せている NEXT の数。next はこの個数までしか来ません
softRows1['soft'] を押しているあいだ、1フレームに落ちる段数
softHoldtrue押しっぱなしで落ち続けるか
lineAtk[0,0,1,3]ふつうに消した時の基本火力 (添字 = 消した行数)
comboAtknumber[9]連続で消した時の上乗せ
btbPct25BtB の上乗せ(%)。端数切り捨て
perfectAtk8全消しの火力。他と足し合わせない
garbMax41回の設置でせり上がる最大行数
orbF14相手の攻撃が飛んでくるのにかかるフレーム数
holdF0届いてから、せり上がれるようになるまで。長引いた試合では margin の決まりで変わる
garbShiftPct30おじゃまの穴が、1行ごとに別の列へ移る割合(%)
riseF60対戦ルールが RIZE戦の時、何フレームごとに1段せり上がるか (置かなくても上がる)
lockF60接地してから固定されるまでのフレーム数
lockResetMax15接地中に動かして、固定を先延ばしできる回数
liftMax6接地したミノを浮かせて置き直せる回数。超えると強制的に固定
hdHoldF5hard で置いてから固定されるまでの猶予。この間は回転・横移動で置き直せる
areF10固定してから次のミノが出るまで
clearF[0,18,20,23]ライン消去の演出 (添字 = 消した行数)。この間は操作できない
perfectClearF10全消しの時の演出。clearF の代わりにこちらになる
dasF / arrF9 / 2横溜め / 横連射のフレーム数
gravobject落下速度の決まり (後述)
marginobject長引いた試合の底上げの決まり (後述)

落下速度 (meta.grav)

落下速度は試合の中で変わります。消した行数と経過時間でレベルが上がり、速くなります。 いまの落下速度は、毎フレームの me.gravity に入っています。

    level        = 1 + floor(lines / linesPerLevel) + floor(経過ミリ秒 / msPerLevel)
    framesPerRow = max(minF, baseF * decay^(level - 1))   // 1段落ちるのにかかるフレーム数
キー値意味
baseF48レベル1で1段落ちるフレーム数
decay0.86レベルが1上がるごとに掛かる倍率
minFnumberこれより速くはならない (1未満なら1フレームに複数段落ちる)
linesPerLevel8この行数を消すごとにレベルが1上がる
msPerLevel40000この時間 (40秒) ごとにもレベルが1上がる

長引いた試合の底上げ (meta.margin)

対戦が長引くと段階が上がり、火力が増えて、おじゃまの猶予が変わります。

    段階   = 経過 < startMs ? 0 : 1 + floor((経過ミリ秒 - startMs) / stepMs)     // mul の最後まで
    火力   = 段階 > 0 ? round(基本の火力 * mul[段階]) : 基本の火力
    猶予   = 段階 > 0 ? max(minF, round(holdF / 2^段階)) : holdF
キー値意味
startMs90000底上げが始まるまでの時間 (90秒)
stepMs160001段階すすむまでの時間 (16秒)
mul[1,1.33,1.8,2.4,3.2,4]段階ごとの火力の倍率 (先頭は、まだ始まっていない時)
minF6猶予をこれより短くしない

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[3]次に来る順番。meta.nextLen 個まで
linesnumber消した行数の合計
piecesnumber出てきたミノの数 (いま操作しているものを含む)。これが増えたら次のミノ
combonumber連続で消した回数。つながっていない時は -1
btbbool次にスピンか3ライン消しを決めると BtB が乗るか
aliveboolまだ生きているか
groundedbool床または積みに接しているか
lockLeftnumber固定されるまでの残りフレーム。接地していない時は -1
garbagearray[{n, in}] = n 行が in フレーム後にせり上がれるようになる
garbageTotalnumbergarbage の n の合計
gravityobjectいまの落下速度 (下の表)
holeHintnumber盤面のいちばん下に出ている、次のおじゃまの穴の予告の列 (0〜6)。出ていない時は -1
meternumber火力メーターに見えている段数。相手の攻撃の玉が届くまでは増えない
actionstring盤面に出ているアクションの文字 (複数行の時は改行区切り。例: BtB / S SPIN / DOUBLE)。出ていない時は ''
canActboolいま送った入力が cur に効くか。ミノが無い間 (置いたあとの待ち・消去の演出中) と、ポーズ・カウントダウン中は false
curIdnumber操作しているミノの番号。新しく出た時と、HOLD で入れ替わった時に +1
phasestring'play' 操作中 / 'drop' ハードドロップして固定待ち (まだ動かせる) / 'clear' 消去の演出中 / 'wait' 次のミノ待ち / 'dead'

gravity から下は、人間が画面を見て分かることだけを渡しています。 内部の乱数や残りフレームのような、画面に出ない値は入れません。

canAct が false の間に送った入力は効きません。AI が動かす盤面には先行入力が無く、ミノがある時に送った入力だけが効きます。

gravity の中身

キー型意味
modestring'level' = レベルで決まるふつうの落下 / 'rows' = トレーニングの固定の重力 / 'none' = 勝手に落ちない
framesPerRownumber1段落ちるのにかかるフレーム数。1未満なら1フレームに複数段。'none' は -1
rowsPerFramenumber1フレームに落ちる段数 (framesPerRow の逆数)

['soft'] を押している間は、これに関係なく1フレームに meta.softRows 段落ちます。 ハードドロップの猶予中・設置待ち・ライン消去の演出中は、落下そのものが止まっています。

opp.full を必ず見てください。 CPU 戦の相手は中身まで見えるので full:true で上と同じものが全部入ります。通信対戦の相手は送られてくる見た目しか無いため full:false になり、board に落下中のミノが焼き込まれた状態で来ます。cur は null、next と garbage は空です。画面に出ている meter と action は入り、盤面に予告が出ない holeHint は -1、gravity は null です。canAct は null、curId は -1、phase は '' になります。

「AI と対戦」で PAYA BLOCKS / MAGI BLOCKS 用の AI を相手に選んだ時も full:false です。盤面の形そのものが違うので、何も無い盤面が入ります。読めるのは、自分と同じゲームの盤面だけです。


盤面の読み方

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 = trueハードドロップの猶予中に回せるか。AI が動かす盤面では、プレイヤーの設定に関わらずいつも true です

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 を見てください。


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


対戦相手として投稿する

自分の AI を、ほかの人が選べる対戦相手として上げることができます。載ったものは、AI DEVELOP と CPU MATCH の「AI と対戦」から選べます。 AI DEVELOP の アップロード準備 から進みます。

関門は、GreaterGate に10連勝することです。

何戦目条件
1〜4戦目ふつうの対戦
5〜7戦目はじめから両方の盤面に10段のお邪魔がある
8〜10戦目1秒ごとに両方の盤面が1段せり上がる
  • 「ファイルをえらぶ」で選んだ AI が動いていること、ファイルが 500KB 以下であることが条件です
  • 1敗したらはじめからやり直しです
  • 抜けると名前 (16文字まで) と作った人 (12文字まで) を入れる画面になり、アップロードする を押すと「AI と対戦」の一覧に並びます
  • 同じ名前の AI がすでにある時は、上書きするかどうかを聞かれます。上書きしても、レートと成績は引き継がれます
  • 上げたあとは、AI DEVELOP の 投稿済み AI の 調整する から、名前や備考を直したり消したりできます

お邪魔つきとせり上がりの対戦は、AI DEVELOP の 対戦ルール (お邪魔開始戦 / RIZE戦) で、あらかじめ何度でも試せます。

相手役として動く時

上げた AI は、手元で動かしていたファイルのまま相手役として動きます。隔離した枠 (sandbox="allow-scripts") の中で動き、 ページの中身 (DOM・Cookie・localStorage) には触れられません。渡すのは盤面だけ、返ってくるのは操作の名前だけです。

  • me が自分 (相手役)、opp がプレイヤーの盤面です。プレイヤーがほかのゲーム (PAYA BLOCKS / MAGI BLOCKS) で遊んでいる時は、opp は何も無い盤面になります
  • ping は飛んできません。起動した時にゲームから hello が届き、対戦が始まると state が流れ始めます

つまずきやすい所

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