Back to Stick Brawl
SOURCE / PINNED RELEASE

Made of little things.

Stick Brawl

Release
3ef3c9faba5c…
Author-recorded commit
14482dfed9d1…
License
LICENSE
Author’s source reference
nostr://npub1ny7l44q0au5xkv3mjgt6zks5f0e2ydefslq4nhh2ve2xypgc2mqqat8qx2/wss%3A%2F%2Fgit.napplet.soy%2F/n-554b329db93

Archive hash verified: 1d3a7d5688a99948…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/main.ts
import { CONFIG_DOMAIN } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import { startSettings } from './napplet-settings.js';
import { Game, VIEW_H, VIEW_W } from './game/game.js';
import { createGamepadInput, type GamepadSample } from './gamepad.js';
import './styles.css';

// Stick Brawl keeps its own night-time colours; it never follows the host theme.

const app = requireElement<HTMLElement>('#app');
const stage = requireElement<HTMLDivElement>('#stage');
const screen = requireElement<HTMLDivElement>('#screen');
const canvas = requireElement<HTMLCanvasElement>('#game');
const menu = requireElement<HTMLDivElement>('#menu');
const menuButton = requireElement<HTMLButtonElement>('#menuButton');
const playButton = requireElement<HTMLButtonElement>('#playButton');
const restartButton = requireElement<HTMLButtonElement>('#restartButton');
const fullscreenButton = requireElement<HTMLButtonElement>('#fullscreenButton');
const menuFullscreenButton = requireElement<HTMLButtonElement>('#menuFullscreenButton');
const touch = requireElement<HTMLDivElement>('#touch');
const tooSmall = requireElement<HTMLDivElement>('#tooSmall');

function requireElement<T extends HTMLElement>(selector: string): T {
  const element = document.querySelector<T>(selector);
  if (!element) throw new Error(`Missing required element: ${selector}`);
  return element;
}

const game = new Game(canvas);

// ---------- held buttons ----------

type Held = 'left' | 'right' | 'down' | 'block';
const HELD: Held[] = ['left', 'right', 'down', 'block'];
/** What the keyboard and touch buttons are holding, and what controllers are holding. Either one counts. */
const keyHeld: Record<Held, boolean> = { left: false, right: false, down: false, block: false };
const padHeld: Record<Held, boolean> = { left: false, right: false, down: false, block: false };

function setKeyHeld(what: Held, down: boolean): void {
  keyHeld[what] = down;
  game.held[what] = keyHeld[what] || padHeld[what];
}

function setPadHeld(what: Held, down: boolean): void {
  padHeld[what] = down;
  game.held[what] = keyHeld[what] || padHeld[what];
}

// ---------- touch buttons ----------

const touchScreen = matchMedia('(pointer: coarse)').matches && !matchMedia('(any-pointer: fine)').matches;
let alwaysShowTouch = false;

function updateTouchMode(): void {
  const show = touchScreen || alwaysShowTouch;
  touch.hidden = !show;
  app.classList.toggle('touch-mode', show);
  fitScreen();
}

/** A touch button that works like holding a key, and optionally like pressing one. */
function touchButton(selector: string, held: 'left' | 'right' | 'down' | 'block' | null, press: 'jump' | 'attack' | 'kick' | 'roll' | 'shoot' | 'sword' | null): void {
  const button = requireElement<HTMLButtonElement>(selector);
  const release = () => {
    if (held) setKeyHeld(held, false);
    button.classList.remove('held');
  };
  button.addEventListener('pointerdown', (event) => {
    event.preventDefault();
    button.setPointerCapture(event.pointerId);
    if (held) setKeyHeld(held, true);
    if (press) game.press(press);
    button.classList.add('held');
  });
  button.addEventListener('pointerup', release);
  button.addEventListener('pointercancel', release);
  button.addEventListener('lostpointercapture', release);
  button.addEventListener('contextmenu', (event) => event.preventDefault());
}

touchButton('#touchLeft', 'left', null);
touchButton('#touchRight', 'right', null);
touchButton('#touchRoll', 'down', 'roll');
touchButton('#touchJump', null, 'jump');
touchButton('#touchAttack', null, 'attack');
touchButton('#touchBlock', 'block', null);
touchButton('#touchShoot', null, 'shoot');
touchButton('#touchSword', null, 'sword');
touchButton('#touchKick', null, 'kick');

// ---------- fitting the game to the frame ----------

/** Keep the game's wide shape and make it as big as the frame allows. */
function fitScreen(): void {
  const width = stage.clientWidth;
  const height = stage.clientHeight - (touch.hidden ? 0 : touch.offsetHeight + 8);
  const scale = Math.max(0.1, Math.min(width / VIEW_W, height / VIEW_H));
  const cssW = Math.floor(VIEW_W * scale);
  const cssH = Math.floor(VIEW_H * scale);
  screen.style.width = `${cssW}px`;
  screen.style.height = `${cssH}px`;
  game.resize(cssW, cssH);
  tooSmall.hidden = stage.clientWidth >= 240 && stage.clientHeight >= 170;
}

new ResizeObserver(fitScreen).observe(stage);

// ---------- the menu ----------

let started = false;

function openMenu(): void {
  game.paused = true;
  releaseAll();
  menu.hidden = false;
  menuButton.hidden = true;
  fullscreenButton.hidden = true;
  playButton.textContent = started ? 'Keep fighting' : 'Fight!';
  restartButton.hidden = !started;
  playButton.focus();
}

function closeMenu(): void {
  started = true;
  menu.hidden = true;
  menuButton.hidden = false;
  fullscreenButton.hidden = !document.fullscreenEnabled;
  game.paused = false;
  canvas.focus();
}

playButton.addEventListener('click', closeMenu);
restartButton.addEventListener('click', () => {
  game.restart();
  closeMenu();
});
menuButton.addEventListener('click', openMenu);

// ---------- full screen ----------

// Only offered when the page around the game allows it.
menuFullscreenButton.hidden = !document.fullscreenEnabled;

function toggleFullscreen(): void {
  if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
  else void app.requestFullscreen().catch(() => {});
}

for (const button of [fullscreenButton, menuFullscreenButton]) button.addEventListener('click', toggleFullscreen);

document.addEventListener('fullscreenchange', () => {
  const full = document.fullscreenElement !== null;
  fullscreenButton.title = full ? 'Leave full screen' : 'Full screen';
  menuFullscreenButton.textContent = full ? '⛶ Leave full screen' : '⛶ Full screen';
  if (menu.hidden) canvas.focus();
});

// ---------- keyboard ----------

function releaseAll(): void {
  for (const what of HELD) {
    keyHeld[what] = false;
    padHeld[what] = false;
    game.held[what] = false;
  }
}

const HELD_KEYS: Record<string, Held> = {
  ArrowLeft: 'left',
  ArrowRight: 'right',
  ArrowDown: 'down',
  ControlLeft: 'block',
  ControlRight: 'block',
};
const PRESS_KEYS: Record<string, 'jump' | 'attack' | 'kick' | 'roll' | 'shoot' | 'sword'> = {
  ArrowUp: 'jump',
  Space: 'attack',
  KeyZ: 'kick',
  ArrowDown: 'roll',
  AltLeft: 'shoot',
  ShiftLeft: 'sword',
};

window.addEventListener('keydown', (event) => {
  if (event.code === 'Escape') {
    event.preventDefault();
    if (menu.hidden) openMenu();
    else closeMenu();
    return;
  }
  // While the menu is open, keys work the buttons instead of the stickman.
  if (!menu.hidden) return;
  const held = HELD_KEYS[event.code];
  const press = PRESS_KEYS[event.code];
  if (!held && !press) return;
  event.preventDefault();
  if (held) setKeyHeld(held, true);
  if (press && !event.repeat) game.press(press);
});
window.addEventListener('keyup', (event) => {
  // Stop the browser from opening its own menu when Alt is let go.
  if (event.code === 'AltLeft') event.preventDefault();
  const held = HELD_KEYS[event.code];
  if (held) setKeyHeld(held, false);
});
window.addEventListener('blur', releaseAll);
document.addEventListener('visibilitychange', () => {
  if (document.hidden && menu.hidden) openMenu();
});
canvas.addEventListener('pointerdown', () => canvas.focus());

// ---------- game controllers ----------

/** The shell's controller domain, if it offers one. Otherwise we read controllers straight from the browser. */
type ShellGamepad = {
  getGamepads(): ArrayLike<GamepadSample | null>;
  available?: boolean | (() => boolean);
  focused?: boolean | (() => boolean);
};
const shellGamepad = (window as Window & { napplet?: { gamepad?: ShellGamepad } }).napplet?.gamepad;
const flag = (value: boolean | (() => boolean) | undefined, fallback: boolean) =>
  typeof value === 'function' ? value.call(shellGamepad) : (value ?? fallback);

// Standard layout positions: A jump, B kick, X punch, Y sword, LB/LT block, RB/RT shoot, Start menu.
const pad = createGamepadInput(
  {
    moveX: [{ axis: 0 }, { button: 14, scale: -1 }, { button: 15 }],
    moveY: [{ axis: 1 }, { button: 12, scale: -1 }, { button: 13 }],
    jump: [{ button: 0 }, { button: 12 }],
    kick: [{ button: 1 }],
    attack: [{ button: 2 }],
    sword: [{ button: 3 }],
    block: [{ button: 4 }, { button: 6 }],
    shoot: [{ button: 5 }, { button: 7 }],
    menu: [{ button: 9 }],
  },
  shellGamepad
    ? {
        read: () => {
          if (!flag(shellGamepad.available, true)) throw new Error('Controllers unavailable');
          return shellGamepad.getGamepads();
        },
        active: () => flag(shellGamepad.focused, document.hasFocus()) && !document.hidden,
      }
    : {},
);

/** Whether each controller's stick was pushed up or down last frame, so a push only counts once. */
const lastStick = new Map<number, { up: boolean; down: boolean; left: boolean; right: boolean }>();

/** Move between the menu's buttons. */
function moveMenuFocus(step: number): void {
  const buttons = [playButton, restartButton].filter((button) => !button.hidden);
  const at = buttons.indexOf(document.activeElement as HTMLButtonElement);
  buttons[(at + step + buttons.length) % buttons.length]?.focus();
}

function pollControllers(): void {
  const frame = pad.poll();
  if (frame.status !== 'ready') lastStick.clear();
  for (const index of frame.disconnected) lastStick.delete(index);

  const held: Record<Held, boolean> = { left: false, right: false, down: false, block: false };
  for (const controller of frame.players) {
    const a = controller.actions;
    const stick = { up: a.moveY.value <= -0.5, down: a.moveY.value >= 0.5, left: a.moveX.value <= -0.5, right: a.moveX.value >= 0.5 };
    // A controller we just met may already have the stick pushed: don't count that as a new push.
    const before = lastStick.get(controller.index) ?? stick;
    lastStick.set(controller.index, stick);

    if (!menu.hidden) {
      if (a.menu.pressed || a.jump.pressed) {
        const focused = document.activeElement;
        (focused === restartButton ? restartButton : playButton).click();
      } else if ((stick.up && !before.up) || (stick.left && !before.left)) moveMenuFocus(-1);
      else if ((stick.down && !before.down) || (stick.right && !before.right)) moveMenuFocus(1);
      continue;
    }
    if (a.menu.pressed) {
      openMenu();
      continue;
    }
    held.left ||= stick.left;
    held.right ||= stick.right;
    held.down ||= stick.down;
    held.block ||= a.block.down;
    if (stick.down && !before.down) {
      setPadHeld('down', true);
      game.press('roll');
    }
    if (a.jump.pressed) game.press('jump');
    if (a.attack.pressed) game.press('attack');
    if (a.kick.pressed) game.press('kick');
    if (a.shoot.pressed) game.press('shoot');
    if (a.sword.pressed) game.press('sword');
  }
  for (const what of HELD) {
    if (padHeld[what] !== (held[what] && menu.hidden)) setPadHeld(what, held[what] && menu.hidden);
  }
  requestAnimationFrame(pollControllers);
}

// Optional setting: show the touch buttons even with a keyboard. Without the config domain it stays off.
if (runtimeHasDomain(CONFIG_DOMAIN)) {
  startSettings((values) => {
    alwaysShowTouch = values.touchButtons === true;
    updateTouchMode();
  });
}

updateTouchMode();
openMenu();
game.start();
pollControllers();