Input remembered keys under KeyboardEvent.key. Pressing a, adding Shift and letting go sent "A" on release, which matched nothing, so "a" stayed down until the next release without Shift. - New rule keyName in src/core/keys.ts lowercases a single character. So a and A are the same key, also under Caps Lock. - Input remembers per physical key (e.code) under which name it went down and releases exactly that name. So 1 released as ! is freed too, which lowercasing alone does not cover. - On key down, up and held, Key is down and Axis input compare with the same rule. A node that already stores A reacts to a. - Recording a key in the inspector stores Shift+A as a. - Limit: holding Shift before pressing 1 still sends !, and a node on 1 does not react. Nothing gets stuck by it. Tests: - 3 new unit tests in tests/core/behavior-events.test.ts: the flow from the issue, 1 released as !, and a node on A hearing a. - Without lowercasing exactly the issue test and the A test fail. - Without the code memory exactly the 1/! test fails. - ./check.sh: 159 files, 2340 tests, CHECK EXIT 0 (before 2337). - No e2e change, the check sum stays at 1377. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
504 lines
17 KiB
TypeScript
504 lines
17 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 { beforeAll, 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 { loadPhysics } from '../../src/runtime/physics/engine';
|
||
import {
|
||
COLLISION_SYSTEMS,
|
||
graphEditor,
|
||
makeBehavior,
|
||
makeSpriteScene,
|
||
place,
|
||
startWorld,
|
||
type SceneFixture,
|
||
} 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);
|
||
});
|
||
|
||
it('a key pressed as "a" and let go as "A" under Shift is released (#227)', () => {
|
||
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', 'KeyA');
|
||
frame();
|
||
input.press('Shift', 'ShiftLeft');
|
||
input.press('A', 'KeyA'); // auto-repeat, now with Shift: still the same key
|
||
frame();
|
||
input.release('A', 'KeyA');
|
||
frame();
|
||
input.release('Shift', 'ShiftLeft');
|
||
frame();
|
||
expect(world.log).toEqual(['held', 'held', 'up']);
|
||
expect(input.isDown('a')).toBe(false);
|
||
});
|
||
|
||
it('a key keeps the name it went down as, even when Shift gives its release another one', () => {
|
||
const f = scene();
|
||
logOn(f, 'OnKeyUp', { key: '1' }, 'up');
|
||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||
|
||
input.press('1', 'Digit1');
|
||
frame();
|
||
input.release('!', 'Digit1');
|
||
frame();
|
||
expect(world.log).toEqual(['up']);
|
||
expect(input.heldKeys.size).toBe(0);
|
||
});
|
||
|
||
it('a key stored upper-case in the graph still hears the key', () => {
|
||
const f = scene();
|
||
logOn(f, 'OnKeyDown', { key: 'A' }, 'down');
|
||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||
|
||
input.press('a', 'KeyA');
|
||
frame();
|
||
expect(world.log).toEqual(['down']);
|
||
expect(input.isDown('A')).toBe(true);
|
||
});
|
||
});
|
||
|
||
// 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('late is how far the firing frame is past the due time, so time minus late is exact', () => {
|
||
const f = scene();
|
||
const edit = graphEditor(f, f.behavior);
|
||
const timer = edit.addNode('OnTimer', { interval: 1 });
|
||
const log = edit.addNode('Log', {});
|
||
edit.connect(timer, 'exec', log, 'exec');
|
||
edit.connect(timer, 'late', log, 'message');
|
||
const { world, frame } = startWorld(f, { behaviors: true });
|
||
|
||
frame(0.75);
|
||
frame(0.375); // 1.125 s: due at 1.0
|
||
expect(world.log).toEqual(['0.125']);
|
||
frame(0.875); // 2.0 s: due exactly now
|
||
expect(world.log).toEqual(['0.125', '0']);
|
||
frame(2.5); // 4.5 s: catches up 3.0 and 4.0, the first one longer ago
|
||
expect(world.log).toEqual(['0.125', '0', '1.5', '0.5']);
|
||
});
|
||
|
||
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.each(COLLISION_SYSTEMS)('$system', ({ physics }) => {
|
||
beforeAll(async () => {
|
||
await loadPhysics();
|
||
});
|
||
|
||
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, { physics, 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']);
|
||
});
|
||
});
|
||
|
||
/** Every lifecycle event logs its own name, so `world.log` reads as the order they fired in. */
|
||
function lifecycle(): ReturnType<typeof scene> {
|
||
const f = scene();
|
||
for (const event of ['OnStart', 'OnEnable', 'OnUpdate', 'OnDisable', 'OnDestroy'])
|
||
logOn(f, event, {}, event.slice(2).toLowerCase());
|
||
return f;
|
||
}
|
||
|
||
describe('lifecycle events', () => {
|
||
it('fires On start, then On enable, then the first On update', () => {
|
||
const { world, frame } = startWorld(lifecycle(), { behaviors: true });
|
||
frame();
|
||
expect(world.log).toEqual(['start', 'enable', 'update']);
|
||
});
|
||
|
||
it('fires On disable when switched off and On enable – not On start – when switched back on', () => {
|
||
const f = lifecycle();
|
||
const { world, frame } = startWorld(f, { behaviors: true });
|
||
const logic = world.entities.find((e) => e.name === 'Logic')!;
|
||
frame();
|
||
|
||
logic.props.enabled = false;
|
||
frame();
|
||
frame();
|
||
expect(world.log).toEqual(['start', 'enable', 'update', 'disable']);
|
||
|
||
logic.props.enabled = true;
|
||
frame();
|
||
expect(world.log).toEqual(['start', 'enable', 'update', 'disable', 'enable', 'update']);
|
||
});
|
||
|
||
it('treats a parent being switched off the same way', () => {
|
||
const f = lifecycle();
|
||
const { world, frame } = startWorld(f, { behaviors: true });
|
||
const box = world.findByObjectId(f.box)!;
|
||
frame();
|
||
|
||
box.props.enabled = false;
|
||
frame();
|
||
expect(world.log).toEqual(['start', 'enable', 'update', 'disable']);
|
||
box.props.enabled = true;
|
||
frame();
|
||
expect(world.log.slice(4)).toEqual(['enable', 'update']);
|
||
});
|
||
|
||
it('fires On disable then On destroy when the owner is destroyed, in the same frame', () => {
|
||
const f = lifecycle();
|
||
// `On key down → Destroy(Owner)`: the behavior destroys the box it hangs below, itself included.
|
||
const edit = graphEditor(f, f.behavior);
|
||
const key = edit.addNode('OnKeyDown', { key: 'x' });
|
||
const owner = edit.addNode('Owner');
|
||
const destroy = edit.addNode('Destroy');
|
||
edit.wire([
|
||
[key, 'exec', destroy, 'exec'],
|
||
[owner, 'object', destroy, 'object'],
|
||
]);
|
||
const { world, input, frame, behaviors } = startWorld(f, { behaviors: true });
|
||
frame();
|
||
expect(behaviors!.runningIds()).toHaveLength(1);
|
||
|
||
input.press('x');
|
||
frame();
|
||
// The frame that destroys it still runs to its end – the farewell comes after this frame's update.
|
||
expect(world.log).toEqual(['start', 'enable', 'update', 'update', 'disable', 'destroy']);
|
||
expect(behaviors!.runningIds()).toEqual([]);
|
||
expect(world.entities.some((e) => e.name === 'Logic')).toBe(false);
|
||
frame();
|
||
expect(world.log).toHaveLength(6);
|
||
});
|
||
|
||
it('skips On disable for a behavior destroyed while it was off', () => {
|
||
const f = lifecycle();
|
||
const { world, frame } = startWorld(f, { behaviors: true });
|
||
const logic = world.entities.find((e) => e.name === 'Logic')!;
|
||
logic.props.enabled = false;
|
||
frame();
|
||
world.remove(logic);
|
||
frame();
|
||
expect(world.log).toEqual(['start', 'enable', 'disable', 'destroy']);
|
||
});
|
||
|
||
it('runs On disable and On destroy for every instance when the world is torn down', () => {
|
||
const f = lifecycle();
|
||
place(f, f.behavior.id, { x: 0, y: 0 });
|
||
const { world, frame } = startWorld(f, { behaviors: true });
|
||
frame();
|
||
world.destroy();
|
||
expect(world.log).toEqual([
|
||
'start',
|
||
'enable',
|
||
'start',
|
||
'enable',
|
||
'update',
|
||
'update',
|
||
'disable',
|
||
'destroy',
|
||
'disable',
|
||
'destroy',
|
||
]);
|
||
});
|
||
});
|