powergrid-viewer 2.0.2 → 2.0.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.css +1 -1
- package/dist/powergrid-viewer.umd.min.js +4 -4
- package/package.json +2 -2
- package/src/components/Game.vue +578 -139
- package/src/components/boards/PowerPlantMarket.vue +3 -28
- package/src/components/buttons/LayoutButton.vue +27 -0
- package/src/components/buttons/index.js +2 -1
- package/src/launch.ts +1 -0
- package/src/self-contained.ts +15 -1
- package/src/types/ui-data.ts +3 -0
package/src/components/Game.vue
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div :class="['game', { fitToScreen: preferences.fitToScreen }]">
|
|
2
|
+
<div :class="['game', { fitToScreen: preferences.fitToScreen && !stacked, stacked: stacked }]">
|
|
3
3
|
<div class="statusBar">
|
|
4
4
|
{{ getStatusMessage() }}
|
|
5
5
|
</div>
|
|
@@ -10,62 +10,106 @@
|
|
|
10
10
|
<source src="../audio/notification.mp3" type="audio/mpeg" />
|
|
11
11
|
<source src="../audio/notification.ogg" type="audio/ogg" />
|
|
12
12
|
</audio>
|
|
13
|
-
<svg
|
|
14
|
-
v-if="G"
|
|
15
|
-
id="scene"
|
|
16
|
-
:viewBox="G.map.viewBox ? `0 0 ${G.map.viewBox[0]} ${G.map.viewBox[1]}` : '0 0 1500 800'"
|
|
17
|
-
style="width: 100%"
|
|
18
|
-
>
|
|
13
|
+
<svg v-if="G" id="scene" :class="{ stacked: stacked }" :viewBox="sceneViewBox" style="width: 100%">
|
|
19
14
|
<rect width="100%" height="100%" x="0" y="0" fill="yellowgreen" />
|
|
20
15
|
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
:
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
16
|
+
<g ref="slotPlayerOrder" :transform="slotT('playerOrder')">
|
|
17
|
+
<PlayerOrder
|
|
18
|
+
ref="playerOrder"
|
|
19
|
+
:transform="`translate(${G.map.playerOrderPosition[0]}, ${G.map.playerOrderPosition[1]})`"
|
|
20
|
+
:playerColors="playerColors"
|
|
21
|
+
/>
|
|
22
|
+
</g>
|
|
23
|
+
|
|
24
|
+
<g ref="slotCityCount" :transform="slotT('cityCount')">
|
|
25
|
+
<CityCount
|
|
26
|
+
ref="cityCount"
|
|
27
|
+
:transform="`translate(${G.map.cityCountPosition[0]}, ${G.map.cityCountPosition[1]})`"
|
|
28
|
+
:playerColors="playerColors"
|
|
29
|
+
:citiesToEndGame="G.citiesToEndGame"
|
|
30
|
+
:citiesToStep2="G.map.name === 'Manhattan' ? undefined : G.citiesToStep2"
|
|
31
|
+
/>
|
|
32
|
+
</g>
|
|
33
|
+
|
|
34
|
+
<!-- The power-plant draw pile. Authored at the market's own origin so
|
|
35
|
+
the landscape board is unchanged, but kept as a separate group so
|
|
36
|
+
the portrait layout can move it off the market's row. -->
|
|
37
|
+
<g
|
|
38
|
+
ref="slotPowerPlantDeck"
|
|
39
|
+
:transform="
|
|
40
|
+
slotT('powerPlantDeck') ||
|
|
41
|
+
`translate(${G.map.powerPlantMarketPosition[0]}, ${G.map.powerPlantMarketPosition[1]})`
|
|
42
|
+
"
|
|
43
|
+
>
|
|
44
|
+
<text x="10" y="14" font-weight="600" fill="black">Power Plant Deck:</text>
|
|
45
|
+
<template v-if="G.cardsLeft > 0">
|
|
46
|
+
<rect
|
|
47
|
+
v-for="index in G.cardsLeft"
|
|
48
|
+
:key="'deckcard' + index"
|
|
49
|
+
:x="35 + index / 5"
|
|
50
|
+
:y="38 - index / 10"
|
|
51
|
+
width="60"
|
|
52
|
+
height="40"
|
|
53
|
+
fill="gray"
|
|
54
|
+
stroke="black"
|
|
55
|
+
stroke-width="2"
|
|
56
|
+
rx="4"
|
|
57
|
+
/>
|
|
58
|
+
<rect
|
|
59
|
+
:x="35 + G.cardsLeft / 5"
|
|
60
|
+
:y="38 - G.cardsLeft / 10"
|
|
61
|
+
width="60"
|
|
62
|
+
height="40"
|
|
63
|
+
:fill="G.nextCardWeak ? 'gray' : 'lightgray'"
|
|
64
|
+
stroke="black"
|
|
65
|
+
stroke-width="2"
|
|
66
|
+
rx="4"
|
|
67
|
+
>
|
|
68
|
+
<title>
|
|
69
|
+
{{ G.cardsLeft }} cards left{{ G.nextCardWeak ? ', next is an initial plant' : '' }}
|
|
70
|
+
</title>
|
|
71
|
+
</rect>
|
|
72
|
+
</template>
|
|
73
|
+
</g>
|
|
74
|
+
|
|
75
|
+
<g ref="slotPowerPlantMarket" :transform="slotT('powerPlantMarket')">
|
|
76
|
+
<PowerPlantMarket
|
|
77
|
+
ref="powerPlantMarket"
|
|
78
|
+
:transform="`translate(${G.map.powerPlantMarketPosition[0]}, ${G.map.powerPlantMarketPosition[1]})`"
|
|
79
|
+
:canBid="canBid()"
|
|
80
|
+
:canChoose="canChoose()"
|
|
81
|
+
:chooseablePowerPlants="getChooseablePowerPlants()"
|
|
82
|
+
:cardsLeft="G.cardsLeft"
|
|
83
|
+
:minBid="G.currentBid + 1 || G.minimunBid"
|
|
84
|
+
:maxBid="G.players[player] ? G.players[player].money : 0"
|
|
85
|
+
:nextCardWeak="G.nextCardWeak"
|
|
86
|
+
:plantDiscountActive="G.plantDiscountActive"
|
|
87
|
+
@choosePowerPlant="choosePowerPlant($event)"
|
|
88
|
+
@bid="bid($event)"
|
|
89
|
+
/>
|
|
90
|
+
</g>
|
|
91
|
+
|
|
92
|
+
<g ref="slotMap" :transform="slotT('map')">
|
|
93
|
+
<Map
|
|
94
|
+
ref="map"
|
|
95
|
+
:transform="mapTransform"
|
|
96
|
+
:playerColors="playerColors"
|
|
97
|
+
:cities="G.map.cities"
|
|
98
|
+
:connections="G.map.connections"
|
|
99
|
+
:polygons="G.map.polygons"
|
|
100
|
+
:buildableCities="getBuildableCities()"
|
|
101
|
+
:blockedCities="G.blockedCities"
|
|
102
|
+
:pickableRegions="getPickableRegions()"
|
|
103
|
+
:noUraniumRegions="G.map.noUraniumRegions"
|
|
104
|
+
:devBackdrop="G.map.devBackdrop"
|
|
105
|
+
:mapRotation="G.map.mapRotation || 0"
|
|
106
|
+
@build="build($event)"
|
|
107
|
+
@pickRegion="pickRegion($event)"
|
|
108
|
+
/>
|
|
109
|
+
</g>
|
|
66
110
|
|
|
67
111
|
<!-- Japan: Free Jump indicator -->
|
|
68
|
-
<g v-if="G.map.name === 'Japan'">
|
|
112
|
+
<g v-if="G.map.name === 'Japan'" ref="slotFreeJump" :transform="slotT('freeJump')">
|
|
69
113
|
<text x="330" y="93" font-size="20" font-weight="bold" fill="black">Free Jump:</text>
|
|
70
114
|
<template v-for="(fjPlayer, i) in G.players">
|
|
71
115
|
<g
|
|
@@ -98,27 +142,35 @@
|
|
|
98
142
|
</template>
|
|
99
143
|
</g>
|
|
100
144
|
|
|
101
|
-
<
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
145
|
+
<g ref="slotResources" :transform="slotT('resources')">
|
|
146
|
+
<Resources
|
|
147
|
+
ref="resources"
|
|
148
|
+
:transform="`translate(${G.map.supplyPosition[0]}, ${G.map.supplyPosition[1]})`"
|
|
149
|
+
:isUsaRecharged="G.options.variant == 'recharged' && G.map.name == 'USA'"
|
|
150
|
+
:isMiddleEast="G.map.name == 'Middle East'"
|
|
151
|
+
:isIndiaResourceMarket="G.map.name == 'India' && G.coalPrices && G.garbagePrices && G.uraniumPrices"
|
|
152
|
+
:availableSurplusOil="
|
|
153
|
+
G.map.name == 'Middle East'
|
|
154
|
+
? Math.max(G.oilMarket - G.oilPrices.filter((p) => p > 1).length, 0)
|
|
155
|
+
: 0
|
|
156
|
+
"
|
|
157
|
+
:buyableResources="buyableResources()"
|
|
158
|
+
:coalStorage="G.coalStorage"
|
|
159
|
+
:resourceResupply="getResourceResupply()"
|
|
160
|
+
:resourceResupplyNorth="getResourceResupplyNorth()"
|
|
161
|
+
@buyResource="buyResource($event)"
|
|
162
|
+
/>
|
|
163
|
+
</g>
|
|
116
164
|
|
|
117
165
|
<!-- Australia: uranium-mine selling table. Six price rows $7 (top) →
|
|
118
166
|
$2 (bottom), two token slots each. Sellers place one token per mine
|
|
119
167
|
on the highest empty slot; the resource refill removes from the
|
|
120
168
|
cheap (bottom) end. Lives in the clear upper-left margin. -->
|
|
121
|
-
<g
|
|
169
|
+
<g
|
|
170
|
+
v-if="G.map.name === 'Australia' && G.uraniumMineMarket"
|
|
171
|
+
ref="slotUraniumMines"
|
|
172
|
+
:transform="slotT('uraniumMines') || 'translate(15, 70)'"
|
|
173
|
+
>
|
|
122
174
|
<rect x="0" y="0" width="104" height="430" rx="6" fill="#8aa84a" stroke="#4d6322" stroke-width="3" />
|
|
123
175
|
<text x="52" y="22" text-anchor="middle" font-weight="700" fill="black" style="font-size: 15px">
|
|
124
176
|
Uranium
|
|
@@ -176,7 +228,12 @@
|
|
|
176
228
|
</text>
|
|
177
229
|
</g>
|
|
178
230
|
|
|
179
|
-
<g
|
|
231
|
+
<g
|
|
232
|
+
ref="slotRoundInfo"
|
|
233
|
+
:transform="
|
|
234
|
+
slotT('roundInfo') || `translate(${G.map.roundInfoPosition[0]}, ${G.map.roundInfoPosition[1]})`
|
|
235
|
+
"
|
|
236
|
+
>
|
|
180
237
|
<template v-if="gameEnded(G)">
|
|
181
238
|
<Button
|
|
182
239
|
:transform="`translate(20, 50)`"
|
|
@@ -204,7 +261,10 @@
|
|
|
204
261
|
</template>
|
|
205
262
|
</g>
|
|
206
263
|
|
|
207
|
-
<g
|
|
264
|
+
<g
|
|
265
|
+
ref="slotButtons"
|
|
266
|
+
:transform="slotT('buttons') || `translate(${G.map.buttonsPosition[0]}, ${G.map.buttonsPosition[1]})`"
|
|
267
|
+
>
|
|
208
268
|
<PassButton
|
|
209
269
|
transform="translate(15, 15)"
|
|
210
270
|
:enabled="canPass()"
|
|
@@ -222,30 +282,43 @@
|
|
|
222
282
|
<SoundButton transform="translate(110, 13)" :isOn="preferences.sound" @click="toggleSound()" />
|
|
223
283
|
<HelpButton transform="translate(110, 54)" :isOn="!preferences.disableHelp" @click="toggleHelp()" />
|
|
224
284
|
<RulesButton transform="translate(110, 95)" @click="rulesVisible = true" />
|
|
285
|
+
<!-- Only offered where it means something. Shown whenever the
|
|
286
|
+
viewport is portrait — not only while stacking is active — so
|
|
287
|
+
it can undo its own effect. Sits under Rules rather than under
|
|
288
|
+
Log: the left column's next slot overlaps the turn-order table
|
|
289
|
+
on the authored board. -->
|
|
290
|
+
<LayoutButton
|
|
291
|
+
v-if="portraitViewport"
|
|
292
|
+
transform="translate(110, 136)"
|
|
293
|
+
:isOn="stacked"
|
|
294
|
+
@click="toggleStackLayout()"
|
|
295
|
+
/>
|
|
225
296
|
</g>
|
|
226
297
|
|
|
227
|
-
<
|
|
228
|
-
<
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
G.map.playerBoardsPosition[
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
298
|
+
<g ref="slotPlayerBoards" :transform="slotT('playerBoards')">
|
|
299
|
+
<template v-for="(playerIndex, i) in adjustedPlayerOrder">
|
|
300
|
+
<PlayerBoard
|
|
301
|
+
:key="'B' + playerIndex"
|
|
302
|
+
:transform="`translate(${G.map.playerBoardsPosition[0]}, ${
|
|
303
|
+
G.map.playerBoardsPosition[1] + 110 * i
|
|
304
|
+
})`"
|
|
305
|
+
:player="G.players[playerIndex]"
|
|
306
|
+
:color="playerColors[playerIndex]"
|
|
307
|
+
:avatar="avatars[playerIndex]"
|
|
308
|
+
:owner="playerIndex"
|
|
309
|
+
:isCurrentPlayer="isCurrentPlayer(playerIndex)"
|
|
310
|
+
:ended="gameEnded(G)"
|
|
311
|
+
:isPlayer="player == playerIndex"
|
|
312
|
+
:ranking="sortedPlayers.findIndex((x) => x.id == G.players[playerIndex].id) + 1"
|
|
313
|
+
:showMoney="player == playerIndex || gameEnded(G) || G.options.showMoney"
|
|
314
|
+
:showBid="!G.options.fastBid"
|
|
315
|
+
:phase="G.phase"
|
|
316
|
+
:isAustralia="G.map.name === 'Australia'"
|
|
317
|
+
@powerPlantClick="powerPlantClick($event)"
|
|
318
|
+
@discardResource="discardResource($event)"
|
|
319
|
+
/>
|
|
320
|
+
</template>
|
|
321
|
+
</g>
|
|
249
322
|
</svg>
|
|
250
323
|
|
|
251
324
|
<div v-if="G" :class="['modal', { visible: logVisible }]">
|
|
@@ -371,22 +444,24 @@
|
|
|
371
444
|
<div class="modal-content">
|
|
372
445
|
<span class="close" @click="endScoreVisible = false">×</span>
|
|
373
446
|
<div class="modal-title">Final Score</div>
|
|
374
|
-
<
|
|
375
|
-
<
|
|
376
|
-
<
|
|
377
|
-
|
|
378
|
-
<
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
<
|
|
383
|
-
|
|
384
|
-
<
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
447
|
+
<div class="table-scroll">
|
|
448
|
+
<table class="final-score-table">
|
|
449
|
+
<tr>
|
|
450
|
+
<th><div>Player</div></th>
|
|
451
|
+
<th v-for="player in sortedPlayers" :key="'FS' + player.id">
|
|
452
|
+
<div :style="'background-color: ' + playerColors[player.id]">{{ player.name }}</div>
|
|
453
|
+
</th>
|
|
454
|
+
</tr>
|
|
455
|
+
<tr v-for="(cat, i) in ['Cities Powered', 'Money', 'Total Cities']" :key="'FC_' + cat">
|
|
456
|
+
<td>{{ cat }}</td>
|
|
457
|
+
<td v-for="player in sortedPlayers" :key="'FS' + player.id + i">
|
|
458
|
+
<div>
|
|
459
|
+
{{ i == 0 ? player.citiesPowered : i == 1 ? player.money : player.cities.length }}
|
|
460
|
+
</div>
|
|
461
|
+
</td>
|
|
462
|
+
</tr>
|
|
463
|
+
</table>
|
|
464
|
+
</div>
|
|
390
465
|
</div>
|
|
391
466
|
</div>
|
|
392
467
|
|
|
@@ -394,20 +469,22 @@
|
|
|
394
469
|
<div class="modal-content">
|
|
395
470
|
<span class="close" @click="spendingVisible = false">×</span>
|
|
396
471
|
<div class="modal-title">Spending</div>
|
|
397
|
-
<
|
|
398
|
-
<
|
|
399
|
-
<
|
|
400
|
-
|
|
401
|
-
<
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
<
|
|
406
|
-
|
|
407
|
-
<
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
472
|
+
<div class="table-scroll">
|
|
473
|
+
<table class="spending-table">
|
|
474
|
+
<tr>
|
|
475
|
+
<th><div>Player</div></th>
|
|
476
|
+
<th v-for="player in sortedPlayers" :key="'FS' + player.id">
|
|
477
|
+
<div :style="'background-color: ' + playerColors[player.id]">{{ player.name }}</div>
|
|
478
|
+
</th>
|
|
479
|
+
</tr>
|
|
480
|
+
<tr v-for="row in spendingRows" :key="'FC_' + row.label">
|
|
481
|
+
<td>{{ row.label }}</td>
|
|
482
|
+
<td v-for="player in sortedPlayers" :key="'FS' + player.id + row.label">
|
|
483
|
+
<div>{{ row.value(player) }}</div>
|
|
484
|
+
</td>
|
|
485
|
+
</tr>
|
|
486
|
+
</table>
|
|
487
|
+
</div>
|
|
411
488
|
</div>
|
|
412
489
|
</div>
|
|
413
490
|
|
|
@@ -520,26 +597,39 @@
|
|
|
520
597
|
<br />
|
|
521
598
|
<div>
|
|
522
599
|
<strong>Map Specific Rules:</strong><br />
|
|
523
|
-
<span
|
|
600
|
+
<span class="map-specific-rules">{{ G.map.mapSpecificRules }}</span>
|
|
524
601
|
</div>
|
|
525
602
|
</template>
|
|
526
603
|
<br />
|
|
604
|
+
<div>
|
|
605
|
+
<strong>Deck build:</strong><br />
|
|
606
|
+
<span class="map-specific-rules">{{ standardDeckBuild }}</span>
|
|
607
|
+
<template v-if="mapDeckBuild">
|
|
608
|
+
<br />
|
|
609
|
+
<span class="map-specific-rules"
|
|
610
|
+
><strong>On {{ G.map.name }}:</strong> {{ mapDeckBuild }}</span
|
|
611
|
+
>
|
|
612
|
+
</template>
|
|
613
|
+
</div>
|
|
614
|
+
<br />
|
|
527
615
|
<div>
|
|
528
616
|
<strong>Payment Table</strong>
|
|
529
|
-
<
|
|
530
|
-
<
|
|
531
|
-
<
|
|
532
|
-
|
|
533
|
-
<
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
<
|
|
538
|
-
|
|
539
|
-
<
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
617
|
+
<div class="table-scroll">
|
|
618
|
+
<table class="payment-table">
|
|
619
|
+
<tr>
|
|
620
|
+
<td><strong>Cities</strong></td>
|
|
621
|
+
<template v-for="index in G.citiesToEndGame">
|
|
622
|
+
<td :key="'cities' + index">{{ index - 1 }}</td>
|
|
623
|
+
</template>
|
|
624
|
+
</tr>
|
|
625
|
+
<tr>
|
|
626
|
+
<td><strong>Payment</strong></td>
|
|
627
|
+
<template v-for="index in G.citiesToEndGame">
|
|
628
|
+
<td :key="'payment' + index">${{ G.paymentTable[index - 1] }}</td>
|
|
629
|
+
</template>
|
|
630
|
+
</tr>
|
|
631
|
+
</table>
|
|
632
|
+
</div>
|
|
543
633
|
</div>
|
|
544
634
|
</div>
|
|
545
635
|
</div>
|
|
@@ -554,7 +644,16 @@ import { EventEmitter } from 'events';
|
|
|
554
644
|
import { matchesTurnBuffer, rebaseTurnBuffer, replayTurnBuffer as replayBuffer } from '../util/turn-buffer';
|
|
555
645
|
import { UIData, Preferences } from '../types/ui-data';
|
|
556
646
|
import { Card, House, Coal, Oil, Garbage, Uranium } from './pieces';
|
|
557
|
-
import {
|
|
647
|
+
import {
|
|
648
|
+
Button,
|
|
649
|
+
PassButton,
|
|
650
|
+
UndoButton,
|
|
651
|
+
LogButton,
|
|
652
|
+
SoundButton,
|
|
653
|
+
HelpButton,
|
|
654
|
+
RulesButton,
|
|
655
|
+
LayoutButton,
|
|
656
|
+
} from './buttons';
|
|
558
657
|
import PlayerBoard from './PlayerBoard.vue';
|
|
559
658
|
import Calculator from './Calculator.vue';
|
|
560
659
|
import PowerPlantMarket from './boards/PowerPlantMarket.vue';
|
|
@@ -567,6 +666,84 @@ import { Phase, playerTimeUsed, PowerPlant, PowerPlantType, ResourceType } from
|
|
|
567
666
|
import { City } from 'powergrid-engine/src/maps';
|
|
568
667
|
import { formatDuration } from '../util/time';
|
|
569
668
|
|
|
669
|
+
// Portrait layout: the rows the scene is broken into, top to bottom. Slots on
|
|
670
|
+
// the same row sit side by side and share one scale. Names map to the `slotX`
|
|
671
|
+
// refs on the wrapper groups in the template; a slot whose group is absent for
|
|
672
|
+
// this map (Australia's mine market, Japan's free jump) is simply skipped.
|
|
673
|
+
const STACK_ROWS: string[][] = [
|
|
674
|
+
['cityCount', 'playerOrder'],
|
|
675
|
+
['map'],
|
|
676
|
+
// Everything that is chrome or reference-only shares ONE row, so the three
|
|
677
|
+
// markets below can have a row of their own to grow into. John's call at the
|
|
678
|
+
// phone, 2026-08-19: the market is what you read and tap during an auction;
|
|
679
|
+
// the draw pile and the round readout are things you glance at.
|
|
680
|
+
['buttons', 'roundInfo', 'powerPlantDeck'],
|
|
681
|
+
['powerPlantMarket'],
|
|
682
|
+
['resources'],
|
|
683
|
+
['uraniumMines'],
|
|
684
|
+
['freeJump'],
|
|
685
|
+
['playerBoards'],
|
|
686
|
+
];
|
|
687
|
+
/** Width of the stacked canvas in scene units — kept at the usual scene width
|
|
688
|
+
* so the numbers stay recognisable next to the per-map coordinates. */
|
|
689
|
+
const STACK_WIDTH = 1465;
|
|
690
|
+
const STACK_PAD = 16;
|
|
691
|
+
const STACK_GAP = 28;
|
|
692
|
+
/** A small strip magnified without limit looks broken rather than legible. */
|
|
693
|
+
const STACK_MAX_SCALE = 4;
|
|
694
|
+
/**
|
|
695
|
+
* No single row may fill more than this share of the screen, so the row below it
|
|
696
|
+
* always peeks and the page reads as scrollable. Without it a tall, narrow board
|
|
697
|
+
* (Manhattan takes 92% of an iPhone XR) looks like the whole page.
|
|
698
|
+
*/
|
|
699
|
+
const STACK_MAX_ROW_SCREENS = 0.72;
|
|
700
|
+
/**
|
|
701
|
+
* Per-slot ceilings, for groups that share a row with something that deserves
|
|
702
|
+
* the space more. The round/step/phase readout is text and only needs to be
|
|
703
|
+
* readable; the buttons next to it are tap targets and want every pixel.
|
|
704
|
+
*/
|
|
705
|
+
const STACK_SLOT_MAX_SCALE: Record<string, number> = {
|
|
706
|
+
roundInfo: 2.2,
|
|
707
|
+
playerOrder: 2.5,
|
|
708
|
+
};
|
|
709
|
+
/**
|
|
710
|
+
* Share of the canvas width a slot may occupy. The market is held short of the
|
|
711
|
+
* full width on purpose: stretched edge to edge, "Actual Market" sits under the
|
|
712
|
+
* far left of the screen and the bid OK button under the far right — the two
|
|
713
|
+
* spots a thumb reaches worst while holding the phone.
|
|
714
|
+
*
|
|
715
|
+
* This has to be a WIDTH budget rather than a scale ceiling, because the market's
|
|
716
|
+
* own width varies: Benelux authors `actualMarketWidth: 495`, and it shrinks again
|
|
717
|
+
* in Step 3 once the future market is gone. A fixed scale that centred Bremen left
|
|
718
|
+
* Benelux at 97% of the canvas, i.e. still touching both edges.
|
|
719
|
+
*/
|
|
720
|
+
const STACK_SLOT_MAX_WIDTH: Record<string, number> = {
|
|
721
|
+
powerPlantMarket: 0.88,
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* How the deck and opening market are built when a map says nothing special —
|
|
726
|
+
* i.e. the rule the per-map `deckBuild` notes are differences FROM. Wording is
|
|
727
|
+
* the legend row of the deck-build grid Mike reviewed on 2026-08-13.
|
|
728
|
+
*/
|
|
729
|
+
const STANDARD_DECK_BUILD: Record<string, string> = {
|
|
730
|
+
recharged:
|
|
731
|
+
'Shuffle the 13 low power plants — the "plug plants" (3-15), named for the plug on the back of ' +
|
|
732
|
+
'the printed cards. Draw 8 and sort them ascending: the 4 cheapest form the ' +
|
|
733
|
+
'current market, the next 4 the future market. One of the remaining low plants goes face down on ' +
|
|
734
|
+
'top of the draw deck. Player-count removal: 2 players remove 1 low + 5 higher plants; 3 players ' +
|
|
735
|
+
'remove 2 low + 6 higher; 4 players remove 1 low + 3 higher; 5-6 players remove none. The leftover ' +
|
|
736
|
+
'low plants are shuffled into the deck, with the Step 3 card on the bottom.',
|
|
737
|
+
original:
|
|
738
|
+
'The market is fixed: power plants 3, 4, 5, 6 are the current market and 7, 8, 9, 10 the future ' +
|
|
739
|
+
'market. Power plant 13 and the Step 3 card are set aside and the rest of the deck is shuffled, ' +
|
|
740
|
+
'then 8 random plants are removed for 2-3 players, 4 for 4 players, and none for 5-6 players. ' +
|
|
741
|
+
'Power plant 13 goes on top of the deck and the Step 3 card on the bottom.',
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
const slotRef = (name: string) => `slot${name[0].toUpperCase()}${name.slice(1)}`;
|
|
745
|
+
const round = (n: number, digits = 2) => Number(n.toFixed(digits));
|
|
746
|
+
|
|
570
747
|
@Component({
|
|
571
748
|
created(this: Game) {
|
|
572
749
|
this.emitter.on('replayStart', () => {
|
|
@@ -610,6 +787,7 @@ import { formatDuration } from '../util/time';
|
|
|
610
787
|
SoundButton,
|
|
611
788
|
HelpButton,
|
|
612
789
|
RulesButton,
|
|
790
|
+
LayoutButton,
|
|
613
791
|
Button,
|
|
614
792
|
Calculator,
|
|
615
793
|
PowerPlantMarket,
|
|
@@ -797,6 +975,10 @@ export default class Game extends Vue {
|
|
|
797
975
|
this.cityCount.createPieces(this.G!);
|
|
798
976
|
this.map.createPieces(this.G!);
|
|
799
977
|
this.resources.createPieces(this.G!);
|
|
978
|
+
// Pieces are added imperatively, so the groups only reach their
|
|
979
|
+
// final size here — the portrait layout must measure after this,
|
|
980
|
+
// not on the state change that triggered it.
|
|
981
|
+
this.scheduleRelayout();
|
|
800
982
|
});
|
|
801
983
|
}
|
|
802
984
|
|
|
@@ -825,6 +1007,9 @@ export default class Game extends Vue {
|
|
|
825
1007
|
setTimeout(() => this.updateUI());
|
|
826
1008
|
return;
|
|
827
1009
|
}
|
|
1010
|
+
|
|
1011
|
+
// Pieces have finished moving, so the groups are at their settled size.
|
|
1012
|
+
this.scheduleRelayout();
|
|
828
1013
|
}
|
|
829
1014
|
|
|
830
1015
|
checkPass() {
|
|
@@ -1584,6 +1769,21 @@ export default class Game extends Vue {
|
|
|
1584
1769
|
];
|
|
1585
1770
|
}
|
|
1586
1771
|
|
|
1772
|
+
// The standard deck build for the variant in play. Shown on EVERY map: the
|
|
1773
|
+
// per-map notes are written as differences from it, so without it a line like
|
|
1774
|
+
// "18, 22 and 27 are set aside" tells a new player nothing about the rest.
|
|
1775
|
+
get standardDeckBuild(): string {
|
|
1776
|
+
return STANDARD_DECK_BUILD[this.G?.options.variant ?? 'recharged'] ?? STANDARD_DECK_BUILD.recharged;
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
/** This map's departure from the standard build, or '' when it follows it. */
|
|
1780
|
+
get mapDeckBuild(): string {
|
|
1781
|
+
const deckBuild = this.G?.map.deckBuild;
|
|
1782
|
+
if (!deckBuild) return '';
|
|
1783
|
+
if (typeof deckBuild === 'string') return deckBuild;
|
|
1784
|
+
return deckBuild[(this.G!.options.variant ?? 'recharged') as 'recharged' | 'original'] ?? '';
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1587
1787
|
get mapTransform() {
|
|
1588
1788
|
if (!this.G?.map) return '';
|
|
1589
1789
|
const [mx, my] = this.G.map.mapPosition!;
|
|
@@ -1597,6 +1797,166 @@ export default class Game extends Vue {
|
|
|
1597
1797
|
return `translate(${mx}, ${my}) rotate(${rotation}, ${cx}, ${cy})`;
|
|
1598
1798
|
}
|
|
1599
1799
|
|
|
1800
|
+
// ── Portrait ("stacked") layout ─────────────────────────────────────────
|
|
1801
|
+
// The whole game is ONE svg laid out in per-map absolute coordinates: board
|
|
1802
|
+
// on the left, market and player boards in a right-hand column, resource
|
|
1803
|
+
// supply along the bottom. That composition assumes a landscape viewport.
|
|
1804
|
+
// On a portrait phone it letterboxes: measured at 390x844 the board drew at
|
|
1805
|
+
// 390x229 and left 73% of the screen empty, with 7x10px city slots and
|
|
1806
|
+
// 21x7px buttons.
|
|
1807
|
+
//
|
|
1808
|
+
// Rather than author a second set of coordinates for 24 maps, we re-use the
|
|
1809
|
+
// ones we already have: each group is measured with getBBox() and mapped
|
|
1810
|
+
// onto a full-width row by a single transform on its wrapper <g>. Nothing
|
|
1811
|
+
// inside the components changes, and on a landscape/desktop viewport no
|
|
1812
|
+
// transform is emitted at all, so those layouts render exactly as before.
|
|
1813
|
+
|
|
1814
|
+
/** True while the portrait row layout is in effect. */
|
|
1815
|
+
stacked = false;
|
|
1816
|
+
/**
|
|
1817
|
+
* True when the viewport is one the row layout applies to, whether or not the
|
|
1818
|
+
* player has it switched on. Kept separate from `stacked` so the toggle stays
|
|
1819
|
+
* visible after someone turns stacking off — otherwise the button that undoes
|
|
1820
|
+
* the choice would disappear along with the layout.
|
|
1821
|
+
*/
|
|
1822
|
+
portraitViewport = false;
|
|
1823
|
+
/** Height of the stacked canvas, in scene units. */
|
|
1824
|
+
stackHeight = STACK_WIDTH;
|
|
1825
|
+
/** Wrapper transform per slot; empty means "render as authored". */
|
|
1826
|
+
slotTransforms: Record<string, string> = {};
|
|
1827
|
+
|
|
1828
|
+
get sceneViewBox() {
|
|
1829
|
+
if (this.stacked) {
|
|
1830
|
+
return `0 0 ${STACK_WIDTH} ${this.stackHeight}`;
|
|
1831
|
+
}
|
|
1832
|
+
return this.G?.map?.viewBox ? `0 0 ${this.G.map.viewBox[0]} ${this.G.map.viewBox[1]}` : '0 0 1500 800';
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
slotT(name: string): string | undefined {
|
|
1836
|
+
return this.slotTransforms[name];
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
/**
|
|
1840
|
+
* Only portrait viewports are re-laid out. A landscape phone already reads
|
|
1841
|
+
* well (the scene's own aspect ratio is close to the screen's), so leaving
|
|
1842
|
+
* it alone keeps the change surface small.
|
|
1843
|
+
*/
|
|
1844
|
+
private isPortraitViewport(): boolean {
|
|
1845
|
+
return window.innerWidth < 900 && window.innerHeight > window.innerWidth * 1.1;
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
private shouldStack(): boolean {
|
|
1849
|
+
return this.isPortraitViewport() && this.preferences.stackOnPortrait !== false;
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
toggleStackLayout() {
|
|
1853
|
+
const newVal = !this.stacked;
|
|
1854
|
+
|
|
1855
|
+
this.emitter.emit('update:preference', { name: 'stackOnPortrait', value: newVal });
|
|
1856
|
+
this.preferences.stackOnPortrait = newVal;
|
|
1857
|
+
this.relayout();
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
relayout() {
|
|
1861
|
+
this.portraitViewport = this.isPortraitViewport();
|
|
1862
|
+
|
|
1863
|
+
if (!this.shouldStack()) {
|
|
1864
|
+
if (this.stacked) {
|
|
1865
|
+
this.stacked = false;
|
|
1866
|
+
this.slotTransforms = {};
|
|
1867
|
+
}
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
// getBBox() reports a group's box in its OWN user space, i.e. before its
|
|
1872
|
+
// own transform is applied — so measuring is safe even while a previous
|
|
1873
|
+
// stacked transform is in place, and a re-layout never feeds on its own
|
|
1874
|
+
// output.
|
|
1875
|
+
const boxes: Record<string, { x: number; y: number; width: number; height: number }> = {};
|
|
1876
|
+
for (const name of STACK_ROWS.flat()) {
|
|
1877
|
+
const el = this.$refs[slotRef(name)] as SVGGraphicsElement | undefined;
|
|
1878
|
+
if (!el || typeof el.getBBox !== 'function') continue;
|
|
1879
|
+
try {
|
|
1880
|
+
const bb = el.getBBox();
|
|
1881
|
+
if (bb.width > 0 && bb.height > 0) {
|
|
1882
|
+
boxes[name] = { x: bb.x, y: bb.y, width: bb.width, height: bb.height };
|
|
1883
|
+
}
|
|
1884
|
+
} catch {
|
|
1885
|
+
// Not rendered yet (or detached) — skip; the next relayout catches it.
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
// A row of `h` scene units renders at h * (innerWidth / STACK_WIDTH) css px,
|
|
1890
|
+
// because the canvas width always maps to the viewport width.
|
|
1891
|
+
const unitsPerScreen = (STACK_MAX_ROW_SCREENS * window.innerHeight * STACK_WIDTH) / window.innerWidth;
|
|
1892
|
+
const heightCapFor = (height: number) => unitsPerScreen / height;
|
|
1893
|
+
|
|
1894
|
+
const transforms: Record<string, string> = {};
|
|
1895
|
+
let y = STACK_PAD;
|
|
1896
|
+
for (const row of STACK_ROWS) {
|
|
1897
|
+
const present = row.filter((name) => boxes[name]);
|
|
1898
|
+
if (!present.length) continue;
|
|
1899
|
+
|
|
1900
|
+
const gaps = STACK_GAP * (present.length - 1);
|
|
1901
|
+
const usable = STACK_WIDTH - 2 * STACK_PAD - gaps;
|
|
1902
|
+
const combined = present.reduce((sum, name) => sum + boxes[name].width, 0);
|
|
1903
|
+
// One scale per row keeps neighbours visually consistent. The cap stops
|
|
1904
|
+
// a small strip (the turn-order token row) from being blown up absurdly.
|
|
1905
|
+
const rowScale = Math.min(usable / combined, STACK_MAX_SCALE);
|
|
1906
|
+
// A slot may decline part of that scale so a neighbour keeps the space,
|
|
1907
|
+
// and no slot may grow past the height budget for one row.
|
|
1908
|
+
const scaleOf = (name: string) =>
|
|
1909
|
+
Math.min(
|
|
1910
|
+
rowScale,
|
|
1911
|
+
STACK_SLOT_MAX_SCALE[name] ?? Infinity,
|
|
1912
|
+
((STACK_SLOT_MAX_WIDTH[name] ?? Infinity) * usable) / boxes[name].width,
|
|
1913
|
+
heightCapFor(boxes[name].height)
|
|
1914
|
+
);
|
|
1915
|
+
const rowWidth = present.reduce((sum, name) => sum + boxes[name].width * scaleOf(name), 0);
|
|
1916
|
+
const rowHeight = Math.max(...present.map((name) => boxes[name].height * scaleOf(name)));
|
|
1917
|
+
|
|
1918
|
+
let x = (STACK_WIDTH - (rowWidth + gaps)) / 2;
|
|
1919
|
+
for (const name of present) {
|
|
1920
|
+
const bb = boxes[name];
|
|
1921
|
+
const scale = scaleOf(name);
|
|
1922
|
+
// Centre a shorter slot against the tallest one in its row.
|
|
1923
|
+
const top = y + (rowHeight - bb.height * scale) / 2;
|
|
1924
|
+
transforms[name] =
|
|
1925
|
+
`translate(${round(x - bb.x * scale)}, ${round(top - bb.y * scale)}) scale(${round(scale, 4)})`;
|
|
1926
|
+
x += bb.width * scale + STACK_GAP;
|
|
1927
|
+
}
|
|
1928
|
+
y += rowHeight + STACK_GAP;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
this.stackHeight = Math.round(y + STACK_PAD - STACK_GAP);
|
|
1932
|
+
this.slotTransforms = transforms;
|
|
1933
|
+
this.stacked = true;
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
private scheduleRelayout() {
|
|
1937
|
+
this.$nextTick(() => this.relayout());
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
private onViewportResize = () => this.scheduleRelayout();
|
|
1941
|
+
|
|
1942
|
+
mounted() {
|
|
1943
|
+
window.addEventListener('resize', this.onViewportResize);
|
|
1944
|
+
window.addEventListener('orientationchange', this.onViewportResize);
|
|
1945
|
+
this.scheduleRelayout();
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
beforeDestroy() {
|
|
1949
|
+
window.removeEventListener('resize', this.onViewportResize);
|
|
1950
|
+
window.removeEventListener('orientationchange', this.onViewportResize);
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
// Boards grow as players buy plants, so the row heights are re-derived on
|
|
1954
|
+
// every state change rather than measured once at mount.
|
|
1955
|
+
@Watch('G')
|
|
1956
|
+
onSceneContentChanged() {
|
|
1957
|
+
this.scheduleRelayout();
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1600
1960
|
get adjustedPlayerOrder() {
|
|
1601
1961
|
if (!this.G) {
|
|
1602
1962
|
return [];
|
|
@@ -1665,6 +2025,20 @@ ul {
|
|
|
1665
2025
|
margin: 40px auto auto auto;
|
|
1666
2026
|
}
|
|
1667
2027
|
|
|
2028
|
+
// Portrait: the scene is taller than the viewport by design (each row is scaled
|
|
2029
|
+
// to the full width), so it must be allowed to overflow and scroll instead of
|
|
2030
|
+
// being squeezed back into one screen.
|
|
2031
|
+
.game.stacked {
|
|
2032
|
+
height: auto;
|
|
2033
|
+
align-items: stretch;
|
|
2034
|
+
|
|
2035
|
+
#scene {
|
|
2036
|
+
max-height: none;
|
|
2037
|
+
flex-grow: 0;
|
|
2038
|
+
margin-top: 40px;
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
|
|
1668
2042
|
body,
|
|
1669
2043
|
html {
|
|
1670
2044
|
height: 100%;
|
|
@@ -1766,6 +2140,8 @@ text {
|
|
|
1766
2140
|
margin: auto;
|
|
1767
2141
|
padding: 10px 20px 20px 20px;
|
|
1768
2142
|
border: 1px solid #888;
|
|
2143
|
+
box-sizing: border-box;
|
|
2144
|
+
max-width: 100%;
|
|
1769
2145
|
}
|
|
1770
2146
|
|
|
1771
2147
|
@media only screen and (min-width: 1240px) {
|
|
@@ -1820,6 +2196,25 @@ text {
|
|
|
1820
2196
|
cursor: pointer;
|
|
1821
2197
|
}
|
|
1822
2198
|
|
|
2199
|
+
// Printed rules text keeps its authored line breaks, but a long unbroken run
|
|
2200
|
+
// (a city list, a URL) must still fold rather than widen the dialog.
|
|
2201
|
+
.map-specific-rules {
|
|
2202
|
+
display: block;
|
|
2203
|
+
white-space: pre-wrap;
|
|
2204
|
+
overflow-wrap: anywhere;
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
// Tables are naturally wider than a phone (one column per player, plus a wide
|
|
2208
|
+
// label column). Without a scroll container they simply overflowed the modal and
|
|
2209
|
+
// the far columns were unreachable — on a 6-player game only the last player was
|
|
2210
|
+
// visible. The container scrolls; the label column stays pinned so a value never
|
|
2211
|
+
// loses its row.
|
|
2212
|
+
.table-scroll {
|
|
2213
|
+
max-width: 100%;
|
|
2214
|
+
overflow-x: auto;
|
|
2215
|
+
-webkit-overflow-scrolling: touch;
|
|
2216
|
+
}
|
|
2217
|
+
|
|
1823
2218
|
.payment-table {
|
|
1824
2219
|
border: 1px solid black;
|
|
1825
2220
|
margin: 5px auto;
|
|
@@ -1854,6 +2249,11 @@ text {
|
|
|
1854
2249
|
|
|
1855
2250
|
td:first-child,
|
|
1856
2251
|
th:first-child {
|
|
2252
|
+
position: sticky;
|
|
2253
|
+
left: 0;
|
|
2254
|
+
background-color: #fefefe;
|
|
2255
|
+
box-shadow: 2px 0 3px -1px rgba(0, 0, 0, 0.3);
|
|
2256
|
+
|
|
1857
2257
|
div {
|
|
1858
2258
|
width: 250px;
|
|
1859
2259
|
}
|
|
@@ -1861,6 +2261,45 @@ text {
|
|
|
1861
2261
|
}
|
|
1862
2262
|
}
|
|
1863
2263
|
|
|
2264
|
+
// Phone-sized screens: shrink the columns so more than one player fits on
|
|
2265
|
+
// screen before any scrolling is needed.
|
|
2266
|
+
@media only screen and (max-width: 700px) {
|
|
2267
|
+
.modal-content {
|
|
2268
|
+
padding: 10px;
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
.final-score-table,
|
|
2272
|
+
.spending-table {
|
|
2273
|
+
tr {
|
|
2274
|
+
td,
|
|
2275
|
+
th {
|
|
2276
|
+
div {
|
|
2277
|
+
width: auto;
|
|
2278
|
+
min-width: 60px;
|
|
2279
|
+
padding: 0 6px;
|
|
2280
|
+
line-height: 32px;
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
td:first-child,
|
|
2285
|
+
th:first-child {
|
|
2286
|
+
div {
|
|
2287
|
+
width: auto;
|
|
2288
|
+
min-width: 0;
|
|
2289
|
+
max-width: 38vw;
|
|
2290
|
+
text-align: left;
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
.payment-table {
|
|
2297
|
+
tr td {
|
|
2298
|
+
padding: 0 6px;
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
|
|
1864
2303
|
.confirm-message {
|
|
1865
2304
|
font-size: 18px;
|
|
1866
2305
|
padding: 10px;
|