powergrid-viewer 2.1.5 → 2.1.6

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.
@@ -0,0 +1,158 @@
1
+ import { createTutorial, TutorialMount, TutorialSnapshot } from '@boardgamers/protocol/tutorial';
2
+ import { mountTutorialGuide } from '@boardgamers/protocol/tutorial/dom';
3
+ import { EventEmitter } from 'events';
4
+ import { GameState, stripSecret } from 'powergrid-engine';
5
+ import Vue from 'vue';
6
+ import Game from '../components/Game.vue';
7
+ import { Action, lessons, LessonState } from './lessons';
8
+ import './tutorial.css';
9
+
10
+ export const mountTutorial: TutorialMount = async (target, { chapter, onProgress, nextChapter }) => {
11
+ const lesson = lessons.find((entry) => entry.id === chapter);
12
+ if (!lesson) throw Error('Unknown Powergrid chapter');
13
+ target.className = 'powergrid-tutorial';
14
+ const guide = document.createElement('section');
15
+ const controls = document.createElement('div');
16
+ controls.className = 'tutorial-answers';
17
+ controls.setAttribute('aria-label', 'Lesson answers');
18
+ const summary = document.createElement('div');
19
+ summary.className = 'tutorial-summary';
20
+ const board = document.createElement('div');
21
+ board.className = 'tutorial-board';
22
+ target.append(guide, controls, summary, board);
23
+ const emitter = new EventEmitter();
24
+ const params = {
25
+ state: null as GameState | null,
26
+ player: 0,
27
+ avatars: ['Y', 'A', 'L'].map(
28
+ (initial) =>
29
+ 'data:image/svg+xml,' +
30
+ encodeURIComponent(
31
+ `<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><circle cx="24" cy="24" r="23" fill="#233745"/><text x="24" y="31" text-anchor="middle" font-family="sans-serif" font-size="24" fill="white">${initial}</text></svg>`
32
+ )
33
+ ),
34
+ tutorialCity: undefined as string | undefined,
35
+ emitter,
36
+ preferences: Vue.observable({
37
+ sound: false,
38
+ disableHelp: false,
39
+ adjustPlayerOrder: false,
40
+ undoWholeTurn: true,
41
+ fitToScreen: true,
42
+ stackOnPortrait: true,
43
+ portraitResourceTrack: false,
44
+ }),
45
+ interactionDisabled: false,
46
+ tutorialMove: (move) => {
47
+ void play({ kind: 'move', move });
48
+ },
49
+ };
50
+ const app = new Vue({ render: (h) => h(Game, { props: params }) }).$mount(
51
+ board.appendChild(document.createElement('div'))
52
+ );
53
+ let destroyed = false;
54
+ let animate = false;
55
+ let timer: number | undefined;
56
+ let finishFrame: (() => void) | undefined;
57
+ let latest: TutorialSnapshot<LessonState>;
58
+ let previous: LessonState | undefined;
59
+ async function present(game: GameState) {
60
+ if (destroyed) return;
61
+ params.state = { ...window.structuredClone(stripSecret(game, 0)), newTurn: true };
62
+ app.$forceUpdate();
63
+ await app.$nextTick();
64
+ }
65
+ let storage: Storage | undefined;
66
+ try {
67
+ storage = localStorage;
68
+ } catch {
69
+ /* Saving is optional. */
70
+ }
71
+ const controller = await createTutorial({
72
+ ...lesson,
73
+ storage,
74
+ onProgress,
75
+ async move(state, action) {
76
+ const frames: GameState[] = [];
77
+ const result = lesson.move(state, action, (frame) => frames.push(frame));
78
+ if (animate)
79
+ for (const frame of frames) {
80
+ if (destroyed) break;
81
+ await present(frame);
82
+ await new Promise<void>((resolve) => {
83
+ finishFrame = resolve;
84
+ timer = window.setTimeout(
85
+ resolve,
86
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 450
87
+ );
88
+ });
89
+ finishFrame = undefined;
90
+ }
91
+ return result;
92
+ },
93
+ });
94
+ async function play(action: Action) {
95
+ if (destroyed || latest.busy || latest.completed || latest.canContinue) return;
96
+ animate = true;
97
+ try {
98
+ await controller.play(action);
99
+ } finally {
100
+ animate = false;
101
+ }
102
+ }
103
+ let controlStep = -1;
104
+ const off = controller.subscribe((snapshot) => {
105
+ latest = snapshot;
106
+ params.tutorialCity = (
107
+ { first: 'Essen', second: 'Duisburg', third: 'Dusseldorf', seventh: 'Kassel' } as Record<string, string>
108
+ )[lesson.steps[snapshot.step]?.id ?? ''];
109
+ params.interactionDisabled = snapshot.busy || snapshot.completed || snapshot.canContinue;
110
+ if (previous !== snapshot.state) {
111
+ previous = snapshot.state;
112
+ void present(snapshot.state.game);
113
+ const player = snapshot.state.game.players[0];
114
+ summary.replaceChildren();
115
+ for (const text of [
116
+ `Germany · Classic rules · Round ${snapshot.state.game.round}`,
117
+ `Cash: $${player.money}`,
118
+ `Cities: ${player.cities.length}`,
119
+ `Plant output: ${player.powerPlants.reduce((n, p) => n + p.citiesPowered, 0)}`,
120
+ `Fuel: ${player.coalLeft} coal · ${player.oilLeft} oil`,
121
+ ]) {
122
+ const item = document.createElement('span');
123
+ item.textContent = text;
124
+ summary.append(item);
125
+ }
126
+ }
127
+ if (controlStep !== snapshot.step) {
128
+ controlStep = snapshot.step;
129
+ controls.replaceChildren();
130
+ for (const choice of lesson.choices(snapshot.state, lesson.steps[snapshot.step]?.id ?? '')) {
131
+ const button = document.createElement('button');
132
+ button.type = 'button';
133
+ button.textContent = choice.label;
134
+ button.onclick = () => {
135
+ void play(choice.action);
136
+ };
137
+ controls.append(button);
138
+ }
139
+ controls.hidden = !controls.childElementCount;
140
+ }
141
+ controls.querySelectorAll('button').forEach((button) => {
142
+ button.disabled = snapshot.busy || snapshot.completed;
143
+ });
144
+ app.$forceUpdate();
145
+ });
146
+ const removeGuide = mountTutorialGuide(guide, controller, { nextChapter });
147
+ return () => {
148
+ destroyed = true;
149
+ clearTimeout(timer);
150
+ finishFrame?.();
151
+ removeGuide();
152
+ off();
153
+ controller.destroy();
154
+ emitter.removeAllListeners();
155
+ app.$destroy();
156
+ target.replaceChildren();
157
+ };
158
+ };
@@ -0,0 +1,135 @@
1
+ .powergrid-tutorial {
2
+ max-width: 1600px;
3
+ margin: auto;
4
+ padding: 16px;
5
+ color: #233745;
6
+ background: #f0f1e9;
7
+ font: 16px/1.5 system-ui, sans-serif;
8
+ box-sizing: border-box;
9
+ }
10
+ .powergrid-tutorial .bgs-tutorial-guide {
11
+ background: #fffef5;
12
+ border: 1px solid #c6cbc4;
13
+ border-top: 4px solid #3c758a;
14
+ border-radius: 8px;
15
+ padding: 16px 20px;
16
+ }
17
+ .powergrid-tutorial .bgs-tutorial-heading {
18
+ display: flex;
19
+ justify-content: space-between;
20
+ align-items: baseline;
21
+ gap: 12px;
22
+ }
23
+ .powergrid-tutorial .bgs-tutorial-heading strong {
24
+ font-size: 20px;
25
+ }
26
+ .powergrid-tutorial .bgs-tutorial-body p {
27
+ max-width: 90ch;
28
+ margin: 12px 0;
29
+ }
30
+ .powergrid-tutorial .bgs-tutorial-body small {
31
+ color: #566870;
32
+ display: block;
33
+ margin: 12px 0;
34
+ }
35
+ .powergrid-tutorial .bgs-tutorial-body [role='alert'] {
36
+ color: #a3311d;
37
+ font-weight: 600;
38
+ }
39
+ .powergrid-tutorial .bgs-tutorial-feedback {
40
+ background: #e0f0df;
41
+ border-left: 4px solid #45824b;
42
+ padding: 10px 14px;
43
+ margin: 12px 0;
44
+ }
45
+ .powergrid-tutorial .bgs-tutorial-controls,
46
+ .powergrid-tutorial .bgs-tutorial-playback,
47
+ .powergrid-tutorial .bgs-tutorial-help,
48
+ .powergrid-tutorial .tutorial-answers {
49
+ display: flex;
50
+ align-items: center;
51
+ gap: 8px;
52
+ flex-wrap: wrap;
53
+ }
54
+ .powergrid-tutorial .bgs-tutorial-playback {
55
+ flex-wrap: nowrap;
56
+ }
57
+ .powergrid-tutorial .bgs-tutorial-help:empty {
58
+ display: none;
59
+ }
60
+ .powergrid-tutorial button {
61
+ font: inherit;
62
+ border: 1px solid #96afb5;
63
+ border-radius: 6px;
64
+ padding: 8px 12px;
65
+ min-height: 40px;
66
+ background: #edf3f2;
67
+ color: #234250;
68
+ cursor: pointer;
69
+ }
70
+ .powergrid-tutorial button:hover:not(:disabled) {
71
+ background: #d8e8ea;
72
+ }
73
+ .powergrid-tutorial button:focus-visible {
74
+ outline: 3px solid #267f9f;
75
+ outline-offset: 2px;
76
+ }
77
+ .powergrid-tutorial button:disabled {
78
+ opacity: 0.45;
79
+ cursor: default;
80
+ }
81
+ .powergrid-tutorial .bgs-tutorial-transport {
82
+ display: inline-flex;
83
+ align-items: center;
84
+ justify-content: center;
85
+ width: 40px;
86
+ flex: 0 0 40px;
87
+ padding: 0;
88
+ }
89
+ .powergrid-tutorial .bgs-tutorial-next-chapter {
90
+ display: inline-flex;
91
+ align-items: center;
92
+ justify-content: center;
93
+ gap: 6px;
94
+ height: 40px;
95
+ white-space: nowrap;
96
+ }
97
+ .powergrid-tutorial .bgs-tutorial-guide [hidden],
98
+ .powergrid-tutorial .tutorial-answers[hidden] {
99
+ display: none;
100
+ }
101
+ .powergrid-tutorial .tutorial-answers {
102
+ margin-top: 12px;
103
+ }
104
+ .powergrid-tutorial .tutorial-summary {
105
+ display: flex;
106
+ flex-wrap: wrap;
107
+ gap: 8px 24px;
108
+ padding: 14px 4px;
109
+ font-size: 13px;
110
+ color: #536568;
111
+ }
112
+ .powergrid-tutorial .tutorial-board {
113
+ background: white;
114
+ border-radius: 8px;
115
+ overflow: hidden;
116
+ }
117
+ .powergrid-tutorial .bgs-tutorial-highlight {
118
+ outline: 3px solid #d28913;
119
+ outline-offset: 3px;
120
+ }
121
+ @media (max-width: 600px) {
122
+ .powergrid-tutorial {
123
+ padding: 8px;
124
+ font-size: 14px;
125
+ }
126
+ .powergrid-tutorial .bgs-tutorial-guide {
127
+ padding: 12px;
128
+ }
129
+ .powergrid-tutorial .bgs-tutorial-heading strong {
130
+ font-size: 17px;
131
+ }
132
+ .powergrid-tutorial .tutorial-summary {
133
+ gap: 5px 12px;
134
+ }
135
+ }
package/src/wrapper.ts CHANGED
@@ -1,8 +1,19 @@
1
- // Import vue component
2
- import launch from './launch';
1
+ import { createTutorialLauncher, TutorialLaunchOptions } from '@boardgamers/protocol/tutorial';
2
+ import launchGame, { destroyViewer } from './launch';
3
+ import { mountTutorial } from './tutorial/mount';
4
+
5
+ const tutorial = createTutorialLauncher(mountTutorial);
6
+ export function launch(selector: string) {
7
+ tutorial.destroy();
8
+ return launchGame(selector);
9
+ }
10
+ export function launchTutorial(selector: string, options: TutorialLaunchOptions) {
11
+ destroyViewer();
12
+ return tutorial.launch(selector, options);
13
+ }
3
14
 
4
15
  if (typeof window !== 'undefined') {
5
- (window as any).powergrid = { launch };
16
+ (window as any).powergrid = { launch, launchTutorial };
6
17
  }
7
18
 
8
19
  export default launch;
package/tsconfig.json CHANGED
@@ -12,7 +12,7 @@
12
12
  "noImplicitAny": false,
13
13
  "sourceMap": true,
14
14
  "baseUrl": ".",
15
- "types": ["webpack-env", "mocha", "chai"],
15
+ "types": ["webpack-env", "mocha", "chai", "node"],
16
16
  "paths": {
17
17
  "@/*": ["src/*"]
18
18
  },