テトリスをC#とWPFで作ってみた

物凄い今更ですが、ニコニコ動画のプログラミング界隈で伝説的な動画「【プログラミング】テトリスを1時間強で作ってみた【実況解説】」を見て、c# + WPF(Canvas) でテトリスが作れないか試してみました。

参考にした動画は↓です。
https://www.nicovideo.jp/watch/sm8517855www.nicovideo.jp

動画は1時間強でサクッと制作が完了していましたが、c言語 → c# と WPF でポーティングしながら、処理を追加・変更していたため、プログラミングだけで4時間ぐらいかかってしまいました。(超汗)

成果物

作ったゲームの動作中の画面はこんな感じです。
f:id:Takachan:20150325221911p:plain:h300

ゲームオーバーすると赤くなります。
f:id:Takachan:20150329004554p:plain:h300

成果物は GitHub に上げてみました。マイクロソフト製IDE VisualStudio2013 で作成しているので 2008 以前の VisualStudio だと開けない可能性があります。バイナリを含めていないので各自コンパイルが必要です。また動作には windows vista 以降の OS と .net 4.5.1 が必要です。
github.com

このテトリスの仕様

画面デザイン

画面は、非常にシンプルで一番外枠が(当たり前なのですが) Window で、その中に Border で囲った Canvas を配置しています。

f:id:Takachan:20150325222948p:plain:h300

処理仕様

一応動画を見ている前提で説明をしようかと思います。また、テトリス自体のゲームの処理仕様は書き始めると長くなってしまうので省きたいとおもいます。

ゲームで一般的かどうか不明なのですが、このテトリスの座標系は、左上が原点で右へ行くとXが増加し、下へ行くとYが増加します。冒頭の動画とは座標系の上下が反転しているのでご注意ください。
f:id:Takachan:20150325223617p:plain:h280

ブロックの盤面は動画と同じく縦 25 x 横 12です。周囲を下の画像の様に「番兵」が囲んでいます。上部3列は画面には表示されません。これは、新しいブロックが出現した際に徐々に画面へ落ちてくる演出のためです。
f:id:Takachan:20150325225348p:plain:h380

キーボードイベントが等間隔に発生しない問題

動画ではキーボードの矢印キーでボタンを押した場合、ブロックを動かすためのキーボード入力をて1秒間に30回キャプチャキーしています。そうするとブロックがスムーズに動くのですが、WPFで同じ事を仕様として、KeyDownイベントでキーの入力を見様としたのですが、イベントの発生間隔がどうも違うようでした。

最初のKeyDownはボタンを押したときに即座に発生しますが、キーを押しっぱなしにしたときに次にKeyDownイベントが発生するのは数瞬間が空いてその後連続で発生します。

ボタン押下 -> 初回KeyDownイベント -> [0.3秒くらい?] -> KeyDownイベント -> KeyDownイベント...

となるようです。キーが連続で入力されるのを防ぐための様ですがゲームだとこの仕様が非常に邪魔です。2回目以降のKeyDownが始まる前にゲームオーバーしてしまう可能性があります。
(これは、ウインドウプロシージャでVK_DOWN(0x100)を監視した場合も同じです。

なので、KeyDown -> KeyUpまでに33ミリ秒に1回イベントを発行する PeriodicSignals クラスを以下のように実装して問題を解決しています。

以下のクラス、KeyDownイベントで Start(キー種別), KeyUpイベント で Stop() することで、Tick に登録したメソッドをコンストラクタで指定した間隔で呼び出しています。

実際のゲームのほうでは"ブロックの回転"と"ブロック移動"で感度調整をしているので(回転を移動と同じ感度でキーボードイベントを拾ってしまうと超高速回転してしまうので)上キー(= ブロックの回転)は、ブロックの移動より5倍遅い間隔でイベントを発行しています。

/// <summary>
/// (キーボードイベントの発生が不連続のため)
/// キーが押されっぱなしの状態を通知するためのクラス
/// </summary>
public class PeriodicSignals
{
    /// <summary>キーが押され続けたときに発生する押しっぱなしイベントの発生周期</summary>
    DispatcherTimer timer;

    /// <summary>現在押下中のキー</summary>
    private Key key;

    /// <summary>
    /// 定期的に発生するキーイベントを設定または取得します
    /// </summary>
    public event Action<Key> Ticks;

    /// <summary>
    /// イベント発生間隔を指定してオブジェクトを初期化します。
    /// </summary>
    public PeriodicSignals(TimeSpan s)
    {
        this.timer = new DispatcherTimer()
        {
            Interval = s
        };
        this.timer.Tick += Tick;
    }

    /// <summary>
    /// 押されっぱなしの状態を開始します。
    /// </summary>
    public void Start(Key key)
    {
        if (this.key == key)
        {
            return; // 既にキーシグナル発行中
        }
        this.key = key;
        timer.Start();
        this.Ticks(key); // 最初の1回は即時呼び出し
    }

    /// <summary>
    /// キーが離された事をこのオブジェクトをへ通知します。
    /// </summary>
    public void Stop()
    {
        timer.Stop();
        this.key = Key.None;
    }

    /// <summary>
    /// Ticks イベントを発行します。
    /// </summary>
    public void Tick(object sender, EventArgs e)
    {
        if (this.Ticks == null)
        {
            return;
        }
        this.Ticks(this.key);
    }
}
画像の取り扱いが全然違う問題

当然ですが、c# なので動画とは画像の取り扱い方がかなり違います。Canvas 上にオブジェクトとして画像を表示するため UIElement クラス系列の Image クラスを使用する必要がありますが、内部で持っている型は BitmapImage や CrippedImage なので、少々迂遠ですが以下のような関係を持たせています。

f:id:Takachan:20150325232803p:plain:w550

画面に表示するときに全ての画像をいちいちロードするのは処理が非常に重くなるため、各ブロックを CrippedImage までははロードしておいて、実際に画面にブロックを描画するときに Image クラスを1ブロックごとに作成し、ImageSource に作成済みの CrippedImage を指定することで画像読み込みのオーバーヘッドを抑えています。(デザインパターンで言うところの Flyweight パターンですね)

まとめ

キーボードイベントが想定通り発生しないことへの対応、画面に描画するための処理を全て自前で実装するのは少し大変でしたが興味深い経験でした。

あと、使う画像のリソースで同じゲームでも天と地ほど印象が変わるのは興味深いですね。

今はゲームの基本要素があるだけですがこの上に、更に他のゲームシステム(スコアやだんだん難しくなる仕様)を実装するど、まだまだ発展の余地があると思います。更新したらその時は、別途報告しようかと思います。