const Orientation = { UP: 'up', DOWN: 'down', LEFT: 'left', RIGHT: 'right' } // TODO: make these not global. let upArrowPressed = false; let downArrowPressed = false; let leftArrowPressed = false; let rightArrowPressed = false; class Input { constructor() { 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; window.addEventListener('gamepadconnected', this.gamepadConnected); window.addEventListener('gamepaddisconnected', this.gamepadDisconnected); document.addEventListener('keydown', this.keyDown); document.addEventListener('keyup', this.keyUp); } keyDown(e) { if (e.key == 'ArrowUp' || e.key == 'w') { upArrowPressed = true; } if (e.key == 'ArrowDown' || e.key == 's') { downArrowPressed = true; } if (e.key == 'ArrowLeft' || e.key == 'a') { leftArrowPressed = true; } if (e.key == 'ArrowRight' || e.key == 'd') { rightArrowPressed = true; } } keyUp(e) { if (e.key == 'ArrowUp' || e.key == 'w') { upArrowPressed = false; } if (e.key == 'ArrowDown' || e.key == 's') { downArrowPressed = false; } if (e.key == 'ArrowLeft' || e.key == 'a') { leftArrowPressed = false; } if (e.key == 'ArrowRight' || e.key == 'd') { rightArrowPressed = false; } } update() { // TODO: have a config screen instead of hard-coding the 8Bitdo SNES30 pad. // TODO: handle connects / disconnects more correctly. this.up = upArrowPressed; this.down = downArrowPressed; this.left = leftArrowPressed; this.right = rightArrowPressed; const gamepad = navigator.getGamepads()[0]; if (gamepad == null || !gamepad.connected || gamepad.axes.length < 2 || gamepad.buttons.length < 12) { return; } this.up |= gamepad.axes[1] < 0; this.down |= gamepad.axes[1] > 0; this.left |= gamepad.axes[0] < 0; this.right |= gamepad.axes[0] > 0; this.a = gamepad.buttons[0].pressed; this.b = gamepad.buttons[1].pressed; this.x = gamepad.buttons[3].pressed; this.y = gamepad.buttons[4].pressed; this.l = gamepad.buttons[6].pressed; this.r = gamepad.buttons[7].pressed; this.select = gamepad.buttons[10].pressed; this.start = gamepad.buttons[11].pressed; debug(this.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); } 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 Graphics { constructor(canvas) { this.canvas_ = canvas; this.ctx_ = canvas.getContext('2d'); } 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_.imageSmoothingEnabled = false; this.ctx_.fillStyle = color; this.ctx_.font = '' + size + 'px monospace'; this.ctx_.fillText(string, x, y); } drawImage(image, dx, dy) { const src = image[0]; const sx = image[1]; const sy = image[2]; const width = image[3]; const height = image[4]; this.ctx_.drawImage( src, sx, sy, width, height, dx, dy, width, 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.input_ = new Input(); this.player_ = new Player(); // 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); this.input_.update(); 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.playerRenderer_.draw(gfx, this.resources_.sprites, this.player_); // this.gamepadRenderer_.draw(gfx, this.input_); this.fpsCounter_.draw(gfx); } } class Resources { constructor() { const atlantis = document.getElementById('atlantis'); const ghost = document.getElementById('ghost'); const ts = 16; this.sprites = { 'ground0': [atlantis, 2 * ts, 1 * ts, 16, 16], 'ground1': [atlantis, 3 * ts, 1 * ts, 16, 16], 'ground2': [atlantis, 4 * ts, 1 * ts, 16, 16], 'ground3': [atlantis, 5 * ts, 1 * ts, 16, 16], 'ground4': [atlantis, 6 * ts, 1 * ts, 16, 16], 'ground5': [atlantis, 7 * ts, 1 * ts, 16, 16], 'ground6': [atlantis, 8 * ts, 1 * ts, 16, 16], 'rock0': [atlantis, 1 * ts, 2 * ts, 16, 16], 'rock1': [atlantis, 2 * ts, 2 * ts, 16, 16], 'rock2': [atlantis, 3 * ts, 2 * ts, 16, 16], 'anchor0': [atlantis, 21 * ts, 1 * ts, 16, 16], 'seaweed0': [atlantis, 20 * ts, 2 * ts, 16, 32], 'seaweed1': [atlantis, 16 * ts, 2 * ts, 16, 32], 'coral0': [atlantis, 15 * ts, 9 * ts, 32, 16], 'rockpile0': [atlantis, 17 * ts, 10 * ts, 32, 32], 'ghostdown0': [ghost, 0, 0, 24, 36], 'ghostdown1': [ghost, 26, 0, 24, 36], 'ghostdown2': [ghost, 52, 0, 24, 36], 'ghostleft0': [ghost, 0, 36, 24, 36], 'ghostleft1': [ghost, 26, 36, 24, 36], 'ghostleft2': [ghost, 52, 36, 24, 36], 'ghostright0': [ghost, 0, 72, 24, 36], 'ghostright1': [ghost, 26, 72, 24, 36], 'ghostright2': [ghost, 52, 72, 24, 36], 'ghostup0': [ghost, 0, 108, 24, 36], 'ghostup1': [ghost, 26, 108, 24, 36], 'ghostup2': [ghost, 52, 108, 24, 36], } } } class Player { constructor() { this.x = (256 - 26) / 2; this.y = (224 - 36) / 2; this.orientation = Orientation.DOWN; } moveLeft() { this.orientation = Orientation.LEFT; this.x -= 2; if (this.x < -4) { this.x = -4; } } moveRight() { this.orientation = Orientation.RIGHT; this.x += 2; if (this.x > 256 - 21) { this.x = 256 - 21; } } moveUp() { this.orientation = Orientation.UP; this.y -= 2; if (this.y < -7) { this.y = -7; } } moveDown() { this.orientation = Orientation.DOWN; this.y += 2; if (this.y > 224 - 36) { this.y = 224 - 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 spriteName = 'ghost' + player.orientation + spriteIndex; gfx.drawImage(sprites[spriteName], player.x, player.y); this.frameNum++; } } class TileRenderer { draw(gfx, sprites) { const tileSize = 16; const rows = gfx.height / tileSize; const columns = gfx.width / tileSize; const layer1 = ["-,*-...*'.,-_'`o", "_..'-_**,',_.'oo", "-*-''_-'o,0O_```", "o`0_._,*O'`--'-'", "`0O-_'',`o*o*`-,", "*,`'---o'O'_*''-", "'-.**.'_'`.,'-.'", ".O'``*``'`*,,_o`", "_*_''*O'`_OO-_'o", "0`0,*-,`_*'`O'*.", ".o'-*.*_',`,,`.'", "`o`O',.`OO,*-'**", "-..*'-''',*'.'.O", "*-_'-0.--__O`O`_", "*-_,O_'*'`*'_._.", "-.*,`OO'_`'*-0-O"]; const layer2 = [" ", " ", " iil ", " ", " A ", " ", " ", " ", " ", " i ", " l ", " ", " c R ", " c "]; const spriteLookup = { '.': sprites.ground0, ',': sprites.ground1, '_': sprites.ground2, '`': sprites.ground3, '-': sprites.ground4, '*': sprites.ground5, "'": sprites.ground6, 'o': sprites.rock0, 'O': sprites.rock1, '0': sprites.rock2, 'A': sprites.anchor0, 'i': sprites.seaweed0, 'l': sprites.seaweed1, 'c': sprites.coral0, 'R': sprites.rockpile0, }; 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[layer1[i][j]]; if (sprite) { gfx.drawImage(sprite, dx, dy); } } } 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[layer2[i][j]]; if (sprite) { gfx.drawImage(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 snesWidth = 256; const snesHeight = 224; const canvas = document.getElementById('canvas'); canvas.style.width = '' + snesWidth * scale + 'px'; canvas.style.height = '' + snesHeight * scale + 'px'; } 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); setCanvasScale(3); window.requestAnimationFrame(loop(world, gfx)); debug('initialized!'); } init();