snej/main.js

591 lines
16 KiB
JavaScript
Raw Normal View History

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));
}
2019-09-25 20:17:44 +00:00
// 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 {
2019-09-24 18:57:24 +00:00
constructor() {
this.reset();
}
reset() {
2019-09-24 18:57:24 +00:00
this.up = false;
this.down = false;
2019-09-25 00:16:01 +00:00
this.left = false;
this.right = false;
2019-09-24 18:57:24 +00:00
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;
2019-09-24 18:57:24 +00:00
}
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);
}
}
2019-09-24 18:57:24 +00:00
class Graphics {
constructor(canvas) {
this.canvas_ = canvas;
this.ctx_ = canvas.getContext('2d');
this.ctx_.imageSmoothingEnabled = false;
2019-09-24 18:57:24 +00:00
}
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);
}
2019-09-24 18:57:24 +00:00
}
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);
2019-09-24 18:57:24 +00:00
}
}
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();
2019-09-24 18:57:24 +00:00
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();
}
2019-09-24 18:57:24 +00:00
}
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_);
2019-09-24 18:57:24 +00:00
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);
}
}
}
}
}
2019-09-24 18:57:24 +00:00
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);
2019-09-24 18:57:24 +00:00
}
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();
2019-09-24 18:57:24 +00:00
window.requestAnimationFrame(loop(world, gfx));
}
init();