SOURCE / PINNED RELEASE
Made of little things.
Mini Samurai
- Release
- a7beb798adbc…
- Author-recorded commit
- 4f97be1515b8…
- License
- LICENSE
- Author’s source reference
- nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-3b28f0efc5b
Archive hash verified: 401ec9347ab8f8cd…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import { ACTIONS, defaultControls, type ControlSettings, type KeyLayout } from './controls.js';
/** Raw per-player input, before it is turned camera-relative. */
export interface RawInput {
/** Screen-space stick: x right, y up/forward. */
x: number;
y: number;
run: boolean;
/** Car handbrake: the run key (left Shift) or right Shift, or the run button on a controller. */
handbrake: boolean;
crouch: boolean;
jump: boolean;
up: boolean;
down: boolean;
light: boolean;
heavy: boolean;
block: boolean;
grab: boolean;
/** Dodge roll (pressed this frame). */
roll: boolean;
/** Camera turn keys / right stick: -1 left .. 1 right. */
turn: number;
/** Right stick up/down: tilts the camera like moving the mouse (-1 stick up .. 1 stick down). */
tilt: number;
/** Mouse movement since the last frame, in pixels (player 1 only). */
lookX: number;
lookY: number;
}
/** Standard-mapping gamepad buttons. */
const PAD = { a: 0, b: 1, start: 9, up: 12, down: 13, left: 14, right: 15 };
export function emptyRaw(): RawInput {
return { x: 0, y: 0, run: false, handbrake: false, crouch: false, jump: false, up: false, down: false, light: false, heavy: false, block: false, grab: false, roll: false, turn: 0, tilt: 0, lookX: 0, lookY: 0 };
}
/** Touch overlay: a floating stick on the left half, action buttons on the right. */
export class TouchPad {
readonly el: HTMLDivElement;
private stickId: number | null = null;
private origin = { x: 0, y: 0 };
private vec = { x: 0, y: 0 };
private readonly held = new Set<string>();
private readonly pressed = new Set<string>();
private readonly knob: HTMLDivElement;
private readonly base: HTMLDivElement;
constructor(parent: HTMLElement) {
this.el = document.createElement('div');
this.el.className = 'touch';
this.el.innerHTML = `
<div class="touch-stick"><div class="touch-base"><div class="touch-knob"></div></div></div>
<div class="touch-buttons">
<button data-act="block" aria-label="Block">守<small>Block</small></button>
<button data-act="heavy" aria-label="Heavy attack">重<small>Heavy</small></button>
<button data-act="light" class="big" aria-label="Attack">斬<small>Attack</small></button>
<button data-act="crouch" aria-label="Crouch or drop">伏<small>Crouch</small></button>
<button data-act="grab" aria-label="Pick up">取<small>Grab</small></button>
<button data-act="jump" class="big" aria-label="Jump or climb">跳<small>Jump</small></button>
<button data-act="roll" aria-label="Roll">転<small>Roll</small></button>
</div>`;
parent.append(this.el);
this.base = this.el.querySelector('.touch-base')!;
this.knob = this.el.querySelector('.touch-knob')!;
const stick = this.el.querySelector<HTMLDivElement>('.touch-stick')!;
stick.addEventListener('pointerdown', (e) => {
if (this.stickId !== null) return;
this.stickId = e.pointerId;
stick.setPointerCapture(e.pointerId);
const r = stick.getBoundingClientRect();
this.origin = { x: e.clientX, y: e.clientY };
this.base.style.left = `${e.clientX - r.left}px`;
this.base.style.top = `${e.clientY - r.top}px`;
this.base.classList.add('on');
this.moveStick(e);
});
stick.addEventListener('pointermove', (e) => {
if (e.pointerId === this.stickId) this.moveStick(e);
});
const end = (e: PointerEvent) => {
if (e.pointerId !== this.stickId) return;
this.stickId = null;
this.vec = { x: 0, y: 0 };
this.knob.style.transform = '';
this.base.classList.remove('on');
};
stick.addEventListener('pointerup', end);
stick.addEventListener('pointercancel', end);
for (const btn of this.el.querySelectorAll<HTMLButtonElement>('button')) {
const act = btn.dataset.act!;
btn.addEventListener('pointerdown', (e) => {
e.preventDefault();
btn.setPointerCapture(e.pointerId);
this.held.add(act);
this.pressed.add(act);
btn.classList.add('down');
});
const up = () => {
this.held.delete(act);
btn.classList.remove('down');
};
btn.addEventListener('pointerup', up);
btn.addEventListener('pointercancel', up);
btn.addEventListener('contextmenu', (e) => e.preventDefault());
}
}
private moveStick(e: PointerEvent): void {
const max = 46;
let dx = e.clientX - this.origin.x;
let dy = e.clientY - this.origin.y;
const len = Math.hypot(dx, dy);
if (len > max) {
dx = (dx / len) * max;
dy = (dy / len) * max;
}
this.knob.style.transform = `translate(${dx}px, ${dy}px)`;
this.vec = { x: dx / max, y: -dy / max };
}
read(into: RawInput): void {
const len = Math.hypot(this.vec.x, this.vec.y);
if (len > 0.15) {
into.x += this.vec.x;
into.y += this.vec.y;
if (len > 0.92) into.run = true;
}
if (this.held.has('crouch')) into.crouch = true;
if (this.held.has('block')) into.block = true;
if (this.pressed.has('jump')) into.jump = into.up = true;
if (this.pressed.has('crouch')) into.down = true;
if (this.pressed.has('light')) into.light = true;
if (this.pressed.has('heavy')) into.heavy = true;
if (this.pressed.has('grab')) into.grab = true;
if (this.pressed.has('roll')) into.roll = true;
this.pressed.clear();
}
release(): void {
this.held.clear();
this.pressed.clear();
this.stickId = null;
this.vec = { x: 0, y: 0 };
}
set visible(v: boolean) {
this.el.hidden = !v;
}
}
/** Edges for driving menus with any controller. */
export interface MenuPad {
dx: number;
dy: number;
confirm: boolean;
back: boolean;
start: boolean;
}
export const MAX_PLAYERS = 4;
export class Input {
private readonly down = new Set<string>();
private readonly pressed = new Set<string>();
private mouseLight = false;
private mouseHeavy = false;
private lookX = 0;
private lookY = 0;
/** Last frame's buttons per gamepad index (for presses and joining). */
private readonly padPrev = new Map<number, boolean[]>();
private readonly menuPrev = new Map<number, { buttons: boolean[]; dx: number; dy: number }>();
/** Pads that joined this frame: their joining press shouldn't also act. */
private readonly justJoined = new Set<number>();
/**
* Which gamepad plays as each player (by browser gamepad index), or null.
* Player 1 always has the keyboard and mouse; slot 0 is only used when p1Pad is on.
*/
readonly slots: (number | null)[] = [null, null, null, null];
settings: ControlSettings = defaultControls();
private boundCodes = new Set<string>();
pausePressed = false;
touch: TouchPad | null = null;
/** While false (menus), keys keep their normal browser behaviour. */
capture = false;
/** Rebinding: the next key press is handed here instead of the game. */
keyCapture: ((code: string) => void) | null = null;
/** Rebinding: the next controller button press is handed here. */
padCapture: ((button: number) => void) | null = null;
/** Called on the first key or click so audio can start. */
onActivity: () => void = () => undefined;
/** A controller joined as (or left) a player (1-4). */
onJoin: (player: number) => void = () => undefined;
onLeave: (player: number) => void = () => undefined;
constructor(target: HTMLElement) {
this.setSettings(this.settings);
window.addEventListener(
'keydown',
(e) => {
this.onActivity();
if (this.keyCapture) {
e.preventDefault();
e.stopPropagation();
if (!e.repeat) {
const done = this.keyCapture;
this.keyCapture = null;
done(e.code);
}
return;
}
if (!this.capture) return;
if (e.code === 'Escape' || e.code === 'KeyP') {
e.preventDefault();
if (!e.repeat) this.pausePressed = true;
return;
}
if (!this.boundCodes.has(e.code)) return;
// Keep the browser from scrolling or focusing; Ctrl alone is a crouch key.
e.preventDefault();
if (!e.repeat) this.pressed.add(e.code);
this.down.add(e.code);
},
true,
);
window.addEventListener('keyup', (e) => {
this.down.delete(e.code);
// Releasing Ctrl/Shift can swallow the other key's keyup on some layouts.
if (e.key === 'Control') this.down.delete('ControlLeft'), this.down.delete('ControlRight');
if (e.key === 'Shift') this.down.delete('ShiftLeft'), this.down.delete('ShiftRight');
});
target.addEventListener('pointerdown', (e) => {
this.onActivity();
if (e.pointerType !== 'mouse') return;
if (e.button === 0) this.mouseLight = true;
if (e.button === 2) this.mouseHeavy = true;
});
target.addEventListener('contextmenu', (e) => e.preventDefault());
// Mouse look: works with pointer lock, or with a free cursor moving over the game.
window.addEventListener('mousemove', (e) => {
if (!this.capture) return;
this.lookX += Math.max(-200, Math.min(200, e.movementX));
this.lookY += Math.max(-200, Math.min(200, e.movementY));
});
window.addEventListener('blur', () => this.releaseAll());
document.addEventListener('visibilitychange', () => {
if (document.hidden) this.releaseAll();
});
// Some browsers only hand controllers to pages that listen for them.
window.addEventListener('gamepadconnected', () => this.onActivity());
}
setSettings(settings: ControlSettings): void {
const wasP1Pad = this.settings.p1Pad;
this.settings = settings;
// Right Shift is always a car handbrake too (left Shift, the run key, is the main one).
this.boundCodes = new Set([...ACTIONS.flatMap((a) => settings.keys[a]), 'ShiftRight']);
// Turning "player 1 uses a controller" off hands that controller back to the queue.
if (wasP1Pad && !settings.p1Pad && this.slots[0] !== null) {
this.slots[0] = null;
this.onLeave(1);
}
this.releaseAll();
}
releaseAll(): void {
this.down.clear();
this.pressed.clear();
this.mouseLight = this.mouseHeavy = false;
this.lookX = this.lookY = 0;
this.touch?.release();
}
/** Players taking part: player 1 plus every joined controller, packed together. */
get humans(): number {
let n = 1;
for (let i = 1; i < MAX_PLAYERS; i++) if (this.slots[i] !== null) n = i + 1;
return n;
}
/** Close gaps left by controllers that went away (between matches). */
compactSlots(): void {
const pads = this.slots.slice(1).filter((s): s is number => s !== null);
for (let i = 1; i < MAX_PLAYERS; i++) this.slots[i] = pads[i - 1] ?? null;
}
/** Let a joined controller go (Controls screen). */
release(player: number): void {
const i = player - 1;
if (this.slots[i] === null) return;
this.slots[i] = null;
this.onLeave(player);
}
private any(codes: string[], set: Set<string>): boolean {
for (const c of codes) if (set.has(c)) return true;
return false;
}
private readKeys(map: KeyLayout, into: RawInput): void {
const d = this.down;
const p = this.pressed;
into.x += (this.any(map.right, d) ? 1 : 0) - (this.any(map.left, d) ? 1 : 0);
into.y += (this.any(map.up, d) ? 1 : 0) - (this.any(map.down, d) ? 1 : 0);
into.run ||= this.any(map.run, d);
into.handbrake ||= this.any(map.run, d) || d.has('ShiftRight');
into.crouch ||= this.any(map.crouch, d) || this.any(map.crouch, p);
into.block ||= this.any(map.block, d);
into.jump ||= this.any(map.jump, p);
into.up ||= this.any(map.up, p) || this.any(map.jump, p);
into.down ||= this.any(map.down, p);
into.light ||= this.any(map.light, p);
into.heavy ||= this.any(map.heavy, p);
into.grab ||= this.any(map.grab, p);
into.roll ||= this.any(map.roll, p);
into.turn += (this.any(map.turnRight, d) ? 1 : 0) - (this.any(map.turnLeft, d) ? 1 : 0);
}
/** Read every device once per frame. Returns one RawInput per player slot (4). */
poll(): RawInput[] {
const players = Array.from({ length: MAX_PLAYERS }, emptyRaw);
this.readKeys(this.settings.keys, players[0]!);
if (this.mouseLight) players[0]!.light = true;
if (this.mouseHeavy) players[0]!.heavy = true;
players[0]!.lookX = this.lookX;
players[0]!.lookY = this.lookY;
this.lookX = this.lookY = 0;
this.touch?.read(players[0]!);
this.pollPads(players);
for (const p of players) {
const len = Math.hypot(p.x, p.y);
if (len > 1) {
p.x /= len;
p.y /= len;
}
}
this.pressed.clear();
this.mouseLight = this.mouseHeavy = false;
return players;
}
/** Why controllers can't be read at all, if they can't (shown in the Controls screen). */
padError: string | null = null;
private gamepads(): (Gamepad | null)[] {
if (typeof navigator.getGamepads !== 'function') {
this.padError = 'This browser has no controller support.';
return [];
}
try {
const pads = [...navigator.getGamepads()];
this.padError = null;
return pads;
} catch (e) {
// Gamepad access can be blocked by policy (e.g. a host frame); keyboard and touch still work.
this.padError = `The browser blocked controller access: ${e instanceof Error ? e.message : String(e)}`;
return [];
}
}
/** Stick positions when each controller was first seen (to notice a stick being moved). */
private readonly restAxes = new Map<number, number[]>();
/** Controllers the browser has shown us (joined or not). */
private readonly seen = new Set<number>();
/** A controller became visible but hasn't joined yet. */
onSeen: (index: number, id: string) => void = () => undefined;
/** Live description of what the browser reports, for the Controls screen's Controller check. */
diagnostics(): { error: string | null; pads: { index: number; id: string; mapping: string; player: number; buttons: number[]; axes: string[] }[] } {
const pads = this.gamepads()
.filter((p): p is Gamepad => !!p && p.connected)
.map((p) => ({
index: p.index,
id: p.id,
mapping: p.mapping || 'non-standard',
player: this.slots.indexOf(p.index) + 1,
buttons: p.buttons.map((b, i) => (b.pressed ? i : -1)).filter((i) => i >= 0),
axes: p.axes.map((a) => a.toFixed(2)),
}));
return { error: this.padError, pads };
}
/**
* Notice controllers going away, and let any controller that presses a button
* join as the next free player.
*/
private scanPads(pads: (Gamepad | null)[]): void {
this.justJoined.clear();
for (let i = 0; i < MAX_PLAYERS; i++) {
const index = this.slots[i];
if (index !== null && index !== undefined && !pads[index]?.connected) {
this.slots[i] = null;
this.onLeave(i + 1);
}
}
for (const pad of pads) {
// Any controller counts, not only ones the browser maps to the standard layout.
if (!pad?.connected) {
if (pad) {
this.padPrev.delete(pad.index);
this.restAxes.delete(pad.index);
this.seen.delete(pad.index);
}
continue;
}
const firstSight = !this.seen.has(pad.index);
if (firstSight) {
this.seen.add(pad.index);
// Sticks rest near 0; triggers reported as axes may rest at -1. Remember where they sit.
this.restAxes.set(pad.index, pad.axes.map((a) => (Math.abs(a) > 0.5 ? a : 0)));
}
// Browsers often reveal a controller only once a button is pressed on it, so a
// button already down when we first see it is that joining press.
const prev = this.padPrev.get(pad.index) ?? [];
if (this.slots.includes(pad.index) || this.padCapture) continue;
const rest = this.restAxes.get(pad.index) ?? [];
// A button press or a firm push of a stick / D-pad (some report the D-pad as axes).
const pressed =
pad.buttons.some((b, i) => (b.pressed || b.value > 0.5) && !prev[i]) || pad.axes.some((a, i) => Math.abs(a - (rest[i] ?? 0)) > 0.6);
if (!pressed) {
if (firstSight) this.onSeen(pad.index, pad.id);
continue;
}
const free = this.settings.p1Pad && this.slots[0] === null ? 0 : this.slots.findIndex((s, i) => i > 0 && s === null);
if (free < 0) continue;
this.slots[free] = pad.index;
this.justJoined.add(pad.index);
this.onJoin(free + 1);
}
}
private pollPads(players: RawInput[]): void {
// No focus check: inside a host frame the page may not report focus, and the
// browser already stops sending controller input to background tabs.
const pads = this.gamepads();
this.scanPads(pads);
const map = this.settings.pad;
this.slots.forEach((index, player) => {
if (index === null) return;
const pad = pads[index];
if (!pad) return;
const prev = this.padPrev.get(index) ?? [];
const btn = (i: number) => pad.buttons[i]?.pressed ?? false;
const held = (list: number[]) => list.some(btn);
const edge = (list: number[]) => list.some((i) => btn(i) && !prev[i]);
if (!this.padCapture && !this.justJoined.has(index)) {
const into = players[player]!;
let ax = pad.axes[0] ?? 0;
let ay = -(pad.axes[1] ?? 0);
if (Math.hypot(ax, ay) < 0.2) ax = ay = 0;
ax += (btn(PAD.right) ? 1 : 0) - (btn(PAD.left) ? 1 : 0);
ay += (btn(PAD.up) ? 1 : 0) - (btn(PAD.down) ? 1 : 0);
into.x += ax;
into.y += ay;
into.run ||= held(map.run);
into.handbrake ||= held(map.run);
into.crouch ||= held(map.crouch);
into.block ||= held(map.block);
into.jump ||= edge(map.jump);
into.up ||= edge(map.jump) || (btn(PAD.up) && !prev[PAD.up]);
into.down ||= edge(map.crouch) || (btn(PAD.down) && !prev[PAD.down]);
into.light ||= edge(map.light);
into.heavy ||= edge(map.heavy);
into.grab ||= edge(map.grab);
into.roll ||= edge(map.roll);
// Right stick turns and tilts the camera (on unrecognised layouts axes 2-3 may be triggers).
const rx = pad.mapping === 'standard' ? (pad.axes[2] ?? 0) : 0;
const ry = pad.mapping === 'standard' ? (pad.axes[3] ?? 0) : 0;
if (Math.abs(rx) > 0.25) into.turn += rx;
if (Math.abs(ry) > 0.25) into.tilt += ry;
if (btn(PAD.start) && !prev[PAD.start]) this.pausePressed = true;
}
});
// Remember every pad's buttons (joined or not) for the next frame's presses.
for (const pad of pads) if (pad?.connected) this.padPrev.set(pad.index, pad.buttons.map((b) => b.pressed));
}
/** Menu navigation from any controller: stick/D-pad steps, A confirm, B back, Start. */
menuPoll(): MenuPad {
const out: MenuPad = { dx: 0, dy: 0, confirm: false, back: false, start: false };
for (const pad of this.gamepads()) {
if (!pad?.connected) continue;
const state = this.menuPrev.get(pad.index) ?? { buttons: pad.buttons.map((b) => b.pressed), dx: 0, dy: 0 };
const btn = (i: number) => pad.buttons[i]?.pressed ?? false;
const edge = (i: number) => btn(i) && !state.buttons[i];
if (this.padCapture) {
// Rebinding a controller button: the first new press on any controller is the answer.
const pressed = pad.buttons.findIndex((b, i) => b.pressed && !state.buttons[i]);
if (pressed >= 0 && pressed !== PAD.start) {
const done = this.padCapture;
this.padCapture = null;
done(pressed);
}
} else if (!this.justJoined.has(pad.index)) {
// Stick flicks count once until the stick returns near the middle.
const sx = Math.abs(pad.axes[0] ?? 0) > 0.6 ? Math.sign(pad.axes[0]!) : 0;
const sy = Math.abs(pad.axes[1] ?? 0) > 0.6 ? Math.sign(pad.axes[1]!) : 0;
const dx = (btn(PAD.right) ? 1 : 0) - (btn(PAD.left) ? 1 : 0) || sx;
const dy = (btn(PAD.down) ? 1 : 0) - (btn(PAD.up) ? 1 : 0) || sy;
if (dx && dx !== state.dx) out.dx = dx;
if (dy && dy !== state.dy) out.dy = dy;
state.dx = dx;
state.dy = dy;
out.confirm ||= edge(PAD.a);
out.back ||= edge(PAD.b);
out.start ||= edge(PAD.start);
}
state.buttons = pad.buttons.map((b) => b.pressed);
this.menuPrev.set(pad.index, state);
}
return out;
}
takePause(): boolean {
const p = this.pausePressed;
this.pausePressed = false;
return p;
}
}
