diff --git a/README.md b/README.md index 489ee95e..6b74b1f6 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,12 @@ shown on the node and editable in the inspector. (inspector → Parent, or drag it onto that object in the hierarchy; dashed line in the viewport). The `Owner` node returns it; `Self` returns the behavior itself; `Parent` walks up from any object. +- **Auto-repeat on `On key down`**: two settings on the node, *Repeat after (ms)* and *Repeat + interval (ms)* – milliseconds, so both stay whole numbers. A delay of 0 leaves the node as it was – one fire per press; with a delay set, a + key that stays held fires again after it and then every interval (0 = every frame). That is the + sideways slide of a Tetris piece, or a held arrow walking a menu. The browser's own key repeat is + deliberately not used: its delay and rate are an operating-system setting, not a game's. Both + rows stay off the node body while the delay is 0, so a plain key node keeps its single line. - **Node set** (`src/core/graph/nodes/`, one file per node): events `On start`, `On update`, `On key down/up/held`, `On timer`, `On collision enter/stay/exit`, `On click` (the mouse pressed on the owner – anywhere when it has none), `Start` · flow `Branch`, `Sequence`, `Repeat`, `Run behavior`, `End` · data `Self`, `Owner`, `Parent`, `Object by name`, `Name of`, `Is asset` (is this object an instance of asset X – how you tell what you collided with), `Get/Set diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index 8c733974..946895a1 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -51,6 +51,11 @@ export interface NodeDef { outputs: PortDef[]; /** Node settings that are not ports (key name, operator, variable). */ params: PropertyDef[]; + /** + * Settings that say nothing at this value set, so the node body leaves them off; the inspector + * still offers them. Without it a switched-off repeat is two rows of "0" on every key node. + */ + hiddenParams?(params: PropertyBag): string[]; /** * Pure data nodes are evaluated on demand when a downstream input needs their value; exec nodes * run when the flow reaches them. Event nodes have no `run` – the interpreter seeds their outputs. diff --git a/src/core/graph/keyRepeat.ts b/src/core/graph/keyRepeat.ts new file mode 100644 index 00000000..ee822d0b --- /dev/null +++ b/src/core/graph/keyRepeat.ts @@ -0,0 +1,56 @@ +/** + * The auto-repeat of `On key down`, kept apart from the runtime so it can be checked without a + * world: given how long the key has been held, how often does the node fire this frame? + * + * The browser's own key auto-repeat is deliberately not used (`Input.press` drops it): its delay + * and rate are an operating-system setting, not something a game can tune. + */ + +/** Smallest repeat gap, mirroring `On timer`; 0 keeps its own meaning of "every frame". */ +export const MIN_REPEAT_INTERVAL = 0.01; + +export interface KeyRepeatState { + /** Seconds since the key went down. */ + held: number; + /** Repeats so far in this press; the next is due at `after + repeats × interval`. */ + repeats: number; +} + +export interface KeyRepeatStep { + /** The key went down *this frame*, as opposed to `down`, which is the whole hold. */ + pressed: boolean; + down: boolean; + dt: number; + /** Seconds of holding before the first repeat; 0 switches repeating off. */ + after: number; + /** Seconds between repeats; 0 fires every frame. */ + interval: number; +} + +/** + * `state` is null while the key is not being tracked. A key that is already held when tracking + * starts stays untracked until it is pressed again, so a repeat never runs without its first fire. + */ +export function stepKeyRepeat( + state: KeyRepeatState | null, + step: KeyRepeatStep, +): { state: KeyRepeatState | null; fires: number } { + if (step.pressed) return { state: { held: 0, repeats: 0 }, fires: 1 }; + if (!step.down) return { state: null, fires: 0 }; + if (!state) return { state: null, fires: 0 }; + + const after = Math.max(0, step.after); + const held = state.held + Math.max(0, step.dt); + if (after <= 0 || held < after) return { state: { ...state, held }, fires: 0 }; + + if (step.interval <= 0) return { state: { ...state, held }, fires: 1 }; + + const interval = Math.max(MIN_REPEAT_INTERVAL, step.interval); + let repeats = state.repeats; + let fires = 0; + while (held >= after + repeats * interval) { + repeats += 1; + fires += 1; + } + return { state: { held, repeats }, fires }; +} diff --git a/src/core/graph/nodes/events/OnKeyDown.ts b/src/core/graph/nodes/events/OnKeyDown.ts index 5db40f32..c08e6301 100644 --- a/src/core/graph/nodes/events/OnKeyDown.ts +++ b/src/core/graph/nodes/events/OnKeyDown.ts @@ -1,12 +1,19 @@ import type { NodeDef } from '../../NodeRegistry'; +import { asNumber } from '../../values'; import { exec, keyParam } from '../shared'; export const OnKeyDown: NodeDef = { type: 'OnKeyDown', label: 'On key down', category: 'event', - description: 'Runs once when the key is pressed.', + description: 'Runs when the key is pressed, and again while it stays held if a repeat is set.', inputs: [], outputs: [exec()], - params: [keyParam], + params: [ + keyParam, + { key: 'repeatAfter', label: 'Repeat after (ms)', kind: 'number', default: 0, min: 0, step: 50 }, + { key: 'repeatInterval', label: 'Repeat interval (ms)', kind: 'number', default: 0, min: 0, step: 10 }, + ], + // Both rows go together: with no delay there is no repeat, so the interval is not in play either. + hiddenParams: (params) => (asNumber(params.repeatAfter ?? 0) > 0 ? [] : ['repeatAfter', 'repeatInterval']), }; diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 7e186777..35cf6c26 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -188,6 +188,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E nodeAt: (nodeId: string) => graphEditor.canvas.nodeGlobal(nodeId), nodeLabel: (nodeId: string) => graphEditor.canvas.nodeLabel(nodeId), nodeProblem: (nodeId: string) => graphEditor.canvas.nodeProblem(nodeId), + nodeSettings: (nodeId: string) => graphEditor.canvas.nodeSettings(nodeId), /** Drawn rectangles on screen: what "fit" has to keep inside `canvasRect`. */ nodeRect: (nodeId: string) => graphEditor.canvas.nodeRect(nodeId), canvasRect: () => graphEditor.canvas.canvasRect(), diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index e790f816..c085b393 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -1042,6 +1042,11 @@ export class GraphCanvas extends Widget { return this.views.get(nodeId)?.problemText ?? null; } + /** The setting rows a node is drawn with – a setting left off the body is absent here too. */ + nodeSettings(nodeId: string): string[] | null { + return this.views.get(nodeId)?.settingLines ?? null; + } + /** Global screen position of a node's title bar centre. */ nodeGlobal(nodeId: string): { x: number; y: number } | null { const view = this.views.get(nodeId); diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index 7016c169..ca2a20bf 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -119,7 +119,9 @@ export class NodeView extends Widget { ): void { this.def = def; this.problem = problem; + const hidden = new Set(this.def.hiddenParams?.(node.params) ?? []); this.paramLines = this.def.params + .filter((p) => !hidden.has(p.key)) .map((p) => ({ label: p.label, value: formatParam(p, node.params[p.key] ?? p.default, asset, project), @@ -148,6 +150,11 @@ export class NodeView extends Widget { return this.problem; } + /** The setting rows as drawn, for the same reason. */ + get settingLines(): string[] { + return [...this.paramLines]; + } + /** Everything above the first port row. The problem line sits in here, so ports move down with it. */ private headerHeight(): number { const lines = this.paramLines.length + (this.problem ? 1 : 0); diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 36eb50ec..c5e7526e 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -1,10 +1,13 @@ import { + asNumber, type EntityHandle, + type GraphNode, GraphInterpreter, isHandleEnabled, type NodeContext, type TraceListener, } from '../../core/graph'; +import { stepKeyRepeat } from '../../core/graph/keyRepeat'; import { behaviorLookup } from '../../core/model/behaviorPrograms'; import type { PropertyBag } from '../../core/model/Properties'; import { findAsset, type Project } from '../../core/model/Project'; @@ -100,7 +103,7 @@ export class BehaviorSystem implements System { this.run( instance, (i, ctx) => { - for (const key of this.input.justPressed) i.fire('OnKeyDown', ctx, {}, (n) => keyOf(n) === key); + for (const node of i.eventNodes('OnKeyDown')) this.fireKeyDown(i, ctx, node, dt); for (const key of this.input.justReleased) i.fire('OnKeyUp', ctx, {}, (n) => keyOf(n) === key); for (const key of this.input.heldKeys) i.fire('OnKeyHeld', ctx, { dt }, (n) => keyOf(n) === key); for (const node of i.eventNodes('OnTimer')) { @@ -128,6 +131,29 @@ export class BehaviorSystem implements System { } } + /** + * The press plus whatever repeats holding the key has earned. Each node keeps its own hold clock + * in `nodeState`, so two nodes on the same key repeat on their own schedules. + */ + private fireKeyDown(i: GraphInterpreter, ctx: NodeContext, node: GraphNode, dt: number): void { + const key = String(node.params.key ?? ''); + const stored = i.nodeState.get(node.id); + const { state, fires } = stepKeyRepeat( + stored ? { held: stored.held ?? 0, repeats: stored.repeats ?? 0 } : null, + { + pressed: this.input.justPressed.has(key), + down: this.input.heldKeys.has(key), + dt, + // The node asks for milliseconds so the fields stay whole numbers; the clock runs in seconds. + after: asNumber(node.params.repeatAfter ?? 0) / 1000, + interval: asNumber(node.params.repeatInterval ?? 0) / 1000, + }, + ); + if (state) i.nodeState.set(node.id, { held: state.held, repeats: state.repeats }); + else i.nodeState.delete(node.id); + for (let fired = 0; fired < fires; fired++) i.fire('OnKeyDown', ctx, {}, (n) => n.id === node.id); + } + /** * Collision events for this behavior: every pair in which one collider belongs to the * behavior's body (its owner, or itself when it has none) fires once, with `other` being diff --git a/tests/core/behavior-events.test.ts b/tests/core/behavior-events.test.ts index c8d3966a..764d80e1 100644 --- a/tests/core/behavior-events.test.ts +++ b/tests/core/behavior-events.test.ts @@ -119,6 +119,117 @@ describe('key events', () => { }); }); +// The node's settings are milliseconds, the frame lengths seconds. Those lengths are exact in +// binary (0.125, 0.25, 0.5), so a repeat due at a round hold time is never lost to a rounding error. +describe('On key down repeat', () => { + it('fires again after the delay, then on its own interval', () => { + const f = scene(); + logOn(f, 'OnKeyDown', { key: 'a', repeatAfter: 500, repeatInterval: 250 }, 'down'); + const { world, input, frame } = startWorld(f, { behaviors: true }); + + input.press('a'); + frame(0.125); + expect(world.log).toEqual(['down']); + + for (let n = 0; n < 3; n++) frame(0.125); // held 0.375 s: still short of the delay + expect(world.log).toEqual(['down']); + + frame(0.125); // held 0.5 s + expect(world.log).toEqual(['down', 'down']); + + frame(0.125); + expect(world.log).toHaveLength(2); // 0.625 s: half an interval on + frame(0.125); + expect(world.log).toHaveLength(3); // 0.75 s + + frame(0.25); + expect(world.log).toHaveLength(4); + }); + + it('leaves a key alone while the delay is zero', () => { + const f = scene(); + logOn(f, 'OnKeyDown', { key: 'a', repeatAfter: 0, repeatInterval: 50 }, 'down'); + const { world, input, frame } = startWorld(f, { behaviors: true }); + + input.press('a'); + for (let n = 0; n < 30; n++) frame(0.125); + expect(world.log).toEqual(['down']); + }); + + it('fires every frame when the interval is zero', () => { + const f = scene(); + logOn(f, 'OnKeyDown', { key: 'a', repeatAfter: 250, repeatInterval: 0 }, 'down'); + const { world, input, frame } = startWorld(f, { behaviors: true }); + + input.press('a'); + frame(0.125); + frame(0.125); // held 0.125 s + expect(world.log).toEqual(['down']); + + frame(0.125); // held 0.25 s: the delay is up + expect(world.log).toHaveLength(2); + + frame(0.0625); + frame(0.0625); + expect(world.log).toHaveLength(4); // a short frame counts as much as a long one + }); + + it('starts the delay over on the next press', () => { + const f = scene(); + logOn(f, 'OnKeyDown', { key: 'a', repeatAfter: 250, repeatInterval: 250 }, 'down'); + const { world, input, frame } = startWorld(f, { behaviors: true }); + + input.press('a'); + frame(0.125); + input.release('a'); + frame(0.5); // well past the delay, but the key is gone: no repeat + expect(world.log).toEqual(['down']); + + input.press('a'); + frame(0.125); + expect(world.log).toEqual(['down', 'down']); // the press itself, not a repeat + + frame(0.125); // held 0.125 s: the clock restarted, so nothing is due yet + expect(world.log).toHaveLength(2); + + frame(0.125); // held 0.25 s + expect(world.log).toHaveLength(3); + }); + + it('catches up when one frame is longer than the interval', () => { + const f = scene(); + logOn(f, 'OnKeyDown', { key: 'a', repeatAfter: 500, repeatInterval: 500 }, 'down'); + const { world, input, frame } = startWorld(f, { behaviors: true }); + + input.press('a'); + frame(0.125); + frame(2); // repeats due at 0.5, 1.0, 1.5 and 2.0 + expect(world.log).toHaveLength(5); + }); + + it('gives two nodes on the same key their own clocks', () => { + const f = scene(); + logOn(f, 'OnKeyDown', { key: 'a', repeatAfter: 250, repeatInterval: 250 }, 'fast'); + logOn(f, 'OnKeyDown', { key: 'a', repeatAfter: 750, repeatInterval: 750 }, 'slow'); + const { world, input, frame } = startWorld(f, { behaviors: true }); + + input.press('a'); + frame(0.125); + expect(world.log).toEqual(['fast', 'slow']); // the press reaches both + + frame(0.125); + frame(0.125); // held 0.25 s + expect(world.log).toEqual(['fast', 'slow', 'fast']); + + frame(0.125); + frame(0.125); // held 0.5 s + expect(world.log).toEqual(['fast', 'slow', 'fast', 'fast']); + + frame(0.25); // held 0.75 s: the fast one a third time, the slow one its first + expect(world.log).toEqual(['fast', 'slow', 'fast', 'fast', 'fast', 'slow']); + }); +}); + describe('On timer', () => { it('fires on its own clock, independent of the frame rate', () => { const f = scene(); diff --git a/tests/core/key-repeat.test.ts b/tests/core/key-repeat.test.ts new file mode 100644 index 00000000..54dfc8ba --- /dev/null +++ b/tests/core/key-repeat.test.ts @@ -0,0 +1,73 @@ +/** + * The repeat clock behind `On key down`, checked without a world: the cases that are awkward to + * stage in one (a key already held when tracking starts, a hostile interval) live here. + */ +import { describe, expect, it } from 'vitest'; +import { nodeDef } from '../../src/core/graph'; +import { MIN_REPEAT_INTERVAL, stepKeyRepeat } from '../../src/core/graph/keyRepeat'; +import type { PropertyBag } from '../../src/core/model/Properties'; + +const held = { pressed: false, down: true, dt: 0.1, after: 0.2, interval: 0.2 }; + +describe('key repeat', () => { + it('starts the clock on the press, whatever the frame was worth', () => { + const step = stepKeyRepeat(null, { ...held, pressed: true, dt: 5 }); + expect(step.fires).toBe(1); + expect(step.state).toEqual({ held: 0, repeats: 0 }); + }); + + it('forgets the clock when the key comes up', () => { + expect(stepKeyRepeat({ held: 9, repeats: 3 }, { ...held, down: false })).toEqual({ + state: null, + fires: 0, + }); + }); + + it('stays quiet for a key that was already held when it started watching', () => { + // No state means the press was never seen – repeating without a first fire would be a lie. + expect(stepKeyRepeat(null, held)).toEqual({ state: null, fires: 0 }); + }); + + it('never repeats while the delay is zero, however long the key is held', () => { + let state = { held: 0, repeats: 0 }; + for (let n = 0; n < 100; n++) { + const step = stepKeyRepeat(state, { ...held, after: 0, dt: 1 }); + expect(step.fires).toBe(0); + state = step.state!; + } + expect(state.held).toBe(100); + }); + + it('treats a negative delay as no delay at all', () => { + expect(stepKeyRepeat({ held: 5, repeats: 0 }, { ...held, after: -1 }).fires).toBe(0); + }); + + it('holds an absurdly small interval to the floor instead of freezing the frame', () => { + // Without the floor a 1 ns interval over a 1 s frame would ask for a billion fires. + const step = stepKeyRepeat({ held: 0.2, repeats: 0 }, { ...held, dt: 1, interval: 1e-9 }); + expect(step.fires).toBeLessThanOrEqual(1 / MIN_REPEAT_INTERVAL + 1); + expect(step.fires).toBeGreaterThan(1 / MIN_REPEAT_INTERVAL - 2); + }); + + it('counts every due repeat when one frame swallows several', () => { + const step = stepKeyRepeat({ held: 0, repeats: 0 }, { ...held, dt: 1, after: 0.5, interval: 0.5 }); + expect(step).toEqual({ state: { held: 1, repeats: 2 }, fires: 2 }); + }); +}); + +describe('the repeat rows on the node body', () => { + const hidden = (params: PropertyBag) => nodeDef('OnKeyDown')?.hiddenParams?.(params); + + it('leaves both off while the repeat is switched off', () => { + expect(hidden({ key: 'a' })).toEqual(['repeatAfter', 'repeatInterval']); + expect(hidden({ key: 'a', repeatAfter: 0, repeatInterval: 50 })).toEqual([ + 'repeatAfter', + 'repeatInterval', + ]); + }); + + it('shows both once a delay is set, an interval of zero included', () => { + // Zero is a setting here ("every frame"), not an absence – hiding that row would lie. + expect(hidden({ key: 'a', repeatAfter: 300, repeatInterval: 0 })).toEqual([]); + }); +}); diff --git a/todo-notes/on-key-down-repeat.md b/todo-notes/on-key-down-repeat.md new file mode 100644 index 00000000..c5cb8b83 --- /dev/null +++ b/todo-notes/on-key-down-repeat.md @@ -0,0 +1,82 @@ +das on key down event node bräuchte ein "repeat" +sprich wie wenn man auf der tastatur eine taste länger drückt: +- man drückt die taste, das event wir ganz normal gefeuert +- man hält länger gedrückt, nach einem bestimmten delay, feuert das event nochmal +- hält man weiterhin gedrückt, feuert das event öfter hintereinandert + +das ist ein beispiel szenario für tetris links/rechts shape bewegung + +ich denk wir brauchen beim "on key down" event zwei properties: +- repeat after (seconds) +- repeat interval (seconds, nachdem das erstmal repeat after aufgerufen wurde) + +ist repeat after auf 0 - kein effekt (alles wie bisher) +ist repeat interval auf 0 - jedes weitere frame aufrufen + +--- + +## Umgesetzt + +Genau die beschriebene Form: zwei Zahlen-Properties am `On key down`-Node. + +| | | +|---|---| +| **Repeat after (ms)** | Haltedauer bis zur ersten Wiederholung. **0 = kein Effekt**, alles wie bisher | +| **Repeat interval (ms)** | Abstand der weiteren Wiederholungen. **0 = jedes Frame** | + +Beide Felder sind **Millisekunden** — damit bleiben die ueblichen Werte ganze Zahlen (300, 50) +statt 0,3 und 0,05. Die Uhr dahinter rechnet weiter in Sekunden; umgerechnet wird beim Auslesen +der Node-Properties. + +Der erste Druck feuert immer sofort und unveraendert — die Wiederholung haengt nur hinten dran. + +### Warum das nicht der Auto-Repeat des Browsers ist + +Der Browser feuert bei gehaltener Taste selbst wiederholt `keydown`. Diese Ereignisse werden +seit jeher verworfen (`Input.press`: `if (!this.down.has(key)) ... // ignore auto-repeat`), und +das bleibt so: Verzoegerung und Rate sind eine **Betriebssystem-Einstellung des Spielers**, +kein Spielparameter. Die Wiederholung wird stattdessen aus der gemessenen Haltedauer gebaut — +damit ist sie auf jeder Maschine gleich und pro Node einstellbar. + +Die Regel selbst steht als reine Funktion in `src/core/graph/keyRepeat.ts`, nicht als Bedingung +im Runtime-Code — deshalb ist sie ohne Browser pruefbar. Die Haltedauer pro Node liegt in +`nodeState`, derselben Ablage, aus der `On timer` seine Uhr bezieht. + +### Eine Entscheidung, die ich getroffen habe + +Ein sehr kleines, positives Intervall wird auf **10 ms** angehoben — dieselbe Untergrenze, die +`On timer` schon hat. Sonst wuerde ein vertipptes `0,0001` bei einem langen Frame tausende +Aufrufe in einem Bild ausloesen. **0 bleibt davon unberuehrt** und behaelt seine eigene +Bedeutung ("jedes Frame"). + +### Der Node bleibt schmal + +Zwei Zeilen `Repeat after (ms): 0` an *jedem* Tastatur-Node waeren reiner Laerm gewesen — ich habe +mir das Bild angesehen, es war deutlich sichtbar. Beide Zeilen bleiben vom Node-Koerper weg, +solange die Verzoegerung 0 ist; der Inspector zeigt sie immer. Neu dafuer ist `NodeDef.hiddenParams`. +Sobald eine Verzoegerung gesetzt ist, erscheinen **beide** — auch ein Intervall von 0, denn das +ist dort eine Einstellung und keine Abwesenheit. + +### Der Beweis + +Zwei `On key down` auf **derselben** Taste, einer schlicht, einer wiederholend — ein einziges +Halten misst beide, damit "es hat wiederholt" nicht mit "die Taste kam oefter an" verwechselt +werden kann. + +Die folgende Ausgabe stammt aus dem Lauf **vor** der Umstellung auf Millisekunden (daher `0.300` / +`0.050` / `0.75 s`); das Modul tippt jetzt `300`, `50` und `750`. Der Nachlauf steht aus. + +``` +PASS a hold shorter than the delay fires once, repeat or not + – plain 0 → 1, repeating 0 → 1 over 120 ms with a 0.3 s delay +PASS holding past the delay keeps the repeating node firing, while the plain one counts one press + – plain +1, repeating +11 over 800 ms (press + ~10 repeats expected) +PASS a node with no repeat set carries no repeat rows, the repeating one carries both + – plain ["Key: Right arrow"], repeating [… "Repeat after (s): 0.300", "Repeat interval (s): 0.050"] +PASS typing a new delay lands on the node +PASS the delay typed into the inspector is the one the run uses + – repeating 0 → 1 over 500 ms with the delay raised to 0.75 s +``` + +Der letzte ist der wichtigste: derselbe Graph, nur die getippte Zahl geaendert — und der Node +schweigt. Damit haengt die Laufzeit nachweislich am Inspector-Wert und nicht an etwas anderem. diff --git a/tools/e2e/20-key-repeat.mjs b/tools/e2e/20-key-repeat.mjs new file mode 100644 index 00000000..333fbd88 --- /dev/null +++ b/tools/e2e/20-key-repeat.mjs @@ -0,0 +1,168 @@ +import { edge, node } from '../lib/builders.mjs'; + +/** + * The auto-repeat of `On key down`: a held key fires again after a delay and then on its own + * interval, which is what lets a Tetris piece slide sideways on one press. + * + * The step brings its own box and behavior with *two* `On key down` nodes on the same key – one + * plain, one repeating. One hold then measures both at once, so "it repeated" cannot be confused + * with "the key arrived more often than expected". Everything it adds is removed again at the end. + */ + +const PLAIN = 'plain hit'; +const REPEATING = 'repeating hit'; +const DELAY = 300; +const INTERVAL = 50; + +const repeatGraph = { + nodes: [ + node('r-plain', 'OnKeyDown', 0, 0, { key: 'ArrowRight' }), + node('r-plain-log', 'Log', 340, 0, { message: PLAIN }), + node('r-repeat', 'OnKeyDown', 0, 220, { + key: 'ArrowRight', + repeatAfter: DELAY, + repeatInterval: INTERVAL, + }), + node('r-repeat-log', 'Log', 340, 220, { message: REPEATING }), + ], + edges: [ + edge('r-e1', 'r-plain', 'exec', 'r-plain-log', 'exec'), + edge('r-e2', 'r-repeat', 'exec', 'r-repeat-log', 'exec'), + ], +}; + +export async function keyRepeat(t) { + const { check, sleep, json, shot, click, holdKey, world, mutate, stop, select, typeText } = t; + /** Both counts out of one read of the world, so the two are always of the same moment. */ + const hits = async () => { + const log = (await world()).log; + const count = (message) => log.filter((line) => line.includes(message)).length; + return { plain: count(PLAIN), repeating: count(REPEATING) }; + }; + + // 20a. A box with the two-node behavior below it, written into the model directly: what is under + // test is the repeat, not another way of building a graph. + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + const proto = scene.objects[0]; + const a = structuredClone(p.assets.find((x) => x.id === 'mover')); + Object.assign(a, { + id: 'repeater', + name: 'Repeater', + props: { enabled: true, runInEditor: false }, + variables: [], + graph: ${JSON.stringify(repeatGraph)}, + }); + p.assets.push(a); + const make = (id, assetId, parentId, y) => { + const o = structuredClone(proto); + Object.assign(o, { + id, + assetId, + name: id === 'r-box' ? 'Repeat box' : 'Repeater', + position: { x: 0, y, z: 0 }, + parentId, + overrides: {}, + instanceEdits: {}, + }); + return o; + }; + scene.objects.push(make('r-box', 'crate', '', -300), make('r-logic', 'repeater', 'r-box', 0)); + `); + await sleep(400); + + await t.play(); + await sleep(300); + + // 20b. A hold shorter than the delay is still just a press. Without this the check below would + // also pass on a node that simply fires every frame the key is down. + const beforeShort = await hits(); + await holdKey('ArrowRight', 120); + await sleep(200); + const afterShort = await hits(); + check( + 'a hold shorter than the delay fires once, repeat or not', + afterShort.plain === beforeShort.plain + 1 && afterShort.repeating === beforeShort.repeating + 1, + `plain ${beforeShort.plain} → ${afterShort.plain}, repeating ${beforeShort.repeating} → ${afterShort.repeating} over 120 ms with a ${DELAY} ms delay`, + ); + + // 20c. Hold it past the delay: the plain node still counts one press, the repeating one runs. + await holdKey('ArrowRight', 800); + await sleep(200); + const afterLong = await hits(); + const gained = afterLong.repeating - afterShort.repeating; + check( + 'holding past the delay keeps the repeating node firing, while the plain one counts one press', + afterLong.plain === afterShort.plain + 1 && gained > 4 && gained < 25, + `plain +${afterLong.plain - afterShort.plain}, repeating +${gained} over 800 ms (press + ~${Math.round((800 - DELAY) / INTERVAL)} repeats expected)`, + ); + await stop(); + await sleep(300); + + // 20d. Both settings are on the node itself, so a graph can be tuned where it is built. + await select({ kind: 'node', assetId: 'repeater', nodeId: 'r-repeat' }); + await sleep(300); + const afterField = await json(`__gm.inspectorField('Repeat after (ms)')`); + const intervalField = await json(`__gm.inspectorField('Repeat interval (ms)')`); + check( + 'the node inspector offers the delay and the interval', + afterField !== null && intervalField !== null, + JSON.stringify({ afterField, intervalField }), + ); + + // A switched-off repeat is worth no rows: two lines of "0" on every key node would make each + // one taller for nothing. The repeating node next to it shows both, so this is not just hiding. + const plainRows = await json(`__gm.graph.nodeSettings('r-plain')`); + const repeatRows = await json(`__gm.graph.nodeSettings('r-repeat')`); + check( + 'a node with no repeat set carries no repeat rows, the repeating one carries both', + plainRows.length === 1 && + repeatRows.length === 3 && + repeatRows.some((r) => r.startsWith('Repeat after')) && + repeatRows.some((r) => r.startsWith('Repeat interval')), + JSON.stringify({ plainRows, repeatRows }), + ); + await shot('40-key-repeat-node'); + + await click(afterField.x, afterField.y); + await typeText('750'); + await click(intervalField.x, intervalField.y); + await sleep(200); + const params = await json( + `__gm.state.project.assets.find((a) => a.id === 'repeater').graph.nodes.find((n) => n.id === 'r-repeat').params`, + ); + check( + 'typing a new delay lands on the node', + params.repeatAfter === 750 && params.repeatInterval === INTERVAL, + JSON.stringify(params), + ); + + // 20e. The typed value is what the next run obeys: 750 ms is longer than this hold, so the + // repeating node falls silent although nothing else about the graph changed. + await t.play(); + await sleep(300); + const beforeTuned = await hits(); + await holdKey('ArrowRight', 500); + await sleep(200); + const afterTuned = await hits(); + check( + 'the delay typed into the inspector is the one the run uses', + afterTuned.repeating === beforeTuned.repeating + 1, + `repeating ${beforeTuned.repeating} → ${afterTuned.repeating} over 500 ms with the delay raised to 750 ms`, + ); + await stop(); + await sleep(300); + + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + scene.objects = scene.objects.filter((o) => o.id !== 'r-box' && o.id !== 'r-logic'); + p.assets = p.assets.filter((a) => a.id !== 'repeater'); + `); + await sleep(300); + check( + 'the step leaves nothing of its own behind', + (await json(`__gm.sceneObjects().filter((o) => o.id.startsWith('r-')).length`)) === 0, + ); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index a9f3ac45..c188f6eb 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -22,6 +22,7 @@ import { containerTab } from './16-container.mjs'; import { editorRun } from './17-editor-run.mjs'; import { pause } from './18-pause.mjs'; import { nestedBehavior } from './19-nested-behavior.mjs'; +import { keyRepeat } from './20-key-repeat.mjs'; export const STEPS = [ hierarchy, @@ -43,4 +44,5 @@ export const STEPS = [ editorRun, pause, nestedBehavior, + keyRepeat, ];