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.

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