feat(input): define semantic gesture contracts

This commit is contained in:
Meta Vens 2026-07-11 03:16:42 +01:00
commit 91277a6845
10 changed files with 248 additions and 1 deletions

View file

@ -0,0 +1,15 @@
# Input capabilities
Providers publish semantic actions and suggested profiles. Clients own physical
device discovery, user bindings, exposed action-bar controls, gesture timing,
and override persistence.
Every binding maps one gesture to one semantic action. Direct controls,
keyboard chords, axes, and ordered gamepad D-pad sequences are gesture kinds,
not separate execution systems. The action bar is a compact editor over the
same binding document; its controls are chosen by the player.
Actions may be derived from live state such as equipped modules, weapon groups,
cargo consumables, role, docking state, or mode. A binding whose action is not
currently available remains dormant so it can recover when that semantic action
returns.

View file

@ -150,3 +150,4 @@ export declare function createWorldActionIntent(command: string, action: Record<
export declare function applyEveSurfaceStyles(styles: EveSurfaceStyles | undefined, body?: HTMLElement): void;
export declare function createEveCommandIntent(commandId: string, props?: Record<string, unknown>, options?: EveBrowserLoweringOptions): EveCommandIntent;
export declare function emptyState(message: string): HTMLElement;
export * from "./input-gestures.js";

View file

@ -231,7 +231,7 @@ export function renderEveComponent(node, options = currentOptions) {
resolveAssetUrl,
});
}
if (kind === "world.scene3d") {
if (kind === "world.scene3d" || kind === "world.scene2d") {
return renderWorldScene(node, props, layout, style, options);
}
if (kind === "image.background") {
@ -1541,3 +1541,4 @@ function el(tag, className = "", text = "") {
element.textContent = text;
return element;
}
export * from "./input-gestures.js";

View file

@ -0,0 +1,23 @@
export type EveInputGestureKind = "direct" | "chord" | "sequence" | "axis";
export type EveInputGesture = {
kind: EveInputGestureKind;
controls: string[];
maxStepIntervalMs?: number;
completionControl?: string;
};
export type EveInputBinding = {
bindingId: string;
actionId: string;
gesture: EveInputGesture;
actionBar?: boolean;
};
export declare class EveInputGestureResolver {
private readonly bindings;
private readonly pressed;
private readonly sequences;
constructor(bindings: EveInputBinding[]);
press(control: string, at?: number): string[];
release(control: string): void;
reset(): void;
private advanceSequence;
}

View file

@ -0,0 +1,44 @@
export class EveInputGestureResolver {
bindings;
pressed = new Set();
sequences = new Map();
constructor(bindings) {
this.bindings = bindings;
}
press(control, at = Date.now()) {
this.pressed.add(control);
const actions = [];
for (const binding of this.bindings) {
const gesture = binding.gesture;
if (gesture.kind === "direct" && gesture.controls[0] === control)
actions.push(binding.actionId);
if (gesture.kind === "chord" && gesture.controls.every(candidate => this.pressed.has(candidate)))
actions.push(binding.actionId);
if (gesture.kind === "sequence" && this.advanceSequence(binding, control, at))
actions.push(binding.actionId);
}
return [...new Set(actions)];
}
release(control) {
this.pressed.delete(control);
}
reset() {
this.pressed.clear();
this.sequences.clear();
}
advanceSequence(binding, control, at) {
const controls = binding.gesture.controls;
if (controls.length === 0)
return false;
const previous = this.sequences.get(binding.bindingId) || { index: 0, at: 0 };
const timeout = binding.gesture.maxStepIntervalMs || 650;
const index = previous.index > 0 && at - previous.at <= timeout ? previous.index : 0;
const next = controls[index] === control ? index + 1 : controls[0] === control ? 1 : 0;
if (next === controls.length) {
this.sequences.delete(binding.bindingId);
return !binding.gesture.completionControl;
}
this.sequences.set(binding.bindingId, { index: next, at });
return false;
}
}

View file

@ -1731,3 +1731,4 @@ function el(tag: string, className = "", text = ""): HTMLElement {
if (text) element.textContent = text;
return element;
}
export * from "./input-gestures.js";

View file

@ -0,0 +1,58 @@
export type EveInputGestureKind = "direct" | "chord" | "sequence" | "axis";
export type EveInputGesture = {
kind: EveInputGestureKind;
controls: string[];
maxStepIntervalMs?: number;
completionControl?: string;
};
export type EveInputBinding = {
bindingId: string;
actionId: string;
gesture: EveInputGesture;
actionBar?: boolean;
};
export class EveInputGestureResolver {
private readonly pressed = new Set<string>();
private readonly sequences = new Map<string, { index: number; at: number }>();
constructor(private readonly bindings: EveInputBinding[]) {}
press(control: string, at = Date.now()): string[] {
this.pressed.add(control);
const actions: string[] = [];
for (const binding of this.bindings) {
const gesture = binding.gesture;
if (gesture.kind === "direct" && gesture.controls[0] === control) actions.push(binding.actionId);
if (gesture.kind === "chord" && gesture.controls.every(candidate => this.pressed.has(candidate))) actions.push(binding.actionId);
if (gesture.kind === "sequence" && this.advanceSequence(binding, control, at)) actions.push(binding.actionId);
}
return [...new Set(actions)];
}
release(control: string): void {
this.pressed.delete(control);
}
reset(): void {
this.pressed.clear();
this.sequences.clear();
}
private advanceSequence(binding: EveInputBinding, control: string, at: number): boolean {
const controls = binding.gesture.controls;
if (controls.length === 0) return false;
const previous = this.sequences.get(binding.bindingId) || { index: 0, at: 0 };
const timeout = binding.gesture.maxStepIntervalMs || 650;
const index = previous.index > 0 && at - previous.at <= timeout ? previous.index : 0;
const next = controls[index] === control ? index + 1 : controls[0] === control ? 1 : 0;
if (next === controls.length) {
this.sequences.delete(binding.bindingId);
return !binding.gesture.completionControl;
}
this.sequences.set(binding.bindingId, { index: next, at });
return false;
}
}

View file

@ -0,0 +1,18 @@
import test from "node:test";
import assert from "node:assert/strict";
import { EveInputGestureResolver } from "../dist/input-gestures.js";
test("resolves keyboard chords", () => {
const resolver = new EveInputGestureResolver([{ bindingId: "boost", actionId: "drive.boost", gesture: { kind: "chord", controls: ["keyboard.shift", "keyboard.w"] } }]);
assert.deepEqual(resolver.press("keyboard.shift", 0), []);
assert.deepEqual(resolver.press("keyboard.w", 1), ["drive.boost"]);
});
test("resolves ordered d-pad strings and resets wrong input", () => {
const resolver = new EveInputGestureResolver([{ bindingId: "shield", actionId: "shield.overcharge", gesture: { kind: "sequence", controls: ["gamepad.dpad.up", "gamepad.dpad.right", "gamepad.dpad.down"], maxStepIntervalMs: 500 } }]);
assert.deepEqual(resolver.press("gamepad.dpad.up", 0), []);
assert.deepEqual(resolver.press("gamepad.dpad.left", 100), []);
assert.deepEqual(resolver.press("gamepad.dpad.up", 200), []);
assert.deepEqual(resolver.press("gamepad.dpad.right", 300), []);
assert.deepEqual(resolver.press("gamepad.dpad.down", 400), ["shield.overcharge"]);
});

View file

@ -0,0 +1,16 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "gamecult.eve.input_bindings.v1",
"title": "Eve User Input Bindings",
"type": "object",
"required": ["schema", "providerId", "capabilityId", "bindings", "actionBarControls"],
"properties": {
"schema": { "const": "gamecult.eve.input_bindings.v1" },
"providerId": { "type": "string", "minLength": 1 },
"capabilityId": { "type": "string", "minLength": 1 },
"bindings": { "type": "array", "items": { "$ref": "gamecult.eve.input_capability.v1#/$defs/binding" } },
"actionBarControls": { "type": "array", "items": { "$ref": "gamecult.eve.input_capability.v1#/$defs/gesture" } },
"updatedAtUtc": { "type": "string" }
},
"additionalProperties": false
}

View file

@ -0,0 +1,70 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "gamecult.eve.input_capability.v1",
"title": "Eve Input Capability",
"type": "object",
"required": ["schema", "providerId", "capabilityId", "actions", "defaultProfiles"],
"properties": {
"schema": { "const": "gamecult.eve.input_capability.v1" },
"providerId": { "type": "string", "minLength": 1 },
"capabilityId": { "type": "string", "minLength": 1 },
"version": { "type": "integer", "minimum": 0 },
"actions": {
"type": "array",
"items": {
"type": "object",
"required": ["actionId", "label", "operation", "availability"],
"properties": {
"actionId": { "type": "string", "minLength": 1 },
"label": { "type": "string", "minLength": 1 },
"operation": { "type": "string", "minLength": 1 },
"context": { "type": "string" },
"category": { "type": "string" },
"iconRef": { "type": "string" },
"availability": { "enum": ["available", "dormant", "unavailable"] },
"sourceRef": { "type": "string" },
"payload": { "type": "object", "additionalProperties": { "type": "string" } }
},
"additionalProperties": false
}
},
"defaultProfiles": {
"type": "array",
"items": {
"type": "object",
"required": ["profileId", "deviceClass", "bindings"],
"properties": {
"profileId": { "type": "string", "minLength": 1 },
"deviceClass": { "enum": ["keyboard-mouse", "gamepad"] },
"bindings": { "type": "array", "items": { "$ref": "#/$defs/binding" } }
},
"additionalProperties": false
}
}
},
"$defs": {
"gesture": {
"type": "object",
"required": ["kind", "controls"],
"properties": {
"kind": { "enum": ["direct", "chord", "sequence", "axis"] },
"controls": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } },
"maxStepIntervalMs": { "type": "integer", "minimum": 50 },
"completionControl": { "type": "string" }
},
"additionalProperties": false
},
"binding": {
"type": "object",
"required": ["bindingId", "actionId", "gesture"],
"properties": {
"bindingId": { "type": "string", "minLength": 1 },
"actionId": { "type": "string", "minLength": 1 },
"gesture": { "$ref": "#/$defs/gesture" },
"actionBar": { "type": "boolean" }
},
"additionalProperties": false
}
},
"additionalProperties": false
}