@johnmorrisdotca/kyogijo-stadiums 0.51.0 → 0.53.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/dist/turn.dom.js CHANGED
@@ -12,6 +12,20 @@ import { turnBoardMarkup } from "./overlay.js";
12
12
  import { attachOutside3d } from "./outside.dom.js";
13
13
  import { TIER_PROFILES, createGovernor, handheld, layerBudget, qualityOf, rasterOf, rasterShares, tierOf, viewShare, FLOOR_SHARE } from "./quality.js";
14
14
  import { size } from "./turn.kit.js";
15
+ /**
16
+ * The replay's own code, which `turn.replay.dom.ts` puts in when it is loaded (the `/turn` entry and the element load it): a page that only turns a view (a district, a tournament layout) carries none of the replay
17
+ * and its layers.
18
+ */
19
+ let replayKit;
20
+ /** Put the replay's code in: called once by `turn.replay.dom.ts`. */
21
+ export function useTurnReplay(kit) {
22
+ replayKit = kit;
23
+ }
24
+ const kitOf = () => {
25
+ if (replayKit === undefined)
26
+ throw new Error("the replay is not loaded: import mountKyogijoTurn from @johnmorrisdotca/kyogijo-stadiums/turn");
27
+ return replayKit;
28
+ };
15
29
  /** True where the page can draw a plane in 3D by one `matrix3d`: that is, nearly everywhere with a stylesheet. */
16
30
  export function turnSupported(win = typeof window === "undefined" ? undefined : window) {
17
31
  const css = win?.CSS;
@@ -34,13 +48,13 @@ const between = (from, to, t) => ({ yaw: from.yaw + (nearest(from.yaw, to.yaw) -
34
48
  * ```
35
49
  */
36
50
  export function mountKyogijoTurn(host, options, settings = {}) {
37
- return mountTurn(host, options, settings, buildTurn);
51
+ return mountTurn(host, options, settings, buildTurn, true);
38
52
  }
39
53
  /**
40
54
  * Mount any 3D model (`mountKyogijoTurn` is this with the model of one surface; `mountMultiTurn` with a tournament layout's): `build` makes the model from what `update` is given, and the rest, the controls, the pointer, the keys
41
55
  * and the auto-turn, is the same.
42
56
  */
43
- export function mountTurn(host, options, settings, build) {
57
+ export function mountTurn(host, options, settings, build, replayable = false) {
44
58
  const doc = host.ownerDocument;
45
59
  const win = doc.defaultView;
46
60
  /** The venue's settings as they are now: the mounted ones, altered by `update`. */
@@ -71,6 +85,10 @@ export function mountTurn(host, options, settings, build) {
71
85
  };
72
86
  const sameRaster = (a, b) => (typeof a === "number" || typeof b === "number" ? a === b : a.length === b.length && a.every((x, i) => x === b[i]));
73
87
  let current = options;
88
+ /** What a replay lays on a model (its floor at the replay's time, and the players standing on it), and what it does once the world is drawn again: both unset without a replay. */
89
+ let decorate;
90
+ let rendered;
91
+ let replaying;
74
92
  let model = build(options, { ...settings, quality: tier });
75
93
  if (model.tiered !== true)
76
94
  tier = "high";
@@ -89,7 +107,12 @@ export function mountTurn(host, options, settings, build) {
89
107
  let alive = true;
90
108
  if (!supported) {
91
109
  // The flat drawing, with a sentence that says why it does not turn when the browser is the reason.
110
+ let flatReplay;
92
111
  const flat = (next) => {
112
+ if (flatReplay !== undefined) {
113
+ flatReplay.view.draw(floorOptions(next));
114
+ return;
115
+ }
93
116
  host.innerHTML = `<div class="ky-turn" data-mode="flat"><style>${KYOGIJO_TURN_STYLE}</style>${model.flat ?? drawKyogijo({ ...floorOptions(next), board: next.board })}${model.turnable ? `<p class="ky-turn-flat">${esc(turnWords(model).flat)}</p>` : ""}</div>`;
94
117
  };
95
118
  flat(options);
@@ -111,12 +134,33 @@ export function mountTurn(host, options, settings, build) {
111
134
  update: (next, change) => {
112
135
  if (change?.venue !== undefined)
113
136
  venue = { ...(typeof venue === "object" ? venue : {}), ...change.venue };
137
+ current = next;
114
138
  model = build(next, { ...settings, id: settings.id ?? model.id, venue });
115
139
  flat(next);
116
140
  },
117
141
  svg: () => (alive ? host.querySelector("svg.kyogijo") : null),
142
+ // The flat replay, where the 3D view cannot be drawn.
143
+ replay(timeline, replaySettings = {}) {
144
+ if (!replayable)
145
+ throw new Error("a replay plays on one surface, not on a layout");
146
+ flatReplay?.view.destroy();
147
+ const view = kitOf().flat(host, timeline, replaySettings, floorOptions(current));
148
+ const mine = { view, timeline, settings: replaySettings };
149
+ flatReplay = mine;
150
+ return {
151
+ ...view.controller,
152
+ destroy() {
153
+ if (flatReplay !== mine)
154
+ return;
155
+ view.destroy();
156
+ flatReplay = undefined;
157
+ flat(current);
158
+ },
159
+ };
160
+ },
118
161
  destroy: () => {
119
162
  alive = false;
163
+ flatReplay?.view.destroy();
120
164
  host.replaceChildren();
121
165
  },
122
166
  };
@@ -239,9 +283,11 @@ export function mountTurn(host, options, settings, build) {
239
283
  };
240
284
  /** Draw the world again, from the model of the tier and the options now: the camera, the auto-turn and the pointer are not touched (the elements are the only thing replaced). */
241
285
  const renderWorld = () => {
242
- const known = made.get(tier);
243
- model = known ?? build(current, { ...settings, quality: tier, id: settings.id ?? baseId, venue, preset: undefined, view });
244
- made.set(tier, model);
286
+ // A model with a replay on it is made fresh each time, at the replay's time: only a plain one is kept.
287
+ const known = decorate === undefined ? made.get(tier) : undefined;
288
+ const plain = known ?? (decorate === undefined ? undefined : made.get(tier)) ?? build(current, { ...settings, quality: tier, id: settings.id ?? baseId, venue, preset: undefined, view });
289
+ made.set(tier, plain);
290
+ model = decorate === undefined ? plain : decorate(plain);
245
291
  ceiling = ceilingFor(model, stage.clientWidth || 960, stage.clientHeight || 600, shown.zoom);
246
292
  raster = rasterFor(model);
247
293
  const layout = layoutOf(model, shown, stage.clientWidth || 960, stage.clientHeight || 600, settings.lens, false, raster);
@@ -263,6 +309,7 @@ export function mountTurn(host, options, settings, build) {
263
309
  root.dataset.quality = quality;
264
310
  root.dataset.tier = tier;
265
311
  root.dataset.share = String(ceiling);
312
+ rendered?.();
266
313
  };
267
314
  /** The governor stepped: draw at the new tier. */
268
315
  const retier = (next) => {
@@ -419,11 +466,36 @@ export function mountTurn(host, options, settings, build) {
419
466
  go(opening, 560, "none");
420
467
  say(turnWords(model).reset);
421
468
  };
469
+ // ---- One frame request a frame: the auto-turn and a replay on the view ask through this, and share one animation frame when both run. ----
470
+ const queued = new Map();
471
+ let queueId = 0;
472
+ let pendingFrame = 0;
473
+ const requestFrame = (callback) => {
474
+ queueId += 1;
475
+ queued.set(queueId, callback);
476
+ if (pendingFrame === 0 && win !== null) {
477
+ pendingFrame = win.requestAnimationFrame((stamp) => {
478
+ pendingFrame = 0;
479
+ const run = [...queued.values()];
480
+ queued.clear();
481
+ for (const one of run)
482
+ one(stamp);
483
+ });
484
+ }
485
+ return queueId;
486
+ };
487
+ const cancelFrame = (id) => {
488
+ queued.delete(id);
489
+ if (queued.size === 0 && pendingFrame !== 0) {
490
+ win?.cancelAnimationFrame(pendingFrame);
491
+ pendingFrame = 0;
492
+ }
493
+ };
422
494
  // ---- The auto-turn: a trophy case's slow turn. It runs only when asked, holds while the pointer holds the view, and does nothing while the page is hidden. ----
423
495
  const hidden = () => doc.hidden === true;
424
496
  const stopTick = () => {
425
497
  if (tickFrame !== undefined)
426
- win?.cancelAnimationFrame(tickFrame);
498
+ cancelFrame(tickFrame);
427
499
  tickFrame = undefined;
428
500
  lastTick = undefined;
429
501
  };
@@ -455,7 +527,7 @@ export function mountTurn(host, options, settings, build) {
455
527
  };
456
528
  const loop = () => {
457
529
  if (tickFrame === undefined && playing && alive && win !== null)
458
- tickFrame = win.requestAnimationFrame(tick);
530
+ tickFrame = requestFrame(tick);
459
531
  };
460
532
  /** Start or stop the auto-turn. `quiet` leaves the live region alone (a key or a drag stopped it, and says its own thing). */
461
533
  const setPlaying = (next, quiet = false) => {
@@ -531,7 +603,7 @@ export function mountTurn(host, options, settings, build) {
531
603
  root.addEventListener("click", onClick);
532
604
  // ---- Keys. ----
533
605
  const onKey = (event) => {
534
- if (event.ctrlKey || event.metaKey || event.altKey || KEYS[event.key] !== true)
606
+ if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey || KEYS[event.key] !== true)
535
607
  return;
536
608
  const big = event.shiftKey;
537
609
  const yaw = big ? STEP.yawBig : STEP.yaw;
@@ -764,10 +836,51 @@ export function mountTurn(host, options, settings, build) {
764
836
  return last === undefined ? undefined : { ms: last.ms, people: last.stats.people, mode: last.stats.mode, pixels: last.pixels };
765
837
  },
766
838
  svg: () => (alive ? root.querySelector(".ky-turn-floor svg.kyogijo") : null),
839
+ replay(timeline, replaySettings = {}) {
840
+ if (!alive)
841
+ throw new Error("this view was destroyed");
842
+ if (!replayable)
843
+ throw new Error("a replay plays on one surface, not on a layout");
844
+ replaying?.destroy();
845
+ const internals = {
846
+ doc,
847
+ root,
848
+ stage,
849
+ model: () => model,
850
+ setDecorate(next, after) {
851
+ decorate = next;
852
+ rendered = after;
853
+ // (A view that is going away is not drawn again.)
854
+ if (!alive)
855
+ return;
856
+ renderWorld();
857
+ paint(shown);
858
+ },
859
+ repaint: () => {
860
+ if (alive)
861
+ paint(shown);
862
+ },
863
+ requestFrame,
864
+ cancelFrame,
865
+ };
866
+ const mine = kitOf().attach(internals, timeline, replaySettings);
867
+ replaying = mine;
868
+ return {
869
+ ...mine,
870
+ destroy() {
871
+ if (replaying !== mine)
872
+ return;
873
+ replaying = undefined;
874
+ mine.destroy();
875
+ },
876
+ };
877
+ },
767
878
  destroy() {
768
879
  if (!alive)
769
880
  return;
770
881
  alive = false;
882
+ replaying?.destroy();
883
+ replaying = undefined;
771
884
  clearTimeout(settling);
772
885
  outside?.destroy();
773
886
  stop();
@@ -1,7 +1,8 @@
1
+ import type { HungBoard } from "./venue.boards.ts";
1
2
  /** A point or a direction: x, y, z in feet. */
2
3
  export type V3 = readonly [number, number, number];
3
4
  /** What a plane is a part of: it is the class of its element (`ky-up-<kind>`) and what a test finds it by. */
4
- export type PartKind = "mast" | "post" | "net" | "tape" | "antenna" | "board" | "glass" | "rim" | "stanchion" | "frame" | "goal" | "flag" | "stump" | "leg" | "table" | "pylon" | "wall" | "screen" | "pole" | "ground" | "target" | "seats" | "concourse" | "fascia" | "roof" | "banner" | "platform" | "rail" | "matedge" | "shrine" | "surround" | "chair" | "desk" | "card" | "sign" | "apron" | "bench" | "floor" | "ribbon" | "hung-board" | "block";
5
+ export type PartKind = "mast" | "post" | "net" | "tape" | "antenna" | "board" | "glass" | "rim" | "stanchion" | "frame" | "goal" | "flag" | "stump" | "leg" | "table" | "pylon" | "wall" | "screen" | "pole" | "ground" | "target" | "seats" | "concourse" | "fascia" | "roof" | "banner" | "platform" | "rail" | "matedge" | "shrine" | "surround" | "chair" | "desk" | "card" | "sign" | "apron" | "bench" | "floor" | "ribbon" | "hung-board" | "block" | "player";
5
6
  /** One flat rectangle. Its own x runs along `u` (its whole length) and its own y along `v`; `o` is the corner both start from. `svg` is what is drawn on it, in feet along `u` and `v`. */
6
7
  export interface Plane {
7
8
  /** A name that says what it is and where: `net`, `post-left`, `goal-right-back`. */
@@ -74,6 +75,8 @@ export interface Upright {
74
75
  /** A second height worth saying (a net's height at the centre, a post's crossbar), in feet. */
75
76
  low?: number;
76
77
  planes: Plane[];
78
+ /** What a scoreboard hung over the surface was made from, so that a replay can draw its faces again with the score and the clock of a time (`replayTurnModel`). */
79
+ hung?: HungBoard;
77
80
  }
78
81
  /** The four numbers a plane's SVG needs: its length along `u` and along `v`, in feet. */
79
82
  export declare const size: (p: Pick<Plane, "u" | "v">) => [number, number];
@@ -0,0 +1,23 @@
1
+ import { type ReplayBoardState } from "./replay.board.ts";
2
+ import type { ReplayChange, ReplayTimeline } from "./replay.types.ts";
3
+ import type { Plane } from "./turn.kit.ts";
4
+ import type { TurnModel } from "./turn.model.ts";
5
+ import { type HungBoard } from "./venue.boards.ts";
6
+ /** The scoreboards a model has hung over its surface, with what each was made from. */
7
+ export declare const hungBoards: (model: Pick<TurnModel, "uprights">) => HungBoard[];
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
+ 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 the clock does. */
11
+ export declare const boardKey: (state: ReplayBoardState) => string;
12
+ /** The longest the game clock goes without a write while the replay plays, in milliseconds. */
13
+ export declare const CLOCK_EVERY = 1000;
14
+ /** A scoreboard in the page: its planes' SVG elements, what was last written to each, and what it shows. */
15
+ export interface BoardFollower {
16
+ /** Find the board's elements in the page after the view drew its world: the model was made at this time, so it already shows `state`. */
17
+ bind(root: ParentNode, model: TurnModel, state: ReplayBoardState | undefined): void;
18
+ /** Follow the clock to `t`: write the board if what it shows changed (and, while playing, if the clock has not just been written). */
19
+ follow(t: number, model: TurnModel, change?: ReplayChange): void;
20
+ clear(): void;
21
+ }
22
+ /** The follower of a replay's scoreboard. It is idle (no elements, no work) where the view has none. */
23
+ export declare function boardFollower(timeline: ReplayTimeline, now: () => number): BoardFollower;
@@ -0,0 +1,81 @@
1
+ // The scoreboard hung over a 3D view, following a replay: the faces drawn with the score, the period and the game clock of the time the replay is at (`replayBoard`), in the model (pure) and in the
2
+ // page (a few SVG strings written when the board changes, never the view). A goal, a new period, a seek, a step or a pause is written at once; the clock, while the replay plays, once a second at most.
3
+ import { replayBoard } from "./replay.board.js";
4
+ import { centreBoard, safeClock } from "./venue.boards.js";
5
+ /** The scoreboards a model has hung over its surface, with what each was made from. */
6
+ export const hungBoards = (model) => model.uprights.items.flatMap((item) => (item.kind === "hung-board" && item.hung !== undefined ? [item.hung] : []));
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
+ export function hungBoardPlanes(hung, state) {
9
+ const clock = safeClock(state.clock);
10
+ const live = clock === "" ? undefined : { clock, ...(state.period === undefined ? {} : { period: state.period }) };
11
+ return centreBoard(hung.id, hung.box, hung.lift, { ...hung.look, score: state.score, ...(live === undefined ? {} : { live }) });
12
+ }
13
+ /** What is written on a board, as one string: it changes when, and only when, the score, the period or the clock does. */
14
+ export const boardKey = (state) => `${state.score[0]}-${state.score[1]} ${state.period ?? ""} ${state.clock}`;
15
+ /** The longest the game clock goes without a write while the replay plays, in milliseconds. */
16
+ export const CLOCK_EVERY = 1000;
17
+ const FACE = /-(south|north|east|west)$/;
18
+ /** The follower of a replay's scoreboard. It is idle (no elements, no work) where the view has none. */
19
+ export function boardFollower(timeline, now) {
20
+ let found = new Map();
21
+ let shown = "";
22
+ let state;
23
+ let wroteAt = Number.NEGATIVE_INFINITY;
24
+ /** The figures on the faces as text, for a test and for a reader of the page's markup. */
25
+ const label = (s) => {
26
+ for (const [id, one] of found) {
27
+ if (!FACE.test(id))
28
+ continue;
29
+ one.face.dataset.score = `${s.score[0]}-${s.score[1]}`;
30
+ one.face.dataset.period = s.period === undefined ? "" : String(s.period);
31
+ one.face.dataset.clock = s.clock;
32
+ }
33
+ };
34
+ return {
35
+ bind(root, model, at) {
36
+ found = new Map();
37
+ state = at;
38
+ if (at === undefined)
39
+ return;
40
+ const written = new Map(model.planes.filter((p) => p.kind === "hung-board").map((p) => [p.id, p.svg]));
41
+ for (const face of root.querySelectorAll(".ky-up-hung-board")) {
42
+ const svg = face.querySelector(":scope > svg");
43
+ const id = face.dataset.part ?? "";
44
+ if (svg !== null && written.has(id))
45
+ found.set(id, { svg, face, written: written.get(id) ?? "" });
46
+ }
47
+ shown = boardKey(at);
48
+ wroteAt = now();
49
+ label(at);
50
+ },
51
+ follow(t, model, change) {
52
+ if (found.size === 0)
53
+ return;
54
+ const next = replayBoard(timeline, t);
55
+ const key = boardKey(next);
56
+ if (key === shown)
57
+ return;
58
+ const moved = state === undefined || state.score[0] !== next.score[0] || state.score[1] !== next.score[1] || state.period !== next.period;
59
+ const at = now();
60
+ if (!moved && change?.reason === "tick" && change.state.playing && at - wroteAt < CLOCK_EVERY)
61
+ return;
62
+ shown = key;
63
+ state = next;
64
+ wroteAt = at;
65
+ for (const hung of hungBoards(model)) {
66
+ for (const plane of hungBoardPlanes(hung, next)) {
67
+ const one = found.get(plane.id);
68
+ if (one === undefined || one.written === plane.svg)
69
+ continue;
70
+ one.svg.innerHTML = plane.svg;
71
+ one.written = plane.svg;
72
+ }
73
+ }
74
+ label(next);
75
+ },
76
+ clear() {
77
+ found = new Map();
78
+ state = undefined;
79
+ },
80
+ };
81
+ }
@@ -0,0 +1,61 @@
1
+ import { type ReplayBoardState } from "./replay.board.ts";
2
+ import { type ReplayGroup } from "./replay.ts";
3
+ import type { ReplayLook, ReplayObject, ReplayTeam, ReplayTimeline } from "./replay.types.ts";
4
+ import { type Plane } from "./turn.kit.ts";
5
+ import type { TurnModel } from "./turn.model.ts";
6
+ import type { ScoreOptions, SurfaceOptions } from "./types.ts";
7
+ /** Who stands up on a 3D view: the players and the officials. The ball and the puck stay on the floor. */
8
+ export declare const STANDING: readonly ReplayTeam[];
9
+ /** Whether a group of the replay stays on the floor of a 3D view: the trails, the passes, the shots, the goals and the ball do, and the players, their numbers and the officials stand up. */
10
+ export declare const keepOnFloor: (group: Pick<ReplayGroup, "id">) => boolean;
11
+ /** The settings a 3D replay takes beyond the flat replay's look: `stand: false` keeps the players flat on the floor, as the flat replay draws them. */
12
+ export interface TurnReplayLook extends ReplayLook {
13
+ stand?: boolean;
14
+ /** `false` leaves a scoreboard hung over the surface as the options made it; by default it shows the replay's score, period and clock (`replayBoard`). */
15
+ board?: boolean;
16
+ }
17
+ /**
18
+ * The size of a disc, in feet: a share of the playing surface's diagonal (the area the view is fitted to, not the ground or the building round it), so that it reads on a rink and on a pitch
19
+ * alike (about 6.5 feet on a hockey rink, 12 on a soccer pitch), times the look's `scale`. It is a mark, not a person: its size says nothing of a player's height.
20
+ */
21
+ export declare function discSize(area: {
22
+ w: number;
23
+ h: number;
24
+ }, look?: ReplayLook): number;
25
+ /** The playing surface of a model, in feet: what stands on it says, else the whole floor. */
26
+ export declare const playingArea: (model: Pick<TurnModel, "viewBox" | "uprights">) => {
27
+ w: number;
28
+ h: number;
29
+ };
30
+ /** The custom properties a floor drawing sets on its root (the teams' colours among them): the discs stand outside that SVG and take the same. */
31
+ export declare function floorVars(floor: string): string;
32
+ /** What is drawn on a disc of `d` feet: the flat replay's mark for the side, and the number over it, in the drawing's own classes so that they take its colours, light and dark. */
33
+ export declare function discMarkup(o: Pick<ReplayObject, "team" | "number">, d: number, sport: string, theme: string | undefined, vars: string): string;
34
+ /** The plane of one disc: a billboard that leans to face the eye, its lower edge on the floor when it stands, its middle over (x, y). */
35
+ export declare function discPlane(o: Pick<ReplayObject, "id" | "team" | "number">, x: number, y: number, d: number, lift: number, svg: string): Plane;
36
+ /** Put a disc over a new place on the floor, as `discPlane` does: the plane is the caller's, changed in place, so that a page moving many does not make new ones. */
37
+ export declare function moveDisc(plane: Plane, x: number, y: number, d: number, lift: number): void;
38
+ /** A replay laid on a 3D view's model: what `replayTurnModel` gives back. */
39
+ export interface TurnReplayModel {
40
+ model: TurnModel;
41
+ /** The disc of each one standing, by id, and whether it is on the surface at the time. */
42
+ discs: Map<string, {
43
+ plane: Plane;
44
+ on: boolean;
45
+ }>;
46
+ /** The disc's size, in feet. */
47
+ size: number;
48
+ /** The options the floor was drawn with, its `id` among them. */
49
+ floorOptions: SurfaceOptions | ScoreOptions;
50
+ /** What the hung scoreboard shows at the time, when the model has one and the look lets the replay drive it. */
51
+ board?: ReplayBoardState;
52
+ }
53
+ /** The floor's options for a replay: the model's own, and an id of its own, so that the replay's groups are found in it. */
54
+ export declare const replayFloorOptions: (model: Pick<TurnModel, "id" | "options">) => SurfaceOptions | ScoreOptions;
55
+ /**
56
+ * A 3D view's model with a replay on it at a time: the floor drawn as the replay's frame (without the players when they stand), and a disc for each player and official the timeline
57
+ * tracks, after the model's own planes. One that is not on the surface at the time is still a plane, and says so (`on`), so that the elements stay the same while it plays. Pure.
58
+ */
59
+ export declare function replayTurnModel(model: TurnModel, timeline: ReplayTimeline, t: number, look?: TurnReplayLook): TurnReplayModel;
60
+ /** A number for a test or a page that wants to know where a disc stands: its middle on the floor, in feet. */
61
+ export declare const discAt: (plane: Plane) => string;
@@ -0,0 +1,23 @@
1
+ import type { KyogijoReplay } from "./mount.ts";
2
+ import type { ReplaySettings, ReplayTimeline } from "./replay.types.ts";
3
+ import type { TurnModel } from "./turn.model.ts";
4
+ import { type TurnReplayLook } from "./turn.replay.ts";
5
+ /** What a 3D view lends a replay: its elements, its model, a way to lay the replay on the model, and a way to paint the view again. */
6
+ export interface TurnInternals {
7
+ doc: Document;
8
+ /** The view's root (`.ky-turn`) and the element that turns (`.ky-turn-view`). */
9
+ root: HTMLElement;
10
+ stage: HTMLElement;
11
+ model(): TurnModel;
12
+ /** Lay a replay on every model the view makes from now on (`undefined` takes it off), draw the world again with it, and call `after` once it is drawn, then and each time after. */
13
+ setDecorate(decorate: ((model: TurnModel) => TurnModel) | undefined, after?: () => void): void;
14
+ /** Paint the view as it is now: the planes whose place changed are written, the others are not touched. */
15
+ repaint(): void;
16
+ /** The view's own frame requests, which the auto-turn shares: one animation frame a frame, whatever runs. */
17
+ requestFrame(callback: FrameRequestCallback): number;
18
+ cancelFrame(id: number): void;
19
+ }
20
+ /** Settings of a replay on a 3D view: the flat replay's, and `stand`. */
21
+ export type TurnReplaySettings = ReplaySettings & TurnReplayLook;
22
+ /** Attach a replay to a 3D view. The controller it gives back has `destroy`, which takes the replay away and leaves the view as it was. */
23
+ export declare function attachTurnReplay(view: TurnInternals, timeline: ReplayTimeline, settings: TurnReplaySettings): KyogijoReplay;
@@ -0,0 +1,147 @@
1
+ import { createReplayView, replayDriver, replayPainter } from "./replay.dom.js";
2
+ import { useTurnReplay } from "./turn.dom.js";
3
+ import { drawFrameOf, replayPlacer, replaySnapshot } from "./replay.js";
4
+ import { boardFollower } from "./turn.replay.board.js";
5
+ import { keepOnFloor, moveDisc, replayTurnModel } from "./turn.replay.js";
6
+ /** Attach a replay to a 3D view. The controller it gives back has `destroy`, which takes the replay away and leaves the view as it was. */
7
+ export function attachTurnReplay(view, timeline, settings) {
8
+ const { doc, root, stage } = view;
9
+ const look = settings;
10
+ let language = view.model().language;
11
+ let laid;
12
+ let floorSvg = null;
13
+ /** The element of each disc, and whether it was last shown. */
14
+ let discs = new Map();
15
+ let placedAt = Number.NaN;
16
+ /** The scoreboard hung over the surface, which follows the game. */
17
+ const board = boardFollower(timeline, () => doc.defaultView?.performance.now() ?? 0);
18
+ const painter = replayPainter({
19
+ doc,
20
+ timeline,
21
+ look,
22
+ svg: () => floorSvg,
23
+ options: () => laid?.floorOptions ?? view.model().options,
24
+ language: () => language,
25
+ keep: look.stand === false ? undefined : keepOnFloor,
26
+ redraw: (t) => {
27
+ // The floor alone is drawn again (a mark that cannot be matched): the planes round it stay.
28
+ if (floorSvg === null || laid === undefined)
29
+ return;
30
+ const scratch = doc.createElement("div");
31
+ scratch.innerHTML = drawFrameOf(laid.floorOptions, timeline, t, look, look.stand === false ? () => true : keepOnFloor);
32
+ const fresh = scratch.querySelector("svg.kyogijo");
33
+ if (fresh === null)
34
+ return;
35
+ floorSvg.replaceWith(fresh);
36
+ floorSvg = fresh;
37
+ },
38
+ });
39
+ /** Move the discs to a time: a plane's place changes, and an element is shown or hidden only when that changes. */
40
+ const place = (t) => {
41
+ if (laid === undefined || t === placedAt)
42
+ return;
43
+ placedAt = t;
44
+ const snap = replaySnapshot(timeline, t, look);
45
+ const placed = replayPlacer(laid.floorOptions, timeline)(snap.objects.map((o) => [o.x, o.y]));
46
+ const here = new Map();
47
+ snap.objects.forEach((o, i) => here.set(o.id, placed[i]));
48
+ const lift = view.model().uprights.lift;
49
+ for (const [id, disc] of laid.discs) {
50
+ const at = here.get(id);
51
+ if (at !== undefined)
52
+ moveDisc(disc.plane, at[0], at[1], laid.size, lift);
53
+ const node = discs.get(id);
54
+ if (node?.el !== null && node !== undefined && node.shown !== (at !== undefined)) {
55
+ node.shown = at !== undefined;
56
+ node.el.style.visibility = node.shown ? "" : "hidden";
57
+ }
58
+ }
59
+ };
60
+ const wrap = doc.createElement("div");
61
+ wrap.className = "ky-replay ky-turn-replay";
62
+ const driver = replayDriver(doc, timeline, settings, {
63
+ paint: (t, change) => {
64
+ painter.paint(t);
65
+ place(t);
66
+ if (look.board !== false)
67
+ board.follow(t, view.model(), change);
68
+ view.repaint();
69
+ },
70
+ language,
71
+ keysOn: wrap,
72
+ holder: wrap,
73
+ seen: stage,
74
+ help: "turn",
75
+ frames: { request: view.requestFrame, cancel: view.cancelFrame },
76
+ });
77
+ const { style, hint, bar, live } = driver.parts;
78
+ wrap.append(style, hint, bar, live);
79
+ const controls = root.querySelector(".ky-turn-controls");
80
+ if (controls !== null)
81
+ controls.after(wrap);
82
+ else
83
+ root.append(wrap);
84
+ // The view's own description, and the replay's keys after it.
85
+ const described = stage.getAttribute("aria-describedby");
86
+ stage.setAttribute("aria-describedby", `${described ?? ""} ${hint.id}`.trim());
87
+ const shortcuts = stage.getAttribute("aria-keyshortcuts");
88
+ stage.setAttribute("aria-keyshortcuts", `${shortcuts ?? ""} , . Shift+, Shift+. End`.trim());
89
+ // The keys of the view itself: Space plays and pauses the replay (the Auto-turn button still turns the view), comma and full stop step, End goes to the end. The arrows stay the view's.
90
+ const onStageKey = (event) => {
91
+ const target = event.target;
92
+ if (target === null || (target !== stage && !stage.contains(target)))
93
+ return;
94
+ driver.key(event, "stage");
95
+ };
96
+ root.addEventListener("keydown", onStageKey, true);
97
+ // Each time the view draws its world (now, and again after `update` or a change of quality), the model has the replay at the clock's time: the elements are found again.
98
+ const after = () => {
99
+ language = view.model().language;
100
+ driver.setLanguage(language);
101
+ floorSvg = root.querySelector(".ky-turn-floor > svg.kyogijo");
102
+ discs = new Map();
103
+ if (laid !== undefined) {
104
+ const byPart = new Map([...root.querySelectorAll(".ky-up-player")].map((el) => [el.dataset.part ?? "", el]));
105
+ for (const [id, disc] of laid.discs) {
106
+ const el = byPart.get(disc.plane.id) ?? null;
107
+ if (el !== null && !disc.on)
108
+ el.style.visibility = "hidden";
109
+ discs.set(id, { el, shown: disc.on });
110
+ }
111
+ }
112
+ const t = driver.controller.state().t;
113
+ placedAt = t;
114
+ painter.bind(t);
115
+ // The scoreboard was drawn by the model at this time: its elements are found again.
116
+ board.bind(root, view.model(), laid?.board);
117
+ };
118
+ view.setDecorate((model) => {
119
+ laid = replayTurnModel(model, timeline, driver.controller.state().t, look);
120
+ return laid.model;
121
+ }, after);
122
+ if (settings.autoplay === true && driver.controller.state().motion === "smooth" && doc.visibilityState !== "hidden")
123
+ driver.controller.play();
124
+ return {
125
+ ...driver.controller,
126
+ destroy() {
127
+ driver.destroy();
128
+ root.removeEventListener("keydown", onStageKey, true);
129
+ if (described === null)
130
+ stage.removeAttribute("aria-describedby");
131
+ else
132
+ stage.setAttribute("aria-describedby", described);
133
+ if (shortcuts === null)
134
+ stage.removeAttribute("aria-keyshortcuts");
135
+ else
136
+ stage.setAttribute("aria-keyshortcuts", shortcuts);
137
+ wrap.remove();
138
+ laid = undefined;
139
+ discs = new Map();
140
+ board.clear();
141
+ floorSvg = null;
142
+ view.setDecorate(undefined);
143
+ },
144
+ };
145
+ }
146
+ // The view's `replay` plays with this file's code once it is loaded.
147
+ useTurnReplay({ attach: attachTurnReplay, flat: createReplayView });