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.

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