powergrid-viewer 2.1.5 → 2.1.7
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/README.md +22 -0
- package/dist/powergrid-viewer.css +1 -1
- package/dist/powergrid-viewer.umd.min.js +6 -6
- package/package.json +11 -4
- package/src/components/Game.vue +382 -21
- package/src/components/RoundPlanner.vue +120 -0
- package/src/components/boards/CityCount.vue +89 -23
- package/src/components/boards/Map.vue +19 -0
- package/src/components/pieces/House.vue +7 -1
- package/src/game-chat.ts +32 -1
- package/src/launch.ts +8 -1
- package/src/self-contained.ts +8 -10
- package/src/sounds.ts +1 -1
- package/src/tutorial/lessons.ts +646 -0
- package/src/tutorial/mount.ts +158 -0
- package/src/tutorial/tutorial.css +135 -0
- package/src/util/round-plan.ts +12 -0
- package/src/wrapper.ts +14 -3
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section class="round-planner" aria-label="Round planning">
|
|
3
|
+
<template v-if="plan">
|
|
4
|
+
<div class="planner-strip">
|
|
5
|
+
<div v-if="queueable || hasQueue" class="planner-controls">
|
|
6
|
+
<button
|
|
7
|
+
v-if="queueable"
|
|
8
|
+
class="primary"
|
|
9
|
+
:disabled="pending"
|
|
10
|
+
:title="
|
|
11
|
+
playsNow
|
|
12
|
+
? 'Play this phase and queue the remaining moves'
|
|
13
|
+
: 'Queue these moves for your turn'
|
|
14
|
+
"
|
|
15
|
+
@click="$emit('queue')"
|
|
16
|
+
>
|
|
17
|
+
Validate
|
|
18
|
+
</button>
|
|
19
|
+
<button v-if="hasQueue" :disabled="pending" @click="$emit('cancel')">Cancel premoves</button>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
<p v-if="!queueable && queueHint" class="muted queue-hint">{{ queueHint }}</p>
|
|
23
|
+
<p v-if="liveChanged" class="notice">
|
|
24
|
+
The live game changed. Costs and fuel will be checked again before queueing.
|
|
25
|
+
</p>
|
|
26
|
+
<p v-if="plan.finalScoring" class="muted">
|
|
27
|
+
The end-game threshold is reached. Final scoring uses the most cities you can power with your fuel;
|
|
28
|
+
there is no normal income payment.
|
|
29
|
+
</p>
|
|
30
|
+
</template>
|
|
31
|
+
<p v-if="queue && queue.notice" role="status" class="notice">{{ queue.notice }}</p>
|
|
32
|
+
<p v-if="pending" role="status">Saving premoves…</p>
|
|
33
|
+
<p v-if="error" role="status" class="notice">{{ error }}</p>
|
|
34
|
+
</section>
|
|
35
|
+
</template>
|
|
36
|
+
<script lang="ts">
|
|
37
|
+
import Vue from 'vue';
|
|
38
|
+
import type { RoundPlan } from 'powergrid-engine/src/planning';
|
|
39
|
+
import type { PremovePlan } from 'powergrid-engine/src/premoves';
|
|
40
|
+
export default Vue.extend({
|
|
41
|
+
props: {
|
|
42
|
+
plan: { type: Object as () => RoundPlan | null, default: null },
|
|
43
|
+
queue: { type: Object as () => PremovePlan | undefined, default: undefined },
|
|
44
|
+
liveChanged: Boolean,
|
|
45
|
+
queueable: Boolean,
|
|
46
|
+
playsNow: Boolean,
|
|
47
|
+
pending: Boolean,
|
|
48
|
+
error: String,
|
|
49
|
+
queueHint: String,
|
|
50
|
+
},
|
|
51
|
+
computed: {
|
|
52
|
+
hasQueue(): boolean {
|
|
53
|
+
return !!this.queue?.phases.length;
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
</script>
|
|
58
|
+
<style scoped>
|
|
59
|
+
.round-planner,
|
|
60
|
+
.planner-strip {
|
|
61
|
+
display: contents;
|
|
62
|
+
}
|
|
63
|
+
.planner-controls {
|
|
64
|
+
display: flex;
|
|
65
|
+
align-items: center;
|
|
66
|
+
gap: 10px;
|
|
67
|
+
flex-wrap: wrap;
|
|
68
|
+
padding: 5px 10px 5px 0;
|
|
69
|
+
font: 14px Arial, sans-serif;
|
|
70
|
+
color: #f0f2e9;
|
|
71
|
+
}
|
|
72
|
+
p {
|
|
73
|
+
margin: 6px 0;
|
|
74
|
+
}
|
|
75
|
+
button {
|
|
76
|
+
font: inherit;
|
|
77
|
+
border: 1px solid #68725f;
|
|
78
|
+
border-radius: 3px;
|
|
79
|
+
background: transparent;
|
|
80
|
+
color: inherit;
|
|
81
|
+
padding: 5px 10px;
|
|
82
|
+
cursor: pointer;
|
|
83
|
+
}
|
|
84
|
+
button:hover {
|
|
85
|
+
background: #35412b;
|
|
86
|
+
}
|
|
87
|
+
button:disabled {
|
|
88
|
+
opacity: 0.45;
|
|
89
|
+
cursor: default;
|
|
90
|
+
}
|
|
91
|
+
.primary {
|
|
92
|
+
background: #9acd32;
|
|
93
|
+
color: #182007;
|
|
94
|
+
border-color: #9acd32;
|
|
95
|
+
font-weight: 600;
|
|
96
|
+
}
|
|
97
|
+
.primary:hover {
|
|
98
|
+
background: #b0df52;
|
|
99
|
+
}
|
|
100
|
+
.round-planner > p {
|
|
101
|
+
flex-basis: 100%;
|
|
102
|
+
padding: 0 12px;
|
|
103
|
+
margin: 0 0 8px;
|
|
104
|
+
font: 13px Arial, sans-serif;
|
|
105
|
+
}
|
|
106
|
+
.muted {
|
|
107
|
+
color: #bcc7ad;
|
|
108
|
+
font-size: 13px;
|
|
109
|
+
}
|
|
110
|
+
button:focus-visible {
|
|
111
|
+
outline: 2px solid #d7ed95;
|
|
112
|
+
outline-offset: 2px;
|
|
113
|
+
}
|
|
114
|
+
.queue-hint {
|
|
115
|
+
margin: 7px 0 0;
|
|
116
|
+
}
|
|
117
|
+
.notice {
|
|
118
|
+
color: #efcb88;
|
|
119
|
+
}
|
|
120
|
+
</style>
|
|
@@ -1,40 +1,72 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<g>
|
|
3
|
-
<
|
|
4
|
-
|
|
2
|
+
<g class="city-count-track">
|
|
3
|
+
<g v-for="row in compact ? 2 : 1" :key="'row' + row" :transform="`translate(0, ${(row - 1) * 70})`">
|
|
4
|
+
<rect :width="compact ? 367 : 730" height="68" x="10" y="10" rx="3" fill="goldenrod" />
|
|
5
|
+
<rect :width="compact ? 359 : 722" height="20" x="14" y="56" rx="2" fill="#365343" />
|
|
6
|
+
</g>
|
|
7
|
+
<g
|
|
8
|
+
v-for="(income, count) in paymentTable"
|
|
9
|
+
:key="count"
|
|
10
|
+
:transform="`translate(${cellX(count)}, ${cellY(count)})`"
|
|
11
|
+
>
|
|
12
|
+
<title>
|
|
13
|
+
{{ count }} powered {{ count === 1 ? 'city' : 'cities' }}: ${{ income }} total income, before any map
|
|
14
|
+
penalties. Houses mark cities built.
|
|
15
|
+
</title>
|
|
5
16
|
<rect
|
|
6
|
-
:key="'playerCityCount' + index"
|
|
7
17
|
width="28"
|
|
8
18
|
height="38"
|
|
9
|
-
|
|
10
|
-
y="
|
|
19
|
+
x="13"
|
|
20
|
+
y="14"
|
|
11
21
|
rx="2"
|
|
12
22
|
fill="darkgoldenrod"
|
|
13
|
-
:stroke="
|
|
23
|
+
:stroke="count == citiesToStep2 || count == citiesToEndGame ? '#916a08' : 'darkgoldenrod'"
|
|
14
24
|
stroke-width="2px"
|
|
15
25
|
/>
|
|
16
26
|
<text
|
|
17
|
-
:key="'playerCityCountText' + index"
|
|
18
27
|
text-anchor="middle"
|
|
19
28
|
style="font-size: 24px; font-family: monospace"
|
|
20
29
|
letter-spacing="-2"
|
|
21
|
-
|
|
22
|
-
y="
|
|
30
|
+
x="26"
|
|
31
|
+
y="27"
|
|
23
32
|
fill="gold"
|
|
24
33
|
>
|
|
25
|
-
{{
|
|
34
|
+
{{ count }}
|
|
26
35
|
</text>
|
|
27
|
-
|
|
28
|
-
|
|
36
|
+
<line v-if="count % (compact ? 11 : 22) > 0" x1="10.5" x2="10.5" y1="61" y2="71" stroke="#607765" />
|
|
37
|
+
<text
|
|
38
|
+
class="city-income"
|
|
39
|
+
x="27"
|
|
40
|
+
y="66"
|
|
41
|
+
text-anchor="middle"
|
|
42
|
+
style="font-size: 15px; dominant-baseline: central"
|
|
43
|
+
fill="#fff3cf"
|
|
44
|
+
>
|
|
45
|
+
{{ income }}
|
|
46
|
+
</text>
|
|
47
|
+
</g>
|
|
48
|
+
<!-- Keep row placement synchronous so the portrait layout measures the new track bounds. -->
|
|
49
|
+
<g
|
|
50
|
+
v-for="house in houses"
|
|
51
|
+
:key="house.id"
|
|
52
|
+
:transform="`translate(${cellX(house.cityCount)}, ${cellY(house.cityCount)})`"
|
|
53
|
+
>
|
|
29
54
|
<House
|
|
30
|
-
:key="house.id"
|
|
31
55
|
:pieceId="house.id"
|
|
32
56
|
:targetState="{ x: house.x, y: house.y }"
|
|
33
57
|
:owner="house.owner"
|
|
34
58
|
:ownerName="house.ownerName"
|
|
35
59
|
:color="house.color"
|
|
60
|
+
:scale="0.03"
|
|
36
61
|
/>
|
|
37
|
-
</
|
|
62
|
+
</g>
|
|
63
|
+
<g class="city-income-legend" :transform="`translate(0, ${compact ? 157 : 87})`">
|
|
64
|
+
<circle cx="21" cy="0" r="7" fill="#365343" />
|
|
65
|
+
<text x="21" text-anchor="middle" font-size="11" fill="#fff3cf">$</text>
|
|
66
|
+
<text x="33" y="0" style="font-size: 13px; dominant-baseline: central" fill="#29432e">
|
|
67
|
+
Total income for powered cities
|
|
68
|
+
</text>
|
|
69
|
+
</g>
|
|
38
70
|
</g>
|
|
39
71
|
</template>
|
|
40
72
|
|
|
@@ -44,6 +76,15 @@ import { Vue, Component, Prop } from 'vue-property-decorator';
|
|
|
44
76
|
import { House } from '../pieces';
|
|
45
77
|
import { Piece } from '../../types/ui-data';
|
|
46
78
|
|
|
79
|
+
// The House piece at the 0.03 scale this track draws it, rounded to whole units: the
|
|
80
|
+
// path spans 3-396 x 4-456, so 12 x 14 on the board. Used as the grid pitch below.
|
|
81
|
+
const HOUSE_WIDTH = 12;
|
|
82
|
+
const HOUSE_HEIGHT = 14;
|
|
83
|
+
// The cell rect the template draws at x 13 y 14, 28 x 38 — houses are placed against
|
|
84
|
+
// its bottom edge, clear of the income strip that starts 4 units below it.
|
|
85
|
+
const CELL_CENTRE_X = 27;
|
|
86
|
+
const CELL_BOTTOM = 52;
|
|
87
|
+
|
|
47
88
|
@Component({
|
|
48
89
|
components: {
|
|
49
90
|
House
|
|
@@ -53,8 +94,18 @@ export default class CityCount extends Vue {
|
|
|
53
94
|
@Prop() citiesToStep2?: number;
|
|
54
95
|
@Prop() citiesToEndGame?: number;
|
|
55
96
|
@Prop() playerColors?: string[];
|
|
97
|
+
@Prop({ required: true }) paymentTable!: number[];
|
|
98
|
+
@Prop({ default: false }) compact!: boolean;
|
|
99
|
+
|
|
100
|
+
houses: (Piece & { cityCount: number })[] = [];
|
|
56
101
|
|
|
57
|
-
|
|
102
|
+
cellX(count: number) {
|
|
103
|
+
return 33 * (this.compact ? count % 11 : count);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
cellY(count: number) {
|
|
107
|
+
return this.compact ? Math.floor(count / 11) * 70 : 0;
|
|
108
|
+
}
|
|
58
109
|
|
|
59
110
|
createPieces(gameState: GameState) {
|
|
60
111
|
this.houses = [];
|
|
@@ -63,18 +114,33 @@ export default class CityCount extends Vue {
|
|
|
63
114
|
|
|
64
115
|
gameState.players.forEach((player, pi) => adjustCityCount[player.cities.length].push(pi));
|
|
65
116
|
gameState.players.forEach((player, pi) => {
|
|
66
|
-
|
|
67
|
-
|
|
117
|
+
// Players sharing a count get a grid, bottom-aligned in the cell so it grows
|
|
118
|
+
// upward from the income strip and leaves the digit above readable. At the
|
|
119
|
+
// old 6px/3px offsets even TWO players on the same number — the ordinary
|
|
120
|
+
// case all game — overlapped by half and read as one smudge.
|
|
121
|
+
//
|
|
122
|
+
// Five or six share go three wide rather than three tall: a third row starts
|
|
123
|
+
// at the top of the cell and hides the number completely, which is every
|
|
124
|
+
// game's opening position. Three columns only have to overlap by a few
|
|
125
|
+
// pixels, and the cells are spaced wider than they are drawn.
|
|
126
|
+
const sharing = adjustCityCount[player.cities.length];
|
|
127
|
+
const seat = sharing.indexOf(pi);
|
|
128
|
+
const cols = sharing.length > 4 ? 3 : 2;
|
|
129
|
+
const pitch = cols === 3 ? 9 : HOUSE_WIDTH;
|
|
130
|
+
const rows = Math.ceil(sharing.length / cols);
|
|
68
131
|
|
|
69
132
|
this.houses.push({
|
|
70
133
|
id: pi + '_cityCount',
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
134
|
+
cityCount: player.cities.length,
|
|
135
|
+
// CELL_CENTRE_X/BOTTOM are the darkgoldenrod cell drawn in the template.
|
|
136
|
+
x:
|
|
137
|
+
sharing.length == 1
|
|
138
|
+
? CELL_CENTRE_X - HOUSE_WIDTH / 2
|
|
139
|
+
: CELL_CENTRE_X - ((cols - 1) * pitch + HOUSE_WIDTH) / 2 + (seat % cols) * pitch,
|
|
140
|
+
y: CELL_BOTTOM - rows * HOUSE_HEIGHT + Math.floor(seat / cols) * HOUSE_HEIGHT,
|
|
76
141
|
color: this.playerColors![pi],
|
|
77
142
|
owner: pi,
|
|
143
|
+
ownerName: player.name,
|
|
78
144
|
});
|
|
79
145
|
});
|
|
80
146
|
}
|
|
@@ -193,6 +193,7 @@
|
|
|
193
193
|
<circle
|
|
194
194
|
v-if="city.connectionCost == null"
|
|
195
195
|
:key="city.name + '_circle2'"
|
|
196
|
+
:data-tutorial="city.name === tutorialCity ? 'destination' : undefined"
|
|
196
197
|
:class="[{ canClick: canBuild(city) || canPickRegion(city) }]"
|
|
197
198
|
r="20"
|
|
198
199
|
:cx="city.x"
|
|
@@ -460,6 +461,21 @@
|
|
|
460
461
|
stroke-linecap="round"
|
|
461
462
|
/>
|
|
462
463
|
</g>
|
|
464
|
+
<g v-if="guidedCity" pointer-events="none">
|
|
465
|
+
<circle :cx="guidedCity.x" :cy="guidedCity.y" r="29" fill="none" stroke="#a96600" stroke-width="4" />
|
|
466
|
+
<text
|
|
467
|
+
:x="guidedCity.x"
|
|
468
|
+
:y="guidedCity.y - 37"
|
|
469
|
+
text-anchor="middle"
|
|
470
|
+
fill="#233745"
|
|
471
|
+
stroke="#fffef5"
|
|
472
|
+
stroke-width="6"
|
|
473
|
+
paint-order="stroke"
|
|
474
|
+
font-size="19"
|
|
475
|
+
font-weight="600"
|
|
476
|
+
v-text="guidedCity.name"
|
|
477
|
+
/>
|
|
478
|
+
</g>
|
|
463
479
|
</g>
|
|
464
480
|
</template>
|
|
465
481
|
|
|
@@ -481,6 +497,8 @@ const CITY_RING_RADIUS = 25;
|
|
|
481
497
|
},
|
|
482
498
|
})
|
|
483
499
|
export default class Map extends Vue {
|
|
500
|
+
@Prop() tutorialCity?: string;
|
|
501
|
+
get guidedCity() { return this.cities?.find((city) => city.name === this.tutorialCity); }
|
|
484
502
|
@Prop() polygons?: Polygon[];
|
|
485
503
|
@Prop() cities?: City[];
|
|
486
504
|
@Prop() connections?: Connection[];
|
|
@@ -553,6 +571,7 @@ export default class Map extends Vue {
|
|
|
553
571
|
y: city.y + offsetY,
|
|
554
572
|
color: this.playerColors![pi],
|
|
555
573
|
owner: pi,
|
|
574
|
+
ownerName: player.name,
|
|
556
575
|
scale: pieceScale,
|
|
557
576
|
});
|
|
558
577
|
});
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
stroke-width="12"
|
|
23
23
|
stroke-miterlimit="10"
|
|
24
24
|
/>
|
|
25
|
-
<title>{{
|
|
25
|
+
<title>{{ houseTitle }}</title>
|
|
26
26
|
</g>
|
|
27
27
|
</template>
|
|
28
28
|
<script lang="ts">
|
|
@@ -50,5 +50,11 @@ export default class House extends Mixins(Piece) {
|
|
|
50
50
|
|
|
51
51
|
@Prop()
|
|
52
52
|
ownerName?: string;
|
|
53
|
+
|
|
54
|
+
get houseTitle() {
|
|
55
|
+
if (this.owner !== undefined && this.owner === this.player) return 'Your House';
|
|
56
|
+
if (this.ownerName) return `${this.ownerName}'s House`;
|
|
57
|
+
return this.owner === undefined ? 'House' : `Player ${this.owner + 1}'s House`;
|
|
58
|
+
}
|
|
53
59
|
}
|
|
54
60
|
</script>
|
package/src/game-chat.ts
CHANGED
|
@@ -69,6 +69,32 @@ export function mountGameChat(emitter: ViewerEmitter<any, any>, host: Element):
|
|
|
69
69
|
shortcutCount.className = 'chat-shortcut-count';
|
|
70
70
|
shortcut.append(shortcutCount);
|
|
71
71
|
(host.querySelector('.chat-tabs-host') || slot).append(shortcut);
|
|
72
|
+
function positionShortcut() {
|
|
73
|
+
shortcut.style.bottom = '';
|
|
74
|
+
if (shortcut.hidden) return;
|
|
75
|
+
const box = shortcut.getBoundingClientRect();
|
|
76
|
+
let top = box.top;
|
|
77
|
+
const controls = Array.from(host.querySelectorAll('#scene g.button'))
|
|
78
|
+
.map((control) => control.getBoundingClientRect())
|
|
79
|
+
.filter((control) => control.left < box.right && control.right > box.left)
|
|
80
|
+
.sort((a, b) => b.top - a.top);
|
|
81
|
+
for (const control of controls) {
|
|
82
|
+
if (control.top < top + box.height && control.bottom > top) top = control.top - box.height - 8;
|
|
83
|
+
}
|
|
84
|
+
if (top !== box.top) shortcut.style.bottom = `${window.innerHeight - top - box.height}px`;
|
|
85
|
+
}
|
|
86
|
+
let positionFrame: number | undefined;
|
|
87
|
+
function scheduleShortcutPosition() {
|
|
88
|
+
if (positionFrame !== undefined) return;
|
|
89
|
+
positionFrame = window.requestAnimationFrame(() => {
|
|
90
|
+
positionFrame = undefined;
|
|
91
|
+
positionShortcut();
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
window.addEventListener('scroll', scheduleShortcutPosition, { passive: true });
|
|
95
|
+
window.addEventListener('resize', scheduleShortcutPosition);
|
|
96
|
+
const layoutObserver = new ResizeObserver(scheduleShortcutPosition);
|
|
97
|
+
layoutObserver.observe(host);
|
|
72
98
|
const feeds = host.querySelector<HTMLElement>('.journal-and-chat');
|
|
73
99
|
const tabs = document.createElement('nav');
|
|
74
100
|
tabs.className = 'game-feed-tabs';
|
|
@@ -114,6 +140,7 @@ export function mountGameChat(emitter: ViewerEmitter<any, any>, host: Element):
|
|
|
114
140
|
shortcut.hidden = chatVisible || !count;
|
|
115
141
|
shortcut.setAttribute('aria-label', `Open ${label}`);
|
|
116
142
|
chatTab.textContent = label;
|
|
143
|
+
positionShortcut();
|
|
117
144
|
}
|
|
118
145
|
shortcut.onclick = () => {
|
|
119
146
|
selectFeed('chat');
|
|
@@ -149,6 +176,10 @@ export function mountGameChat(emitter: ViewerEmitter<any, any>, host: Element):
|
|
|
149
176
|
dispose.push(() => tabs.remove());
|
|
150
177
|
return () => {
|
|
151
178
|
dispose.forEach((cleanup) => cleanup());
|
|
179
|
+
window.removeEventListener('scroll', scheduleShortcutPosition);
|
|
180
|
+
window.removeEventListener('resize', scheduleShortcutPosition);
|
|
181
|
+
layoutObserver.disconnect();
|
|
182
|
+
if (positionFrame !== undefined) window.cancelAnimationFrame(positionFrame);
|
|
152
183
|
view.destroy();
|
|
153
184
|
shortcut.remove();
|
|
154
185
|
style.remove();
|
|
@@ -192,6 +223,6 @@ export function installLocalChat(emitter: ChatEmitter): void {
|
|
|
192
223
|
createdAt: new Date().toISOString(),
|
|
193
224
|
},
|
|
194
225
|
]);
|
|
195
|
-
emitter.emit('chat:result', { requestId, ok: true });
|
|
226
|
+
if (requestId) emitter.emit('chat:result', { requestId, ok: true });
|
|
196
227
|
});
|
|
197
228
|
}
|
package/src/launch.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createViewer } from '@boardgamers/protocol/viewer';
|
|
2
2
|
import { EventEmitter } from 'events';
|
|
3
3
|
import type { GameState, Move } from 'powergrid-engine';
|
|
4
|
+
import type { PremoveCommand } from 'powergrid-engine/src/premoves';
|
|
4
5
|
import Vue from 'vue';
|
|
5
6
|
import Game from './components/Game.vue';
|
|
6
7
|
import { mountGameChat } from './game-chat';
|
|
@@ -10,6 +11,11 @@ import { shouldAdoptLogState } from './util/turn-buffer';
|
|
|
10
11
|
|
|
11
12
|
let dispose: (() => void) | undefined;
|
|
12
13
|
|
|
14
|
+
export function destroyViewer() {
|
|
15
|
+
dispose?.();
|
|
16
|
+
dispose = undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
13
19
|
function launch(selector: string) {
|
|
14
20
|
const target = document.querySelector(selector);
|
|
15
21
|
if (!target) throw new Error(`Viewer mount point not found: ${selector}`);
|
|
@@ -46,7 +52,7 @@ function launch(selector: string) {
|
|
|
46
52
|
}).$mount(mountPoint);
|
|
47
53
|
|
|
48
54
|
let replaying = false;
|
|
49
|
-
const viewer = createViewer<GameState, Move[]>({
|
|
55
|
+
const viewer = createViewer<GameState, Move[] | PremoveCommand>({
|
|
50
56
|
async onState(data) {
|
|
51
57
|
params.state = data;
|
|
52
58
|
app.$forceUpdate();
|
|
@@ -92,6 +98,7 @@ function launch(selector: string) {
|
|
|
92
98
|
});
|
|
93
99
|
const item = viewer.emitter;
|
|
94
100
|
params.emitter.on('move', (moves: Move[]) => viewer.move(moves));
|
|
101
|
+
params.emitter.on('premoves', (command: PremoveCommand) => viewer.move(command));
|
|
95
102
|
params.emitter.on('fetchState', () => viewer.fetchState());
|
|
96
103
|
params.emitter.on('addLog', (data: string[]) => viewer.addLog(data));
|
|
97
104
|
params.emitter.on('replaceLog', (data: string[]) => viewer.replaceLog(data));
|
package/src/self-contained.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { cloneDeep } from 'lodash';
|
|
2
|
-
import {
|
|
3
|
-
import { moveAI } from 'powergrid-engine/src/engine';
|
|
2
|
+
import { Move, Phase, setup, stripSecret } from 'powergrid-engine';
|
|
4
3
|
import type { MapName, Variant } from 'powergrid-engine/src/gamestate';
|
|
4
|
+
import type { PremoveCommand } from 'powergrid-engine/src/premoves';
|
|
5
|
+
import { move as wrapperMove, moveAI as wrapperAI } from 'powergrid-engine/wrapper';
|
|
5
6
|
import { installLocalChat } from './game-chat';
|
|
6
7
|
import launch from './launch';
|
|
7
8
|
import { mountSoundTests } from './sounds';
|
|
@@ -60,16 +61,13 @@ function launchSelfContained(selector = '#app') {
|
|
|
60
61
|
if (player.id != playerIndex) player.isAI = true;
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
emitter.on('move', async (moves: Move |
|
|
64
|
-
setTimeout(() => {
|
|
64
|
+
emitter.on('move', async (moves: Move[] | PremoveCommand) => {
|
|
65
|
+
setTimeout(async () => {
|
|
65
66
|
console.log('moves received', moves);
|
|
66
67
|
|
|
67
68
|
// Mimic the platform: replay the whole turn buffer from the last committed
|
|
68
69
|
// state; only keep (persist) the result once the turn is committed.
|
|
69
|
-
|
|
70
|
-
for (const move of Array.isArray(moves) ? moves : [moves]) {
|
|
71
|
-
newState = execMove(newState, move, playerIndex);
|
|
72
|
-
}
|
|
70
|
+
const newState = await wrapperMove(cloneDeep(gameState), moves, playerIndex);
|
|
73
71
|
console.log('new game state', newState);
|
|
74
72
|
|
|
75
73
|
if (newState.newTurn === false) {
|
|
@@ -84,7 +82,7 @@ function launchSelfContained(selector = '#app') {
|
|
|
84
82
|
let delay = delayBase;
|
|
85
83
|
const moveAIAux = () => {
|
|
86
84
|
if (gameState.players.some((pl) => pl.isAI && pl.availableMoves)) {
|
|
87
|
-
gameState =
|
|
85
|
+
gameState = wrapperAI(
|
|
88
86
|
gameState,
|
|
89
87
|
gameState.players.findIndex((pl) => pl.isAI && pl.availableMoves)
|
|
90
88
|
);
|
|
@@ -114,7 +112,7 @@ function launchSelfContained(selector = '#app') {
|
|
|
114
112
|
|
|
115
113
|
let delay = delayBase;
|
|
116
114
|
while (gameState.players.some((pl) => pl.isAI && pl.availableMoves)) {
|
|
117
|
-
gameState =
|
|
115
|
+
gameState = wrapperAI(
|
|
118
116
|
gameState,
|
|
119
117
|
gameState.players.findIndex((pl) => pl.isAI && pl.availableMoves)
|
|
120
118
|
);
|
package/src/sounds.ts
CHANGED
|
@@ -102,7 +102,7 @@ export function installActionSounds(emitter: Pick<ViewerEmitter<any, any>, 'on'>
|
|
|
102
102
|
let previous: string[] | undefined;
|
|
103
103
|
let replaying = false;
|
|
104
104
|
emitter.on('update:preference', (pref) => {
|
|
105
|
-
if (pref?.name === 'sound') {
|
|
105
|
+
if (pref?.name === 'sound' && typeof pref.value === 'boolean') {
|
|
106
106
|
setSoundEnabled(pref.value);
|
|
107
107
|
}
|
|
108
108
|
});
|