Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .claude/skills/jgengine-gameplay/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions .claude/skills/jgengine-world/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,13 @@
- `ActionStateTracker` (interface): interface ActionStateTracker<TAction extends string> — ⚠ 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<AxisName, AxisRange> — ⚠ undocumented
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 31 additions & 1 deletion packages/core/src/game/controlGate.ts
Original file line number Diff line number Diff line change
@@ -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<GameContext, ActionContextStack>();

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");
}
29 changes: 29 additions & 0 deletions packages/core/src/input/actionContexts.test.ts
Original file line number Diff line number Diff line change
@@ -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"] });
});
});
79 changes: 79 additions & 0 deletions packages/core/src/input/actionContexts.ts
Original file line number Diff line number Diff line change
@@ -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,
}));
},
};
}
7 changes: 5 additions & 2 deletions packages/shell/src/GamePlayerShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 }) => {
Expand Down
1 change: 1 addition & 0 deletions scripts/export-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@
"./i18n/i18n",
"./index",
"./input/actionBindings",
"./input/actionContexts",
"./input/axisInput",
"./input/bindingOverrides",
"./input/controlGroups",
Expand Down
Loading