TypeScriptでゲームプログラム1「基本の構造」

Webゲームのプログラム構造

TypeScript(というかJavaScript)でWebゲームを作る場合の基本的なプログラムの構造は以下のようになります。

  1. CANVAS要素を用意する。
  2. ゲームの状況に応じてCANVASを描画する。
  3. ゲームの状況は、時間経過やプレイヤーの操作によって変化する。

CANVAS要素を用意する

とりあえず下のようなHTMLを用意します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8" />
    <title>Game</title>
    <script src="game.js"></script>
</head>
<body>
    <canvas id="game" width="320" height="320"></canvas>
</body>
</html>

ゲームのプログラムはgame.jsにあります。その中でプレイヤー操作を検知し、状況を更新し、<canvas id='game'>に描画を行います。

CANVASを描画する

WebゲームやCANVASを用いたアニメーションなどを行いたい場合、Windos.requestAnimationFrame()を使って以下のようなコードにすることが推奨されています。

function GameLoop(timestamp: number) {
    // CANVASに描画する。
    DrawGameCanvas();

    // ゲームの状況更新、再描画のために、requestAnimationFrame()に再度実行する。
    window.requestAnimationFrame(GameLoop);
}

window.requestAnimationFrame(GameLoop);

// DrawGameCanvas()の詳細は省略する。

Windos.requestAnimationFrame()の詳細な説明は、https://developer.mozilla.org/ja/docs/Web/API/Window/requestAnimationFrameを参照してください。
簡単に説明すると、上のようなコードにすることで約1/60秒毎に、DrawGameCanvas()がコールされ、CANVASが再描画されるようになります。

単にCANVASを用いたアニメーションを作りたいだけなら、残りの作業はDrawGameCanvas()を完成するだけになります。

時間経過によるゲーム状況の変化とCANVASの描画

シューティングゲームでは、プレイヤーが何の操作もしていなくても、敵キャラや撃ちだされた弾が移動します。それらがスムーズに移動するように見せるには、敵キャラや弾を1/8秒毎とか1/30秒毎など一定の短い時間毎に移動させ、再描画する必要があります。

またキャラクターが移動するだけでなく、ちょっとしたアニメーションを行うためにも短時間での再描画は必要です。例えばRPGでは、2種類のグラフィック(右足を前にしているもの/左足を前にしているもの)を用意し、1/2秒ぐらいの短時間で切り替えることで、ゲーム画面の中で歩いているように見せることができます。

結局のところ多くのゲームでは、一定時間ごとにゲームの状況が更新され、それをCANVASに再描画することを繰り返しています。

例として、1/8秒毎にゲームの状況変化とCANVAS描画を行うようなコードの例を以下に示します。

/**
 * FPS(Frame per second)
 */
const FPS = 8;

/**
 * フレームカウンタ(ゲーム内の時刻カウンタ)
 * GameLoop()が最初にコールされたときを0とし、
 * 1/FPS秒を1フレームとしてカウントする。
 */
let FrameCounter: number | undefined;

/**
* GameLoop()が最初にコールされたときのtimestampを保持する。
*/
let GameLoopStartTime = 0;

/**
 * ゲームのループ処理
 * window.requestAnimationFrame()のコールバック
 * @param timestamp window.requestAnimationFrame()から渡されるタイムスタンプ
 */
function GameLoop(timestamp: number) {
    // 初めてコールされたときに、GameLoopStartTimeとFrameCounterを初期化
    if (FrameCounter === undefined) {
        FrameCounter = 0;
        GameLoopStartTime = timestamp;
    }

    // ゲームループ開始時と現在のtimestampの差から、フレーム数を計算
    let frame = Math.floor((timestamp - GameLoopStartTime) * FPS / 1000);
    if (frame > FrameCounter) {
        // フレームカウンタを更新
        FrameCounter = frame;

        // ゲームの状況を更新する。
        UpdateGameSituation();

        // フレーム数が更新されたので、描画を行う。
        DrawGameCanvas();
    }

    // ゲームの状況更新、再描画のために、requestAnimationFrame()に再度実行する。
    window.requestAnimationFrame(GameLoop);
}

window.requestAnimationFrame(GameLoop);

まず、ゲームの実行時間を1/FPS秒毎に分割した値を1フレームと呼ぶことにします。そしてゲーム開始からのフレーム数をFrameCounterでカウントします。

Window.requestAnimationFrame()のコールバック関数であるGameLoop()には、タイムスタンプ値が引数として渡されます。このタイムスタンプ値は1ミリ秒単位の整数値です。

そこでGameLoop()が初めてコールされた時のタイムスタンプ値をGameLoopStartTimeにセットしておき、以後はそれとの差を取ることで、ゲームの実行開始から何ミリ秒経過しているのか知ることができます。そして、経過時間(ミリ秒)に FPS/1000を掛けることで、フレーム数を計算できます。

フレームカウンタが更新されるたびに、UpdateGameSituation()をコールしてゲームの状況を更新し、DrawGameCanvas()で再描画を行います。

前述したRPGのように1/2秒ごとに描画を切り替えたい場合は、FPS=2にしてFrameCounterが奇数か偶数かで描画を切り替えます。シューティングゲームやアクションゲームならキャラクターがスムーズに移動するようにしたほうが良いので、FPS=15やFPS=30などにすればよいです。

実際の例

UpdateGameSituation()とDrawGameCanvas()の簡単な例を下に示します。
この例は、黄色い球がCANVAS内で弾んでいる姿が描画されるだけです。

この例のUpdateGameSituation()とDrawGameCanvas()のコードは下の通りです。

/**
 * 画面に表示する球
 */
let Ball = {
    X: 10,    // X座標
    Y: 10,    // Y座標
    VX: 5,   // X座標の速度
    VY: 0,    // Y座標の速度
    SIZE: 10, // 球のサイズ
};

/**
 * 球が移動できる範囲
 */
let Area = {
    X: 10,
    Y: 10,
    Width: 300,
    Height: 300,
}


/**
 * ゲームの状況を更新する
 */
function UpdateGameSituation() {
    let left = Area.X + Ball.SIZE;
    let right = Area.X + Area.Width - Ball.SIZE;
    let top = Area.Y + Ball.SIZE
    let bottom = Area.Y + Area.Height - Ball.SIZE;

    // x座標の移動
    let x = Ball.X + Ball.VX;
    if (x < left) {
        x = left + left - x;
        Ball.VX = -Ball.VX;
    }
    else if (x > right) {
        x = right + right - x;
        Ball.VX = - Ball.VX;
    }
    Ball.X = x;

    // y座標の移動
    let y = Ball.Y + Ball.VY;
    if (y < top) {
        y = top + top - y;
        Ball.VY = -Ball.VY * 0.5;
    }
    else if (y > bottom) {
        y = bottom + bottom - y;
        Ball.VY = -Ball.VY;
    }
    else {
        Ball.VY = Ball.VY + 0.25;
    }
    Ball.Y = y;
}

/**
 * ゲームCANVASを描画する。
 */
function DrawGameCanvas() {
    let cvsGame = document.getElementById("game") as HTMLCanvasElement;
    if (cvsGame) {
        let width = cvsGame.width;
        let height = cvsGame.height;
        let context = cvsGame.getContext("2d");
        if (context && width > 0 && height > 0) {
            // 実際の描画処理
            // 背景を黒に
            context.fillStyle = "black";
            context.fillRect(0, 0, width, height);

            // Areaの範囲を白で描画
            context.strokeStyle = "white";
            context.strokeRect(Area.X, Area.Y, Area.Width, Area.Height);

            // 球を黄色で描画
            context.fillStyle = "yellow";
            context.beginPath();
            context.arc(Ball.X, Ball.Y, Ball.SIZE, 0, 2 * Math.PI);
            context.fill();
        }
    }
}

UpdateGameSituation()がコールされると、Ballの座標(XとY)と速度(VX,とVY)が更新されます。
下方向に加速する処理や左右の壁や床に当たったときの跳ね返り処理も行っています。

DrawGameCanvas()では、背景を黒にし、(10,10,300,300)の四角とBallを描画しています。

コメント