@johnmorrisdotca/kyogijo-stadiums 0.61.0 → 0.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +2 -2
- package/catalogue.json +1 -1
- package/dist/catalogue.data.js +1 -1
- package/dist/replay-entry.d.ts +7 -3
- package/dist/replay-entry.js +3 -1
- package/dist/replay.board.d.ts +75 -4
- package/dist/replay.board.js +113 -13
- package/dist/replay.d.ts +1 -1
- package/dist/replay.deadtime.d.ts +58 -0
- package/dist/replay.deadtime.js +169 -0
- package/dist/replay.hockey.d.ts +143 -0
- package/dist/replay.hockey.js +276 -0
- package/dist/replay.js +12 -6
- package/dist/replay.types.d.ts +41 -0
- package/dist/strings.core.d.ts +1 -0
- package/dist/strings.en.js +1 -1
- package/dist/strings.ja.js +1 -1
- package/dist/turn.replay.board.d.ts +1 -1
- package/dist/turn.replay.board.js +20 -3
- package/dist/venue.boards.d.ts +6 -0
- package/dist/venue.boards.js +20 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { DeadTimeRules } from "./replay.deadtime.ts";
|
|
2
|
+
/** Why play stopped, in hockey: each says where it starts again. */
|
|
3
|
+
export type HockeyStop = "goal" | "save" | "frozen" | "icing" | "offside" | "penalty" | "whistle" | "period-start" | "period-end";
|
|
4
|
+
/** The nine face-off spots, feet from centre ice (NHL Rule 1.9): centre, the four in the neutral zone (5 feet outside the blue lines, 44 feet apart) and the four in the end zones (20 feet out from the goal lines, 44 feet apart). */
|
|
5
|
+
export declare const HOCKEY_DOTS: readonly (readonly [number, number])[];
|
|
6
|
+
/** The blue lines are 25 feet either side of centre: past one, play is in an end zone. */
|
|
7
|
+
export declare const HOCKEY_BLUE_LINE = 25;
|
|
8
|
+
/** Is a place one of the nine face-off spots (to a tenth of a foot)? */
|
|
9
|
+
export declare const isHockeyDot: (x: number, y: number) => boolean;
|
|
10
|
+
/** Where play stopped and why, to say where it starts again. */
|
|
11
|
+
export interface HockeyStopAt {
|
|
12
|
+
reason: HockeyStop | string;
|
|
13
|
+
/** Where play stopped: the puck's place, or for a save the place the shot came from. Centre ice when not given. */
|
|
14
|
+
x?: number;
|
|
15
|
+
y?: number;
|
|
16
|
+
/** For icing, offside and a penalty: the end the side it was called against defends, as the sign of its goal line's x (-1 for the left end, 1 for the right). */
|
|
17
|
+
defends?: 1 | -1;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The face-off spot play starts again at after a stoppage, by NHL rule: centre ice after a goal and at the start of a period; after a save or a frozen puck, the end-zone spot of that end on the side
|
|
21
|
+
* the puck was (the nearest spot in the zone); after icing and after a penalty, an end-zone spot of the end the offending side defends (Rules 81 and 76); after offside, the
|
|
22
|
+
* neutral-zone spot just outside the zone the offending side attacks (Rule 83); after any other whistle, the nearest spot in the zone where play stopped (an end-zone spot in an end, a neutral-zone
|
|
23
|
+
* spot between the blue lines, never centre ice). An end-zone spot's side is the side of the rink the puck was on.
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* import { hockeyFaceoffDot } from "@johnmorrisdotca/kyogijo-stadiums/replay";
|
|
27
|
+
* console.log(hockeyFaceoffDot({ reason: "goal", x: 88, y: 2 }).join(), hockeyFaceoffDot({ reason: "save", x: 60, y: -18 }).join());
|
|
28
|
+
* console.log(hockeyFaceoffDot({ reason: "icing", x: 80, y: 30, defends: -1 }).join(), hockeyFaceoffDot({ reason: "offside", x: 26, y: 5, defends: -1 }).join());
|
|
29
|
+
* // 0,0 69,-22
|
|
30
|
+
* // -69,22 20,22
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export declare function hockeyFaceoffDot(stop: HockeyStopAt): readonly [number, number];
|
|
34
|
+
/** The places of a face-off's line-up. */
|
|
35
|
+
export type HockeyRole = "C" | "LW" | "RW" | "LD" | "RD" | "G";
|
|
36
|
+
/**
|
|
37
|
+
* Where a side lines up for a face-off at a spot (NHL Rule 76), as `[x, y]` in feet from centre ice: `attacks` is the end it attacks (1 for the right). The centre stands on the dot a step back on his
|
|
38
|
+
* own side; the wingers on their own side of the circle's hash marks, outside it; the defence behind them (at the blue line when the spot is in the end the side attacks, in front of its own net when it
|
|
39
|
+
* is in its own end, a stride back from the wingers at centre and in the neutral zone); the goaltender in the crease. A left winger is on the left of a player facing the end his side attacks.
|
|
40
|
+
* @example
|
|
41
|
+
* ```ts
|
|
42
|
+
* import { hockeyFormation } from "@johnmorrisdotca/kyogijo-stadiums/replay";
|
|
43
|
+
* const at = hockeyFormation([0, 0], 1);
|
|
44
|
+
* console.log(at.C.join(), at.LW.join(), at.G.join());
|
|
45
|
+
* // -2.5,0 -5,17 -85,0
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export declare function hockeyFormation(dot: readonly [number, number], attacks: 1 | -1): Record<HockeyRole, [number, number]>;
|
|
49
|
+
/** The speeds and seconds of hockey's dead time in a replay, and where each side's bench is. */
|
|
50
|
+
export declare const HOCKEY_DEAD_TIME: {
|
|
51
|
+
/** The fastest a player glides to his place, feet a second (about 12 miles an hour: a glide, not a sprint). */
|
|
52
|
+
readonly speed: 18;
|
|
53
|
+
/** Seconds before the players head for the face-off: a goal is celebrated. */
|
|
54
|
+
readonly pause: {
|
|
55
|
+
readonly goal: 2.5;
|
|
56
|
+
readonly other: 0.6;
|
|
57
|
+
};
|
|
58
|
+
/** Seconds the dead puck is seen where it stopped (in the net after a goal), then out of sight until the drop. */
|
|
59
|
+
readonly shown: {
|
|
60
|
+
readonly goal: 2.5;
|
|
61
|
+
readonly other: 1.2;
|
|
62
|
+
};
|
|
63
|
+
/** The least time the players take to their places, and the seconds they stand in them before the puck is dropped. */
|
|
64
|
+
readonly least: 2;
|
|
65
|
+
readonly hold: 1;
|
|
66
|
+
/** The line-up before the opening face-off of a period, seconds: the clock reads the period's full time until the drop. */
|
|
67
|
+
readonly lineUp: 1.5;
|
|
68
|
+
/** The players' benches' gates, on the side of negative y (the side the benches are on, as the arenas draw them): the home side's nearer the left end, the away side's the right. */
|
|
69
|
+
readonly bench: Record<"home" | "away", readonly [number, number]>;
|
|
70
|
+
};
|
|
71
|
+
/** Hockey's dead time as `addDeadTime` reads it: the puck is the dead one, and a player going off or coming on goes by his side's bench. */
|
|
72
|
+
export declare const HOCKEY_RULES: DeadTimeRules;
|
|
73
|
+
/**
|
|
74
|
+
* The stoppage a play of an NHL-style play-by-play is (`typeDescKey` and, for a `stoppage`, `details.reason`): `goal`, `penalty`, the rule set's word for the stoppage's reason, or `undefined` for a
|
|
75
|
+
* play that does not stop play (a shot, a hit, a delayed penalty, which play goes on through until the side it is against touches the puck).
|
|
76
|
+
* @example
|
|
77
|
+
* ```ts
|
|
78
|
+
* import { hockeyStopOf } from "@johnmorrisdotca/kyogijo-stadiums/replay";
|
|
79
|
+
* console.log(hockeyStopOf("stoppage", "icing"), hockeyStopOf("stoppage", "puck-in-netting"), hockeyStopOf("goal"), hockeyStopOf("delayed-penalty"));
|
|
80
|
+
* // icing whistle goal undefined
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
export declare function hockeyStopOf(typeDescKey: string, reason?: string): HockeyStop | undefined;
|
|
84
|
+
/** The kinds of penalty, and their minutes on the game clock (NHL Rules 16 to 21: a minor 2, a double minor 4, a major 5, a misconduct 10; a bench minor is a minor served by a player the coach names). */
|
|
85
|
+
export declare const HOCKEY_PENALTY_MINUTES: {
|
|
86
|
+
readonly minor: 2;
|
|
87
|
+
readonly "bench-minor": 2;
|
|
88
|
+
readonly "double-minor": 4;
|
|
89
|
+
readonly major: 5;
|
|
90
|
+
readonly misconduct: 10;
|
|
91
|
+
};
|
|
92
|
+
export type HockeyPenaltyKind = keyof typeof HOCKEY_PENALTY_MINUTES;
|
|
93
|
+
/**
|
|
94
|
+
* The kind of a penalty of an NHL-style play-by-play (`details.typeCode` and `details.duration` of a `penalty`): `MIN` a minor (4 minutes of it a double minor), `BEN` a bench minor, `MAJ` a major,
|
|
95
|
+
* `MIS` and `GMIS` a misconduct; else by its minutes. `undefined` for a penalty shot or a match penalty, which this rule set does not play.
|
|
96
|
+
* @example
|
|
97
|
+
* ```ts
|
|
98
|
+
* import { hockeyPenaltyKind } from "@johnmorrisdotca/kyogijo-stadiums/replay";
|
|
99
|
+
* console.log(hockeyPenaltyKind("MIN", 2), hockeyPenaltyKind("MIN", 4), hockeyPenaltyKind("BEN", 2), hockeyPenaltyKind("MAJ", 5), hockeyPenaltyKind("MIS", 10), hockeyPenaltyKind("PS", 0));
|
|
100
|
+
* // minor double-minor bench-minor major misconduct undefined
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
export declare function hockeyPenaltyKind(typeCode?: string, duration?: number): HockeyPenaltyKind | undefined;
|
|
104
|
+
/**
|
|
105
|
+
* Where a side's penalty box is, the seat its penalized player sits on, in the `nhl-api` frame: across the ice from the players' benches (NHL Rule 1: the two penalty boxes on the side opposite the
|
|
106
|
+
* benches, one each side of the off-ice officials' box at centre), the home side's on the left, behind the boards. A stand-in for the rink's own figures (the benches and boxes of the rink, KYOGIJO-
|
|
107
|
+
* STADIUMS-47) until they are in the package: then this reads them, and nothing that calls it changes.
|
|
108
|
+
*/
|
|
109
|
+
export declare function hockeyPenaltyBox(team: "home" | "away"): readonly [number, number];
|
|
110
|
+
/** A call of a penalty, on the game clock: when (seconds of game played since the start), against which side, what kind. */
|
|
111
|
+
export interface HockeyCall {
|
|
112
|
+
team: "home" | "away";
|
|
113
|
+
kind: HockeyPenaltyKind;
|
|
114
|
+
at: number;
|
|
115
|
+
}
|
|
116
|
+
/** When a penalty's time begins and ends on the game clock, and whether its side plays short. */
|
|
117
|
+
export interface HockeyServed {
|
|
118
|
+
/** When its time begins: the call, or, for a side already two short, when one of those ends (it never plays with fewer than three skaters). */
|
|
119
|
+
from: number;
|
|
120
|
+
/** When the player leaves the box: his time ran out, or a power-play goal ended his minor. */
|
|
121
|
+
to: number;
|
|
122
|
+
short: boolean;
|
|
123
|
+
/** Ended by a power-play goal. */
|
|
124
|
+
byGoal: boolean;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* How a game's penalties are served, by NHL rule, all on the game clock (seconds played since the start): each penalty's player sits from the call; a minor is 2 minutes, a double minor 4, a major 5 and a
|
|
128
|
+
* misconduct 10; his side plays a skater short (not for a misconduct, served by a substitute, nor for coincidental penalties, called on both sides at once and as many each, which leave the strength
|
|
129
|
+
* even); a side is never more than two short, so a third penalty's time waits for one of the first two to end; a goal by the side with more skaters ends the scored-on side's minor with the least time
|
|
130
|
+
* left (a double minor loses the part it is in; a major is served in full). Pure.
|
|
131
|
+
* @example
|
|
132
|
+
* ```ts
|
|
133
|
+
* import { hockeyPenaltyEnds } from "@johnmorrisdotca/kyogijo-stadiums/replay";
|
|
134
|
+
* const calls = [{ team: "away" as const, kind: "minor" as const, at: 100 }, { team: "home" as const, kind: "major" as const, at: 400 }];
|
|
135
|
+
* const goals = [{ team: "home" as const, at: 160 }, { team: "away" as const, at: 500 }];
|
|
136
|
+
* console.log(hockeyPenaltyEnds(calls, goals).map((s) => `${s.from}-${s.to} ${s.short} ${s.byGoal}`).join(", "));
|
|
137
|
+
* // 100-160 true true, 400-700 true false
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
export declare function hockeyPenaltyEnds(calls: readonly HockeyCall[], goals: readonly {
|
|
141
|
+
team: "home" | "away";
|
|
142
|
+
at: number;
|
|
143
|
+
}[]): HockeyServed[];
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/** The nine face-off spots, feet from centre ice (NHL Rule 1.9): centre, the four in the neutral zone (5 feet outside the blue lines, 44 feet apart) and the four in the end zones (20 feet out from the goal lines, 44 feet apart). */
|
|
2
|
+
export const HOCKEY_DOTS = [
|
|
3
|
+
[0, 0],
|
|
4
|
+
[20, 22],
|
|
5
|
+
[20, -22],
|
|
6
|
+
[-20, 22],
|
|
7
|
+
[-20, -22],
|
|
8
|
+
[69, 22],
|
|
9
|
+
[69, -22],
|
|
10
|
+
[-69, 22],
|
|
11
|
+
[-69, -22],
|
|
12
|
+
];
|
|
13
|
+
/** The blue lines are 25 feet either side of centre: past one, play is in an end zone. */
|
|
14
|
+
export const HOCKEY_BLUE_LINE = 25;
|
|
15
|
+
/** Is a place one of the nine face-off spots (to a tenth of a foot)? */
|
|
16
|
+
export const isHockeyDot = (x, y) => HOCKEY_DOTS.some(([dx, dy]) => Math.abs(dx - x) < 0.05 && Math.abs(dy - y) < 0.05);
|
|
17
|
+
const side = (n) => ((n ?? 0) >= 0 ? 1 : -1);
|
|
18
|
+
/**
|
|
19
|
+
* The face-off spot play starts again at after a stoppage, by NHL rule: centre ice after a goal and at the start of a period; after a save or a frozen puck, the end-zone spot of that end on the side
|
|
20
|
+
* the puck was (the nearest spot in the zone); after icing and after a penalty, an end-zone spot of the end the offending side defends (Rules 81 and 76); after offside, the
|
|
21
|
+
* neutral-zone spot just outside the zone the offending side attacks (Rule 83); after any other whistle, the nearest spot in the zone where play stopped (an end-zone spot in an end, a neutral-zone
|
|
22
|
+
* spot between the blue lines, never centre ice). An end-zone spot's side is the side of the rink the puck was on.
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* import { hockeyFaceoffDot } from "@johnmorrisdotca/kyogijo-stadiums/replay";
|
|
26
|
+
* console.log(hockeyFaceoffDot({ reason: "goal", x: 88, y: 2 }).join(), hockeyFaceoffDot({ reason: "save", x: 60, y: -18 }).join());
|
|
27
|
+
* console.log(hockeyFaceoffDot({ reason: "icing", x: 80, y: 30, defends: -1 }).join(), hockeyFaceoffDot({ reason: "offside", x: 26, y: 5, defends: -1 }).join());
|
|
28
|
+
* // 0,0 69,-22
|
|
29
|
+
* // -69,22 20,22
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export function hockeyFaceoffDot(stop) {
|
|
33
|
+
const x = Number.isFinite(stop.x) ? stop.x : 0;
|
|
34
|
+
const y = Number.isFinite(stop.y) ? stop.y : 0;
|
|
35
|
+
const across = 22 * side(y);
|
|
36
|
+
switch (stop.reason) {
|
|
37
|
+
case "goal":
|
|
38
|
+
case "period-start":
|
|
39
|
+
case "period-end":
|
|
40
|
+
return [0, 0];
|
|
41
|
+
case "save":
|
|
42
|
+
case "frozen":
|
|
43
|
+
return [69 * side(x), across];
|
|
44
|
+
case "icing":
|
|
45
|
+
case "penalty":
|
|
46
|
+
return stop.defends === undefined ? hockeyFaceoffDot({ reason: "whistle", x, y }) : [69 * stop.defends, across];
|
|
47
|
+
case "offside":
|
|
48
|
+
return stop.defends === undefined ? hockeyFaceoffDot({ reason: "whistle", x: 20 * side(x), y }) : [-20 * stop.defends, across];
|
|
49
|
+
default:
|
|
50
|
+
return Math.abs(x) > HOCKEY_BLUE_LINE ? [69 * side(x), across] : [20 * side(x), across];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Where a side lines up for a face-off at a spot (NHL Rule 76), as `[x, y]` in feet from centre ice: `attacks` is the end it attacks (1 for the right). The centre stands on the dot a step back on his
|
|
55
|
+
* own side; the wingers on their own side of the circle's hash marks, outside it; the defence behind them (at the blue line when the spot is in the end the side attacks, in front of its own net when it
|
|
56
|
+
* is in its own end, a stride back from the wingers at centre and in the neutral zone); the goaltender in the crease. A left winger is on the left of a player facing the end his side attacks.
|
|
57
|
+
* @example
|
|
58
|
+
* ```ts
|
|
59
|
+
* import { hockeyFormation } from "@johnmorrisdotca/kyogijo-stadiums/replay";
|
|
60
|
+
* const at = hockeyFormation([0, 0], 1);
|
|
61
|
+
* console.log(at.C.join(), at.LW.join(), at.G.join());
|
|
62
|
+
* // -2.5,0 -5,17 -85,0
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export function hockeyFormation(dot, attacks) {
|
|
66
|
+
const [dx, dy] = dot;
|
|
67
|
+
const back = -attacks;
|
|
68
|
+
const left = attacks;
|
|
69
|
+
const G = [85 * back, 0];
|
|
70
|
+
const C = [dx + 2.5 * back, dy];
|
|
71
|
+
if (Math.abs(dx) < 50) {
|
|
72
|
+
// Centre ice and the neutral zone: the wingers wide of the circle, the defence a stride back.
|
|
73
|
+
const LW = [dx + 5 * back, clampY(dy + 17 * left)];
|
|
74
|
+
const RW = [dx + 5 * back, clampY(dy - 17 * left)];
|
|
75
|
+
const d = Math.abs(dx) < 1 ? 30 : 22;
|
|
76
|
+
return { C, LW, RW, LD: [dx + d * back, clampY(dy + 12 * left)], RD: [dx + d * back, clampY(dy - 12 * left)], G };
|
|
77
|
+
}
|
|
78
|
+
// An end-zone spot: the wingers on the hash marks either side of the circle, the defence at the points or in front of their own net.
|
|
79
|
+
const LW = [dx + 3.5 * back, clampY(dy + 17 * left)];
|
|
80
|
+
const RW = [dx + 3.5 * back, clampY(dy - 17 * left)];
|
|
81
|
+
const own = Math.sign(dx) === back;
|
|
82
|
+
if (own) {
|
|
83
|
+
const toward = -Math.sign(dy) || 1;
|
|
84
|
+
return { C, LW, RW, LD: [78 * back, clampY(dy * 0.4)], RD: [80 * back, 8 * toward], G };
|
|
85
|
+
}
|
|
86
|
+
return { C, LW, RW, LD: [(HOCKEY_BLUE_LINE + 3) * attacks, clampY(18 * left)], RD: [(HOCKEY_BLUE_LINE + 3) * attacks, clampY(-18 * left)], G };
|
|
87
|
+
}
|
|
88
|
+
const clampY = (y) => Math.max(-39, Math.min(39, y));
|
|
89
|
+
/** The speeds and seconds of hockey's dead time in a replay, and where each side's bench is. */
|
|
90
|
+
export const HOCKEY_DEAD_TIME = {
|
|
91
|
+
/** The fastest a player glides to his place, feet a second (about 12 miles an hour: a glide, not a sprint). */
|
|
92
|
+
speed: 18,
|
|
93
|
+
/** Seconds before the players head for the face-off: a goal is celebrated. */
|
|
94
|
+
pause: { goal: 2.5, other: 0.6 },
|
|
95
|
+
/** Seconds the dead puck is seen where it stopped (in the net after a goal), then out of sight until the drop. */
|
|
96
|
+
shown: { goal: 2.5, other: 1.2 },
|
|
97
|
+
/** The least time the players take to their places, and the seconds they stand in them before the puck is dropped. */
|
|
98
|
+
least: 2,
|
|
99
|
+
hold: 1,
|
|
100
|
+
/** The line-up before the opening face-off of a period, seconds: the clock reads the period's full time until the drop. */
|
|
101
|
+
lineUp: 1.5,
|
|
102
|
+
/** The players' benches' gates, on the side of negative y (the side the benches are on, as the arenas draw them): the home side's nearer the left end, the away side's the right. */
|
|
103
|
+
bench: { home: [-36, -41], away: [36, -41] },
|
|
104
|
+
};
|
|
105
|
+
/** Hockey's dead time as `addDeadTime` reads it: the puck is the dead one, and a player going off or coming on goes by his side's bench. */
|
|
106
|
+
export const HOCKEY_RULES = {
|
|
107
|
+
speed: HOCKEY_DEAD_TIME.speed,
|
|
108
|
+
pause: (reason) => (reason === "goal" ? HOCKEY_DEAD_TIME.pause.goal : HOCKEY_DEAD_TIME.pause.other),
|
|
109
|
+
shown: (reason) => (reason === "goal" ? HOCKEY_DEAD_TIME.shown.goal : HOCKEY_DEAD_TIME.shown.other),
|
|
110
|
+
least: HOCKEY_DEAD_TIME.least,
|
|
111
|
+
hold: HOCKEY_DEAD_TIME.hold,
|
|
112
|
+
bench: (o) => (o.team === "home" || o.team === "away" ? HOCKEY_DEAD_TIME.bench[o.team] : undefined),
|
|
113
|
+
dead: (o) => o.team === "puck" || o.team === "ball",
|
|
114
|
+
};
|
|
115
|
+
/** The words of the NHL's public play-by-play for why play stopped (`details.reason` of a `stoppage`), as the rule set's. Any other word is a plain whistle. */
|
|
116
|
+
const NHL_REASONS = {
|
|
117
|
+
"goalie-stopped-after-sog": "save",
|
|
118
|
+
"puck-frozen": "frozen",
|
|
119
|
+
icing: "icing",
|
|
120
|
+
offside: "offside",
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* The stoppage a play of an NHL-style play-by-play is (`typeDescKey` and, for a `stoppage`, `details.reason`): `goal`, `penalty`, the rule set's word for the stoppage's reason, or `undefined` for a
|
|
124
|
+
* play that does not stop play (a shot, a hit, a delayed penalty, which play goes on through until the side it is against touches the puck).
|
|
125
|
+
* @example
|
|
126
|
+
* ```ts
|
|
127
|
+
* import { hockeyStopOf } from "@johnmorrisdotca/kyogijo-stadiums/replay";
|
|
128
|
+
* console.log(hockeyStopOf("stoppage", "icing"), hockeyStopOf("stoppage", "puck-in-netting"), hockeyStopOf("goal"), hockeyStopOf("delayed-penalty"));
|
|
129
|
+
* // icing whistle goal undefined
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
export function hockeyStopOf(typeDescKey, reason) {
|
|
133
|
+
if (typeDescKey === "goal")
|
|
134
|
+
return "goal";
|
|
135
|
+
if (typeDescKey === "penalty")
|
|
136
|
+
return "penalty";
|
|
137
|
+
if (typeDescKey === "period-end")
|
|
138
|
+
return "period-end";
|
|
139
|
+
if (typeDescKey !== "stoppage")
|
|
140
|
+
return undefined;
|
|
141
|
+
return NHL_REASONS[reason ?? ""] ?? "whistle";
|
|
142
|
+
}
|
|
143
|
+
/** The kinds of penalty, and their minutes on the game clock (NHL Rules 16 to 21: a minor 2, a double minor 4, a major 5, a misconduct 10; a bench minor is a minor served by a player the coach names). */
|
|
144
|
+
export const HOCKEY_PENALTY_MINUTES = { minor: 2, "bench-minor": 2, "double-minor": 4, major: 5, misconduct: 10 };
|
|
145
|
+
/**
|
|
146
|
+
* The kind of a penalty of an NHL-style play-by-play (`details.typeCode` and `details.duration` of a `penalty`): `MIN` a minor (4 minutes of it a double minor), `BEN` a bench minor, `MAJ` a major,
|
|
147
|
+
* `MIS` and `GMIS` a misconduct; else by its minutes. `undefined` for a penalty shot or a match penalty, which this rule set does not play.
|
|
148
|
+
* @example
|
|
149
|
+
* ```ts
|
|
150
|
+
* import { hockeyPenaltyKind } from "@johnmorrisdotca/kyogijo-stadiums/replay";
|
|
151
|
+
* console.log(hockeyPenaltyKind("MIN", 2), hockeyPenaltyKind("MIN", 4), hockeyPenaltyKind("BEN", 2), hockeyPenaltyKind("MAJ", 5), hockeyPenaltyKind("MIS", 10), hockeyPenaltyKind("PS", 0));
|
|
152
|
+
* // minor double-minor bench-minor major misconduct undefined
|
|
153
|
+
* ```
|
|
154
|
+
*/
|
|
155
|
+
export function hockeyPenaltyKind(typeCode, duration) {
|
|
156
|
+
const code = (typeCode ?? "").toUpperCase();
|
|
157
|
+
if (code === "MIN")
|
|
158
|
+
return duration === 4 ? "double-minor" : "minor";
|
|
159
|
+
if (code === "BEN")
|
|
160
|
+
return "bench-minor";
|
|
161
|
+
if (code === "MAJ")
|
|
162
|
+
return "major";
|
|
163
|
+
if (code === "MIS" || code === "GMIS")
|
|
164
|
+
return "misconduct";
|
|
165
|
+
if (code !== "")
|
|
166
|
+
return undefined;
|
|
167
|
+
return duration === 2 ? "minor" : duration === 4 ? "double-minor" : duration === 5 ? "major" : duration === 10 ? "misconduct" : undefined;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Where a side's penalty box is, the seat its penalized player sits on, in the `nhl-api` frame: across the ice from the players' benches (NHL Rule 1: the two penalty boxes on the side opposite the
|
|
171
|
+
* benches, one each side of the off-ice officials' box at centre), the home side's on the left, behind the boards. A stand-in for the rink's own figures (the benches and boxes of the rink, KYOGIJO-
|
|
172
|
+
* STADIUMS-47) until they are in the package: then this reads them, and nothing that calls it changes.
|
|
173
|
+
*/
|
|
174
|
+
export function hockeyPenaltyBox(team) {
|
|
175
|
+
return team === "home" ? HOCKEY_BOXES.home : HOCKEY_BOXES.away;
|
|
176
|
+
}
|
|
177
|
+
const HOCKEY_BOXES = { home: [-14, 45], away: [14, 45] };
|
|
178
|
+
/**
|
|
179
|
+
* How a game's penalties are served, by NHL rule, all on the game clock (seconds played since the start): each penalty's player sits from the call; a minor is 2 minutes, a double minor 4, a major 5 and a
|
|
180
|
+
* misconduct 10; his side plays a skater short (not for a misconduct, served by a substitute, nor for coincidental penalties, called on both sides at once and as many each, which leave the strength
|
|
181
|
+
* even); a side is never more than two short, so a third penalty's time waits for one of the first two to end; a goal by the side with more skaters ends the scored-on side's minor with the least time
|
|
182
|
+
* left (a double minor loses the part it is in; a major is served in full). Pure.
|
|
183
|
+
* @example
|
|
184
|
+
* ```ts
|
|
185
|
+
* import { hockeyPenaltyEnds } from "@johnmorrisdotca/kyogijo-stadiums/replay";
|
|
186
|
+
* const calls = [{ team: "away" as const, kind: "minor" as const, at: 100 }, { team: "home" as const, kind: "major" as const, at: 400 }];
|
|
187
|
+
* const goals = [{ team: "home" as const, at: 160 }, { team: "away" as const, at: 500 }];
|
|
188
|
+
* console.log(hockeyPenaltyEnds(calls, goals).map((s) => `${s.from}-${s.to} ${s.short} ${s.byGoal}`).join(", "));
|
|
189
|
+
* // 100-160 true true, 400-700 true false
|
|
190
|
+
* ```
|
|
191
|
+
*/
|
|
192
|
+
export function hockeyPenaltyEnds(calls, goals) {
|
|
193
|
+
const out = calls.map((c) => ({
|
|
194
|
+
from: c.at,
|
|
195
|
+
to: c.at + HOCKEY_PENALTY_MINUTES[c.kind] * 60,
|
|
196
|
+
short: c.kind !== "misconduct",
|
|
197
|
+
byGoal: false,
|
|
198
|
+
left: HOCKEY_PENALTY_MINUTES[c.kind] * 60,
|
|
199
|
+
part: c.kind === "double-minor" ? 2 : 1,
|
|
200
|
+
kind: c.kind,
|
|
201
|
+
team: c.team,
|
|
202
|
+
started: false,
|
|
203
|
+
}));
|
|
204
|
+
// Coincidental: at one moment, the same number of penalties that would make each side short are served without making either short.
|
|
205
|
+
const moments = [...new Set(calls.map((c) => c.at))];
|
|
206
|
+
for (const at of moments) {
|
|
207
|
+
const here = out.filter((p) => p.from === at && p.short);
|
|
208
|
+
const home = here.filter((p) => p.team === "home");
|
|
209
|
+
const away = here.filter((p) => p.team === "away");
|
|
210
|
+
const pairs = Math.min(home.length, away.length);
|
|
211
|
+
for (let i = 0; i < pairs; i += 1)
|
|
212
|
+
[home[i].short, away[i].short] = [false, false];
|
|
213
|
+
}
|
|
214
|
+
// Walk the game clock: calls start their time (or wait while their side is two short), goals end minors, time runs out.
|
|
215
|
+
const marks = [...new Set([...calls.map((c) => c.at), ...goals.map((g) => g.at)])].sort((a, b) => a - b);
|
|
216
|
+
const running = (team) => out.filter((p) => p.team === team && p.started && p.short && p.left > 0);
|
|
217
|
+
let now = Number.NEGATIVE_INFINITY;
|
|
218
|
+
const advance = (to) => {
|
|
219
|
+
// Time runs for every started penalty; one that runs out lets a waiting one of its side begin.
|
|
220
|
+
for (;;) {
|
|
221
|
+
const live = out.filter((p) => p.started && p.left > 0);
|
|
222
|
+
const first = live.reduce((best, p) => Math.min(best, now + p.left), Infinity);
|
|
223
|
+
if (first > to || first === Infinity) {
|
|
224
|
+
if (Number.isFinite(to))
|
|
225
|
+
for (const p of live)
|
|
226
|
+
p.left -= to - now;
|
|
227
|
+
now = to;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
for (const p of live)
|
|
231
|
+
p.left -= first - now;
|
|
232
|
+
now = first;
|
|
233
|
+
for (const p of live)
|
|
234
|
+
if (p.left <= 1e-9)
|
|
235
|
+
[p.left, p.to] = [0, now];
|
|
236
|
+
start(now);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
const start = (at) => {
|
|
240
|
+
for (const p of out) {
|
|
241
|
+
if (p.started || p.from > at)
|
|
242
|
+
continue;
|
|
243
|
+
if (p.short && running(p.team).length >= 2)
|
|
244
|
+
continue;
|
|
245
|
+
p.started = true;
|
|
246
|
+
p.from = Math.max(p.from, at);
|
|
247
|
+
p.to = p.from + p.left;
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
for (const m of marks) {
|
|
251
|
+
if (now === Number.NEGATIVE_INFINITY)
|
|
252
|
+
now = m;
|
|
253
|
+
advance(m);
|
|
254
|
+
// A goal at the moment of a call ends none of that call's penalties (the call came after it): goals first, then the calls begin.
|
|
255
|
+
for (const g of goals.filter((one) => one.at === m)) {
|
|
256
|
+
const other = g.team === "home" ? "away" : "home";
|
|
257
|
+
const theirs = running(other);
|
|
258
|
+
if (theirs.length <= running(g.team).length)
|
|
259
|
+
continue;
|
|
260
|
+
const minors = theirs.filter((p) => p.kind === "minor" || p.kind === "bench-minor" || p.kind === "double-minor").sort((a, b) => a.left - b.left);
|
|
261
|
+
const ended = minors[0];
|
|
262
|
+
if (ended === undefined)
|
|
263
|
+
continue;
|
|
264
|
+
if (ended.kind === "double-minor" && ended.left > 120) {
|
|
265
|
+
ended.left = 120;
|
|
266
|
+
ended.to = m + 120;
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
[ended.left, ended.to, ended.byGoal] = [0, m, true];
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
start(m);
|
|
273
|
+
}
|
|
274
|
+
advance(Infinity);
|
|
275
|
+
return out.map(({ from, to, short, byGoal }) => ({ from, to, short, byGoal }));
|
|
276
|
+
}
|
package/dist/replay.js
CHANGED
|
@@ -217,7 +217,7 @@ export function interpolate(timeline, t, options = {}) {
|
|
|
217
217
|
}
|
|
218
218
|
/**
|
|
219
219
|
* The path each object took over the last `seconds` before `t`, oldest place first and ending at its place at `t`: the frames in between, and the places at the two ends.
|
|
220
|
-
* Only objects that are there at `t` have a trail. `teams` and `ids` choose whose; all by default.
|
|
220
|
+
* Only objects that are there at `t` have a trail, and it begins after the last frame the object was not in (a dead puck out of sight). `teams` and `ids` choose whose; all by default.
|
|
221
221
|
* @example
|
|
222
222
|
* ```ts
|
|
223
223
|
* import { trails } from "@johnmorrisdotca/kyogijo-stadiums/replay";
|
|
@@ -246,7 +246,7 @@ export function trails(timeline, t, seconds, options = {}) {
|
|
|
246
246
|
const end = positionAt(p, id, now, smooth);
|
|
247
247
|
if (end === undefined)
|
|
248
248
|
continue;
|
|
249
|
-
|
|
249
|
+
let points = [];
|
|
250
250
|
const start = positionAt(p, id, from, smooth);
|
|
251
251
|
if (start !== undefined)
|
|
252
252
|
points.push(start);
|
|
@@ -254,7 +254,10 @@ export function trails(timeline, t, seconds, options = {}) {
|
|
|
254
254
|
if (p.times[k] <= from || p.times[k] >= now)
|
|
255
255
|
continue;
|
|
256
256
|
const o = mapOf(p, k)?.get(id);
|
|
257
|
-
|
|
257
|
+
// Out of sight in a frame (a dead puck between the whistle and the drop): the trail begins again after it, and never joins where it went out to where it came back.
|
|
258
|
+
if (o === undefined)
|
|
259
|
+
points = [];
|
|
260
|
+
else
|
|
258
261
|
points.push([o.x, o.y]);
|
|
259
262
|
}
|
|
260
263
|
const last = points[points.length - 1];
|
|
@@ -343,7 +346,7 @@ export function eventsBetween(timeline, from, to) {
|
|
|
343
346
|
return prepare(timeline).events.filter((e) => e.t > from && e.t <= to);
|
|
344
347
|
}
|
|
345
348
|
const wordsOf = (language) => KYOGIJO_STRINGS[language === "ja" ? "ja" : "en"].replay;
|
|
346
|
-
const KINDS = ["pass", "shot", "goal", "whistle"];
|
|
349
|
+
const KINDS = ["pass", "shot", "goal", "whistle", "faceoff"];
|
|
347
350
|
const kindWord = (w, kind) => (KINDS.includes(kind) ? w[kind] : kind);
|
|
348
351
|
/**
|
|
349
352
|
* What is said of an event, for a live region and a tooltip: its `label`, else the word for its kind and its team, then the time.
|
|
@@ -427,7 +430,8 @@ export function replayGroups(timeline, snap, look = {}, language = "en") {
|
|
|
427
430
|
const e = a.event;
|
|
428
431
|
const title = describeEvent(e, language, start);
|
|
429
432
|
const team = e.team === "home" || e.team === "away" ? e.team : undefined;
|
|
430
|
-
|
|
433
|
+
// A face-off is the puck on the dot: it is said, and the puck itself marks it.
|
|
434
|
+
if (!isNum(a.x) || !isNum(a.y) || e.kind === "faceoff")
|
|
431
435
|
continue;
|
|
432
436
|
const ended = isNum(a.x2) && isNum(a.y2) && (a.x2 !== a.x || a.y2 !== a.y);
|
|
433
437
|
if (ended && (e.kind === "pass" || e.kind === "shot" || e.kind === "goal")) {
|
|
@@ -483,7 +487,9 @@ export function replayGroups(timeline, snap, look = {}, language = "en") {
|
|
|
483
487
|
});
|
|
484
488
|
}
|
|
485
489
|
const balls = pick(["ball", "puck"]);
|
|
486
|
-
|
|
490
|
+
// The ball's layer is there whenever the timeline tracks a ball or a puck, empty while it is dead and out of sight (between a whistle and the drop), so that it comes back in its own place and
|
|
491
|
+
// nothing else of the drawing is written again.
|
|
492
|
+
if (balls.length > 0 || replayCast(timeline).some((o) => o.team === "ball" || o.team === "puck")) {
|
|
487
493
|
out.push({
|
|
488
494
|
id: "replay-ball",
|
|
489
495
|
kind: "points",
|
package/dist/replay.types.d.ts
CHANGED
|
@@ -60,6 +60,43 @@ export interface ReplayPeriod {
|
|
|
60
60
|
/** How long the period is on the game clock, in seconds, when the replay is re-timed (a made-up game played in minutes: 1200 for a hockey period). The board's clock runs through it from start to end. Else the clock runs in the timeline's own seconds. */
|
|
61
61
|
length?: number;
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* A stoppage: play is dead from `start` to `end` (the drop of the puck, a throw-in, a snap), in seconds on the timeline's clock. The game clock stands still in it while the replay's own clock (the
|
|
65
|
+
* slider) moves on: the players skate to their places for the restart, and the ball or the puck is dead.
|
|
66
|
+
*/
|
|
67
|
+
export interface ReplayStoppage {
|
|
68
|
+
/** When play stopped. */
|
|
69
|
+
start: number;
|
|
70
|
+
/** When play starts again: the game clock runs from here. */
|
|
71
|
+
end: number;
|
|
72
|
+
/** Why: a word of the sport's rule set (in hockey `goal`, `save`, `frozen`, `icing`, `offside`, `penalty`, `whistle`, `period-start`, `period-end`), or one of your own. */
|
|
73
|
+
reason?: string;
|
|
74
|
+
/** Where play starts again (the face-off dot), in the timeline's frame. */
|
|
75
|
+
x?: number;
|
|
76
|
+
y?: number;
|
|
77
|
+
/** The side the stoppage was called against (the side that iced the puck, went offside or took the penalty), when there is one. */
|
|
78
|
+
team?: "home" | "away";
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* A penalty: a player in the penalty box from `start` (the call, a stoppage) to `end` (when he leaves it: his time ran out on the game clock, or a power-play goal ended a minor), in seconds on the
|
|
82
|
+
* timeline's clock. His side plays a skater short in that time unless `short` is `false` (a misconduct, or coincidental penalties, which leave the strength even).
|
|
83
|
+
*/
|
|
84
|
+
export interface ReplayPenalty {
|
|
85
|
+
team: "home" | "away";
|
|
86
|
+
/** The player who serves it: the id of his object, which the frames put in the box. */
|
|
87
|
+
by?: string;
|
|
88
|
+
start: number;
|
|
89
|
+
/** When he leaves the box. Without it, the penalty runs for its `minutes` of game clock from the drop after the call. */
|
|
90
|
+
end?: number;
|
|
91
|
+
/** Its length on the game clock: 2 for a minor, 4 for a double minor, 5 for a major, 10 for a misconduct. */
|
|
92
|
+
minutes: number;
|
|
93
|
+
/** A word of the sport's rule set (in hockey `minor`, `double-minor`, `major`, `misconduct`, `bench-minor`), or one of your own. */
|
|
94
|
+
kind?: string;
|
|
95
|
+
/** Whether his side plays short while he sits: `true` unless said otherwise. */
|
|
96
|
+
short?: boolean;
|
|
97
|
+
/** `goal` when a power-play goal ended it before its time was up: until that goal the board counts its minutes down as if it would run them all. */
|
|
98
|
+
endedBy?: "goal";
|
|
99
|
+
}
|
|
63
100
|
/** Tracking data over time on a surface. Do not change a timeline after it has been used: make a new one. */
|
|
64
101
|
export interface ReplayTimeline {
|
|
65
102
|
/** The positions, from the first moment to the last. */
|
|
@@ -83,6 +120,10 @@ export interface ReplayTimeline {
|
|
|
83
120
|
periods?: readonly ReplayPeriod[];
|
|
84
121
|
/** Whether a scoreboard's game clock counts the time left in the period (`down`, the default: hockey, basketball, football) or the time gone (`up`: soccer). */
|
|
85
122
|
clock?: "down" | "up";
|
|
123
|
+
/** The stoppages, in order. The game clock stands still in each, from its `start` to its `end`, and the board shows the same time all through it. */
|
|
124
|
+
stoppages?: readonly ReplayStoppage[];
|
|
125
|
+
/** The penalties, in order of their calls: who sits in the box, and for how long on the game clock. The board shows each side's strength and the time left of each penalty. */
|
|
126
|
+
penalties?: readonly ReplayPenalty[];
|
|
86
127
|
}
|
|
87
128
|
/** How a frame is drawn. */
|
|
88
129
|
export interface ReplayLook {
|
package/dist/strings.core.d.ts
CHANGED
package/dist/strings.en.js
CHANGED
|
@@ -439,7 +439,7 @@ export const WORDS_EN = {
|
|
|
439
439
|
grid: { title: "A grid of drawings ({n})", drawings: "Drawings ({n}): {list}.", key: "Key: {list}.", cell: "Drawing {n} of {total}", listSep: "; " },
|
|
440
440
|
replay: {
|
|
441
441
|
title: "Replay, {time}", objects: "{n} tracked objects", objectsOne: "{n} tracked object", events: "{n} events", eventsOne: "{n} event", players: "Players", others: "Ball, puck and officials", trail: "Trail of the last {s} seconds", numbers: "Shirt numbers", eventLines: "Passes and shots",
|
|
442
|
-
home: "home", away: "away", ball: "ball", puck: "puck", official: "official", pass: "Pass", shot: "Shot", goal: "Goal", whistle: "Whistle",
|
|
442
|
+
home: "home", away: "away", ball: "ball", puck: "puck", official: "official", pass: "Pass", shot: "Shot", goal: "Goal", whistle: "Whistle", faceoff: "Face-off",
|
|
443
443
|
announce: "{what}, {time}", announceTeam: "{what}, {team}, {time}",
|
|
444
444
|
group: "Replay controls", play: "Play", pause: "Pause", back: "Previous frame", forward: "Next frame", stepBack: "Back", stepForward: "Forward", speed: "Speed", loop: "Loop", position: "Position", time: "Time", event: "Event", spokenSeconds: "0:{ss}", spokenMinutes: "{m}:{ss}", spokenHours: "{h}:{mm}:{ss}", positionValue: "{t} of {total}",
|
|
445
445
|
keys: "Replay of the play. Space plays or pauses, the left and right arrow keys step by a frame, Shift with an arrow key moves 5 seconds, and Home and End go to the ends.",
|
package/dist/strings.ja.js
CHANGED
|
@@ -464,7 +464,7 @@ export const WORDS_JA = {
|
|
|
464
464
|
grid: { title: "図のグリッド({n}枚)", drawings: "図({n}枚):{list}。", key: "凡例:{list}。", cell: "{total}枚中{n}枚目の図", listSep: ";" },
|
|
465
465
|
replay: {
|
|
466
466
|
title: "リプレイ(再生時間{time})", objects: "追跡対象は{n}個", objectsOne: "追跡対象は{n}個", events: "イベント{n}件", eventsOne: "イベント{n}件", players: "選手", others: "ボール・パック・審判", trail: "直近{s}秒の軌跡", numbers: "背番号", eventLines: "パスとシュート",
|
|
467
|
-
home: "ホーム", away: "アウェイ", ball: "ボール", puck: "パック", official: "審判", pass: "パス", shot: "シュート", goal: "ゴール", whistle: "ホイッスル",
|
|
467
|
+
home: "ホーム", away: "アウェイ", ball: "ボール", puck: "パック", official: "審判", pass: "パス", shot: "シュート", goal: "ゴール", whistle: "ホイッスル", faceoff: "フェイスオフ",
|
|
468
468
|
announce: "{what}、{time}", announceTeam: "{team}の{what}、{time}",
|
|
469
469
|
group: "リプレイの操作", play: "再生", pause: "一時停止", back: "前のコマ", forward: "次のコマ", stepBack: "前へ", stepForward: "次へ", speed: "再生速度", loop: "繰り返し", position: "再生位置", time: "時間", event: "イベント", spokenSeconds: "{s}秒", spokenMinutes: "{m}分{s}秒", spokenHours: "{h}時間{m}分{s}秒", positionValue: "{total}中{t}",
|
|
470
470
|
keys: "プレーのリプレイです。スペースキーで再生と一時停止、左右の矢印キーで1コマずつ前後に移動、Shiftキーを押しながら矢印キーで5秒ずつ移動、Homeキーで最初、Endキーで最後に移動します。",
|
|
@@ -7,7 +7,7 @@ import { type HungBoard } from "./venue.boards.ts";
|
|
|
7
7
|
export declare const hungBoards: (model: Pick<TurnModel, "uprights">) => HungBoard[];
|
|
8
8
|
/** The planes of a hung scoreboard with the replay's board on it: the same planes (the same ids, in the same places), the faces drawn with the score, the period and the clock. */
|
|
9
9
|
export declare function hungBoardPlanes(hung: HungBoard, state: ReplayBoardState): Plane[];
|
|
10
|
-
/** What is written on a board, as one string: it changes when, and only when, the score, the period or
|
|
10
|
+
/** What is written on a board, as one string: it changes when, and only when, the score, the period, the clock or a penalty's clock does. */
|
|
11
11
|
export declare const boardKey: (state: ReplayBoardState) => string;
|
|
12
12
|
/** The longest the game clock goes without a write while the replay plays, in milliseconds. */
|
|
13
13
|
export declare const CLOCK_EVERY = 1000;
|
|
@@ -7,11 +7,20 @@ export const hungBoards = (model) => model.uprights.items.flatMap((item) => (ite
|
|
|
7
7
|
/** The planes of a hung scoreboard with the replay's board on it: the same planes (the same ids, in the same places), the faces drawn with the score, the period and the clock. */
|
|
8
8
|
export function hungBoardPlanes(hung, state) {
|
|
9
9
|
const clock = safeClock(state.clock);
|
|
10
|
-
const live = clock === "" ? undefined : { clock, ...(state.period === undefined ? {} : { period: state.period }) };
|
|
10
|
+
const live = clock === "" ? undefined : { clock, ...(state.period === undefined ? {} : { period: state.period }), ...powerPlay(state) };
|
|
11
11
|
return centreBoard(hung.id, hung.box, hung.lift, { ...hung.look, score: state.score, ...(live === undefined ? {} : { live }) });
|
|
12
12
|
}
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/** A power play on the board: the clocks of each side's penalties that make it play short, and which side has more skaters. Nothing when no side plays short. */
|
|
14
|
+
function powerPlay(state) {
|
|
15
|
+
const short = (state.penalties ?? []).filter((p) => p.short);
|
|
16
|
+
if (short.length === 0 || state.strength === undefined)
|
|
17
|
+
return {};
|
|
18
|
+
const [home, away] = state.strength;
|
|
19
|
+
const pp = home > away ? "home" : away > home ? "away" : undefined;
|
|
20
|
+
return { penalties: { home: short.filter((p) => p.team === "home").map((p) => p.left), away: short.filter((p) => p.team === "away").map((p) => p.left), ...(pp === undefined ? {} : { pp }) } };
|
|
21
|
+
}
|
|
22
|
+
/** What is written on a board, as one string: it changes when, and only when, the score, the period, the clock or a penalty's clock does. */
|
|
23
|
+
export const boardKey = (state) => `${state.score[0]}-${state.score[1]} ${state.period ?? ""} ${state.clock}${state.strength === undefined ? "" : ` ${state.strength.join("-")} ${(state.penalties ?? []).map((p) => `${p.team}${p.left}${p.short ? "" : "x"}`).join(",")}`}`;
|
|
15
24
|
/** The longest the game clock goes without a write while the replay plays, in milliseconds. */
|
|
16
25
|
export const CLOCK_EVERY = 1000;
|
|
17
26
|
const FACE = /-(south|north|east|west)$/;
|
|
@@ -29,6 +38,14 @@ export function boardFollower(timeline, now) {
|
|
|
29
38
|
one.face.dataset.score = `${s.score[0]}-${s.score[1]}`;
|
|
30
39
|
one.face.dataset.period = s.period === undefined ? "" : String(s.period);
|
|
31
40
|
one.face.dataset.clock = s.clock;
|
|
41
|
+
if (s.strength === undefined) {
|
|
42
|
+
delete one.face.dataset.strength;
|
|
43
|
+
delete one.face.dataset.penalties;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
one.face.dataset.strength = s.strength.join("-");
|
|
47
|
+
one.face.dataset.penalties = (s.penalties ?? []).map((p) => `${p.team} ${p.left}`).join(", ");
|
|
48
|
+
}
|
|
32
49
|
}
|
|
33
50
|
};
|
|
34
51
|
return {
|
package/dist/venue.boards.d.ts
CHANGED
|
@@ -20,6 +20,12 @@ export interface BoardLive {
|
|
|
20
20
|
period?: number;
|
|
21
21
|
/** The clock as it is written, `19:20`: digits and one colon. */
|
|
22
22
|
clock: string;
|
|
23
|
+
/** A power play: each side's penalty clocks (`1:42`, the soonest first, two at most are drawn) in its block of colour, and `PP` in the block of the side with more skaters. */
|
|
24
|
+
penalties?: {
|
|
25
|
+
home: string[];
|
|
26
|
+
away: string[];
|
|
27
|
+
pp?: "home" | "away";
|
|
28
|
+
};
|
|
23
29
|
}
|
|
24
30
|
/** The scoreboard hung over a surface, with what it is made from: a replay draws its faces again with the score and the clock of the time it is at. */
|
|
25
31
|
export interface HungBoard {
|