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.

592 lines
16 KiB

  1. const SNES_WIDTH = 256;
  2. const SNES_HEIGHT = 224;
  3. const Orientation = {
  4. UP: 'up',
  5. DOWN: 'down',
  6. LEFT: 'left',
  7. RIGHT: 'right'
  8. }
  9. function bound(low, x, high) {
  10. return Math.max(low, Math.min(x, high));
  11. }
  12. // Representation of the state of the buttons on an SNES controller. (This may
  13. // be the result of keyboard or gamepad input, which get mapped to the
  14. // standard SNES buttons.)
  15. class SnesInput {
  16. constructor() {
  17. this.reset();
  18. }
  19. reset() {
  20. this.up = false;
  21. this.down = false;
  22. this.left = false;
  23. this.right = false;
  24. this.a = false;
  25. this.b = false;
  26. this.x = false;
  27. this.y = false;
  28. this.l = false;
  29. this.r = false;
  30. this.select = false;
  31. this.start = false;
  32. }
  33. copyFrom(other) {
  34. this.up = other.up;
  35. this.down = other.down;
  36. this.left = other.left;
  37. this.right = other.right;
  38. this.a = other.a;
  39. this.b = other.b;
  40. this.x = other.x;
  41. this.y = other.y;
  42. this.l = other.l;
  43. this.r = other.r;
  44. this.select = other.select;
  45. this.start = other.start;
  46. }
  47. toString() {
  48. let result = '';
  49. if (this.up) {
  50. result += '^';
  51. } else if (this.down) {
  52. result += 'v';
  53. } else {
  54. result += '-';
  55. }
  56. if (this.left) {
  57. result += '<';
  58. } else if (this.right) {
  59. result += '>';
  60. } else {
  61. result += '-';
  62. }
  63. result += ' ';
  64. if (this.a) {
  65. result += 'A';
  66. }
  67. if (this.b) {
  68. result += 'B';
  69. }
  70. if (this.x) {
  71. result += 'X';
  72. }
  73. if (this.y) {
  74. result += 'Y';
  75. }
  76. if (this.l) {
  77. result += 'L';
  78. }
  79. if (this.r) {
  80. result += 'R';
  81. }
  82. if (this.select) {
  83. result += 's';
  84. }
  85. if (this.start) {
  86. result += 'S';
  87. }
  88. return result;
  89. }
  90. }
  91. class SnesGamepad {
  92. update(input) {
  93. const gamepads = navigator.getGamepads();
  94. if (gamepads.length < 1 || !gamepads[0] || !gamepads[0].connected) {
  95. input.reset();
  96. return;
  97. }
  98. this.wasConnected = true;
  99. // TODO: have a config screen instead of hard-coding the 8Bitdo SNES30 pad.
  100. const gamepad = gamepads[0];
  101. input.up = gamepad.axes[1] < 0;
  102. input.down = gamepad.axes[1] > 0;
  103. input.left = gamepad.axes[0] < 0;
  104. input.right = gamepad.axes[0] > 0;
  105. input.a = gamepad.buttons[0].pressed;
  106. input.b = gamepad.buttons[1].pressed;
  107. input.x = gamepad.buttons[3].pressed;
  108. input.y = gamepad.buttons[4].pressed;
  109. input.l = gamepad.buttons[6].pressed;
  110. input.r = gamepad.buttons[7].pressed;
  111. input.select = gamepad.buttons[10].pressed;
  112. input.start = gamepad.buttons[11].pressed;
  113. }
  114. }
  115. class InputHandler {
  116. constructor() {
  117. this.keysPressed = {};
  118. this.gamepad = new SnesGamepad();
  119. window.addEventListener(
  120. 'gamepadconnected', (e) => this.gamepadConnected(e));
  121. window.addEventListener(
  122. 'gamepaddisconnected', (e) => this.gamepadDisconnected(e));
  123. document.addEventListener('keydown', (e) => this.keyDown(e));
  124. document.addEventListener('keyup', (e) => this.keyUp(e));
  125. }
  126. keyDown(e) {
  127. this.keysPressed[e.key] = true;
  128. }
  129. keyUp(e) {
  130. this.keysPressed[e.key] = false;
  131. }
  132. update(input) {
  133. this.gamepad.update(input);
  134. // Default ZSNES keybindings. See:
  135. // http://zsnes-docs.sourceforge.net/html/readme.htm#default_keys
  136. input.up |= this.keysPressed['ArrowUp'];
  137. input.down |= this.keysPressed['ArrowDown'];
  138. input.left |= this.keysPressed['ArrowLeft'];
  139. input.right |= this.keysPressed['ArrowRight'];
  140. input.start |= this.keysPressed['Enter'];
  141. input.select |= this.keysPressed['Shift'];
  142. input.a |= this.keysPressed['x'];
  143. input.b |= this.keysPressed['z'];
  144. input.x |= this.keysPressed['s'];
  145. input.y |= this.keysPressed['a'];
  146. input.l |= this.keysPressed['d'];
  147. input.r |= this.keysPressed['c'];
  148. debug(input.toString());
  149. }
  150. gamepadConnected(e) {
  151. debug('gamepad connected! :)');
  152. console.log('gamepad connected @ index %d: %d buttons, %d axes\n[%s]',
  153. e.gamepad.index, e.gamepad.buttons.length, e.gamepad.axes.length,
  154. e.gamepad.id);
  155. }
  156. gamepadDisconnected(e) {
  157. debug('gamepad disconnected :(');
  158. console.log('gamepad disconnected @ index %d:\n[%s]', e.gamepad.index,
  159. e.gamepad.id);
  160. }
  161. }
  162. class Graphics {
  163. constructor(canvas) {
  164. this.canvas_ = canvas;
  165. this.ctx_ = canvas.getContext('2d');
  166. this.ctx_.imageSmoothingEnabled = false;
  167. }
  168. get width() {
  169. return this.canvas_.width;
  170. }
  171. get height() {
  172. return this.canvas_.height;
  173. }
  174. fill(color) {
  175. this.ctx_.fillStyle = color;
  176. this.ctx_.beginPath();
  177. this.ctx_.rect(0, 0, this.canvas_.width, this.canvas_.height);
  178. this.ctx_.fill();
  179. this.ctx_.closePath();
  180. }
  181. circle(x, y, radius, color) {
  182. this.ctx_.fillStyle = color;
  183. this.ctx_.beginPath();
  184. this.ctx_.arc(x, y, radius, 0, 2 * Math.PI)
  185. this.ctx_.fill();
  186. this.ctx_.closePath();
  187. }
  188. // TODO: replace with custom sprite-based text rendering.
  189. text(string, x, y, size, color) {
  190. this.ctx_.fillStyle = color;
  191. this.ctx_.font = '' + size + 'px monospace';
  192. this.ctx_.fillText(string, x, y);
  193. }
  194. drawSprite(sprite, dx, dy) {
  195. this.ctx_.drawImage(
  196. sprite.image,
  197. sprite.ulx, sprite.uly,
  198. sprite.width, sprite.height,
  199. dx, dy,
  200. sprite.width, sprite.height);
  201. }
  202. }
  203. class FpsCounter {
  204. constructor() {
  205. this.fps = 0;
  206. this.frameTimes_ = new Array(60);
  207. this.idx_ = 0;
  208. }
  209. update(timestampMs) {
  210. if (this.frameTimes_[this.idx_]) {
  211. const timeElapsed = (timestampMs - this.frameTimes_[this.idx_]) / 1000;
  212. this.fps = this.frameTimes_.length / timeElapsed;
  213. }
  214. this.frameTimes_[this.idx_] = timestampMs;
  215. this.idx_++;
  216. if (this.idx_ == this.frameTimes_.length) {
  217. this.idx_ = 0;
  218. }
  219. }
  220. draw(gfx) {
  221. const fpsDiv = document.getElementById('fps');
  222. fpsDiv.innerText = 'FPS: ' + Math.round(this.fps);
  223. }
  224. }
  225. class World {
  226. constructor() {
  227. this.state_ = null;
  228. this.fpsCounter_ = new FpsCounter();
  229. this.inputHandler_ = new InputHandler();
  230. this.input_ = new SnesInput();
  231. this.lastInput_ = new SnesInput();
  232. this.player_ = new Player();
  233. this.layer1_ = [
  234. "~~888888888888~~",
  235. "~8..,_.--.,.*.8~",
  236. "~._.*._.,.--,._~",
  237. "~_``_.,_..._..`~",
  238. "~,.`.....'...._~",
  239. "~.`_'*...._.._.~",
  240. "~,'.'_-'...__.`~",
  241. "~....,'...*,'..~",
  242. "~_..'_......_..~",
  243. "~*..``.,.,..,.,~",
  244. "~..'._'*'..___.~",
  245. "~._'.-....,.`..~",
  246. "~2_.*_,'-...`-2~",
  247. "~~222222222222~~"];
  248. this.layer2_ = [
  249. " T ",
  250. " ",
  251. " ",
  252. " ",
  253. " ",
  254. " ",
  255. " o ",
  256. " c c ",
  257. " ",
  258. " c c ",
  259. " ",
  260. " ",
  261. " ",
  262. " "];
  263. // TODO: move rendering stuff to a separate object.
  264. this.resources_ = new Resources();
  265. this.tileRenderer_ = new TileRenderer();
  266. this.playerRenderer_ = new PlayerRenderer();
  267. this.gamepadRenderer_ = new GamepadRenderer();
  268. }
  269. update(timestampMs) {
  270. this.fpsCounter_.update(timestampMs);
  271. // We copy values to avoid allocating new SnesInput objects every frame.
  272. // TODO: is this actually worth it?
  273. this.lastInput_.copyFrom(this.input_);
  274. this.inputHandler_.update(this.input_);
  275. if (!this.lastInput_.r && this.input_.r) {
  276. this.player_.cycleSprite();
  277. }
  278. if (this.input_.left) {
  279. this.player_.moveLeft();
  280. }
  281. if (this.input_.right) {
  282. this.player_.moveRight();
  283. }
  284. if (this.input_.up) {
  285. this.player_.moveUp();
  286. }
  287. if (this.input_.down) {
  288. this.player_.moveDown();
  289. }
  290. }
  291. draw(gfx) {
  292. gfx.fill('black');
  293. this.tileRenderer_.draw(gfx, this.resources_.sprites, this.layer1_);
  294. this.playerRenderer_.draw(gfx, this.resources_.sprites, this.player_);
  295. this.tileRenderer_.draw(gfx, this.resources_.sprites, this.layer2_);
  296. // this.gamepadRenderer_.draw(gfx, this.input_);
  297. this.fpsCounter_.draw(gfx);
  298. }
  299. }
  300. class Sprite {
  301. constructor(image, ulx, uly, width, height) {
  302. this.image = image;
  303. this.ulx = ulx;
  304. this.uly = uly;
  305. this.width = width;
  306. this.height = height;
  307. }
  308. }
  309. class CharacterSprite {
  310. constructor(image, ulx, uly, tileWidth, tileHeight) {
  311. // Assumption: a character sprite consists of 4 rows, which include the
  312. // character facing down, left, right, up (in that order). Each row has 3
  313. // columns, which can be used for a walking animation.
  314. this.down = [];
  315. this.left = [];
  316. this.right = [];
  317. this.up = [];
  318. for (let i = 0; i < 3; i++) {
  319. const x = ulx + i * tileWidth;
  320. this.down.push(new Sprite(
  321. image, x, uly, tileWidth, tileHeight));
  322. this.left.push(new Sprite(
  323. image, x, uly + tileHeight, tileWidth, tileHeight));
  324. this.right.push(new Sprite(
  325. image, x, uly + tileHeight * 2, tileWidth, tileHeight));
  326. this.up.push(new Sprite(
  327. image, x, uly + tileHeight * 3, tileWidth, tileHeight));
  328. }
  329. }
  330. }
  331. class Resources {
  332. constructor() {
  333. const terrain = document.getElementById('terrain');
  334. const inside = document.getElementById('inside');
  335. const outside = document.getElementById('outside');
  336. const water = document.getElementById('water');
  337. const witch = document.getElementById('witch');
  338. const spritesheet = document.getElementById('spritesheet');
  339. const ts = 16;
  340. this.sprites = {
  341. 'ground0': new Sprite(terrain, 2 * ts, 1 * ts, 16, 16),
  342. 'ground1': new Sprite(terrain, 3 * ts, 1 * ts, 16, 16),
  343. 'ground2': new Sprite(terrain, 4 * ts, 1 * ts, 16, 16),
  344. 'ground3': new Sprite(terrain, 5 * ts, 1 * ts, 16, 16),
  345. 'ground4': new Sprite(terrain, 6 * ts, 1 * ts, 16, 16),
  346. 'ground5': new Sprite(terrain, 2 * ts, 2 * ts, 16, 16),
  347. 'ground6': new Sprite(terrain, 6 * ts, 2 * ts, 16, 16),
  348. 'water_ul': new Sprite(water, 1 * ts, 9 * ts, 16, 16),
  349. 'water_uc': new Sprite(water, 2 * ts, 9 * ts, 16, 16),
  350. 'water_ur': new Sprite(water, 3 * ts, 9 * ts, 16, 16),
  351. 'water_ml': new Sprite(water, 1 * ts, 10 * ts, 16, 16),
  352. 'water_mc': new Sprite(water, 2 * ts, 10 * ts, 16, 16),
  353. 'water_mr': new Sprite(water, 3 * ts, 10 * ts, 16, 16),
  354. 'water_ll': new Sprite(water, 1 * ts, 11 * ts, 16, 16),
  355. 'water_lc': new Sprite(water, 2 * ts, 11 * ts, 16, 16),
  356. 'water_lr': new Sprite(water, 3 * ts, 11 * ts, 16, 16),
  357. 'cauldron_blue': new Sprite(witch, 0, 0, 32, 32),
  358. 'cauldron_green': new Sprite(witch, 32, 0, 32, 32),
  359. 'cup': new Sprite(inside, 41 * ts, 27 * ts, 16, 16),
  360. 'teapot': new Sprite(inside, 36 * ts, 25 * ts, 16, 16),
  361. 'bigtree': new Sprite(outside, 528, 16, 96, 96),
  362. }
  363. for (const key in spritesheet_json) {
  364. const sprite_data = spritesheet_json[key];
  365. this.sprites[sprite_data['name']] = new CharacterSprite(
  366. spritesheet, sprite_data['x'], sprite_data['y'],
  367. sprite_data['width'] / 3, sprite_data['height'] / 4);
  368. }
  369. }
  370. }
  371. class Player {
  372. // TODO: stop hard-coding player bounding box.
  373. constructor() {
  374. this.x = (SNES_WIDTH - 26) / 2;
  375. this.y = (SNES_HEIGHT - 36) / 2 - 28;
  376. this.orientation = Orientation.DOWN;
  377. this.spriteNames_ = [];
  378. for (const name in spritesheet_json) {
  379. this.spriteNames_.push(name);
  380. }
  381. this.spriteNamesIdx_ = 0;
  382. }
  383. get spriteName() {
  384. return this.spriteNames_[this.spriteNamesIdx_];
  385. }
  386. cycleSprite() {
  387. this.spriteNamesIdx_++;
  388. if (this.spriteNamesIdx_ >= this.spriteNames_.length) {
  389. this.spriteNamesIdx_ = 0;
  390. }
  391. }
  392. moveLeft() {
  393. this.orientation = Orientation.LEFT;
  394. this.x -= 2;
  395. if (this.x < -2) {
  396. this.x = -2;
  397. }
  398. }
  399. moveRight() {
  400. this.orientation = Orientation.RIGHT;
  401. this.x += 2;
  402. if (this.x > SNES_WIDTH - 23) {
  403. this.x = SNES_WIDTH - 23;
  404. }
  405. }
  406. moveUp() {
  407. this.orientation = Orientation.UP;
  408. this.y -= 2;
  409. if (this.y < -2) {
  410. this.y = -2;
  411. }
  412. }
  413. moveDown() {
  414. this.orientation = Orientation.DOWN;
  415. this.y += 2;
  416. if (this.y > SNES_HEIGHT - 36) {
  417. this.y = SNES_HEIGHT - 36;
  418. }
  419. }
  420. }
  421. class PlayerRenderer {
  422. constructor() {
  423. this.frameNum = 0;
  424. }
  425. draw(gfx, sprites, player) {
  426. let spriteIndex = Math.floor((this.frameNum % 40) / 10);
  427. if (spriteIndex == 3) { spriteIndex = 1; }
  428. const charSprite = sprites[player.spriteName][player.orientation][spriteIndex];
  429. gfx.drawSprite(charSprite, player.x, player.y);
  430. this.frameNum++;
  431. }
  432. }
  433. class TileRenderer {
  434. draw(gfx, sprites, layer) {
  435. const tileSize = 16;
  436. // The screen is 14 x 16 tiles by default.
  437. const rows = gfx.height / tileSize;
  438. const columns = gfx.width / tileSize;
  439. const spriteLookup = {
  440. '.': sprites.ground0,
  441. ',': sprites.ground1,
  442. '_': sprites.ground2,
  443. '`': sprites.ground3,
  444. '-': sprites.ground4,
  445. '*': sprites.ground5,
  446. "'": sprites.ground6,
  447. '~': sprites.water_mc,
  448. '2': sprites.water_uc,
  449. '8': sprites.water_lc,
  450. 'O': sprites.cauldron_blue,
  451. 'o': sprites.cauldron_green,
  452. 'c': sprites.cup,
  453. 't': sprites.teapot,
  454. 'T': sprites.bigtree,
  455. };
  456. for (let j = 0; j < columns; j++) {
  457. for (let i = 0; i < rows; i++) {
  458. const dx = tileSize * j;
  459. const dy = tileSize * i;
  460. const sprite = spriteLookup[layer[i][j]];
  461. if (sprite) {
  462. gfx.drawSprite(sprite, dx, dy);
  463. }
  464. }
  465. }
  466. }
  467. }
  468. class GamepadRenderer {
  469. draw(gfx, input) {
  470. const centerX = gfx.width / 2;
  471. const centerY = gfx.height / 2;
  472. // Select & Start
  473. gfx.circle(centerX + 12, centerY, 8, input.start ? 'cyan' : 'grey');
  474. gfx.circle(centerX - 12, centerY, 8, input.select ? 'cyan' : 'grey');
  475. // Y X B A
  476. gfx.circle(centerX + 48, centerY, 8, input.y ? 'cyan' : 'grey');
  477. gfx.circle(centerX + 64, centerY - 16, 8, input.x ? 'cyan' : 'grey');
  478. gfx.circle(centerX + 64, centerY + 16, 8, input.b ? 'cyan' : 'grey');
  479. gfx.circle(centerX + 80, centerY, 8, input.a ? 'cyan' : 'grey');
  480. // dpad
  481. gfx.circle(centerX - 48, centerY, 8, input.right ? 'cyan' : 'grey');
  482. gfx.circle(centerX - 64, centerY - 16, 8, input.up ? 'cyan' : 'grey');
  483. gfx.circle(centerX - 64, centerY + 16, 8, input.down ? 'cyan' : 'grey');
  484. gfx.circle(centerX - 80, centerY, 8, input.left ? 'cyan' : 'grey');
  485. // L & R
  486. gfx.circle(centerX + 30, centerY - 32, 8, input.r ? 'cyan' : 'grey');
  487. gfx.circle(centerX - 30, centerY - 32, 8, input.l ? 'cyan' : 'grey');
  488. }
  489. }
  490. function debug(message) {
  491. const debugDiv = document.getElementById('debug');
  492. debugDiv.innerText = message;
  493. }
  494. function loop(world, gfx) {
  495. return timestampMs => {
  496. world.update(timestampMs);
  497. world.draw(gfx);
  498. window.requestAnimationFrame(loop(world, gfx));
  499. };
  500. }
  501. function setCanvasScale(scale) {
  502. const canvas = document.getElementById('canvas');
  503. canvas.style.width = '' + SNES_WIDTH * scale + 'px';
  504. canvas.style.height = '' + SNES_HEIGHT * scale + 'px';
  505. canvas.style.display = '';
  506. debug('set scale to ' + scale + 'x');
  507. }
  508. function setAutoCanvasScale() {
  509. const widthAspect = Math.floor(window.innerWidth / SNES_WIDTH);
  510. const heightAspect = Math.floor(window.innerHeight / SNES_HEIGHT);
  511. const scale = bound(1, Math.min(widthAspect, heightAspect), 8);
  512. setCanvasScale(scale);
  513. }
  514. function init() {
  515. const world = new World();
  516. const gfx = new Graphics(document.getElementById('canvas'));
  517. document.getElementById('1x').onclick = () => setCanvasScale(1);
  518. document.getElementById('2x').onclick = () => setCanvasScale(2);
  519. document.getElementById('3x').onclick = () => setCanvasScale(3);
  520. document.getElementById('4x').onclick = () => setCanvasScale(4);
  521. document.getElementById('5x').onclick = () => setCanvasScale(5);
  522. document.getElementById('6x').onclick = () => setCanvasScale(6);
  523. document.getElementById('7x').onclick = () => setCanvasScale(7);
  524. document.getElementById('8x').onclick = () => setCanvasScale(8);
  525. setAutoCanvasScale();
  526. window.requestAnimationFrame(loop(world, gfx));
  527. }
  528. init();