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.

503 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. this.ctx_.imageSmoothingEnabled = false;
  145. this.ctx_.mozImageSmoothingEnabled = false;
  146. this.ctx_.webkitImageSmoothingEnabled = false;
  147. this.ctx_.msImageSmoothingEnabled = false;
  148. }
  149. get width() {
  150. return this.canvas_.width;
  151. }
  152. get height() {
  153. return this.canvas_.height;
  154. }
  155. fill(color) {
  156. this.ctx_.fillStyle = color;
  157. this.ctx_.beginPath();
  158. this.ctx_.rect(0, 0, this.canvas_.width, this.canvas_.height);
  159. this.ctx_.fill();
  160. this.ctx_.closePath();
  161. }
  162. circle(x, y, radius, color) {
  163. this.ctx_.fillStyle = color;
  164. this.ctx_.beginPath();
  165. this.ctx_.arc(x, y, radius, 0, 2 * Math.PI)
  166. this.ctx_.fill();
  167. this.ctx_.closePath();
  168. }
  169. // TODO: replace with custom sprite-based text rendering.
  170. text(string, x, y, size, color) {
  171. this.ctx_.fillStyle = color;
  172. this.ctx_.font = '' + size + 'px monospace';
  173. this.ctx_.fillText(string, x, y);
  174. }
  175. drawImage(image, dx, dy) {
  176. const src = image[0];
  177. const sx = image[1];
  178. const sy = image[2];
  179. const width = image[3];
  180. const height = image[4];
  181. this.ctx_.drawImage(
  182. src, sx, sy, width, height, dx, dy, width, 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. this.input_.update();
  222. if (this.input_.left) {
  223. this.player_.moveLeft();
  224. }
  225. if (this.input_.right) {
  226. this.player_.moveRight();
  227. }
  228. if (this.input_.up) {
  229. this.player_.moveUp();
  230. }
  231. if (this.input_.down) {
  232. this.player_.moveDown();
  233. }
  234. }
  235. draw(gfx) {
  236. gfx.fill('black');
  237. this.tileRenderer_.draw(gfx, this.resources_.sprites);
  238. this.playerRenderer_.draw(gfx, this.resources_.sprites, this.player_);
  239. // this.gamepadRenderer_.draw(gfx, this.input_);
  240. this.fpsCounter_.draw(gfx);
  241. }
  242. }
  243. class Resources {
  244. constructor() {
  245. const atlantis = document.getElementById('atlantis');
  246. const ghost = document.getElementById('ghost');
  247. const ts = 16;
  248. this.sprites = {
  249. 'ground0': [atlantis, 2 * ts, 1 * ts, 16, 16],
  250. 'ground1': [atlantis, 3 * ts, 1 * ts, 16, 16],
  251. 'ground2': [atlantis, 4 * ts, 1 * ts, 16, 16],
  252. 'ground3': [atlantis, 5 * ts, 1 * ts, 16, 16],
  253. 'ground4': [atlantis, 6 * ts, 1 * ts, 16, 16],
  254. 'ground5': [atlantis, 7 * ts, 1 * ts, 16, 16],
  255. 'ground6': [atlantis, 8 * ts, 1 * ts, 16, 16],
  256. 'rock0': [atlantis, 1 * ts, 2 * ts, 16, 16],
  257. 'rock1': [atlantis, 2 * ts, 2 * ts, 16, 16],
  258. 'rock2': [atlantis, 3 * ts, 2 * ts, 16, 16],
  259. 'anchor0': [atlantis, 21 * ts, 1 * ts, 16, 16],
  260. 'seaweed0': [atlantis, 20 * ts, 2 * ts, 16, 32],
  261. 'seaweed1': [atlantis, 16 * ts, 2 * ts, 16, 32],
  262. 'coral0': [atlantis, 15 * ts, 9 * ts, 32, 16],
  263. 'rockpile0': [atlantis, 17 * ts, 10 * ts, 32, 32],
  264. 'ghostdown0': [ghost, 0, 0, 24, 36],
  265. 'ghostdown1': [ghost, 26, 0, 24, 36],
  266. 'ghostdown2': [ghost, 52, 0, 24, 36],
  267. 'ghostleft0': [ghost, 0, 36, 24, 36],
  268. 'ghostleft1': [ghost, 26, 36, 24, 36],
  269. 'ghostleft2': [ghost, 52, 36, 24, 36],
  270. 'ghostright0': [ghost, 0, 72, 24, 36],
  271. 'ghostright1': [ghost, 26, 72, 24, 36],
  272. 'ghostright2': [ghost, 52, 72, 24, 36],
  273. 'ghostup0': [ghost, 0, 108, 24, 36],
  274. 'ghostup1': [ghost, 26, 108, 24, 36],
  275. 'ghostup2': [ghost, 52, 108, 24, 36],
  276. }
  277. }
  278. }
  279. class Player {
  280. constructor() {
  281. this.x = (256 - 26) / 2;
  282. this.y = (224 - 36) / 2;
  283. this.orientation = Orientation.DOWN;
  284. }
  285. moveLeft() {
  286. this.orientation = Orientation.LEFT;
  287. this.x -= 2;
  288. if (this.x < -4) {
  289. this.x = -4;
  290. }
  291. }
  292. moveRight() {
  293. this.orientation = Orientation.RIGHT;
  294. this.x += 2;
  295. if (this.x > 256 - 21) {
  296. this.x = 256 - 21;
  297. }
  298. }
  299. moveUp() {
  300. this.orientation = Orientation.UP;
  301. this.y -= 2;
  302. if (this.y < -7) {
  303. this.y = -7;
  304. }
  305. }
  306. moveDown() {
  307. this.orientation = Orientation.DOWN;
  308. this.y += 2;
  309. if (this.y > 224 - 36) {
  310. this.y = 224 - 36;
  311. }
  312. }
  313. }
  314. class PlayerRenderer {
  315. constructor() {
  316. this.frameNum = 0;
  317. }
  318. draw(gfx, sprites, player) {
  319. let spriteIndex = Math.floor((this.frameNum % 40) / 10);
  320. if (spriteIndex == 3) { spriteIndex = 1; }
  321. const spriteName = 'ghost' + player.orientation + spriteIndex;
  322. gfx.drawImage(sprites[spriteName], player.x, player.y);
  323. this.frameNum++;
  324. }
  325. }
  326. class TileRenderer {
  327. draw(gfx, sprites) {
  328. const tileSize = 16;
  329. const rows = gfx.height / tileSize;
  330. const columns = gfx.width / tileSize;
  331. const layer1 = ["-,*-...*'.,-_'`o",
  332. "_..'-_**,',_.'oo",
  333. "-*-''_-'o,0O_```",
  334. "o`0_._,*O'`--'-'",
  335. "`0O-_'',`o*o*`-,",
  336. "*,`'---o'O'_*''-",
  337. "'-.**.'_'`.,'-.'",
  338. ".O'``*``'`*,,_o`",
  339. "_*_''*O'`_OO-_'o",
  340. "0`0,*-,`_*'`O'*.",
  341. ".o'-*.*_',`,,`.'",
  342. "`o`O',.`OO,*-'**",
  343. "-..*'-''',*'.'.O",
  344. "*-_'-0.--__O`O`_",
  345. "*-_,O_'*'`*'_._.",
  346. "-.*,`OO'_`'*-0-O"];
  347. const layer2 = [" ",
  348. " ",
  349. " iil ",
  350. " ",
  351. " A ",
  352. " ",
  353. " ",
  354. " ",
  355. " ",
  356. " i ",
  357. " l ",
  358. " ",
  359. " c R ",
  360. " c "];
  361. const spriteLookup = {
  362. '.': sprites.ground0,
  363. ',': sprites.ground1,
  364. '_': sprites.ground2,
  365. '`': sprites.ground3,
  366. '-': sprites.ground4,
  367. '*': sprites.ground5,
  368. "'": sprites.ground6,
  369. 'o': sprites.rock0,
  370. 'O': sprites.rock1,
  371. '0': sprites.rock2,
  372. 'A': sprites.anchor0,
  373. 'i': sprites.seaweed0,
  374. 'l': sprites.seaweed1,
  375. 'c': sprites.coral0,
  376. 'R': sprites.rockpile0,
  377. };
  378. for (let j = 0; j < columns; j++) {
  379. for (let i = 0; i < rows; i++) {
  380. const dx = tileSize * j;
  381. const dy = tileSize * i;
  382. const sprite = spriteLookup[layer1[i][j]];
  383. if (sprite) {
  384. gfx.drawImage(sprite, dx, dy);
  385. }
  386. }
  387. }
  388. for (let j = 0; j < columns; j++) {
  389. for (let i = 0; i < rows; i++) {
  390. const dx = tileSize * j;
  391. const dy = tileSize * i;
  392. const sprite = spriteLookup[layer2[i][j]];
  393. if (sprite) {
  394. gfx.drawImage(sprite, dx, dy);
  395. }
  396. }
  397. }
  398. }
  399. }
  400. class GamepadRenderer {
  401. draw(gfx, input) {
  402. const centerX = gfx.width / 2;
  403. const centerY = gfx.height / 2;
  404. // Select & Start
  405. gfx.circle(centerX + 12, centerY, 8, input.start ? 'cyan' : 'grey');
  406. gfx.circle(centerX - 12, centerY, 8, input.select ? 'cyan' : 'grey');
  407. // Y X B A
  408. gfx.circle(centerX + 48, centerY, 8, input.y ? 'cyan' : 'grey');
  409. gfx.circle(centerX + 64, centerY - 16, 8, input.x ? 'cyan' : 'grey');
  410. gfx.circle(centerX + 64, centerY + 16, 8, input.b ? 'cyan' : 'grey');
  411. gfx.circle(centerX + 80, centerY, 8, input.a ? 'cyan' : 'grey');
  412. // dpad
  413. gfx.circle(centerX - 48, centerY, 8, input.right ? 'cyan' : 'grey');
  414. gfx.circle(centerX - 64, centerY - 16, 8, input.up ? 'cyan' : 'grey');
  415. gfx.circle(centerX - 64, centerY + 16, 8, input.down ? 'cyan' : 'grey');
  416. gfx.circle(centerX - 80, centerY, 8, input.left ? 'cyan' : 'grey');
  417. // L & R
  418. gfx.circle(centerX + 30, centerY - 32, 8, input.r ? 'cyan' : 'grey');
  419. gfx.circle(centerX - 30, centerY - 32, 8, input.l ? 'cyan' : 'grey');
  420. }
  421. }
  422. function debug(message) {
  423. const debugDiv = document.getElementById('debug');
  424. debugDiv.innerText = message;
  425. }
  426. function loop(world, gfx) {
  427. return timestampMs => {
  428. world.update(timestampMs);
  429. world.draw(gfx);
  430. window.requestAnimationFrame(loop(world, gfx));
  431. };
  432. }
  433. function setCanvasScale(scale) {
  434. const snesWidth = 256;
  435. const snesHeight = 224;
  436. const canvas = document.getElementById('canvas');
  437. canvas.style.width = '' + snesWidth * scale + 'px';
  438. canvas.style.height = '' + snesHeight * scale + 'px';
  439. }
  440. function init() {
  441. const world = new World();
  442. const gfx = new Graphics(document.getElementById('canvas'));
  443. document.getElementById('1x').onclick = () => setCanvasScale(1);
  444. document.getElementById('2x').onclick = () => setCanvasScale(2);
  445. document.getElementById('3x').onclick = () => setCanvasScale(3);
  446. document.getElementById('4x').onclick = () => setCanvasScale(4);
  447. document.getElementById('5x').onclick = () => setCanvasScale(5);
  448. setCanvasScale(4);
  449. window.requestAnimationFrame(loop(world, gfx));
  450. debug('initialized!');
  451. }
  452. init();