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.

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