UIAPduino のアルゴリズムが陣地を取り合う対戦スネーク — CPU ラダーと 2 台での直接対戦
▦ SnakeVS.ino の動きを図で理解する →CPU 5段ラダーを 3 本勝負(2 本先取)で勝ち上がります。引き分けは仕切り直しです。
Tools → USB → WebHID Only で書き込んでください。
プレイヤー名はスケッチの #define PLAYER_NAME を書き換えて設定します(半角英数 12 文字まで)。
両者とも毎ティック 1 マス成長し、縮みません。壁・岩・どちらかの体に進入したら死亡。 両者の頭が同じマスに入る、または頭を入れ替える動きは両者死亡=引き分けで仕切り直しです。 スタート位置は 6 パターンからランダムに選ばれ、どれも 180° 回転対称なので有利不利はありません。
| CPU Lv | アルゴリズム | 岩 | 目安 |
|---|---|---|---|
| 1 | ランダムな安全手 | 0 | 肩慣らし |
| 2 | 貪欲(最初に見つかった安全方向) | 4 | |
| 3 | BFS 最大空間優先 | 8 | 配布スケッチと互角 |
| 4 | ボロノイ(自分の空間 − 相手の空間) | 10 | ここが壁 |
| 5 | ボロノイ + 相手の最善手を読む先読み | 10 | 最終段 |
岩は 180° 回転対称((x,y) と (15−x,15−y) をペア)で配置され、開始位置とその進行方向の直近には置かれません。
Feature Report 32バイト(Web → UIAPduino)/ Input Report 8バイト(UIAPduino → Web)
| 方向 | byte[0] | ペイロード | 説明 |
|---|---|---|---|
| Web→Arduino | 0x01 CMD_START | [1..2]=自分の頭 [3..4]=相手の頭 [5..6]=自分の初期の向き(+1) | ラウンド開始 |
| Web→Arduino | 0x02 CMD_TICK | [1..2]=自分の頭 [3..4]=相手の頭 | 1ティック進行(移動後の両者の頭) |
| Web→Arduino | 0x03 CMD_ROCK | [1]=x [2]=y | 岩の座標(CMD_START の前に全部送信) |
| Web→Arduino | 0x05 CMD_RESET | — | 盤面・岩をリセット |
| Web→Arduino | 0x40 CMD_GET_NAME | — | プレイヤー名を要求(接続直後) |
| Arduino→Web | 0x10 CMD_DIR | [1]=dx [2]=dy | 次の移動方向 |
| Arduino→Web | 0x41 CMD_NAME | [1]=chunk(0/1) [2..7]=名前6文字 | プレイヤー名 |
| Arduino→Web | 0x14 CMD_READY | — | 起動完了・接続確認 |
進行はロックステップ(デバイスの CMD_DIR を待ってから次のティックへ)。
USB の遅延で勝敗が決まらないようにしています。ただし 2 秒応答がないと直前の方向を継続し、連続すると反則負けです。
観戦できるよう 1 ティックの下限を 200ms にしています。
uint8_t cell[16][16]; // EMPTY / MINE / OPP / ROCK // CMD_TICK 受信時: 両者の頭を記録するだけ myX = buf[1]; myY = buf[2]; oppX = buf[3]; oppY = buf[4]; cell[myY][myX] = MINE; cell[oppY][oppX] = OPP;1 セルを 1 バイトにまとめているのは RAM 節約のためです(bool 配列 3 枚より 512 バイト少ない)。 CH32V003 の RAM は 2KB しかないので、キミのアルゴリズム用に余裕を残してあります。
vs.sendDir(dx, dy) で方向を送ります(必ず呼ぶこと)。
cell[y][x] が EMPTY かどうかを見れば全部まとめて判定できます:bool passable(int16_t x, int16_t y) {
if (x < 0 || x >= 16 || y < 0 || y >= 16) return false;
return cell[y][x] == EMPTY;
}
画面の 「陣地 ON」で、どちらが先に到達できるかを色分け表示できます。
自分の陣地が緑、相手の陣地が赤。CPU Lv4 がこれを最大化してくることを目で確認できます。// そのマスは相手も次の 1 手で入れるか?
bool contested(int16_t nx, int16_t ny) {
int16_t d = (nx > oppX ? nx - oppX : oppX - nx)
+ (ny > oppY ? ny - oppY : oppY - ny);
return d == 1; // 相手の頭から 1 マス = 取り合いになる
}
配布スケッチはこの判定を入れてあります。
入れないとどうなるか実測した結果がこちらです(配布スケッチ同士を 60 戦させたもの):相手の頭だけ避ける(絶対方向の固定順) → 決着 10%・引き分け 54(全て正面衝突) + 次に入れるマスも避ける → 決着 83%(ただし片側に偏る) + 手の評価順もシャッフル → 決着 87%・偏りなし2 台とも同じスケッチだと、180° 対称の盤面で完全な鏡像になり、 中央で必ず正面衝突します。手の評価順をシャッフルして鏡像を崩すのも セットで必要です。配布スケッチは PLAYER_NAME と開始座標から乱数の種を作っているので、 2 台が同じ動きになりません。
uint16_t countReachable(uint8_t sx, uint8_t sy) {
bool visited[16][16];
// ... 初期化 ...
uint16_t head = 0, tail = 0;
bfsQ[tail++] = (sy << 4) | sx; // 座標を 1 バイトに詰める
visited[sy][sx] = true;
while (head < tail) {
uint8_t p = bfsQ[head++];
uint8_t x = p & 15, y = p >> 4;
for (uint8_t d = 0; d < 4; d++) {
int16_t nx = x + DDX[d], ny = y + DDY[d];
if (!passable(nx, ny) || visited[ny][nx]) continue;
visited[ny][nx] = true;
bfsQ[tail++] = (ny << 4) | nx;
}
}
return tail; // 到達できたマス数
}
これは Snake Solver のヒント 4 と同じ考え方で、配布スケッチがこの実装です。
CPU Lv3 と互角まで来ますが、自分が詰まないことしか見ていないので Lv4 で止まります。
myDist[y][x] と、
相手の頭から BFS した距離 oppDist[y][x] を作り、各マスを比べます。myDist < oppDist → 自分の陣地oppDist < myDist → 相手の陣地// 距離配列は uint8_t 16×16 = 256 バイト × 2 枚。
// 255 を「未到達」の印にすれば visited 配列は要らない。
uint8_t myDist[16][16], oppDist[16][16];
// (sx, sy) から各マスまでの最短距離を BFS で求める。
// 壁・岩・両者の体で到達できないマスは 255 のままになる。
void bfsFill(uint8_t dist[16][16], uint8_t sx, uint8_t sy) {
for (uint8_t y = 0; y < 16; y++)
for (uint8_t x = 0; x < 16; x++)
dist[y][x] = 255;
const int8_t dx[4] = { 1, 0, -1, 0 };
const int8_t dy[4] = { 0, 1, 0, -1 };
uint16_t head = 0, tail = 0;
dist[sy][sx] = 0;
bfsQ[tail++] = (uint8_t)((sy << 4) | sx);
while (head < tail) {
uint8_t p = bfsQ[head++];
uint8_t x = p & 15;
uint8_t y = p >> 4;
for (uint8_t d = 0; d < 4; d++) {
int16_t nx = (int16_t)x + dx[d];
int16_t ny = (int16_t)y + dy[d];
if (!passable(nx, ny)) continue;
if (dist[ny][nx] != 255) continue;
dist[ny][nx] = dist[y][x] + 1;
bfsQ[tail++] = (uint8_t)((ny << 4) | nx);
}
}
}
int16_t voronoiScore() {
bfsFill(myDist, myX, myY);
bfsFill(oppDist, oppX, oppY);
int16_t mineCount = 0, oppCount = 0;
for (uint8_t y = 0; y < 16; y++)
for (uint8_t x = 0; x < 16; x++) {
if (myDist[y][x] == 255 && oppDist[y][x] == 255) continue;
if (myDist[y][x] < oppDist[y][x]) mineCount++;
else if (oppDist[y][x] < myDist[y][x]) oppCount++;
}
return mineCount - oppCount;
}
相手を壁際に追い込む動きが自然に出てきます。これで CPU Lv4 を狙えます。// 深さ 2(自分の手 → 相手の手)
int16_t best = -32000; int8_t bestD = 0;
for (自分の手 m : 4方向) {
if (!合法(m)) continue;
仮に m を打つ;
int16_t worst = 32000;
for (相手の手 o : 4方向) {
if (!合法(o)) continue;
仮に o を打つ;
int16_t v = voronoiScore();
if (v < worst) worst = v;
o を戻す;
}
m を戻す;
if (worst > best) { best = worst; bestD = m; }
}
気をつけること:cell を書き換えたら復元)visited をローカルに取らず、
距離配列を再利用してスタックを節約する
ボード: HID ProMicro CH32V003 / Tools → USB: WebHID Only