Someone
Someone

Reputation: 3578

Javascript Tile Engine Game - Moving fixed amount of pixels is juddery

enter image description here

I'm creating a small custom tile-engine in Javascript. The render loop uses requestAnimationFrame and the update loop is fixed at 60 ticks per second.

Note that my display is running at 240fps so there is a disparity between the update interval and the render interval.

I'm currently trying to implement player movement.

In my case, for every input I want the player to move exactly one tile at a time. To achieve this, I calculate what the next position should be (position + tilewidth) and tween between the current value and the next value. However, while the player is in the center of the screen the movement is very juddery.

I can't figure out if this is a flaw in;

Only what I hope is the relevant code below. Under that is a full working snippet.

Main Loop

init(){
    // ... init other objects

    requestAnimationFrame(this.loop.bind(this));
}

loop(timeStamp){
    this.state.performance.delta += (timeStamp - this.state.performance.lastUpdateTimeMs);
    this.state.performance.lastUpdateTimeMs = timeStamp;

    while(this.state.performance.delta >= this.state.performance.updateTimeStep){
        this.update(this.state.performance.updateTimeStep);
        this.state.performance.delta -= this.state.performance.updateTimeStep;
    }

    this.render();
    requestAnimationFrame(this.loop.bind(this));
}

update(){
    // ... other updates
    this.state.map.update(this.state);
    this.state.player.update(this.state);
    this.state.camera.update(this.state);
}

render() {
    this.state.viewport.clear();
    this.state.map.render(this.state);
    this.state.player.render(this.state);
}

Camera

update(state){
    this.position.x = (state.player.position.x + state.player.dimensions.width / 2) - (state.viewport.dimensions.width / 2);
    this.position.y = (state.player.position.y + state.player.dimensions.height / 2) - (state.viewport.dimensions.height / 2);

    this.clampToMap(state);
}

clampToMap(state) {
    if(this.position.x < 0) this.position.x = 0;
    if(this.position.y < 0) this.position.y = 0;

    if(this.position.x + state.viewport.dimensions.width > state.map.dimensions.width) this.position.x = state.map.dimensions.width - state.viewport.dimensions.width;
    if(this.position.y + state.viewport.dimensions.height > state.map.dimensions.height) this.position.y = state.map.dimensions.height - state.viewport.dimensions.height;
}

Player Movement

transitionMovement(state) {
    this.allowMovement = false;

    const interval = setInterval(() => {
        this.moving = this.position.x !== this.position.nextX || this.position.y !== this.position.nextY;

        if (this.position.nextX < this.position.x) {
            this.position.x -= this.speed;
        }

        if (this.position.nextX > this.position.x) {
            this.position.x += this.speed;
        }

        if (this.position.nextY < this.position.y) {
            this.position.y -= this.speed;
        }

        if (this.position.nextY > this.position.y) {
            this.position.y += this.speed;
        }

        if (this.position.nextX === this.position.x && this.position.nextY === this.position.y) {
            this.position.x = this.position.nextX;
            this.position.y = this.position.nextY;
            this.moving = false;
            this.allowMovement = true;
            clearInterval(interval);
        }
    }, state.performance.updateTimeStep);
}

I've included the rest of the code and a working version of it in the snippet below. In case you can't see it, I've also included a gif of the problem.

class Engine {
    constructor() {
        const tileSize = 64;

        this.state = {
            performance: new Performance(60),
            viewport: new Viewport(1024, 576),
            player: new Player(tileSize, tileSize, tileSize, tileSize),
            keyboard: new Keyboard(),
            map: new Map(tileSize),
            camera: new Camera(),
            console: new Console()
        }

        this.init();
    }

    init(){
        this.state.console.init(this.state);
        this.state.viewport.init(this.state);
        this.state.keyboard.init();
        this.state.context = this.state.viewport.context;

        requestAnimationFrame(this.loop.bind(this));
    }

    loop(timeStamp){
        this.state.performance.delta += (timeStamp - this.state.performance.lastUpdateTimeMs);
        this.state.performance.lastUpdateTimeMs = timeStamp;

        while(this.state.performance.delta >= this.state.performance.updateTimeStep){
            this.update(this.state.performance.updateTimeStep);
            this.state.performance.delta -= this.state.performance.updateTimeStep;
        }

        this.render();
        requestAnimationFrame(this.loop.bind(this));
    }

    update(){
        this.state.keyboard.update(this.state);
        this.state.performance.updateUps(this.state);
        this.state.map.update(this.state);
        this.state.player.update(this.state);
        this.state.camera.update(this.state);
    }

    render() {
        this.state.viewport.clear();
        this.state.map.render(this.state);
        this.state.player.render(this.state);
    }
}


class Camera {
    constructor() {
        this.position = {
            x: 0,
            y: 0,
        }
    }

    update(state){
        this.position.x = (state.player.position.x + state.player.dimensions.width / 2) - (state.viewport.dimensions.width / 2);
        this.position.y = (state.player.position.y + state.player.dimensions.height / 2) - (state.viewport.dimensions.height / 2);

        this.clampToMap(state);
    }

    clampToMap(state) {
        if(this.position.x < 0) this.position.x = 0;
        if(this.position.y < 0) this.position.y = 0;

        if(this.position.x + state.viewport.dimensions.width > state.map.dimensions.width) this.position.x = state.map.dimensions.width - state.viewport.dimensions.width;
        if(this.position.y + state.viewport.dimensions.height > state.map.dimensions.height) this.position.y = state.map.dimensions.height - state.viewport.dimensions.height;
    }
}

class Player {
    constructor(x, y, w, h) {
        this.dimensions = {
            width: w,
            height: h,
        }

        this.position = {
            x: x,
            y: y,
            nextX: x,
            nextY: y,
        }

        this.direction = null;
        this.moving = false;
        this.color = '#ff0000';
        this.speed = 4;
        this.allowMovement = true;
    }

    update(state) {
        if (!this.allowMovement) return;

        if (state.keyboard.isDown('KEY_UP') && state.keyboard.lastKey === 'KEY_UP') {
            this.direction = 'UP';
            this.position.nextY -= state.map.tileSize;
        }

        if (state.keyboard.isDown('KEY_DOWN') && state.keyboard.lastKey === 'KEY_DOWN') {
            this.direction = 'DOWN';
            this.position.nextY += state.map.tileSize;
        }

        if (state.keyboard.isDown('KEY_LEFT')  && state.keyboard.lastKey === 'KEY_LEFT') {
            this.direction = 'LEFT';
            this.position.nextX -= state.map.tileSize;
        }

        if (state.keyboard.isDown('KEY_RIGHT') && state.keyboard.lastKey === 'KEY_RIGHT') {
            this.direction = 'RIGHT';
            this.position.nextX += state.map.tileSize;
        }

        if (state.map.isTileWalkable(this.position.nextX, this.position.nextY)) {
            this.transitionMovement(state);
        } else {
            this.position.nextX = this.position.x;
            this.position.nextY = this.position.y;
        }

        state.console.log('PLAYER', this);
    }

    transitionMovement(state) {
        this.allowMovement = false;

        const interval = setInterval(() => {
            this.moving = this.position.x !== this.position.nextX || this.position.y !== this.position.nextY;

            if (this.position.nextX < this.position.x) {
                this.position.x -= this.speed;
            }

            if (this.position.nextX > this.position.x) {
                this.position.x += this.speed;
            }

            if (this.position.nextY < this.position.y) {
                this.position.y -= this.speed;
            }

            if (this.position.nextY > this.position.y) {
                this.position.y += this.speed;
            }

            if (this.position.nextX === this.position.x && this.position.nextY === this.position.y) {
                this.position.x = this.position.nextX;
                this.position.y = this.position.nextY;
                this.moving = false;
                this.allowMovement = true;
                clearInterval(interval);
            }
        }, state.performance.updateTimeStep);
    }

    render(state){
       state.viewport.render(this, state);
    }
}

class Keyboard{
    constructor(){
        this.events = {};
        this.lastKey = {};
        this.keys = {
            KEY_UP: {code: 87, isDown: false},
            KEY_DOWN: {code: 83, isDown: false},
            KEY_LEFT: {code: 65, isDown: false},
            KEY_RIGHT: {code: 68, isDown: false},
            KEY_1: {code: 49, isDown: false},
            KEY_2: {code: 50, isDown: false},
            KEY_0: {code: 48, isDown: false},
            KEY_MINUS: {code: 189, isDown: false},
            KEY_EQUALS: {code: 187, isDown: false},
            KEY_SHIFT: {code: 16, isDown: false}
        };
    }

    init(){
        window.addEventListener('keydown', this.handleKeyDown.bind(this));
        window.addEventListener('keyup', this.handleKeyUp.bind(this));
    }

    registerEvent(key, event){
        this.events[key] = event;
    }

    handleKeyDown(e){
        const KEY_NAME = this.getKeyName(e.keyCode);

        if(e.repeat || !this.keys.hasOwnProperty(KEY_NAME)) return;

        if(KEY_NAME === 'KEY_UP' || KEY_NAME === 'KEY_DOWN' || KEY_NAME === 'KEY_LEFT' || KEY_NAME === 'KEY_RIGHT'){
            this.lastKey = KEY_NAME;
        }

        this.keys[KEY_NAME].isDown = true;
        this.handleRegisteredEvent(KEY_NAME);
    }

    handleKeyUp(e){
        const KEY_NAME = this.getKeyName(e.keyCode);
        if(e.repeat || !this.keys.hasOwnProperty(KEY_NAME)) return;

        this.keys[KEY_NAME].isDown = false;
    }

    handleRegisteredEvent(key){
        if(this.events.hasOwnProperty(key)){
            this.events[key]();
        }
    }

    isDown(key){
        return this.keys[key].isDown;
    }

    // utility
    getKeyName(keyCode){
        for(let key in this.keys){
            if(this.keys[key].code === keyCode){
                return key;
            }
        }
    }

    update(state){
        state.console.log('KEYBOARD', Object.keys(this.keys).map(key => `${key}: ${this.keys[key].isDown}`));
    }
}

class Viewport {
    constructor(w, h) {
        this.dimensions = {
            width: w,
            height: h
        }

        this.grid = false;
    }

    init(state) {
        this.canvas = document.createElement("canvas");
        this.context = this.canvas.getContext("2d");
        this.canvas.width = this.dimensions.width;
        this.canvas.height = this.dimensions.height;
        this.canvas.style.width = this.dimensions.width + "px";
        this.canvas.style.height = this.dimensions.height + "px";

        this.state = state;

        document.body.appendChild(this.canvas);
        this.bindEvents(state);
    }

    bindEvents(state) {
        state.keyboard.registerEvent('KEY_2', () => {
            this.toggleGrid();
        });
    }

    toggleGrid() {
        this.drawGrid = !this.drawGrid;
    }

    clear() {
        this.context.fillStyle = '#6495ED';
        this.context.clearRect(0, 0, this.dimensions.width, this.dimensions.height);
        this.context.fillRect(0, 0, this.dimensions.width, this.dimensions.height);
    }

    renderGrid(state) {
        if(!this.drawGrid) return;

        this.context.strokeStyle = '#000000';
        this.context.lineWidth = 1;

        for (let x = 0; x < this.dimensions.width; x += state.map.tileSize) {
            this.context.beginPath();
            this.context.moveTo(x, 0);
            this.context.lineTo(x, this.dimensions.height);
            this.context.stroke();

            for (let y = 0; y < this.dimensions.height; y += state.map.tileSize) {
                this.context.beginPath();
                this.context.moveTo(0, y);
                this.context.lineTo(this.dimensions.width, y);
                this.context.stroke();
            }
        }
    }

    render(obj, state) {
        this.context.save();
        this.context.fillStyle = obj.color;
        //this.context.scale(state.camera.zoom, state.camera.zoom);
        this.context.fillRect(
            obj.position.x - state.camera.position.x,
            obj.position.y - state.camera.position.y,
            obj.dimensions.width,
            obj.dimensions.height
        );
        this.context.restore();
    }
}

class Console{
    constructor() {
        this.visible = false;
        this.data = [];
    }

    init(state){
        this.bindEvents(state);
    }

    log(key, message){
        this.data[key] = message;
    }

    toggleVisible(){
        this.visible = !this.visible;
    }

    bindEvents(state){
        state.keyboard.registerEvent('KEY_1', () => {this.toggleVisible()});
    }

    renderHelp(state){
        state.context.fillStyle = 'rgba(0, 0, 0, 0.7)';
        state.context.fillRect(20, state.viewport.dimensions.height - 80, 240, 60);

        state.context.fillStyle = '#ffffff';
        state.context.font = '16px Monospace';
        state.context.fillText('Press 1 to toggle console', 30, state.viewport.dimensions.height - 60);

        state.context.fillStyle = '#ffffff';
        state.context.font = '16px Monospace';
        state.context.fillText('Press 2 to toggle grid', 30, state.viewport.dimensions.height - 30);
    }

    render(state){
        this.renderHelp(state);

        if(!this.visible) return;

        // add a black background with 50% opacity
        state.context.fillStyle = 'rgba(0, 0, 0, 0.7)';
        state.context.fillRect(0, 0, state.viewport.dimensions.width, state.viewport.dimensions.height);

        let line = 0;

        // output each key/value pair - value is an object
        for(let key in this.data){
            state.context.fillStyle = '#00ff00';
            state.context.font = '16px Monospace';
            state.context.fillText(key, 10, 20 + (line * 20));
            line++;

            // Output each key and value - value may be an object or a string
            if(typeof this.data[key] === 'object'){
                for(let k in this.data[key]){
                    state.context.fillStyle = '#ffff00';
                    state.context.font = '12px Monospace';
                    state.context.fillText(k + ': ' + (typeof this.data[key][k] === 'object' ? '' : this.data[key][k]), 20, 20 + (line * 20));
                    line++;

                    // Output each subkey - subkey may be an object or a string
                    state.context.fillStyle = '#ffffff';
                    state.context.font = '12px Monospace';
                    if(typeof this.data[key][k] === 'object'){
                        for(let sk in this.data[key][k]){
                            state.context.fillText(sk + ': ' + this.data[key][k][sk], 30, 20 + (line * 20));
                            line++;
                        }
                    }
                }
            }else{
                state.context.fillStyle = '#ffff00';
                state.context.font = '12px Monospace';
                state.context.fillText(this.data[key], 20, 20 + (line * 20));
                line++;
            }
        }
    }
}

class Map {
    TILES = {
        0: '#DDDDDD',
        1: '#00FF00',
        2: '#FF0000'
    }

    constructor(ts) {
        this.tileSize = ts;
        this.map = [
            [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],

        ]

        this.dimensions = {
            width: this.map[0].length * this.tileSize,
            height: this.map.length * this.tileSize
        }
    }

    getMapPosition(x, y) {
        return {
            x: Math.floor(x / this.tileSize),
            y: Math.floor(y / this.tileSize)
        }
    }

    getTile(x, y) {
        return this.map[y][x];
    }

    isTileWalkable(x, y) {
        const mapPosition = this.getMapPosition(x, y);

        return this.getTile(mapPosition.x, mapPosition.y) !== 0;
    }

    update(state) {
        // log this except map
        state.console.log('MAP', Object.keys(this).reduce((object, key) => {
            if (key !== 'map' && key !== 'TILES') {
                object[key] = this[key];
            }
            return object;
        }, {}));
    }

    render(state) {
        this.map.forEach((row, y) => {
            row.forEach((tile, x) => {
                state.viewport.render({
                    position: {
                        x: x * this.tileSize,
                        y: y * this.tileSize
                    },
                    dimensions: {
                        width: this.tileSize,
                        height: this.tileSize
                    },
                    color: this.TILES[tile]
                }, state);
            });
        });
    }
}

class Performance {
    constructor(ups){
        this.updateTimeStep = 1000 / ups;
        this.delta = 0;
        this.lastUpdateTimeMs = 0;
        this.updatesPerSecond = 0;
        this.framesPerSecond = 0;

        this.lastUpdateTimestamp = 0;
        this.lastFrameTimestamp = 0;

        this.targetUps = ups;

        this.updateCount = 0;
        this.frameCount = 0;
    }

    updateUps(state){
        this.updateCount++;

        if(performance.now() > this.lastUpdateTimestamp + 1000){
            this.updatesPerSecond = this.updateCount;
            this.updateCount = 0;
            this.lastUpdateTimestamp = performance.now();
        }
    }

    updateFps(state){
        this.frameCount++;

        if(performance.now() > this.lastFrameTimestamp + 1000){
            this.framesPerSecond = this.frameCount;
            this.frameCount = 0;
            this.lastFrameTimestamp = performance.now();
        }

        state.console.log('PERFORMANCE', {
            ups: this.updatesPerSecond,
            fps: this.framesPerSecond,
            delta: this.delta,
        });
    }
}

let engine = new Engine();
*{
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

html, body{
    width: 100%;
    height: 100%;
    background-color: black;
    overflow: hidden;
}

canvas{

    display: block;
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    margin: auto;
    z-index: 0;
}
<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Pokégame</title>

    <link rel="stylesheet" href="reset.css">
</head>
<body>

</body>
</html>

Upvotes: 3

Views: 65

Answers (1)

blakkwater
blakkwater

Reputation: 1181

My display is 120 Hz, and it also renders the Player tile twitching when I'm moving it. But when I changed Performance(60) to Performance(120), things became smooth.

So I added a little debug logging into your Engine.loop. It counts how many times this.update() is called in the while loop on each frame. Once every 250 milliseconds, collected stats are dumped onto the canvas (dots are counting frames after the last dump switch, just to show it doesn't stuck if the pattern is repeating). In my case, the pattern is 0,1,0,1.... Which makes you suspect something doesn't update coherently between frames.

Cheap solution: call this.render() only if this.update() has been called (I did that in my snippet). Your FPS will be (sort of) synchronized with Performance setting. Works for me.

BUT. setInterval in Player.transitionMovement seems to be minding its own business... If I were you, I'd investigate if it is causing interference.

Apart from that, the best practice is to always use a single place in your code, where all updates to the physical state of objects are finally applied.

let tickCountersLast = [''];
let tickCountersCurrent = [];
let dumpSwitchTime = Date.now();

class Engine {
    constructor() {
        const tileSize = 64;

        this.state = {
            performance: new Performance(60),
            viewport: new Viewport(1024, 576),
            player: new Player(tileSize, tileSize, tileSize, tileSize),
            keyboard: new Keyboard(),
            map: new Map(tileSize),
            camera: new Camera(),
            console: new Console()
        }

        this.init();
    }

    init(){
        this.state.console.init(this.state);
        this.state.viewport.init(this.state);
        this.state.keyboard.init();
        this.state.context = this.state.viewport.context;

        requestAnimationFrame(this.loop.bind(this));
    }

    loop(timeStamp){
        this.state.performance.delta += (timeStamp - this.state.performance.lastUpdateTimeMs);
        this.state.performance.lastUpdateTimeMs = timeStamp;

        let ticks = 0;
        while(this.state.performance.delta >= this.state.performance.updateTimeStep){
            this.update(this.state.performance.updateTimeStep);
            this.state.performance.delta -= this.state.performance.updateTimeStep;
            ticks++;
        }
        tickCountersCurrent.push(ticks);

        if (dumpSwitchTime + 250 <= Date.now()) {
            dumpSwitchTime = Date.now();
            tickCountersLast = tickCountersCurrent;
            tickCountersCurrent = [];
        }

        if (ticks) {
            this.render();
            let ctx = this.state.viewport.context;
            ctx.save();
            ctx.font = '10px monospace';
            ctx.fillStyle = 'black';
            ctx.fillText(tickCountersLast.join(',') + '.'.repeat(tickCountersCurrent.length), 10, ctx.canvas.height / 2);
            ctx.restore();
        }

        requestAnimationFrame(this.loop.bind(this));
    }

    update(){
        this.state.keyboard.update(this.state);
        this.state.performance.updateUps(this.state);
        this.state.map.update(this.state);
        this.state.player.update(this.state);
        this.state.camera.update(this.state);
    }

    render() {
        this.state.viewport.clear();
        this.state.map.render(this.state);
        this.state.player.render(this.state);
    }
}

class Camera {
    constructor() {
        this.position = {
            x: 0,
            y: 0,
        }
    }

    update(state){
        this.position.x = (state.player.position.x + state.player.dimensions.width / 2) - (state.viewport.dimensions.width / 2);
        this.position.y = (state.player.position.y + state.player.dimensions.height / 2) - (state.viewport.dimensions.height / 2);

        this.clampToMap(state);
    }

    clampToMap(state) {
        if(this.position.x < 0) this.position.x = 0;
        if(this.position.y < 0) this.position.y = 0;

        if(this.position.x + state.viewport.dimensions.width > state.map.dimensions.width) this.position.x = state.map.dimensions.width - state.viewport.dimensions.width;
        if(this.position.y + state.viewport.dimensions.height > state.map.dimensions.height) this.position.y = state.map.dimensions.height - state.viewport.dimensions.height;
    }
}

class Player {
    constructor(x, y, w, h) {
        this.dimensions = {
            width: w,
            height: h,
        }

        this.position = {
            x: x,
            y: y,
            nextX: x,
            nextY: y,
        }

        this.direction = null;
        this.moving = false;
        this.color = '#ff0000';
        this.speed = 4;
        this.allowMovement = true;
    }

    update(state) {
        if (!this.allowMovement) return;

        if (state.keyboard.isDown('KEY_UP') && state.keyboard.lastKey === 'KEY_UP') {
            this.direction = 'UP';
            this.position.nextY -= state.map.tileSize;
        }

        if (state.keyboard.isDown('KEY_DOWN') && state.keyboard.lastKey === 'KEY_DOWN') {
            this.direction = 'DOWN';
            this.position.nextY += state.map.tileSize;
        }

        if (state.keyboard.isDown('KEY_LEFT')  && state.keyboard.lastKey === 'KEY_LEFT') {
            this.direction = 'LEFT';
            this.position.nextX -= state.map.tileSize;
        }

        if (state.keyboard.isDown('KEY_RIGHT') && state.keyboard.lastKey === 'KEY_RIGHT') {
            this.direction = 'RIGHT';
            this.position.nextX += state.map.tileSize;
        }

        if (state.map.isTileWalkable(this.position.nextX, this.position.nextY)) {
            this.transitionMovement(state);
        } else {
            this.position.nextX = this.position.x;
            this.position.nextY = this.position.y;
        }

        state.console.log('PLAYER', this);
    }

    transitionMovement(state) {
        this.allowMovement = false;

        const interval = setInterval(() => {
            this.moving = this.position.x !== this.position.nextX || this.position.y !== this.position.nextY;

            if (this.position.nextX < this.position.x) {
                this.position.x -= this.speed;
            }

            if (this.position.nextX > this.position.x) {
                this.position.x += this.speed;
            }

            if (this.position.nextY < this.position.y) {
                this.position.y -= this.speed;
            }

            if (this.position.nextY > this.position.y) {
                this.position.y += this.speed;
            }

            if (this.position.nextX === this.position.x && this.position.nextY === this.position.y) {
                this.position.x = this.position.nextX;
                this.position.y = this.position.nextY;
                this.moving = false;
                this.allowMovement = true;
                clearInterval(interval);
            }
        }, state.performance.updateTimeStep);
    }

    render(state){
       state.viewport.render(this, state);
    }
}

class Keyboard{
    constructor(){
        this.events = {};
        this.lastKey = {};
        this.keys = {
            KEY_UP: {code: 87, isDown: false},
            KEY_DOWN: {code: 83, isDown: false},
            KEY_LEFT: {code: 65, isDown: false},
            KEY_RIGHT: {code: 68, isDown: false},
            KEY_1: {code: 49, isDown: false},
            KEY_2: {code: 50, isDown: false},
            KEY_0: {code: 48, isDown: false},
            KEY_MINUS: {code: 189, isDown: false},
            KEY_EQUALS: {code: 187, isDown: false},
            KEY_SHIFT: {code: 16, isDown: false}
        };
    }

    init(){
        window.addEventListener('keydown', this.handleKeyDown.bind(this));
        window.addEventListener('keyup', this.handleKeyUp.bind(this));
    }

    registerEvent(key, event){
        this.events[key] = event;
    }

    handleKeyDown(e){
        const KEY_NAME = this.getKeyName(e.keyCode);

        if(e.repeat || !this.keys.hasOwnProperty(KEY_NAME)) return;

        if(KEY_NAME === 'KEY_UP' || KEY_NAME === 'KEY_DOWN' || KEY_NAME === 'KEY_LEFT' || KEY_NAME === 'KEY_RIGHT'){
            this.lastKey = KEY_NAME;
        }

        this.keys[KEY_NAME].isDown = true;
        this.handleRegisteredEvent(KEY_NAME);
    }

    handleKeyUp(e){
        const KEY_NAME = this.getKeyName(e.keyCode);
        if(e.repeat || !this.keys.hasOwnProperty(KEY_NAME)) return;

        this.keys[KEY_NAME].isDown = false;
    }

    handleRegisteredEvent(key){
        if(this.events.hasOwnProperty(key)){
            this.events[key]();
        }
    }

    isDown(key){
        return this.keys[key].isDown;
    }

    // utility
    getKeyName(keyCode){
        for(let key in this.keys){
            if(this.keys[key].code === keyCode){
                return key;
            }
        }
    }

    update(state){
        state.console.log('KEYBOARD', Object.keys(this.keys).map(key => `${key}: ${this.keys[key].isDown}`));
    }
}

class Viewport {
    constructor(w, h) {
        this.dimensions = {
            width: w,
            height: h
        }

        this.grid = false;
    }

    init(state) {
        this.canvas = document.createElement("canvas");
        this.context = this.canvas.getContext("2d");
        this.canvas.width = this.dimensions.width;
        this.canvas.height = this.dimensions.height;
        this.canvas.style.width = this.dimensions.width + "px";
        this.canvas.style.height = this.dimensions.height + "px";

        this.state = state;

        document.body.appendChild(this.canvas);
        this.bindEvents(state);
    }

    bindEvents(state) {
        state.keyboard.registerEvent('KEY_2', () => {
            this.toggleGrid();
        });
    }

    toggleGrid() {
        this.drawGrid = !this.drawGrid;
    }

    clear() {
        this.context.fillStyle = '#6495ED';
        this.context.clearRect(0, 0, this.dimensions.width, this.dimensions.height);
        this.context.fillRect(0, 0, this.dimensions.width, this.dimensions.height);
    }

    renderGrid(state) {
        if(!this.drawGrid) return;

        this.context.strokeStyle = '#000000';
        this.context.lineWidth = 1;

        for (let x = 0; x < this.dimensions.width; x += state.map.tileSize) {
            this.context.beginPath();
            this.context.moveTo(x, 0);
            this.context.lineTo(x, this.dimensions.height);
            this.context.stroke();

            for (let y = 0; y < this.dimensions.height; y += state.map.tileSize) {
                this.context.beginPath();
                this.context.moveTo(0, y);
                this.context.lineTo(this.dimensions.width, y);
                this.context.stroke();
            }
        }
    }

    render(obj, state) {
        this.context.save();
        this.context.fillStyle = obj.color;
        //this.context.scale(state.camera.zoom, state.camera.zoom);
        this.context.fillRect(
            obj.position.x - state.camera.position.x,
            obj.position.y - state.camera.position.y,
            obj.dimensions.width,
            obj.dimensions.height
        );
        this.context.restore();
    }
}

class Console{
    constructor() {
        this.visible = false;
        this.data = [];
    }

    init(state){
        this.bindEvents(state);
    }

    log(key, message){
        this.data[key] = message;
    }

    toggleVisible(){
        this.visible = !this.visible;
    }

    bindEvents(state){
        state.keyboard.registerEvent('KEY_1', () => {this.toggleVisible()});
    }

    renderHelp(state){
        state.context.fillStyle = 'rgba(0, 0, 0, 0.7)';
        state.context.fillRect(20, state.viewport.dimensions.height - 80, 240, 60);

        state.context.fillStyle = '#ffffff';
        state.context.font = '16px Monospace';
        state.context.fillText('Press 1 to toggle console', 30, state.viewport.dimensions.height - 60);

        state.context.fillStyle = '#ffffff';
        state.context.font = '16px Monospace';
        state.context.fillText('Press 2 to toggle grid', 30, state.viewport.dimensions.height - 30);
    }

    render(state){
        this.renderHelp(state);

        if(!this.visible) return;

        // add a black background with 50% opacity
        state.context.fillStyle = 'rgba(0, 0, 0, 0.7)';
        state.context.fillRect(0, 0, state.viewport.dimensions.width, state.viewport.dimensions.height);

        let line = 0;

        // output each key/value pair - value is an object
        for(let key in this.data){
            state.context.fillStyle = '#00ff00';
            state.context.font = '16px Monospace';
            state.context.fillText(key, 10, 20 + (line * 20));
            line++;

            // Output each key and value - value may be an object or a string
            if(typeof this.data[key] === 'object'){
                for(let k in this.data[key]){
                    state.context.fillStyle = '#ffff00';
                    state.context.font = '12px Monospace';
                    state.context.fillText(k + ': ' + (typeof this.data[key][k] === 'object' ? '' : this.data[key][k]), 20, 20 + (line * 20));
                    line++;

                    // Output each subkey - subkey may be an object or a string
                    state.context.fillStyle = '#ffffff';
                    state.context.font = '12px Monospace';
                    if(typeof this.data[key][k] === 'object'){
                        for(let sk in this.data[key][k]){
                            state.context.fillText(sk + ': ' + this.data[key][k][sk], 30, 20 + (line * 20));
                            line++;
                        }
                    }
                }
            }else{
                state.context.fillStyle = '#ffff00';
                state.context.font = '12px Monospace';
                state.context.fillText(this.data[key], 20, 20 + (line * 20));
                line++;
            }
        }
    }
}

class Map {
    TILES = {
        0: '#DDDDDD',
        1: '#00FF00',
        2: '#FF0000'
    }

    constructor(ts) {
        this.tileSize = ts;
        this.map = [
            [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
            [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],

        ]

        this.dimensions = {
            width: this.map[0].length * this.tileSize,
            height: this.map.length * this.tileSize
        }
    }

    getMapPosition(x, y) {
        return {
            x: Math.floor(x / this.tileSize),
            y: Math.floor(y / this.tileSize)
        }
    }

    getTile(x, y) {
        return this.map[y][x];
    }

    isTileWalkable(x, y) {
        const mapPosition = this.getMapPosition(x, y);

        return this.getTile(mapPosition.x, mapPosition.y) !== 0;
    }

    update(state) {
        // log this except map
        state.console.log('MAP', Object.keys(this).reduce((object, key) => {
            if (key !== 'map' && key !== 'TILES') {
                object[key] = this[key];
            }
            return object;
        }, {}));
    }

    render(state) {
        this.map.forEach((row, y) => {
            row.forEach((tile, x) => {
                state.viewport.render({
                    position: {
                        x: x * this.tileSize,
                        y: y * this.tileSize
                    },
                    dimensions: {
                        width: this.tileSize,
                        height: this.tileSize
                    },
                    color: this.TILES[tile]
                }, state);
            });
        });
    }
}

class Performance {
    constructor(ups){
        this.updateTimeStep = 1000 / ups;
        this.delta = 0;
        this.lastUpdateTimeMs = 0;
        this.updatesPerSecond = 0;
        this.framesPerSecond = 0;

        this.lastUpdateTimestamp = 0;
        this.lastFrameTimestamp = 0;

        this.targetUps = ups;

        this.updateCount = 0;
        this.frameCount = 0;
    }

    updateUps(state){
        this.updateCount++;

        if(performance.now() > this.lastUpdateTimestamp + 1000){
            this.updatesPerSecond = this.updateCount;
            this.updateCount = 0;
            this.lastUpdateTimestamp = performance.now();
        }
    }

    updateFps(state){
        this.frameCount++;

        if(performance.now() > this.lastFrameTimestamp + 1000){
            this.framesPerSecond = this.frameCount;
            this.frameCount = 0;
            this.lastFrameTimestamp = performance.now();
        }

        state.console.log('PERFORMANCE', {
            ups: this.updatesPerSecond,
            fps: this.framesPerSecond,
            delta: this.delta,
        });
    }
}

let engine = new Engine();
*{
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

html, body{
    width: 100%;
    height: 100%;
    background-color: black;
    overflow: hidden;
}

canvas{
    display: block;
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    margin: auto;
    z-index: 0;
}
<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Pokégame</title>
    <link rel="stylesheet" href="reset.css">
</head>
<body>
</body>
</html>

Upvotes: 3

Related Questions