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.

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