diff --git a/.idea/[NanoForge] Engine.iml b/.idea/[NanoForge] Engine.iml
index 6291e06c..85b6fa8e 100644
--- a/.idea/[NanoForge] Engine.iml
+++ b/.idea/[NanoForge] Engine.iml
@@ -13,6 +13,5 @@
-
\ No newline at end of file
diff --git a/packages/common/src/library/libraries/abstracts/input.library.abstract.ts b/packages/common/src/library/libraries/abstracts/input.library.abstract.ts
index e41f8629..4befe142 100644
--- a/packages/common/src/library/libraries/abstracts/input.library.abstract.ts
+++ b/packages/common/src/library/libraries/abstracts/input.library.abstract.ts
@@ -1,4 +1,7 @@
+import { type Context } from "../../../context";
import { type IInputLibrary } from "../interfaces";
import { Library } from "../library";
-export abstract class BaseInputLibrary extends Library implements IInputLibrary {}
+export abstract class BaseInputLibrary extends Library implements IInputLibrary {
+ public abstract __run(_context: Context): Promise;
+}
diff --git a/packages/common/src/library/libraries/interfaces/finals/input.library.type.ts b/packages/common/src/library/libraries/interfaces/finals/input.library.type.ts
index 596a519b..e01957b4 100644
--- a/packages/common/src/library/libraries/interfaces/finals/input.library.type.ts
+++ b/packages/common/src/library/libraries/interfaces/finals/input.library.type.ts
@@ -1,3 +1,4 @@
import { type IExposedLibrary } from "../bases/exposed.library.type";
+import { type IRunnerLibrary } from "../bases/runner.library.type";
-export interface IInputLibrary extends IExposedLibrary {}
+export interface IInputLibrary extends IExposedLibrary, IRunnerLibrary {}
diff --git a/packages/input/src/input-handler.ts b/packages/input/src/input-handler.ts
index 1a842a5a..13c83bf8 100644
--- a/packages/input/src/input-handler.ts
+++ b/packages/input/src/input-handler.ts
@@ -1,9 +1,41 @@
import { InputEnum } from "./input.enum";
+import {
+ BUTTONS_MASKS,
+ type DragState,
+ MOUSE_BUTTON_MAP,
+ type MouseState,
+ type WheelState,
+} from "./mouse.types";
export class InputHandler {
public inputs: Record = {};
+ public mouse: MouseState = {
+ x: 0,
+ y: 0,
+ prevX: 0,
+ prevY: 0,
+ deltaX: 0,
+ deltaY: 0,
+ focus: false,
+ };
+ public wheel: WheelState = {
+ deltaX: 0,
+ deltaY: 0,
+ deltaZ: 0,
+ };
+ public drag: DragState = {
+ active: false,
+ startX: 0,
+ startY: 0,
+ x: 0,
+ y: 0,
+ deltaX: 0,
+ deltaY: 0,
+ };
constructor() {
+ this.resetInputs();
+
window.addEventListener("keydown", (e: KeyboardEvent) => {
this.inputs[e.code] = true;
});
@@ -12,12 +44,134 @@ export class InputHandler {
this.inputs[e.code] = false;
});
+ window.addEventListener("mousedown", (e: MouseEvent) => {
+ const button = MOUSE_BUTTON_MAP[e.button];
+ if (button === undefined) return;
+
+ this.inputs[button] = true;
+ this.updatePointer(e);
+
+ this.drag.active = true;
+ this.drag.button = button;
+ this.drag.startX = e.clientX;
+ this.drag.startY = e.clientY;
+ this.drag.x = e.clientX;
+ this.drag.y = e.clientY;
+ this.drag.deltaX = 0;
+ this.drag.deltaY = 0;
+ });
+
+ window.addEventListener("mouseup", (e: MouseEvent) => {
+ const button = MOUSE_BUTTON_MAP[e.button];
+ if (button !== undefined) this.inputs[button] = false;
+
+ this.updatePointer(e);
+
+ if (this.drag.button === button) {
+ this.drag.active = false;
+ this.drag.button = undefined;
+ this.drag.x = 0;
+ this.drag.y = 0;
+ this.drag.deltaX = 0;
+ this.drag.deltaY = 0;
+ }
+ });
+
+ window.addEventListener("mousemove", (e: MouseEvent) => {
+ this.updatePointer(e);
+ this.updateInputsMouseButtons(e.buttons);
+
+ if (this.drag.active) {
+ this.drag.x = e.clientX;
+ this.drag.y = e.clientY;
+ this.drag.deltaX = e.clientX - this.drag.startX;
+ this.drag.deltaY = e.clientY - this.drag.startY;
+ }
+ });
+
+ window.addEventListener("wheel", (e: WheelEvent) => {
+ this.wheel.deltaX += e.deltaX;
+ this.wheel.deltaY += e.deltaY;
+ this.wheel.deltaZ += e.deltaZ;
+ });
+
+ window.addEventListener("mouseenter", () => {
+ this.mouse.focus = true;
+ });
+
+ window.addEventListener("mouseleave", () => {
+ this.mouse.focus = false;
+ });
+
+ window.addEventListener("blur", () => {
+ this.resetInputs();
+ });
+
+ document.addEventListener("visibilitychange", () => {
+ if (document.hidden) this.resetInputs();
+ });
+
for (const key in InputEnum) {
this.inputs[key] = false;
}
}
- getKeyStatus(key: InputEnum): boolean {
+ public getKeyStatus(key: InputEnum): boolean {
return this.inputs[key] || false;
}
+
+ public getMousePosition() {
+ return { x: this.mouse.x, y: this.mouse.y };
+ }
+
+ public isDragging(button?: InputEnum): boolean {
+ if (!button) return this.drag.active;
+ return this.drag.active && this.drag.button === button;
+ }
+
+ public resetPerFrame(): void {
+ this.mouse.deltaX = 0;
+ this.mouse.deltaY = 0;
+ this.wheel.deltaX = 0;
+ this.wheel.deltaY = 0;
+ this.wheel.deltaZ = 0;
+ }
+
+ private updatePointer(e: MouseEvent): void {
+ this.mouse.prevX = this.mouse.x;
+ this.mouse.prevY = this.mouse.y;
+ this.mouse.x = e.clientX;
+ this.mouse.y = e.clientY;
+ this.mouse.deltaX = this.mouse.x - this.mouse.prevX;
+ this.mouse.deltaY = this.mouse.y - this.mouse.prevY;
+ }
+
+ private updateInputsMouseButtons(buttons: number): void {
+ for (const [mask, input] of BUTTONS_MASKS) {
+ this.inputs[input] = (buttons & mask) !== 0;
+ }
+ }
+
+ private resetInputs(): void {
+ for (const key of Object.values(InputEnum)) {
+ this.inputs[key] = false;
+ }
+
+ this.drag.active = false;
+ this.drag.button = undefined;
+ this.drag.startX = 0;
+ this.drag.startY = 0;
+ this.drag.x = 0;
+ this.drag.y = 0;
+ this.drag.deltaX = 0;
+ this.drag.deltaY = 0;
+
+ this.wheel.deltaX = 0;
+ this.wheel.deltaY = 0;
+ this.wheel.deltaZ = 0;
+
+ this.mouse.deltaX = 0;
+ this.mouse.deltaY = 0;
+ this.mouse.focus = false;
+ }
}
diff --git a/packages/input/src/input.enum.ts b/packages/input/src/input.enum.ts
index cb1899c7..1cb53165 100644
--- a/packages/input/src/input.enum.ts
+++ b/packages/input/src/input.enum.ts
@@ -141,4 +141,9 @@ export enum InputEnum {
BrowserBack = "BrowserBack",
LaunchApp1 = "LaunchApp1",
LaunchMail = "LaunchMail",
+ MouseLeft = "MouseLeft",
+ MouseMiddle = "MouseMiddle",
+ MouseRight = "MouseRight",
+ Back = "BackButton",
+ Forward = "Forward",
}
diff --git a/packages/input/src/input.library.ts b/packages/input/src/input.library.ts
index 7eb50a43..103d93f3 100644
--- a/packages/input/src/input.library.ts
+++ b/packages/input/src/input.library.ts
@@ -1,11 +1,16 @@
-import { BaseInputLibrary } from "@nanoforge-dev/common";
+import { BaseInputLibrary, GRAPHICS_LIBRARY } from "@nanoforge-dev/common";
import { InputHandler } from "./input-handler";
import { type InputEnum } from "./input.enum";
+import { type DragState, type MouseState, type WheelState } from "./mouse.types";
export class InputLibrary extends BaseInputLibrary {
private _inputHandler?: InputHandler;
+ constructor() {
+ super({ runAfter: [GRAPHICS_LIBRARY] });
+ }
+
get __name(): string {
return "InputLibrary";
}
@@ -14,6 +19,11 @@ export class InputLibrary extends BaseInputLibrary {
this._inputHandler = new InputHandler();
}
+ public override async __run() {
+ if (!this._inputHandler) this.throwNotInitializedError();
+ this._inputHandler.resetPerFrame();
+ }
+
public isKeyPressed(key: InputEnum): boolean | undefined {
if (!this._inputHandler) this.throwNotInitializedError();
return this._inputHandler.getKeyStatus(key);
@@ -28,4 +38,29 @@ export class InputLibrary extends BaseInputLibrary {
}
return res;
}
+
+ public getMousePosition(): { x: number; y: number } {
+ if (!this._inputHandler) this.throwNotInitializedError();
+ return this._inputHandler.getMousePosition();
+ }
+
+ public getMouseState(): MouseState {
+ if (!this._inputHandler) this.throwNotInitializedError();
+ return this._inputHandler.mouse;
+ }
+
+ public isDragging(button?: InputEnum): boolean {
+ if (!this._inputHandler) this.throwNotInitializedError();
+ return this._inputHandler.isDragging(button);
+ }
+
+ public getDragState(): DragState {
+ if (!this._inputHandler) this.throwNotInitializedError();
+ return this._inputHandler.drag;
+ }
+
+ public getWheelState(): WheelState {
+ if (!this._inputHandler) this.throwNotInitializedError();
+ return this._inputHandler.wheel;
+ }
}
diff --git a/packages/input/src/mouse.types.ts b/packages/input/src/mouse.types.ts
new file mode 100644
index 00000000..b6d95f13
--- /dev/null
+++ b/packages/input/src/mouse.types.ts
@@ -0,0 +1,40 @@
+import { InputEnum } from "./input.enum";
+
+export type MouseState = {
+ x: number;
+ y: number;
+ prevX: number;
+ prevY: number;
+ deltaX: number;
+ deltaY: number;
+ focus: boolean;
+};
+
+export type DragState = {
+ active: boolean;
+ button?: InputEnum | undefined;
+ startX: number;
+ startY: number;
+ x: number;
+ y: number;
+ deltaX: number;
+ deltaY: number;
+};
+
+export type WheelState = { deltaX: number; deltaY: number; deltaZ: number };
+
+export const MOUSE_BUTTON_MAP: Partial> = {
+ 0: InputEnum.MouseLeft,
+ 1: InputEnum.MouseMiddle,
+ 2: InputEnum.MouseRight,
+ 3: InputEnum.Back,
+ 4: InputEnum.Forward,
+};
+
+export const BUTTONS_MASKS = new Map([
+ [1, InputEnum.MouseLeft],
+ [2, InputEnum.MouseRight],
+ [4, InputEnum.MouseMiddle],
+ [8, InputEnum.Back],
+ [16, InputEnum.Forward],
+]);
diff --git a/packages/input/test/input.library.spec.ts b/packages/input/test/input.library.spec.ts
index f126f0d5..f0ae56eb 100644
--- a/packages/input/test/input.library.spec.ts
+++ b/packages/input/test/input.library.spec.ts
@@ -2,25 +2,43 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { InputEnum, InputLibrary } from "../src";
-const makeWindowMock = () => {
- const listeners: Record void)[]> = {};
+type MockInputEvent = {
+ code?: string;
+ button?: number;
+ buttons?: number;
+ clientX?: number;
+ clientY?: number;
+ deltaX?: number;
+ deltaY?: number;
+ deltaZ?: number;
+};
+
+const makeEventTargetMock = () => {
+ const listeners: Record void)[]> = {};
return {
- addEventListener: vi.fn((event: string, handler: (e: KeyboardEvent) => void) => {
+ addEventListener: vi.fn((event: string, handler: (e: MockInputEvent) => void) => {
if (!listeners[event]) listeners[event] = [];
listeners[event].push(handler);
}),
- dispatch: (event: string, e: Partial) => {
- listeners[event]?.forEach((h) => h(e as KeyboardEvent));
+ dispatch: (event: string, e: Partial) => {
+ listeners[event]?.forEach((h) => h(e as MockInputEvent));
},
};
};
describe("InputLibrary", () => {
- let windowMock: ReturnType;
+ let windowMock: ReturnType;
+ let documentMock: ReturnType & { hidden: boolean };
beforeEach(() => {
- windowMock = makeWindowMock();
+ windowMock = makeEventTargetMock();
+ documentMock = {
+ ...makeEventTargetMock(),
+ hidden: false,
+ };
+
vi.stubGlobal("window", windowMock);
+ vi.stubGlobal("document", documentMock);
});
afterEach(() => {
@@ -34,14 +52,21 @@ describe("InputLibrary", () => {
});
describe("before initialization", () => {
- it("should throw when isKeyPressed is called before __init", () => {
+ it("should throw when methods are called before __init", () => {
const library = new InputLibrary();
expect(() => library.isKeyPressed(InputEnum.KeyA)).toThrow();
+ expect(() => library.getPressedKeys()).toThrow();
+ expect(() => library.getMousePosition()).toThrow();
+ expect(() => library.getMouseState()).toThrow();
+ expect(() => library.isDragging()).toThrow();
+ expect(() => library.getDragState()).toThrow();
+ expect(() => library.getWheelState()).toThrow();
});
- it("should throw when getPressedKeys is called before __init", () => {
+ it("should throw when __run is called before __init", async () => {
const library = new InputLibrary();
- expect(() => library.getPressedKeys()).toThrow();
+
+ await expect(library.__run()).rejects.toThrow();
});
});
@@ -53,9 +78,20 @@ describe("InputLibrary", () => {
await library.__init();
});
- it("should register keydown and keyup event listeners", () => {
+ it("should register all expected event listeners", () => {
expect(windowMock.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
expect(windowMock.addEventListener).toHaveBeenCalledWith("keyup", expect.any(Function));
+ expect(windowMock.addEventListener).toHaveBeenCalledWith("mousedown", expect.any(Function));
+ expect(windowMock.addEventListener).toHaveBeenCalledWith("mouseup", expect.any(Function));
+ expect(windowMock.addEventListener).toHaveBeenCalledWith("mousemove", expect.any(Function));
+ expect(windowMock.addEventListener).toHaveBeenCalledWith("wheel", expect.any(Function));
+ expect(windowMock.addEventListener).toHaveBeenCalledWith("mouseenter", expect.any(Function));
+ expect(windowMock.addEventListener).toHaveBeenCalledWith("mouseleave", expect.any(Function));
+ expect(windowMock.addEventListener).toHaveBeenCalledWith("blur", expect.any(Function));
+ expect(documentMock.addEventListener).toHaveBeenCalledWith(
+ "visibilitychange",
+ expect.any(Function),
+ );
});
it("should return false for any key before any key event", () => {
@@ -96,5 +132,176 @@ describe("InputLibrary", () => {
expect(pressed).not.toContain(InputEnum.KeyA);
expect(pressed).toContain(InputEnum.Space);
});
+
+ it("should handle mouse button press and release", () => {
+ windowMock.dispatch("mousedown", { button: 0, clientX: 10, clientY: 20 });
+ expect(library.isKeyPressed(InputEnum.MouseLeft)).toBe(true);
+
+ windowMock.dispatch("mouseup", { button: 0, clientX: 10, clientY: 20 });
+ expect(library.isKeyPressed(InputEnum.MouseLeft)).toBe(false);
+ });
+
+ it("should handle unknown mouse input", () => {
+ windowMock.dispatch("mousedown", { button: -2 });
+ windowMock.dispatch("mousedown", { button: 999 });
+ windowMock.dispatch("mouseup", { button: 999 });
+
+ const pressed = library.getPressedKeys();
+
+ expect(pressed).toHaveLength(0);
+ });
+
+ it("should update mouse position and state on move", () => {
+ windowMock.dispatch("mousemove", { clientX: 100, clientY: 200, buttons: 0 });
+
+ expect(library.getMousePosition()).toStrictEqual({ x: 100, y: 200 });
+ expect(library.getMouseState()).toMatchObject({
+ x: 100,
+ y: 200,
+ deltaX: 100,
+ deltaY: 200,
+ });
+ });
+
+ it("should start, update and stop dragging", () => {
+ windowMock.dispatch("mousedown", { button: 0, clientX: 10, clientY: 20 });
+ expect(library.isDragging()).toBe(true);
+ expect(library.isDragging(InputEnum.MouseLeft)).toBe(true);
+
+ windowMock.dispatch("mousemove", { clientX: 25, clientY: 45, buttons: 1 });
+ expect(library.getDragState()).toMatchObject({
+ active: true,
+ startX: 10,
+ startY: 20,
+ x: 25,
+ y: 45,
+ deltaX: 15,
+ deltaY: 25,
+ });
+
+ windowMock.dispatch("mouseup", { button: 0, clientX: 25, clientY: 45 });
+ expect(library.isDragging()).toBe(false);
+ expect(library.getDragState()).toMatchObject({
+ active: false,
+ deltaX: 0,
+ deltaY: 0,
+ });
+ });
+
+ it("should accumulate wheel delta", () => {
+ windowMock.dispatch("wheel", { deltaX: 1, deltaY: 2, deltaZ: 3 });
+ windowMock.dispatch("wheel", { deltaX: 4, deltaY: 5, deltaZ: 6 });
+
+ expect(library.getWheelState()).toStrictEqual({
+ deltaX: 5,
+ deltaY: 7,
+ deltaZ: 9,
+ });
+ });
+
+ it("should reset per frame on __run", async () => {
+ windowMock.dispatch("mousemove", { clientX: 10, clientY: 20, buttons: 0 });
+ windowMock.dispatch("wheel", { deltaX: 1, deltaY: 2, deltaZ: 3 });
+
+ await library.__run();
+
+ expect(library.getMouseState()).toMatchObject({
+ deltaX: 0,
+ deltaY: 0,
+ });
+ expect(library.getWheelState()).toStrictEqual({
+ deltaX: 0,
+ deltaY: 0,
+ deltaZ: 0,
+ });
+ });
+
+ it("should reset inputs on blur", () => {
+ windowMock.dispatch("keydown", { code: InputEnum.KeyA });
+ windowMock.dispatch("mousedown", { button: 0, clientX: 10, clientY: 20 });
+ windowMock.dispatch("wheel", { deltaX: 1, deltaY: 2, deltaZ: 3 });
+
+ windowMock.dispatch("blur", {});
+
+ expect(library.isKeyPressed(InputEnum.KeyA)).toBe(false);
+ expect(library.isKeyPressed(InputEnum.MouseLeft)).toBe(false);
+ expect(library.isDragging()).toBe(false);
+ expect(library.getWheelState()).toStrictEqual({
+ deltaX: 0,
+ deltaY: 0,
+ deltaZ: 0,
+ });
+ });
+
+ it("should reset inputs when document becomes hidden", () => {
+ windowMock.dispatch("keydown", { code: InputEnum.KeyA });
+ documentMock.hidden = true;
+
+ documentMock.dispatch("visibilitychange", {});
+
+ expect(library.isKeyPressed(InputEnum.KeyA)).toBe(false);
+ });
+
+ it("should update pointer focus on mouse enter and leave", () => {
+ windowMock.dispatch("mouseenter", {});
+ expect(library.getMouseState().focus).toBe(true);
+
+ windowMock.dispatch("mouseleave", {});
+ expect(library.getMouseState().focus).toBe(false);
+ });
+
+ it("should fully reset drag state when the released button matches the active drag button", () => {
+ windowMock.dispatch("mousedown", { button: 0, clientX: 10, clientY: 20 });
+ windowMock.dispatch("mousemove", { clientX: 30, clientY: 50, buttons: 1 });
+
+ expect(library.getDragState()).toMatchObject({
+ active: true,
+ button: InputEnum.MouseLeft,
+ x: 30,
+ y: 50,
+ deltaX: 20,
+ deltaY: 30,
+ });
+
+ windowMock.dispatch("mouseup", { button: 0, clientX: 30, clientY: 50 });
+
+ expect(library.getDragState()).toMatchObject({
+ active: false,
+ button: undefined,
+ x: 0,
+ y: 0,
+ deltaX: 0,
+ deltaY: 0,
+ });
+ });
+
+ it("should not reset inputs when visibility changes but document is not hidden", () => {
+ windowMock.dispatch("keydown", { code: InputEnum.KeyA });
+ documentMock.hidden = false;
+
+ documentMock.dispatch("visibilitychange", {});
+
+ expect(library.isKeyPressed(InputEnum.KeyA)).toBe(true);
+ });
+
+ it("should keep drag active when mouseup button does not match active drag button", () => {
+ windowMock.dispatch("mousedown", { button: 0, clientX: 10, clientY: 20 });
+
+ expect(library.getDragState()).toMatchObject({
+ active: true,
+ button: InputEnum.MouseLeft,
+ startX: 10,
+ startY: 20,
+ });
+
+ windowMock.dispatch("mouseup", { button: 2, clientX: 15, clientY: 25 });
+
+ expect(library.getDragState()).toMatchObject({
+ active: true,
+ button: InputEnum.MouseLeft,
+ startX: 10,
+ startY: 20,
+ });
+ });
});
});