SNES-like engine in JavaScript.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

590 lines
16 KiB

  1. const SNES_WIDTH = 256;
  2. const SNES_HEIGHT = 224;
  3. const Orientation = {
  4. UP: 'up',
  5. DOWN: 'down',
  6. LEFT: 'left',
  7. RIGHT: 'right'
  8. }
  9. function bound(low, x, high) {
  10. return Math.max(low, Math.min(x, high));
  11. }
  12. // Representation of the state of the buttons on an SNES controller. (This may
  13. // be the result of keyboard or gamepad input, which get mapped to the
  14. // standard SNES buttons.)
  15. class SnesInput {
  16. constructor() {
  17. this.reset();
  18. }
  19. reset() {
  20. this.up = false;
  21. this.down = false;
  22. this.left = false;
  23. this.right = false;
  24. this.a = false;
  25. this.b = false;
  26. this.x = false;
  27. this.y = false;
  28. this.l = false;
  29. this.r = false;
  30. this.select = false;
  31. this.start = false;
  32. }
  33. copyFrom(other) {
  34. this.up = other.up;
  35. this.down = other.down;
  36. this.left = other.left;
  37. this.right = other.right;
  38. this.a = other.a;
  39. this.b = other.b;
  40. this.x = other.x;
  41. this.y = other.y;
  42. this.l = other.l;
  43. this.r = other.r;
  44. this.select = other.select;
  45. this.start = other.start;
  46. }
  47. toString() {
  48. let result = '';
  49. if (this.up) {
  50. result += '^';
  51. } else if (this.down) {
  52. result += 'v';
  53. } else {
  54. result += '-';
  55. }
  56. if (this.left) {
  57. result += '<';
  58. } else if (this.right) {
  59. result += '>';
  60. } else {
  61. result += '-';
  62. }
  63. result += ' ';
  64. if (this.a) {
  65. result += 'A';
  66. }
  67. if (this.b) {
  68. result += 'B';
  69. }
  70. if (this.x) {
  71. result += 'X';
  72. }
  73. if (this.y) {
  74. result += 'Y';
  75. }
  76. if (this.l) {
  77. result += 'L';
  78. }
  79. if (this.r) {
  80. result += 'R';
  81. }
  82. if (this.select) {
  83. result += 's';
  84. }
  85. if (this.start) {
  86. result += 'S';
  87. }
  88. return result;
  89. }
  90. }
  91. class SnesGamepad {
  92. update(input) {
  93. const gamepads = navigator.getGamepads();
  94. if (gamepads.length < 1 || !gamepads[0] || !gamepads[0].connected) {
  95. input.reset();
  96. return;
  97. }
  98. this.wasConnected = true;
  99. // TODO: have a config screen instead of hard-coding the 8Bitdo SNES30 pad.
  100. const gamepad = gamepads[0];
  101. input.up = gamepad.axes[1] < 0;
  102. input.down = gamepad.axes[1] > 0;
  103. input.left = gamepad.axes[0] < 0;
  104. input.right = gamepad.axes[0] > 0;
  105. input.a = gamepad.buttons[0].pressed;
  106. input.b = gamepad.buttons[1].pressed;
  107. input.x = gamepad.buttons[3].pressed;
  108. input.y = gamepad.buttons[4].pressed;
  109. input.l = gamepad.buttons[6].pressed;
  110. input.r = gamepad.buttons[7].pressed;
  111. input.select = gamepad.buttons[10].pressed;
  112. input.start = gamepad.buttons[11].pressed;
  113. }
  114. }
  115. class InputHandler {
  116. constructor() {
  117. this.keysPressed = {};
  118. this.gamepad = new SnesGamepad();
  119. window.addEventListener(
  120. 'gamepadconnected', (e) => this.gamepadConnected(e));
  121. window.addEventListener(
  122. 'gamepaddisconnected', (e) => this.gamepadDisconnected(e));
  123. document.addEventListener('keydown', (e) => this.keyDown(e));
  124. document.addEventListener('keyup', (e) => this.keyUp(e));
  125. }
  126. keyDown(e) {
  127. this.keysPressed[e.key] = true;
  128. }
  129. keyUp(e) {
  130. this.keysPressed[e.key] = false;
  131. }
  132. update(input) {
  133. this.gamepad.update(input);
  134. // Default ZSNES keybindings. See:
  135. // http://zsnes-docs.sourceforge.net/html/readme.htm#default_keys
  136. input.up |= this.keysPressed['ArrowUp'];
  137. input.down |= this.keysPressed['ArrowDown'];
  138. input.left |= this.keysPressed['ArrowLeft'];
  139. input.right |= this.keysPressed['ArrowRight'];
  140. input.start |= this.keysPressed['Enter'];
  141. input.select |= this.keysPressed['Shift'];
  142. input.a |= this.keysPressed['x'];
  143. input.b |= this.keysPressed['z'];
  144. input.x |= this.keysPressed['s'];
  145. input.y |= this.keysPressed['a'];
  146. input.l |= this.keysPressed['d'];
  147. input.r |= this.keysPressed['c'];
  148. debug(input.toString());
  149. }
  150. gamepadConnected(e) {
  151. debug('gamepad connected! :)');
  152. console.log('gamepad connected @ index %d: %d buttons, %d axes\n[%s]',
  153. e.gamepad.index, e.gamepad.buttons.length, e.gamepad.axes.length,
  154. e.gamepad.id);
  155. }
  156. gamepadDisconnected(e) {
  157. debug('gamepad disconnected :(');
  158. console.log('gamepad disconnected @ index %d:\n[%s]', e.gamepad.index,
  159. e.gamepad.id);
  160. }
  161. }
  162. class Graphics {
  163. constructor(canvas) {
  164. this.canvas_ = canvas;
  165. this.ctx_ = canvas.getContext('2d');
  166. this.ctx_.imageSmoothingEnabled = false;
  167. }
  168. get width() {
  169. return this.canvas_.width;
  170. }
  171. get height() {
  172. return this.canvas_.height;
  173. }
  174. fill(color) {
  175. this.ctx_.fillStyle = color;
  176. this.ctx_.beginPath();
  177. this.ctx_.rect(0, 0, this.canvas_.width, this.canvas_.height);
  178. this.ctx_.fill();
  179. this.ctx_.closePath();
  180. }
  181. circle(x, y, radius, color) {
  182. this.ctx_.fillStyle = color;
  183. this.ctx_.beginPath();
  184. this.ctx_.arc(x, y, radius, 0, 2 * Math.PI)
  185. this.ctx_.fill();
  186. this.ctx_.closePath();
  187. }
  188. // TODO: replace with custom sprite-based text rendering.
  189. text(string, x, y, size, color) {
  190. this.ctx_.fillStyle = color;
  191. this.ctx_.font = '' + size + 'px monospace';
  192. this.ctx_.fillText(string, x, y);
  193. }
  194. drawSprite(sprite, dx, dy) {
  195. this.ctx_.drawImage(
  196. sprite.image,
  197. sprite.ulx, sprite.uly,
  198. sprite.width, sprite.height,
  199. dx, dy,
  200. sprite.width, sprite.height);
  201. }
  202. }
  203. class FpsCounter {
  204. constructor() {
  205. this.fps = 0;
  206. this.frameTimes_ = new Array(60);
  207. this.idx_ = 0;
  208. }
  209. update(timestampMs) {
  210. if (this.frameTimes_[this.idx_]) {
  211. const timeElapsed = (timestampMs - this.frameTimes_[this.idx_]) / 1000;
  212. this.fps = this.frameTimes_.length / timeElapsed;
  213. }
  214. this.frameTimes_[this.idx_] = timestampMs;
  215. this.idx_++;
  216. if (this.idx_ == this.frameTimes_.length) {
  217. this.idx_ = 0;
  218. }
  219. }
  220. draw(gfx) {
  221. const fpsDiv = document.getElementById('fps');
  222. fpsDiv.innerText = 'FPS: ' + Math.round(this.fps);
  223. }
  224. }
  225. class World {
  226. constructor() {
  227. this.state_ = null;
  228. this.fpsCounter_ = new FpsCounter();
  229. this.inputHandler_ = new InputHandler();
  230. this.input_ = new SnesInput();
  231. this.lastInput_ = new SnesInput();
  232. this.player_ = new Player();
  233. // TODO: move rendering stuff to a separate object.
  234. this.resources_ = new Resources();
  235. this.tileRenderer_ = new TileRenderer();
  236. this.playerRenderer_ = new PlayerRenderer();
  237. this.gamepadRenderer_ = new GamepadRenderer();
  238. }
  239. update(timestampMs) {
  240. this.fpsCounter_.update(timestampMs);
  241. // We copy values to avoid allocating new SnesInput objects every frame.
  242. // TODO: is this actually worth it?
  243. this.lastInput_.copyFrom(this.input_);
  244. this.inputHandler_.update(this.input_);
  245. if (!this.lastInput_.r && this.input_.r) {
  246. this.player_.cycleSprite();
  247. }
  248. if (this.input_.left) {
  249. this.player_.moveLeft();
  250. }
  251. if (this.input_.right) {
  252. this.player_.moveRight();
  253. }
  254. if (this.input_.up) {
  255. this.player_.moveUp();
  256. }
  257. if (this.input_.down) {
  258. this.player_.moveDown();
  259. }
  260. }
  261. draw(gfx) {
  262. gfx.fill('black');
  263. this.tileRenderer_.draw(gfx, this.resources_.sprites);
  264. this.playerRenderer_.draw(gfx, this.resources_.sprites, this.player_);
  265. // this.gamepadRenderer_.draw(gfx, this.input_);
  266. this.fpsCounter_.draw(gfx);
  267. }
  268. }
  269. class Sprite {
  270. constructor(image, ulx, uly, width, height) {
  271. this.image = image;
  272. this.ulx = ulx;
  273. this.uly = uly;
  274. this.width = width;
  275. this.height = height;
  276. }
  277. }
  278. class CharacterSprite {
  279. constructor(image, ulx, uly, tileWidth, tileHeight) {
  280. // Assumption: a character sprite consists of 4 rows, which include the
  281. // character facing down, left, right, up (in that order). Each row has 3
  282. // columns, which can be used for a walking animation.
  283. this.down = [];
  284. this.left = [];
  285. this.right = [];
  286. this.up = [];
  287. for (let i = 0; i < 3; i++) {
  288. const x = ulx + i * tileWidth;
  289. this.down.push(new Sprite(
  290. image, x, uly, tileWidth, tileHeight));
  291. this.left.push(new Sprite(
  292. image, x, uly + tileHeight, tileWidth, tileHeight));
  293. this.right.push(new Sprite(
  294. image, x, uly + tileHeight * 2, tileWidth, tileHeight));
  295. this.up.push(new Sprite(
  296. image, x, uly + tileHeight * 3, tileWidth, tileHeight));
  297. }
  298. }
  299. }
  300. class Resources {
  301. constructor() {
  302. const atlantis = document.getElementById('atlantis');
  303. const ghost = document.getElementById('ghost');
  304. const cats = document.getElementById('cats');
  305. const ts = 16;
  306. this.sprites = {
  307. 'ground0': new Sprite(atlantis, 2 * ts, 1 * ts, 16, 16),
  308. 'ground1': new Sprite(atlantis, 3 * ts, 1 * ts, 16, 16),
  309. 'ground2': new Sprite(atlantis, 4 * ts, 1 * ts, 16, 16),
  310. 'ground3': new Sprite(atlantis, 5 * ts, 1 * ts, 16, 16),
  311. 'ground4': new Sprite(atlantis, 6 * ts, 1 * ts, 16, 16),
  312. 'ground5': new Sprite(atlantis, 7 * ts, 1 * ts, 16, 16),
  313. 'ground6': new Sprite(atlantis, 8 * ts, 1 * ts, 16, 16),
  314. 'rock0': new Sprite(atlantis, 1 * ts, 2 * ts, 16, 16),
  315. 'rock1': new Sprite(atlantis, 2 * ts, 2 * ts, 16, 16),
  316. 'rock2': new Sprite(atlantis, 3 * ts, 2 * ts, 16, 16),
  317. 'anchor0': new Sprite(atlantis, 21 * ts, 1 * ts, 16, 16),
  318. 'seaweed0': new Sprite(atlantis, 20 * ts, 2 * ts, 16, 32),
  319. 'seaweed1': new Sprite(atlantis, 16 * ts, 2 * ts, 16, 32),
  320. 'coral0': new Sprite(atlantis, 15 * ts, 9 * ts, 32, 16),
  321. 'rockpile0': new Sprite(atlantis, 17 * ts, 10 * ts, 32, 32),
  322. 'ghost': new CharacterSprite(ghost, 0, 0, 26, 36),
  323. 'cat0': new CharacterSprite(cats, 0, 0, 26, 36),
  324. 'cat1': new CharacterSprite(cats, 26 * 3, 0, 26, 36),
  325. 'cat2': new CharacterSprite(cats, 26 * 6, 0, 26, 36),
  326. 'cat3': new CharacterSprite(cats, 26 * 9, 0, 26, 36),
  327. 'cat4': new CharacterSprite(cats, 0, 36 * 4, 26, 36),
  328. 'cat5': new CharacterSprite(cats, 26 * 3, 36 * 4, 26, 36),
  329. 'cat6': new CharacterSprite(cats, 26 * 6, 36 * 4, 26, 36),
  330. 'cat7': new CharacterSprite(cats, 26 * 9, 36 * 4, 26, 36),
  331. }
  332. }
  333. }
  334. class Player {
  335. // TODO: stop hard-coding player bounding box.
  336. constructor() {
  337. this.x = (SNES_WIDTH - 26) / 2;
  338. this.y = (SNES_HEIGHT - 36) / 2;
  339. this.orientation = Orientation.DOWN;
  340. this.spriteNames_ = [
  341. 'ghost', 'cat0', 'cat1', 'cat2', 'cat3', 'cat4', 'cat5', 'cat6',
  342. 'cat7'];
  343. this.spriteNamesIdx_ = 3;
  344. }
  345. get spriteName() {
  346. return this.spriteNames_[this.spriteNamesIdx_];
  347. }
  348. cycleSprite() {
  349. this.spriteNamesIdx_++;
  350. if (this.spriteNamesIdx_ >= this.spriteNames_.length) {
  351. this.spriteNamesIdx_ = 0;
  352. }
  353. }
  354. moveLeft() {
  355. this.orientation = Orientation.LEFT;
  356. this.x -= 2;
  357. if (this.x < -4) {
  358. this.x = -4;
  359. }
  360. }
  361. moveRight() {
  362. this.orientation = Orientation.RIGHT;
  363. this.x += 2;
  364. if (this.x > SNES_WIDTH - 21) {
  365. this.x = SNES_WIDTH - 21;
  366. }
  367. }
  368. moveUp() {
  369. this.orientation = Orientation.UP;
  370. this.y -= 2;
  371. if (this.y < -7) {
  372. this.y = -7;
  373. }
  374. }
  375. moveDown() {
  376. this.orientation = Orientation.DOWN;
  377. this.y += 2;
  378. if (this.y > SNES_HEIGHT - 36) {
  379. this.y = SNES_HEIGHT - 36;
  380. }
  381. }
  382. }
  383. class PlayerRenderer {
  384. constructor() {
  385. this.frameNum = 0;
  386. }
  387. draw(gfx, sprites, player) {
  388. let spriteIndex = Math.floor((this.frameNum % 40) / 10);
  389. if (spriteIndex == 3) { spriteIndex = 1; }
  390. const charSprite = sprites[player.spriteName][player.orientation][spriteIndex];
  391. gfx.drawSprite(charSprite, player.x, player.y);
  392. this.frameNum++;
  393. }
  394. }
  395. class TileRenderer {
  396. draw(gfx, sprites) {
  397. const tileSize = 16;
  398. const rows = gfx.height / tileSize;
  399. const columns = gfx.width / tileSize;
  400. const layer1 = ["-,*-...*'.,-_'`o",
  401. "_..'-_**,',_.'oo",
  402. "-*-''_-'o,0O_```",
  403. "o`0_._,*O'`--'-'",
  404. "`0O-_'',`o*o*`-,",
  405. "*,`'---o'O'_*''-",
  406. "'-.**.'_'`.,'-.'",
  407. ".O'``*``'`*,,_o`",
  408. "_*_''*O'`_OO-_'o",
  409. "0`0,*-,`_*'`O'*.",
  410. ".o'-*.*_',`,,`.'",
  411. "`o`O',.`OO,*-'**",
  412. "-..*'-''',*'.'.O",
  413. "*-_'-0.--__O`O`_",
  414. "*-_,O_'*'`*'_._.",
  415. "-.*,`OO'_`'*-0-O"];
  416. const layer2 = [" ",
  417. " ",
  418. " iil ",
  419. " ",
  420. " A ",
  421. " ",
  422. " ",
  423. " ",
  424. " ",
  425. " i ",
  426. " l ",
  427. " ",
  428. " c R ",
  429. " c "];
  430. const spriteLookup = {
  431. '.': sprites.ground0,
  432. ',': sprites.ground1,
  433. '_': sprites.ground2,
  434. '`': sprites.ground3,
  435. '-': sprites.ground4,
  436. '*': sprites.ground5,
  437. "'": sprites.ground6,
  438. 'o': sprites.rock0,
  439. 'O': sprites.rock1,
  440. '0': sprites.rock2,
  441. 'A': sprites.anchor0,
  442. 'i': sprites.seaweed0,
  443. 'l': sprites.seaweed1,
  444. 'c': sprites.coral0,
  445. 'R': sprites.rockpile0,
  446. };
  447. for (let j = 0; j < columns; j++) {
  448. for (let i = 0; i < rows; i++) {
  449. const dx = tileSize * j;
  450. const dy = tileSize * i;
  451. const sprite = spriteLookup[layer1[i][j]];
  452. if (sprite) {
  453. gfx.drawSprite(sprite, dx, dy);
  454. }
  455. }
  456. }
  457. for (let j = 0; j < columns; j++) {
  458. for (let i = 0; i < rows; i++) {
  459. const dx = tileSize * j;
  460. const dy = tileSize * i;
  461. const sprite = spriteLookup[layer2[i][j]];
  462. if (sprite) {
  463. gfx.drawSprite(sprite, dx, dy);
  464. }
  465. }
  466. }
  467. }
  468. }
  469. class GamepadRenderer {
  470. draw(gfx, input) {
  471. const centerX = gfx.width / 2;
  472. const centerY = gfx.height / 2;
  473. // Select & Start
  474. gfx.circle(centerX + 12, centerY, 8, input.start ? 'cyan' : 'grey');
  475. gfx.circle(centerX - 12, centerY, 8, input.select ? 'cyan' : 'grey');
  476. // Y X B A
  477. gfx.circle(centerX + 48, centerY, 8, input.y ? 'cyan' : 'grey');
  478. gfx.circle(centerX + 64, centerY - 16, 8, input.x ? 'cyan' : 'grey');
  479. gfx.circle(centerX + 64, centerY + 16, 8, input.b ? 'cyan' : 'grey');
  480. gfx.circle(centerX + 80, centerY, 8, input.a ? 'cyan' : 'grey');
  481. // dpad
  482. gfx.circle(centerX - 48, centerY, 8, input.right ? 'cyan' : 'grey');
  483. gfx.circle(centerX - 64, centerY - 16, 8, input.up ? 'cyan' : 'grey');
  484. gfx.circle(centerX - 64, centerY + 16, 8, input.down ? 'cyan' : 'grey');
  485. gfx.circle(centerX - 80, centerY, 8, input.left ? 'cyan' : 'grey');
  486. // L & R
  487. gfx.circle(centerX + 30, centerY - 32, 8, input.r ? 'cyan' : 'grey');
  488. gfx.circle(centerX - 30, centerY - 32, 8, input.l ? 'cyan' : 'grey');
  489. }
  490. }
  491. function debug(message) {
  492. const debugDiv = document.getElementById('debug');
  493. debugDiv.innerText = message;
  494. }
  495. function loop(world, gfx) {
  496. return timestampMs => {
  497. world.update(timestampMs);
  498. world.draw(gfx);
  499. window.requestAnimationFrame(loop(world, gfx));
  500. };
  501. }
  502. function setCanvasScale(scale) {
  503. const canvas = document.getElementById('canvas');
  504. canvas.style.width = '' + SNES_WIDTH * scale + 'px';
  505. canvas.style.height = '' + SNES_HEIGHT * scale + 'px';
  506. canvas.style.display = '';
  507. debug('set scale to ' + scale + 'x');
  508. }
  509. function setAutoCanvasScale() {
  510. const widthAspect = Math.floor(window.innerWidth / SNES_WIDTH);
  511. const heightAspect = Math.floor(window.innerHeight / SNES_HEIGHT);
  512. const scale = bound(1, Math.min(widthAspect, heightAspect), 8);
  513. setCanvasScale(scale);
  514. }
  515. function init() {
  516. const world = new World();
  517. const gfx = new Graphics(document.getElementById('canvas'));
  518. document.getElementById('1x').onclick = () => setCanvasScale(1);
  519. document.getElementById('2x').onclick = () => setCanvasScale(2);
  520. document.getElementById('3x').onclick = () => setCanvasScale(3);
  521. document.getElementById('4x').onclick = () => setCanvasScale(4);
  522. document.getElementById('5x').onclick = () => setCanvasScale(5);
  523. document.getElementById('6x').onclick = () => setCanvasScale(6);
  524. document.getElementById('7x').onclick = () => setCanvasScale(7);
  525. document.getElementById('8x').onclick = () => setCanvasScale(8);
  526. setAutoCanvasScale();
  527. window.requestAnimationFrame(loop(world, gfx));
  528. }
  529. init();