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.

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