アクションパズルゲームの作り方 – ロードランナーにインスパイヤされて(その2)

ゲーム

ゲームエンジンやライブラリを使わずに、HTMLとJavaScriptだけでゲームを作ります。

以下のような人のためにあります。

ゲームを作ってみたいけど、何をどうしていいか分からない。
単純なゲームを作りたいだけなのに、UnityとかUnreal Engineは面倒くさい。

ある程度プログラミングの知識がある人を対象にしています。

この記事では

デモの内容は前回と同じものですが、記事ではマップ上の構成物の作り方を説明します。

レンガ、ハシゴ、バーなどの画面の構成物とプレイヤーの移動部分を実装します。
下記キーを押してみてください。
i … 上
m … 下
j … 左
l … 右

先に読んでおく記事

キャラクターの状態と、その管理・切り替え方法については、下記記事に書いていますので、先に読んでおくことをお勧めします。

ソースコード

how-to-make-lode-runnder-like-game/01_chara_and_world_base at main ?? yenshan/how-to-make-lode-runnder-like-game
Contribute to yenshan/how-to-make-lode-runnder-like-game development by creating an account on GitHub.

このデモのソースコードの構成は下記のとおりです。

.
├── Chara.js
├── World.js
├── index.html
├── index.js
└── spritesheet.png

ここでは index.js、World.js について説明します。ソースは下記のとおりです。

import { Chara } from "./Chara.js"
import { World } from "./World.js"

// background canvas
const canvas_bg = document.getElementById('canvasBg');
const context_bg = canvas_bg.getContext('2d');

// display canvas
const canvas = document.getElementById('gameCanvas');
const context = canvas.getContext('2d');
context.imageSmoothingEnabled = false;

const SPRITE_SHEET_WIDTH = 16;

const map1 = [
    0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,
    1,0,0,0,0,0,0,0,0,1,
    1,2,1,1,1,1,0,0,0,1,
    1,2,3,3,3,3,0,0,0,1,
    1,2,0,0,0,0,0,0,0,1,
    1,2,0,0,0,1,1,2,1,1,
    1,2,0,0,0,0,0,2,0,1,
    1,2,0,0,0,0,0,2,0,1,
    1,1,1,1,1,1,1,1,1,1,
]


// 入力ハンドラー
document.addEventListener('keydown', keyDownHandler, false);

function keyDownHandler(event) {
    if (event.key === 'j') {
        player.move_left();
    } 
    if (event.key === 'l') {
        player.move_right();
    }
    if (event.key === 'i') {
        player.move_up();
    }
    if (event.key === 'm') {
        player.move_down();
    }
}


export function drawSprite(sprite_no, x, y, flip=false) {
    let sx = (sprite_no % SPRITE_SHEET_WIDTH) *8;
    let sy = Math.floor(sprite_no / SPRITE_SHEET_WIDTH)*8;
    if (flip) {
        context_bg.save();
        context_bg.scale(-1,1);
        context_bg.drawImage(spriteSheet, sx, sy, 8, 8, -x-8, y, 8, 8);
        context_bg.restore();
    } else {
        context_bg.drawImage(spriteSheet, sx, sy, 8, 8, x, y, 8, 8);
    }
}


function update() {
    // オリジナルサイズをバックグランドバッファに描画
    context_bg.clearRect(0, 0, canvas_bg.width, canvas_bg.height);

    for (let o of obj_list) {
        o.update();
        o.draw();
    }

    // 表示用に拡大する
    context.clearRect(0, 0, canvas.width, canvas.height);
    context.drawImage(canvas_bg, 0, 0, canvas_bg.width, canvas_bg.height, 0, 0, canvas.width, canvas.height);
}

// スプライトシートのロード
const spriteSheet = new Image();
spriteSheet.src = "./spritesheet.png";

let map = new World(10, 10, map1);
let player = new Chara(8, 0, map);

let obj_list = [];
obj_list.push(map);
obj_list.push(player);


setInterval(update, 10);
import {drawSprite} from "./index.js"

const MAP_ELEM_SIZE = 8;

const Elem = {
    NONE: 0,
    WALL: 1,
    LADDER: 2,
    BAR: 3,
}

const spriteMap = new Map([
    [Elem.WALL, 16],
    [Elem.LADDER, 1],
    [Elem.BAR, 3],
]);

export class World {
    constructor(w,h, data) {
        this.w = w;
        this.h = h;
        this.data = data;
    }

    update() {
    }

    get_obj(sx,sy) {
        let x = Math.floor(sx/MAP_ELEM_SIZE);
        let y = Math.floor(sy/MAP_ELEM_SIZE);
        return this.data[x + y*this.w];
    }

    isHitWall(sx, sy) {
        return this.get_obj(sx,sy) == Elem.WALL;
    }

    isOnLadder(sx, sy) {
        return this.get_obj(sx,sy) == Elem.LADDER;
    }

    isOnBar(sx, sy) {
        return this.get_obj(sx,sy) == Elem.BAR;
    }

    sprite_no(id) {
        return spriteMap.get(id) || 0;
    }

    draw() {
        for (let y = 0; y < this.h; y++) {
            for (let x = 0; x < this.w; x++) {
                let id = this.data[x+y*this.w];
                let sp_no = this.sprite_no(id);
                drawSprite(sp_no, x*MAP_ELEM_SIZE, y*MAP_ELEM_SIZE);
            }
        }
    }
}

マップデータの管理と描画

マップデータはindex.jsで定義していますが、ゲーム中のデータの管理と描画は class Worldで行っています。理由は後で述べます。

このデモのマップデータの実装はindex.jsにある下記のコードです。10 x 10のマスで構成しています。

const map1 = [
    0,0,0,0,0,0,0,0,0,0,
    0,0,0,0,0,0,0,0,0,0,
    1,0,0,0,0,0,0,0,0,1,
    1,2,1,1,1,1,0,0,0,1,
    1,2,3,3,3,3,0,0,0,1,
    1,2,0,0,0,0,0,0,0,1,
    1,2,0,0,0,1,1,2,1,1,
    1,2,0,0,0,0,0,2,0,1,
    1,2,0,0,0,0,0,2,0,1,
    1,1,1,1,1,1,1,1,1,1,
]

例によって一次元配列の方が扱いやすいので、そうしています。0とか1の意味は、World.js の中で定義しています。

const Elem = {
    NONE: 0,
    WALL: 1,
    LADDER: 2,
    BAR: 3,
}

マップを描画する際に、この構成物の番号とドット絵(スプライト)の番号を紐づけてやる必要があります。それを行なっているのがspriteMapオブジェクトです。

const spriteMap = new Map([
    [Elem.WALL, 16],
    [Elem.LADDER, 1],
    [Elem.BAR, 3],
]);

new Map()で生成しているMapオブジェクトはJavaScript標準で用意されているものです。上記の定義だと、spriteMap.get(Elem.WALL) のように使用して値を得ることができます。

各構成物Elemに対応するスプライト番号 は、筆者が用意したスプライトシートの中の順番がたまたまこうだっただけです。

画面への描画は以下の関数でやっています。

draw() {
    for (let y = 0; y < this.h; y++) {
        for (let x = 0; x < this.w; x++) {
            let id = this.data[x+y*this.w];
            let sp_no = this.sprite_no(id);
            drawSprite(sp_no, x*MAP_ELEM_SIZE, y*MAP_ELEM_SIZE);
        }
    }
}

MAP_ELEM_SIZEはマップのひとマスの大きさで8×8ドットです。キャラクターのサイズと同じです。

マップ上の配置物との当たり判定

キャラクターの位置がマップ上のどこにあるかを見て、何に当たっているかを判断するには、その座標にある構成物を取得します。下記のコードがそれにあたります。

    get_obj(sx,sy) {
        let x = Math.floor(sx/MAP_ELEM_SIZE);
        let y = Math.floor(sy/MAP_ELEM_SIZE);
        return this.data[x + y*this.w];
    }

与えられるsx, sy はキャラクターの座標で80×80の解像度です。一方で、マップデータの方は、8×8ドットの大きさのマスで構成される10×10の世界です。その間の座標変換しています。

上記関数を使って、例えば壁(ブロック)に当たっているかどうかは、下記のような関数を定義しています。

    isHitWall(sx, sy) {
        return this.get_obj(sx,sy) == Elem.WALL;
    }

当たり判定のときに指定する座標は、キャラクターの判断したい状態を考慮して行います。

  • 足元が空間なのか?(落ちるべきなのか?)
  • ハシゴと重なっているのか?(上下移動できるか?)
  • 行き先に壁があるかどうか?(左右いどうできるか?)

例えば、左右の移動のときは、下記のような感じです。座標は移動先を指定するところがポイントです。

    check_move_right() { 
        // 右に動く時は、キャラクター領域の右1ドット先を見て、壁なら移動できない
        if (this.map.isHitWall(this.x+this.w+1, this.y))
            return;
    ・・・
    }
    check_move_left() {
        // 左に動く時は、キャラクター領域の左1ドット先を見て、壁なら移動できない
        if (this.map.isHitWall(this.x-1, this.y))
            return;
   ・・・
  }

よく使う当たり判定は、わかりやすい関数名でまとめると、コードの見通しがしやすくなります。

    can_fall() {
        return ! (this.is_on_wall() || this.is_on_ladder() || this.is_over_ladder() || this.is_over_bar());
    }

    is_on_wall() {
        return this.map.isHitWall(this.x, this.y+this.h+1);
    }

    is_over_ladder() {
        return this.map.isOnLadder(this.x+this.w/2, this.y+this.h/2);
    }

    is_on_ladder() {
        return this.map.isOnLadder(this.x, this.y+this.h+1);
    }

    is_over_bar() {
        return this.map.isOnBar(this.x+this.w/2, this.y+this.h/2);
    }

クラス設計とオブジェクトの関係

マップデータを管理するのに、わざわざclass Worldを設けました。理由は、マップデータに対してCharaから直接参照させないようにするためです。

Charaは自分の位置とマップ上の配置物と当たり判定をする必要がありますが、すべてWorldのメソッド(関数)を通して行います。図にすると下記のとおりです。

この構造にすることで、将来的にマップデータの構造が変わっても、class Worldの実装だけ変更すればよく、class Charaの実装は変えなくても済みます。

次回は

マップデータの持ち方と、当たり判定について見てきました。前回も含めて作成したデモは、プレイヤーの操作キャラクターが画面上を縦横無尽に動き回れます。

次はいよいよ、ロードランナーのゲームのギミックである、自動的に回復するブロックの部分を実装します。


コメント

タイトルとURLをコピーしました