9_5:p5.play 「Jumper」プラットフォームゲーム

本稿では、「Game Programming」サイトの「Game Examples」ページで公開されている「Jumper」ゲームサンプルを見ていきます。

「Jumper」はプラットフォームゲームと呼ばれるジャンルに属するゲームで、前の「Angry Bernie」に出てきたバーニーが足場から足場へジャンプして飛び移ります。

次のリンクをクリックすると、実際の動作が確認できます。ただし音楽が鳴るので注意してください。「Jumper完成版

左から右に移動する足場(プラットフォーム)

ゲーム作りで難しいのは、ゲームのクリアをそこそこ難しくしかしそこそこ簡単にする難易度の設定です。最初から難しければユーザーはすぐに放棄するでしょうし、簡単すぎてもすぐに飽きるでしょう。

この「Jumper」では、最初の足場を横に長く設定してユーザーにジャンプする時間的猶予を与え、2つめの足場を最初の足場のすぐ隣に作成して、誰でも容易に2つめの足場に飛び移れるようにしています。もちろんこれはプログラミングによる結果なので、足場のスピードがいくつでプレイヤースプライトのジャンプ力はいくつと、具体的な数値が指定されているわけですが、数値の決定に至るまでには、おそらくこのゲームの作成者は何回もテストしたはずです。

ゲームの難易度の問題は、プログラミングのテクニックで解決できるものではなく、ある意味作り手のセンスが問われる類の問題なので、これが正解というものはありません。プレイヤーにそこそこの達成感とそこそこの挑戦感を持たせる適切な難易度は、ゲーム作成者が自分で設定するしかありません。そのためには月並みではありますが、著名なゲームを自分でプレイしてそのゲームで行われている難易度設定の工夫を研究してみると、参考になるでしょう。

最初の足場を長く、以降の足場を次々に左から右に移動させるコードは次のようになります。

// 足場のグループ
let platformsGroup;

// 足場を生成する時間間隔 => ゲーム難度に影響する
let platformSpawnInterval = 1400;
// タイマー
let lastPlatformSpawned = 0;

// 足場が動くスピードはだんだん速くなる
// 足場が動く最小スピード
const MIN_WORLD_SPEED = -7.0;
// 足場が動く最高スピード
const MAX_WORLD_SPEED = -25.0;
// 初めは最小スピードで動く
let worldSpeed = MIN_WORLD_SPEED;
// 足場の高さ
const PLATFORM_HEIGHT = 40;

function setup() {
    createCanvas(windowWidth, windowHeight);

    // 足場のグループ
    platformsGroup = new Group();
    // 1つめの足場を生成
    spawnFirstPlatform();
}

function draw() {
    // ゲームはプレイ中
    background(80, 200, 250);
    update();
    drawSprites();
}

function update() {
    // 足場を作成
    spawnPlatform();
}

// 最初の足場を生成する
function spawnFirstPlatform() {
    // キャンバス幅一杯に長くして、ユーザーがジャンプできる時間的余裕をこしらえる
    const firstPlatformSp = createSprite(width / 2, height * 0.8, width, PLATFORM_HEIGHT);
    // 足場はworldSpeedで左へ移動
    firstPlatformSp.velocity.x = worldSpeed;
    // コライダーを設定
    firstPlatformSp.setCollider("rectangle", 0, 0, width, PLATFORM_HEIGHT);
    firstPlatformSp.shapeColor = color(random(100, 190));
    // 足場のグループに追加
    platformsGroup.add(firstPlatformSp);
    // ユーザーが容易に飛び移れるように、2つめの足場が最初の足場の直後にに生成されるよう変数値を調整
    lastPlatformSpawned = -10000;
}

// 足場を生成する
function spawnPlatform() {
    // 直近の計測時からplatformSpawnIntervalだけ経過したら
    if (millis() > lastPlatformSpawned + platformSpawnInterval) {
        // 足場の属性を設定
        const platformWidth = width / 4;
        const platformY = random(height * .5, height * .8);
        // 足場のスプライトを作成
        const platformSp = createSprite(width + platformWidth / 2, platformY, platformWidth, PLATFORM_HEIGHT);
        // 足場のスプライトはworldSpeedで左へ移動
        platformSp.velocity.x = worldSpeed;
        platformSp.setCollider("rectangle", 0, 0, platformWidth, PLATFORM_HEIGHT);
        platformSp.shapeColor = color(random(100, 190));
        platformsGroup.add(platformSp);
        // 時間を計測
        lastPlatformSpawned = millis();
    }
}

上記コードをブラウザで開くと、左から右に、細長い足場が移動し、つづいて次の短い足場が移動し、その後も短い足場次々と移動します。

プレイヤースプライトは初め、長い足場の上にいます。プレイヤーはマウスクリックするかXキーを押すかしてスプライトをジャンプさせ、次の足場に飛び移らないと落ちて死んでしまうのですが、ゲームのスタート時にはその知識も準備もないので、最初の足場が短いと、何をやっていいかも分からないまま、スプライトは落下してしまいます。これではプレイヤーの意欲を削ぎかねないので、足場を長くすることで、プレイヤーに時間の余裕を与えているわけです。

そして次の足場は、初めの足場のすぐ後に移動してきます。2つの足場は少しの間しか空いていないので、飛び移るのは比較的容易です。「Jumper」ではこのようにして、プレイヤーがゲームに入りやすくする工夫がされています。

足場は最初、MIN_WORLD_SPEED(-7)のスピードで移動し、platformSpawnInterval(1400)の時間間隔で生成されます。そしてlastPlatformSpawnedという変数が、最初の足場を生成するspawnFirstPlatform()関数で-10000に設定されています。このlastPlatformSpawnedは、毎フレーム呼び出されるspawnPlatform()で、次のように使用されます。

// 最初の足場を生成する
function spawnFirstPlatform() {
  ...
  lastPlatformSpawned = -10000;
}

// 足場を生成する
function spawnPlatform() {
    // 直近の計測時からplatformSpawnIntervalだけ経過したら
    if (millis() > lastPlatformSpawned + platformSpawnInterval) {
        ...
        // 時間を計測
        lastPlatformSpawned = millis();
    }
}

短い足場を生成するspawnPlatform()でlastPlatformSpawnedはmillis()(プログラム開始時からの経過時間)に設定されるので、短い足場はplatformSpawnIntervalおきに生成されます。では、spawnFirstPlatform()関数でのlastPlatformSpawnedへの-10000の代入は、何を意味しているのでしょう?

-10000はまさにマジックナンバーで、作成者でないと分かりませんが、推測することはできます。-10000はプログラムの時間を戻す効果があります。つまり、setup()でspawnPlatform()が呼び出され、lastPlatformSpawnedは-10000になり、その後draw()->update()経由でspawnPlatform()が呼び出され、最初のifステートメントでmillis() > lastPlatformSpawned + platformSpawnIntervalが評価されるので、ここでJavaScriptは「lastPlatformSpawnedがすごく小さく、すぐに短い足場を生成しなくちゃ」と判断するわけです。これは言わば、「だましのテクニック」です。これにより、長い足場のすぐ後に次の短い足場が生成され、プレイヤーはその足場に飛び移りやすくなり、しかも以降の短い足場は通常のplatformSpawnIntervalおきに生成されます。

ジャンプして飛び移る

ジャンプして飛び移るアクションは、プラットフォームゲームをプレイするプレイヤーの醍醐味であると同時に、初めてゲームを作成しようとする初心者プログラマーの醍醐味でもあります。プレイヤースプライトの足場からの飛び移りの成功と失敗は次のようなコードで実現できます。

// プレイヤースプライト
let jumperSp;
// 今、足場の上に乗っているかどうか
let canJump = false;

let platformsGroup;
let platformSpawnInterval = 1400;
let lastPlatformSpawned = 0;

// 左の壁
let leftWallSp;
// 地面の壁
let bottomWallSp;

// 自由に調整できるグローバルパラメータ
const GRAVITY = 1;
const JUMP = 25;

const MIN_WORLD_SPEED = -7.0;
const MAX_WORLD_SPEED = -25.0;
let worldSpeed = MIN_WORLD_SPEED;
const PLATFORM_HEIGHT = 40;

// アニメーション2つ
let jumperRunAnim;
let jumperJumpAnim;

function preload() {
    jumperRunAnim = loadAnimation("assets/bernie-walk-0.png", "assets/bernie-walk-3.png");
    jumperJumpAnim = loadAnimation("assets/bernie-jump.png");
}

function setup() {
    createCanvas(windowWidth, windowHeight);
    // プレイヤースプライトの作成
    jumperSp = createSprite();
    jumperSp.addAnimation('running', jumperRunAnim);
    jumperSp.addAnimation('jumping', jumperJumpAnim);
    // コライダーを少し小さくする
    jumperSp.setCollider("rectangle", -4, 0, 150, 410);
    jumperSp.scale = 0.3;
    // アニメーションのフレームを間引く
    jumperSp.animation.frameDelay = 6;
    jumperSp.position.x = width / 4;
    jumperSp.position.y = height / 2;
    jumperSp.debug = true;

    platformsGroup = new Group();
    // 1つめの足場を生成
    spawnFirstPlatform();

    // 左と下の壁スプライトを作成
    leftWallSp = createSprite(-1500, height / 2, 10, height);
    bottomWallSp = createSprite(width / 2, height + 20, width, 10);
}

function draw() {
    // ゲームはプレイ中
    background(80, 200, 250);
    update();
    drawSprites();
}

function update() {
    // 足場を作成
    spawnPlatform();
    // プレイヤースプライトは落下する。
    jumperSp.velocity.y += GRAVITY;
    // 足場にうまく飛び移れたら
    if (jumperSp.collide(platformsGroup)) {
        // 足場の上に乗っているので、canJumpをtrueに設定
        canJump = true;
        // runningアニメーションに変更
        jumperSp.changeAnimation("running");
        // うまく飛び移れたので、重力は作用しない
        jumperSp.velocity.y = 0;
    }

    // 下の壁まで落下したか、画面左端を超えてしまったら
    if (jumperSp.collide(bottomWallSp) || jumperSp.position.x < 0) {
        // ゲームオーバー
        print('GAME OVER');
        // とりあえず世界を停止させる
        noLoop(); // (仮)
    }

    // Xキーかマウスボタンが押し下げられたら
    if (keyWentDown("x") || mouseWentDown()) {
        // かつ足場の上に乗っているなら
        if (canJump) {
            // jumpingアニメーションに変更
            jumperSp.changeAnimation("jumping");
            // 上昇する
            jumperSp.velocity.y = -JUMP;
            // もう足場の上に乗っていないので、canJumpをfalseに設定
            canJump = false;
        }
    }
    // 画面外になった足場を削除
    leftWallSp.collide(platformsGroup, deleteBuilding);
}

// 左の壁と衝突した足場を削除
function deleteBuilding(currentSp, overlappedSp) {
    overlappedSp.remove();
}

function spawnFirstPlatform() {
    const firstPlatformSp = createSprite(width / 2, height * 0.8, width, PLATFORM_HEIGHT);
    firstPlatformSp.velocity.x = worldSpeed;
    firstPlatformSp.setCollider("rectangle", 0, 0, width, PLATFORM_HEIGHT);
    firstPlatformSp.shapeColor = color(random(100, 190));
    platformsGroup.add(firstPlatformSp);
    lastPlatformSpawned = -10000;
}

function spawnPlatform() {
    if (millis() > lastPlatformSpawned + platformSpawnInterval) {
        const platformWidth = width / 4;
        const platformY = random(height * .5, height * .8);
        const platformSp = createSprite(width + platformWidth / 2, platformY, platformWidth, PLATFORM_HEIGHT);
        platformSp.velocity.x = worldSpeed;
        platformSp.setCollider("rectangle", 0, 0, platformWidth, PLATFORM_HEIGHT);
        platformSp.shapeColor = color(random(100, 190));
        platformsGroup.add(platformSp);
        lastPlatformSpawned = millis();

        // ゲーム難度のアップ
        // 足場のスピードを制限付きで上げる
        worldSpeed = constrain(worldSpeed - 0.2, MAX_WORLD_SPEED, MIN_WORLD_SPEED);
        // 足場の生成頻度も上げる
        platformSpawnInterval = map(worldSpeed, MIN_WORLD_SPEED, MAX_WORLD_SPEED, 1400, 1400 / 3.6);
    }
}

次のリンクをクリックすると、実際の動作が確認できます。「Jumper ジャンプして飛び移る

プレイヤースプライトのjumperSpの主な動作はupdate()関数で指定しています。

jumperSpはまず、いついかなるときも落下します。

jumperSp.velocity.y += GRAVITY;

落下しないのは、jumperSpが足場グループに含まれるいずれかの足場スプライトと重なっている間です。この間jumperSpのvelocity.yを0に設定しつづけると、jumperSpは落下しません。重なっていない間は落下します。

// 足場にうまく飛び移れたら
if (jumperSp.collide(platformsGroup)) {
    // 足場の上に乗っているので、canJumpをtrueに設定
    canJump = true;
    // runningアニメーションに変更
    jumperSp.changeAnimation("running");
    // うまく飛び移れたので、重力は作用しない
    jumperSp.velocity.y = 0;
}

画面下端を超えて落下するか、足場に画面左まで押しやられたときには、ゲームオーバーです。ここではとりあえず、draw()関数の呼び出しを停止し、GAME OVERの文字をコンソールに出力しています。

// 下の壁まで落下したか、画面左端を超えてしまったら
if (jumperSp.collide(bottomWallSp) || jumperSp.position.x < 0) {
    // ゲームオーバー
    print('GAME OVER');
    // とりあえず世界を停止させる
    noLoop(); // (仮)
}

マウスとキーによるジャンプは次のコードで行います。

// Xキーかマウスボタンが押し下げられたら
if (keyWentDown("x") || mouseWentDown()) {
    // かつ足場の上に乗っているなら
    if (canJump) {
        // jumpingアニメーションに変更
        jumperSp.changeAnimation("jumping");
        // 上昇する
        jumperSp.velocity.y = -JUMP;
        // もう足場の上に乗っていないので、canJumpをfalseに設定
        canJump = false;
    }
}

全体としては前の「Angry Bernie」に似ていますが、ここでは非常に面白いcanJump変数があります。これは「今ジャンプできるかどうか」を表すブーリアン型の変数です。「今ジャンプできるかどうか」を深刻に考えるとプログラミングが難しそうに思えますが、これも「Angry Bernie」のときと同様、現実世界に即して考えることができます。

それは、「今ジャンプできるかどうか」「今足場の上にいるかどうか」と見なす考え方です。プログラミングではこういった発想の転換が必要になる場合があり、できるだけシンプルに考えた方が結果も良くなります。

  • ブラウザが開くときにはまだ何もないので、canJump変数はfalse(初期化時)
  • プレイヤースプライトが足場グループと重なっているときには、jumperSpの下に足場がありジャンプできるので、canJump変数はtrue(最初の長い足場も足場グループに含まれている)
  • マウスかキー操作があったら、jumperSpはジャンプするので、その間はjumperSpの下には足場がなくcanJump変数はfalse。ただしジャンプはjumperSpの下に足場があるときに限る(canJumpがtrueのとき)

ゲーム難度のアップ

ゲームプレイヤーの習熟度は向上するので、多くのゲームでは、ゲームのクリアを難しくします。たとえばレベル1では足場を移動スピードを10にし、それをクリアした次のレベルではスピードを20にする、といった具合です。「Jumper」には、レベルのクリアはありませんが、時間経過とともにゲーム難度を上げる方法が取られています。

それはspawnPlatform()関数の終わりにあります。

function spawnPlatform() {
    if (millis() > lastPlatformSpawned + platformSpawnInterval) {
        ...

        // ゲーム難度のアップ
        // 足場のスピードを制限付きで上げる
        worldSpeed = constrain(worldSpeed - 0.2, MAX_WORLD_SPEED, MIN_WORLD_SPEED);
        // 足場の生成頻度も上げる
        platformSpawnInterval = map(worldSpeed, MIN_WORLD_SPEED, MAX_WORLD_SPEED, 1400, 1400 / 3.6);
    }
}

worldSpeed変数は足場スプライトのvelocity.xプロパティ値なので、新しい足場が生成されるたびに、MIN_WORLD_SPEEDの制限付きで、足場スプライトが右から左に移動するスピードは0.2ずつアップします。

また足場を生成する間隔も短くなります。これにはp5.jsのmap()関数が使用できます。map()関数については「4_7:マッピング(対応付け) p5.js JavaScript」で述べています。具体的には、worldSpeedの値が、MIN_WORLD_SPEEDからMAX_WORLD_SPEEDを範囲とする値から、1400と1400 / 3.6を範囲とする値に変換され、それがplatformSpawnIntervalに代入されます。

サウンドとフォント、ゲームオーバー画面の追加

映画の映画音楽における働きを見るまでもなく、サウンドはゲームの臨場感の向上に大いに貢献します。ただし使い過ぎはうるさくなるだけなので禁物です。

let jumperSp;
let canJump = false;
let platformsGroup;
let platformSpawnInterval = 1400;
let lastPlatformSpawned = 0;

let leftWallSp;
let bottomWallSp;

const GRAVITY = 1;
const JUMP = 25;
const MIN_WORLD_SPEED = -7.0;
const MAX_WORLD_SPEED = -25.0;
let worldSpeed = MIN_WORLD_SPEED;
const PLATFORM_HEIGHT = 40;

let jumperRunAnim;
let jumperJumpAnim;

// スコア、ゲームオーバー、フォントなど
let score = 0;
let gameStartedTime;
let gameOver = false;
let gameFont;

// サウンド
let sound_jump;
let sound_lose;
let sound_music;

function preload() {
    jumperRunAnim = loadAnimation("assets/bernie-walk-0.png", "assets/bernie-walk-3.png");
    jumperJumpAnim = loadAnimation("assets/bernie-jump.png");

    // サウンドを読み込む
    sound_jump = loadSound("assets/jump.wav");
    sound_lose = loadSound("assets/lose.wav");
    sound_music = loadSound("assets/music.wav");
    // フォントを読み込む
    gameFont = loadFont("assets/TinyPixy.ttf");
}

function setup() {
    createCanvas(windowWidth, windowHeight);

    // 表示する文字のサイズと使用するフォントの指定
    textSize(50);
    textFont(gameFont);

    jumperSp = createSprite();
    jumperSp.addAnimation('running', jumperRunAnim);
    jumperSp.addAnimation('jumping', jumperJumpAnim);
    jumperSp.setCollider("rectangle", -4, 0, 150, 410);
    jumperSp.scale = 0.3;
    jumperSp.animation.frameDelay = 6;
    jumperSp.position.x = width / 4;
    jumperSp.position.y = height / 2;
    jumperSp.debug = true;

    platformsGroup = new Group();

    spawnFirstPlatform();

    leftWallSp = createSprite(-1500, height / 2, 10, height);
    bottomWallSp = createSprite(width / 2, height + 20, width, 10);

    // 開始時間をメモ
    gameStartedTime = millis();

    // sound_musicをループ再生(ただし再生開始のきっかけはマウスクリックかキーダウン)
    sound_music.loop();
}

function draw() {
    // ゲームオーバーなら
    if (gameOver) {
        // 背景色を暗い灰色に
        background(50);

        // ゲームオーバー時の文字の描画
        textAlign(CENTER, CENTER);

        // ここでもドロップシャドウのテキスト。\nは改行
        fill(0);
        text("YOU ARE DEAD BUT AT LEAST YOU \n SCORED " + score + " POINTS", width / 2 + 3, height / 2 + 3);
        fill(255);
        text("YOU ARE DEAD BUT AT LEAST YOU \n SCORED " + score + " POINTS", width / 2, height / 2);

        // ゲームが進行中なら
    }
    else {
        background(80, 200, 250);

        // 毎フレーム、この時点までの経過時間をスコアとして左上に表示
        score = int((millis() - gameStartedTime) / 10);

        textAlign(LEFT, CENTER);
        // ドロップシャドウのテキストを作成
        // シャドウの部分を右下に少しずらして描画
        fill(0);
        text("SCORE: " + score, 16 + 3, 20 + 3);
        // 白い文字をその左上に重ねて描画
        fill(255);
        text("SCORE: " + score, 16, 20);

        update();
        drawSprites();
    }
}

function update() {
    jumperSp.velocity.y += GRAVITY;

    spawnPlatform();

    if (jumperSp.collide(platformsGroup)) {
        canJump = true;
        jumperSp.changeAnimation("running");
        jumperSp.velocity.y = 0;
    }

    // ゲームオーバー
    if (jumperSp.collide(bottomWallSp) || jumperSp.position.x < 0) {
        // gameOver変数をtrueにする
        gameOver = true;
        // sound_loseを再生
        sound_lose.play();
    }

    if (keyWentDown("x") || mouseWentDown()) {
        if (canJump) {
            jumperSp.changeAnimation("jumping");
            jumperSp.velocity.y = -JUMP;
            canJump = false;
            // sound_jumpを再生
            sound_jump.play();
        }
    }
    leftWallSp.collide(platformsGroup, deleteBuilding);
}

function spawnFirstPlatform() {
    const firstPlatformSp = createSprite(width / 2, height * 0.8, width, PLATFORM_HEIGHT);
    firstPlatformSp.velocity.x = worldSpeed;
    firstPlatformSp.setCollider("rectangle", 0, 0, width, PLATFORM_HEIGHT);
    firstPlatformSp.shapeColor = color(random(100, 190));
    platformsGroup.add(firstPlatformSp);
    lastPlatformSpawned = -10000;
}

function spawnPlatform() {
    if (millis() > lastPlatformSpawned + platformSpawnInterval) {
        const platformWidth = width / 4;
        const platformY = random(height * .5, height * .8);

        const platformSp = createSprite(width + platformWidth / 2, platformY, platformWidth, PLATFORM_HEIGHT);
        platformSp.velocity.x = worldSpeed;
        platformSp.setCollider("rectangle", 0, 0, platformWidth, PLATFORM_HEIGHT);
        platformSp.shapeColor = color(random(100, 190));
        platformsGroup.add(platformSp);
        lastPlatformSpawned = millis();

        worldSpeed = constrain(worldSpeed - 0.2, MAX_WORLD_SPEED, MIN_WORLD_SPEED);
        platformSpawnInterval = map(worldSpeed, MIN_WORLD_SPEED, MAX_WORLD_SPEED, 1400, 1400 / 3.6);
    }
}

function deleteBuilding(currentSp, overlappedSp) {
    overlappedSp.remove();
}

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

CAPTCHA