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.

252 lines
6.3 KiB

  1. class Input {
  2. constructor() {
  3. this.left = false;
  4. this.right = false;
  5. this.up = false;
  6. this.down = false;
  7. this.a = false;
  8. this.b = false;
  9. this.x = false;
  10. this.y = false;
  11. this.l = false;
  12. this.r = false;
  13. this.select = false;
  14. this.start = false;
  15. window.addEventListener('gamepadconnected', this.gamepadConnected);
  16. window.addEventListener('gamepaddisconnected', this.gamepadDisconnected);
  17. }
  18. update() {
  19. // TODO: have a config screen instead of hard-coding the 8Bitdo SNES30 pad.
  20. // TODO: handle connects / disconnects more correctly.
  21. const gamepad = navigator.getGamepads()[0];
  22. if (gamepad == null || !gamepad.connected || gamepad.axes.length < 2 ||
  23. gamepad.buttons.length < 12) {
  24. return;
  25. }
  26. this.left = gamepad.axes[0] < 0;
  27. this.right = gamepad.axes[0] > 0;
  28. this.up = gamepad.axes[1] < 0;
  29. this.down = gamepad.axes[1] > 0;
  30. this.a = gamepad.buttons[0].pressed;
  31. this.b = gamepad.buttons[1].pressed;
  32. this.x = gamepad.buttons[3].pressed;
  33. this.y = gamepad.buttons[4].pressed;
  34. this.l = gamepad.buttons[6].pressed;
  35. this.r = gamepad.buttons[7].pressed;
  36. this.select = gamepad.buttons[10].pressed;
  37. this.start = gamepad.buttons[11].pressed;
  38. debug(this.toString());
  39. }
  40. gamepadConnected(e) {
  41. debug('gamepad connected! :)');
  42. console.log('gamepad connected @ index %d: %d buttons, %d axes\n[%s]',
  43. e.gamepad.index, e.gamepad.buttons.length, e.gamepad.axes.length,
  44. e.gamepad.id);
  45. }
  46. gamepadDisconnected(e) {
  47. debug('gamepad disconnected :(');
  48. console.log('gamepad disconnected @ index %d:\n[%s]', e.gamepad.index,
  49. e.gamepad.id);
  50. }
  51. toString() {
  52. let result = '';
  53. if (this.up) {
  54. result += '^';
  55. } else if (this.down) {
  56. result += 'v';
  57. } else {
  58. result += '-';
  59. }
  60. if (this.left) {
  61. result += '<';
  62. } else if (this.right) {
  63. result += '>';
  64. } else {
  65. result += '-';
  66. }
  67. result += ' ';
  68. if (this.a) {
  69. result += 'A';
  70. }
  71. if (this.b) {
  72. result += 'B';
  73. }
  74. if (this.x) {
  75. result += 'X';
  76. }
  77. if (this.y) {
  78. result += 'Y';
  79. }
  80. if (this.l) {
  81. result += 'L';
  82. }
  83. if (this.r) {
  84. result += 'R';
  85. }
  86. if (this.select) {
  87. result += 's';
  88. }
  89. if (this.start) {
  90. result += 'S';
  91. }
  92. return result;
  93. }
  94. }
  95. class Graphics {
  96. constructor(canvas) {
  97. this.canvas_ = canvas;
  98. this.ctx_ = canvas.getContext('2d');
  99. }
  100. get width() {
  101. return this.canvas_.width;
  102. }
  103. get height() {
  104. return this.canvas_.height;
  105. }
  106. fill(color) {
  107. this.ctx_.fillStyle = color;
  108. this.ctx_.beginPath();
  109. this.ctx_.rect(0, 0, this.canvas_.width, this.canvas_.height);
  110. this.ctx_.fill();
  111. this.ctx_.closePath();
  112. }
  113. circle(x, y, radius, color) {
  114. this.ctx_.fillStyle = color;
  115. this.ctx_.beginPath();
  116. this.ctx_.arc(x, y, radius, 0, 2 * Math.PI)
  117. this.ctx_.fill();
  118. this.ctx_.closePath();
  119. }
  120. // TODO: replace with custom sprite-based text rendering.
  121. text(string, x, y, size, color) {
  122. this.ctx_.imageSmoothingEnabled = false;
  123. this.ctx_.fillStyle = color;
  124. this.ctx_.font = '' + size + 'px monospace';
  125. this.ctx_.fillText(string, x, y);
  126. }
  127. }
  128. class FpsCounter {
  129. constructor() {
  130. this.fps = 0;
  131. this.frameTimes_ = new Array(60);
  132. this.idx_ = 0;
  133. }
  134. update(timestampMs) {
  135. if (this.frameTimes_[this.idx_]) {
  136. const timeElapsed = (timestampMs - this.frameTimes_[this.idx_]) / 1000;
  137. this.fps = this.frameTimes_.length / timeElapsed;
  138. }
  139. this.frameTimes_[this.idx_] = timestampMs;
  140. this.idx_++;
  141. if (this.idx_ == this.frameTimes_.length) {
  142. this.idx_ = 0;
  143. }
  144. }
  145. draw(gfx) {
  146. gfx.text('FPS: ' + Math.round(this.fps), 8, 16, 16, 'yellow');
  147. }
  148. }
  149. class World {
  150. constructor() {
  151. this.state_ = null;
  152. this.fpsCounter_ = new FpsCounter();
  153. this.input_ = new Input();
  154. this.gamepadRenderer_ = new GamepadRenderer();
  155. }
  156. update(timestampMs) {
  157. this.fpsCounter_.update(timestampMs);
  158. this.input_.update();
  159. }
  160. draw(gfx) {
  161. this.gamepadRenderer_.draw(gfx, this.input_);
  162. this.fpsCounter_.draw(gfx);
  163. }
  164. }
  165. class GamepadRenderer {
  166. draw(gfx, input) {
  167. const centerX = gfx.width / 2;
  168. const centerY = gfx.height / 2;
  169. gfx.fill('black');
  170. // Select & Start
  171. gfx.circle(centerX + 12, centerY, 8, input.start ? 'cyan' : 'grey');
  172. gfx.circle(centerX - 12, centerY, 8, input.select ? 'cyan' : 'grey');
  173. // Y X B A
  174. gfx.circle(centerX + 48, centerY, 8, input.y ? 'cyan' : 'grey');
  175. gfx.circle(centerX + 64, centerY - 16, 8, input.x ? 'cyan' : 'grey');
  176. gfx.circle(centerX + 64, centerY + 16, 8, input.b ? 'cyan' : 'grey');
  177. gfx.circle(centerX + 80, centerY, 8, input.a ? 'cyan' : 'grey');
  178. // dpad
  179. gfx.circle(centerX - 48, centerY, 8, input.right ? 'cyan' : 'grey');
  180. gfx.circle(centerX - 64, centerY - 16, 8, input.up ? 'cyan' : 'grey');
  181. gfx.circle(centerX - 64, centerY + 16, 8, input.down ? 'cyan' : 'grey');
  182. gfx.circle(centerX - 80, centerY, 8, input.left ? 'cyan' : 'grey');
  183. // L & R
  184. gfx.circle(centerX + 30, centerY - 32, 8, input.r ? 'cyan' : 'grey');
  185. gfx.circle(centerX - 30, centerY - 32, 8, input.l ? 'cyan' : 'grey');
  186. }
  187. }
  188. function debug(message) {
  189. const debugDiv = document.getElementById('debug');
  190. debugDiv.innerText = message;
  191. }
  192. function loop(world, gfx) {
  193. return timestampMs => {
  194. world.update(timestampMs);
  195. world.draw(gfx);
  196. window.requestAnimationFrame(loop(world, gfx));
  197. };
  198. }
  199. function setCanvasScale(scale) {
  200. const snesWidth = 256;
  201. const snesHeight = 224;
  202. const canvas = document.getElementById('canvas');
  203. canvas.style.width = '' + snesWidth * scale + 'px';
  204. canvas.style.height = '' + snesHeight * scale + 'px';
  205. }
  206. function init() {
  207. const world = new World();
  208. const gfx = new Graphics(document.getElementById('canvas'));
  209. document.getElementById('1x').onclick = () => setCanvasScale(1);
  210. document.getElementById('2x').onclick = () => setCanvasScale(2);
  211. document.getElementById('3x').onclick = () => setCanvasScale(3);
  212. document.getElementById('4x').onclick = () => setCanvasScale(4);
  213. document.getElementById('5x').onclick = () => setCanvasScale(5);
  214. setCanvasScale(4);
  215. window.requestAnimationFrame(loop(world, gfx));
  216. debug('initialized!');
  217. }
  218. init();