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.

459 lines
12 KiB

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