このメモについて
trisblocks の AI 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 } | 名乗り・pong・ready への返事。盤面の決まりごとを渡す |
| ゲーム → 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 / h | 7 / 15 | 見えている盤面の 幅 / 高さ |
hid | 2 | いちばん上の、フィールド外の行数。ミノはここに出る |
rows | 17 | hid + h。board はこの行数ぶん来る |
fps | 60 | 1秒あたりのフレーム数 |
pieces | string[6] | ["I","J","L","S","Z","A"] |
actions | string[7] | 送れる操作の名前 |
shapes | object | shapes[ミノ][向き] = 中心からの相対座標 3つ。向きは 0〜3 (時計回り) |
spawn | object | spawn[ミノ] = {x,y}。出現したときの中心の位置 |
kicks | object | 回転の蹴り表 (後述) |
spin | object | スピンの成立条件と火力 (後述) |
chars | object[] | 選べるキャラクター [{id,name}] |
char | string | いま選ばれているキャラクターの id |
nextLen | 6 | 見せている NEXT の数 |
softRows | 1 | ['soft'] を押しているあいだ、1フレームに落ちる段数 |
softHold | true | 押しっぱなしで落ち続けるか |
lineAtk | [0,0,1,3] | ふつうに消した時の基本火力 (添字 = 消した行数) |
comboAtk | number[9] | 連続で消した時の上乗せ |
btbPct | 25 | BtB の上乗せ(%)。端数切り捨て |
perfectAtk | 8 | 全消しの火力。他と足し合わせない |
garbMax | 4 | 1回の設置でせり上がる最大行数 |
orbF | 27 | 相手の攻撃が飛んでくるのにかかるフレーム数 (0.45秒) |
holdF | 60 | 届いてから実際にせり上がるまで (1秒)。この間もまだ相殺できる |
state — 毎フレームの盤面
{
type: 'state', from: 'game',
f: 1234, // フレーム番号
applied: 87, // ゲームがこれまでに反映した操作の数
mode: 'cpu',
me: { ... }, // 自分
opp: { ... } // 相手
}
applied は、ゲームが受け取って実際に反映した操作の通算数です。 送った操作が効いたかどうかは、盤面の見た目ではなくこの数で判るので、 「送ったのに動いていない」と早合点して同じ操作を送り直さずに済みます。 たとえば 4 個送ったあと applied が 4 増えていれば、その 4 個は届いています。
me / opp の中身
| キー | 型 | 意味 |
|---|---|---|
board | string(119) | 積まれているブロックだけ。落下中のミノは入らない |
cur | object / null | 落下中のミノ {t,x,y,r,cells,ghostY}。設置演出中などは null |
hold | string / null | 手持ちのミノ |
holdUsed | bool | このミノでもうホールドを使ったか |
next | string[6] | 次に来る順番 |
lines | number | 消した行数の合計 |
pieces | number | 置いたミノの数。これが増えたら次のミノ |
combo | number | 連続で消した回数。つながっていない時は -1 |
btb | bool | 次にスピンか3ライン消しを決めると BtB が乗るか |
alive | bool | まだ生きているか |
grounded | bool | 床または積みに接しているか |
lockLeft | number | 固定されるまでの残りフレーム。接地していない時は -1 |
garbage | array | [{n, in}] = n 行が in フレーム後にせり上がる |
garbageTotal | number | garbage の n の合計 |
opp.full を必ず見てください。 CPU 戦の相手は中身まで見えるので full:true で上と同じものが全部入ります。通信対戦の相手は送られてくる見た目しか無いため full:false になり、board に落下中のミノが焼き込まれた状態で来ます。cur は null、next と garbage は空です。
盤面の読み方
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がいちばん下
マスの数字
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|
| 空 | I | J | L | S | Z | A | おじゃま |
何のミノだったかを気にしないなら、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 = 2 | A だけは形の都合で3隅に届かないので別扱い。背中側を除く3方向のうち2つ以上 (下で説明します) |
atk = [0,2,4,6] | スピンで消した時の基本火力。ふつうの lineAtk と置き換わる |
aTriple = 8 | A のスピンで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 はゲーム側が橋渡しします |
.js | Web 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から開いてください