const SNES_WIDTH = 256; const SNES_HEIGHT = 224; const Orientation = { UP: 'up', DOWN: 'down', LEFT: 'left', RIGHT: 'right' } function bound(low, x, high) { return Math.max(low, Math.min(x, high)); } // Representation of the state of the buttons on an SNES controller. (This may // be the result of keyboard or gamepad input, which get mapped to the // standard SNES buttons.) class SnesInput { constructor() { this.reset(); } reset() { this.up = false; this.down = false; this.left = false; this.right = false; this.a = false; this.b = false; this.x = false; this.y = false; this.l = false; this.r = false; this.select = false; this.start = false; } copyFrom(other) { this.up = other.up; this.down = other.down; this.left = other.left; this.right = other.right; this.a = other.a; this.b = other.b; this.x = other.x; this.y = other.y; this.l = other.l; this.r = other.r; this.select = other.select; this.start = other.start; } toString() { let result = ''; if (this.up) { result += '^'; } else if (this.down) { result += 'v'; } else { result += '-'; } if (this.left) { result += '<'; } else if (this.right) { result += '>'; } else { result += '-'; } result += ' '; if (this.a) { result += 'A'; } if (this.b) { result += 'B'; } if (this.x) { result += 'X'; } if (this.y) { result += 'Y'; } if (this.l) { result += 'L'; } if (this.r) { result += 'R'; } if (this.select) { result += 's'; } if (this.start) { result += 'S'; } return result; } } class SnesGamepad { update(input) { const gamepads = navigator.getGamepads(); if (gamepads.length < 1 || !gamepads[0] || !gamepads[0].connected) { input.reset(); return; } this.wasConnected = true; // TODO: have a config screen instead of hard-coding the 8Bitdo SNES30 pad. const gamepad = gamepads[0]; input.up = gamepad.axes[1] < 0; input.down = gamepad.axes[1] > 0; input.left = gamepad.axes[0] < 0; input.right = gamepad.axes[0] > 0; input.a = gamepad.buttons[0].pressed; input.b = gamepad.buttons[1].pressed; input.x = gamepad.buttons[3].pressed; input.y = gamepad.buttons[4].pressed; input.l = gamepad.buttons[6].pressed; input.r = gamepad.buttons[7].pressed; input.select = gamepad.buttons[10].pressed; input.start = gamepad.buttons[11].pressed; } } class InputHandler { constructor() { this.keysPressed = {}; this.gamepad = new SnesGamepad(); window.addEventListener( 'gamepadconnected', (e) => this.gamepadConnected(e)); window.addEventListener( 'gamepaddisconnected', (e) => this.gamepadDisconnected(e)); document.addEventListener('keydown', (e) => this.keyDown(e)); document.addEventListener('keyup', (e) => this.keyUp(e)); } keyDown(e) { this.keysPressed[e.key] = true; } keyUp(e) { this.keysPressed[e.key] = false; } update(input) { this.gamepad.update(input); // Default ZSNES keybindings. See: // http://zsnes-docs.sourceforge.net/html/readme.htm#default_keys input.up |= this.keysPressed['ArrowUp']; input.down |= this.keysPressed['ArrowDown']; input.left |= this.keysPressed['ArrowLeft']; input.right |= this.keysPressed['ArrowRight']; input.start |= this.keysPressed['Enter']; input.select |= this.keysPressed['Shift']; input.a |= this.keysPressed['x']; input.b |= this.keysPressed['z']; input.x |= this.keysPressed['s']; input.y |= this.keysPressed['a']; input.l |= this.keysPressed['d']; input.r |= this.keysPressed['c']; debug(input.toString()); } gamepadConnected(e) { debug('gamepad connected! :)'); console.log('gamepad connected @ index %d: %d buttons, %d axes\n[%s]', e.gamepad.index, e.gamepad.buttons.length, e.gamepad.axes.length, e.gamepad.id); } gamepadDisconnected(e) { debug('gamepad disconnected :('); console.log('gamepad disconnected @ index %d:\n[%s]', e.gamepad.index, e.gamepad.id); } } class Graphics { constructor(canvas) { this.canvas_ = canvas; this.ctx_ = canvas.getContext('2d'); this.ctx_.imageSmoothingEnabled = false; } get width() { return this.canvas_.width; } get height() { return this.canvas_.height; } fill(color) { this.ctx_.fillStyle = color; this.ctx_.beginPath(); this.ctx_.rect(0, 0, this.canvas_.width, this.canvas_.height); this.ctx_.fill(); this.ctx_.closePath(); } circle(x, y, radius, color) { this.ctx_.fillStyle = color; this.ctx_.beginPath(); this.ctx_.arc(x, y, radius, 0, 2 * Math.PI) this.ctx_.fill(); this.ctx_.closePath(); } // TODO: replace with custom sprite-based text rendering. text(string, x, y, size, color) { this.ctx_.fillStyle = color; this.ctx_.font = '' + size + 'px monospace'; this.ctx_.fillText(string, x, y); } drawSprite(sprite, dx, dy) { this.ctx_.drawImage( sprite.image, sprite.ulx, sprite.uly, sprite.width, sprite.height, dx, dy, sprite.width, sprite.height); } } class FpsCounter { constructor() { this.fps = 0; this.frameTimes_ = new Array(60); this.idx_ = 0; } update(timestampMs) { if (this.frameTimes_[this.idx_]) { const timeElapsed = (timestampMs - this.frameTimes_[this.idx_]) / 1000; this.fps = this.frameTimes_.length / timeElapsed; } this.frameTimes_[this.idx_] = timestampMs; this.idx_++; if (this.idx_ == this.frameTimes_.length) { this.idx_ = 0; } } draw(gfx) { const fpsDiv = document.getElementById('fps'); fpsDiv.innerText = 'FPS: ' + Math.round(this.fps); } } class World { constructor() { this.state_ = null; this.fpsCounter_ = new FpsCounter(); this.inputHandler_ = new InputHandler(); this.input_ = new SnesInput(); this.lastInput_ = new SnesInput(); this.player_ = new Player(); this.layer1_ = [ "~~888888888888~~", "~8..,_.--.,.*.8~", "~._.*._.,.--,._~", "~_``_.,_..._..`~", "~,.`.....'...._~", "~.`_'*...._.._.~", "~,'.'_-'...__.`~", "~....,'...*,'..~", "~_..'_......_..~", "~*..``.,.,..,.,~", "~..'._'*'..___.~", "~._'.-....,.`..~", "~2_.*_,'-...`-2~", "~~222222222222~~"]; this.layer2_ = [ " T ", " ", " ", " ", " ", " ", " o ", " c c ", " ", " c c ", " ", " ", " ", " "]; // TODO: move rendering stuff to a separate object. this.resources_ = new Resources(); this.tileRenderer_ = new TileRenderer(); this.playerRenderer_ = new PlayerRenderer(); this.gamepadRenderer_ = new GamepadRenderer(); } update(timestampMs) { this.fpsCounter_.update(timestampMs); // We copy values to avoid allocating new SnesInput objects every frame. // TODO: is this actually worth it? this.lastInput_.copyFrom(this.input_); this.inputHandler_.update(this.input_); if (!this.lastInput_.r && this.input_.r) { this.player_.cycleSprite(); } if (this.input_.left) { this.player_.moveLeft(); } if (this.input_.right) { this.player_.moveRight(); } if (this.input_.up) { this.player_.moveUp(); } if (this.input_.down) { this.player_.moveDown(); } } draw(gfx) { gfx.fill('black'); this.tileRenderer_.draw(gfx, this.resources_.sprites, this.layer1_); this.playerRenderer_.draw(gfx, this.resources_.sprites, this.player_); this.tileRenderer_.draw(gfx, this.resources_.sprites, this.layer2_); // this.gamepadRenderer_.draw(gfx, this.input_); this.fpsCounter_.draw(gfx); } } class Sprite { constructor(image, ulx, uly, width, height) { this.image = image; this.ulx = ulx; this.uly = uly; this.width = width; this.height = height; } } class CharacterSprite { constructor(image, ulx, uly, tileWidth, tileHeight) { // Assumption: a character sprite consists of 4 rows, which include the // character facing down, left, right, up (in that order). Each row has 3 // columns, which can be used for a walking animation. this.down = []; this.left = []; this.right = []; this.up = []; for (let i = 0; i < 3; i++) { const x = ulx + i * tileWidth; this.down.push(new Sprite( image, x, uly, tileWidth, tileHeight)); this.left.push(new Sprite( image, x, uly + tileHeight, tileWidth, tileHeight)); this.right.push(new Sprite( image, x, uly + tileHeight * 2, tileWidth, tileHeight)); this.up.push(new Sprite( image, x, uly + tileHeight * 3, tileWidth, tileHeight)); } } } class Resources { constructor() { const terrain = document.getElementById('terrain'); const inside = document.getElementById('inside'); const outside = document.getElementById('outside'); const water = document.getElementById('water'); const witch = document.getElementById('witch'); const spritesheet = document.getElementById('spritesheet'); const ts = 16; this.sprites = { 'ground0': new Sprite(terrain, 2 * ts, 1 * ts, 16, 16), 'ground1': new Sprite(terrain, 3 * ts, 1 * ts, 16, 16), 'ground2': new Sprite(terrain, 4 * ts, 1 * ts, 16, 16), 'ground3': new Sprite(terrain, 5 * ts, 1 * ts, 16, 16), 'ground4': new Sprite(terrain, 6 * ts, 1 * ts, 16, 16), 'ground5': new Sprite(terrain, 2 * ts, 2 * ts, 16, 16), 'ground6': new Sprite(terrain, 6 * ts, 2 * ts, 16, 16), 'water_ul': new Sprite(water, 1 * ts, 9 * ts, 16, 16), 'water_uc': new Sprite(water, 2 * ts, 9 * ts, 16, 16), 'water_ur': new Sprite(water, 3 * ts, 9 * ts, 16, 16), 'water_ml': new Sprite(water, 1 * ts, 10 * ts, 16, 16), 'water_mc': new Sprite(water, 2 * ts, 10 * ts, 16, 16), 'water_mr': new Sprite(water, 3 * ts, 10 * ts, 16, 16), 'water_ll': new Sprite(water, 1 * ts, 11 * ts, 16, 16), 'water_lc': new Sprite(water, 2 * ts, 11 * ts, 16, 16), 'water_lr': new Sprite(water, 3 * ts, 11 * ts, 16, 16), 'cauldron_blue': new Sprite(witch, 0, 0, 32, 32), 'cauldron_green': new Sprite(witch, 32, 0, 32, 32), 'cup': new Sprite(inside, 41 * ts, 27 * ts, 16, 16), 'teapot': new Sprite(inside, 36 * ts, 25 * ts, 16, 16), 'bigtree': new Sprite(outside, 528, 16, 96, 96), } for (const key in spritesheet_json) { const sprite_data = spritesheet_json[key]; this.sprites[sprite_data['name']] = new CharacterSprite( spritesheet, sprite_data['x'], sprite_data['y'], sprite_data['width'] / 3, sprite_data['height'] / 4); } } } class Player { // TODO: stop hard-coding player bounding box. constructor() { this.x = (SNES_WIDTH - 26) / 2; this.y = (SNES_HEIGHT - 36) / 2 - 28; this.orientation = Orientation.DOWN; this.spriteNames_ = []; for (const name in spritesheet_json) { this.spriteNames_.push(name); } this.spriteNamesIdx_ = 0; } get spriteName() { return this.spriteNames_[this.spriteNamesIdx_]; } cycleSprite() { this.spriteNamesIdx_++; if (this.spriteNamesIdx_ >= this.spriteNames_.length) { this.spriteNamesIdx_ = 0; } } moveLeft() { this.orientation = Orientation.LEFT; this.x -= 2; if (this.x < -2) { this.x = -2; } } moveRight() { this.orientation = Orientation.RIGHT; this.x += 2; if (this.x > SNES_WIDTH - 23) { this.x = SNES_WIDTH - 23; } } moveUp() { this.orientation = Orientation.UP; this.y -= 2; if (this.y < -2) { this.y = -2; } } moveDown() { this.orientation = Orientation.DOWN; this.y += 2; if (this.y > SNES_HEIGHT - 36) { this.y = SNES_HEIGHT - 36; } } } class PlayerRenderer { constructor() { this.frameNum = 0; } draw(gfx, sprites, player) { let spriteIndex = Math.floor((this.frameNum % 40) / 10); if (spriteIndex == 3) { spriteIndex = 1; } const charSprite = sprites[player.spriteName][player.orientation][spriteIndex]; gfx.drawSprite(charSprite, player.x, player.y); this.frameNum++; } } class TileRenderer { draw(gfx, sprites, layer) { const tileSize = 16; // The screen is 14 x 16 tiles by default. const rows = gfx.height / tileSize; const columns = gfx.width / tileSize; const spriteLookup = { '.': sprites.ground0, ',': sprites.ground1, '_': sprites.ground2, '`': sprites.ground3, '-': sprites.ground4, '*': sprites.ground5, "'": sprites.ground6, '~': sprites.water_mc, '2': sprites.water_uc, '8': sprites.water_lc, 'O': sprites.cauldron_blue, 'o': sprites.cauldron_green, 'c': sprites.cup, 't': sprites.teapot, 'T': sprites.bigtree, }; for (let j = 0; j < columns; j++) { for (let i = 0; i < rows; i++) { const dx = tileSize * j; const dy = tileSize * i; const sprite = spriteLookup[layer[i][j]]; if (sprite) { gfx.drawSprite(sprite, dx, dy); } } } } } class GamepadRenderer { draw(gfx, input) { const centerX = gfx.width / 2; const centerY = gfx.height / 2; // Select & Start gfx.circle(centerX + 12, centerY, 8, input.start ? 'cyan' : 'grey'); gfx.circle(centerX - 12, centerY, 8, input.select ? 'cyan' : 'grey'); // Y X B A gfx.circle(centerX + 48, centerY, 8, input.y ? 'cyan' : 'grey'); gfx.circle(centerX + 64, centerY - 16, 8, input.x ? 'cyan' : 'grey'); gfx.circle(centerX + 64, centerY + 16, 8, input.b ? 'cyan' : 'grey'); gfx.circle(centerX + 80, centerY, 8, input.a ? 'cyan' : 'grey'); // dpad gfx.circle(centerX - 48, centerY, 8, input.right ? 'cyan' : 'grey'); gfx.circle(centerX - 64, centerY - 16, 8, input.up ? 'cyan' : 'grey'); gfx.circle(centerX - 64, centerY + 16, 8, input.down ? 'cyan' : 'grey'); gfx.circle(centerX - 80, centerY, 8, input.left ? 'cyan' : 'grey'); // L & R gfx.circle(centerX + 30, centerY - 32, 8, input.r ? 'cyan' : 'grey'); gfx.circle(centerX - 30, centerY - 32, 8, input.l ? 'cyan' : 'grey'); } } function debug(message) { const debugDiv = document.getElementById('debug'); debugDiv.innerText = message; } function loop(world, gfx) { return timestampMs => { world.update(timestampMs); world.draw(gfx); window.requestAnimationFrame(loop(world, gfx)); }; } function setCanvasScale(scale) { const canvas = document.getElementById('canvas'); canvas.style.width = '' + SNES_WIDTH * scale + 'px'; canvas.style.height = '' + SNES_HEIGHT * scale + 'px'; canvas.style.display = ''; debug('set scale to ' + scale + 'x'); } function setAutoCanvasScale() { const widthAspect = Math.floor(window.innerWidth / SNES_WIDTH); const heightAspect = Math.floor(window.innerHeight / SNES_HEIGHT); const scale = bound(1, Math.min(widthAspect, heightAspect), 8); setCanvasScale(scale); } function init() { const world = new World(); const gfx = new Graphics(document.getElementById('canvas')); document.getElementById('1x').onclick = () => setCanvasScale(1); document.getElementById('2x').onclick = () => setCanvasScale(2); document.getElementById('3x').onclick = () => setCanvasScale(3); document.getElementById('4x').onclick = () => setCanvasScale(4); document.getElementById('5x').onclick = () => setCanvasScale(5); document.getElementById('6x').onclick = () => setCanvasScale(6); document.getElementById('7x').onclick = () => setCanvasScale(7); document.getElementById('8x').onclick = () => setCanvasScale(8); setAutoCanvasScale(); window.requestAnimationFrame(loop(world, gfx)); } init();