diff --git a/.claude/skills/jgengine-gameplay/api.md b/.claude/skills/jgengine-gameplay/api.md index 638c859f..fa66395b 100644 --- a/.claude/skills/jgengine-gameplay/api.md +++ b/.claude/skills/jgengine-gameplay/api.md @@ -387,6 +387,8 @@ ## @jgengine/core/game/controlGate - `PLAY_CONTROLS_STORE_KEY` (const): const PLAY_CONTROLS_STORE_KEY: "jg.playControls" — ⚠ undocumented +- `actionContextStack` (function): function actionContextStack(ctx: GameContext): ActionContextStack — Returns the context stack associated with a game context. +- `activeActionCodes` (function): function activeActionCodes(ctx: GameContext, base: ActionCodesMap): ActionCodesMap — Applies active contexts to a base action map for shell input tracking. - `playControlsActive` (function): function playControlsActive(ctx: GameContext): boolean — ⚠ undocumented - `setPlayControlsActive` (function): function setPlayControlsActive(ctx: GameContext, active: boolean): void — ⚠ undocumented diff --git a/.claude/skills/jgengine-world/api.md b/.claude/skills/jgengine-world/api.md index 3ba41438..31076da9 100644 --- a/.claude/skills/jgengine-world/api.md +++ b/.claude/skills/jgengine-world/api.md @@ -342,6 +342,13 @@ - `ActionStateTracker` (interface): interface ActionStateTracker — ⚠ undocumented - `ShouldDispatchActionInput` (interface): interface ShouldDispatchActionInput — ⚠ undocumented +## @jgengine/core/input/actionContexts + +- `ActionContext` (interface): interface ActionContext — Named action-binding layer that may optionally expose lower layers. +- `ActionContextStack` (interface): interface ActionContextStack — Mutable layered action-map stack with snapshot and restore support. +- `ActionContextStackSnapshot` (interface): interface ActionContextStackSnapshot — Serializable state for an action-context stack. +- `createActionContextStack` (function): function createActionContextStack(): ActionContextStack — Creates a serializable stack of layered action maps for menus and gameplay modes. + ## @jgengine/core/input/axisInput - `AXIS_RANGE` (const): const AXIS_RANGE: Record — ⚠ undocumented diff --git a/CHANGELOG.md b/CHANGELOG.md index bc4a22e1..a6a241f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ between (`--json` for structured output). - Core gamepad snapshot model, deadzone/curve resolver, and controller glyph labels. - Shell gamepad source polls connected pads, merges analog actions, supports synthetic `?gamepad=1` input, and exposes `ctx.input.rumble`. +- Core action context stacks layer gameplay, menus, and passthrough overlays with snapshot/restore; shell action tracking now respects the active context. - Core perception tracks deterministic sight, hearing, occlusion, and bounded observation memory for AI. - `syncWorldColliders` now keeps terrain and static object colliders synchronized with a physics backend. - Core decision graphs provide deterministic selector, sequence, condition, action, and utility runtimes with snapshot/restore. diff --git a/packages/core/src/game/controlGate.ts b/packages/core/src/game/controlGate.ts index 68fb9118..cd5471e3 100644 --- a/packages/core/src/game/controlGate.ts +++ b/packages/core/src/game/controlGate.ts @@ -1,11 +1,41 @@ import type { GameContext } from "../runtime/gameContext"; +import { createActionContextStack, type ActionContextStack } from "../input/actionContexts"; +import type { ActionCodesMap } from "../input/actionBindings"; export const PLAY_CONTROLS_STORE_KEY = "jg.playControls"; +const ACTION_CONTEXTS = new WeakMap(); + +function contextsFor(ctx: GameContext): ActionContextStack { + let stack = ACTION_CONTEXTS.get(ctx); + if (stack === undefined) { + stack = createActionContextStack(); + ACTION_CONTEXTS.set(ctx, stack); + } + return stack; +} + +/** Returns the context stack associated with a game context. */ +export function actionContextStack(ctx: GameContext): ActionContextStack { + return contextsFor(ctx); +} + +/** Applies active contexts to a base action map for shell input tracking. */ +export function activeActionCodes(ctx: GameContext, base: ActionCodesMap): ActionCodesMap { + const stack = contextsFor(ctx); + const layered = createActionContextStack(); + layered.push({ id: "__jg_base_actions", codes: base, passthrough: true }); + for (const context of stack.snapshot().contexts) layered.push(context); + return layered.active(); +} export function setPlayControlsActive(ctx: GameContext, active: boolean): void { + const stack = contextsFor(ctx); + if (active) stack.pop("menu"); + else stack.push({ id: "menu", codes: {}, passthrough: false }); ctx.game.store.set(PLAY_CONTROLS_STORE_KEY, active); } export function playControlsActive(ctx: GameContext): boolean { - return ctx.game.store.get(PLAY_CONTROLS_STORE_KEY) !== false; + return ctx.game.store.get(PLAY_CONTROLS_STORE_KEY) !== false && + !actionContextStack(ctx).snapshot().contexts.some((context) => context.id === "menu"); } diff --git a/packages/core/src/input/actionContexts.test.ts b/packages/core/src/input/actionContexts.test.ts new file mode 100644 index 00000000..8c08c2f2 --- /dev/null +++ b/packages/core/src/input/actionContexts.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; +import { createActionContextStack } from "./actionContexts"; + +describe("action context stack", () => { + test("merges passthrough contexts from top down", () => { + const stack = createActionContextStack(); + stack.push({ id: "play", codes: { move: ["KeyW"], pause: ["Escape"] }, passthrough: true }); + stack.push({ id: "overlay", codes: { pause: ["KeyP"], chat: ["Enter"] }, passthrough: true }); + expect(stack.active()).toEqual({ move: ["KeyW"], pause: ["KeyP"], chat: ["Enter"] }); + }); + + test("a non-passthrough context blocks lower contexts", () => { + const stack = createActionContextStack(); + stack.push({ id: "play", codes: { move: ["KeyW"] }, passthrough: true }); + stack.push({ id: "menu", codes: { confirm: ["Enter"] }, passthrough: false }); + expect(stack.active()).toEqual({ confirm: ["Enter"] }); + expect(stack.pop("menu")).toBe(true); + expect(stack.active()).toEqual({ move: ["KeyW"] }); + }); + + test("snapshots and restores layered state", () => { + const stack = createActionContextStack(); + stack.push({ id: "play", codes: { jump: ["Space"] }, passthrough: true }); + const snapshot = stack.snapshot(); + stack.push({ id: "menu", codes: {}, passthrough: false }); + stack.restore(snapshot); + expect(stack.active()).toEqual({ jump: ["Space"] }); + }); +}); diff --git a/packages/core/src/input/actionContexts.ts b/packages/core/src/input/actionContexts.ts new file mode 100644 index 00000000..c6a192f3 --- /dev/null +++ b/packages/core/src/input/actionContexts.ts @@ -0,0 +1,79 @@ +import type { ActionCodesMap } from "./actionBindings"; + +/** Named action-binding layer that may optionally expose lower layers. */ +export interface ActionContext { + id: string; + codes: ActionCodesMap; + passthrough: boolean; +} + +/** Serializable state for an action-context stack. */ +export interface ActionContextStackSnapshot { + contexts: ActionContext[]; +} + +/** Mutable layered action-map stack with snapshot and restore support. */ +export interface ActionContextStack { + push(context: ActionContext): void; + pop(id: string): boolean; + active(): ActionCodesMap; + snapshot(): ActionContextStackSnapshot; + restore(snapshot: ActionContextStackSnapshot): void; +} + +function cloneCodes(codes: ActionCodesMap): ActionCodesMap { + const result: ActionCodesMap = {}; + for (const [action, value] of Object.entries(codes)) { + const modes = value as { hold?: readonly string[]; toggle?: readonly string[]; repeatMs?: number }; + result[action] = Array.isArray(value) + ? [...value] + : { + ...(modes.hold === undefined ? {} : { hold: [...modes.hold] }), + ...(modes.toggle === undefined ? {} : { toggle: [...modes.toggle] }), + ...(modes.repeatMs === undefined ? {} : { repeatMs: modes.repeatMs }), + }; + } + return result; +} + +/** Creates a serializable stack of layered action maps for menus and gameplay modes. */ +export function createActionContextStack(): ActionContextStack { + let contexts: ActionContext[] = []; + + return { + push(context) { + contexts = [...contexts.filter((current) => current.id !== context.id), { + id: context.id, + codes: cloneCodes(context.codes), + passthrough: context.passthrough, + }]; + }, + pop(id) { + const next = contexts.filter((context) => context.id !== id); + const changed = next.length !== contexts.length; + contexts = next; + return changed; + }, + active() { + const merged: ActionCodesMap = {}; + for (let index = contexts.length - 1; index >= 0; index -= 1) { + const context = contexts[index]!; + for (const [action, codes] of Object.entries(context.codes)) { + if (merged[action] === undefined) merged[action] = codes; + } + if (!context.passthrough) break; + } + return merged; + }, + snapshot() { + return { contexts: contexts.map((context) => ({ ...context, codes: cloneCodes(context.codes) })) }; + }, + restore(snapshot) { + contexts = snapshot.contexts.map((context) => ({ + id: context.id, + codes: cloneCodes(context.codes), + passthrough: context.passthrough, + })); + }, + }; +} diff --git a/packages/shell/src/GamePlayerShell.tsx b/packages/shell/src/GamePlayerShell.tsx index b7d9fe1d..ac124947 100644 --- a/packages/shell/src/GamePlayerShell.tsx +++ b/packages/shell/src/GamePlayerShell.tsx @@ -16,6 +16,7 @@ import { deriveTouchScheme, withTouchCodes, DEFAULT_TOUCH_STYLE } from "@jgengin import { activeTouchControlsMode } from "@jgengine/core/input/touchControlsMode"; import { normalizePointerToAxis, type PointerAxisState } from "@jgengine/core/input/pointerAxis"; import { createGameContext, type GameContext } from "@jgengine/core/runtime/gameContext"; +import { activeActionCodes } from "@jgengine/core/game/controlGate"; import type { PresencePoseRow } from "@jgengine/core/runtime/transport"; import { useDisplayProfile } from "@jgengine/react/display"; import { RotateDeviceScreen } from "@jgengine/react/rotateDevice"; @@ -148,8 +149,10 @@ export function GamePlayerShell({ [playable], ); const tracker = useMemo( - () => createActionStateTracker(toActionStateBindingMap(withTouchCodes(effectiveInput))), - [effectiveInput], + () => createActionStateTracker(toActionStateBindingMap(withTouchCodes( + ctx === null ? effectiveInput : activeActionCodes(ctx, effectiveInput), + ))), + [ctx, effectiveInput], ); const graphics = useGraphicsSettings(settingsStore, playable.shadows ?? true, playable.graphics); const trackPointerAxis = (event: { clientX: number; clientY: number }) => { diff --git a/scripts/export-manifest.json b/scripts/export-manifest.json index 0fccdc60..2b58a39d 100644 --- a/scripts/export-manifest.json +++ b/scripts/export-manifest.json @@ -186,6 +186,7 @@ "./i18n/i18n", "./index", "./input/actionBindings", + "./input/actionContexts", "./input/axisInput", "./input/bindingOverrides", "./input/controlGroups",