3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

テトリス開発はバカにできない

Last updated at Posted at 2024-12-06

SLP-KBIT AdventCalendar2024
本記事は7日目になります.

まえがき

私がサークルに所属して3年半,一貫してゲーム開発に取り組んでまいりました.
個人開発,チーム開発を通して数々のゲームを作成し,大変多くの技術的経験値を手に入れることができたと思います.

ふと,最初に作成したゲームはなんだろうと考えたところ,B1後期に作成したテトリス(コンソール版)を思い出しました.

当時学んだばかりのC言語を駆使して作成しましたが,今思えばとても綺麗とは言い難い,お粗末なソースコードだったのではないかと考えます(もちろんソースコードは残ってません.コード管理なんて概念は微塵も頭にない時代).

ということで今回は「自身の成長を実感してみよう」と,3年半で得た知識・経験を活用してテトリスを新規作成しました.

その新たに作成したテトリスの解説を行います.

開発環境

  • 使用言語$\cdots$C++
  • 統合開発環境$\cdots$Visual Studio 2022
  • エディタ$\cdots$Visual Studio Code
  • 使用ライブラリ$\cdots$DXライブラリ

コンソール版からGUI版に進化しました.
また使用言語もC言語からC++へとオブジェクト指向を意識しており,ソースコードの内容も一段と進化しています.

更に,前回作成したテトリスは(おそらく記憶の中では)ガチガチのハードコーディングであったのに対し,今回は数学的要素を取り入れてハードコーディングを少なくする工夫を施しました.

ソースコード

GitHubで公開中です.
exeファイルも用意しているので,遊ぶこともできます.

解説

  • コメントアウトのみでは説明できなかった箇所
  • 自身が気に入っている箇所

これらを中心に解説します.
必要に応じてソースコードをご参照ください.

WinMain()関数

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
    Tetris* tetris = new Tetris;
    delete tetris;
    return 0;
}

解説

本プログラムのメイン関数です.
Tetrisクラスのコンストラクタを実行した後,デストラクタを実行するといったシンプルな構造になっています.

いやぁ$\cdots$なんと美しい.

これぞ至高のオブジェクト指向(激寒).

KeyPressed()関数

キー押下の処理をどうするかという悩みはゲームプログラマ共通かと思われます.その最適解とは言えないかもしれませんが,自分なりに工夫して関数化したので紹介します.

bool KeyPressed(int keyIndex)
{
    if (intKey[keyIndex] && !pressFlag[keyIndex])
    {
        pressFlag[keyIndex] = true;
        return true;
    }

    if (!intKey[keyIndex])
    {
        pressFlag[keyIndex] = false;
    }

    return false;
}

解説

「ある1フレームの瞬間のみ押した判定にしたい」という願望をかなえてくれる関数です.その実装には,キーが押されているか押されていないかの情報を格納する変数に加え,押されたときのフラグ変数も用意することが重要です.

コマ送り(フレーム単位)で順を追って解説します.

t=0F

まだキーが押されていないとき,その状態は以下の通りです.

  • intKey[keyIndex] = 0
  • pressFlag[keyIndex] = false
  • return false

t=1F

この瞬間,keyIndex番号のキーが押されたとします.

  • intKey[keyIndex] = 1
  • pressFlag = false

よって以下のif文が実行されます.

if (intKey[keyIndex] && !pressFlag[keyIndex])
{
    pressFlag[keyIndex] = true;
    return true;
}
  • intKey[Index] = 1
  • pressFlag = true
  • return true

t=2F

この状態でもまだキーが押され続けているとします.
どのif文の条件も満たさないので,変数の値の変更はありません.

  • return false

3tick目以降もキーを押し続ける限り,この状態が続きます.

t=nF

ここでようやくキーを離します.

  • intKey[keyIndex] = 0

キーを離すとkeyIndex番号の変数の値が1から0へ変更となり,以下のif文が実行されます.

if (!intKey[keyIndex])
{
    pressFlag[keyIndex] = false;
}
  • intKey[keyIndex] = 0
  • pressFlag[keyIndex] = false
  • return false

まとめ

以上の過程より,t=1Fのとき,つまりキーを押した瞬間のフレームのみtrueを返却していることが分かります.

この関数を活用することにより,例えばキャラクターの操作処理をスムーズに実装することが可能です.

Mino::rotMino()メソッド

パクり お勉強の一環として,以下の動画を参考にしました.

テトロミノの回転の表現方法は2通りあります.

  • 全パターンの描画状態を先に記述する方法
  • 状態に応じて回転計算を行う方法

今回は後者を選択します.

動画でも説明がありますが,ゲーム開発において重要なのはバグ発生時における再現性です.今回で例えると,テトロミノの回転状態を一つひとつ記述するより,最低限の回転状態の情報を保持し描画の都度計算する方がはるかに再現性が高いのです(最悪総当たりで全状態を再現可能).

その計算には,行列計算を用います.

プログラムの世界においてx軸が右方向,y軸が下方向に伸びていることを考慮すると,座標$(x,y)$を角度$\theta$だけ回転した座標$(X,Y)$は以下のように表されます.

\begin{bmatrix} X \\ Y \end{bmatrix} =
\begin{bmatrix} \cos\theta & -\sin\theta \\ \sin\theta & \cos\theta \end{bmatrix}
\begin{bmatrix} x \\ y \end{bmatrix}

つまり

\begin{bmatrix} X \\ Y \end{bmatrix} =
\begin{bmatrix} x\cos\theta-y\sin\theta \\ x\sin\theta+y\cos\theta \end{bmatrix}

$\theta=90^\circ$(y軸下方向なので下向き時計回り)のときは

\begin{cases}
X=-y\\Y=x
\end{cases}

のように表すことができます.

ここで,時計回りに$90^\circ$回転することを「1時計回り」とします.

反時計回りに対しては,1反時計回り=3時計回りであるので,法を4とした合同式を活用します.回転数を負の方向にも展開すると,それぞれ同じ回転状態である3時計回り,7時計回り,1反時計回り(-1時計回り)は

\begin{align}
3\equiv3\mod4\\7\equiv3\mod4\\-1\equiv3\mod4
\end{align}

となり,どれも同じ回転状態であることを計算することができます.

しかし,多くのプログラム言語では負の値に対する合同式の結果は負の値を返すので,このままでは実装できません($-1\equiv-1\mod4$).

その解決策として,あらかじめ4の倍数である大きな数(今回は400とした)を用意し,そこに回転数を加えて計算を行います.上記の式を適応すると,下記のような式になります.

\begin{align}
403\equiv3\mod4\\407\equiv3\mod4\\399\equiv3\mod4
\end{align}

Mino::draw()メソッド

動画では説明がありませんが,先述した行列計算を愚直にプログラムへ埋め込むとテトロミノはうまく回転してくれません.

例えば,座標$(4,15)$にあるテトロミノを$90^\circ$回転した後の座標は$(-15,4)$ですが,そもそもテトリスのフィールドが座標$(0,0)$から座標$(12,20)$の範囲なので画面に描写されません.

従って,テトロミノは原点のみで回転を行い,その後本来の位置への座標修正を行う必要があります.

void Mino::draw()
{
    this->rotMino(this->block);
    for (int i = 0; i < NUM; i++)
    {
        DrawBlock(this->block[i]->x+this->x, this->block[i]->y+this->y);
    }
}

DrawBlock()関数の引数には,テトロミノを構成する各ブロックの座標(原点を中心とした位置)とテトロミノの座標を加えています.

おわりに

本記事のタイトルに全く触れていなかったので,ここで語ります.

つまり,「技術レベルに関わらず(またテトリス開発に関わらず)リメイク版を作ることで得られるものは,多少なりある」ということです.

今回のテトリス開発で,私は数学的なアプローチによって解決できるノウハウを知ることができました.リメイク版を作成したはずなのに,新たな知見を手に入れることができたのです.

全ての開発において,リメイク版を作ることは当時の知識をブラッシュアップ&新しいアプローチの発見に繋がるのではないかと考えています.

もし昔作ったプロジェクトの中で,放置であったり頓挫しているものがあるならば,「作り直す」という選択肢も加味していただけますと,私としては喜ばしい限りです.

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?