Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions example/pong-network/apps/client/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { NanoforgeFactory } from "@nanoforge-dev/core";
import { EcsLibrary } from "@nanoforge-dev/ecs/client";
import { Circle, Graphics2DLibrary, Layer, Rect } from "@nanoforge-dev/graphics-2d";
import { InputEnum, InputLibrary } from "@nanoforge-dev/input";
import { ControllerLibrary } from "@nanoforge-dev/input/controller";
import { Channel, NetworkClientLibrary } from "@nanoforge-dev/network/client";

import { AssignId, Play } from "../../../shared/protocol";
Expand All @@ -27,14 +28,23 @@ export const main = async (options: ClientRunOptions): Promise<void> => {
const ecs = new EcsLibrary();
const network = new NetworkClientLibrary();
const input = new InputLibrary();
const controller = new ControllerLibrary();

app.use(graphics);
app.use(ecs);
app.use(network);
app.use(input);
app.use(controller);

await app.init(options);

// TEMP controller test: log connections and rumble on connect.
controller.onConnect((c) => {
console.log("Manette connectée :", c.id, "mapping:", c.mapping);
void controller.vibrate({ duration: 300 });
});
controller.onDisconnect((c) => console.log("Manette déconnectée :", c.id));

const registry = ecs.registry;

graphics.stage.add(layer);
Expand Down
14 changes: 12 additions & 2 deletions example/pong-network/apps/client/src/systems/systems.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type Context } from "@nanoforge-dev/common";
import { type Registry } from "@nanoforge-dev/ecs/client";
import { ControllerAxis, ControllerButton } from "@nanoforge-dev/input/controller";

import { Input, Move } from "../../../../shared/protocol";
import {
Expand All @@ -24,10 +25,19 @@ export const controlPlayer = (registry: Registry, ctx: Context) => {
const entities = registry.getZipper([Controller]);
const input = ctx.input;
const network = ctx.network;
const pad = ctx.controller;

entities.forEach(({ Controller }) => {
const upPressed = input.isKeyPressed(Controller.up);
const downPressed = input.isKeyPressed(Controller.down);
// TEMP controller test: D-pad or left stick drive the paddle too.
const stickY = pad.getAxis(ControllerAxis.LeftStickY);
const upPressed =
!!input.isKeyPressed(Controller.up) ||
pad.isButtonPressed(ControllerButton.DPadUp) ||
stickY < -0.5;
const downPressed =
!!input.isKeyPressed(Controller.down) ||
pad.isButtonPressed(ControllerButton.DPadDown) ||
stickY > 0.5;
if (upPressed == downPressed) {
if (Controller.lastPressedUp || Controller.lastPressedDown) {
network.packets.send(Input, { key: "stop" });
Expand Down
2 changes: 1 addition & 1 deletion modules/ecs/build/libecs.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export interface ClassHandle {
[Symbol.dispose](): void;
clone(): this;
}
export interface container extends ClassHandle {
export interface container extends ClassHandle, Iterable<any | undefined> {
size(): number;
get(_0: number): any | undefined | undefined;
push_back(_0?: any): void;
Expand Down
22 changes: 21 additions & 1 deletion modules/input/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "@nanoforge-dev/input",
"version": "2.0.0",
"description": "NanoForge Engine - Input",
"description": "NanoForge Engine - Input (keyboard, mouse and controller)",
"keywords": [
"nanoforge",
"game",
Expand Down Expand Up @@ -35,6 +35,26 @@
"default": "./dist/index.js"
}
},
"./keyboard-mouse": {
"require": {
"types": "./dist/keyboard-mouse/index.d.cts",
"default": "./dist/keyboard-mouse/index.cjs"
},
"import": {
"types": "./dist/keyboard-mouse/index.d.ts",
"default": "./dist/keyboard-mouse/index.js"
}
},
"./controller": {
"require": {
"types": "./dist/controller/index.d.cts",
"default": "./dist/controller/index.cjs"
},
"import": {
"types": "./dist/controller/index.d.ts",
"default": "./dist/controller/index.js"
}
},
"./package.json": "./package.json"
},
"type": "module",
Expand Down
7 changes: 7 additions & 0 deletions modules/input/src/controller/context-augmentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { ControllerContextApi } from "./controller-context.type";

declare module "@nanoforge-dev/common" {
interface Context {
controller: ControllerContextApi;
}
}
23 changes: 23 additions & 0 deletions modules/input/src/controller/controller-context.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { ControllerAxis, ControllerButton, ControllerStick } from "./controller.enum";
import type {
ControllerListener,
ControllerState,
ControllerVibration,
StickPosition,
} from "./controller.types";

/** Public surface of `ControllerLibrary`, exposed on `Context.controller`. */
export interface ControllerContextApi {
getControllers(): ControllerState[];
getController(index?: number): ControllerState | undefined;
isConnected(index?: number): boolean;
isButtonPressed(button: ControllerButton, index?: number): boolean;
isButtonJustPressed(button: ControllerButton, index?: number): boolean;
isButtonJustReleased(button: ControllerButton, index?: number): boolean;
getButtonValue(button: ControllerButton, index?: number): number;
getAxis(axis: ControllerAxis, index?: number): number;
getStick(stick: ControllerStick, index?: number): StickPosition;
vibrate(effect: ControllerVibration, index?: number): Promise<boolean>;
onConnect(listener: ControllerListener): () => void;
onDisconnect(listener: ControllerListener): () => void;
}
148 changes: 148 additions & 0 deletions modules/input/src/controller/controller-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { nextButtonState } from "../shared";
import { ControllerAxis, type ControllerStick } from "./controller.enum";
import type {
ControllerButtonState,
ControllerListener,
ControllerState,
StickPosition,
} from "./controller.types";

const STICK_AXES: Record<ControllerStick, [ControllerAxis, ControllerAxis]> = {
left: [ControllerAxis.LeftStickX, ControllerAxis.LeftStickY],
right: [ControllerAxis.RightStickX, ControllerAxis.RightStickY],
};

/**
* Tracks connected controllers through the Gamepad API.
*
* @remarks
* The Gamepad API does not emit events for buttons or axes: state is read by
* `poll`, once per frame. Connection events are only used to report
* connections/disconnections as early as possible; `poll` also detects them,
* since some browsers only expose a controller after its first input.
*/
export class ControllerHandler {
private readonly deadzone: number;
private readonly controllers = new Map<number, ControllerState>();
private readonly connectListeners = new Set<ControllerListener>();
private readonly disconnectListeners = new Set<ControllerListener>();

constructor(deadzone: number) {
this.deadzone = deadzone;

window.addEventListener("gamepadconnected", this.onConnected);
window.addEventListener("gamepaddisconnected", this.onDisconnected);
}

/** Removes every listener registered by the constructor. */
public destroy(): void {
window.removeEventListener("gamepadconnected", this.onConnected);
window.removeEventListener("gamepaddisconnected", this.onDisconnected);
this.controllers.clear();
this.connectListeners.clear();
this.disconnectListeners.clear();
}

/** Reads every gamepad and updates button transitions for this frame. */
public poll(): void {
const seen = new Set<number>();
for (const gamepad of navigator.getGamepads()) {
if (!gamepad?.connected) continue;
seen.add(gamepad.index);
this.update(gamepad);
}
for (const index of [...this.controllers.keys()]) {
if (!seen.has(index)) this.disconnect(index);
}
}

public getControllers(): ControllerState[] {
return [...this.controllers.values()];
}

public getController(index: number): ControllerState | undefined {
return this.controllers.get(index);
}

public getButton(button: number, index: number): ControllerButtonState | undefined {
return this.controllers.get(index)?.buttons[button];
}

/** Axis value with the deadzone applied and rescaled to keep the full range. */
public getAxis(axis: number, index: number): number {
const value = this.controllers.get(index)?.axes[axis] ?? 0;
const magnitude = Math.abs(value);
if (magnitude < this.deadzone) return 0;
return (Math.sign(value) * (magnitude - this.deadzone)) / (1 - this.deadzone);
}

/** Stick position with a radial deadzone, so diagonals are not clipped. */
public getStick(stick: ControllerStick, index: number): StickPosition {
const controller = this.controllers.get(index);
const [axisX, axisY] = STICK_AXES[stick];
const x = controller?.axes[axisX] ?? 0;
const y = controller?.axes[axisY] ?? 0;
const magnitude = Math.hypot(x, y);
if (magnitude < this.deadzone) return { x: 0, y: 0 };
const scale = Math.min(1, (magnitude - this.deadzone) / (1 - this.deadzone)) / magnitude;
return { x: x * scale, y: y * scale };
}

public async vibrate(
index: number,
duration: number,
strongMagnitude: number,
weakMagnitude: number,
): Promise<boolean> {
const gamepad = navigator.getGamepads()[index];
const actuator = gamepad?.vibrationActuator as GamepadHapticActuator | null | undefined;
if (!actuator) return false;
const result = await actuator.playEffect("dual-rumble", {
duration,
strongMagnitude,
weakMagnitude,
});
return result === "complete";
}

public onConnect(listener: ControllerListener): () => void {
this.connectListeners.add(listener);
return () => this.connectListeners.delete(listener);
}

public onDisconnect(listener: ControllerListener): () => void {
this.disconnectListeners.add(listener);
return () => this.disconnectListeners.delete(listener);
}

private readonly onConnected = (e: GamepadEvent): void => {
this.update(e.gamepad);
};

private readonly onDisconnected = (e: GamepadEvent): void => {
this.disconnect(e.gamepad.index);
};

private update(gamepad: Gamepad): void {
const previous = this.controllers.get(gamepad.index);
const state: ControllerState = {
index: gamepad.index,
id: gamepad.id,
mapping: gamepad.mapping,
buttons: gamepad.buttons.map((button, i) => ({
...nextButtonState(button.pressed, previous?.buttons[i]?.pressed),
value: button.value,
})),
axes: [...gamepad.axes],
};
this.controllers.set(gamepad.index, state);
if (!previous) this.connectListeners.forEach((listener) => listener(state));
}

private disconnect(index: number): void {
const state = this.controllers.get(index);
if (!state) return;
this.controllers.delete(index);
this.disconnectListeners.forEach((listener) => listener(state));
}
}
52 changes: 52 additions & 0 deletions modules/input/src/controller/controller.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Controller buttons, following the W3C "standard" gamepad layout.
*
* @remarks
* Values are the indices of `Gamepad.buttons` when `Gamepad.mapping` is
* `"standard"`. Face buttons are named after their Xbox labels: `A` is the
* bottom face button (Cross on PlayStation), `B` the right one, `X` the left
* one and `Y` the top one. Use with `ControllerLibrary.isButtonPressed`.
*
* @example
* ```ts
* if (ctx.controller.isButtonJustPressed(ControllerButton.A)) {
* player.jump();
* }
* ```
*/
export enum ControllerButton {
A = 0,
B = 1,
X = 2,
Y = 3,
LeftBumper = 4,
RightBumper = 5,
LeftTrigger = 6,
RightTrigger = 7,
Select = 8,
Start = 9,
LeftStick = 10,
RightStick = 11,
DPadUp = 12,
DPadDown = 13,
DPadLeft = 14,
DPadRight = 15,
Home = 16,
}

/**
* Controller axes, following the W3C "standard" gamepad layout.
*
* @remarks
* Values are the indices of `Gamepad.axes` when `Gamepad.mapping` is
* `"standard"`. Axis values range from `-1` to `1`; negative is left/up.
*/
export enum ControllerAxis {
LeftStickX = 0,
LeftStickY = 1,
RightStickX = 2,
RightStickY = 3,
}

/** Analog sticks available on a "standard" controller. */
export type ControllerStick = "left" | "right";
Loading
Loading