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>
This commit is contained in:
1 parent
4b0919a224
commit
3ab8d88ad6
13 files changed
+552
-3
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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']),
|
||||
};
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user