A row of arrows appears, and I dance it with the arrow keys or the on-screen pad before the time bar runs out, while a blocky dancer strikes the poses. Underneath sits the question every combo game asks: did the player just enter this sequence, judged one input at a time? The new toolbox module is ComboMatcher.
Play it · Source: collection-spielwerk/07-dance-combo · Toolbox 5.5.0 or later
The game
A round has ten combos of three to seven arrows; every other combo is one arrow longer, and every new round starts one longer with a little less time. Each correct step lights the next arrow and the dancer takes the pose. A wrong step, or a step after more than 1.5 seconds of hesitation, breaks the combo ("Daneben!", missed): the row shakes and starts over. When the bar runs out it is "Zu langsam!" (too slow) and the next combo comes without points. A combo in time scores its length × 10 plus a bonus for the time left; after ten combos the round shows "Runde vorbei" (round over) with the score, and the best score of the session stays.
- Mouse/touch: the four arrow buttons, and "Neue Runde" (new round).
- Keyboard: arrow keys or WASD, Enter for a new round.
New in the toolbox: ComboMatcher
The first draft of "did the player enter the combo?" is an array that collects every key and gets compared with the target. That works as long as nobody cares about the moment in between. This game reacts after every single step (light the arrow, change the pose, shake on a mistake), so it needs "how far am I" after each input. That is a ComboMatcher: one target sequence, one position, and an answer per input.
export type ComboResult = "progress" | "complete" | "broken" | "ignored";
export class ComboMatcher<T> {
constructor(sequence: readonly T[], options?: ComboOptions, equals?: (a: T, b: T) => boolean);
readonly sequence: readonly T[];
get position(): number; // how many steps match so far
get progress(): number; // 0..1
get is_complete(): boolean;
input(value: T, now_seconds?: number): ComboResult;
reset(): void;
}
T is anything: strings like "ArrowLeft", an enum, objects with their own equals. progress means "that was the next step", complete "that was the last", broken "something was in progress and this did not continue it", ignored "nothing was in progress and this does not start it either".
The subtle part is broken. Where does the matcher stand after A A B on the sequence A B? The first A fits, the second does not, but the second A is itself the start of the sequence. A matcher that drops back to 0 now demands a third A, although the player just pressed one. So after a wrong step the matcher looks for the longest tail of the inputs so far that is also a head of the sequence, and continues there: after A A it stands at 1, and B completes. That is the failure function from string search, computed on the spot because combos are short:
private restart_position(value: T): number {
const matched = this.sequence.slice(0, this._position).concat([value]);
const longest = Math.min(matched.length, this.sequence.length - 1);
for (let length = longest; length > 0; length--) {
const start = matched.length - length; // try the longest tail first
let is_prefix = true;
for (let index = 0; index < length; index++) {
if (!this.equals(this.sequence[index], matched[start + index])) {
is_prefix = false;
break;
}
}
if (is_prefix) {
return length; // continue right after it
}
}
return 0;
}
For a fighting game's move list that is the difference between "the special move fires" and "press it again". For those there is ComboList: several named sequences, every input goes to all of them, and input() returns the names of the combos it just completed. A B and A B C may overlap; both fire at their own last step.
The second decision: the matcher never reads a clock. max_gap_seconds is the rule "hesitate too long and you start over", but the time comes in from outside as now_seconds. The game passes its own clock, the sum of all frame deltas, so the rule stops while the game is paused, runs in a test with 0, 1, 2 by hand, and does not depend on performance.now().
The game's whole use of it fits in one method:
// src/game/Game.ts, trimmed
this.matcher = new ComboMatcher(this.combo, { max_gap_seconds: MAX_GAP_SECONDS }); // 1.5 s
public input(arrow: Arrow): void {
if (this.phase !== 'entering') {
return;
}
const result = this.matcher.input(arrow, this.elapsed_seconds);
if (result === 'progress') {
this.pose = arrow;
return;
}
if (result === 'complete') {
// score it
this.phases.transition('hit');
return;
}
// "broken" or "ignored": the matcher may have restarted on a prefix; the dance does not
this.matcher.reset();
this.shake_seconds = SHAKE_SECONDS;
}
The dance deliberately throws away the restart position: one wrong step, the whole row again. The test next to the module (src/input/ComboMatcher.test.ts) pins down both the restart and the gap rule:
it("restarts when the wrong input is the first element (A A B on A B)", () => {
const matcher = new ComboMatcher(["A", "B"]);
expect(matcher.input("A")).toBe("progress");
expect(matcher.input("A")).toBe("broken");
expect(matcher.position).toBe(1);
expect(matcher.input("B")).toBe("complete");
});
it("drops a partial match when the gap between steps is too long", () => {
const matcher = new ComboMatcher(["A", "B", "C"], { max_gap_seconds: 0.5 });
expect(matcher.input("A", 0)).toBe("progress");
expect(matcher.input("B", 0.4)).toBe("progress");
expect(matcher.input("C", 1.2)).toBe("broken");
expect(matcher.position).toBe(0);
});
Path of the module: src/input/ComboMatcher.ts.
From earlier steps
SeededRandomfrom step 01: draws the arrows of every combo.KeyboardStatefrom step 02: read once per frame;was_pressed('ArrowLeft')ends up in the sameinput()as a click on the pad.StateMachinefrom step 04: the phasesentering,hit,missed,over.Timerfrom step 05: the shrinking time bar of each combo.
The scaffold, start screen, intro and scaling are explained once in Spielwerk 00: The workbench. The pad is four Buttons on a UiPage, styled by theme rules. Nothing in the game reads a clock: Timer, StateMachine and ComboMatcher all get their time from the same update(delta_seconds), so a test plays a whole round in milliseconds.
Embed it
<script src="https://www.game-object.de/tce-elements/tobidot/dll-tce-assets"></script>
<script src="https://www.game-object.de/tce-elements/tobidot/tce-dance-combo"></script>
<tce-dance-combo init-seed="4711"></tce-dance-combo>
| Attribute | Values | Default |
|---|---|---|
init-seed |
whole number; decides every combo of this element | random |
init-intro |
true / false; play the GO logo first |
true |
Next
Previous: Spielwerk 06: Olympische Sprünge · Next: Spielwerk 08: 2D-Koordinaten-Picker · All steps
Comments