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.

254 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. const input = new Input();
  96. class Graphics {
  97. constructor(canvas) {
  98. this.canvas_ = canvas;
  99. this.ctx_ = canvas.getContext('2d');
  100. }
  101. get width() {
  102. return this.canvas_.width;
  103. }
  104. get height() {
  105. return this.canvas_.height;
  106. }
  107. fill(color) {
  108. this.ctx_.fillStyle = color;
  109. this.ctx_.beginPath();
  110. this.ctx_.rect(0, 0, this.canvas_.width, this.canvas_.height);
  111. this.ctx_.fill();
  112. this.ctx_.closePath();
  113. }
  114. circle(x, y, radius, color) {
  115. this.ctx_.fillStyle = color;
  116. this.ctx_.beginPath();
  117. this.ctx_.arc(x, y, radius, 0, 2 * Math.PI)
  118. this.ctx_.fill();
  119. this.ctx_.closePath();
  120. }
  121. // TODO: replace with custom sprite-based text rendering.
  122. text(string, x, y, size, color) {
  123. this.ctx_.imageSmoothingEnabled = false;
  124. this.ctx_.fillStyle = color;
  125. this.ctx_.font = '' + size + 'px monospace';
  126. this.ctx_.fillText(string, x, y);
  127. }
  128. }
  129. class FpsCounter {
  130. constructor() {
  131. this.fps = 0;
  132. this.frameTimes_ = new Array(60);
  133. this.idx_ = 0;
  134. }
  135. update(timestampMs) {
  136. if (this.frameTimes_[this.idx_]) {
  137. const timeElapsed = (timestampMs - this.frameTimes_[this.idx_]) / 1000;
  138. this.fps = this.frameTimes_.length / timeElapsed;
  139. }
  140. this.frameTimes_[this.idx_] = timestampMs;
  141. this.idx_++;
  142. if (this.idx_ == this.frameTimes_.length) {
  143. this.idx_ = 0;
  144. }
  145. }
  146. draw(gfx) {
  147. gfx.text('FPS: ' + Math.round(this.fps), 8, 16, 16, 'yellow');
  148. }
  149. }
  150. class World {
  151. constructor() {
  152. this.state_ = null;
  153. this.fpsCounter_ = new FpsCounter();
  154. this.input_ = new Input();
  155. this.gamepadRenderer_ = new GamepadRenderer();
  156. }
  157. update(timestampMs) {
  158. this.fpsCounter_.update(timestampMs);
  159. this.input_.update();
  160. }
  161. draw(gfx) {
  162. this.gamepadRenderer_.draw(gfx, this.input_);
  163. this.fpsCounter_.draw(gfx);
  164. }
  165. }
  166. class GamepadRenderer {
  167. draw(gfx, input) {
  168. const centerX = gfx.width / 2;
  169. const centerY = gfx.height / 2;
  170. gfx.fill('black');
  171. // Select & Start
  172. gfx.circle(centerX + 12, centerY, 8, input.start ? 'cyan' : 'grey');
  173. gfx.circle(centerX - 12, centerY, 8, input.select ? 'cyan' : 'grey');
  174. // Y X B A
  175. gfx.circle(centerX + 48, centerY, 8, input.y ? 'cyan' : 'grey');
  176. gfx.circle(centerX + 64, centerY - 16, 8, input.x ? 'cyan' : 'grey');
  177. gfx.circle(centerX + 64, centerY + 16, 8, input.b ? 'cyan' : 'grey');
  178. gfx.circle(centerX + 80, centerY, 8, input.a ? 'cyan' : 'grey');
  179. // dpad
  180. gfx.circle(centerX - 48, centerY, 8, input.right ? 'cyan' : 'grey');
  181. gfx.circle(centerX - 64, centerY - 16, 8, input.up ? 'cyan' : 'grey');
  182. gfx.circle(centerX - 64, centerY + 16, 8, input.down ? 'cyan' : 'grey');
  183. gfx.circle(centerX - 80, centerY, 8, input.left ? 'cyan' : 'grey');
  184. // L & R
  185. gfx.circle(centerX + 30, centerY - 32, 8, input.r ? 'cyan' : 'grey');
  186. gfx.circle(centerX - 30, centerY - 32, 8, input.l ? 'cyan' : 'grey');
  187. }
  188. }
  189. function debug(message) {
  190. const debugDiv = document.getElementById('debug');
  191. debugDiv.innerText = message;
  192. }
  193. function loop(world, gfx) {
  194. return timestampMs => {
  195. world.update(timestampMs);
  196. world.draw(gfx);
  197. window.requestAnimationFrame(loop(world, gfx));
  198. };
  199. }
  200. function setCanvasScale(scale) {
  201. const snesWidth = 256;
  202. const snesHeight = 224;
  203. const canvas = document.getElementById('canvas');
  204. canvas.style.width = '' + snesWidth * scale + 'px';
  205. canvas.style.height = '' + snesHeight * scale + 'px';
  206. }
  207. function init() {
  208. const world = new World();
  209. const gfx = new Graphics(document.getElementById('canvas'));
  210. document.getElementById('1x').onclick = () => setCanvasScale(1);
  211. document.getElementById('2x').onclick = () => setCanvasScale(2);
  212. document.getElementById('3x').onclick = () => setCanvasScale(3);
  213. document.getElementById('4x').onclick = () => setCanvasScale(4);
  214. document.getElementById('5x').onclick = () => setCanvasScale(5);
  215. setCanvasScale(4);
  216. window.requestAnimationFrame(loop(world, gfx));
  217. debug('initialized!');
  218. }
  219. init();