How to Make an Action Puzzle Game – Inspired by Lode Runner (Part 3)

Game

This guide is for those who want to create a game using only JavaScript, without using game engines or libraries.

This is for people who:

Want to make a game but don’t know where to start.
Want to create a simple game without the hassle of using Unity or Unreal Engine.

This guide is aimed at those who already have some programming knowledge.

What You’ll Make in This Article

We’re going to create an automatic repairing block gimmick from the demo in the previous article.

You can destroy the blocks with a laser. After a while, the blocks will automatically repair themselves.
x … Dig left、c … Dig right
j … Up、m … Down、j … Left、l … Right

Articles to Read Before This One

Source Code

how-to-make-lode-runnder-like-game/02_auto_recover_block 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.

Here’s how the demo’s source code is structured:

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

Since the last time, we’ve added Block.js. World.js has also been significantly changed.

const BLOCK_RECOVER_SEC = 7;

const State = {
    NORMAL: 'NORMAL',
    BREAKING: 'BREAKING',
    RECOVER: 'RECOVER',
    BROKEN: 'BROKEN',
}

const anime_table = {
    NORMAL: { frames: [16], frame_interval: 1},
    BREAKING: { frames: [17,18,19,20], frame_interval: 20 },
    RECOVER: { frames: [19,18,17], frame_interval: 60 },
    BROKEN: { frames: [20], frame_interval: 60*BLOCK_RECOVER_SEC },
};

const next_state_table = new Map([
    [State.NORMAL, State.NORMAL],
    [State.BREAKING, State.BROKEN],
    [State.BROKEN, State.RECOVER],
    [State.RECOVER, State.NORMAL],
]);

export class EBlock {
    constructor() {
        this.state = State.NORMAL;
        this.anime_index = 0;
        this.frame_count = 0;
        this.sprite = 16;
    }

    can_go_through() { return this.state != State.NORMAL; }

    can_up() { return false; }

    can_stand_on() { return this.state == State.NORMAL; }

    can_hang() { return false; }

    sprite_no() { return this.sprite; }

    dig() {
        if (this.state != State.NORMAL) 
            return;
        this.state = State.BREAKING;
    }

    update () {
        if (!this.anime_update()) {
            this.state = next_state_table.get(this.state);
            this.anime_index = 0;
            this.frame_count = 0;
        }
    }

    anime_update() {
        let frames = anime_table[this.state].frames;

        if (this.anime_index >= frames.length) {
            this.anime_index = 0;
            return false;
        }

        if (this.frame_count >= anime_table[this.state].frame_interval) {
            this.anime_index++;
            this.frame_count = 0;
        }
        this.frame_count++;

        if (this.anime_index < frames.length) {
            this.sprite = frames[this.anime_index];
        }

        return true;
    }
}
import {drawSprite} from "./index.js"
import { EBlock } from "./Block.js"

const MAP_ELEM_SIZE = 8;

const Elem = {
    NONE: 0,
    BLOCK: 1,
    LADDER: 2,
    BAR: 3,
    ROCK: 4,
}

class ENone {
    can_go_through() { return true; }
    can_up() { return false; }
    can_stand_on() { return false; }
    can_hang() { return false; }
    sprite_no() { return 0; }
    dig() {}
    update() {}
}

class ELadder {
    can_go_through() { return true; }
    can_up() { return true; }
    can_stand_on() { return true; }
    can_hang() { return true; }
    sprite_no() { return 1; }
    dig() {}
    update() {}
}

class EBar {
    can_go_through() { return true; }
    can_up() { return false; }
    can_stand_on() { return false; }
    can_hang() { return true; }
    sprite_no() { return 3; }
    dig() {}
    update() {}
}

class ERock {
    can_go_through() { return false; }
    can_up() { return false; }
    can_stand_on() { return true; }
    can_hang() { return false; }
    sprite_no() { return 22; }
    dig() {}
    update() {}
}


function createElem(id) {
    switch(id) {
    case Elem.NONE: return new ENone();
    case Elem.BLOCK: return new EBlock();
    case Elem.LADDER: return new ELadder();
    case Elem.BAR: return new EBar();
    case Elem.ROCK: return new ERock();
    }
}

function createMap(m) {
    let dat = [];
    for (let i = 0; i < m.length; i++) {
        dat[i] = createElem(m[i]);
    }
    return dat;
}

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

    update() {
        for (let o of this.data) {
            o.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];
    }

    canGoThrough(x,y) {
        return this.get_obj(x,y).can_go_through();
    }

    canUp(x,y) {
        return this.get_obj(x,y).can_up();
    }

    canStandOn(x,y) {
        return this.get_obj(x,y).can_stand_on();
    }

    canHang(x,y) {
        return this.get_obj(x,y).can_hang();
    }

    isOnLadder(x,y) {
        return this.get_obj(x,y) instanceof ELadder;
    }

    isOnBar(x,y) {
        return this.get_obj(x,y) instanceof EBar;
    }

    dig(x, y) {
        this.get_obj(x,y).dig();
    }

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

Rebuilding the Map Data Management

In “How to Make Lode Runner Part 2,” the map data was defined as a one-dimensional array of numbers. We’re still using that approach, but after receiving that data in the World class, we’re converting it to an array of objects for internal management.

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

The map data map1 remains a number array. We’ll manage the level data in this format, passing it to the constructor of the World object in index.js.

let map = new World(10, 10, map1);

Inside the World class, the map data received from outside is converted to an array of objects internally.

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

The internal object array is generated using a separate createMap() function.

function createMap(m) {
    let dat = [];
    for (let i = 0; i < m.length; i++) {
        dat[i] = createElem(m[i]);
    }
    return dat;
}

Individual objects are created with the createElem() function, which returns an object corresponding to each number.

function createElem(id) {
    switch(id) {
    case Elem.NONE: return new ENone();
    case Elem.BLOCK: return new EBlock();
    case Elem.LADDER: return new ELadder();
    case Elem.BAR: return new EBar();
    case Elem.ROCK: return new ERock();
    }
}

Design Changes for Map Management and Collision Detection

In the implementation of the previous demo, the relationship between the Chara object and the World object was as shown in the diagram below.

By having World hide the map data, we ensured that Chara didn’t need to change its implementation even if the data structure changed.

This time, the relationship between the Chara and World objects is as shown below.

Unlike before, the map data in World is not an array of numbers but an array of objects corresponding to those numbers. Also, instead of providing methods for collision detection by object type, World offers methods like canGoThrough() that use a different approach.

When Chara moves, it needs to check the elements on the screen to decide if it can go further. In the previous implementation, it was like this:

    check_move_right() {
        if (this.map.isHitWall(this.x+this.w+1, this.y))
            return;
    ・・・
    }

World had a method called isHitWall() which was used. Chara decided its actions with “walls” in mind.

This time, we changed the implementation of check_move_right() as shown below:

    check_move_right() {
        if (!this.map.canGoThrough(this.x+this.w+1, this.y))
            return;
    ・・・
    }

isHitWall() has been replaced with canGoThrough(). As the name suggests, canGoThrough returns the result of whether you can pass through or not, not what type of object it is.

With this approach, even if new types of elements are added to the screen, there’s no need to change the implementation on the Chara side.

However, the original design also has its merits. If Chara gains some ability to “pass through walls,” it’s more straightforward to look at the object type on the Chara side.

The design of the simple object relationships changes depending on the specifications of the game you want to create. Figuring out the simplest way to build something is the most enjoyable part of programming.

Implementing Auto-Recovering Blocks

We changed the map data managed within the World class from a simple number array to an array of objects to implement auto-recovering blocks.

Auto-recovering blocks have the following four states:

Within the Block class, we define the states and the corresponding animation table like this:

const State = {
    NORMAL: 'NORMAL',
    BREAKING: 'BREAKING',
    RECOVER: 'RECOVER',
    BROKEN: 'BROKEN',
}

const anime_table = {
    NORMAL: { frames: [16], frame_interval: 1},
    BREAKING: { frames: [17,18,19,20], frame_interval: 20 },
    RECOVER: { frames: [19,18,17], frame_interval: 60 },
    BROKEN: { frames: [20], frame_interval: 60*BLOCK_RECOVER_SEC },
};

The state transitions are as follows:

The concept is as shown above, but in the program, there’s no special judgment for “completely broken” or “recovery complete.” Only the passage of time is the condition. It proceeds to the next state after the time specified in anime_table for each frame_interval.

If there’s no user action, the block remains in the NORMAL state. When the dig() method is called, it transitions to BREAKING.

Except for NORMAL to BREAKING, the next state is fixed, so we defined it in a table like this:

const next_state_table = new Map([
    [State.NORMAL, State.NORMAL],
    [State.BREAKING, State.BROKEN],
    [State.BROKEN, State.RECOVER],
    [State.RECOVER, State.NORMAL],
]);

Block is updated at regular intervals, and within update(), it looks at this table to decide the next state.

If there’s no user action, the NORMAL state stays as NORMAL. By defining the next transition state of NORMAL as NORMAL in the table, we avoid the need to write special code to handle NORMAL.

Collision Detection with Blocks in Different States

Blocks can’t be passed through in their normal state but can be passed through when they are broken.

In a straightforward implementation, Chara.js directly checks the block state to decide whether it can pass through.

Instead, if the block itself decides whether it can be passed through, Chara doesn’t need to know the block’s state definition, as shown in the diagram below.

Let’s look at the implementation of Block’s can_go_through().

    can_go_through() { return this.state != State.NORMAL; }

It’s simply set to not allow passage if it’s not in the NORMAL state.

The advantage of this software structure is that even if the types of objects or block states increase, it doesn’t affect the implementation on the Chara side.

For example, if you add a new object like a door (locked/unlocked), Chara doesn’t even need to know that the object at the specified coordinates is a door. It just needs the result from canGoThrough(), so no change to the implementation is needed.

Next Time

We’ve implemented the “auto-recovering block,” the main gimmick of Lode Runner.

Next time, we’ll work on implementing “enemy characters,” which are an important element of the game.


コメント

Copied title and URL