powergrid-viewer 2.1.2 → 2.1.3
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/powergrid-viewer.umd.min.js +11 -11
- package/package.json +7 -4
- package/src/components/Game.vue +2 -1
- package/src/game-chat.ts +107 -265
- package/src/launch.ts +68 -64
- package/src/sounds.ts +6 -4
- package/tsconfig.json +4 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "powergrid-viewer",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.3",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/boardgamers/powergrid.git",
|
|
@@ -11,13 +11,14 @@
|
|
|
11
11
|
"url": "https://github.com/boardgamers/powergrid/issues"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
+
"@boardgamers/protocol": "0.2.2",
|
|
14
15
|
"core-js": "^3.6.5",
|
|
15
16
|
"lodash": "^4.17.19",
|
|
16
17
|
"regenerator-runtime": "^0.13.7",
|
|
17
18
|
"tslib": "^2.2.0",
|
|
18
19
|
"vue": "^2.6.11",
|
|
19
20
|
"vue-class-component": "^7.2.3",
|
|
20
|
-
"vue-property-decorator": "^
|
|
21
|
+
"vue-property-decorator": "^9.1.2",
|
|
21
22
|
"powergrid-engine": "2.0.6"
|
|
22
23
|
},
|
|
23
24
|
"devDependencies": {
|
|
@@ -35,9 +36,10 @@
|
|
|
35
36
|
"chai": "^4.1.2",
|
|
36
37
|
"eslint": "^7.28.0",
|
|
37
38
|
"eslint-plugin-vue": "^7.11.0",
|
|
39
|
+
"playwright": "1.62.1",
|
|
38
40
|
"sass": "^1.26.5",
|
|
39
41
|
"sass-loader": "^8.0.2",
|
|
40
|
-
"typescript": "^
|
|
42
|
+
"typescript": "^5.9.3",
|
|
41
43
|
"vue-template-compiler": "^2.6.11"
|
|
42
44
|
},
|
|
43
45
|
"files": [
|
|
@@ -53,6 +55,7 @@
|
|
|
53
55
|
"serve": "set NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service serve",
|
|
54
56
|
"build": "set NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service build",
|
|
55
57
|
"test:unit": "vue-cli-service test:unit",
|
|
56
|
-
"package": "vue-cli-service build --target lib --name powergrid-viewer src/wrapper.ts"
|
|
58
|
+
"package": "vue-cli-service build --target lib --name powergrid-viewer src/wrapper.ts",
|
|
59
|
+
"test:browser": "node scripts/protocol-smoke.mjs"
|
|
57
60
|
}
|
|
58
61
|
}
|
package/src/components/Game.vue
CHANGED
|
@@ -414,6 +414,7 @@
|
|
|
414
414
|
</g>
|
|
415
415
|
</svg>
|
|
416
416
|
|
|
417
|
+
<div class="chat-tabs-host"></div>
|
|
417
418
|
<div class="journal-and-chat">
|
|
418
419
|
<InlineLog v-if="G" :entries="logReversed.slice().reverse()" :mapName="G.options.map" />
|
|
419
420
|
<div class="chat-host"></div>
|
|
@@ -2022,7 +2023,7 @@ export default class Game extends Vue {
|
|
|
2022
2023
|
|
|
2023
2024
|
if (!this.G || this.G.log.length == 1) {
|
|
2024
2025
|
return 'Game Start!';
|
|
2025
|
-
} else if (this.G.currentPlayers
|
|
2026
|
+
} else if (this.G.currentPlayers.length === 0) {
|
|
2026
2027
|
return 'Game ended!';
|
|
2027
2028
|
} else if (this.player !== undefined && this.G?.currentPlayers.includes(this.player)) {
|
|
2028
2029
|
const currentPlayer = this.G.players[this.player];
|
package/src/game-chat.ts
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
|
+
import { ChatController, ChatMessage, chatSegments } from '@boardgamers/protocol/chat';
|
|
2
|
+
import { mountChat } from '@boardgamers/protocol/chat/dom';
|
|
3
|
+
import { attachChat, ViewerEmitter } from '@boardgamers/protocol/viewer';
|
|
1
4
|
import { playerColors } from 'powergrid-engine/src/gamestate';
|
|
2
|
-
type
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const panel = document.createElement('details');
|
|
9
|
-
panel.className = 'bgs-game-chat';
|
|
10
|
-
panel.open = true;
|
|
11
|
-
panel.innerHTML =
|
|
12
|
-
'<summary>Chat</summary><div class="chat-messages" role="log" aria-label="Game chat"></div><div class="chat-composer"><input type="text" aria-label="Chat message" placeholder="Message…" autocomplete="off"><button type="button">Send</button></div><div class="chat-status" role="status"></div>';
|
|
5
|
+
type ChatEmitter = Pick<ViewerEmitter<any, any>, 'on' | 'emit'>;
|
|
6
|
+
export function mountGameChat(emitter: ViewerEmitter<any, any>, host: Element): () => void {
|
|
7
|
+
const chat = new ChatController();
|
|
8
|
+
const detach = attachChat(emitter, chat);
|
|
9
|
+
const slot = host.querySelector<HTMLElement>('.chat-host') || document.createElement('div');
|
|
10
|
+
if (!slot.parentElement) host.insertAdjacentElement('afterend', slot);
|
|
13
11
|
const style = document.createElement('style');
|
|
14
12
|
style.textContent = `
|
|
15
13
|
.bgs-game-chat{box-sizing:border-box;font:14px/1.4 Arial,sans-serif;border:1px solid #a2aa82;border-radius:3px;margin:8px 0;padding:8px 12px;background:#f3f0dc;color:#263521}
|
|
16
14
|
.bgs-game-chat summary{cursor:pointer;font-weight:650;border-radius:3px;width:fit-content;padding:2px 4px;margin:-2px -4px}
|
|
17
15
|
.bgs-game-chat summary:hover{color:#126778}
|
|
18
16
|
.bgs-game-chat summary:focus-visible,.bgs-game-chat button:focus-visible{outline:2px solid #247d8c;outline-offset:3px}
|
|
17
|
+
.bgs-game-chat .chat-day{display:flex;align-items:center;gap:8px;margin:12px 0 6px;font-size:.85em;opacity:.8}
|
|
18
|
+
.bgs-game-chat .chat-day::before,.bgs-game-chat .chat-day::after{content:"";flex:1;border-top:1px solid currentColor;opacity:.3}
|
|
19
|
+
.bgs-game-chat .chat-day time{margin:0;color:inherit;font-size:inherit}
|
|
19
20
|
.bgs-game-chat .chat-messages{max-height:250px;overflow:auto;overscroll-behavior:auto;margin:6px 0 8px}
|
|
20
21
|
.bgs-game-chat article{padding:5px 0;border-bottom:1px solid #75818d26;white-space:pre-wrap;overflow-wrap:anywhere}
|
|
21
22
|
.bgs-game-chat article strong{padding:0 3px;font-weight:bold}
|
|
@@ -28,6 +29,11 @@ export function mountGameChat(emitter: ChatEmitter, host: Element): void {
|
|
|
28
29
|
.bgs-game-chat button{box-sizing:border-box;height:30px;cursor:pointer;border:1px solid #7f8c8d;border-radius:3px;background:#dddeda;color:#172d34;padding:3px 12px;font:14px Arial,sans-serif}
|
|
29
30
|
.bgs-game-chat button:hover:not(:disabled){background:#c8d3d0}
|
|
30
31
|
.bgs-game-chat button:disabled{color:#788786;border-color:#b0bcb8;background:#dce3df;cursor:default}
|
|
32
|
+
.bgs-game-chat .chat-mention{height:auto;padding:0 2px;border:0;background:transparent;color:inherit;font:inherit;font-weight:bold;text-decoration:underline}
|
|
33
|
+
.bgs-game-chat article a{color:inherit;text-decoration:underline}
|
|
34
|
+
.chat-suggestions{display:flex;flex-wrap:wrap;gap:4px;margin-top:4px}
|
|
35
|
+
.chat-suggestions:empty{display:none}
|
|
36
|
+
.chat-suggestions button[aria-pressed="true"]{outline:2px solid #527f89}
|
|
31
37
|
.bgs-game-chat .chat-status{font-size:12px;margin-top:6px}
|
|
32
38
|
.bgs-game-chat .chat-status:empty{display:none}
|
|
33
39
|
.chat-shortcut{position:fixed;left:16px;bottom:max(16px,env(safe-area-inset-bottom));z-index:900;padding:7px 12px;border:1px solid #6a8589;border-radius:3px;background:#263521;color:#fff;font:600 14px Arial,sans-serif;cursor:pointer;box-shadow:0 2px 6px #0003}
|
|
@@ -44,292 +50,120 @@ export function mountGameChat(emitter: ChatEmitter, host: Element): void {
|
|
|
44
50
|
.chat-shortcut:focus-visible{outline:2px solid #fff;outline-offset:2px}
|
|
45
51
|
|
|
46
52
|
`;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
53
|
+
slot.append(style);
|
|
54
|
+
let players: { id: number; name: string; color?: string; faction?: string }[] = [];
|
|
55
|
+
let localPlayer: number | undefined;
|
|
56
|
+
let chatVisible = false;
|
|
57
|
+
const shortcut = document.createElement('button');
|
|
58
|
+
shortcut.type = 'button';
|
|
59
|
+
shortcut.className = 'chat-shortcut';
|
|
60
|
+
shortcut.hidden = true;
|
|
61
|
+
(host.querySelector('.chat-tabs-host') || slot).append(shortcut);
|
|
62
|
+
const feeds = host.querySelector<HTMLElement>('.journal-and-chat');
|
|
52
63
|
const tabs = document.createElement('nav');
|
|
53
64
|
tabs.className = 'game-feed-tabs';
|
|
54
65
|
tabs.setAttribute('aria-label', 'Chat and journal');
|
|
55
66
|
const chatTab = document.createElement('button');
|
|
56
67
|
const journalTab = document.createElement('button');
|
|
57
68
|
chatTab.type = journalTab.type = 'button';
|
|
58
|
-
chatTab.textContent = 'Chat';
|
|
59
69
|
journalTab.textContent = 'Journal';
|
|
60
70
|
tabs.append(chatTab, journalTab);
|
|
61
|
-
|
|
62
|
-
function selectFeed(feed: 'chat' | 'journal')
|
|
71
|
+
host.querySelector('.chat-tabs-host')?.append(tabs);
|
|
72
|
+
function selectFeed(feed: 'chat' | 'journal') {
|
|
63
73
|
if (feeds) feeds.dataset.feed = feed;
|
|
64
74
|
chatTab.setAttribute('aria-pressed', String(feed === 'chat'));
|
|
65
75
|
journalTab.setAttribute('aria-pressed', String(feed === 'journal'));
|
|
66
76
|
}
|
|
67
77
|
selectFeed('chat');
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const status = panel.querySelector('.chat-status') as HTMLDivElement;
|
|
78
|
-
let messages: ChatMessage[] = [];
|
|
79
|
-
let players: { id: number; name: string; color?: string }[] = [];
|
|
80
|
-
let localPlayer: number | undefined;
|
|
81
|
-
emitter.on('state', (state) => {
|
|
82
|
-
players = state?.players || [];
|
|
83
|
-
render();
|
|
84
|
-
});
|
|
85
|
-
emitter.on('player', (player) => {
|
|
86
|
-
localPlayer = player?.index;
|
|
87
|
-
render();
|
|
78
|
+
const view = mountChat(slot, {
|
|
79
|
+
chat,
|
|
80
|
+
styles: false,
|
|
81
|
+
openPlayer: (index) => emitter.emit('player:clicked', { index }),
|
|
82
|
+
renderAuthor,
|
|
83
|
+
onVisibilityChange(visible) {
|
|
84
|
+
chatVisible = visible;
|
|
85
|
+
updateShortcut();
|
|
86
|
+
},
|
|
88
87
|
});
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
shortcut.hidden = true;
|
|
104
|
-
(feeds || panel).insertAdjacentElement('afterend', shortcut);
|
|
105
|
-
let chatVisible = false;
|
|
106
|
-
function updateShortcut(): void {
|
|
107
|
-
const count = unread.size;
|
|
88
|
+
function renderAuthor(message: ChatMessage): Node {
|
|
89
|
+
const author = document.createElement('strong');
|
|
90
|
+
author.textContent = message.author || 'Game';
|
|
91
|
+
const index =
|
|
92
|
+
message.playerIndex ??
|
|
93
|
+
(message.author === 'You' ? localPlayer : players.find((p) => p.name === message.author)?.id);
|
|
94
|
+
if (index !== undefined && playerColors[index]) {
|
|
95
|
+
author.style.backgroundColor = players.find((p) => p.id === index)?.color || playerColors[index];
|
|
96
|
+
author.style.color = author.style.backgroundColor === 'brown' ? '#fff' : '#111';
|
|
97
|
+
}
|
|
98
|
+
return author;
|
|
99
|
+
}
|
|
100
|
+
function updateShortcut() {
|
|
101
|
+
const count = chat.unread;
|
|
108
102
|
const label = count ? `Chat · ${count} unread` : 'Chat';
|
|
109
103
|
shortcut.textContent = label;
|
|
104
|
+
shortcut.hidden = chatVisible;
|
|
110
105
|
shortcut.setAttribute('aria-label', `Open ${label}`);
|
|
111
|
-
summary.textContent = label;
|
|
112
106
|
chatTab.textContent = label;
|
|
113
|
-
shortcut.hidden = chatVisible;
|
|
114
107
|
}
|
|
115
108
|
shortcut.onclick = () => {
|
|
116
109
|
selectFeed('chat');
|
|
117
|
-
|
|
118
|
-
requestAnimationFrame(() => {
|
|
119
|
-
const firstUnread = Array.from(list.children).find((row) =>
|
|
120
|
-
unread.has((row as HTMLElement).dataset.id || '')
|
|
121
|
-
);
|
|
122
|
-
if (firstUnread) firstUnread.scrollIntoView({ block: 'center' });
|
|
123
|
-
else panel.scrollIntoView({ block: 'center' });
|
|
124
|
-
summary.focus({ preventScroll: true });
|
|
125
|
-
read();
|
|
126
|
-
});
|
|
110
|
+
view.open();
|
|
127
111
|
};
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
read();
|
|
140
|
-
},
|
|
141
|
-
{ threshold: Array.from({ length: 21 }, (_, i) => i / 20) }
|
|
142
|
-
);
|
|
143
|
-
visibility.observe(panel);
|
|
144
|
-
function controls(): void {
|
|
145
|
-
button.disabled = !canSend || disabled || !!pending || !input.value.trim();
|
|
146
|
-
input.disabled = !canSend || disabled;
|
|
147
|
-
}
|
|
148
|
-
function read(): void {
|
|
149
|
-
if (!panel.open || document.visibilityState !== 'visible' || !document.hasFocus()) {
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
const bounds = list.getBoundingClientRect();
|
|
153
|
-
if (!bounds.width || !bounds.height) return;
|
|
154
|
-
const visible = Array.from(list.children).filter((el) => {
|
|
155
|
-
const r = el.getBoundingClientRect();
|
|
156
|
-
return r.bottom <= Math.min(bounds.bottom, window.innerHeight) + 1 && r.top >= Math.max(bounds.top, 0);
|
|
157
|
-
});
|
|
158
|
-
for (const row of visible) unread.delete((row as HTMLElement).dataset.id || '');
|
|
159
|
-
updateShortcut();
|
|
160
|
-
const id = (visible[visible.length - 1] as HTMLElement)?.dataset.id || '';
|
|
161
|
-
if (!/^[a-f0-9]{24}$/i.test(id) || id.toLowerCase() <= watermark) {
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
candidate = candidate > id.toLowerCase() ? candidate : id.toLowerCase();
|
|
165
|
-
if (!readTimer) {
|
|
166
|
-
readTimer = setTimeout(() => {
|
|
167
|
-
readTimer = undefined;
|
|
168
|
-
if (
|
|
169
|
-
panel.open &&
|
|
170
|
-
document.visibilityState === 'visible' &&
|
|
171
|
-
document.hasFocus() &&
|
|
172
|
-
candidate > watermark
|
|
173
|
-
) {
|
|
174
|
-
watermark = candidate;
|
|
175
|
-
emitter.emit('chat:read', { messageId: watermark });
|
|
176
|
-
}
|
|
177
|
-
}, 500);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
function render(): void {
|
|
181
|
-
list.textContent = '';
|
|
182
|
-
for (const message of messages) {
|
|
183
|
-
const row = document.createElement('article');
|
|
184
|
-
row.dataset.id = message._id || '';
|
|
185
|
-
const author = document.createElement('strong');
|
|
186
|
-
author.textContent = message.author || 'Game';
|
|
187
|
-
const index =
|
|
188
|
-
message.playerIndex ??
|
|
189
|
-
(message.author === 'You' ? localPlayer : players.find((p) => p.name === message.author)?.id);
|
|
190
|
-
if (index !== undefined && playerColors[index]) {
|
|
191
|
-
author.style.backgroundColor = players.find((p) => p.id === index)?.color || playerColors[index];
|
|
192
|
-
author.style.color = author.style.backgroundColor === 'brown' ? '#fff' : '#111';
|
|
112
|
+
const dispose = [
|
|
113
|
+
detach,
|
|
114
|
+
chat.subscribe(updateShortcut),
|
|
115
|
+
emitter.on('state', (state) => {
|
|
116
|
+
players = (state?.players || []).map((player: any, index: number) => ({ ...player, id: index }));
|
|
117
|
+
view.refresh();
|
|
118
|
+
}),
|
|
119
|
+
emitter.on('gamelog', ({ data }: any) => {
|
|
120
|
+
if (data?.state) {
|
|
121
|
+
players = (data.state.players || []).map((player: any, index: number) => ({ ...player, id: index }));
|
|
122
|
+
view.refresh();
|
|
193
123
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
list.append(row);
|
|
206
|
-
}
|
|
207
|
-
if (following) {
|
|
208
|
-
list.scrollTop = list.scrollHeight;
|
|
209
|
-
}
|
|
210
|
-
updateShortcut();
|
|
211
|
-
read();
|
|
212
|
-
}
|
|
213
|
-
emitter.on('chat:messages', (data: ChatMessage[]) => {
|
|
214
|
-
messages = data || [];
|
|
215
|
-
unread.clear();
|
|
216
|
-
render();
|
|
217
|
-
});
|
|
218
|
-
emitter.on('chat:appended', (data: ChatMessage[]) => {
|
|
219
|
-
for (const message of data || []) {
|
|
220
|
-
if (!message._id || !messages.some((m) => m._id === message._id)) {
|
|
221
|
-
messages.push(message);
|
|
222
|
-
const own = message.playerIndex !== undefined && message.playerIndex === localPlayer;
|
|
223
|
-
if (message._id && !own && message.author !== 'You') unread.add(message._id);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
render();
|
|
227
|
-
});
|
|
228
|
-
emitter.on('chat:updated', (data: ChatMessage[]) => {
|
|
229
|
-
for (const message of data || []) {
|
|
230
|
-
const index = messages.findIndex((m) => m._id === message._id);
|
|
231
|
-
if (index >= 0) {
|
|
232
|
-
messages[index] = message;
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
render();
|
|
236
|
-
});
|
|
237
|
-
emitter.on('chat:deleted', (ids: string[]) => {
|
|
238
|
-
messages = messages.filter((m) => !ids.includes(m._id || ''));
|
|
239
|
-
ids.forEach((id) => unread.delete(id));
|
|
240
|
-
render();
|
|
241
|
-
});
|
|
242
|
-
emitter.on('chat:disabled', (value: boolean) => {
|
|
243
|
-
disabled = value;
|
|
244
|
-
status.textContent = value ? 'Chat disabled' : reason;
|
|
245
|
-
controls();
|
|
246
|
-
});
|
|
247
|
-
emitter.on('chat:state', (value: { canSend: boolean; reason?: string }) => {
|
|
248
|
-
canSend = value.canSend;
|
|
249
|
-
reason = canSend
|
|
250
|
-
? ''
|
|
251
|
-
: (
|
|
252
|
-
{
|
|
253
|
-
'not-logged-in': 'Sign in to chat',
|
|
254
|
-
'not-confirmed': 'Confirm your account to chat',
|
|
255
|
-
'not-a-player': 'Only players can send messages',
|
|
256
|
-
'chat-disabled': 'Chat disabled',
|
|
257
|
-
'no-game': 'Chat will be available when the game starts',
|
|
258
|
-
} as Record<string, string>
|
|
259
|
-
)[value.reason || ''] || 'Chat is read-only';
|
|
260
|
-
status.textContent = reason;
|
|
261
|
-
controls();
|
|
262
|
-
});
|
|
263
|
-
emitter.on('chat:result', (result: { requestId: string; ok: boolean; error?: string }) => {
|
|
264
|
-
if (pending?.id !== result.requestId) {
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
267
|
-
if (timeout) {
|
|
268
|
-
clearTimeout(timeout);
|
|
269
|
-
}
|
|
270
|
-
if (result.ok && input.value === pending.text) {
|
|
271
|
-
input.value = '';
|
|
272
|
-
}
|
|
273
|
-
status.textContent = result.ok ? '' : result.error || 'Message could not be sent. Your draft is kept.';
|
|
274
|
-
pending = undefined;
|
|
275
|
-
controls();
|
|
276
|
-
});
|
|
277
|
-
function sendMessage(): void {
|
|
278
|
-
if (button.disabled) {
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
pending = { id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, text: input.value };
|
|
282
|
-
controls();
|
|
283
|
-
status.textContent = 'Sending…';
|
|
284
|
-
const sending = pending;
|
|
285
|
-
timeout = setTimeout(() => {
|
|
286
|
-
if (pending) {
|
|
287
|
-
pending = undefined;
|
|
288
|
-
status.textContent = 'No confirmation received. Check the conversation before sending again.';
|
|
289
|
-
controls();
|
|
290
|
-
}
|
|
291
|
-
}, 20000);
|
|
292
|
-
emitter.emit('chat:send', { text: sending.text.trim(), requestId: sending.id });
|
|
293
|
-
}
|
|
294
|
-
button.onclick = sendMessage;
|
|
295
|
-
input.oninput = controls;
|
|
296
|
-
input.onkeydown = (event) => {
|
|
297
|
-
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
|
298
|
-
event.preventDefault();
|
|
299
|
-
event.stopPropagation();
|
|
300
|
-
sendMessage();
|
|
301
|
-
}
|
|
124
|
+
}),
|
|
125
|
+
emitter.on('player', (player) => {
|
|
126
|
+
localPlayer = player?.index;
|
|
127
|
+
view.refresh();
|
|
128
|
+
}),
|
|
129
|
+
];
|
|
130
|
+
chatTab.onclick = () => {
|
|
131
|
+
selectFeed('chat');
|
|
132
|
+
chat.setOpen(true);
|
|
133
|
+
view.refresh();
|
|
302
134
|
};
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
135
|
+
journalTab.onclick = () => {
|
|
136
|
+
selectFeed('journal');
|
|
137
|
+
view.refresh();
|
|
306
138
|
};
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
139
|
+
dispose.push(() => tabs.remove());
|
|
140
|
+
return () => {
|
|
141
|
+
dispose.forEach((cleanup) => cleanup());
|
|
142
|
+
view.destroy();
|
|
143
|
+
shortcut.remove();
|
|
144
|
+
style.remove();
|
|
312
145
|
};
|
|
313
|
-
window.addEventListener('scroll', read, { passive: true });
|
|
314
|
-
window.addEventListener('focus', read);
|
|
315
|
-
window.addEventListener('resize', read);
|
|
316
|
-
const sizing = new ResizeObserver(() => {
|
|
317
|
-
if (following && list.clientHeight) list.scrollTop = list.scrollHeight;
|
|
318
|
-
updateShortcut();
|
|
319
|
-
read();
|
|
320
|
-
});
|
|
321
|
-
sizing.observe(panel);
|
|
322
|
-
sizing.observe(list);
|
|
323
|
-
document.addEventListener('visibilitychange', read);
|
|
324
|
-
status.textContent = reason;
|
|
325
|
-
controls();
|
|
326
146
|
}
|
|
327
147
|
|
|
328
148
|
export function installLocalChat(emitter: ChatEmitter): void {
|
|
149
|
+
let seat = 0;
|
|
150
|
+
let roster: { id: string; name: string; playerIndex: number }[] = [];
|
|
151
|
+
emitter.on('player', (player) => {
|
|
152
|
+
seat = player.index ?? 0;
|
|
153
|
+
});
|
|
154
|
+
emitter.on('state', (state) => {
|
|
155
|
+
roster = (state?.players || []).map((player: any, index: number) => ({
|
|
156
|
+
id: String(index),
|
|
157
|
+
name: player.name || `Player ${index + 1}`,
|
|
158
|
+
playerIndex: index,
|
|
159
|
+
}));
|
|
160
|
+
emitter.emit('chat:state', { canSend: true, mentions: roster.filter((player) => player.playerIndex !== seat) });
|
|
161
|
+
});
|
|
329
162
|
emitter.emit('chat:state', { canSend: true });
|
|
330
163
|
emitter.emit('chat:messages', [
|
|
331
164
|
{
|
|
332
165
|
_id: '000000000000000000000001',
|
|
166
|
+
type: 'system',
|
|
333
167
|
author: 'Playtest',
|
|
334
168
|
text: 'Local chat preview. Messages stay in this browser.',
|
|
335
169
|
createdAt: new Date().toISOString(),
|
|
@@ -338,7 +172,15 @@ export function installLocalChat(emitter: ChatEmitter): void {
|
|
|
338
172
|
let next = 2;
|
|
339
173
|
emitter.on('chat:send', ({ text, requestId }) => {
|
|
340
174
|
emitter.emit('chat:appended', [
|
|
341
|
-
{
|
|
175
|
+
{
|
|
176
|
+
_id: (next++).toString(16).padStart(24, '0'),
|
|
177
|
+
type: 'text',
|
|
178
|
+
author: 'You',
|
|
179
|
+
playerIndex: seat,
|
|
180
|
+
text,
|
|
181
|
+
segments: chatSegments(text, new Map(roster.map((player) => [player.name, player.id])), true),
|
|
182
|
+
createdAt: new Date().toISOString(),
|
|
183
|
+
},
|
|
342
184
|
]);
|
|
343
185
|
emitter.emit('chat:result', { requestId, ok: true });
|
|
344
186
|
});
|
package/src/launch.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createViewer } from '@boardgamers/protocol/viewer';
|
|
1
2
|
import { EventEmitter } from 'events';
|
|
2
3
|
import type { GameState, Move } from 'powergrid-engine';
|
|
3
4
|
import Vue from 'vue';
|
|
@@ -7,7 +8,14 @@ import { installActionSounds } from './sounds';
|
|
|
7
8
|
import type { Preferences } from './types/ui-data';
|
|
8
9
|
import { shouldAdoptLogState } from './util/turn-buffer';
|
|
9
10
|
|
|
11
|
+
let dispose: (() => void) | undefined;
|
|
12
|
+
|
|
10
13
|
function launch(selector: string) {
|
|
14
|
+
const target = document.querySelector(selector);
|
|
15
|
+
if (!target) throw new Error(`Viewer mount point not found: ${selector}`);
|
|
16
|
+
dispose?.();
|
|
17
|
+
const mountPoint = document.createElement('div');
|
|
18
|
+
target.append(mountPoint);
|
|
11
19
|
let params: {
|
|
12
20
|
state: null | GameState;
|
|
13
21
|
player?: number;
|
|
@@ -35,75 +43,71 @@ function launch(selector: string) {
|
|
|
35
43
|
|
|
36
44
|
const app = new Vue({
|
|
37
45
|
render: (h) => h(Game, { props: params }, []),
|
|
38
|
-
}).$mount(
|
|
46
|
+
}).$mount(mountPoint);
|
|
39
47
|
|
|
40
|
-
const item: EventEmitter = new EventEmitter();
|
|
41
48
|
let replaying = false;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
params.emitter.on('move', (moves: Move[]) => item.emit('move', moves));
|
|
46
|
-
params.emitter.on('fetchState', () => item.emit('fetchState'));
|
|
47
|
-
params.emitter.on('addLog', (data: string[]) => item.emit('addLog', data));
|
|
48
|
-
params.emitter.on('replaceLog', (data: string[]) => item.emit('replaceLog', data));
|
|
49
|
-
params.emitter.on('replay:info', (info: { start: number; current: number; end: number }) =>
|
|
50
|
-
item.emit('replay:info', info)
|
|
51
|
-
);
|
|
52
|
-
params.emitter.on('update:preference', (data: { name: string; value: any }) =>
|
|
53
|
-
item.emit('update:preference', data)
|
|
54
|
-
);
|
|
55
|
-
item.addListener('avatars', (data) => {
|
|
56
|
-
params.avatars = data;
|
|
57
|
-
app.$forceUpdate();
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
item.addListener('state', (data) => {
|
|
61
|
-
params.state = data;
|
|
62
|
-
app.$forceUpdate();
|
|
63
|
-
app.$nextTick().then(() => item.emit('ready'));
|
|
64
|
-
});
|
|
65
|
-
item.addListener('state:updated', () => item.emit('fetchState'));
|
|
66
|
-
item.addListener('player', (data) => {
|
|
67
|
-
params.player = data.index;
|
|
68
|
-
app.$forceUpdate();
|
|
69
|
-
});
|
|
70
|
-
item.addListener('preferences', (data) => {
|
|
71
|
-
// Mutate (don't replace) the observable object so the update stays reactive
|
|
72
|
-
Object.assign(params.preferences, data);
|
|
73
|
-
app.$forceUpdate();
|
|
74
|
-
});
|
|
75
|
-
item.addListener('gamelog', (logData) => {
|
|
76
|
-
if (replaying) {
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (shouldAdoptLogState(logData?.data?.state)) {
|
|
81
|
-
// Move responses carry the (possibly tentative) resulting state. Tentative
|
|
82
|
-
// states are never persisted or broadcast by the platform — this is the
|
|
83
|
-
// only way they reach the acting player's viewer. Committed states are
|
|
84
|
-
// refetched; see shouldAdoptLogState for why adopting them is unsafe.
|
|
85
|
-
params.state = logData.data.state;
|
|
49
|
+
const viewer = createViewer<GameState, Move[]>({
|
|
50
|
+
async onState(data) {
|
|
51
|
+
params.state = data;
|
|
86
52
|
app.$forceUpdate();
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
53
|
+
await app.$nextTick();
|
|
54
|
+
},
|
|
55
|
+
onPlayer(data) {
|
|
56
|
+
params.player = data.index;
|
|
57
|
+
app.$forceUpdate();
|
|
58
|
+
},
|
|
59
|
+
onPreferences(data) {
|
|
60
|
+
Object.assign(params.preferences, data);
|
|
61
|
+
app.$forceUpdate();
|
|
62
|
+
},
|
|
63
|
+
onAvatars(data) {
|
|
64
|
+
params.avatars = data;
|
|
65
|
+
app.$forceUpdate();
|
|
66
|
+
},
|
|
67
|
+
onUpdate() {
|
|
68
|
+
if (!replaying) viewer.fetchState();
|
|
69
|
+
},
|
|
70
|
+
async onLog(logData) {
|
|
71
|
+
if (replaying) return;
|
|
72
|
+
const data = logData.data as { state?: GameState } | undefined;
|
|
73
|
+
// Only tentative move responses may replace the local turn buffer.
|
|
74
|
+
if (shouldAdoptLogState(data?.state)) {
|
|
75
|
+
params.state = data!.state!;
|
|
76
|
+
app.$forceUpdate();
|
|
77
|
+
await app.$nextTick();
|
|
78
|
+
} else viewer.fetchState();
|
|
79
|
+
},
|
|
80
|
+
onReplayStart() {
|
|
81
|
+
replaying = true;
|
|
82
|
+
params.emitter.emit('replayStart');
|
|
83
|
+
},
|
|
84
|
+
onReplayTo(index) {
|
|
85
|
+
params.emitter.emit('replayTo', index);
|
|
86
|
+
},
|
|
87
|
+
onReplayEnd() {
|
|
88
|
+
params.emitter.emit('replayEnd');
|
|
89
|
+
replaying = false;
|
|
90
|
+
viewer.fetchState();
|
|
91
|
+
},
|
|
103
92
|
});
|
|
104
|
-
|
|
93
|
+
const item = viewer.emitter;
|
|
94
|
+
params.emitter.on('move', (moves: Move[]) => viewer.move(moves));
|
|
95
|
+
params.emitter.on('fetchState', () => viewer.fetchState());
|
|
96
|
+
params.emitter.on('addLog', (data: string[]) => viewer.addLog(data));
|
|
97
|
+
params.emitter.on('replaceLog', (data: string[]) => viewer.replaceLog(data));
|
|
98
|
+
params.emitter.on('replay:info', (info) => viewer.setReplayInfo(info));
|
|
99
|
+
params.emitter.on('update:preference', ({ name, value }) => viewer.updatePreference(name, value));
|
|
105
100
|
installActionSounds(item);
|
|
106
|
-
mountGameChat(item, app.$el);
|
|
101
|
+
const removeChat = mountGameChat(item, app.$el);
|
|
102
|
+
app.$once('hook:beforeDestroy', () => {
|
|
103
|
+
removeChat();
|
|
104
|
+
viewer.destroy();
|
|
105
|
+
params.emitter.removeAllListeners();
|
|
106
|
+
});
|
|
107
|
+
dispose = () => {
|
|
108
|
+
app.$destroy();
|
|
109
|
+
target.replaceChildren();
|
|
110
|
+
};
|
|
107
111
|
return item;
|
|
108
112
|
}
|
|
109
113
|
|