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.

500 lines
13 KiB

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