A held key fired once and then nothing, so sliding a Tetris piece sideways or walking a menu meant building a timer next to every key node. Two settings on the node do it now: Repeat after and Repeat interval, both in milliseconds so the usual values stay whole numbers (300, 50) instead of 0.3 and 0.05. A delay of 0 keeps the node exactly as it was, an interval of 0 means every frame. The browser's own auto-repeat is deliberately not used - Input.press has always dropped it - because its delay and rate are an operating-system setting of the player's, not a game's. The repeat is built from the measured hold time instead, per node, so two nodes on one key keep their own clocks. The rule itself is a pure function in keyRepeat.ts and is checked without a world. Both rows stay off the node body while the delay is 0: two lines of "0" on every key node in every graph would be noise, and the screenshot said so. NodeDef gains hiddenParams for that; the inspector still shows them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
/**
|
||
* The event nodes have no `run` – the BehaviorSystem decides when they fire. These tests drive a
|
||
* real world and check that timing: key edges vs. held keys, the timer's own clock, and the
|
||
* difference between a collision that starts and one that continues.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
|
||
import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||
import { graphEditor, makeBehavior, makeSpriteScene, place, type SceneFixture, startWorld } from '../helpers';
|
||
|
||
/** A scene with a 100×60 sprite and a behavior placed below it, ready to have an event wired up. */
|
||
function scene(): SceneFixture & { behavior: BehaviorAsset; box: string } {
|
||
const f = makeSpriteScene('Box');
|
||
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 60));
|
||
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, box };
|
||
}
|
||
|
||
/** Wires `<event> → Log(message)` so a fired event shows up as one entry in `world.log`. */
|
||
function logOn(
|
||
f: SceneFixture & { behavior: BehaviorAsset },
|
||
event: string,
|
||
params: Record<string, string | number> = {},
|
||
message = event,
|
||
): void {
|
||
const edit = graphEditor(f, f.behavior);
|
||
const node = edit.addNode(event, params);
|
||
const log = edit.addNode('Log', { message });
|
||
edit.connect(node, 'exec', log, 'exec');
|
||
}
|
||
|
||
describe('key events', () => {
|
||
it('On key down fires once per press, not while the key stays held', () => {
|
||
const f = scene();
|
||
logOn(f, 'OnKeyDown', { key: 'a' }, 'down');
|
||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||
|
||
frame();
|
||
expect(world.log).toEqual([]);
|
||
|
||
input.press('a');
|
||
frame();
|
||
expect(world.log).toEqual(['down']);
|
||
|
||
input.press('a'); // auto-repeat while held: no new edge
|
||
frame();
|
||
frame();
|
||
expect(world.log).toEqual(['down']);
|
||
|
||
input.release('a');
|
||
input.press('a');
|
||
frame();
|
||
expect(world.log).toEqual(['down', 'down']);
|
||
});
|
||
|
||
it('On key up fires once when the key is released', () => {
|
||
const f = scene();
|
||
logOn(f, 'OnKeyUp', { key: 'a' }, 'up');
|
||
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(['up']);
|
||
|
||
frame(); // the edge is consumed, not sticky
|
||
expect(world.log).toEqual(['up']);
|
||
});
|
||
|
||
it('On key held fires every frame the key is down', () => {
|
||
const f = scene();
|
||
logOn(f, 'OnKeyHeld', { key: 'a' }, 'held');
|
||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||
|
||
input.press('a');
|
||
frame();
|
||
frame();
|
||
frame();
|
||
expect(world.log).toEqual(['held', 'held', 'held']);
|
||
|
||
input.release('a');
|
||
frame();
|
||
expect(world.log).toHaveLength(3);
|
||
});
|
||
|
||
it('only the node whose key matches fires', () => {
|
||
const f = scene();
|
||
logOn(f, 'OnKeyDown', { key: 'a' }, 'a');
|
||
logOn(f, 'OnKeyDown', { key: 'b' }, 'b');
|
||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||
|
||
input.press('b');
|
||
frame();
|
||
expect(world.log).toEqual(['b']);
|
||
});
|
||
|
||
it('losing the window releases every held key, so nothing stays down behind the user’s back', () => {
|
||
const f = scene();
|
||
logOn(f, 'OnKeyUp', { key: 'a' }, 'up');
|
||
logOn(f, 'OnKeyHeld', { key: 'a' }, 'held');
|
||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||
|
||
input.press('a');
|
||
frame();
|
||
expect(world.log).toEqual(['held']);
|
||
|
||
input.releaseAll();
|
||
frame();
|
||
expect(world.log).toEqual(['held', 'up']);
|
||
frame();
|
||
expect(world.log).toHaveLength(2);
|
||
});
|
||
});
|
||
|
||
// 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();
|
||
logOn(f, 'OnTimer', { interval: 1 }, 'tick');
|
||
const { world, frame } = startWorld(f, { behaviors: true });
|
||
|
||
for (let i = 0; i < 4; i++) frame(0.2); // 0.8 s
|
||
expect(world.log).toEqual([]);
|
||
frame(0.2); // 1.0 s
|
||
expect(world.log).toEqual(['tick']);
|
||
for (let i = 0; i < 5; i++) frame(0.2); // 2.0 s
|
||
expect(world.log).toEqual(['tick', 'tick']);
|
||
});
|
||
|
||
it('catches up when one frame is longer than the interval', () => {
|
||
const f = scene();
|
||
logOn(f, 'OnTimer', { interval: 0.5 }, 'tick');
|
||
const { world, frame } = startWorld(f, { behaviors: true });
|
||
|
||
frame(2); // a hitch worth four intervals
|
||
expect(world.log).toEqual(['tick', 'tick', 'tick', 'tick']);
|
||
});
|
||
|
||
it('two timers keep separate clocks', () => {
|
||
const f = scene();
|
||
logOn(f, 'OnTimer', { interval: 1 }, 'slow');
|
||
logOn(f, 'OnTimer', { interval: 0.5 }, 'fast');
|
||
const { world, frame } = startWorld(f, { behaviors: true });
|
||
|
||
frame(0.5);
|
||
expect(world.log).toEqual(['fast']);
|
||
frame(0.5);
|
||
expect(world.log).toEqual(['fast', 'slow', 'fast']);
|
||
});
|
||
});
|
||
|
||
describe('On collision stay', () => {
|
||
it('fires every frame the overlap lasts, while enter fires only on the first one', () => {
|
||
const f = makeSpriteScene('Crate');
|
||
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 60));
|
||
const behavior = makeBehavior(f, 'Hit');
|
||
const a = place(f, f.spriteId, { x: 0, y: 0 });
|
||
const b = place(f, f.spriteId, { x: 300, y: 0 });
|
||
f.stack.execute(new AddColliderCommand(f.sceneId, a));
|
||
f.stack.execute(new AddColliderCommand(f.sceneId, b));
|
||
place(f, behavior.id, { x: 0, y: 0 }, a);
|
||
const fixture = { ...f, behavior };
|
||
logOn(fixture, 'OnCollisionEnter', {}, 'enter');
|
||
logOn(fixture, 'OnCollisionStay', {}, 'stay');
|
||
logOn(fixture, 'OnCollisionExit', {}, 'exit');
|
||
|
||
const { world, frame } = startWorld(f, { collisions: true, behaviors: true });
|
||
const crateB = world.findByObjectId(b)!;
|
||
|
||
frame(0.1);
|
||
expect(world.log).toEqual([]);
|
||
|
||
crateB.x = 50; // now overlapping
|
||
frame(0.1);
|
||
expect(world.log).toEqual(['enter']);
|
||
frame(0.1);
|
||
frame(0.1);
|
||
expect(world.log).toEqual(['enter', 'stay', 'stay']);
|
||
|
||
crateB.x = 500;
|
||
frame(0.1);
|
||
expect(world.log).toEqual(['enter', 'stay', 'stay', 'exit']);
|
||
frame(0.1);
|
||
expect(world.log).toHaveLength(4);
|
||
});
|
||
});
|
||
|
||
describe('a switched-off behavior receives no events', () => {
|
||
it('ignores keys and timers until it is switched back on', () => {
|
||
const f = scene();
|
||
logOn(f, 'OnKeyDown', { key: 'a' }, 'down');
|
||
logOn(f, 'OnTimer', { interval: 0.5 }, 'tick');
|
||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||
const logic = world.entities.find((e) => e.name === 'Logic')!;
|
||
|
||
logic.props.enabled = false;
|
||
input.press('a');
|
||
frame(1);
|
||
expect(world.log).toEqual([]);
|
||
|
||
logic.props.enabled = true;
|
||
input.release('a');
|
||
input.press('a');
|
||
frame(1);
|
||
// The timer starts counting when the behavior runs again – no backlog from the off period.
|
||
expect(world.log).toEqual(['down', 'tick', 'tick']);
|
||
});
|
||
});
|