Programming/JavaScript

[EloquentJS] Ch16. Project: A Platform Game

dododoo 2020. 5. 21. 14:56

Code

HTML

<!doctype html>
<html>
    <head>
        <meta charset="utf-8">
        <title>Platform game</title>
        <script src="ch16_prj3.js"></script>
        <script src="ch16_levels.js"></script>
        <link rel="stylesheet" href="ch16_prj3.css">
    </head>
    <body>
        <script>
            runGame(GAME_LEVELS, DOMDisplay);
        </script>
    </body>
</html>

CSS

.background    { background: rgb(52, 166, 251);
                 table-layout: fixed;
                 border-spacing: 0;                }
.background td { padding: 0;                       }
.lava          { background: rgb(255, 100, 100); }
.wall          { background: white;              } 

.actor         { position: absolute;               }
.coin          { background: rgb(241, 229, 89);  }
.player        { background: rgb(64, 64, 64);    }

.lost .player  {
    background: rgb(160, 64, 64);
}
.won .player   {
    box-shadow: -4px -7px 8px white, 4px -7px 8px white;
}

.game {
    overflow: hidden;
    max-width: 600px;
    max-height: 450px;
    position: relative;
}

JS

let simpleLevelPlan = `
......................
..#................#..
..#..............=.#..
..#.........o.o....#..
..#.@......#####...#..
..#####............#..
......#++++++++++++#..
......##############..
......................`;

class Level {
    constructor(plan) {
        let rows = plan.trim().split("\n").map(l => [...l]);
        this.height = rows.length;
        this.width = rows[0].length;
        this.startActors = [];

        this.rows = rows.map((row, y) => {
            return row.map((ch, x) => {
                let type = levelChar[ch];
                if (typeof type == "string") return type;
                this.startActors.push(
                    type.create(new Vec(x, y), ch));
                return "empty";
            });
        });
    }
}

class State {
    constructor(level, actors, status) {
        this.level = level;
        this.actors = actors;
        this.status = status;
    }

    static start(level) {
        return new State(level, level.startActors, "playing");
    } 

    get player() {
        return this.actors.find(a => a.type == "player");
    }
}

class Vec {
    constructor(x, y) {
        this.x = x; this.y = y;
    }
    plus(other) {
        return new Vec(this.x + other.x, this.y + other.y);
    }
    times(factor) {
        return new Vec(this.x * factor, this.y * factor);
    }
    /*
    minus(other) {
        return new Vec(this.x - other.x, this.y - other.y)
    }
    get length() {
        return Math.sqrt(this.x * this.x + this.y * this.y);
    }
    */
}

class Player {
    constructor(pos, speed) {
        this.pos = pos;
        this.speed = speed;
    } 

    get type() { return "player"; }

    static create(pos) {
        // Because a player is one-and-a-half squares high.
        return new Player(pos.plus(new Vec(0, -0.5)), 
                                   new Vec(0, 0));
    }
}

Player.prototype.size = new Vec(0.8, 1.5);

class Lava {
    constructor(pos, speed, reset) {
        this.pos = pos;
        this.speed = speed;
        this.reset = reset;
    }

    get type() { return "lava"; }

    static create(pos, ch) {
        if (ch == "=") {
            return new Lava(pos, new Vec(2, 0));
        } else if (ch == "|") {
            return new Lava(pos, new Vec(0, 2));
        } else if (ch == "v") {
            return new Lava(pos, new Vec(0, 3), pos);
        }
    }
}

Lava.prototype.size = new Vec(1, 1);

class Coin {
    constructor(pos, basePos, wobble) {
        this.pos = pos;
        this.basePos = basePos;
        this.wobble = wobble;
    }

    get type() { return "coin"; } 

    static create(pos) {
        let basePos = pos.plus(new Vec(0.2, 0.1)); // ?
        return new Coin(basePos, basePos, 
                        Math.random() * Math.PI * 2);
    }
}

Coin.prototype.size = new Vec(0.6, 0.6);

const levelChar = {
    ".": "empty", "#": "wall", "+": "lava",
    "@": Player, "o": Coin,
    "=": Lava, "|": Lava, "v": Lava
};

/*
let simpleLevel = new Level(simpleLevelPlan);
console.log(`${simpleLevel.width} by ${simpleLevel.height}`);
// 22 by 9
*/

// Helper function
function elt(name, attrs, ...children) {
    let dom = document.createElement(name);
    for (let attr of Object.keys(attrs)) {
        dom.setAttribute(attr, attrs[attr]);
    }
    for (let child of children) {
        dom.appendChild(child);
    }
    return dom;
}

class DOMDisplay {
    constructor(parent, level) {
        this.dom = elt("div", {class: "game"}, drawGrid(level));
        this.actorLayer = null;
        parent.appendChild(this.dom);
    }

    clear() { this.dom.remove(); }
}

const scale = 20;

function drawGrid(level) {
    return elt("table", {
        class: "background", 
        style: `width: ${level.width * scale}px`
    }, ...level.rows.map(row => 
        elt("tr", {style: `height: ${scale}px`}, 
            ...row.map(type => elt("td", {class: type})))
        // rest parameter에 아무것도 안들어가면 파라미터는 빈 배열이 됨
    ));
}

function drawActors(actors) {
    return elt("div", {}, ...actors.map(actor => {
        let rect = elt("div", {class: `actor ${actor.type}`});
        rect.style.width = `${actor.size.x * scale}px`;
        rect.style.height = `${actor.size.y * scale}px`;
        rect.style.left = `${actor.pos.x * scale}px`;
        rect.style.top = `${actor.pos.y * scale}px`;
        return rect;
    }));
}

DOMDisplay.prototype.syncState = function(state) {
    if (this.actorLayer) this.actorLayer.remove();
    this.actorLayer = drawActors(state.actors);
    this.dom.appendChild(this.actorLayer);
    this.dom.className = `game ${state.status}`;
    this.scrollPlayerIntoView(state);
};

DOMDisplay.prototype.scrollPlayerIntoView = function(state) {
    let width = this.dom.clientWidth;
    let height = this.dom.clientHeight;
    let margin = width / 3;

    // Viewport
    let left = this.dom.scrollLeft, right = left + width;
    let top = this.dom.scrollTop, bottom = top + height;

    let player = state.player;
    let center = player.pos.plus(player.size.times(0.5))
                           .times(scale);

    if (center.x < left + margin) {
        this.dom.scrollLeft = center.x - margin;
    } else if (center.x > right - margin) {
        this.dom.scrollLeft = center.x + margin - width;
    }
    if (center.y < top + margin) {
        this.dom.scrollTop = center.y - margin;
    } else if (center.y > bottom - margin) {
        this.dom.scrollTop = center.y + margin - height;
    }
}

Level.prototype.touches = function(pos, size, type) {
    let xStart = Math.floor(pos.x);
    let xEnd = Math.ceil(pos.x + size.x);
    let yStart = Math.floor(pos.y);
    let yEnd = Math.ceil(pos.y + size.y);

    for (let y = yStart; y < yEnd; y++) {
        for (let x = xStart; x < xEnd; x++) {
            let isOutside = x < 0 || x >= this.width ||
                            y < 0 || y >= this.height;
            let here = isOutside ? "wall" : this.rows[y][x];
            if (here == type) return true;
        }
    }
    return false;
};

State.prototype.update = function(time, keys) {
    let actors = this.actors
        .map(actor => actor.update(time, this, keys));
    let newState = new State(this.level, actors, this.status);
    // actor update하기 전에 테스트해도 될 거 같은데
    // 아 실패했어도 움직여야 되니까?
    if (newState.status != "playing") return newState;

    let player = newState.player;
    if (this.level.touches(player.pos, player.size, "lava")) {
        return new State(this.level, actors, "lost");
    }

    for (let actor of actors) {
        if (actor != player && overlap(actor, player)) {
            newState = actor.collide(newState);
        }
    }
    return newState;
};

function overlap(actor1, actor2) {
    return actor1.pos.x + actor1.size.x > actor2.pos.x &&
           actor1.pos.x < actor2.pos.x + actor2.size.x &&
           actor1.pos.y + actor1.size.y > actor2.pos.y &&
           actor1.pos.y < actor2.pos.y + actor2.size.y;
}

Lava.prototype.collide = function(state) {
    return new State(state.level, state.actors, "lost");
};

Coin.prototype.collide = function(state) {
    let filtered = state.actors.filter(a => a != this);
    let status = state.status;
    if (!filtered.some(a => a.type == "coin")) status = "won";
    return new State(state.level, filtered, status);
};

Lava.prototype.update = function(time, state) {
    let newPos = this.pos.plus(this.speed.times(time));
    if (!state.level.touches(newPos, this.size, "wall")) {
        return new Lava(newPos, this.speed, this.reset);
    } else if (this.reset) {
        return new Lava(this.reset, this.speed, this.reset);
    } else {
        return new Lava(this.pos, this.speed.times(-1));
    }
};

const wobbleSpeed = 9, wobbleDist = 0.07;

Coin.prototype.update = function(time) {
    let wobble = this.wobble + wobbleSpeed * time;
    let wobblePos = Math.sin(wobble) * wobbleDist;
    return new Coin(this.basePos.plus(new Vec(0, wobblePos)),
                    this.basePos, wobble);
};

const playerXSpeed = 7;
const gravity = 30;
const jumpSpeed = 17;

Player.prototype.update = function(time, state, keys) {
    let xSpeed = 0;
    if (keys.ArrowLeft) xSpeed -= playerXSpeed;
    if (keys.ArrowRight) xSpeed += playerXSpeed;
    let pos = this.pos;
    let movedX = pos.plus(new Vec(xSpeed * time, 0));
    if (!state.level.touches(movedX, this.size, "wall")) {
        pos = movedX;
    }

    let ySpeed = this.speed.y + gravity * time;
    let movedY = pos.plus(new Vec(0, ySpeed * time));
    if (!state.level.touches(movedY, this.size, "wall")) {
        pos = movedY;
    } else if (keys.ArrowUp && ySpeed > 0){
        // meaning the thing we hit is below us
        ySpeed = -jumpSpeed;
    } else {
        ySpeed = 0;
    }
    return new Player(pos, new Vec(xSpeed, ySpeed));
};

function trackKeys(keys) {
    let down = Object.create(null);
    function track(event) {
        if (keys.includes(event.key)) {
            down[event.key] = event.type == "keydown";
            event.preventDefault();
        }
    }
    window.addEventListener("keydown", track);
    window.addEventListener("keyup", track);
    return down;
}

const arrowKeys = 
    trackKeys(["ArrowLeft", "ArrowRight", "ArrowUp"]);

// Helper function
function runAnimation(frameFunc) {
    let lastTime = null;
    function frame(time) {
        if (lastTime != null) {
            let timeStep = Math.min(time - lastTime, 100) / 1000;
            if (frameFunc(timeStep) === false) return;
        }
        lastTime = time;
        requestAnimationFrame(frame);
    }
    requestAnimationFrame(frame);
}

function runLevel(level, Display) {
    let display = new Display(document.body, level);
    let state = State.start(level);
    let ending = 1;
    return new Promise(resolve => {
        runAnimation(time => {
            state = state.update(time, arrowKeys);
            display.syncState(state);
            if (state.status == "playing") {
                return true;
            } else if (ending > 0) {
                ending -= time;
                return true;
            } else {
                display.clear();
                resolve(state.status);
                return false;
            }
        });
    });
}

async function runGame(plans, Display) {
    for (let level = 0; level < plans.length;) {
        let status = await runLevel(new Level(plans[level]), 
                                    Display);
        if (status == "won") level++;
    }
    console.log("You've won!");
}

Project: A Platform Game

The game

  • GameFigure1

The technology

  • We will use the browser DOM to display the game, and we’ll read user input by handling key events.
  • we create DOM elements and use styling to give them a background color, size, and position.
  • We can represent the background as a table since it is an unchanging grid of squares. The free-moving elements can be overlaid using absolutely positioned elements.
  • Although the DOM was not originally designed for high-performance graphics, it is actually better at this than you would expect.

Levels

  • let simpleLevelPlan = `
    ......................
    ..#................#..
    ..#..............=.#..
    ..#.........o.o....#..
    ..#.@......#####...#..
    ..#####............#..
    ......#++++++++++++#..
    ......##############..
    ......................`;

Reading a level

  • class Level {
        constructor(plan) {
            let rows = plan.trim().split("\n").map(l => [...l]);
            this.height = rows.length;
            this.width = rows[0].length;
            this.startActors = [];
    
            this.rows = rows.map((row, y) => {
                return row.map((ch, x) => {
                    let type = levelChar[ch];
                    if (typeof type == "string") return type;
                    // Lava class handles several different 
                    // characters.
                    this.startActors.push(
                        type.create(new Vec(x, y), ch));
                    return "empty";
                });
            });
        }
    }
  • The trim method is used to remove whitespace at the start and end of the plan string. This allows our example plan to start with a newline so that all the lines are directly below each other.

  • But we must still separate the moving elements from the background grid. We’ll call moving elements actors. They’ll be stored in an array of objects. The background will be an array of arrays of strings, holding field types such as "empty", "wall", or "lava".

  • To interpret the characters in the plan, the Level constructor uses the levelChars object, which maps background elements to strings and actor characters to classes. When type is an actor class, its static create method is used to create an object, which is added to startActors, and the mapping function returns "empty" for this background square.

  • As the game runs, actors will end up in different places or even disappear entirely (as coins do when collected). We’ll use a State class to track the state of a running game.

  • class State {
        constructor(level, actors, status) {
            this.level = level;
            this.actors = actors;
            this.status = status;
        }
    
        static start(level) {
            return new State(level, level.startActors, "playing");
        } 
    
        get player() {
            return this.actors.find(a => a.type == "player");
        }
    }
  • The status property will switch to "lost" or "won" when the game has ended.

  • This is again a persistent data structure—updating the game state creates a new state and leaves the old one intact.

Actors

  • Actor objects represent the current position and state of a given moving element in our game. All actor objects conform to the same interface. Their pos property holds the coordinates of the element’s top-left corner, and their size property holds its size.

    일관된 인터페이스

  • Then they have an update method, which is used to compute their new state and position after a given time step.

  • A type property contains a string that identifies the type of the actor—"player", "coin", or "lava". This is useful when drawing the game.

  • class Vec {
        constructor(x, y) {
            this.x = x; this.y = y;
        }
        plus(other) {
            return new Vec(this.x + other.x, this.y + other.y);
        }
        times(factor) {
            return new Vec(this.x * factor, this.y * factor);
        }
    }
  • The times method scales a vector by a given number. It will be useful when we need to multiply a speed vector by a time interval to get the distance traveled during that time.

  • The different types of actors get their own classes since their behavior is very different. ... We’ll get to their update methods later.

  • The player class has a property speed that stores its current speed to simulate momentum and gravity.

  • class Player {
        constructor(pos, speed) {
            this.pos = pos;
            this.speed = speed;
        } 
    
        get type() { return "player"; }
    
        static create(pos) {
            // Because a player is one-and-a-half squares high.
            return new Player(pos.plus(new Vec(0, -0.5)), 
                                    new Vec(0, 0));
        }
    }
    
    Player.prototype.size = new Vec(0.8, 1.5);
  • The size property is the same for all instances of Player, so we store it on the prototype rather than on the instances themselves. We could have used a getter like type, but that would create and return a new Vec object every time the property is read, which would be wasteful. (Strings, being immutable, don’t have to be re-created every time they are evaluated.)

    sizetype처럼 getter로 정의할 수 있지만, 이러면 size가 읽힐 때마다 새로운 벡터가 생성되고 반환되므로 비효율적이다. 반면, 문자열은 immutable하기 때문에 매번 재생성될 필요가 없다.

  • When constructing a Lava actor, we need to initialize the object differently depending on the character it is based on. Dynamic lava moves along at its current speed until it hits an obstacle. At that point, if it has a reset property, it will jump back to its start position (dripping). If it does not, it will invert its speed and continue in the other direction (bouncing).

    정적 vs. 동적 // 장애물이 있는지 어떻게 알 수 있을까?

  • class Lava {
        constructor(pos, speed, reset) {
            this.pos = pos;
            this.speed = speed;
            this.reset = reset;
        }
    
        get type() { return "lava"; }
    
        static create(pos, ch) {
            if (ch == "=") {
                return new Lava(pos, new Vec(2, 0));
            } else if (ch == "|") {
                return new Lava(pos, new Vec(0, 2));
            } else if (ch == "v") {
                return new Lava(pos, new Vec(0, 3), pos);
            }
        }
    }
    
    Lava.prototype.size = new Vec(1, 1);
  • Coin actors are relatively simple. They mostly just sit in their place. But to liven up the game a little, they are given a “wobble”, a slight vertical back-and-forth motion. To track this, a coin object stores a base position as well as a wobble property that tracks the phase of the bouncing motion. Together, these determine the coin’s actual position (stored in the pos property).

  • class Coin {
        constructor(pos, basePos, wobble) {
            this.pos = pos;
            this.basePos = basePos;
            this.wobble = wobble;
        }
    
        get type() { return "coin"; } 
    
        static create(pos) {
            let basePos = pos.plus(new Vec(0.2, 0.1)); // ?
            return new Coin(basePos, basePos, 
                            Math.random() * Math.PI * 2);
        }
    }
    
    Coin.prototype.size = new Vec(0.6, 0.6);    
  • Math.sin gives us the y-coordinate of a point on a circle. That coordinate goes back and forth in a smooth waveform as we move along the circle, which makes the sine function useful for modeling a wavy motion.

  • To avoid a situation where all coins move up and down synchronously, the starting phase of each coin is randomized.

  • We can now define the levelChars object that maps plan characters to either background grid types or actor classes.

  • const levelChar = {
        ".": "empty", "#": "wall", "+": "lava",
        "@": Player, "o": Coin,
        "=": Lava, "|": Lava, "v": Lava
    };
  • That gives us all the parts needed to create a Level instance.

  • The task ahead is to display such levels on the screen and to model time and motion inside them.

Encapsulation as a burden

  • Most of the code in this chapter does not worry about encapsulation very much for two reasons. First, encapsulation takes extra effort. ... Second, the various elements in this game are so closely tied together that if the behavior of one of them changed, it is unlikely that any of the others would be able to stay the same.
  • Some cutting points in a system lend themselves well to separation through rigorous interfaces, but others don’t. Trying to encapsulate something that isn’t a suitable boundary is a sure way to waste a lot of energy. When you are making this mistake, you’ll usually notice that your interfaces are getting awkwardly large and detailed and that they need to be changed often, as the program evolves.

    한 시스템에서 인터페이스로 엄격하게 나누는 것이 적절한 지점들도 있지만, 그렇지 않은 지점들도 있다. 적절한 경계가 없는 것을 캡슐화하는 것은 많은 노력을 낭비하는 일이다. 이런 실수를 범할 때는 보통, 프로그램이 커지면서 인터페이스가 어색하게 커지고 상세해지며 자주 변경해야 할 필요가 생긴다.

  • There is one thing that we will encapsulate, and that is the drawing subsystem. The reason for this is that we’ll display the same game in a different way in the next chapter. By putting the drawing behind an interface, we can load the same game program there and plug in a new display module.

    Drawing과 다음 장에서 어떻게 캡슐화를 하는지 주목해보자.

Drawing

  • The encapsulation of the drawing code is done by defining a display object, which displays a given level and state. The display type we define in this chapter is called DOMDisplay because it uses DOM elements to show the level.

  • We’ll be using a style sheet to set the actual colors and other fixed properties of the elements that make up the game.

  • // Helper function
    function elt(name, attrs, ...children) {
        let dom = document.createElement(name);
        for (let attr of Object.keys(attrs)) {
            dom.setAttribute(attr, attrs[attr]);
        }
        for (let child of children) {
            dom.appendChild(child);
        }
        return dom;
    }
    
    class DOMDisplay {
        constructor(parent, level) {
            this.dom = elt("div", {class: "game"}, drawGrid(level));
            this.actorLayer = null;
            parent.appendChild(this.dom);
        }
    
        clear() { this.dom.remove(); }
    }
  • The level’s background grid, which never changes, is drawn once. Actors are redrawn every time the display is updated with a given state. The actorLayer property will be used to track the element that holds the actors so that they can be easily removed and replaced.

  • Our coordinates and sizes are tracked in grid units, where a size or distance of 1 means one grid block. When setting pixel sizes, we will have to scale these coordinates up.

  • const scale = 20;
    
    function drawGrid(level) {
        return elt("table", {
            class: "background", 
            style: `width: ${level.width * scale}px`
        }, ...level.rows.map(row => 
            elt("tr", {style: `height: ${scale}px`}, 
                ...row.map(type => elt("td", {class: type})))
            // rest parameter에 아무것도 안들어가면 파라미터는 빈 배열이 됨
        ));
    }
  • The following CSS makes the table look like the background we want:

  • .background    { background: rgb(52, 166, 251);
                     table-layout: fixed;
                     border-spacing: 0;                }
    .background td { padding: 0;                       }
    .lava          { background: rgb(255, 100, 100);   }
    .wall          { background: white;                }       
  • Some of these (table-layout, border-spacing, and padding) are used to suppress unwanted default behavior. We don’t want the layout of the table to depend upon the contents of its cells, and we don’t want space between the table cells or padding inside them.

  • The background rule sets the background color. CSS allows colors to be specified both as words (white) or with a format such as rgb(R, G, B).

    CSS는 색을 단어 형태 혹은 rgb(R, G, B) 같은 포맷으로 표현할 수 있다.

  • We draw each actor by creating a DOM element for it and setting that element’s position and size based on the actor’s properties.

  • function drawActors(actors) {
        return elt("div", {}, ...actors.map(actor => {
            let rect = elt("div", {class: `actor ${actor.type}`});
            rect.style.width = `${actor.size.x * scale}px`;
            rect.style.height = `${actor.size.y * scale}px`;
            rect.style.left = `${actor.pos.x * scale}px`;
            rect.style.top = `${actor.pos.y * scale}px`;
            return rect;
        }));
    }
  • To give an element more than one class, we separate the class names by spaces.

    스페이스로 분리하여 한 엘리먼트에 여러 클래스 이름을 부여할 수 있다.

  • In the CSS code shown next, the actor class gives the actors their absolute position. ... We don’t have to define the lava class again because we’re reusing the class for the lava grid squares we defined earlier.

  • .actor         { position: absolute;               }
    .coin          { background: rgb(241, 229, 89);    }
    .player        { background: rgb(64, 64, 64);      }
  • The syncState method is used to make the display show a given state. It first removes the old actor graphics, if any, and then redraws the actors in their new positions.

  • It may be tempting to try to reuse the DOM elements for actors, but to make that work, we would need a lot of additional bookkeeping to associate actors with DOM elements and to make sure we remove elements when their actors vanish.

  • DOMDisplay.prototype.syncState = function(state) {
        if (this.actorLayer) this.actorLayer.remove();
        this.actorLayer = drawActors(state.actors);
        this.dom.appendChild(this.actorLayer);
        this.dom.className = `game ${state.status}`;
        this.scrollPlayerIntoView(state);
    };
  • By adding the level’s current status as a class name to the wrapper, we can style the player actor slightly differently when the game is won or lost by adding a CSS rule that takes effect only when the player has an ancestor element with a given class.

  • .lost .player  {
        background: rgb(160, 64, 64);
    }
    .won .player{
        box-shadow: -4px -7px 8px white, 4px -7px 8px white;
    }
  • After touching lava, the player’s color turns dark red. ... When the last coin has been collected, we add two blurred white shadows—one to the top left and one to the top right—to create a white halo effect.

    box-shadow

  • We can’t assume that the level always fits in the viewport—the element into which we draw the game.

    뷰폿(게임을 그리는 요소)이 레벨에 언제나 꼭 맞다고 가정할 수 없다.

  • That is why the scrollPlayerIntoView call is needed. It ensures that if the level is protruding outside the viewport, we scroll that viewport to make sure the player is near its center.

  • The following CSS gives the game’s wrapping DOM element a maximum size and ensures that anything that sticks out of the element’s box is not visible.

    overflow, max-height, max-width

  • .game {
        overflow: hidden;
        max-width: 600px;
        max-height: 450px;
        position: relative;
    }
  • In the scrollPlayerIntoView method, we find the player’s position and update the wrapping element’s scroll position. We change the scroll position by manipulating that element’s scrollLeft and scrollTop properties when the player is too close to the edge.

    scrollTop, scrollLeft

  • DomDisplay.prototype.scrollPlayerIntoView = function(state) {
        let width = state.dom.clientWidth;
        let height = state.dom.clientHeight;
        let margin = width / 3;
    
        // Viewport
        let left = state.dom.scrollLeft, right = left + width;
        let top = state.dom.scrollTop, bottom = top + height;
    
        let player = state.player;
        let center = player.pos.plus(player.size.times(0.5))
                            .times(scale);
    
        if (center.x < left + margin) {
            this.dom.scrollLeft = center.x - margin;
        } else if (center.x > right - margin) {
            this.dom.scrollLeft = center.x + margin - width;
        }
        if (center.y < top + margin) {
            this.dom.scrollTop = center.y - margin;
        } else if (center.y > bottom - margin) {
            this.dom.scrollTop = center.y + margin - height;
        }
    }
  • The way the player’s center is found shows how the methods on our Vec type allow computations with objects to be written in a relatively readable way.

    Vec 연산의 가독성

  • Note that sometimes this will set nonsense scroll coordinates that are below zero or beyond the element’s scrollable area. This is okay—the DOM will constrain them to acceptable values. Setting scrollLeft to -10 will cause it to become 0.

    scrollLeft: 말이 안되는 값을 넣어도 가능한 값으로 바뀐다.

  • We are now able to display our tiny level.

  • <!-- Modified to suit my code -->
    <!doctype html>
    <html>
        <head>
            <meta charset="utf-8">
            <title>Platform game</title>
            <script src="ch16_prj3.js"></script>
            <link rel="stylesheet" href="ch16_prj3.css">
        </head>
        <body>
            <script>
                let simpleLevel = new Level(simpleLevelPlan);
                let display = new DOMDisplay(document.body, simpleLevel);
                display.syncState(State.start(simpleLevel));
            </script>
        </body>
    </html>
  • The <link> tag, when used with rel="stylesheet", is a way to load a CSS file into a page. The file game.css contains the styles necessary for our game.

    <link>, rel="stylesheet"

Motion and collision

  • The basic approach, taken by most games like this, is to split time into small steps and, for each step, move the actors by a distance corresponding to their speed multiplied by the size of the time step.

    (우리 게임과 비슷한) 대부분의 게임이 사용하는 기본적인 방법은 작은 간격으로 시간을 쪼개고, 매 간격마다 간격에 대응하는 거리만큼 액터들을 움직이는 것이다.

  • Moving things is easy. The difficult part is dealing with the interactions between the elements. The game must notice when a given motion causes an object to hit another object and respond accordingly.

  • Solving this for the general case is a big task. (physics engines) We’ll take a more modest approach in this chapter, handling only collisions between rectangular objects and handling them in a rather simplistic way.

  • Before moving the player or a block of lava, we test whether the motion would take it inside of a wall. If it does, we simply cancel the motion altogether. The response to such a collision depends on the type of actor—the player will stop, whereas a lava block will bounce back.

  • This approach requires our time steps to be rather small since it will cause motion to stop before the objects actually touch. ... Another approach, arguably better but more complicated, would be to find the exact collision spot and move there. We will take the simple approach and hide its problems by ensuring the animation proceeds in small steps.

  • Level.prototype.touches = function(pos, size, type) {
        let xStart = Math.floor(pos.x);
        let xEnd = Math.ceil(pos.x + size.x);
        let yStart = Math.floor(pos.y);
        let yEnd = Math.ceil(pos.y + size.y);
    
        for (let y = yStart; y <= yEnd; y++) {
            for (let x = xStart; x <= xEnd; x++) {
                let isOutside = x < 0 || x >= this.width ||
                                y < 0 || y >= this.height;
                let here = isOutside ? "wall" : this.rows[y][x];
                if (here == type) return true;
            }
        }
        return false;
    };
  • The state update method uses touches to figure out whether the player is touching lava.

  • // ...

Actor updates

  • Player.prototype.update = function(time, state, keys) {
        let xSpeed = 0;
        if (keys.ArrowLeft) xSpeed -= playerXSpeed;
        if (keys.ArrowRight) xSpeed += playerXSpeed;
        let pos = this.pos;
        let movedX = pos.plus(new Vec(xSpeed * time, 0));
        if (!state.level.touched(movedX, this.size, "wall")) {
            pos = movedX;
        }
    
        let ySpeed = this.speed.y + gravity * time;
        let movedY = pos.plus(new Vec(0, ySpeed * time));
        if (!state.level.touched(movedY, this.size, "wall")) {
            pos = movedY;
        } else if (keys.ArrowUp && ySpeed > 0){
            // meaning the thing we hit is below us
            ySpeed = -jumpSpeed;
        } else {
            ySpeed = 0;
        }
        return new Player(pos, new Vec(xSpeed, ySpeed));
    };
  • When the up arrow is pressed and we are moving down (meaning the thing we hit is below us), the speed is set to a relatively large, negative value.

Tracking keys (***)

  • For a game like this, we do not want keys to take effect once per keypress. Rather, we want their effect (moving the player figure) to stay active as long as they are held.

  • We need to set up a key handler that stores the current state of the left, right, and up arrow keys. We will also want to call preventDefault for those keys so that they don’t end up scrolling the page.

    방향키의 현재 상태를 저장하는 키 핸들러 + prevendDefault

  • function trackKeys(keys) {
        let down = Objcet.create(null);
        function track(event) {
            if (keys.includes(event.key)) {
                down[event.key] = event.type == "keydown";
                event.preventDefault();
            }
        }
        window.addEventListener("keydown", track);
        window.addEventListener("keyup", track);
        return down;
    }
    
    const arrowKeys = 
        trackKeys(["ArrowLeft", "ArrowRight", "ArrowUp"]);

Running the game

  • function runAnimation(frameFunc) {
        let lastTime = null;
        function frame(time) {
            if (lastTime != null) {
                let timeStep = Math.min(time - lastTime, 100) / 1000;
                if (frameFunc(timeStep) === false) return;
            }
            lastTime = time;
            requestAnimationFrame(frame);
        }
        requestAnimationFrame(frame);
    }
  • I have set a maximum frame step of 100 milliseconds (one-tenth of a second). When the browser tab or window with our page is hidden, requestAnimationFrame calls will be suspended until the tab or window is shown again. In this case, the difference between lastTime and time will be the entire time in which the page was hidden. Advancing the game by that much in a single step would look silly and might cause weird side effects, such as the player falling through the floor.

    브라우저의 탭이나 윈도우가 가려지면 다시 보여지기 전까지 requestAnimationFrame 호출이 중단된다. 그러면 시간 간격이 매우 커질 수 있고, 이러면 예상 못한 결과가 발생할 수 있다.

Exercises

Pausing the game

<!-- Solution -->
<!doctype html>
<script src="code/chapter/16_game.js"></script>
<script src="code/levels.js"></script>

<link rel="stylesheet" href="css/game.css">

<body>
<script>
  // To know when to stop and restart the animation, a level that is
  // being displayed may be in three `running` states:
  //
  // * "yes":     Running normally.
  // * "no":      Paused, animation isn't running
  // * "pausing": Must pause, but animation is still running
  //
  // The key handler, when it notices escape being pressed, will do a
  // different thing depending on the current state. When running is
  // "yes" or "pausing", it will switch to the other of those two
  // states. When it is "no", it will restart the animation and switch
  // the state to "yes".
  //
  // The animation function, when state is "pausing", will set the state
  // to "no" and return false to stop the animation.

  function runLevel(level, Display) {
    let display = new Display(document.body, level);
    let state = State.start(level);
    let ending = 1;
    let running = "yes";

    return new Promise(resolve => {
      function escHandler(event) {
        if (event.key != "Escape") return;
        event.preventDefault();
        if (running == "no") {
          running = "yes";
          runAnimation(frame);
        } else if (running == "yes") {
          running = "pausing";
        } else {
          running = "yes";
        }
      }
      window.addEventListener("keydown", escHandler);
      let arrowKeys = trackKeys(["ArrowLeft", "ArrowRight", "ArrowUp"]);

      function frame(time) {
        if (running == "pausing") {
          running = "no";
          return false;
        }

        state = state.update(time, arrowKeys);
        display.syncState(state);
        if (state.status == "playing") {
          return true;
        } else if (ending > 0) {
          ending -= time;
          return true;
        } else {
          display.clear();
          window.removeEventListener("keydown", escHandler);
          arrowKeys.unregister();
          resolve(state.status);
          return false;
        }
      }
      runAnimation(frame);
    });
  }

  function trackKeys(keys) {
    let down = Object.create(null);
    function track(event) {
      if (keys.includes(event.key)) {
        down[event.key] = event.type == "keydown";
        event.preventDefault();
      }
    }
    window.addEventListener("keydown", track);
    window.addEventListener("keyup", track);
    down.unregister = () => {
      window.removeEventListener("keydown", track);
      window.removeEventListener("keyup", track);
    };
    return down;
  }

  runGame(GAME_LEVELS, DOMDisplay);
</script>
</body>

A monster

<!-- My code -->
<link rel="stylesheet" href="css/game.css">
<style>.monster { background: purple }</style>

<body>
  <script>
    // Complete the constructor, update, and collide methods
    const monsterXSpeed = 3;

    class Monster {
      constructor(pos, speed) {
          this.pos = pos;
        this.speed = speed;
      }

      get type() { return "monster"; }

      static create(pos) {
        return new Monster(pos.plus(new Vec(-0.2, -1)),
                           new Vec(0, 0));
      }

      update(time, state) {
        let xSpeed = 0;
        if (state.player.pos.x < this.pos.x) { 
          xSpeed -= monsterXSpeed;
        } else {
          xSpeed += monsterXSpeed;
        }

        let newSpeed = new Vec(xSpeed, 0);
        let newPos = this.pos.plus(newSpeed.times(time));
        if (!state.level.touches(newPos, this.size, "wall")) {
          return new Monster(newPos, newSpeed);
        } else {
          return this;
        }
      }

      collide(state) {
        let player = state.player;
        let isPressed = (player.pos.y + player.size.y) < 
                        (this.pos.y + this.size.y - player.size.y);
        if (isPressed) {
          let filtered = state.actors.filter(a => a != this);
          return new State(state.level, filtered, state.status);
        } else {
          return new State(state.level, state.actors, "lost");
        }
      }
    }

    Monster.prototype.size = new Vec(1.2, 2);

    levelChars["M"] = Monster;

    runLevel(new Level(`
..................................
.################################.
.#..............................#.
.#..............................#.
.#..............................#.
.#...........................o..#.
.#..@...........................#.
.##########..............########.
..........#..o..o..o..o..#........
..........#...........M..#........
..........################........
..................................
`), DOMDisplay);
  </script>
</body>