Skip to content

Commit 087de1a

Browse files
committed
feat: implement Monocle-style input system and take newer input for controls
1 parent cf7996a commit 087de1a

14 files changed

Lines changed: 774 additions & 48 deletions

docs/tech-checklist.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ Legend:
3838
| Grounded Ultras | WORKS | 390 burst observed from 325 * 1.2 |
3939
| Grounded Ultra Cancel | EXCLUDED | Requires dash interruption sources not present |
4040
| Delayed Ultra | WORKS | Landing after a down-diagonal dash still applies the ultra slide conversion |
41-
| Demodash | EXCLUDED | No dedicated crouch-dash/manual-demo input path yet, and the exact solution is not in the local reference file |
41+
| Demodash | EXCLUDED | Input system implements Monocle-style directional handling but demo-specific startup behavior doesn't emerge |
4242
| Demohyper | EXCLUDED | Depends on the excluded demo input path |
4343
| Up Diagonal Demo | EXCLUDED | Depends on the excluded demo input path |
4444
| Wallbounce | WORKS | Updash + wall jump yields super wall jump values (170/-160) |

src/GameScene.ts

Lines changed: 19 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { EntityWorld, spikeTriangles } from "./entities/EntityWorld";
44
import { RefillEntity, RefillType } from "./entities/types";
55
import { TILE_JUMP_THROUGH, tileAt } from "./grid";
66
import { parseLevel } from "./level";
7+
import { PlayerControls } from "./input/PlayerControls";
78
import { addFloat, approach, maxFloat, stepTimer, subFloat, toFloat } from "./player/math";
89
import { Player } from "./player/Player";
910
import { InputState, PlayerEffect } from "./player/types";
@@ -48,8 +49,7 @@ export class GameScene extends Phaser.Scene {
4849
private tileDepths!: Int32Array;
4950

5051
private keys!: Record<string, Phaser.Input.Keyboard.Key>;
51-
private pendingJumpEdges: Array<"down" | "up"> = [];
52-
private pendingDashPresses = 0;
52+
private controls!: PlayerControls;
5353

5454
private accumulator = 0;
5555
private readonly fixedDt = toFloat(1 / 60);
@@ -116,15 +116,12 @@ export class GameScene extends Phaser.Scene {
116116
right: kb.addKey(Phaser.Input.Keyboard.KeyCodes.RIGHT),
117117
up: kb.addKey(Phaser.Input.Keyboard.KeyCodes.UP),
118118
down: kb.addKey(Phaser.Input.Keyboard.KeyCodes.DOWN),
119-
a: kb.addKey(Phaser.Input.Keyboard.KeyCodes.A),
120-
d: kb.addKey(Phaser.Input.Keyboard.KeyCodes.D),
121-
w: kb.addKey(Phaser.Input.Keyboard.KeyCodes.W),
122-
s: kb.addKey(Phaser.Input.Keyboard.KeyCodes.S),
123119
grab: kb.addKey(Phaser.Input.Keyboard.KeyCodes.Z),
124120
dash: kb.addKey(Phaser.Input.Keyboard.KeyCodes.X),
125121
jump: kb.addKey(Phaser.Input.Keyboard.KeyCodes.C),
126122
restart: kb.addKey(Phaser.Input.Keyboard.KeyCodes.R),
127123
};
124+
this.controls = new PlayerControls();
128125
this.keys.jump.on("down", this.onJumpDown, this);
129126
this.keys.jump.on("up", this.onJumpUp, this);
130127
this.keys.dash.on("down", this.onDashDown, this);
@@ -144,7 +141,7 @@ export class GameScene extends Phaser.Scene {
144141
.text(
145142
VIEWPORT.width - 8,
146143
VIEWPORT.height - 8,
147-
"Move: arrows / WASD\nC jump X dash Z grab R reset",
144+
"Move: arrow keys\nC jump X dash Z grab R reset",
148145
{
149146
fontFamily: "monospace",
150147
fontSize: "9px",
@@ -166,7 +163,7 @@ export class GameScene extends Phaser.Scene {
166163
this.player.hardRespawn(this.spawnX, this.spawnY);
167164
this.world.resetTransientState();
168165
this.syncRefillViews();
169-
this.clearInputEdgeQueues();
166+
this.controls.clearTransientState();
170167
this.forceCameraSnap();
171168
this.cameras.main.fadeIn(80, 10, 10, 20);
172169
this.accumulator = 0;
@@ -205,7 +202,7 @@ export class GameScene extends Phaser.Scene {
205202
this.player.hardRespawn(this.spawnX, this.spawnY);
206203
this.world.resetTransientState();
207204
this.syncRefillViews();
208-
this.clearInputEdgeQueues();
205+
this.controls.clearTransientState();
209206
this.forceCameraSnap();
210207
if (spiked) {
211208
this.cameras.main.flash(180, 0, 0, 0, false);
@@ -297,7 +294,7 @@ export class GameScene extends Phaser.Scene {
297294
this.keys.jump.off("up", this.onJumpUp, this);
298295
this.keys.dash.off("down", this.onDashDown, this);
299296
}
300-
this.clearInputEdgeQueues();
297+
this.controls?.reset();
301298
this.playerView?.destroy();
302299
this.refillEmitter?.destroy();
303300
for (const refill of this.refills) {
@@ -308,51 +305,29 @@ export class GameScene extends Phaser.Scene {
308305
}
309306

310307
private gatherStepInput(): InputState {
311-
let x = 0;
312-
let y = 0;
313-
if (this.keys.left.isDown || this.keys.a.isDown) x -= 1;
314-
if (this.keys.right.isDown || this.keys.d.isDown) x += 1;
315-
if (this.keys.up.isDown || this.keys.w.isDown) y -= 1;
316-
if (this.keys.down.isDown || this.keys.s.isDown) y += 1;
317-
318-
const jump = this.keys.jump.isDown;
319-
const jumpEdge = this.pendingJumpEdges.shift();
320-
const jumpPressed = jumpEdge === "down";
321-
const jumpReleased = jumpEdge === "up";
322-
const dash = this.keys.dash.isDown;
323-
const dashPressed = this.pendingDashPresses > 0;
324-
if (dashPressed) this.pendingDashPresses--;
325-
const grab = this.keys.grab.isDown;
326-
327-
return {
328-
x,
329-
y,
330-
jump,
331-
jumpPressed,
332-
jumpReleased,
333-
dash,
334-
dashPressed,
335-
grab,
336-
};
308+
this.controls.setCheck("leftArrow", this.keys.left.isDown);
309+
this.controls.setCheck("rightArrow", this.keys.right.isDown);
310+
this.controls.setCheck("upArrow", this.keys.up.isDown);
311+
this.controls.setCheck("downArrow", this.keys.down.isDown);
312+
this.controls.setCheck("jump", this.keys.jump.isDown);
313+
this.controls.setCheck("dash", this.keys.dash.isDown);
314+
this.controls.setCheck("grab", this.keys.grab.isDown);
315+
316+
return this.controls.update(this.fixedDt);
337317
}
338318

339319
private onJumpDown(event: KeyboardEvent): void {
340320
if (event.repeat) return;
341-
this.pendingJumpEdges.push("down");
321+
this.controls.queuePress("jump");
342322
}
343323

344324
private onJumpUp(): void {
345-
this.pendingJumpEdges.push("up");
325+
this.controls.queueRelease("jump");
346326
}
347327

348328
private onDashDown(event: KeyboardEvent): void {
349329
if (event.repeat) return;
350-
this.pendingDashPresses++;
351-
}
352-
353-
private clearInputEdgeQueues(): void {
354-
this.pendingJumpEdges.length = 0;
355-
this.pendingDashPresses = 0;
330+
this.controls.queuePress("dash");
356331
}
357332

358333
private updateCamera(snapshot: ReturnType<Player["getSnapshot"]>, dt: number): void {

src/input/ButtonBank.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
export interface ButtonState {
2+
check: boolean;
3+
pressed: boolean;
4+
released: boolean;
5+
}
6+
7+
interface ButtonTracker {
8+
check: boolean;
9+
pressedQueue: number;
10+
releasedQueue: number;
11+
}
12+
13+
const EMPTY_BUTTON_STATE: ButtonState = {
14+
check: false,
15+
pressed: false,
16+
released: false,
17+
};
18+
19+
export class ButtonBank {
20+
private readonly trackers = new Map<string, ButtonTracker>();
21+
private readonly stepStates = new Map<string, ButtonState>();
22+
23+
constructor(names: readonly string[] = []) {
24+
for (const name of names) {
25+
this.register(name);
26+
}
27+
}
28+
29+
register(name: string): void {
30+
if (this.trackers.has(name)) return;
31+
32+
this.trackers.set(name, {
33+
check: false,
34+
pressedQueue: 0,
35+
releasedQueue: 0,
36+
});
37+
this.stepStates.set(name, EMPTY_BUTTON_STATE);
38+
}
39+
40+
setCheck(name: string, value: boolean): void {
41+
this.ensure(name).check = value;
42+
}
43+
44+
queuePress(name: string): void {
45+
this.ensure(name).pressedQueue++;
46+
}
47+
48+
queueRelease(name: string): void {
49+
this.ensure(name).releasedQueue++;
50+
}
51+
52+
beginStep(): void {
53+
for (const [name, tracker] of this.trackers) {
54+
const pressed = tracker.pressedQueue > 0;
55+
const released = tracker.releasedQueue > 0;
56+
if (pressed) tracker.pressedQueue--;
57+
if (released) tracker.releasedQueue--;
58+
59+
this.stepStates.set(name, {
60+
check: tracker.check,
61+
pressed,
62+
released,
63+
});
64+
}
65+
}
66+
67+
get(name: string): ButtonState {
68+
return this.stepStates.get(name) ?? EMPTY_BUTTON_STATE;
69+
}
70+
71+
clearQueues(): void {
72+
for (const tracker of this.trackers.values()) {
73+
tracker.pressedQueue = 0;
74+
tracker.releasedQueue = 0;
75+
}
76+
77+
for (const [name, tracker] of this.trackers) {
78+
this.stepStates.set(name, {
79+
check: tracker.check,
80+
pressed: false,
81+
released: false,
82+
});
83+
}
84+
}
85+
86+
reset(): void {
87+
for (const tracker of this.trackers.values()) {
88+
tracker.check = false;
89+
tracker.pressedQueue = 0;
90+
tracker.releasedQueue = 0;
91+
}
92+
93+
for (const name of this.trackers.keys()) {
94+
this.stepStates.set(name, EMPTY_BUTTON_STATE);
95+
}
96+
}
97+
98+
private ensure(name: string): ButtonTracker {
99+
this.register(name);
100+
return this.trackers.get(name)!;
101+
}
102+
}

src/input/PlayerControls.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { ButtonBank } from "./ButtonBank";
2+
import { VirtualButton } from "./VirtualButton";
3+
import { VirtualIntegerAxis } from "./VirtualIntegerAxis";
4+
import { VirtualJoystick } from "./VirtualJoystick";
5+
import { AxisButtonsNode, ButtonBindingNode, JoystickButtonsNode } from "./nodes";
6+
import { OverlapBehavior } from "./VirtualInput";
7+
import type { InputState } from "../player/types";
8+
9+
export type PlayerBinding =
10+
| "leftArrow"
11+
| "rightArrow"
12+
| "upArrow"
13+
| "downArrow"
14+
| "grab"
15+
| "dash"
16+
| "jump";
17+
18+
const PLAYER_BINDINGS: readonly PlayerBinding[] = [
19+
"leftArrow",
20+
"rightArrow",
21+
"upArrow",
22+
"downArrow",
23+
"grab",
24+
"dash",
25+
"jump",
26+
];
27+
28+
export class PlayerControls {
29+
private readonly bank = new ButtonBank(PLAYER_BINDINGS);
30+
31+
private readonly moveX = new VirtualIntegerAxis(
32+
new AxisButtonsNode(this.bank, OverlapBehavior.TakeNewer, "leftArrow", "rightArrow"),
33+
);
34+
35+
private readonly moveY = new VirtualIntegerAxis(
36+
new AxisButtonsNode(this.bank, OverlapBehavior.TakeNewer, "upArrow", "downArrow"),
37+
);
38+
39+
private readonly aim = new VirtualJoystick(
40+
false,
41+
new JoystickButtonsNode(
42+
this.bank,
43+
OverlapBehavior.TakeNewer,
44+
"leftArrow",
45+
"rightArrow",
46+
"upArrow",
47+
"downArrow",
48+
),
49+
);
50+
51+
private readonly jump = new VirtualButton(0, new ButtonBindingNode(this.bank, "jump"));
52+
private readonly dash = new VirtualButton(0, new ButtonBindingNode(this.bank, "dash"));
53+
private readonly grab = new VirtualButton(0, new ButtonBindingNode(this.bank, "grab"));
54+
55+
setCheck(binding: PlayerBinding, value: boolean): void {
56+
this.bank.setCheck(binding, value);
57+
}
58+
59+
queuePress(binding: PlayerBinding): void {
60+
this.bank.queuePress(binding);
61+
}
62+
63+
queueRelease(binding: PlayerBinding): void {
64+
this.bank.queueRelease(binding);
65+
}
66+
67+
update(dt: number): InputState {
68+
this.bank.beginStep();
69+
this.moveX.update(dt);
70+
this.moveY.update(dt);
71+
this.aim.update(dt);
72+
this.jump.update(dt);
73+
this.dash.update(dt);
74+
this.grab.update(dt);
75+
76+
const aim = this.aim.value;
77+
78+
return {
79+
x: this.moveX.value,
80+
y: this.moveY.value,
81+
aimX: aim.x,
82+
aimY: aim.y,
83+
jump: this.jump.check,
84+
jumpPressed: this.jump.pressed,
85+
jumpReleased: this.jump.released,
86+
dash: this.dash.check,
87+
dashPressed: this.dash.pressed,
88+
grab: this.grab.check,
89+
};
90+
}
91+
92+
clearTransientState(): void {
93+
this.bank.clearQueues();
94+
this.jump.reset();
95+
this.dash.reset();
96+
this.grab.reset();
97+
}
98+
99+
reset(): void {
100+
this.bank.reset();
101+
this.moveX.reset();
102+
this.moveY.reset();
103+
this.aim.reset();
104+
this.jump.reset();
105+
this.dash.reset();
106+
this.grab.reset();
107+
}
108+
}

src/input/VirtualAxis.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { VirtualInput, VirtualInputNode } from "./VirtualInput";
2+
3+
export abstract class VirtualAxisNode extends VirtualInputNode {
4+
abstract get value(): number;
5+
}
6+
7+
export class VirtualAxis extends VirtualInput {
8+
readonly nodes: VirtualAxisNode[];
9+
value = 0;
10+
previousValue = 0;
11+
12+
constructor(...nodes: VirtualAxisNode[]) {
13+
super();
14+
this.nodes = nodes;
15+
}
16+
17+
update(_dt: number): void {
18+
for (const node of this.nodes) {
19+
node.update();
20+
}
21+
22+
this.previousValue = this.value;
23+
this.value = 0;
24+
for (const node of this.nodes) {
25+
const value = node.value;
26+
if (value !== 0) {
27+
this.value = value;
28+
break;
29+
}
30+
}
31+
}
32+
33+
override reset(): void {
34+
this.value = 0;
35+
this.previousValue = 0;
36+
}
37+
}

0 commit comments

Comments
 (0)