591 lines
16 KiB
JavaScript
591 lines
16 KiB
JavaScript
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();
|
|
|
|
// 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.playerRenderer_.draw(gfx, this.resources_.sprites, this.player_);
|
|
// 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 atlantis = document.getElementById('atlantis');
|
|
const ghost = document.getElementById('ghost');
|
|
const cats = document.getElementById('cats');
|
|
const ts = 16;
|
|
this.sprites = {
|
|
'ground0': new Sprite(atlantis, 2 * ts, 1 * ts, 16, 16),
|
|
'ground1': new Sprite(atlantis, 3 * ts, 1 * ts, 16, 16),
|
|
'ground2': new Sprite(atlantis, 4 * ts, 1 * ts, 16, 16),
|
|
'ground3': new Sprite(atlantis, 5 * ts, 1 * ts, 16, 16),
|
|
'ground4': new Sprite(atlantis, 6 * ts, 1 * ts, 16, 16),
|
|
'ground5': new Sprite(atlantis, 7 * ts, 1 * ts, 16, 16),
|
|
'ground6': new Sprite(atlantis, 8 * ts, 1 * ts, 16, 16),
|
|
'rock0': new Sprite(atlantis, 1 * ts, 2 * ts, 16, 16),
|
|
'rock1': new Sprite(atlantis, 2 * ts, 2 * ts, 16, 16),
|
|
'rock2': new Sprite(atlantis, 3 * ts, 2 * ts, 16, 16),
|
|
'anchor0': new Sprite(atlantis, 21 * ts, 1 * ts, 16, 16),
|
|
'seaweed0': new Sprite(atlantis, 20 * ts, 2 * ts, 16, 32),
|
|
'seaweed1': new Sprite(atlantis, 16 * ts, 2 * ts, 16, 32),
|
|
'coral0': new Sprite(atlantis, 15 * ts, 9 * ts, 32, 16),
|
|
'rockpile0': new Sprite(atlantis, 17 * ts, 10 * ts, 32, 32),
|
|
|
|
'ghost': new CharacterSprite(ghost, 0, 0, 26, 36),
|
|
'cat0': new CharacterSprite(cats, 0, 0, 26, 36),
|
|
'cat1': new CharacterSprite(cats, 26 * 3, 0, 26, 36),
|
|
'cat2': new CharacterSprite(cats, 26 * 6, 0, 26, 36),
|
|
'cat3': new CharacterSprite(cats, 26 * 9, 0, 26, 36),
|
|
'cat4': new CharacterSprite(cats, 0, 36 * 4, 26, 36),
|
|
'cat5': new CharacterSprite(cats, 26 * 3, 36 * 4, 26, 36),
|
|
'cat6': new CharacterSprite(cats, 26 * 6, 36 * 4, 26, 36),
|
|
'cat7': new CharacterSprite(cats, 26 * 9, 36 * 4, 26, 36),
|
|
}
|
|
}
|
|
}
|
|
|
|
class Player {
|
|
// TODO: stop hard-coding player bounding box.
|
|
constructor() {
|
|
this.x = (SNES_WIDTH - 26) / 2;
|
|
this.y = (SNES_HEIGHT - 36) / 2;
|
|
this.orientation = Orientation.DOWN;
|
|
this.spriteNames_ = [
|
|
'ghost', 'cat0', 'cat1', 'cat2', 'cat3', 'cat4', 'cat5', 'cat6',
|
|
'cat7'];
|
|
this.spriteNamesIdx_ = 3;
|
|
}
|
|
|
|
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 < -4) {
|
|
this.x = -4;
|
|
}
|
|
}
|
|
|
|
moveRight() {
|
|
this.orientation = Orientation.RIGHT;
|
|
this.x += 2;
|
|
if (this.x > SNES_WIDTH - 21) {
|
|
this.x = SNES_WIDTH - 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 > 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) {
|
|
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.drawSprite(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.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();
|