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.

515 lines
14 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. drawImage(image, dx, dy) {
  177. const src = image[0];
  178. const sx = image[1];
  179. const sy = image[2];
  180. const width = image[3];
  181. const height = image[4];
  182. this.ctx_.drawImage(
  183. src, sx, sy, width, height, dx, dy, width, height);
  184. }
  185. }
  186. class FpsCounter {
  187. constructor() {
  188. this.fps = 0;
  189. this.frameTimes_ = new Array(60);
  190. this.idx_ = 0;
  191. }
  192. update(timestampMs) {
  193. if (this.frameTimes_[this.idx_]) {
  194. const timeElapsed = (timestampMs - this.frameTimes_[this.idx_]) / 1000;
  195. this.fps = this.frameTimes_.length / timeElapsed;
  196. }
  197. this.frameTimes_[this.idx_] = timestampMs;
  198. this.idx_++;
  199. if (this.idx_ == this.frameTimes_.length) {
  200. this.idx_ = 0;
  201. }
  202. }
  203. draw(gfx) {
  204. const fpsDiv = document.getElementById('fps');
  205. fpsDiv.innerText = 'FPS: ' + Math.round(this.fps);
  206. }
  207. }
  208. class World {
  209. constructor() {
  210. this.state_ = null;
  211. this.fpsCounter_ = new FpsCounter();
  212. this.input_ = new Input();
  213. this.player_ = new Player();
  214. // TODO: move rendering stuff to a separate object.
  215. this.resources_ = new Resources();
  216. this.tileRenderer_ = new TileRenderer();
  217. this.playerRenderer_ = new PlayerRenderer();
  218. this.gamepadRenderer_ = new GamepadRenderer();
  219. }
  220. update(timestampMs) {
  221. this.fpsCounter_.update(timestampMs);
  222. this.input_.update();
  223. if (this.input_.left) {
  224. this.player_.moveLeft();
  225. }
  226. if (this.input_.right) {
  227. this.player_.moveRight();
  228. }
  229. if (this.input_.up) {
  230. this.player_.moveUp();
  231. }
  232. if (this.input_.down) {
  233. this.player_.moveDown();
  234. }
  235. }
  236. draw(gfx) {
  237. gfx.fill('black');
  238. this.tileRenderer_.draw(gfx, this.resources_.sprites);
  239. this.playerRenderer_.draw(gfx, this.resources_.sprites, this.player_);
  240. // this.gamepadRenderer_.draw(gfx, this.input_);
  241. this.fpsCounter_.draw(gfx);
  242. }
  243. }
  244. class Resources {
  245. constructor() {
  246. const atlantis = document.getElementById('atlantis');
  247. const ghost = document.getElementById('ghost');
  248. const ts = 16;
  249. this.sprites = {
  250. 'ground0': [atlantis, 2 * ts, 1 * ts, 16, 16],
  251. 'ground1': [atlantis, 3 * ts, 1 * ts, 16, 16],
  252. 'ground2': [atlantis, 4 * ts, 1 * ts, 16, 16],
  253. 'ground3': [atlantis, 5 * ts, 1 * ts, 16, 16],
  254. 'ground4': [atlantis, 6 * ts, 1 * ts, 16, 16],
  255. 'ground5': [atlantis, 7 * ts, 1 * ts, 16, 16],
  256. 'ground6': [atlantis, 8 * ts, 1 * ts, 16, 16],
  257. 'rock0': [atlantis, 1 * ts, 2 * ts, 16, 16],
  258. 'rock1': [atlantis, 2 * ts, 2 * ts, 16, 16],
  259. 'rock2': [atlantis, 3 * ts, 2 * ts, 16, 16],
  260. 'anchor0': [atlantis, 21 * ts, 1 * ts, 16, 16],
  261. 'seaweed0': [atlantis, 20 * ts, 2 * ts, 16, 32],
  262. 'seaweed1': [atlantis, 16 * ts, 2 * ts, 16, 32],
  263. 'coral0': [atlantis, 15 * ts, 9 * ts, 32, 16],
  264. 'rockpile0': [atlantis, 17 * ts, 10 * ts, 32, 32],
  265. 'ghostdown0': [ghost, 0, 0, 24, 36],
  266. 'ghostdown1': [ghost, 26, 0, 24, 36],
  267. 'ghostdown2': [ghost, 52, 0, 24, 36],
  268. 'ghostleft0': [ghost, 0, 36, 24, 36],
  269. 'ghostleft1': [ghost, 26, 36, 24, 36],
  270. 'ghostleft2': [ghost, 52, 36, 24, 36],
  271. 'ghostright0': [ghost, 0, 72, 24, 36],
  272. 'ghostright1': [ghost, 26, 72, 24, 36],
  273. 'ghostright2': [ghost, 52, 72, 24, 36],
  274. 'ghostup0': [ghost, 0, 108, 24, 36],
  275. 'ghostup1': [ghost, 26, 108, 24, 36],
  276. 'ghostup2': [ghost, 52, 108, 24, 36],
  277. }
  278. }
  279. }
  280. class Player {
  281. // TODO: stop hard-coding player bounding box.
  282. constructor() {
  283. this.x = (SNES_WIDTH - 26) / 2;
  284. this.y = (SNES_HEIGHT - 36) / 2;
  285. this.orientation = Orientation.DOWN;
  286. }
  287. moveLeft() {
  288. this.orientation = Orientation.LEFT;
  289. this.x -= 2;
  290. if (this.x < -4) {
  291. this.x = -4;
  292. }
  293. }
  294. moveRight() {
  295. this.orientation = Orientation.RIGHT;
  296. this.x += 2;
  297. if (this.x > SNES_WIDTH - 21) {
  298. this.x = SNES_WIDTH - 21;
  299. }
  300. }
  301. moveUp() {
  302. this.orientation = Orientation.UP;
  303. this.y -= 2;
  304. if (this.y < -7) {
  305. this.y = -7;
  306. }
  307. }
  308. moveDown() {
  309. this.orientation = Orientation.DOWN;
  310. this.y += 2;
  311. if (this.y > SNES_HEIGHT - 36) {
  312. this.y = SNES_HEIGHT - 36;
  313. }
  314. }
  315. }
  316. class PlayerRenderer {
  317. constructor() {
  318. this.frameNum = 0;
  319. }
  320. draw(gfx, sprites, player) {
  321. let spriteIndex = Math.floor((this.frameNum % 40) / 10);
  322. if (spriteIndex == 3) { spriteIndex = 1; }
  323. const spriteName = 'ghost' + player.orientation + spriteIndex;
  324. gfx.drawImage(sprites[spriteName], player.x, player.y);
  325. this.frameNum++;
  326. }
  327. }
  328. class TileRenderer {
  329. draw(gfx, sprites) {
  330. const tileSize = 16;
  331. const rows = gfx.height / tileSize;
  332. const columns = gfx.width / tileSize;
  333. const layer1 = ["-,*-...*'.,-_'`o",
  334. "_..'-_**,',_.'oo",
  335. "-*-''_-'o,0O_```",
  336. "o`0_._,*O'`--'-'",
  337. "`0O-_'',`o*o*`-,",
  338. "*,`'---o'O'_*''-",
  339. "'-.**.'_'`.,'-.'",
  340. ".O'``*``'`*,,_o`",
  341. "_*_''*O'`_OO-_'o",
  342. "0`0,*-,`_*'`O'*.",
  343. ".o'-*.*_',`,,`.'",
  344. "`o`O',.`OO,*-'**",
  345. "-..*'-''',*'.'.O",
  346. "*-_'-0.--__O`O`_",
  347. "*-_,O_'*'`*'_._.",
  348. "-.*,`OO'_`'*-0-O"];
  349. const layer2 = [" ",
  350. " ",
  351. " iil ",
  352. " ",
  353. " A ",
  354. " ",
  355. " ",
  356. " ",
  357. " ",
  358. " i ",
  359. " l ",
  360. " ",
  361. " c R ",
  362. " c "];
  363. const spriteLookup = {
  364. '.': sprites.ground0,
  365. ',': sprites.ground1,
  366. '_': sprites.ground2,
  367. '`': sprites.ground3,
  368. '-': sprites.ground4,
  369. '*': sprites.ground5,
  370. "'": sprites.ground6,
  371. 'o': sprites.rock0,
  372. 'O': sprites.rock1,
  373. '0': sprites.rock2,
  374. 'A': sprites.anchor0,
  375. 'i': sprites.seaweed0,
  376. 'l': sprites.seaweed1,
  377. 'c': sprites.coral0,
  378. 'R': sprites.rockpile0,
  379. };
  380. for (let j = 0; j < columns; j++) {
  381. for (let i = 0; i < rows; i++) {
  382. const dx = tileSize * j;
  383. const dy = tileSize * i;
  384. const sprite = spriteLookup[layer1[i][j]];
  385. if (sprite) {
  386. gfx.drawImage(sprite, dx, dy);
  387. }
  388. }
  389. }
  390. for (let j = 0; j < columns; j++) {
  391. for (let i = 0; i < rows; i++) {
  392. const dx = tileSize * j;
  393. const dy = tileSize * i;
  394. const sprite = spriteLookup[layer2[i][j]];
  395. if (sprite) {
  396. gfx.drawImage(sprite, dx, dy);
  397. }
  398. }
  399. }
  400. }
  401. }
  402. class GamepadRenderer {
  403. draw(gfx, input) {
  404. const centerX = gfx.width / 2;
  405. const centerY = gfx.height / 2;
  406. // Select & Start
  407. gfx.circle(centerX + 12, centerY, 8, input.start ? 'cyan' : 'grey');
  408. gfx.circle(centerX - 12, centerY, 8, input.select ? 'cyan' : 'grey');
  409. // Y X B A
  410. gfx.circle(centerX + 48, centerY, 8, input.y ? 'cyan' : 'grey');
  411. gfx.circle(centerX + 64, centerY - 16, 8, input.x ? 'cyan' : 'grey');
  412. gfx.circle(centerX + 64, centerY + 16, 8, input.b ? 'cyan' : 'grey');
  413. gfx.circle(centerX + 80, centerY, 8, input.a ? 'cyan' : 'grey');
  414. // dpad
  415. gfx.circle(centerX - 48, centerY, 8, input.right ? 'cyan' : 'grey');
  416. gfx.circle(centerX - 64, centerY - 16, 8, input.up ? 'cyan' : 'grey');
  417. gfx.circle(centerX - 64, centerY + 16, 8, input.down ? 'cyan' : 'grey');
  418. gfx.circle(centerX - 80, centerY, 8, input.left ? 'cyan' : 'grey');
  419. // L & R
  420. gfx.circle(centerX + 30, centerY - 32, 8, input.r ? 'cyan' : 'grey');
  421. gfx.circle(centerX - 30, centerY - 32, 8, input.l ? 'cyan' : 'grey');
  422. }
  423. }
  424. function debug(message) {
  425. const debugDiv = document.getElementById('debug');
  426. debugDiv.innerText = message;
  427. }
  428. function loop(world, gfx) {
  429. return timestampMs => {
  430. world.update(timestampMs);
  431. world.draw(gfx);
  432. window.requestAnimationFrame(loop(world, gfx));
  433. };
  434. }
  435. function setCanvasScale(scale) {
  436. const canvas = document.getElementById('canvas');
  437. canvas.style.width = '' + SNES_WIDTH * scale + 'px';
  438. canvas.style.height = '' + SNES_HEIGHT * scale + 'px';
  439. canvas.style.display = '';
  440. debug('set scale to ' + scale + 'x');
  441. }
  442. function setAutoCanvasScale() {
  443. const widthAspect = Math.floor(window.innerWidth / SNES_WIDTH);
  444. const heightAspect = Math.floor(window.innerHeight / SNES_HEIGHT);
  445. const scale = Math.min(widthAspect, heightAspect)
  446. setCanvasScale(scale);
  447. }
  448. function init() {
  449. const world = new World();
  450. const gfx = new Graphics(document.getElementById('canvas'));
  451. document.getElementById('1x').onclick = () => setCanvasScale(1);
  452. document.getElementById('2x').onclick = () => setCanvasScale(2);
  453. document.getElementById('3x').onclick = () => setCanvasScale(3);
  454. document.getElementById('4x').onclick = () => setCanvasScale(4);
  455. document.getElementById('5x').onclick = () => setCanvasScale(5);
  456. document.getElementById('6x').onclick = () => setCanvasScale(6);
  457. document.getElementById('7x').onclick = () => setCanvasScale(7);
  458. document.getElementById('8x').onclick = () => setCanvasScale(8);
  459. setAutoCanvasScale();
  460. window.requestAnimationFrame(loop(world, gfx));
  461. }
  462. init();