diff --git a/benchmark/engine-prompt.md b/benchmark/engine-prompt.md index 49d22092..3b395197 100644 --- a/benchmark/engine-prompt.md +++ b/benchmark/engine-prompt.md @@ -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`. diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index 79a78b1b..0e37e0d4 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -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 diff --git a/src/core/graph/nodes/data/KeyWasPressed.ts b/src/core/graph/nodes/data/KeyWasPressed.ts new file mode 100644 index 00000000..3e4db340 --- /dev/null +++ b/src/core/graph/nodes/data/KeyWasPressed.ts @@ -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 ?? '')) } }), +}; diff --git a/src/core/graph/nodes/data/KeyWasReleased.ts b/src/core/graph/nodes/data/KeyWasReleased.ts new file mode 100644 index 00000000..2161a35d --- /dev/null +++ b/src/core/graph/nodes/data/KeyWasReleased.ts @@ -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 ?? '')) } }), +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 3e046a06..89b2afa5 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -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, diff --git a/src/runtime/systems/behaviorContext.ts b/src/runtime/systems/behaviorContext.ts index 13c82757..7449a800 100644 --- a/src/runtime/systems/behaviorContext.ts +++ b/src/runtime/systems/behaviorContext.ts @@ -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); diff --git a/tests/core/key-edges.test.ts b/tests/core/key-edges.test.ts new file mode 100644 index 00000000..b892cc34 --- /dev/null +++ b/tests/core/key-edges.test.ts @@ -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(.) → 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']); + }); +}); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 5088a63e..21bbe1e7 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -1260,6 +1260,34 @@ const CASES: Record = { }, { 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', diff --git a/tests/helpers.ts b/tests/helpers.ts index 6080db5a..7618bf22 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -235,6 +235,8 @@ export function fakeContext(overrides: Partial = {}): FakeContext { childByName: () => null, objects: () => [], isKeyDown: () => false, + wasKeyPressed: () => false, + wasKeyReleased: () => false, pointer: { ...NO_POINTER }, hitTest: () => false, isTouching: () => false,