powergrid-viewer 2.0.4 → 2.0.5
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 +1 -1
- package/src/components/Game.vue +172 -6
- package/src/components/boards/ResourceBoxes.vue +212 -0
- package/src/launch.ts +8 -3
- package/src/util/resource-rows.ts +246 -0
- package/dist/img/help.ba7779cc.svg +0 -1
- package/dist/img/log.04ef6981.svg +0 -1
- package/dist/img/pass.da9065dc.svg +0 -3
- package/dist/img/rules.64f9aae5.svg +0 -28
- package/dist/img/sound-off.72ada995.svg +0 -3
- package/dist/img/sound-on.c55edd90.svg +0 -3
- package/dist/img/undo.208666d2.svg +0 -3
- package/dist/media/notification.55fa47dd.ogg +0 -0
- package/dist/media/notification.ac905963.mp3 +0 -0
- package/dist/media/piece-drop.eef5f607.mp3 +0 -0
package/package.json
CHANGED
package/src/components/Game.vue
CHANGED
|
@@ -143,7 +143,23 @@
|
|
|
143
143
|
</g>
|
|
144
144
|
|
|
145
145
|
<g ref="slotResources" :transform="slotT('resources')">
|
|
146
|
+
<!-- On a phone the printed price track is a strip of unreadable
|
|
147
|
+
columns, so the stacked layout swaps it for one box per buyable
|
|
148
|
+
source. Landscape and desktop keep the printed board exactly as
|
|
149
|
+
it was — this is an either/or, never an overlay. -->
|
|
150
|
+
<ResourceBoxes
|
|
151
|
+
v-if="stacked"
|
|
152
|
+
:transform="`translate(${G.map.supplyPosition[0]}, ${G.map.supplyPosition[1]})`"
|
|
153
|
+
:gameState="G"
|
|
154
|
+
:player="player"
|
|
155
|
+
:isUsaRecharged="G.options.variant == 'recharged' && G.map.name == 'USA'"
|
|
156
|
+
:buyableResources="buyableResources()"
|
|
157
|
+
:resourceResupply="getResourceResupply()"
|
|
158
|
+
:resourceResupplyNorth="getResourceResupplyNorth()"
|
|
159
|
+
@buyResource="buyResource($event)"
|
|
160
|
+
/>
|
|
146
161
|
<Resources
|
|
162
|
+
v-else
|
|
147
163
|
ref="resources"
|
|
148
164
|
:transform="`translate(${G.map.supplyPosition[0]}, ${G.map.supplyPosition[1]})`"
|
|
149
165
|
:isUsaRecharged="G.options.variant == 'recharged' && G.map.name == 'USA'"
|
|
@@ -162,12 +178,76 @@
|
|
|
162
178
|
/>
|
|
163
179
|
</g>
|
|
164
180
|
|
|
181
|
+
<!-- Australia: the same uranium-mine selling table laid on its side for
|
|
182
|
+
portrait. A 104x430 strip is the worst possible shape for a full-width
|
|
183
|
+
row — it can only grow until its HEIGHT fills the row, so it ends up a
|
|
184
|
+
sliver with the whole width beside it empty. Turned through 90° the six
|
|
185
|
+
prices run left to right in the same order they run top to bottom, and
|
|
186
|
+
the row is filled by something worth reading. -->
|
|
187
|
+
<g
|
|
188
|
+
v-if="stacked && G.map.name === 'Australia' && G.uraniumMineMarket"
|
|
189
|
+
ref="slotUraniumMines"
|
|
190
|
+
:transform="slotT('uraniumMines') || 'translate(15, 70)'"
|
|
191
|
+
>
|
|
192
|
+
<rect x="0" y="0" width="620" height="146" rx="6" fill="#8aa84a" stroke="#4d6322" stroke-width="3" />
|
|
193
|
+
<text x="310" y="26" text-anchor="middle" font-weight="700" fill="black" style="font-size: 22px">
|
|
194
|
+
Uranium mine market
|
|
195
|
+
</text>
|
|
196
|
+
<g v-for="col in 6" :key="'uraniumCol' + col" :transform="`translate(${10 + (col - 1) * 100}, 0)`">
|
|
197
|
+
<text x="50" y="56" text-anchor="middle" font-weight="700" fill="black" style="font-size: 20px">
|
|
198
|
+
${{ 8 - col }}
|
|
199
|
+
</text>
|
|
200
|
+
<rect
|
|
201
|
+
x="21"
|
|
202
|
+
y="66"
|
|
203
|
+
width="26"
|
|
204
|
+
height="26"
|
|
205
|
+
rx="3"
|
|
206
|
+
fill="goldenrod"
|
|
207
|
+
stroke="#4d6322"
|
|
208
|
+
stroke-width="1.5"
|
|
209
|
+
/>
|
|
210
|
+
<rect
|
|
211
|
+
x="53"
|
|
212
|
+
y="66"
|
|
213
|
+
width="26"
|
|
214
|
+
height="26"
|
|
215
|
+
rx="3"
|
|
216
|
+
fill="goldenrod"
|
|
217
|
+
stroke="#4d6322"
|
|
218
|
+
stroke-width="1.5"
|
|
219
|
+
/>
|
|
220
|
+
<circle
|
|
221
|
+
v-if="(G.uraniumMineMarket[6 - col] || 0) >= 1"
|
|
222
|
+
cx="34"
|
|
223
|
+
cy="79"
|
|
224
|
+
r="10"
|
|
225
|
+
fill="#46c655"
|
|
226
|
+
stroke="#1f5c25"
|
|
227
|
+
stroke-width="2"
|
|
228
|
+
/>
|
|
229
|
+
<circle
|
|
230
|
+
v-if="(G.uraniumMineMarket[6 - col] || 0) >= 2"
|
|
231
|
+
cx="66"
|
|
232
|
+
cy="79"
|
|
233
|
+
r="10"
|
|
234
|
+
fill="#46c655"
|
|
235
|
+
stroke="#1f5c25"
|
|
236
|
+
stroke-width="2"
|
|
237
|
+
/>
|
|
238
|
+
</g>
|
|
239
|
+
<line x1="10" y1="108" x2="610" y2="108" stroke="#4d6322" stroke-width="1" />
|
|
240
|
+
<text x="310" y="132" text-anchor="middle" font-weight="700" fill="#22340f" style="font-size: 18px">
|
|
241
|
+
refill −{{ G.map.uraniumMineResupply[G.players.length - 2][G.step - 1] }}/rnd
|
|
242
|
+
</text>
|
|
243
|
+
</g>
|
|
244
|
+
|
|
165
245
|
<!-- Australia: uranium-mine selling table. Six price rows $7 (top) →
|
|
166
246
|
$2 (bottom), two token slots each. Sellers place one token per mine
|
|
167
247
|
on the highest empty slot; the resource refill removes from the
|
|
168
248
|
cheap (bottom) end. Lives in the clear upper-left margin. -->
|
|
169
249
|
<g
|
|
170
|
-
v-if="G.map.name === 'Australia' && G.uraniumMineMarket"
|
|
250
|
+
v-if="!stacked && G.map.name === 'Australia' && G.uraniumMineMarket"
|
|
171
251
|
ref="slotUraniumMines"
|
|
172
252
|
:transform="slotT('uraniumMines') || 'translate(15, 70)'"
|
|
173
253
|
>
|
|
@@ -310,7 +390,7 @@
|
|
|
310
390
|
:ended="gameEnded(G)"
|
|
311
391
|
:isPlayer="player == playerIndex"
|
|
312
392
|
:ranking="sortedPlayers.findIndex((x) => x.id == G.players[playerIndex].id) + 1"
|
|
313
|
-
:showMoney="player == playerIndex ||
|
|
393
|
+
:showMoney="player == playerIndex || moneyIsPublic"
|
|
314
394
|
:showBid="!G.options.fastBid"
|
|
315
395
|
:phase="G.phase"
|
|
316
396
|
:isAustralia="G.map.name === 'Australia'"
|
|
@@ -660,6 +740,7 @@ import PowerPlantMarket from './boards/PowerPlantMarket.vue';
|
|
|
660
740
|
import PlayerOrder from './boards/PlayerOrder.vue';
|
|
661
741
|
import CityCount from './boards/CityCount.vue';
|
|
662
742
|
import Map from './boards/Map.vue';
|
|
743
|
+
import ResourceBoxes from './boards/ResourceBoxes.vue';
|
|
663
744
|
import Resources from './boards/Resources.vue';
|
|
664
745
|
import { LogMove } from 'powergrid-engine/src/log';
|
|
665
746
|
import { Phase, playerTimeUsed, PowerPlant, PowerPlantType, ResourceType } from 'powergrid-engine/src/gamestate';
|
|
@@ -719,6 +800,9 @@ const STACK_SLOT_MAX_SCALE: Record<string, number> = {
|
|
|
719
800
|
*/
|
|
720
801
|
const STACK_SLOT_MAX_WIDTH: Record<string, number> = {
|
|
721
802
|
powerPlantMarket: 0.88,
|
|
803
|
+
// The box market is one tall stack of full-width buttons; run edge to edge it
|
|
804
|
+
// reads as though it had been cropped rather than laid out.
|
|
805
|
+
resources: 0.94,
|
|
722
806
|
};
|
|
723
807
|
|
|
724
808
|
/**
|
|
@@ -794,7 +878,8 @@ const round = (n: number, digits = 2) => Number(n.toFixed(digits));
|
|
|
794
878
|
PlayerOrder,
|
|
795
879
|
CityCount,
|
|
796
880
|
Map,
|
|
797
|
-
Resources
|
|
881
|
+
Resources,
|
|
882
|
+
ResourceBoxes
|
|
798
883
|
},
|
|
799
884
|
})
|
|
800
885
|
export default class Game extends Vue {
|
|
@@ -822,6 +907,11 @@ export default class Game extends Vue {
|
|
|
822
907
|
|
|
823
908
|
paused = false;
|
|
824
909
|
|
|
910
|
+
// Set once the platform has served a finished game, and never cleared.
|
|
911
|
+
// Reactive on purpose: `_futureState` is not, and the money gate has to
|
|
912
|
+
// re-evaluate as the replay scrubber steps through the game.
|
|
913
|
+
gameIsOver = false;
|
|
914
|
+
|
|
825
915
|
@Provide()
|
|
826
916
|
communicator: EventEmitter = new EventEmitter();
|
|
827
917
|
|
|
@@ -944,11 +1034,19 @@ export default class Game extends Vue {
|
|
|
944
1034
|
return state;
|
|
945
1035
|
}
|
|
946
1036
|
|
|
947
|
-
replaceState(state: GameState, replaceState = true) {
|
|
1037
|
+
replaceState(state: GameState, replaceState = true, playSound = true) {
|
|
948
1038
|
if (replaceState) {
|
|
949
1039
|
this._futureState = state;
|
|
950
1040
|
}
|
|
951
1041
|
|
|
1042
|
+
// Remember that the platform has served us a finished game. The replay
|
|
1043
|
+
// scrubber renders reconstructed MID-game states, whose own `ended()` is
|
|
1044
|
+
// false however long ago the game finished, so it cannot be asked. See
|
|
1045
|
+
// `moneyIsPublic`.
|
|
1046
|
+
if (replaceState && ended(state)) {
|
|
1047
|
+
this.gameIsOver = true;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
952
1050
|
// if player is selecting resources, keep the state
|
|
953
1051
|
let player: any;
|
|
954
1052
|
if (this.player != null) {
|
|
@@ -974,7 +1072,8 @@ export default class Game extends Vue {
|
|
|
974
1072
|
this.playerOrder.createPieces(this.G!);
|
|
975
1073
|
this.cityCount.createPieces(this.G!);
|
|
976
1074
|
this.map.createPieces(this.G!);
|
|
977
|
-
|
|
1075
|
+
// Absent while the portrait layout is showing the box market instead.
|
|
1076
|
+
this.resources?.createPieces(this.G!);
|
|
978
1077
|
// Pieces are added imperatively, so the groups only reach their
|
|
979
1078
|
// final size here — the portrait layout must measure after this,
|
|
980
1079
|
// not on the state change that triggered it.
|
|
@@ -982,7 +1081,7 @@ export default class Game extends Vue {
|
|
|
982
1081
|
});
|
|
983
1082
|
}
|
|
984
1083
|
|
|
985
|
-
if (this.preferences.sound && this.G?.log[this.G?.log.length - 1].type == 'move') {
|
|
1084
|
+
if (playSound && this.preferences.sound && this.G?.log[this.G?.log.length - 1].type == 'move') {
|
|
986
1085
|
const move = (this.G?.log[this.G?.log.length - 1] as LogMove).move;
|
|
987
1086
|
if (move.name == MoveName.Pass && this.G.currentPlayers.includes(this.player!)) {
|
|
988
1087
|
(document.getElementById('notification')!.cloneNode(true) as HTMLAudioElement).play();
|
|
@@ -1385,15 +1484,69 @@ export default class Game extends Vue {
|
|
|
1385
1484
|
// replays (and the eventual committed log) reproduce the same times.
|
|
1386
1485
|
this.turnMoves.push({ ...move, time: Date.now() });
|
|
1387
1486
|
|
|
1487
|
+
// Preview the move locally BEFORE sending it. Without this the board — and with
|
|
1488
|
+
// it `availableMoves` — stays frozen on the last server reply, so every click
|
|
1489
|
+
// made while a response is in flight is judged against a stale list. Buy one
|
|
1490
|
+
// resource past what your plants can hold and the platform rejects the whole
|
|
1491
|
+
// replayed buffer with "Wrong argument for the command BuyResource"; the
|
|
1492
|
+
// rejected move then stays in the buffer, so every later action resends it and
|
|
1493
|
+
// fails again until the page is refreshed (#131).
|
|
1494
|
+
//
|
|
1495
|
+
// Replaying the buffer on the committed state is exact: any move with hidden
|
|
1496
|
+
// side effects commits, which ends the buffer (see util/turn-buffer).
|
|
1497
|
+
let preview: GameState | null = null;
|
|
1498
|
+
|
|
1499
|
+
if (this.committedState && this.player != undefined) {
|
|
1500
|
+
const buffered = this.turnMoves.length;
|
|
1501
|
+
preview = this.replayTurnBuffer();
|
|
1502
|
+
|
|
1503
|
+
if (this.turnMoves.length < buffered) {
|
|
1504
|
+
// The engine refused the move we just added, so it never reaches the
|
|
1505
|
+
// server and the board already shows the truth.
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1388
1510
|
// Send the WHOLE turn so far: the platform is stateless between calls and
|
|
1389
1511
|
// replays the buffer from the last committed (saved) state.
|
|
1390
1512
|
this.emitter.emit('move', [...this.turnMoves]);
|
|
1513
|
+
|
|
1514
|
+
// Only adopt a preview that is still TENTATIVE. A committing move (Pass, a bid
|
|
1515
|
+
// that ends an auction) replays hidden outcomes — deck draws, upkeep — on a
|
|
1516
|
+
// state whose deck and seed are stripped, so its preview would flash bogus
|
|
1517
|
+
// results; wait for the server's committed answer instead. The echo of this
|
|
1518
|
+
// same buffer lands next and is authoritative, and it owns the sound, so the
|
|
1519
|
+
// preview stays silent rather than doubling it.
|
|
1520
|
+
if (preview && preview.newTurn === false) {
|
|
1521
|
+
this.replaceState(preview, false, false);
|
|
1522
|
+
}
|
|
1391
1523
|
}
|
|
1392
1524
|
|
|
1393
1525
|
gameEnded(G: GameState) {
|
|
1394
1526
|
return ended(G);
|
|
1395
1527
|
}
|
|
1396
1528
|
|
|
1529
|
+
/**
|
|
1530
|
+
* Is every player's cash public?
|
|
1531
|
+
*
|
|
1532
|
+
* Money is hidden information while a game is running, but a finished game has
|
|
1533
|
+
* nothing left to hide — the engine's `stripSecret` reveals it and the end-game
|
|
1534
|
+
* table prints it. The replay scrubber, though, shows a RECONSTRUCTED mid-game
|
|
1535
|
+
* state, and `ended()` on that state is false however long ago the game actually
|
|
1536
|
+
* finished. So stepping back through a finished game blacked out everyone
|
|
1537
|
+
* else's cash, which is most of what makes a replay worth reviewing: you could
|
|
1538
|
+
* not tell whether someone ending the game early would have changed the result
|
|
1539
|
+
* (eric-hu, #130). Ask whether the GAME is over, not whether the step being
|
|
1540
|
+
* displayed is.
|
|
1541
|
+
*
|
|
1542
|
+
* An in-progress game stays hidden, which is the point — the reconstruction
|
|
1543
|
+
* derives every player's true cash from the log, so this gate is the only thing
|
|
1544
|
+
* standing between a mid-game replay and other people's wallets.
|
|
1545
|
+
*/
|
|
1546
|
+
get moneyIsPublic(): boolean {
|
|
1547
|
+
return !!this.G && (!!this.G.options.showMoney || this.gameIsOver || ended(this.G));
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1397
1550
|
canMove() {
|
|
1398
1551
|
return (
|
|
1399
1552
|
this.player != undefined &&
|
|
@@ -1968,6 +2121,19 @@ export default class Game extends Vue {
|
|
|
1968
2121
|
this.stacked = true;
|
|
1969
2122
|
}
|
|
1970
2123
|
|
|
2124
|
+
/**
|
|
2125
|
+
* Entering or leaving the stacked layout swaps the printed resource track for the
|
|
2126
|
+
* box market, which is a different shape entirely. `relayout` sets `stacked` last,
|
|
2127
|
+
* so the swap only reaches the DOM a tick later — by which point the row heights
|
|
2128
|
+
* it just solved for describe the component that is no longer there. Measure again
|
|
2129
|
+
* once the swap has landed. This settles after one extra pass: the second run
|
|
2130
|
+
* leaves `stacked` unchanged, so it does not re-trigger.
|
|
2131
|
+
*/
|
|
2132
|
+
@Watch('stacked')
|
|
2133
|
+
onStackedChanged() {
|
|
2134
|
+
this.scheduleRelayout();
|
|
2135
|
+
}
|
|
2136
|
+
|
|
1971
2137
|
private scheduleRelayout() {
|
|
1972
2138
|
this.$nextTick(() => this.relayout());
|
|
1973
2139
|
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<g>
|
|
3
|
+
<template v-for="(block, b) in blocks">
|
|
4
|
+
<g :key="'block' + b" :transform="`translate(0, ${block.y})`">
|
|
5
|
+
<text v-if="block.title" x="4" y="16" font-weight="700" fill="black" style="font-size: 26px">
|
|
6
|
+
{{ block.title }}
|
|
7
|
+
</text>
|
|
8
|
+
|
|
9
|
+
<!-- Restock, as the printed refill card reads it: one number per row,
|
|
10
|
+
in the same order the rows are drawn. -->
|
|
11
|
+
<text
|
|
12
|
+
v-if="block.restock"
|
|
13
|
+
:x="BOX_W - 4"
|
|
14
|
+
y="16"
|
|
15
|
+
text-anchor="end"
|
|
16
|
+
font-weight="600"
|
|
17
|
+
fill="#3a2c08"
|
|
18
|
+
style="font-size: 24px"
|
|
19
|
+
>
|
|
20
|
+
Restock {{ block.restock }}
|
|
21
|
+
</text>
|
|
22
|
+
|
|
23
|
+
<text x="4" y="42" fill="#3a2c08" style="font-size: 24px; letter-spacing: 1px">CURRENT PRICE</text>
|
|
24
|
+
<text
|
|
25
|
+
:x="CUBE_BAND_CENTRE"
|
|
26
|
+
y="42"
|
|
27
|
+
text-anchor="middle"
|
|
28
|
+
fill="#3a2c08"
|
|
29
|
+
style="font-size: 24px; letter-spacing: 1px"
|
|
30
|
+
>
|
|
31
|
+
AT THIS PRICE
|
|
32
|
+
</text>
|
|
33
|
+
<text
|
|
34
|
+
:x="BOX_W - 4"
|
|
35
|
+
y="42"
|
|
36
|
+
text-anchor="end"
|
|
37
|
+
fill="#3a2c08"
|
|
38
|
+
style="font-size: 24px; letter-spacing: 1px"
|
|
39
|
+
>
|
|
40
|
+
CUBES LEFT
|
|
41
|
+
</text>
|
|
42
|
+
|
|
43
|
+
<g
|
|
44
|
+
v-for="(row, r) in block.rows"
|
|
45
|
+
:key="'row' + r"
|
|
46
|
+
:transform="`translate(0, ${HEAD_H + r * (BOX_H + BOX_GAP)})`"
|
|
47
|
+
:class="[{ canClick: row.buyable }]"
|
|
48
|
+
@click="row.buyable && $emit('buyResource', row.move)"
|
|
49
|
+
>
|
|
50
|
+
<rect
|
|
51
|
+
:width="BOX_W"
|
|
52
|
+
:height="BOX_H"
|
|
53
|
+
rx="6"
|
|
54
|
+
:fill="row.cubes > 0 ? 'darkgoldenrod' : '#9c8236'"
|
|
55
|
+
:stroke="row.buyable ? '#1e63d0' : '#6b5312'"
|
|
56
|
+
:stroke-width="row.buyable ? 5 : 2"
|
|
57
|
+
:opacity="row.cubes > 0 ? 1 : 0.55"
|
|
58
|
+
/>
|
|
59
|
+
<!-- Whole box is the tap target, cubes and all. -->
|
|
60
|
+
<rect
|
|
61
|
+
v-if="row.buyable"
|
|
62
|
+
:width="BOX_W"
|
|
63
|
+
:height="BOX_H"
|
|
64
|
+
rx="6"
|
|
65
|
+
fill="transparent"
|
|
66
|
+
pointer-events="all"
|
|
67
|
+
/>
|
|
68
|
+
|
|
69
|
+
<text x="18" y="26" font-weight="700" fill="black" style="font-size: 22px; letter-spacing: 1px">
|
|
70
|
+
{{ row.label }}
|
|
71
|
+
</text>
|
|
72
|
+
|
|
73
|
+
<template v-if="row.price != null">
|
|
74
|
+
<text x="18" y="80" font-weight="700" fill="black" style="font-size: 26px">$</text>
|
|
75
|
+
<text x="40" y="84" font-weight="700" fill="black" style="font-size: 46px">
|
|
76
|
+
{{ row.price }}
|
|
77
|
+
</text>
|
|
78
|
+
<text v-if="row.next != null" x="132" y="80" fill="#3a2c08" style="font-size: 22px">
|
|
79
|
+
then ${{ row.next }}
|
|
80
|
+
</text>
|
|
81
|
+
</template>
|
|
82
|
+
<text v-else x="18" y="78" font-weight="700" fill="black" style="font-size: 28px">out</text>
|
|
83
|
+
|
|
84
|
+
<!-- One dot per cube still buyable at the current price. A pool
|
|
85
|
+
with no price track behind it has no such number: every cube
|
|
86
|
+
in it costs the same, so say that instead of drawing dots. -->
|
|
87
|
+
<circle
|
|
88
|
+
v-for="n in row.atPrice"
|
|
89
|
+
:key="'cube' + n"
|
|
90
|
+
:cx="cubeX(row.atPrice, n)"
|
|
91
|
+
:cy="58"
|
|
92
|
+
r="17"
|
|
93
|
+
:fill="row.color"
|
|
94
|
+
stroke="black"
|
|
95
|
+
stroke-width="2"
|
|
96
|
+
/>
|
|
97
|
+
<text v-if="row.flat && row.cubes > 0" x="300" y="66" fill="#3a2c08" style="font-size: 22px">
|
|
98
|
+
every cube at this price
|
|
99
|
+
</text>
|
|
100
|
+
|
|
101
|
+
<text
|
|
102
|
+
:x="BOX_W - 18"
|
|
103
|
+
y="68"
|
|
104
|
+
text-anchor="end"
|
|
105
|
+
font-weight="700"
|
|
106
|
+
fill="black"
|
|
107
|
+
style="font-size: 40px"
|
|
108
|
+
>
|
|
109
|
+
{{ row.cubes }}
|
|
110
|
+
</text>
|
|
111
|
+
<text :x="BOX_W - 18" y="90" text-anchor="end" fill="#3a2c08" style="font-size: 20px">LEFT</text>
|
|
112
|
+
|
|
113
|
+
<!-- India caps which prices are on sale per step; a row above the
|
|
114
|
+
cap is on the board but cannot be bought by anyone yet. -->
|
|
115
|
+
<text v-if="row.capped" x="130" y="96" fill="#7a1d12" font-weight="600" style="font-size: 20px">
|
|
116
|
+
step {{ step }} sells to ${{ maxPrice }}
|
|
117
|
+
</text>
|
|
118
|
+
|
|
119
|
+
<title>{{ row.title }}</title>
|
|
120
|
+
</g>
|
|
121
|
+
</g>
|
|
122
|
+
</template>
|
|
123
|
+
</g>
|
|
124
|
+
</template>
|
|
125
|
+
|
|
126
|
+
<script lang="ts">
|
|
127
|
+
import { GameState } from 'powergrid-engine';
|
|
128
|
+
import { Component, Prop, Vue } from 'vue-property-decorator';
|
|
129
|
+
import { BuyMove, ResourceBlock, resourceBlocks } from '../../util/resource-rows';
|
|
130
|
+
|
|
131
|
+
const BOX_W = 700;
|
|
132
|
+
const BOX_H = 104;
|
|
133
|
+
const BOX_GAP = 8;
|
|
134
|
+
/** Block title + column captions above the first box. */
|
|
135
|
+
const HEAD_H = 62;
|
|
136
|
+
const BLOCK_GAP = 30;
|
|
137
|
+
const CUBE_PITCH = 44;
|
|
138
|
+
/** Middle of the band between the price block and the cubes-left count. */
|
|
139
|
+
const CUBE_BAND_CENTRE = 420;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The portrait resource market.
|
|
143
|
+
*
|
|
144
|
+
* The printed price track is a beautiful object and an unreadable one on a phone:
|
|
145
|
+
* eight to ten columns of three-cube slots, drawn at whatever width the map author
|
|
146
|
+
* chose and then squeezed into a strip. This draws the same market as one box per
|
|
147
|
+
* buyable source, carrying only what a purchase actually needs — what the next cube
|
|
148
|
+
* costs, how many are left at that price, how many are left at all.
|
|
149
|
+
*
|
|
150
|
+
* Every number comes out of the engine state rather than the printed geometry, so the
|
|
151
|
+
* map-specific markets come along for free: Europe's nine price columns, Korea's two
|
|
152
|
+
* sides, Australia's Step-3 CO2 shift (the engine rewrites the price array and the box
|
|
153
|
+
* is only ever a view of it), India's per-step price cap, South Africa's $8 storage
|
|
154
|
+
* pool, USA recharged's $8-from-supply once the track is bare.
|
|
155
|
+
*
|
|
156
|
+
* Which rows a player may actually tap is NOT decided here: `buyableResources` is the
|
|
157
|
+
* engine's own availableMoves, so money, storage capacity, the hybrid tank and Korea's
|
|
158
|
+
* side lock are all accounted for already. The row model itself lives in
|
|
159
|
+
* `util/resource-rows`, where it can be tested against the engine's own arithmetic.
|
|
160
|
+
*/
|
|
161
|
+
@Component
|
|
162
|
+
export default class ResourceBoxes extends Vue {
|
|
163
|
+
@Prop() gameState!: GameState;
|
|
164
|
+
@Prop() buyableResources?: BuyMove[];
|
|
165
|
+
@Prop() resourceResupply?: (number | string)[];
|
|
166
|
+
@Prop() resourceResupplyNorth?: (number | string)[];
|
|
167
|
+
@Prop() isUsaRecharged?: boolean;
|
|
168
|
+
/** The seat looking at the board — only needed for Central Europe's Wien discount. */
|
|
169
|
+
@Prop() player?: number;
|
|
170
|
+
|
|
171
|
+
BOX_W = BOX_W;
|
|
172
|
+
BOX_H = BOX_H;
|
|
173
|
+
BOX_GAP = BOX_GAP;
|
|
174
|
+
HEAD_H = HEAD_H;
|
|
175
|
+
CUBE_BAND_CENTRE = CUBE_BAND_CENTRE;
|
|
176
|
+
|
|
177
|
+
/** Centre the cube cluster in that band, however many cubes there are. */
|
|
178
|
+
cubeX(count: number, n: number): number {
|
|
179
|
+
return CUBE_BAND_CENTRE - ((count - 1) * CUBE_PITCH) / 2 + (n - 1) * CUBE_PITCH;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
get step(): number {
|
|
183
|
+
return this.gameState.step;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
get maxPrice(): number {
|
|
187
|
+
const table = (this.gameState.map as { maxPriceAvailable?: number[] }).maxPriceAvailable;
|
|
188
|
+
return table ? table[this.gameState.step - 1] : 16;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
get blocks(): (ResourceBlock & { y: number })[] {
|
|
192
|
+
let y = 0;
|
|
193
|
+
return resourceBlocks(this.gameState, {
|
|
194
|
+
player: this.player,
|
|
195
|
+
buyable: this.buyableResources,
|
|
196
|
+
resupply: this.resourceResupply,
|
|
197
|
+
resupplyNorth: this.resourceResupplyNorth,
|
|
198
|
+
isUsaRecharged: this.isUsaRecharged,
|
|
199
|
+
}).map((block) => {
|
|
200
|
+
const placed = { ...block, y };
|
|
201
|
+
y += HEAD_H + block.rows.length * (BOX_H + BOX_GAP) - BOX_GAP + BLOCK_GAP;
|
|
202
|
+
return placed;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
</script>
|
|
207
|
+
|
|
208
|
+
<style lang="scss" scoped>
|
|
209
|
+
.canClick {
|
|
210
|
+
cursor: pointer;
|
|
211
|
+
}
|
|
212
|
+
</style>
|
package/src/launch.ts
CHANGED
|
@@ -15,14 +15,18 @@ function launch(selector: string) {
|
|
|
15
15
|
} = {
|
|
16
16
|
state: null,
|
|
17
17
|
emitter: new EventEmitter(),
|
|
18
|
-
|
|
18
|
+
// Observable so preference changes update the UI immediately: Game receives
|
|
19
|
+
// this object as a prop, and Vue 2 does not deep-observe prop values coming
|
|
20
|
+
// from a non-reactive parent — plain-object mutations (the in-game sound/help
|
|
21
|
+
// toggles, platform preference pushes) would only paint on the next re-render.
|
|
22
|
+
preferences: Vue.observable({
|
|
19
23
|
sound: true,
|
|
20
24
|
disableHelp: false,
|
|
21
25
|
adjustPlayerOrder: false,
|
|
22
26
|
undoWholeTurn: true,
|
|
23
27
|
fitToScreen: true,
|
|
24
28
|
stackOnPortrait: true,
|
|
25
|
-
},
|
|
29
|
+
}),
|
|
26
30
|
avatars: [],
|
|
27
31
|
};
|
|
28
32
|
|
|
@@ -61,7 +65,8 @@ function launch(selector: string) {
|
|
|
61
65
|
app.$forceUpdate();
|
|
62
66
|
});
|
|
63
67
|
item.addListener('preferences', (data) => {
|
|
64
|
-
|
|
68
|
+
// Mutate (don't replace) the observable object so the update stays reactive
|
|
69
|
+
Object.assign(params.preferences, data);
|
|
65
70
|
app.$forceUpdate();
|
|
66
71
|
});
|
|
67
72
|
item.addListener('gamelog', (logData) => {
|