Files
feuerwerk/tests/core/behavior-events.test.ts
T
rootandClaude Opus 5 3ab8d88ad6 Let On key down repeat while the key stays held
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>
2026-09-09 22:44:00 +02:00

326 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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']);
});
});