Add initial Nebula Core packages and docs

Import initial monorepo structure for Nebula Core: add packages (@nebula/core, core-glyphs, core-input, core-navigation, core-theme, core-ui, core-utils) with source, dist, tests and assets. Expand README with overview, quick start and API snippets, and add package-level documentation files. Add jsconfig.json for path mapping, package.json and lockfiles to bootstrap the repo. This commit sets up the project layout, docs, and local package links for further development.
This commit is contained in:
2026-01-31 22:57:16 +13:00
parent 40dcfc1853
commit 987ff560f5
309 changed files with 2611 additions and 1 deletions
+41
View File
@@ -0,0 +1,41 @@
# @nebula/core-input
Action-based input abstraction for controller-first apps. Convert raw device events into app-level actions.
## Why it exists
Controller-focused apps should reason in terms of actions (confirm, back, pause) rather than physical inputs. This package lets you plug in your own device adapters and map them to actions consistently.
## Usage
```js
import { createActionMapper } from "@nebula/core-input";
const mapper = createActionMapper({
bindings: {
confirm: [
{ source: "gamepad", control: "a" },
{ source: "keyboard", control: "Enter" }
],
back: [
{ source: "gamepad", control: "b" },
{ source: "keyboard", control: "Escape" }
]
}
});
mapper.onAction((update) => {
if (update.action === "confirm" && update.active) {
console.log("Confirm action");
}
});
mapper.mapEvent({
source: "gamepad",
control: "a",
type: "pressed",
value: 1
});
```
## SteamOS / Steam Deck notes
Pair this with a thin adapter that translates Steam Input or Gamepad API events into `InputEvent` objects. Keep bindings action-first so Deck, Xbox, and PlayStation controllers share the same behavior.
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@nebulaproject/core-input",
"private": true,
"version": "0.1.0",
"description": "Action-based input abstraction for controllers, keyboard, and mouse.",
"type": "module",
"exports": "./src/index.js",
"main": "./src/index.js",
"files": [
"src"
],
"engines": {
"node": ">=18"
},
"scripts": {
"test": "node --test"
},
"dependencies": {
"@nebulaproject/core-utils": "0.1.0"
}
}
+121
View File
@@ -0,0 +1,121 @@
import { createEmitter } from "@nebula/core-utils";
/**
* @typedef {"gamepad" | "keyboard" | "mouse" | "touch" | "unknown"} InputSource
*/
/**
* @typedef {"pressed" | "released" | "moved" | "axis"} InputEventType
*/
/**
* Normalized input event shape.
* @typedef {object} InputEvent
* @property {InputSource} source
* @property {string} control - Physical control identifier (e.g. "a", "dpad-up", "left-stick-x").
* @property {InputEventType} type
* @property {number} value - Normalized 0..1 for digital, -1..1 for analog.
* @property {number} [timestamp]
*/
/**
* Binding definition for mapping physical controls to actions.
* @typedef {object} InputBinding
* @property {InputSource} source
* @property {string} control
* @property {InputEventType | "any"} [type]
* @property {number} [threshold] - Digital activation threshold for analog controls.
*/
/**
* @typedef {object} ActionUpdate
* @property {string} action
* @property {number} value
* @property {boolean} active
* @property {InputEvent} event
*/
/**
* @typedef {object} ActionState
* @property {number} value
* @property {boolean} active
*/
/**
* @typedef {object} ActionMapperOptions
* @property {Record<string, InputBinding[]>} bindings
* @property {number} [deadzone] - Analog deadzone applied to -1..1 axes.
*/
/**
* Create an action-based mapper that converts raw input events into action state updates.
* @param {ActionMapperOptions} options
* @returns {{
* mapEvent: (event: InputEvent) => ActionUpdate[],
* getActionState: (action: string) => ActionState,
* onAction: (listener: (update: ActionUpdate) => void) => () => void,
* reset: () => void
* }}
*/
export function createActionMapper(options) {
const deadzone = options.deadzone ?? 0.2;
/** @type {Map<string, ActionState>} */
const state = new Map();
const updates = createEmitter();
/** @param {string} action */
function ensureState(action) {
if (!state.has(action)) {
state.set(action, { value: 0, active: false });
}
return /** @type {ActionState} */ (state.get(action));
}
/** @param {number} value */
function applyDeadzone(value) {
if (Math.abs(value) < deadzone) return 0;
return value;
}
return {
mapEvent(event) {
/** @type {ActionUpdate[]} */
const result = [];
for (const [action, bindings] of Object.entries(options.bindings)) {
for (const binding of bindings) {
if (binding.source !== event.source) continue;
if (binding.control !== event.control) continue;
if (binding.type && binding.type !== "any" && binding.type !== event.type) continue;
const threshold = binding.threshold ?? 0.5;
const value = event.type === "axis" || event.type === "moved"
? applyDeadzone(event.value)
: event.type === "released"
? 0
: 1;
const active = Math.abs(value) >= threshold;
const current = ensureState(action);
current.value = value;
current.active = active;
const update = { action, value, active, event };
result.push(update);
updates.emit(update);
}
}
return result;
},
getActionState(action) {
return ensureState(action);
},
onAction(listener) {
return updates.on(listener);
},
reset() {
state.clear();
}
};
}
+23
View File
@@ -0,0 +1,23 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createActionMapper } from "../src/index.js";
test("createActionMapper maps events to actions", () => {
const mapper = createActionMapper({
bindings: {
confirm: [{ source: "gamepad", control: "a" }],
back: [{ source: "keyboard", control: "Escape" }]
}
});
const updates = mapper.mapEvent({
source: "gamepad",
control: "a",
type: "pressed",
value: 1
});
assert.equal(updates.length, 1);
assert.equal(updates[0].action, "confirm");
assert.equal(mapper.getActionState("confirm").active, true);
});