powergrid-viewer 2.0.2 → 2.0.4

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.
@@ -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
- <PlayerOrder
22
- ref="playerOrder"
23
- :transform="`translate(${G.map.playerOrderPosition[0]}, ${G.map.playerOrderPosition[1]})`"
24
- :playerColors="playerColors"
25
- />
26
-
27
- <CityCount
28
- ref="cityCount"
29
- :transform="`translate(${G.map.cityCountPosition[0]}, ${G.map.cityCountPosition[1]})`"
30
- :playerColors="playerColors"
31
- :citiesToEndGame="G.citiesToEndGame"
32
- :citiesToStep2="G.map.name === 'Manhattan' ? undefined : G.citiesToStep2"
33
- />
34
-
35
- <PowerPlantMarket
36
- ref="powerPlantMarket"
37
- :transform="`translate(${G.map.powerPlantMarketPosition[0]}, ${G.map.powerPlantMarketPosition[1]})`"
38
- :canBid="canBid()"
39
- :canChoose="canChoose()"
40
- :chooseablePowerPlants="getChooseablePowerPlants()"
41
- :cardsLeft="G.cardsLeft"
42
- :minBid="G.currentBid + 1 || G.minimunBid"
43
- :maxBid="G.players[player] ? G.players[player].money : 0"
44
- :nextCardWeak="G.nextCardWeak"
45
- :plantDiscountActive="G.plantDiscountActive"
46
- @choosePowerPlant="choosePowerPlant($event)"
47
- @bid="bid($event)"
48
- />
49
-
50
- <Map
51
- ref="map"
52
- :transform="mapTransform"
53
- :playerColors="playerColors"
54
- :cities="G.map.cities"
55
- :connections="G.map.connections"
56
- :polygons="G.map.polygons"
57
- :buildableCities="getBuildableCities()"
58
- :blockedCities="G.blockedCities"
59
- :pickableRegions="getPickableRegions()"
60
- :noUraniumRegions="G.map.noUraniumRegions"
61
- :devBackdrop="G.map.devBackdrop"
62
- :mapRotation="G.map.mapRotation || 0"
63
- @build="build($event)"
64
- @pickRegion="pickRegion($event)"
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
- <Resources
102
- ref="resources"
103
- :transform="`translate(${G.map.supplyPosition[0]}, ${G.map.supplyPosition[1]})`"
104
- :isUsaRecharged="G.options.variant == 'recharged' && G.map.name == 'USA'"
105
- :isMiddleEast="G.map.name == 'Middle East'"
106
- :isIndiaResourceMarket="G.map.name == 'India' && G.coalPrices && G.garbagePrices && G.uraniumPrices"
107
- :availableSurplusOil="
108
- G.map.name == 'Middle East' ? Math.max(G.oilMarket - G.oilPrices.filter((p) => p > 1).length, 0) : 0
109
- "
110
- :buyableResources="buyableResources()"
111
- :coalStorage="G.coalStorage"
112
- :resourceResupply="getResourceResupply()"
113
- :resourceResupplyNorth="getResourceResupplyNorth()"
114
- @buyResource="buyResource($event)"
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 v-if="G.map.name === 'Australia' && G.uraniumMineMarket" transform="translate(15, 70)">
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 :transform="`translate(${G.map.roundInfoPosition[0]}, ${G.map.roundInfoPosition[1]})`">
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 :transform="`translate(${G.map.buttonsPosition[0]}, ${G.map.buttonsPosition[1]})`">
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()"
@@ -219,33 +279,46 @@
219
279
  @click="undo()"
220
280
  />
221
281
  <LogButton transform="translate(15, 97)" @click="showLog()" />
222
- <SoundButton transform="translate(110, 13)" :isOn="preferences.sound" @click="toggleSound()" />
223
- <HelpButton transform="translate(110, 54)" :isOn="!preferences.disableHelp" @click="toggleHelp()" />
224
- <RulesButton transform="translate(110, 95)" @click="rulesVisible = true" />
282
+ <SoundButton :transform="iconButton(0)" :isOn="preferences.sound" @click="toggleSound()" />
283
+ <HelpButton :transform="iconButton(1)" :isOn="!preferences.disableHelp" @click="toggleHelp()" />
284
+ <RulesButton :transform="iconButton(2)" @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="iconButton(3)"
293
+ :isOn="stacked"
294
+ @click="toggleStackLayout()"
295
+ />
225
296
  </g>
226
297
 
227
- <template v-for="(playerIndex, i) in adjustedPlayerOrder">
228
- <PlayerBoard
229
- :key="'B' + playerIndex"
230
- :transform="`translate(${G.map.playerBoardsPosition[0]}, ${
231
- G.map.playerBoardsPosition[1] + 110 * i
232
- })`"
233
- :player="G.players[playerIndex]"
234
- :color="playerColors[playerIndex]"
235
- :avatar="avatars[playerIndex]"
236
- :owner="playerIndex"
237
- :isCurrentPlayer="isCurrentPlayer(playerIndex)"
238
- :ended="gameEnded(G)"
239
- :isPlayer="player == playerIndex"
240
- :ranking="sortedPlayers.findIndex((x) => x.id == G.players[playerIndex].id) + 1"
241
- :showMoney="player == playerIndex || gameEnded(G) || G.options.showMoney"
242
- :showBid="!G.options.fastBid"
243
- :phase="G.phase"
244
- :isAustralia="G.map.name === 'Australia'"
245
- @powerPlantClick="powerPlantClick($event)"
246
- @discardResource="discardResource($event)"
247
- />
248
- </template>
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">&times;</span>
373
446
  <div class="modal-title">Final Score</div>
374
- <table class="final-score-table">
375
- <tr>
376
- <th><div>Player</div></th>
377
- <th v-for="player in sortedPlayers" :key="'FS' + player.id">
378
- <div :style="'background-color: ' + playerColors[player.id]">{{ player.name }}</div>
379
- </th>
380
- </tr>
381
- <tr v-for="(cat, i) in ['Cities Powered', 'Money', 'Total Cities']" :key="'FC_' + cat">
382
- <td>{{ cat }}</td>
383
- <td v-for="player in sortedPlayers" :key="'FS' + player.id + i">
384
- <div>
385
- {{ i == 0 ? player.citiesPowered : i == 1 ? player.money : player.cities.length }}
386
- </div>
387
- </td>
388
- </tr>
389
- </table>
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">&times;</span>
396
471
  <div class="modal-title">Spending</div>
397
- <table class="spending-table">
398
- <tr>
399
- <th><div>Player</div></th>
400
- <th v-for="player in sortedPlayers" :key="'FS' + player.id">
401
- <div :style="'background-color: ' + playerColors[player.id]">{{ player.name }}</div>
402
- </th>
403
- </tr>
404
- <tr v-for="row in spendingRows" :key="'FC_' + row.label">
405
- <td>{{ row.label }}</td>
406
- <td v-for="player in sortedPlayers" :key="'FS' + player.id + row.label">
407
- <div>{{ row.value(player) }}</div>
408
- </td>
409
- </tr>
410
- </table>
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 style="white-space: pre-wrap">{{ G.map.mapSpecificRules }}</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
- <table class="payment-table">
530
- <tr>
531
- <td><strong>Cities</strong></td>
532
- <template v-for="index in G.citiesToEndGame">
533
- <td :key="'cities' + index">{{ index - 1 }}</td>
534
- </template>
535
- </tr>
536
- <tr>
537
- <td><strong>Payment</strong></td>
538
- <template v-for="index in G.citiesToEndGame">
539
- <td :key="'payment' + index">${{ G.paymentTable[index - 1] }}</td>
540
- </template>
541
- </tr>
542
- </table>
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 { Button, PassButton, UndoButton, LogButton, SoundButton, HelpButton, RulesButton } from './buttons';
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: 1.9,
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,201 @@ 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
+ * Placement of the icon column beside Pass / Undo / Log. On a portrait viewport
1841
+ * that column gains a fourth button — the layout toggle — in a space authored for
1842
+ * three, and at the authored pitch the fourth one hung 41 units below everything
1843
+ * else and crowded the market row beneath it. On portrait the icons shrink to the
1844
+ * 26-unit height of the text buttons next to them and re-pitch so all four end
1845
+ * level with Log. Landscape and desktop keep the authored positions exactly.
1846
+ */
1847
+ iconButton(index: number): string {
1848
+ if (!this.portraitViewport) return `translate(110, ${13 + 41 * index})`;
1849
+ return `translate(110, ${13 + 30 * index}) scale(${round(26 / 30, 4)})`;
1850
+ }
1851
+
1852
+ /**
1853
+ * Only portrait viewports are re-laid out. A landscape phone already reads
1854
+ * well (the scene's own aspect ratio is close to the screen's), so leaving
1855
+ * it alone keeps the change surface small.
1856
+ */
1857
+ private isPortraitViewport(): boolean {
1858
+ return window.innerWidth < 900 && window.innerHeight > window.innerWidth * 1.1;
1859
+ }
1860
+
1861
+ private shouldStack(): boolean {
1862
+ return this.isPortraitViewport() && this.preferences.stackOnPortrait !== false;
1863
+ }
1864
+
1865
+ toggleStackLayout() {
1866
+ const newVal = !this.stacked;
1867
+
1868
+ this.emitter.emit('update:preference', { name: 'stackOnPortrait', value: newVal });
1869
+ this.preferences.stackOnPortrait = newVal;
1870
+ this.relayout();
1871
+ }
1872
+
1873
+ relayout() {
1874
+ this.portraitViewport = this.isPortraitViewport();
1875
+
1876
+ if (!this.shouldStack()) {
1877
+ if (this.stacked) {
1878
+ this.stacked = false;
1879
+ this.slotTransforms = {};
1880
+ }
1881
+ return;
1882
+ }
1883
+
1884
+ // getBBox() reports a group's box in its OWN user space, i.e. before its
1885
+ // own transform is applied — so measuring is safe even while a previous
1886
+ // stacked transform is in place, and a re-layout never feeds on its own
1887
+ // output.
1888
+ const boxes: Record<string, { x: number; y: number; width: number; height: number }> = {};
1889
+ for (const name of STACK_ROWS.flat()) {
1890
+ const el = this.$refs[slotRef(name)] as SVGGraphicsElement | undefined;
1891
+ if (!el || typeof el.getBBox !== 'function') continue;
1892
+ try {
1893
+ const bb = el.getBBox();
1894
+ if (bb.width > 0 && bb.height > 0) {
1895
+ boxes[name] = { x: bb.x, y: bb.y, width: bb.width, height: bb.height };
1896
+ }
1897
+ } catch {
1898
+ // Not rendered yet (or detached) — skip; the next relayout catches it.
1899
+ }
1900
+ }
1901
+
1902
+ // A row of `h` scene units renders at h * (innerWidth / STACK_WIDTH) css px,
1903
+ // because the canvas width always maps to the viewport width.
1904
+ const unitsPerScreen = (STACK_MAX_ROW_SCREENS * window.innerHeight * STACK_WIDTH) / window.innerWidth;
1905
+ const heightCapFor = (height: number) => unitsPerScreen / height;
1906
+
1907
+ const transforms: Record<string, string> = {};
1908
+ let y = STACK_PAD;
1909
+ for (const row of STACK_ROWS) {
1910
+ const present = row.filter((name) => boxes[name]);
1911
+ if (!present.length) continue;
1912
+
1913
+ const gaps = STACK_GAP * (present.length - 1);
1914
+ const usable = STACK_WIDTH - 2 * STACK_PAD - gaps;
1915
+ const combined = present.reduce((sum, name) => sum + boxes[name].width, 0);
1916
+ // A slot may decline part of the row's scale so a neighbour keeps the
1917
+ // space, and no slot may grow past the height budget for one row.
1918
+ const ceilingOf = (name: string) =>
1919
+ Math.min(
1920
+ STACK_SLOT_MAX_SCALE[name] ?? Infinity,
1921
+ ((STACK_SLOT_MAX_WIDTH[name] ?? Infinity) * usable) / boxes[name].width,
1922
+ heightCapFor(boxes[name].height),
1923
+ STACK_MAX_SCALE
1924
+ );
1925
+ // What a capped slot declines is width left on the table: without this it
1926
+ // became margin either side of the row. Hand it to the slots that can
1927
+ // still grow — the round readout gives up ~130 units beside the buttons,
1928
+ // and the buttons are the part a thumb needs. Repeat until it settles:
1929
+ // growing the free slots can push one of them into its own ceiling, and
1930
+ // then there is more to re-split. rowScale only ever rises here, and a
1931
+ // slot that caps mid-loop leaves the row shorter than solved for, so the
1932
+ // row can never end up wider than the space it was given.
1933
+ // One scale per row keeps neighbours visually consistent. The cap stops a
1934
+ // small strip (the turn-order token row) from being blown up absurdly.
1935
+ let rowScale = Math.min(usable / combined, STACK_MAX_SCALE);
1936
+ for (let pass = 0; pass < present.length; pass++) {
1937
+ let cappedWidth = 0;
1938
+ let freeWidth = 0;
1939
+ for (const name of present) {
1940
+ const ceiling = ceilingOf(name);
1941
+ if (ceiling < rowScale) cappedWidth += boxes[name].width * ceiling;
1942
+ else freeWidth += boxes[name].width;
1943
+ }
1944
+ if (!freeWidth) break;
1945
+ const grown = Math.min((usable - cappedWidth) / freeWidth, STACK_MAX_SCALE);
1946
+ if (grown <= rowScale + 0.0001) break;
1947
+ rowScale = grown;
1948
+ }
1949
+ const scaleOf = (name: string) => Math.min(rowScale, ceilingOf(name));
1950
+ const rowWidth = present.reduce((sum, name) => sum + boxes[name].width * scaleOf(name), 0);
1951
+ const rowHeight = Math.max(...present.map((name) => boxes[name].height * scaleOf(name)));
1952
+
1953
+ let x = (STACK_WIDTH - (rowWidth + gaps)) / 2;
1954
+ for (const name of present) {
1955
+ const bb = boxes[name];
1956
+ const scale = scaleOf(name);
1957
+ // Centre a shorter slot against the tallest one in its row.
1958
+ const top = y + (rowHeight - bb.height * scale) / 2;
1959
+ transforms[name] =
1960
+ `translate(${round(x - bb.x * scale)}, ${round(top - bb.y * scale)}) scale(${round(scale, 4)})`;
1961
+ x += bb.width * scale + STACK_GAP;
1962
+ }
1963
+ y += rowHeight + STACK_GAP;
1964
+ }
1965
+
1966
+ this.stackHeight = Math.round(y + STACK_PAD - STACK_GAP);
1967
+ this.slotTransforms = transforms;
1968
+ this.stacked = true;
1969
+ }
1970
+
1971
+ private scheduleRelayout() {
1972
+ this.$nextTick(() => this.relayout());
1973
+ }
1974
+
1975
+ private onViewportResize = () => this.scheduleRelayout();
1976
+
1977
+ mounted() {
1978
+ window.addEventListener('resize', this.onViewportResize);
1979
+ window.addEventListener('orientationchange', this.onViewportResize);
1980
+ this.scheduleRelayout();
1981
+ }
1982
+
1983
+ beforeDestroy() {
1984
+ window.removeEventListener('resize', this.onViewportResize);
1985
+ window.removeEventListener('orientationchange', this.onViewportResize);
1986
+ }
1987
+
1988
+ // Boards grow as players buy plants, so the row heights are re-derived on
1989
+ // every state change rather than measured once at mount.
1990
+ @Watch('G')
1991
+ onSceneContentChanged() {
1992
+ this.scheduleRelayout();
1993
+ }
1994
+
1600
1995
  get adjustedPlayerOrder() {
1601
1996
  if (!this.G) {
1602
1997
  return [];
@@ -1665,6 +2060,20 @@ ul {
1665
2060
  margin: 40px auto auto auto;
1666
2061
  }
1667
2062
 
2063
+ // Portrait: the scene is taller than the viewport by design (each row is scaled
2064
+ // to the full width), so it must be allowed to overflow and scroll instead of
2065
+ // being squeezed back into one screen.
2066
+ .game.stacked {
2067
+ height: auto;
2068
+ align-items: stretch;
2069
+
2070
+ #scene {
2071
+ max-height: none;
2072
+ flex-grow: 0;
2073
+ margin-top: 40px;
2074
+ }
2075
+ }
2076
+
1668
2077
  body,
1669
2078
  html {
1670
2079
  height: 100%;
@@ -1766,6 +2175,8 @@ text {
1766
2175
  margin: auto;
1767
2176
  padding: 10px 20px 20px 20px;
1768
2177
  border: 1px solid #888;
2178
+ box-sizing: border-box;
2179
+ max-width: 100%;
1769
2180
  }
1770
2181
 
1771
2182
  @media only screen and (min-width: 1240px) {
@@ -1820,6 +2231,25 @@ text {
1820
2231
  cursor: pointer;
1821
2232
  }
1822
2233
 
2234
+ // Printed rules text keeps its authored line breaks, but a long unbroken run
2235
+ // (a city list, a URL) must still fold rather than widen the dialog.
2236
+ .map-specific-rules {
2237
+ display: block;
2238
+ white-space: pre-wrap;
2239
+ overflow-wrap: anywhere;
2240
+ }
2241
+
2242
+ // Tables are naturally wider than a phone (one column per player, plus a wide
2243
+ // label column). Without a scroll container they simply overflowed the modal and
2244
+ // the far columns were unreachable — on a 6-player game only the last player was
2245
+ // visible. The container scrolls; the label column stays pinned so a value never
2246
+ // loses its row.
2247
+ .table-scroll {
2248
+ max-width: 100%;
2249
+ overflow-x: auto;
2250
+ -webkit-overflow-scrolling: touch;
2251
+ }
2252
+
1823
2253
  .payment-table {
1824
2254
  border: 1px solid black;
1825
2255
  margin: 5px auto;
@@ -1854,6 +2284,11 @@ text {
1854
2284
 
1855
2285
  td:first-child,
1856
2286
  th:first-child {
2287
+ position: sticky;
2288
+ left: 0;
2289
+ background-color: #fefefe;
2290
+ box-shadow: 2px 0 3px -1px rgba(0, 0, 0, 0.3);
2291
+
1857
2292
  div {
1858
2293
  width: 250px;
1859
2294
  }
@@ -1861,6 +2296,45 @@ text {
1861
2296
  }
1862
2297
  }
1863
2298
 
2299
+ // Phone-sized screens: shrink the columns so more than one player fits on
2300
+ // screen before any scrolling is needed.
2301
+ @media only screen and (max-width: 700px) {
2302
+ .modal-content {
2303
+ padding: 10px;
2304
+ }
2305
+
2306
+ .final-score-table,
2307
+ .spending-table {
2308
+ tr {
2309
+ td,
2310
+ th {
2311
+ div {
2312
+ width: auto;
2313
+ min-width: 60px;
2314
+ padding: 0 6px;
2315
+ line-height: 32px;
2316
+ }
2317
+ }
2318
+
2319
+ td:first-child,
2320
+ th:first-child {
2321
+ div {
2322
+ width: auto;
2323
+ min-width: 0;
2324
+ max-width: 38vw;
2325
+ text-align: left;
2326
+ }
2327
+ }
2328
+ }
2329
+ }
2330
+
2331
+ .payment-table {
2332
+ tr td {
2333
+ padding: 0 6px;
2334
+ }
2335
+ }
2336
+ }
2337
+
1864
2338
  .confirm-message {
1865
2339
  font-size: 18px;
1866
2340
  padding: 10px;