feat(input): define semantic gesture contracts
This commit is contained in:
parent
229b5a4ca1
commit
91277a6845
10 changed files with 248 additions and 1 deletions
15
docs/input-capabilities.md
Normal file
15
docs/input-capabilities.md
Normal 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.
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
3
packages/eve-browser-lowering/dist/index.js
vendored
3
packages/eve-browser-lowering/dist/index.js
vendored
|
|
@ -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";
|
||||
|
|
|
|||
23
packages/eve-browser-lowering/dist/input-gestures.d.ts
vendored
Normal file
23
packages/eve-browser-lowering/dist/input-gestures.d.ts
vendored
Normal 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;
|
||||
}
|
||||
44
packages/eve-browser-lowering/dist/input-gestures.js
vendored
Normal file
44
packages/eve-browser-lowering/dist/input-gestures.js
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1731,3 +1731,4 @@ function el(tag: string, className = "", text = ""): HTMLElement {
|
|||
if (text) element.textContent = text;
|
||||
return element;
|
||||
}
|
||||
export * from "./input-gestures.js";
|
||||
|
|
|
|||
58
packages/eve-browser-lowering/src/input-gestures.ts
Normal file
58
packages/eve-browser-lowering/src/input-gestures.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
18
packages/eve-browser-lowering/test/input-gestures.test.mjs
Normal file
18
packages/eve-browser-lowering/test/input-gestures.test.mjs
Normal 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"]);
|
||||
});
|
||||
16
schemas/gamecult.eve.input_bindings.v1.schema.json
Normal file
16
schemas/gamecult.eve.input_bindings.v1.schema.json
Normal 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
|
||||
}
|
||||
70
schemas/gamecult.eve.input_capability.v1.schema.json
Normal file
70
schemas/gamecult.eve.input_capability.v1.schema.json
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue