Reputation: 3578
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.
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);
}
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;
}
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
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