Tell a graph the frame a key went down or came up
Key is down says whether a key is held. To act once on a press, a graph had only the events On key down and On key up. A behavior that is called by another one and reads its input as data had to keep the state of the last frame in a variable of its own. - Key was pressed is true for the one frame in which the key goes down, the frame On key down fires in. Key was released is the same for the frame the key comes up. - Both sit next to Key is down in the palette and have the key as their only setting. - NodeContext gains wasKeyPressed and wasKeyReleased. The behavior context answers them from justPressed and justReleased of the input, through keyName, so a key saved as "A" meets the key "a". - No repeat. On key down may fire again while a key stays held, a held key is pressed once here. - When the window loses the keyboard, every held key counts as released in that frame, as On key up has it. - The engine prompt of the benchmark names the two nodes. Tests: - Gate on melektaus-atx right before this commit: 213 files, 3278 tests (3264 + 14), exit 0. - e2e 18,20,43 on melektaus-atx: 57 checks, all pass. - Ten unit tests in key-edges.test.ts on a real world, four cases in the node catalog. - Eight counter-probes all fail their tests. One did not at first (the case of the key saved on the node), a test was added. - No e2e check of its own, the sum stays 1676. The nodes were not looked at in the browser, neither in the palette nor with a real keyboard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b45bbcc976
commit
b5339b96c2
9 files changed
+251
-1
No files matched your search
@@ -211,7 +211,7 @@ Implement all of these. Names are the palette names.
|
||||
- **Math and logic:** `Add`, `Subtract`, `Multiply`, `Divide` (by 0 gives 0), `Modulo`, `Min`, `Max`, `Abs`, `Sqrt`, `Clamp`, `Floor`, `Ceil`, `Round`, `Lerp` (with ease: Linear, In, Out, In-out, or a curve asset), `Random`, `Length`, `Distance`, `Normalize`, `Compare`, `Equals`, `And`, `Or`, `Not`, `Select`, `First not empty`, `Time`. `Compare` and `Equals` treat two numbers as equal up to a relative 1e-9.
|
||||
- **Text:** `To text`, `To number`, `Join text`, `Substring`, `Find text`.
|
||||
- **Lists and structs:** `List` (constant), `Fill`, `Count`, `Item at`, `Set item`, `Add item`, `Sub list`, `Join lists`, `Index of`, `Pack`, `Expand`. List nodes return new lists and leave their input alone.
|
||||
- **Input:** `Key is down`, `Axis input` (four keys to a normalised direction), `Mouse position`, `Mouse button`, `Is hovered`.
|
||||
- **Input:** `Key is down`, `Key was pressed` and `Key was released` (true for the one frame of the edge), `Axis input` (four keys to a normalised direction), `Mouse position`, `Mouse button`, `Is hovered`.
|
||||
- **Actions:** `Move` (dx, dy, scale, dt; optional *Collide with solids*, section 11), `Set position`, `Spawn` (asset, x, y, z, optional parent; outputs the object), `Destroy`, `Emit`, `Play sound`, `Stop sound`, `Play animation`, `Stop animation`, `Pause animation`, `Load scene`, `Log`, `Stop game`, `Restart game`, `Pause game`, `Resume game`, `Set time scale`.
|
||||
- **Queries:** `Is touching`, `Raycast`, `Raycast all`, `Get velocity`, `Animation playing`, `Animation length`, `Current state`, `Is in state`, `Is game paused`, `Time scale`.
|
||||
- **Physics:** `Apply force`, `Apply impulse`, `Set velocity`, `Set angular velocity`, `Set gravity`, `Ignore one-way`.
|
||||
|
||||
@@ -153,6 +153,9 @@ export interface NodeContext {
|
||||
/** Every object in the game right now, in scene order – what the plural lookups filter (#259). */
|
||||
objects(): readonly EntityHandle[];
|
||||
isKeyDown(key: string): boolean;
|
||||
/** Whether the key went down / came up in this frame – the edges `On key down` and `On key up` fire on. */
|
||||
wasKeyPressed(key: string): boolean;
|
||||
wasKeyReleased(key: string): boolean;
|
||||
pointer: PointerState;
|
||||
/**
|
||||
* Whether a world point lies on `target`: on its colliders when it has any, otherwise on its own
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { bool, keyParam } from '../shared';
|
||||
|
||||
import { textOf } from '../../../model/Properties';
|
||||
export const KeyWasPressed: NodeDef = {
|
||||
type: 'KeyWasPressed',
|
||||
label: 'Key was pressed',
|
||||
category: 'data',
|
||||
description: 'Whether a key went down in this frame; true for that one frame only.',
|
||||
inputs: [],
|
||||
outputs: [bool('pressed')],
|
||||
params: [keyParam],
|
||||
run: (ctx, _inputs, params) => ({ outputs: { pressed: ctx.wasKeyPressed(textOf(params.key ?? '')) } }),
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { bool, keyParam } from '../shared';
|
||||
|
||||
import { textOf } from '../../../model/Properties';
|
||||
export const KeyWasReleased: NodeDef = {
|
||||
type: 'KeyWasReleased',
|
||||
label: 'Key was released',
|
||||
category: 'data',
|
||||
description: 'Whether a key came up in this frame; true for that one frame only.',
|
||||
inputs: [],
|
||||
outputs: [bool('released')],
|
||||
params: [keyParam],
|
||||
run: (ctx, _inputs, params) => ({ outputs: { released: ctx.wasKeyReleased(textOf(params.key ?? '')) } }),
|
||||
};
|
||||
@@ -50,6 +50,8 @@ import { IsInState } from './data/IsInState';
|
||||
import { IsTouching } from './data/IsTouching';
|
||||
import { Join } from './data/Join';
|
||||
import { KeyIsDown } from './data/KeyIsDown';
|
||||
import { KeyWasPressed } from './data/KeyWasPressed';
|
||||
import { KeyWasReleased } from './data/KeyWasReleased';
|
||||
import { LayerOf } from './data/LayerOf';
|
||||
import { Length } from './data/Length';
|
||||
import { Lerp } from './data/Lerp';
|
||||
@@ -246,6 +248,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
Normalize,
|
||||
AxisInput,
|
||||
KeyIsDown,
|
||||
KeyWasPressed,
|
||||
KeyWasReleased,
|
||||
MousePosition,
|
||||
MouseButton,
|
||||
IsHovered,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Handles arriving from a graph are resolved to the world's entities here, so no node holds one.
|
||||
*/
|
||||
import type { EntityHandle, NodeContext, PhysicsControl, SharedState } from '../../core/graph';
|
||||
import { keyName } from '../../core/keys';
|
||||
import { collisionFilterOf } from '../../core/model/collisionLayers';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
import { findAssetOf, findScene, type Project } from '../../core/model/Project';
|
||||
@@ -70,6 +71,8 @@ export function behaviorContext(
|
||||
},
|
||||
objects: () => world.entities,
|
||||
isKeyDown: (key) => run.input.isDown(key),
|
||||
wasKeyPressed: (key) => run.input.justPressed.has(keyName(key)),
|
||||
wasKeyReleased: (key) => run.input.justReleased.has(keyName(key)),
|
||||
pointer: world.pointer,
|
||||
hitTest: (target, x, y) => {
|
||||
const found = resolve(target);
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* `Key was pressed` and `Key was released` answer the edges `On key down` and `On key up` fire on,
|
||||
* as values a flow can ask for. Driven through a real world, so the frame they hold for is the
|
||||
* one the run really ends.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import { graphEditor, makeBehavior, makeSpriteScene, place, startWorld, type SceneFixture } from '../helpers';
|
||||
|
||||
function scene(): SceneFixture & { behavior: BehaviorAsset } {
|
||||
const f = makeSpriteScene('Box');
|
||||
const behavior = makeBehavior(f, 'Logic');
|
||||
const box = place(f, f.spriteId, { x: 0, y: 0 });
|
||||
place(f, behavior.id, { x: 0, y: 0 }, box);
|
||||
return { ...f, behavior };
|
||||
}
|
||||
|
||||
/** Wires `On update → Branch(<node>.<port>) → Log(message)`: one log entry per frame the node says yes. */
|
||||
function logWhile(
|
||||
f: SceneFixture & { behavior: BehaviorAsset },
|
||||
type: string,
|
||||
port: string,
|
||||
key: string,
|
||||
message: string,
|
||||
): void {
|
||||
const edit = graphEditor(f, f.behavior);
|
||||
const update = edit.addNode('OnUpdate');
|
||||
const asked = edit.addNode(type, { key });
|
||||
const branch = edit.addNode('Branch');
|
||||
const log = edit.addNode('Log', { message });
|
||||
edit.connect(update, 'exec', branch, 'exec');
|
||||
edit.connect(asked, port, branch, 'condition');
|
||||
edit.connect(branch, 'then', log, 'exec');
|
||||
}
|
||||
|
||||
describe('Key was pressed', () => {
|
||||
it('is true in the frame the key goes down and in no other', () => {
|
||||
const f = scene();
|
||||
logWhile(f, 'KeyWasPressed', 'pressed', 'a', 'pressed');
|
||||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||||
|
||||
frame();
|
||||
expect(world.log).toEqual([]);
|
||||
|
||||
input.press('a');
|
||||
frame();
|
||||
expect(world.log).toEqual(['pressed']);
|
||||
|
||||
frame();
|
||||
frame();
|
||||
expect(world.log).toEqual(['pressed']);
|
||||
});
|
||||
|
||||
it("does not count the browser's own repeat of a held key as a new press", () => {
|
||||
const f = scene();
|
||||
logWhile(f, 'KeyWasPressed', 'pressed', 'a', 'pressed');
|
||||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||||
|
||||
input.press('a');
|
||||
frame();
|
||||
input.press('a');
|
||||
frame();
|
||||
expect(world.log).toEqual(['pressed']);
|
||||
|
||||
input.release('a');
|
||||
input.press('a');
|
||||
frame();
|
||||
expect(world.log).toEqual(['pressed', 'pressed']);
|
||||
});
|
||||
|
||||
it('answers in the same frame as On key down', () => {
|
||||
const f = scene();
|
||||
logWhile(f, 'KeyWasPressed', 'pressed', 'a', 'value');
|
||||
const edit = graphEditor(f, f.behavior);
|
||||
edit.connect(
|
||||
edit.addNode('OnKeyDown', { key: 'a' }),
|
||||
'exec',
|
||||
edit.addNode('Log', { message: 'event' }),
|
||||
'exec',
|
||||
);
|
||||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||||
|
||||
input.press('a');
|
||||
frame();
|
||||
expect([...world.log].sort()).toEqual(['event', 'value']);
|
||||
frame();
|
||||
expect(world.log).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('takes "a" and "A" for one key and leaves another key alone', () => {
|
||||
const f = scene();
|
||||
logWhile(f, 'KeyWasPressed', 'pressed', 'a', 'pressed');
|
||||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||||
|
||||
input.press('b');
|
||||
frame();
|
||||
expect(world.log).toEqual([]);
|
||||
|
||||
input.press('A');
|
||||
frame();
|
||||
expect(world.log).toEqual(['pressed']);
|
||||
});
|
||||
|
||||
it('reads the key on the node the same way, whichever case it was saved in', () => {
|
||||
const f = scene();
|
||||
logWhile(f, 'KeyWasPressed', 'pressed', 'A', 'pressed');
|
||||
logWhile(f, 'KeyWasReleased', 'released', 'A', 'released');
|
||||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||||
|
||||
input.press('a');
|
||||
frame();
|
||||
input.release('a');
|
||||
frame();
|
||||
expect(world.log).toEqual(['pressed', 'released']);
|
||||
});
|
||||
|
||||
it('stays false while the key is only released', () => {
|
||||
const f = scene();
|
||||
logWhile(f, 'KeyWasPressed', 'pressed', 'a', 'pressed');
|
||||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||||
|
||||
input.press('a');
|
||||
frame();
|
||||
input.release('a');
|
||||
frame();
|
||||
expect(world.log).toEqual(['pressed']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Key was released', () => {
|
||||
it('is true in the frame the key comes up and in no other', () => {
|
||||
const f = scene();
|
||||
logWhile(f, 'KeyWasReleased', 'released', 'a', 'released');
|
||||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||||
|
||||
input.press('a');
|
||||
frame();
|
||||
frame();
|
||||
expect(world.log).toEqual([]);
|
||||
|
||||
input.release('a');
|
||||
frame();
|
||||
expect(world.log).toEqual(['released']);
|
||||
|
||||
frame();
|
||||
expect(world.log).toEqual(['released']);
|
||||
});
|
||||
|
||||
it('stays false for a key that was never down', () => {
|
||||
const f = scene();
|
||||
logWhile(f, 'KeyWasReleased', 'released', 'a', 'released');
|
||||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||||
|
||||
input.release('a');
|
||||
frame();
|
||||
expect(world.log).toEqual([]);
|
||||
});
|
||||
|
||||
it('sees a press and a release inside one frame as both', () => {
|
||||
const f = scene();
|
||||
logWhile(f, 'KeyWasPressed', 'pressed', 'a', 'pressed');
|
||||
logWhile(f, 'KeyWasReleased', 'released', 'a', 'released');
|
||||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||||
|
||||
input.press('a');
|
||||
input.release('a');
|
||||
frame();
|
||||
expect([...world.log].sort()).toEqual(['pressed', 'released']);
|
||||
});
|
||||
|
||||
it('is true for every held key when the window loses the keyboard', () => {
|
||||
const f = scene();
|
||||
logWhile(f, 'KeyWasReleased', 'released', 'a', 'released');
|
||||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||||
|
||||
input.press('a');
|
||||
frame();
|
||||
input.releaseAll();
|
||||
frame();
|
||||
expect(world.log).toEqual(['released']);
|
||||
});
|
||||
});
|
||||
@@ -1260,6 +1260,34 @@ const CASES: Record<string, Case[]> = {
|
||||
},
|
||||
{ name: 'no key chosen is never down', context: { isKeyDown: () => true }, outputs: { down: true } },
|
||||
],
|
||||
KeyWasPressed: [
|
||||
{
|
||||
name: 'asks the context for the press of the chosen key',
|
||||
params: { key: 'a' },
|
||||
context: { wasKeyPressed: (k) => k === 'a', wasKeyReleased: () => true },
|
||||
outputs: { pressed: true },
|
||||
},
|
||||
{
|
||||
name: 'another key was not pressed, and a held one is not a press',
|
||||
params: { key: 'b' },
|
||||
context: { wasKeyPressed: (k) => k === 'a', isKeyDown: () => true },
|
||||
outputs: { pressed: false },
|
||||
},
|
||||
],
|
||||
KeyWasReleased: [
|
||||
{
|
||||
name: 'asks the context for the release of the chosen key',
|
||||
params: { key: 'a' },
|
||||
context: { wasKeyReleased: (k) => k === 'a', wasKeyPressed: () => false },
|
||||
outputs: { released: true },
|
||||
},
|
||||
{
|
||||
name: 'another key was not released',
|
||||
params: { key: 'b' },
|
||||
context: { wasKeyReleased: (k) => k === 'a', wasKeyPressed: () => true },
|
||||
outputs: { released: false },
|
||||
},
|
||||
],
|
||||
MousePosition: [
|
||||
{
|
||||
name: 'reports the pointer in world units',
|
||||
|
||||
@@ -235,6 +235,8 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
|
||||
childByName: () => null,
|
||||
objects: () => [],
|
||||
isKeyDown: () => false,
|
||||
wasKeyPressed: () => false,
|
||||
wasKeyReleased: () => false,
|
||||
pointer: { ...NO_POINTER },
|
||||
hitTest: () => false,
|
||||
isTouching: () => false,
|
||||
|
||||
Reference in new issue
Block a user