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.
/**
* 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;
}
