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.

588 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 spritesheet = document.getElementById('spritesheet');
  304. const ts = 16;
  305. this.sprites = {
  306. 'ground0': new Sprite(atlantis, 2 * ts, 1 * ts, 16, 16),
  307. 'ground1': new Sprite(atlantis, 3 * ts, 1 * ts, 16, 16),
  308. 'ground2': new Sprite(atlantis, 4 * ts, 1 * ts, 16, 16),
  309. 'ground3': new Sprite(atlantis, 5 * ts, 1 * ts, 16, 16),
  310. 'ground4': new Sprite(atlantis, 6 * ts, 1 * ts, 16, 16),
  311. 'ground5': new Sprite(atlantis, 7 * ts, 1 * ts, 16, 16),
  312. 'ground6': new Sprite(atlantis, 8 * ts, 1 * ts, 16, 16),
  313. 'rock0': new Sprite(atlantis, 1 * ts, 2 * ts, 16, 16),
  314. 'rock1': new Sprite(atlantis, 2 * ts, 2 * ts, 16, 16),
  315. 'rock2': new Sprite(atlantis, 3 * ts, 2 * ts, 16, 16),
  316. 'anchor0': new Sprite(atlantis, 21 * ts, 1 * ts, 16, 16),
  317. 'seaweed0': new Sprite(atlantis, 20 * ts, 2 * ts, 16, 32),
  318. 'seaweed1': new Sprite(atlantis, 16 * ts, 2 * ts, 16, 32),
  319. 'coral0': new Sprite(atlantis, 15 * ts, 9 * ts, 32, 16),
  320. 'rockpile0': new Sprite(atlantis, 17 * ts, 10 * ts, 32, 32),
  321. }
  322. for (const key in spritesheet_json) {
  323. const sprite_data = spritesheet_json[key];
  324. this.sprites[sprite_data['name']] = new CharacterSprite(
  325. spritesheet, sprite_data['x'], sprite_data['y'],
  326. sprite_data['width'] / 3, sprite_data['height'] / 4);
  327. }
  328. }
  329. }
  330. class Player {
  331. // TODO: stop hard-coding player bounding box.
  332. constructor() {
  333. this.x = (SNES_WIDTH - 26) / 2;
  334. this.y = (SNES_HEIGHT - 36) / 2;
  335. this.orientation = Orientation.DOWN;
  336. this.spriteNames_ = [];
  337. for (const name in spritesheet_json) {
  338. this.spriteNames_.push(name);
  339. }
  340. this.spriteNamesIdx_ = 0;
  341. }
  342. get spriteName() {
  343. return this.spriteNames_[this.spriteNamesIdx_];
  344. }
  345. cycleSprite() {
  346. this.spriteNamesIdx_++;
  347. if (this.spriteNamesIdx_ >= this.spriteNames_.length) {
  348. this.spriteNamesIdx_ = 0;
  349. }
  350. }
  351. moveLeft() {
  352. this.orientation = Orientation.LEFT;
  353. this.x -= 2;
  354. if (this.x < -2) {
  355. this.x = -2;
  356. }
  357. }
  358. moveRight() {
  359. this.orientation = Orientation.RIGHT;
  360. this.x += 2;
  361. if (this.x > SNES_WIDTH - 23) {
  362. this.x = SNES_WIDTH - 23;
  363. }
  364. }
  365. moveUp() {
  366. this.orientation = Orientation.UP;
  367. this.y -= 2;
  368. if (this.y < -2) {
  369. this.y = -2;
  370. }
  371. }
  372. moveDown() {
  373. this.orientation = Orientation.DOWN;
  374. this.y += 2;
  375. if (this.y > SNES_HEIGHT - 36) {
  376. this.y = SNES_HEIGHT - 36;
  377. }
  378. }
  379. }
  380. class PlayerRenderer {
  381. constructor() {
  382. this.frameNum = 0;
  383. }
  384. draw(gfx, sprites, player) {
  385. let spriteIndex = Math.floor((this.frameNum % 40) / 10);
  386. if (spriteIndex == 3) { spriteIndex = 1; }
  387. const charSprite = sprites[player.spriteName][player.orientation][spriteIndex];
  388. gfx.drawSprite(charSprite, player.x, player.y);
  389. this.frameNum++;
  390. }
  391. }
  392. class TileRenderer {
  393. draw(gfx, sprites) {
  394. const tileSize = 16;
  395. const rows = gfx.height / tileSize;
  396. const columns = gfx.width / tileSize;
  397. const layer1 = ["-,*-...*'.,-_'`o",
  398. "_..'-_**,',_.'oo",
  399. "-*-''_-'o,0O_```",
  400. "o`0_._,*O'`--'-'",
  401. "`0O-_'',`o*o*`-,",
  402. "*,`'---o'O'_*''-",
  403. "'-.**.'_'`.,'-.'",
  404. ".O'``*``'`*,,_o`",
  405. "_*_''*O'`_OO-_'o",
  406. "0`0,*-,`_*'`O'*.",
  407. ".o'-*.*_',`,,`.'",
  408. "`o`O',.`OO,*-'**",
  409. "-..*'-''',*'.'.O",
  410. "*-_'-0.--__O`O`_",
  411. "*-_,O_'*'`*'_._.",
  412. "-.*,`OO'_`'*-0-O"];
  413. const layer2 = [" ",
  414. " ",
  415. " iil ",
  416. " ",
  417. " A ",
  418. " ",
  419. " ",
  420. " ",
  421. " ",
  422. " i ",
  423. " l ",
  424. " ",
  425. " c R ",
  426. " c "];
  427. const spriteLookup = {
  428. '.': sprites.ground0,
  429. ',': sprites.ground1,
  430. '_': sprites.ground2,
  431. '`': sprites.ground3,
  432. '-': sprites.ground4,
  433. '*': sprites.ground5,
  434. "'": sprites.ground6,
  435. 'o': sprites.rock0,
  436. 'O': sprites.rock1,
  437. '0': sprites.rock2,
  438. 'A': sprites.anchor0,
  439. 'i': sprites.seaweed0,
  440. 'l': sprites.seaweed1,
  441. 'c': sprites.coral0,
  442. 'R': sprites.rockpile0,
  443. };
  444. for (let j = 0; j < columns; j++) {
  445. for (let i = 0; i < rows; i++) {
  446. const dx = tileSize * j;
  447. const dy = tileSize * i;
  448. const sprite = spriteLookup[layer1[i][j]];
  449. if (sprite) {
  450. gfx.drawSprite(sprite, dx, dy);
  451. }
  452. }
  453. }
  454. for (let j = 0; j < columns; j++) {
  455. for (let i = 0; i < rows; i++) {
  456. const dx = tileSize * j;
  457. const dy = tileSize * i;
  458. const sprite = spriteLookup[layer2[i][j]];
  459. if (sprite) {
  460. gfx.drawSprite(sprite, dx, dy);
  461. }
  462. }
  463. }
  464. }
  465. }
  466. class GamepadRenderer {
  467. draw(gfx, input) {
  468. const centerX = gfx.width / 2;
  469. const centerY = gfx.height / 2;
  470. // Select & Start
  471. gfx.circle(centerX + 12, centerY, 8, input.start ? 'cyan' : 'grey');
  472. gfx.circle(centerX - 12, centerY, 8, input.select ? 'cyan' : 'grey');
  473. // Y X B A
  474. gfx.circle(centerX + 48, centerY, 8, input.y ? 'cyan' : 'grey');
  475. gfx.circle(centerX + 64, centerY - 16, 8, input.x ? 'cyan' : 'grey');
  476. gfx.circle(centerX + 64, centerY + 16, 8, input.b ? 'cyan' : 'grey');
  477. gfx.circle(centerX + 80, centerY, 8, input.a ? 'cyan' : 'grey');
  478. // dpad
  479. gfx.circle(centerX - 48, centerY, 8, input.right ? 'cyan' : 'grey');
  480. gfx.circle(centerX - 64, centerY - 16, 8, input.up ? 'cyan' : 'grey');
  481. gfx.circle(centerX - 64, centerY + 16, 8, input.down ? 'cyan' : 'grey');
  482. gfx.circle(centerX - 80, centerY, 8, input.left ? 'cyan' : 'grey');
  483. // L & R
  484. gfx.circle(centerX + 30, centerY - 32, 8, input.r ? 'cyan' : 'grey');
  485. gfx.circle(centerX - 30, centerY - 32, 8, input.l ? 'cyan' : 'grey');
  486. }
  487. }
  488. function debug(message) {
  489. const debugDiv = document.getElementById('debug');
  490. debugDiv.innerText = message;
  491. }
  492. function loop(world, gfx) {
  493. return timestampMs => {
  494. world.update(timestampMs);
  495. world.draw(gfx);
  496. window.requestAnimationFrame(loop(world, gfx));
  497. };
  498. }
  499. function setCanvasScale(scale) {
  500. const canvas = document.getElementById('canvas');
  501. canvas.style.width = '' + SNES_WIDTH * scale + 'px';
  502. canvas.style.height = '' + SNES_HEIGHT * scale + 'px';
  503. canvas.style.display = '';
  504. debug('set scale to ' + scale + 'x');
  505. }
  506. function setAutoCanvasScale() {
  507. const widthAspect = Math.floor(window.innerWidth / SNES_WIDTH);
  508. const heightAspect = Math.floor(window.innerHeight / SNES_HEIGHT);
  509. const scale = bound(1, Math.min(widthAspect, heightAspect), 8);
  510. setCanvasScale(scale);
  511. }
  512. function init() {
  513. const world = new World();
  514. const gfx = new Graphics(document.getElementById('canvas'));
  515. document.getElementById('1x').onclick = () => setCanvasScale(1);
  516. document.getElementById('2x').onclick = () => setCanvasScale(2);
  517. document.getElementById('3x').onclick = () => setCanvasScale(3);
  518. document.getElementById('4x').onclick = () => setCanvasScale(4);
  519. document.getElementById('5x').onclick = () => setCanvasScale(5);
  520. document.getElementById('6x').onclick = () => setCanvasScale(6);
  521. document.getElementById('7x').onclick = () => setCanvasScale(7);
  522. document.getElementById('8x').onclick = () => setCanvasScale(8);
  523. setAutoCanvasScale();
  524. window.requestAnimationFrame(loop(world, gfx));
  525. }
  526. init();