powergrid-viewer 2.1.4 → 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.
- package/README.md +22 -0
- package/dist/powergrid-viewer.css +1 -1
- package/dist/powergrid-viewer.umd.min.js +6 -6
- package/package.json +10 -3
- package/src/components/Game.vue +59 -16
- 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 +5 -0
- 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/wrapper.ts +14 -3
- package/tsconfig.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "powergrid-viewer",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.6",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/boardgamers/powergrid.git",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"url": "https://github.com/boardgamers/powergrid/issues"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@boardgamers/protocol": "0.
|
|
14
|
+
"@boardgamers/protocol": "0.3.3",
|
|
15
15
|
"core-js": "^3.6.5",
|
|
16
16
|
"lodash": "^4.17.19",
|
|
17
17
|
"regenerator-runtime": "^0.13.7",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"vue": "^2.6.11",
|
|
20
20
|
"vue-class-component": "^7.2.3",
|
|
21
21
|
"vue-property-decorator": "^9.1.2",
|
|
22
|
+
"zod": "4.6.4",
|
|
22
23
|
"powergrid-engine": "2.0.6"
|
|
23
24
|
},
|
|
24
25
|
"devDependencies": {
|
|
@@ -27,6 +28,8 @@
|
|
|
27
28
|
"@types/events": "^3.0.0",
|
|
28
29
|
"@types/lodash": "^4.14.154",
|
|
29
30
|
"@types/mocha": "^5.2.4",
|
|
31
|
+
"@types/node": "15.3.0",
|
|
32
|
+
"@types/webpack-env": "1.18.8",
|
|
30
33
|
"@typescript-eslint/parser": "^4.26.1",
|
|
31
34
|
"@vue/cli-plugin-babel": "~4.4.0",
|
|
32
35
|
"@vue/cli-plugin-typescript": "~4.4.0",
|
|
@@ -56,6 +59,10 @@
|
|
|
56
59
|
"build": "set NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service build",
|
|
57
60
|
"test:unit": "vue-cli-service test:unit",
|
|
58
61
|
"package": "vue-cli-service build --target lib --name powergrid-viewer src/wrapper.ts",
|
|
59
|
-
"test:browser": "node scripts/protocol-smoke.mjs"
|
|
62
|
+
"test:browser": "node scripts/protocol-smoke.mjs",
|
|
63
|
+
"test:tutorial": "node --test tests/tutorial/lessons.cjs",
|
|
64
|
+
"test:tutorial:browser": "node scripts/tutorial-smoke.mjs",
|
|
65
|
+
"preview:tutorial": "node scripts/tutorial-preview.mjs",
|
|
66
|
+
"tutorial:manifest": "node scripts/tutorial-manifest.cjs"
|
|
60
67
|
}
|
|
61
68
|
}
|
package/src/components/Game.vue
CHANGED
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
ref="cityCount"
|
|
27
27
|
:transform="`translate(${G.map.cityCountPosition[0]}, ${G.map.cityCountPosition[1]})`"
|
|
28
28
|
:playerColors="playerColors"
|
|
29
|
+
:paymentTable="G.paymentTable"
|
|
30
|
+
:compact="stacked"
|
|
29
31
|
:citiesToEndGame="G.citiesToEndGame"
|
|
30
32
|
:citiesToStep2="G.map.name === 'Manhattan' ? undefined : G.citiesToStep2"
|
|
31
33
|
/>
|
|
@@ -72,7 +74,7 @@
|
|
|
72
74
|
</template>
|
|
73
75
|
</g>
|
|
74
76
|
|
|
75
|
-
<g ref="slotPowerPlantMarket" :transform="slotT('powerPlantMarket')">
|
|
77
|
+
<g ref="slotPowerPlantMarket" data-tutorial="plants" :transform="slotT('powerPlantMarket')">
|
|
76
78
|
<PowerPlantMarket
|
|
77
79
|
ref="powerPlantMarket"
|
|
78
80
|
:transform="`translate(${G.map.powerPlantMarketPosition[0]}, ${G.map.powerPlantMarketPosition[1]})`"
|
|
@@ -89,12 +91,13 @@
|
|
|
89
91
|
/>
|
|
90
92
|
</g>
|
|
91
93
|
|
|
92
|
-
<g ref="slotMap" :transform="slotT('map')">
|
|
94
|
+
<g ref="slotMap" data-tutorial="map" :transform="slotT('map')">
|
|
93
95
|
<Map
|
|
94
96
|
ref="map"
|
|
95
97
|
:transform="mapTransform"
|
|
96
98
|
:playerColors="playerColors"
|
|
97
99
|
:cities="G.map.cities"
|
|
100
|
+
:tutorialCity="tutorialCity"
|
|
98
101
|
:connections="G.map.connections"
|
|
99
102
|
:polygons="G.map.polygons"
|
|
100
103
|
:buildableCities="getBuildableCities()"
|
|
@@ -147,7 +150,7 @@
|
|
|
147
150
|
<ResourceViewButton :showTrack="showResourceTrack" @select="setResourceView($event)" />
|
|
148
151
|
</g>
|
|
149
152
|
|
|
150
|
-
<g ref="slotResources" :transform="slotT('resources')">
|
|
153
|
+
<g ref="slotResources" data-tutorial="resources" :transform="slotT('resources')">
|
|
151
154
|
<!-- On a phone the printed price track is a strip of unreadable
|
|
152
155
|
columns, so the stacked layout defaults to one box per buyable
|
|
153
156
|
source. It is still only a default: the switch above this row
|
|
@@ -356,13 +359,14 @@
|
|
|
356
359
|
|
|
357
360
|
<g
|
|
358
361
|
ref="slotButtons"
|
|
362
|
+
data-tutorial="turn"
|
|
359
363
|
:transform="slotT('buttons') || `translate(${G.map.buttonsPosition[0]}, ${G.map.buttonsPosition[1]})`"
|
|
360
364
|
>
|
|
361
365
|
<PassButton
|
|
362
366
|
transform="translate(15, 15)"
|
|
363
367
|
:enabled="canPass()"
|
|
364
368
|
:highlightButton="canPass() && !preferences.disableHelp"
|
|
365
|
-
:text="canUndo() ? 'Done' : 'Pass'"
|
|
369
|
+
:text="tutorialMove || canUndo() ? 'Done' : 'Pass'"
|
|
366
370
|
@click="checkPass()"
|
|
367
371
|
/>
|
|
368
372
|
<UndoButton
|
|
@@ -388,7 +392,7 @@
|
|
|
388
392
|
/>
|
|
389
393
|
</g>
|
|
390
394
|
|
|
391
|
-
<g ref="slotPlayerBoards" :transform="slotT('playerBoards')">
|
|
395
|
+
<g ref="slotPlayerBoards" data-tutorial="players" :transform="slotT('playerBoards')">
|
|
392
396
|
<template v-for="(playerIndex, i) in adjustedPlayerOrder">
|
|
393
397
|
<PlayerBoard
|
|
394
398
|
:key="'B' + playerIndex"
|
|
@@ -720,16 +724,21 @@
|
|
|
720
724
|
<strong>Payment Table</strong>
|
|
721
725
|
<div class="table-scroll">
|
|
722
726
|
<table class="payment-table">
|
|
727
|
+
<!-- The whole table, so this agrees with the city track on the
|
|
728
|
+
board. Counting to citiesToEndGame stopped one column short
|
|
729
|
+
of the number that ends the game — and a player can finish
|
|
730
|
+
holding more cities than that, having built in the same
|
|
731
|
+
phase as whoever triggered it. -->
|
|
723
732
|
<tr>
|
|
724
|
-
<td><strong>
|
|
725
|
-
<template v-for="
|
|
726
|
-
<td :key="'cities' +
|
|
733
|
+
<td><strong>Powered cities</strong></td>
|
|
734
|
+
<template v-for="(income, count) in G.paymentTable">
|
|
735
|
+
<td :key="'cities' + count">{{ count }}</td>
|
|
727
736
|
</template>
|
|
728
737
|
</tr>
|
|
729
738
|
<tr>
|
|
730
739
|
<td><strong>Payment</strong></td>
|
|
731
|
-
<template v-for="
|
|
732
|
-
<td :key="'payment' +
|
|
740
|
+
<template v-for="(income, count) in G.paymentTable">
|
|
741
|
+
<td :key="'payment' + count">${{ income }}</td>
|
|
733
742
|
</template>
|
|
734
743
|
</tr>
|
|
735
744
|
</table>
|
|
@@ -935,6 +944,15 @@ export default class Game extends Vue {
|
|
|
935
944
|
@Prop()
|
|
936
945
|
emitter!: EventEmitter;
|
|
937
946
|
|
|
947
|
+
@Prop({ default: false })
|
|
948
|
+
interactionDisabled!: boolean;
|
|
949
|
+
|
|
950
|
+
@Prop()
|
|
951
|
+
tutorialMove?: (move: Move) => void;
|
|
952
|
+
|
|
953
|
+
@Prop()
|
|
954
|
+
tutorialCity?: string;
|
|
955
|
+
|
|
938
956
|
@Prop()
|
|
939
957
|
avatars!: string[];
|
|
940
958
|
|
|
@@ -1022,6 +1040,17 @@ export default class Game extends Vue {
|
|
|
1022
1040
|
|
|
1023
1041
|
@Watch('state', { immediate: true })
|
|
1024
1042
|
onStateChanged(state: GameState) {
|
|
1043
|
+
if (this.tutorialMove) {
|
|
1044
|
+
// Tutorial moves are individual engine checkpoints, not server turn buffers.
|
|
1045
|
+
// Rewinds must also clear temporary UI selections from a later step.
|
|
1046
|
+
this.turnMoves = [];
|
|
1047
|
+
this.G = null;
|
|
1048
|
+
this.confirmVisible = this.discardVisible = this.endScoreVisible = false;
|
|
1049
|
+
this.soleBuyerPlant = null;
|
|
1050
|
+
this.totalBid = 0;
|
|
1051
|
+
this.replaceState(state);
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1025
1054
|
if (state && state.newTurn !== false) {
|
|
1026
1055
|
// Committed state. Usually this clears the turn buffer (our own turn came
|
|
1027
1056
|
// back committed), but during the simultaneous Bureaucracy phase it can be
|
|
@@ -1610,7 +1639,12 @@ export default class Game extends Vue {
|
|
|
1610
1639
|
}
|
|
1611
1640
|
|
|
1612
1641
|
sendMove(move) {
|
|
1613
|
-
if (this.paused) {
|
|
1642
|
+
if (this.paused || this.interactionDisabled) {
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
if (this.tutorialMove) {
|
|
1647
|
+
this.tutorialMove(move);
|
|
1614
1648
|
return;
|
|
1615
1649
|
}
|
|
1616
1650
|
|
|
@@ -1622,10 +1656,13 @@ export default class Game extends Vue {
|
|
|
1622
1656
|
: undefined;
|
|
1623
1657
|
const theServerOfferedThisMove = !!offeredWhenClicked && !!offeredWhenClicked[move.name];
|
|
1624
1658
|
|
|
1625
|
-
// Stamp the move ONCE, when it enters the turn buffer
|
|
1626
|
-
//
|
|
1627
|
-
//
|
|
1628
|
-
//
|
|
1659
|
+
// Stamp the move ONCE, when it enters the turn buffer. This client stamp is the
|
|
1660
|
+
// move's identity for reconciling the buffer against the server's echo, and it
|
|
1661
|
+
// drives the clocks in the LOCAL preview below. The engine never reads the system
|
|
1662
|
+
// clock itself, so the stamp travels with the move on every resend of the buffer.
|
|
1663
|
+
// For the authoritative per-player clocks the server re-stamps a single server
|
|
1664
|
+
// clock (`serverTime`) in `wrapper.move`: browser clocks are skewed, and a stamp
|
|
1665
|
+
// shared across players is what keeps a fast clock from inflating its own timer.
|
|
1629
1666
|
const stamped = { ...move, time: Date.now() };
|
|
1630
1667
|
this.turnMoves.push(stamped);
|
|
1631
1668
|
|
|
@@ -1713,6 +1750,7 @@ export default class Game extends Vue {
|
|
|
1713
1750
|
|
|
1714
1751
|
canMove() {
|
|
1715
1752
|
return (
|
|
1753
|
+
!this.interactionDisabled &&
|
|
1716
1754
|
this.player != undefined &&
|
|
1717
1755
|
this.G &&
|
|
1718
1756
|
this.G.currentPlayers.includes(this.player!) &&
|
|
@@ -2622,7 +2660,12 @@ text {
|
|
|
2622
2660
|
.modal {
|
|
2623
2661
|
display: none; /* Hidden by default */
|
|
2624
2662
|
position: fixed; /* Stay in place */
|
|
2625
|
-
z-index
|
|
2663
|
+
// Above the sticky status bar (z-index 10), which is painted by the page behind
|
|
2664
|
+
// the overlay rather than through it. The modal opens 10vh down and the bar is a
|
|
2665
|
+
// fixed 40px, so on a short viewport — a phone held sideways — the bar landed on
|
|
2666
|
+
// the dialog's own header and covered the close button, leaving no way out of the
|
|
2667
|
+
// dialog at all.
|
|
2668
|
+
z-index: 20;
|
|
2626
2669
|
padding-top: 10vh; /* Location of the box */
|
|
2627
2670
|
left: 0;
|
|
2628
2671
|
top: 0;
|
|
@@ -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
|
@@ -10,6 +10,11 @@ import { shouldAdoptLogState } from './util/turn-buffer';
|
|
|
10
10
|
|
|
11
11
|
let dispose: (() => void) | undefined;
|
|
12
12
|
|
|
13
|
+
export function destroyViewer() {
|
|
14
|
+
dispose?.();
|
|
15
|
+
dispose = undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
13
18
|
function launch(selector: string) {
|
|
14
19
|
const target = document.querySelector(selector);
|
|
15
20
|
if (!target) throw new Error(`Viewer mount point not found: ${selector}`);
|
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
|
});
|