powergrid-viewer 2.0.13 → 2.1.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/powergrid-viewer.css +1 -1
- package/dist/powergrid-viewer.umd.min.js +4 -4
- package/package.json +2 -2
- package/src/components/Game.vue +187 -69
- package/src/components/InlineLog.vue +108 -0
- package/src/components/boards/Map.vue +34 -4
- package/src/game-chat.ts +309 -0
- package/src/launch.ts +4 -0
- package/src/self-contained.ts +4 -0
- package/src/sounds.ts +187 -0
|
@@ -171,8 +171,8 @@
|
|
|
171
171
|
>
|
|
172
172
|
<title>
|
|
173
173
|
{{ city.name }}<template v-if="isBlocked(city)"> — blocked for this player count (transit
|
|
174
|
-
only)</template ><template v-else>
|
|
175
|
-
space)</template>
|
|
174
|
+
only)</template ><template v-else-if="buildCostLabel(city)">{{ buildCostLabel(city) }}</template
|
|
175
|
+
><template v-else> — build for {{ city.slotCosts[0] }} (+ flat 5 per transited space)</template>
|
|
176
176
|
</title>
|
|
177
177
|
</rect>
|
|
178
178
|
<text
|
|
@@ -411,7 +411,7 @@
|
|
|
411
411
|
pointer-events="all"
|
|
412
412
|
@click="onCityClick(city)"
|
|
413
413
|
>
|
|
414
|
-
<title>{{ city.name }}</title>
|
|
414
|
+
<title>{{ city.name }}{{ buildCostLabel(city) }}</title>
|
|
415
415
|
</circle>
|
|
416
416
|
<!-- Node-weighted tiles (Bremen) get the same treatment at the tile's own
|
|
417
417
|
size — their houses sit in slots inside the diamond. Manhattan's spaces
|
|
@@ -431,7 +431,7 @@
|
|
|
431
431
|
:transform="`rotate(45, ${city.x}, ${city.y})`"
|
|
432
432
|
@click="onCityClick(city)"
|
|
433
433
|
>
|
|
434
|
-
<title>{{ city.name }}</title>
|
|
434
|
+
<title>{{ city.name }}{{ buildCostLabel(city) }}</title>
|
|
435
435
|
</rect>
|
|
436
436
|
</template>
|
|
437
437
|
|
|
@@ -486,6 +486,8 @@ export default class Map extends Vue {
|
|
|
486
486
|
@Prop() connections?: Connection[];
|
|
487
487
|
@Prop() playerColors?: string[];
|
|
488
488
|
@Prop() buildableCities?: string[];
|
|
489
|
+
// #148: what each of those cities would cost this player, from available-moves.
|
|
490
|
+
@Prop() buildPrices?: Record<string, { price?: number; jumpPrice?: number }>;
|
|
489
491
|
// Manhattan: spaces blocked for this player count — transitable but never buildable.
|
|
490
492
|
@Prop() blockedCities?: string[];
|
|
491
493
|
// chooseRegions draft: region names the current player may pick this turn.
|
|
@@ -699,6 +701,34 @@ export default class Map extends Vue {
|
|
|
699
701
|
return !!this.buildableCities!.find((cityName) => cityName == city.name);
|
|
700
702
|
}
|
|
701
703
|
|
|
704
|
+
/**
|
|
705
|
+
* The " — build for N" tail appended to a buildable city's tooltip (#148).
|
|
706
|
+
*
|
|
707
|
+
* coyotte508 asked for this because in Step 3 the cost varies city by city with how
|
|
708
|
+
* many players are already there, and the board alone does not say what YOU would
|
|
709
|
+
* pay. It is a tooltip rather than a printed label on purpose: measured across every
|
|
710
|
+
* recharged map, a Step-3 build turn offers a median 26–52% of the cities in play
|
|
711
|
+
* and up to all of them, so labelling each one would bury the board it is meant to
|
|
712
|
+
* explain — and there is nowhere to put such a label in portrait.
|
|
713
|
+
*
|
|
714
|
+
* Empty while the city is only region-pickable, so the draft tooltip is untouched.
|
|
715
|
+
*/
|
|
716
|
+
buildCostLabel(city: City): string {
|
|
717
|
+
const entry = this.buildPrices && this.buildPrices[city.name];
|
|
718
|
+
if (!entry) return '';
|
|
719
|
+
|
|
720
|
+
if (entry.price != null && entry.jumpPrice != null) {
|
|
721
|
+
return ` — build for ${entry.price}, or ${entry.jumpPrice} using your free jump`;
|
|
722
|
+
}
|
|
723
|
+
if (entry.price != null) {
|
|
724
|
+
return ` — build for ${entry.price}`;
|
|
725
|
+
}
|
|
726
|
+
if (entry.jumpPrice != null) {
|
|
727
|
+
return ` — build for ${entry.jumpPrice}, using your free jump`;
|
|
728
|
+
}
|
|
729
|
+
return '';
|
|
730
|
+
}
|
|
731
|
+
|
|
702
732
|
isBlocked(city: City) {
|
|
703
733
|
return !!this.blockedCities && this.blockedCities.includes(city.name);
|
|
704
734
|
}
|
package/src/game-chat.ts
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { playerColors } from 'powergrid-engine/src/gamestate';
|
|
2
|
+
type ChatMessage = { _id?: string; author?: string; text: string; createdAt?: string; playerIndex?: number };
|
|
3
|
+
type ChatEmitter = {
|
|
4
|
+
on: (event: string, fn: (data: any) => void) => unknown;
|
|
5
|
+
emit: (event: string, data: any) => unknown;
|
|
6
|
+
};
|
|
7
|
+
export function mountGameChat(emitter: ChatEmitter, host: Element): void {
|
|
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>';
|
|
13
|
+
const style = document.createElement('style');
|
|
14
|
+
style.textContent = `
|
|
15
|
+
.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
|
+
.bgs-game-chat summary{cursor:pointer;font-weight:650;border-radius:3px;width:fit-content;padding:2px 4px;margin:-2px -4px}
|
|
17
|
+
.bgs-game-chat summary:hover{color:#126778}
|
|
18
|
+
.bgs-game-chat summary:focus-visible,.bgs-game-chat button:focus-visible{outline:2px solid #247d8c;outline-offset:3px}
|
|
19
|
+
.bgs-game-chat .chat-messages{max-height:250px;overflow:auto;overscroll-behavior:auto;margin:6px 0 8px}
|
|
20
|
+
.bgs-game-chat article{padding:5px 0;border-bottom:1px solid #75818d26;white-space:pre-wrap;overflow-wrap:anywhere}
|
|
21
|
+
.bgs-game-chat article strong{padding:0 3px;font-weight:bold}
|
|
22
|
+
.bgs-game-chat article:last-child{border-bottom:0}
|
|
23
|
+
.bgs-game-chat time{font-size:12px;color:#536e77;margin-left:8px;white-space:nowrap}
|
|
24
|
+
.bgs-game-chat .chat-composer{display:flex;gap:6px;align-items:center;margin:0}
|
|
25
|
+
.bgs-game-chat input{flex:1;min-width:0;box-sizing:border-box;height:30px;background:#f3f5eb;color:#263521;border:1px solid #899997;border-radius:2px;padding:4px 7px;font:14px Arial,sans-serif}
|
|
26
|
+
.bgs-game-chat input::placeholder{color:#637477}
|
|
27
|
+
.bgs-game-chat input:focus{outline:2px solid #527f89;outline-offset:1px}
|
|
28
|
+
.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
|
+
.bgs-game-chat button:hover:not(:disabled){background:#c8d3d0}
|
|
30
|
+
.bgs-game-chat button:disabled{color:#788786;border-color:#b0bcb8;background:#dce3df;cursor:default}
|
|
31
|
+
.bgs-game-chat .chat-status{font-size:12px;margin-top:6px}
|
|
32
|
+
.bgs-game-chat .chat-status:empty{display:none}
|
|
33
|
+
.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}
|
|
34
|
+
.chat-shortcut[hidden]{display:none}
|
|
35
|
+
.chat-shortcut:hover{background:#315966}
|
|
36
|
+
.chat-shortcut:focus-visible{outline:2px solid #fff;outline-offset:2px}
|
|
37
|
+
|
|
38
|
+
`;
|
|
39
|
+
panel.append(style);
|
|
40
|
+
const slot = host.querySelector('.chat-host');
|
|
41
|
+
if (slot) slot.append(panel);
|
|
42
|
+
else host.insertAdjacentElement('afterend', panel);
|
|
43
|
+
const list = panel.querySelector('.chat-messages') as HTMLDivElement;
|
|
44
|
+
const input = panel.querySelector('input') as HTMLInputElement;
|
|
45
|
+
const button = panel.querySelector('button') as HTMLButtonElement;
|
|
46
|
+
const status = panel.querySelector('.chat-status') as HTMLDivElement;
|
|
47
|
+
let messages: ChatMessage[] = [];
|
|
48
|
+
let players: { id: number; name: string; color?: string }[] = [];
|
|
49
|
+
let localPlayer: number | undefined;
|
|
50
|
+
emitter.on('state', (state) => {
|
|
51
|
+
players = state?.players || [];
|
|
52
|
+
render();
|
|
53
|
+
});
|
|
54
|
+
emitter.on('player', (player) => {
|
|
55
|
+
localPlayer = player?.index;
|
|
56
|
+
render();
|
|
57
|
+
});
|
|
58
|
+
let canSend = false;
|
|
59
|
+
let disabled = false;
|
|
60
|
+
let reason = 'Chat is connecting…';
|
|
61
|
+
let pending: { id: string; text: string } | undefined;
|
|
62
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
63
|
+
let readTimer: ReturnType<typeof setTimeout> | undefined;
|
|
64
|
+
let watermark = '';
|
|
65
|
+
let candidate = '';
|
|
66
|
+
let following = true;
|
|
67
|
+
const unread = new Set<string>();
|
|
68
|
+
const summary = panel.querySelector('summary')!;
|
|
69
|
+
const shortcut = document.createElement('button');
|
|
70
|
+
shortcut.type = 'button';
|
|
71
|
+
shortcut.className = 'chat-shortcut';
|
|
72
|
+
shortcut.hidden = true;
|
|
73
|
+
panel.insertAdjacentElement('afterend', shortcut);
|
|
74
|
+
let chatVisible = false;
|
|
75
|
+
function updateShortcut(): void {
|
|
76
|
+
const count = unread.size;
|
|
77
|
+
const label = count ? `Chat · ${count} unread` : 'Chat';
|
|
78
|
+
shortcut.textContent = label;
|
|
79
|
+
shortcut.setAttribute('aria-label', `Open ${label}`);
|
|
80
|
+
summary.textContent = label;
|
|
81
|
+
shortcut.hidden = chatVisible;
|
|
82
|
+
}
|
|
83
|
+
shortcut.onclick = () => {
|
|
84
|
+
panel.open = true;
|
|
85
|
+
requestAnimationFrame(() => {
|
|
86
|
+
const firstUnread = Array.from(list.children).find((row) =>
|
|
87
|
+
unread.has((row as HTMLElement).dataset.id || '')
|
|
88
|
+
);
|
|
89
|
+
if (firstUnread) firstUnread.scrollIntoView({ block: 'center' });
|
|
90
|
+
else panel.scrollIntoView({ block: 'center' });
|
|
91
|
+
summary.focus({ preventScroll: true });
|
|
92
|
+
read();
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
const visibility = new IntersectionObserver(
|
|
96
|
+
(entries) => {
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
if (entry.target === panel) {
|
|
99
|
+
chatVisible =
|
|
100
|
+
entry.isIntersecting &&
|
|
101
|
+
entry.intersectionRect.height >=
|
|
102
|
+
Math.min(panel.open ? 80 : 20, entry.boundingClientRect.height);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
updateShortcut();
|
|
106
|
+
read();
|
|
107
|
+
},
|
|
108
|
+
{ threshold: Array.from({ length: 21 }, (_, i) => i / 20) }
|
|
109
|
+
);
|
|
110
|
+
visibility.observe(panel);
|
|
111
|
+
function controls(): void {
|
|
112
|
+
button.disabled = !canSend || disabled || !!pending || !input.value.trim();
|
|
113
|
+
input.disabled = !canSend || disabled;
|
|
114
|
+
}
|
|
115
|
+
function read(): void {
|
|
116
|
+
if (!panel.open || document.visibilityState !== 'visible' || !document.hasFocus()) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const bounds = list.getBoundingClientRect();
|
|
120
|
+
const visible = Array.from(list.children).filter((el) => {
|
|
121
|
+
const r = el.getBoundingClientRect();
|
|
122
|
+
return r.bottom <= Math.min(bounds.bottom, window.innerHeight) + 1 && r.top >= Math.max(bounds.top, 0);
|
|
123
|
+
});
|
|
124
|
+
for (const row of visible) unread.delete((row as HTMLElement).dataset.id || '');
|
|
125
|
+
updateShortcut();
|
|
126
|
+
const id = (visible[visible.length - 1] as HTMLElement)?.dataset.id || '';
|
|
127
|
+
if (!/^[a-f0-9]{24}$/i.test(id) || id.toLowerCase() <= watermark) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
candidate = candidate > id.toLowerCase() ? candidate : id.toLowerCase();
|
|
131
|
+
if (!readTimer) {
|
|
132
|
+
readTimer = setTimeout(() => {
|
|
133
|
+
readTimer = undefined;
|
|
134
|
+
if (
|
|
135
|
+
panel.open &&
|
|
136
|
+
document.visibilityState === 'visible' &&
|
|
137
|
+
document.hasFocus() &&
|
|
138
|
+
candidate > watermark
|
|
139
|
+
) {
|
|
140
|
+
watermark = candidate;
|
|
141
|
+
emitter.emit('chat:read', { messageId: watermark });
|
|
142
|
+
}
|
|
143
|
+
}, 500);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function render(): void {
|
|
147
|
+
list.textContent = '';
|
|
148
|
+
for (const message of messages) {
|
|
149
|
+
const row = document.createElement('article');
|
|
150
|
+
row.dataset.id = message._id || '';
|
|
151
|
+
const author = document.createElement('strong');
|
|
152
|
+
author.textContent = message.author || 'Game';
|
|
153
|
+
const index =
|
|
154
|
+
message.playerIndex ??
|
|
155
|
+
(message.author === 'You' ? localPlayer : players.find((p) => p.name === message.author)?.id);
|
|
156
|
+
if (index !== undefined && playerColors[index]) {
|
|
157
|
+
author.style.backgroundColor = players.find((p) => p.id === index)?.color || playerColors[index];
|
|
158
|
+
author.style.color = author.style.backgroundColor === 'brown' ? '#fff' : '#111';
|
|
159
|
+
}
|
|
160
|
+
row.append(author, document.createTextNode(' '), document.createTextNode(message.text));
|
|
161
|
+
if (message.createdAt) {
|
|
162
|
+
const time = document.createElement('time');
|
|
163
|
+
const date = new Date(message.createdAt);
|
|
164
|
+
if (!isNaN(date.getTime())) {
|
|
165
|
+
time.textContent = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
166
|
+
time.title = date.toLocaleString();
|
|
167
|
+
row.append(time);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
list.append(row);
|
|
172
|
+
}
|
|
173
|
+
if (following) {
|
|
174
|
+
list.scrollTop = list.scrollHeight;
|
|
175
|
+
}
|
|
176
|
+
updateShortcut();
|
|
177
|
+
read();
|
|
178
|
+
}
|
|
179
|
+
emitter.on('chat:messages', (data: ChatMessage[]) => {
|
|
180
|
+
messages = data || [];
|
|
181
|
+
unread.clear();
|
|
182
|
+
render();
|
|
183
|
+
});
|
|
184
|
+
emitter.on('chat:appended', (data: ChatMessage[]) => {
|
|
185
|
+
for (const message of data || []) {
|
|
186
|
+
if (!message._id || !messages.some((m) => m._id === message._id)) {
|
|
187
|
+
messages.push(message);
|
|
188
|
+
const own = message.playerIndex !== undefined && message.playerIndex === localPlayer;
|
|
189
|
+
if (message._id && !own && message.author !== 'You') unread.add(message._id);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
render();
|
|
193
|
+
});
|
|
194
|
+
emitter.on('chat:updated', (data: ChatMessage[]) => {
|
|
195
|
+
for (const message of data || []) {
|
|
196
|
+
const index = messages.findIndex((m) => m._id === message._id);
|
|
197
|
+
if (index >= 0) {
|
|
198
|
+
messages[index] = message;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
render();
|
|
202
|
+
});
|
|
203
|
+
emitter.on('chat:deleted', (ids: string[]) => {
|
|
204
|
+
messages = messages.filter((m) => !ids.includes(m._id || ''));
|
|
205
|
+
ids.forEach((id) => unread.delete(id));
|
|
206
|
+
render();
|
|
207
|
+
});
|
|
208
|
+
emitter.on('chat:disabled', (value: boolean) => {
|
|
209
|
+
disabled = value;
|
|
210
|
+
status.textContent = value ? 'Chat disabled' : reason;
|
|
211
|
+
controls();
|
|
212
|
+
});
|
|
213
|
+
emitter.on('chat:state', (value: { canSend: boolean; reason?: string }) => {
|
|
214
|
+
canSend = value.canSend;
|
|
215
|
+
reason = canSend
|
|
216
|
+
? ''
|
|
217
|
+
: (
|
|
218
|
+
{
|
|
219
|
+
'not-logged-in': 'Sign in to chat',
|
|
220
|
+
'not-confirmed': 'Confirm your account to chat',
|
|
221
|
+
'not-a-player': 'Only players can send messages',
|
|
222
|
+
'chat-disabled': 'Chat disabled',
|
|
223
|
+
'no-game': 'Chat will be available when the game starts',
|
|
224
|
+
} as Record<string, string>
|
|
225
|
+
)[value.reason || ''] || 'Chat is read-only';
|
|
226
|
+
status.textContent = reason;
|
|
227
|
+
controls();
|
|
228
|
+
});
|
|
229
|
+
emitter.on('chat:result', (result: { requestId: string; ok: boolean; error?: string }) => {
|
|
230
|
+
if (pending?.id !== result.requestId) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (timeout) {
|
|
234
|
+
clearTimeout(timeout);
|
|
235
|
+
}
|
|
236
|
+
if (result.ok && input.value === pending.text) {
|
|
237
|
+
input.value = '';
|
|
238
|
+
}
|
|
239
|
+
status.textContent = result.ok ? '' : result.error || 'Message could not be sent. Your draft is kept.';
|
|
240
|
+
pending = undefined;
|
|
241
|
+
controls();
|
|
242
|
+
});
|
|
243
|
+
function sendMessage(): void {
|
|
244
|
+
if (button.disabled) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
pending = { id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, text: input.value };
|
|
248
|
+
controls();
|
|
249
|
+
status.textContent = 'Sending…';
|
|
250
|
+
const sending = pending;
|
|
251
|
+
timeout = setTimeout(() => {
|
|
252
|
+
if (pending) {
|
|
253
|
+
pending = undefined;
|
|
254
|
+
status.textContent = 'No confirmation received. Check the conversation before sending again.';
|
|
255
|
+
controls();
|
|
256
|
+
}
|
|
257
|
+
}, 20000);
|
|
258
|
+
emitter.emit('chat:send', { text: sending.text.trim(), requestId: sending.id });
|
|
259
|
+
}
|
|
260
|
+
button.onclick = sendMessage;
|
|
261
|
+
input.oninput = controls;
|
|
262
|
+
input.onkeydown = (event) => {
|
|
263
|
+
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
|
264
|
+
event.preventDefault();
|
|
265
|
+
event.stopPropagation();
|
|
266
|
+
sendMessage();
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
list.onscroll = () => {
|
|
270
|
+
following = list.scrollHeight - list.scrollTop - list.clientHeight < 32;
|
|
271
|
+
read();
|
|
272
|
+
};
|
|
273
|
+
panel.ontoggle = () => {
|
|
274
|
+
if (panel.open && following) {
|
|
275
|
+
list.scrollTop = list.scrollHeight;
|
|
276
|
+
}
|
|
277
|
+
read();
|
|
278
|
+
};
|
|
279
|
+
window.addEventListener('scroll', read, { passive: true });
|
|
280
|
+
window.addEventListener('focus', read);
|
|
281
|
+
window.addEventListener('resize', read);
|
|
282
|
+
const sizing = new ResizeObserver(() => {
|
|
283
|
+
updateShortcut();
|
|
284
|
+
read();
|
|
285
|
+
});
|
|
286
|
+
sizing.observe(panel);
|
|
287
|
+
document.addEventListener('visibilitychange', read);
|
|
288
|
+
status.textContent = reason;
|
|
289
|
+
controls();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function installLocalChat(emitter: ChatEmitter): void {
|
|
293
|
+
emitter.emit('chat:state', { canSend: true });
|
|
294
|
+
emitter.emit('chat:messages', [
|
|
295
|
+
{
|
|
296
|
+
_id: '000000000000000000000001',
|
|
297
|
+
author: 'Playtest',
|
|
298
|
+
text: 'Local chat preview. Messages stay in this browser.',
|
|
299
|
+
createdAt: new Date().toISOString(),
|
|
300
|
+
},
|
|
301
|
+
]);
|
|
302
|
+
let next = 2;
|
|
303
|
+
emitter.on('chat:send', ({ text, requestId }) => {
|
|
304
|
+
emitter.emit('chat:appended', [
|
|
305
|
+
{ _id: (next++).toString(16).padStart(24, '0'), author: 'You', text, createdAt: new Date().toISOString() },
|
|
306
|
+
]);
|
|
307
|
+
emitter.emit('chat:result', { requestId, ok: true });
|
|
308
|
+
});
|
|
309
|
+
}
|
package/src/launch.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { EventEmitter } from 'events';
|
|
|
2
2
|
import type { GameState, Move } from 'powergrid-engine';
|
|
3
3
|
import Vue from 'vue';
|
|
4
4
|
import Game from './components/Game.vue';
|
|
5
|
+
import { mountGameChat } from './game-chat';
|
|
6
|
+
import { installActionSounds } from './sounds';
|
|
5
7
|
import type { Preferences } from './types/ui-data';
|
|
6
8
|
import { shouldAdoptLogState } from './util/turn-buffer';
|
|
7
9
|
|
|
@@ -100,6 +102,8 @@ function launch(selector: string) {
|
|
|
100
102
|
item.emit('fetchState');
|
|
101
103
|
});
|
|
102
104
|
|
|
105
|
+
installActionSounds(item);
|
|
106
|
+
mountGameChat(item, app.$el);
|
|
103
107
|
return item;
|
|
104
108
|
}
|
|
105
109
|
|
package/src/self-contained.ts
CHANGED
|
@@ -2,7 +2,9 @@ import { cloneDeep } from 'lodash';
|
|
|
2
2
|
import { move as execMove, Move, Phase, setup, stripSecret } from 'powergrid-engine';
|
|
3
3
|
import { moveAI } from 'powergrid-engine/src/engine';
|
|
4
4
|
import type { MapName, Variant } from 'powergrid-engine/src/gamestate';
|
|
5
|
+
import { installLocalChat } from './game-chat';
|
|
5
6
|
import launch from './launch';
|
|
7
|
+
import { mountSoundTests } from './sounds';
|
|
6
8
|
|
|
7
9
|
const delayBase = 0;
|
|
8
10
|
|
|
@@ -10,6 +12,8 @@ function launchSelfContained(selector = '#app') {
|
|
|
10
12
|
const strip = true;
|
|
11
13
|
|
|
12
14
|
const emitter = launch(selector);
|
|
15
|
+
mountSoundTests(emitter);
|
|
16
|
+
installLocalChat(emitter);
|
|
13
17
|
|
|
14
18
|
// The sandbox game can be steered from the URL so a layout or rules change can
|
|
15
19
|
// be checked against several maps without editing this file each time, e.g.
|
package/src/sounds.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
type Note = [number, number, number, number, number?];
|
|
2
|
+
type Cue = { label: string; notes: Note[] };
|
|
3
|
+
export const soundCues: Record<string, Cue> = {
|
|
4
|
+
build: {
|
|
5
|
+
label: 'Connect a city',
|
|
6
|
+
notes: [
|
|
7
|
+
[0, 0.1, 0, 0.12, 1500],
|
|
8
|
+
[0.08, 0.25, 95, 0.1, 45],
|
|
9
|
+
[0.18, 0.14, 360, 0.05, 500],
|
|
10
|
+
],
|
|
11
|
+
},
|
|
12
|
+
fuel: {
|
|
13
|
+
label: 'Buy fuel',
|
|
14
|
+
notes: [
|
|
15
|
+
[0, 0.12, 0, 0.1, 1100],
|
|
16
|
+
[0.1, 0.13, 0, 0.08, 600],
|
|
17
|
+
[0.12, 0.16, 120, 0.06, 65],
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
bid: { label: 'Auction bid', notes: [[0, 0.08, 400, 0.07, 550]] },
|
|
21
|
+
plant: {
|
|
22
|
+
label: 'Power plant',
|
|
23
|
+
notes: [
|
|
24
|
+
[0, 0.15, 110, 0.09],
|
|
25
|
+
[0.07, 0.23, 220, 0.07, 330],
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
power: {
|
|
29
|
+
label: 'Generate electricity',
|
|
30
|
+
notes: [
|
|
31
|
+
[0, 0.45, 60, 0.09, 120],
|
|
32
|
+
[0.05, 0.4, 121, 0.05, 240],
|
|
33
|
+
[0.1, 0.35, 0, 0.04, 1300],
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
let context: AudioContext | undefined;
|
|
38
|
+
let enabled = true;
|
|
39
|
+
export function setSoundEnabled(value: boolean): void {
|
|
40
|
+
enabled = value;
|
|
41
|
+
}
|
|
42
|
+
export function playSound(name: string): void {
|
|
43
|
+
if (!enabled || !soundCues[name] || typeof window === 'undefined') {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (typeof navigator !== 'undefined') {
|
|
47
|
+
const activation = (navigator as Navigator & { userActivation?: { hasBeenActive: boolean } }).userActivation;
|
|
48
|
+
if (activation && !activation.hasBeenActive) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const Audio = window.AudioContext;
|
|
53
|
+
if (!Audio) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
context = context || new Audio();
|
|
57
|
+
const ctx = context;
|
|
58
|
+
void ctx
|
|
59
|
+
.resume()
|
|
60
|
+
.then(() => {
|
|
61
|
+
if (!enabled || ctx.state !== 'running') {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
for (const [offset, duration, frequency, volume, endFrequency] of soundCues[name].notes) {
|
|
65
|
+
const start = ctx.currentTime + offset;
|
|
66
|
+
const gain = ctx.createGain();
|
|
67
|
+
gain.gain.setValueAtTime(0.0001, start);
|
|
68
|
+
gain.gain.exponentialRampToValueAtTime(volume, start + 0.008);
|
|
69
|
+
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
|
|
70
|
+
gain.connect(ctx.destination);
|
|
71
|
+
if (frequency === 0) {
|
|
72
|
+
const buffer = ctx.createBuffer(1, Math.ceil(ctx.sampleRate * duration), ctx.sampleRate);
|
|
73
|
+
const samples = buffer.getChannelData(0);
|
|
74
|
+
for (let i = 0; i < samples.length; i++) {
|
|
75
|
+
samples[i] = Math.random() * 2 - 1;
|
|
76
|
+
}
|
|
77
|
+
const source = ctx.createBufferSource();
|
|
78
|
+
source.buffer = buffer;
|
|
79
|
+
const filter = ctx.createBiquadFilter();
|
|
80
|
+
filter.type = 'lowpass';
|
|
81
|
+
filter.frequency.value = endFrequency || 900;
|
|
82
|
+
source.connect(filter);
|
|
83
|
+
filter.connect(gain);
|
|
84
|
+
source.start(start);
|
|
85
|
+
source.stop(start + duration);
|
|
86
|
+
} else {
|
|
87
|
+
const oscillator = ctx.createOscillator();
|
|
88
|
+
oscillator.type = 'sine';
|
|
89
|
+
oscillator.frequency.setValueAtTime(frequency, start);
|
|
90
|
+
oscillator.frequency.exponentialRampToValueAtTime(endFrequency || frequency, start + duration);
|
|
91
|
+
oscillator.connect(gain);
|
|
92
|
+
oscillator.start(start);
|
|
93
|
+
oscillator.stop(start + duration);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
.catch(() => undefined);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function installActionSounds(emitter: { on: (event: string, fn: (value: any) => void) => unknown }): void {
|
|
101
|
+
let previous: string[] | undefined;
|
|
102
|
+
let replaying = false;
|
|
103
|
+
emitter.on('update:preference', (pref) => {
|
|
104
|
+
if (pref?.name === 'sound') {
|
|
105
|
+
setSoundEnabled(pref.value);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
emitter.on('preferences', (prefs) => {
|
|
109
|
+
if (typeof prefs?.sound === 'boolean') {
|
|
110
|
+
setSoundEnabled(prefs.sound);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
emitter.on('replay:start', () => {
|
|
114
|
+
replaying = true;
|
|
115
|
+
});
|
|
116
|
+
emitter.on('replay:end', () => {
|
|
117
|
+
replaying = false;
|
|
118
|
+
previous = undefined;
|
|
119
|
+
});
|
|
120
|
+
const receiveState = (state: any) => {
|
|
121
|
+
const entries = state?.log || [];
|
|
122
|
+
const next = entries.map((entry: any) => JSON.stringify(entry));
|
|
123
|
+
const extendsHistory =
|
|
124
|
+
previous && previous.length < next.length && previous.every((entry, i) => entry === next[i]);
|
|
125
|
+
const from = previous?.length || 0;
|
|
126
|
+
previous = next;
|
|
127
|
+
if (!extendsHistory || replaying) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const cues = entries
|
|
131
|
+
.slice(from)
|
|
132
|
+
.map((entry: any) => cueForEntry(entry))
|
|
133
|
+
.filter(Boolean);
|
|
134
|
+
// Reconnection can deliver a whole round: play only the most recent event.
|
|
135
|
+
const cue = cues[cues.length - 1];
|
|
136
|
+
if (cue) {
|
|
137
|
+
playSound(cue);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
emitter.on('state', receiveState);
|
|
141
|
+
emitter.on('gamelog', (event) => {
|
|
142
|
+
if (event?.data?.state) {
|
|
143
|
+
receiveState(event.data.state);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function mountSoundTests(emitter: { emit: (event: string, value: any) => unknown }): void {
|
|
149
|
+
const panel = document.createElement('details');
|
|
150
|
+
panel.style.cssText =
|
|
151
|
+
'position:relative;z-index:5;padding:10px 16px;margin:8px;background:#172638;color:#f0f4f8;border:1px solid #56718a;border-radius:8px;font:14px system-ui';
|
|
152
|
+
const summary = document.createElement('summary');
|
|
153
|
+
summary.textContent = 'Playtest tools · sounds';
|
|
154
|
+
panel.append(summary);
|
|
155
|
+
const label = document.createElement('label');
|
|
156
|
+
label.style.margin = '10px';
|
|
157
|
+
const toggle = document.createElement('input');
|
|
158
|
+
toggle.type = 'checkbox';
|
|
159
|
+
toggle.checked = true;
|
|
160
|
+
toggle.onchange = () => {
|
|
161
|
+
setSoundEnabled(toggle.checked);
|
|
162
|
+
emitter.emit('preferences', { sound: toggle.checked });
|
|
163
|
+
};
|
|
164
|
+
label.append(toggle, ' Game sounds');
|
|
165
|
+
panel.append(label);
|
|
166
|
+
for (const [name, cue] of Object.entries(soundCues)) {
|
|
167
|
+
const button = document.createElement('button');
|
|
168
|
+
button.type = 'button';
|
|
169
|
+
button.textContent = cue.label;
|
|
170
|
+
button.style.cssText =
|
|
171
|
+
'margin:8px 4px;padding:7px 12px;color:#f0f4f8;background:#294663;border:1px solid #7391ad;border-radius:5px;cursor:pointer';
|
|
172
|
+
button.onclick = () => playSound(name);
|
|
173
|
+
panel.append(button);
|
|
174
|
+
}
|
|
175
|
+
document.body.prepend(panel);
|
|
176
|
+
}
|
|
177
|
+
function cueForEntry(entry: any): string | undefined {
|
|
178
|
+
return (
|
|
179
|
+
{
|
|
180
|
+
Build: 'build',
|
|
181
|
+
BuyResource: 'fuel',
|
|
182
|
+
Bid: 'bid',
|
|
183
|
+
ChoosePowerPlant: 'plant',
|
|
184
|
+
UsePowerPlant: 'power',
|
|
185
|
+
} as Record<string, string>
|
|
186
|
+
)[entry.move?.name];
|
|
187
|
+
}
|