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.

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