Back to Sickman Survival
SOURCE / PINNED RELEASE

Made of little things.

Sickman Survival

Release
7335c9fdf0a8…
Author-recorded commit
cbd2eeafa70d…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-8804151df7f

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

src/roster.ts
/**
 * Local players, assigned automatically from the connected gamepads.
 *
 * Keyboard and touch drive one solo player, together with a single gamepad.
 * A second gamepad switches to split screen: the solo player keeps their slot
 * and takes the lowest-index pad, and every other pad joins as a new player.
 * Unplugging a pad removes its player; back down to one, play returns to solo.
 * Pads are tracked by browser `index`, never by array position.
 */

export const MAX_PLAYERS = 4;

/** `'solo'` merges keyboard, touch and every pad; a number is one gamepad index. */
export type InputSource = 'solo' | number;

export interface RosterPlayer {
  slot: number;
  source: InputSource;
}

export interface RosterChange {
  added: number[];
  removed: number[];
}

export type PadStatus = 'ready' | 'waiting' | 'inactive' | 'unavailable' | 'blocked' | 'closed';

export class Roster {
  mode: 'solo' | 'split' = 'solo';
  players: RosterPlayer[] = [{ slot: 0, source: 'solo' }];

  /**
   * Reconcile with the pads present this frame. `replaced` lists indices whose
   * pad disconnected or was swapped this frame, even if the index is present again.
   */
  sync(pads: readonly number[], status: PadStatus = 'ready', replaced: readonly number[] = []): RosterChange {
    const change: RosterChange = { added: [], removed: [] };
    // A missing or blocked API is not evidence that every controller was unplugged.
    if (status === 'blocked' || status === 'unavailable') return change;
    const present = new Set(pads);

    if (this.mode === 'split') {
      const gone = this.players.filter(
        (p) => typeof p.source === 'number' && (!present.has(p.source) || replaced.includes(p.source)),
      );
      // Keyboard and touch always keep somebody in play: if every pad left at once, the first player stays.
      if (gone.length === this.players.length) gone.shift();
      for (const player of gone) this.remove(player.slot, change);
      if (this.players.length <= 1) {
        this.mode = 'solo';
        this.players[0].source = 'solo';
      }
    }

    if (this.mode === 'solo') {
      if (present.size < 2) return change;
      // The second pad splits the screen; the solo player takes the first pad.
      this.mode = 'split';
      const ordered = [...present].sort((a, b) => a - b);
      this.players[0].source = ordered[0];
    }

    for (const index of [...present].sort((a, b) => a - b)) {
      if (this.players.length >= MAX_PLAYERS) break;
      if (this.players.some((p) => p.source === index)) continue;
      const slot = [0, 1, 2, 3].find((s) => !this.players.some((p) => p.slot === s))!;
      this.players.push({ slot, source: index });
      change.added.push(slot);
    }
    this.players.sort((a, b) => a.slot - b.slot);
    return change;
  }

  sourceOf(slot: number): InputSource | undefined {
    return this.players.find((p) => p.slot === slot)?.source;
  }

  private remove(slot: number, change: RosterChange): void {
    this.players = this.players.filter((p) => p.slot !== slot);
    change.removed.push(slot);
  }
}

export interface Rect {
  x: number;
  y: number;
  width: number;
  height: number;
}

/** The game frames about 480×340 world units; split along the side that keeps panes closest to that. */
const TARGET_ASPECT = 480 / 340;

/** Pane rectangles for `count` players in a `width`×`height` frame, tiled edge to edge in whole pixels. */
export function layoutPanes(count: number, width: number, height: number): Rect[] {
  let cols: number;
  let rows: number;
  if (count <= 1) {
    cols = rows = 1;
  } else if (count === 2) {
    const sideBySide = Math.abs(Math.log(width / 2 / height / TARGET_ASPECT));
    const stacked = Math.abs(Math.log(width / (height / 2) / TARGET_ASPECT));
    [cols, rows] = sideBySide < stacked ? [2, 1] : [1, 2];
  } else {
    cols = rows = 2;
  }
  const rects: Rect[] = [];
  for (let i = 0; i < cols * rows; i++) {
    const col = i % cols;
    const row = Math.floor(i / cols);
    const x = Math.floor((col * width) / cols);
    const y = Math.floor((row * height) / rows);
    rects.push({
      x,
      y,
      width: Math.floor(((col + 1) * width) / cols) - x,
      height: Math.floor(((row + 1) * height) / rows) - y,
    });
  }
  return rects;
}