Edited at

wasm-bindgenを使って落ちゲーを作った

クソアプリを摂取し過ぎて、「クソアプリとは?(哲学)」という境地になってきましたが、気にせず書きます。

去年は、3次元ライフゲーム という記事を書きました。確か、当日の23時頃まで空き枠で、雑に昔作ったやつの説明を書いて投稿した記憶があります。

それが今年は、クソアプリカレンダー2まですぐに埋まるわ、遅延しようものならすぐに代走記事が投稿されるわということで、「クソアプリカレンダーのやつ、大きくなりやがって」といった感慨さえあります。


作ったもの

「ドミリス」という名前のクソゲーです。

https://eduidl.github.io/domris/ で遊べます(キーボード必須)。

Chrome, Firefox, Vivaldiは動くことを確認しました。Edgeは動かないことを確認しましたが直せていません(直るかもわからない)。

DomrisDomDominoDomです(risは某落ちゲーのrisです)。

ソースコード:https://github.com/eduidl/domris


動機

皆さんドミノって知っていますか?知らない人は、https://ja.wikipedia.org/wiki/ドミノ を見てください。

簡単に言うとサイコロの目が書かれたような1x2サイズのドミノ牌を、同じ数字が隣り合うように並べる遊びです。

そして、複数の正方形を辺でつなげた多角形をポリオミノといいます。一番有名なのはテトロミノでしょうか。実は、ポリオミノという名前は、ドミノが由来です。

で、こう思いませんか?「サイコロの目どこ行った?」と。

思えば、そうです。ドミノ倒しにしても、Google 画像検索すればわかるように、もはや直方体倒しになってしまっています。

もう、ドミノ・ピザ(1-2のドミノ牌が描かれています)しか、ドミノのアイデンティティを認めてくれる者はいないのです。

ということで立ち上がりました。

目的としては、「胡坐をかいているテトロミノにドミノのアイデンティティを思い出して欲しかった。」ということになります。


はい、というわけで謎の使命感に関しては大いに脚色が入っていますが、ポリオミノ関連でルールを考えたのは事実です。ということでルールを紹介します。


ルール

基本は某落ちゲーと同じく上から降ってくるテトロミノを配置していきながら、一列に並べてを消していくゲームです。

大きく異なるのは、以下のようにテトロミノの正方形のタイルごとに数字が振られている点です。これがドミノのアイデンティティを示すポイントになっています。(サイコロの目っぽくないのは、実装をさぼったからです。canvasのsetTextで楽に済ませたかった。)

ドミノとルールを合わせるなら、隣り合う数字同士が同じでないと置けないわけですが、そうした場合に、ゲームオーバーとしたらゲームバランスが悪すぎます。

ということでペナルティを与えることにしました。操作中のテトロミノが固定化されるとき、操作中のテトロミノと、それ以外のブロック(壁も含む)で隣接しているブロックのペアを全て調べ、一つも数字が一致しているペアがなければ、ペナルティとして操作中のテトロミノに隣接している壁でないブロックを全て空にします。


以下のように、I字ブロックが既に固定化されているところに、L字ブロックが上から降ってくることを考えます。

このまま落ちると、左から 6(L)4(I), 4(L)1(I), 3(L)6(I)のペアで隣接します。これらのペアはどれも同じ数字同士のペアではないので、ペナルティが発動します。

ということで、今しがた落ちてきたL字ブロックはそのままで、I字の4, 1, 6のブロックだけ消えます。

(スクショに失敗して、次のブロックも落ちてきていますが、幸いペナルティのない例となっているので残しました。S字ブロックの4とL字ブロックの4が隣接しているのでペナルティはありません。)

ペナルティの影響ということを示しておくと、例えば、雑に積んでいくとこんな感じになります。結構厳しいです。


ペナルティの実装

なかなか自然言語の説明は難しいので、コード(Rust)を一応示しておきます。

重要なのはmatch式の中です。self.boardは、マスの種類(テトロミノ or 壁 or 空)と数字のタプルの二次元配列です。

num == *ref_num で数字の一致を調べて、一致していたらearly returnし、そうでなかったら、それが壁でないブロックの場合、その座標を削除候補として追加(delete_candidates.push((xx, yy)))していきます。

early returnされずループを抜けた場合に、delete_candidatesの座標位置をすべて空にします。

fn penalty(&mut self) {

let mut delete_candidates = Vec::new();

{
let mino = self.current_mino();
for ((x, y), ref_num) in mino.coordinates().iter().zip(mino.numbers()) {
for (dx, dy) in DIRECTIONS.iter() {
if y + dy < 0 { continue; }

let xx = (x + dx) as usize;
let yy = (y + dy) as usize;
match self.board[yy][xx] {
(Cell::Shape(_), Some(num)) => {
if num == *ref_num {
return;
} else {
delete_candidates.push((xx, yy));
}
},
(Cell::Empty, _) => { continue; },
(Cell::Wall, Some(num)) => {
if num == *ref_num { return; }
},
(Cell::Wall, None) => { return; },
(_, _) => {},
}
}
}
}

for (x, y) in delete_candidates {
self.board[y][x] = (Cell::Empty, None);
}
}


レベルについて

書き忘れていました。

初級、中級、上級それぞれで振られる数字の範囲が異なります。


  • 初級: 0, 1

  • 中級: 0 〜 3

  • 上級: 0 〜 6


クソポイント


難しすぎる

自分が考えたペナルティ抜きにしても、現在の本家本元のアレに比べると、


  • 先読みできない

  • ホールドできない

  • そのまま回転するとめり込むようなときに、壁キックして無理やり回転するような挙動がない(あれ、実装どうなっているんだろう?)

  • 乱数の偏りがありのままでてくる(実際乱数で実装してみると結構偏りが出て辛いです、たぶん本家は同じのが出続けると再抽選したりとか、バランスをとるようにしているんじゃないかなあ。)

というので普通に難しいです。

さらに、それに加えてペナルティというクソ要素で、ゲームバランスが大変悪い。


爽快感がない

やっぱり音楽やエフェクトって重要なんだなって思いました。

同じクソアプリアドベントカレンダーだと、社会に一石を投じるクソアプリ開発 がいい例だと思います。

かといって、ページを開いただけで、音楽がなり始めるのは(動画サイト等を除き)のは個人的には邪悪なサイトなので、オンオフが選べるとよいのですが、その実装が面倒くさいそう。


一応、技術的な話

自分はRust + wasm-bindgenを使ってこのアプリを作りました。

wasm-bindgenのチュートリアルは、https://rustwasm.github.io/wasm-bindgen/ 等に譲るとして、

嵌った点等を書きなぐっていきます。


ビルド


package.json

{

"name": "domris",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build:debug": "cargo +beta build --target wasm32-unknown-unknown && wasm-bindgen target/wasm32-unknown-unknown/debug/domris.wasm --out-dir ./wasm && rm -r docs && webpack",
"build:release": "cargo +beta build --release --target wasm32-unknown-unknown && wasm-bindgen target/wasm32-unknown-unknown/release/domris.wasm --out-dir ./wasm && rm -r docs && NODE_ENV=production webpack",
"server": "webpack-dev-server -w"
},
//
}

改行できないので、無理やり書いていますが、Rustからwasmのコンパイルからwebpackでのjsのコンパイルまで全て一気にやるようにしました。

(今考えると、ローカルではwebpack-dev-serverを使っているので、"build:debug": "cargo +beta build --target wasm32-unknown-unknown && wasm-bindgen target/wasm32-unknown-unknown/debug/domris.wasm --out-dir ./wasm"で十分だったかもしれません。)


途中でrm -r docsとしていることに関して

まず、docsという名前は GitHub Pagesが要請してくるのでしょうがなくですが、実際にデプロイされるコンパイル済みファイルが入ると思ってください。

で、それをなぜ削除しているかというと、webpackするごとに、docs/{毎回異なるハッシュ値}.wasm という新しいファイルが生成され、.wasm ファイルがひたすらに溜まっていくからです。最新のものは必要なので、gitignoreするわけにもいかず。

あまり良い方法ではない気がするのですが。


ちなみにwebpack.config.jsは、 wasm-bindgenを気にする必要はなく、(wasm-bindgenの時点で必要なjsファイルがwasm/domris.jsとして吐かれている)普通に書けます。


webpack.config.js

const path = require('path');

const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
mode: process.env.NODE_ENV || 'development',
entry: './index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'docs'),
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
plugins: ['@babel/plugin-syntax-dynamic-import']
}
}
}
]
},
plugins: [
new HtmlWebpackPlugin({
template: 'index.html'
})
]
};



rand

Rustにはrandクレートがありますが、wasm-bindgenと一緒に使うためには、Cargo.tomlfeaturesを指定する必要があります。あと、versionは0.6以上である必要があります(当該issue)。

[dependencies]

rand = { version = "0.6.1", features = ["wasm-bindgen"] }

確かにREADMEにかかれているが、Featuresを単なる機能という意味と解釈し、デフォルトであるはずと思い込み、大分悩んだ。


Crate Features

Rand is built with only the std feature enabled by default. The following optional features are available:

(中略)

wasm-bindgen enables support for OsRng on wasm32-unknown-unknown via wasm-bindgen



printfデバッグ

このマクロを定義しておくと、Rustからconsole.log が呼べて大変便利。(この方法でconsole.logに限らず、色々JavaScriptの関数を呼べます。)

#[wasm_bindgen]

extern "C" {
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
}

macro_rules! console_log {
($($t:tt)*) => (log(&format_args!($($t)*).to_string()))
}

参考:https://rustwasm.github.io/wasm-bindgen/examples/console-log.html


参考リンク


wasm-bindgenについて


特にcanvasについて