powergrid-viewer 2.0.4 → 2.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +224 -7
- package/src/components/boards/ResourceBoxes.vue +274 -0
- package/src/components/boards/Resources.vue +80 -16
- package/src/components/pieces/Coal.vue +1 -1
- package/src/components/pieces/Garbage.vue +1 -1
- package/src/components/pieces/Oil.vue +1 -1
- package/src/components/pieces/Piece.vue +14 -0
- package/src/components/pieces/Uranium.vue +1 -1
- package/src/launch.ts +8 -3
- package/src/types/ui-data.ts +5 -0
- package/src/util/resource-rows.ts +246 -0
- package/src/util/turn-buffer.ts +66 -1
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "powergrid-viewer",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.6",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/boardgamers/powergrid.git",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"vue": "^2.6.11",
|
|
19
19
|
"vue-class-component": "^7.2.3",
|
|
20
20
|
"vue-property-decorator": "^8.4.2",
|
|
21
|
-
"powergrid-engine": "2.0.
|
|
21
|
+
"powergrid-engine": "2.0.4"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@types/assert": "^1.4.7",
|
package/src/components/Game.vue
CHANGED
|
@@ -143,7 +143,25 @@
|
|
|
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
|
+
:bufferedBuys="bufferedBuys"
|
|
160
|
+
@buyResource="buyResource($event)"
|
|
161
|
+
@unbuyResource="unbuyResource($event)"
|
|
162
|
+
/>
|
|
146
163
|
<Resources
|
|
164
|
+
v-else
|
|
147
165
|
ref="resources"
|
|
148
166
|
:transform="`translate(${G.map.supplyPosition[0]}, ${G.map.supplyPosition[1]})`"
|
|
149
167
|
:isUsaRecharged="G.options.variant == 'recharged' && G.map.name == 'USA'"
|
|
@@ -158,16 +176,82 @@
|
|
|
158
176
|
:coalStorage="G.coalStorage"
|
|
159
177
|
:resourceResupply="getResourceResupply()"
|
|
160
178
|
:resourceResupplyNorth="getResourceResupplyNorth()"
|
|
179
|
+
:bufferedBuys="bufferedBuys"
|
|
161
180
|
@buyResource="buyResource($event)"
|
|
181
|
+
@unbuyResource="unbuyResource($event)"
|
|
162
182
|
/>
|
|
163
183
|
</g>
|
|
164
184
|
|
|
185
|
+
<!-- Australia: the same uranium-mine selling table laid on its side for
|
|
186
|
+
portrait. A 104x430 strip is the worst possible shape for a full-width
|
|
187
|
+
row — it can only grow until its HEIGHT fills the row, so it ends up a
|
|
188
|
+
sliver with the whole width beside it empty. Turned through 90° the six
|
|
189
|
+
prices run left to right in the same order they run top to bottom, and
|
|
190
|
+
the row is filled by something worth reading. -->
|
|
191
|
+
<g
|
|
192
|
+
v-if="stacked && G.map.name === 'Australia' && G.uraniumMineMarket"
|
|
193
|
+
ref="slotUraniumMines"
|
|
194
|
+
:transform="slotT('uraniumMines') || 'translate(15, 70)'"
|
|
195
|
+
>
|
|
196
|
+
<rect x="0" y="0" width="620" height="146" rx="6" fill="#8aa84a" stroke="#4d6322" stroke-width="3" />
|
|
197
|
+
<text x="310" y="26" text-anchor="middle" font-weight="700" fill="black" style="font-size: 22px">
|
|
198
|
+
Uranium mine market
|
|
199
|
+
</text>
|
|
200
|
+
<g v-for="col in 6" :key="'uraniumCol' + col" :transform="`translate(${10 + (col - 1) * 100}, 0)`">
|
|
201
|
+
<text x="50" y="56" text-anchor="middle" font-weight="700" fill="black" style="font-size: 20px">
|
|
202
|
+
${{ 8 - col }}
|
|
203
|
+
</text>
|
|
204
|
+
<rect
|
|
205
|
+
x="21"
|
|
206
|
+
y="66"
|
|
207
|
+
width="26"
|
|
208
|
+
height="26"
|
|
209
|
+
rx="3"
|
|
210
|
+
fill="goldenrod"
|
|
211
|
+
stroke="#4d6322"
|
|
212
|
+
stroke-width="1.5"
|
|
213
|
+
/>
|
|
214
|
+
<rect
|
|
215
|
+
x="53"
|
|
216
|
+
y="66"
|
|
217
|
+
width="26"
|
|
218
|
+
height="26"
|
|
219
|
+
rx="3"
|
|
220
|
+
fill="goldenrod"
|
|
221
|
+
stroke="#4d6322"
|
|
222
|
+
stroke-width="1.5"
|
|
223
|
+
/>
|
|
224
|
+
<circle
|
|
225
|
+
v-if="(G.uraniumMineMarket[6 - col] || 0) >= 1"
|
|
226
|
+
cx="34"
|
|
227
|
+
cy="79"
|
|
228
|
+
r="10"
|
|
229
|
+
fill="#46c655"
|
|
230
|
+
stroke="#1f5c25"
|
|
231
|
+
stroke-width="2"
|
|
232
|
+
/>
|
|
233
|
+
<circle
|
|
234
|
+
v-if="(G.uraniumMineMarket[6 - col] || 0) >= 2"
|
|
235
|
+
cx="66"
|
|
236
|
+
cy="79"
|
|
237
|
+
r="10"
|
|
238
|
+
fill="#46c655"
|
|
239
|
+
stroke="#1f5c25"
|
|
240
|
+
stroke-width="2"
|
|
241
|
+
/>
|
|
242
|
+
</g>
|
|
243
|
+
<line x1="10" y1="108" x2="610" y2="108" stroke="#4d6322" stroke-width="1" />
|
|
244
|
+
<text x="310" y="132" text-anchor="middle" font-weight="700" fill="#22340f" style="font-size: 18px">
|
|
245
|
+
refill −{{ G.map.uraniumMineResupply[G.players.length - 2][G.step - 1] }}/rnd
|
|
246
|
+
</text>
|
|
247
|
+
</g>
|
|
248
|
+
|
|
165
249
|
<!-- Australia: uranium-mine selling table. Six price rows $7 (top) →
|
|
166
250
|
$2 (bottom), two token slots each. Sellers place one token per mine
|
|
167
251
|
on the highest empty slot; the resource refill removes from the
|
|
168
252
|
cheap (bottom) end. Lives in the clear upper-left margin. -->
|
|
169
253
|
<g
|
|
170
|
-
v-if="G.map.name === 'Australia' && G.uraniumMineMarket"
|
|
254
|
+
v-if="!stacked && G.map.name === 'Australia' && G.uraniumMineMarket"
|
|
171
255
|
ref="slotUraniumMines"
|
|
172
256
|
:transform="slotT('uraniumMines') || 'translate(15, 70)'"
|
|
173
257
|
>
|
|
@@ -310,7 +394,7 @@
|
|
|
310
394
|
:ended="gameEnded(G)"
|
|
311
395
|
:isPlayer="player == playerIndex"
|
|
312
396
|
:ranking="sortedPlayers.findIndex((x) => x.id == G.players[playerIndex].id) + 1"
|
|
313
|
-
:showMoney="player == playerIndex ||
|
|
397
|
+
:showMoney="player == playerIndex || moneyIsPublic"
|
|
314
398
|
:showBid="!G.options.fastBid"
|
|
315
399
|
:phase="G.phase"
|
|
316
400
|
:isAustralia="G.map.name === 'Australia'"
|
|
@@ -641,7 +725,13 @@ import { Vue, Component, Prop, Watch, Provide, ProvideReactive, Ref } from 'vue-
|
|
|
641
725
|
import { MoveName, ended, playersSortedByScore, reconstructState } from 'powergrid-engine';
|
|
642
726
|
import type { GameState, LogItem, Move, Player } from 'powergrid-engine';
|
|
643
727
|
import { EventEmitter } from 'events';
|
|
644
|
-
import {
|
|
728
|
+
import {
|
|
729
|
+
bufferedBuyCounts,
|
|
730
|
+
lastBuyIndex,
|
|
731
|
+
matchesTurnBuffer,
|
|
732
|
+
rebaseTurnBuffer,
|
|
733
|
+
replayTurnBuffer as replayBuffer,
|
|
734
|
+
} from '../util/turn-buffer';
|
|
645
735
|
import { UIData, Preferences } from '../types/ui-data';
|
|
646
736
|
import { Card, House, Coal, Oil, Garbage, Uranium } from './pieces';
|
|
647
737
|
import {
|
|
@@ -660,6 +750,7 @@ import PowerPlantMarket from './boards/PowerPlantMarket.vue';
|
|
|
660
750
|
import PlayerOrder from './boards/PlayerOrder.vue';
|
|
661
751
|
import CityCount from './boards/CityCount.vue';
|
|
662
752
|
import Map from './boards/Map.vue';
|
|
753
|
+
import ResourceBoxes from './boards/ResourceBoxes.vue';
|
|
663
754
|
import Resources from './boards/Resources.vue';
|
|
664
755
|
import { LogMove } from 'powergrid-engine/src/log';
|
|
665
756
|
import { Phase, playerTimeUsed, PowerPlant, PowerPlantType, ResourceType } from 'powergrid-engine/src/gamestate';
|
|
@@ -719,6 +810,9 @@ const STACK_SLOT_MAX_SCALE: Record<string, number> = {
|
|
|
719
810
|
*/
|
|
720
811
|
const STACK_SLOT_MAX_WIDTH: Record<string, number> = {
|
|
721
812
|
powerPlantMarket: 0.88,
|
|
813
|
+
// The box market is one tall stack of full-width buttons; run edge to edge it
|
|
814
|
+
// reads as though it had been cropped rather than laid out.
|
|
815
|
+
resources: 0.94,
|
|
722
816
|
};
|
|
723
817
|
|
|
724
818
|
/**
|
|
@@ -794,7 +888,8 @@ const round = (n: number, digits = 2) => Number(n.toFixed(digits));
|
|
|
794
888
|
PlayerOrder,
|
|
795
889
|
CityCount,
|
|
796
890
|
Map,
|
|
797
|
-
Resources
|
|
891
|
+
Resources,
|
|
892
|
+
ResourceBoxes
|
|
798
893
|
},
|
|
799
894
|
})
|
|
800
895
|
export default class Game extends Vue {
|
|
@@ -822,6 +917,11 @@ export default class Game extends Vue {
|
|
|
822
917
|
|
|
823
918
|
paused = false;
|
|
824
919
|
|
|
920
|
+
// Set once the platform has served a finished game, and never cleared.
|
|
921
|
+
// Reactive on purpose: `_futureState` is not, and the money gate has to
|
|
922
|
+
// re-evaluate as the replay scrubber steps through the game.
|
|
923
|
+
gameIsOver = false;
|
|
924
|
+
|
|
825
925
|
@Provide()
|
|
826
926
|
communicator: EventEmitter = new EventEmitter();
|
|
827
927
|
|
|
@@ -944,11 +1044,19 @@ export default class Game extends Vue {
|
|
|
944
1044
|
return state;
|
|
945
1045
|
}
|
|
946
1046
|
|
|
947
|
-
replaceState(state: GameState, replaceState = true) {
|
|
1047
|
+
replaceState(state: GameState, replaceState = true, playSound = true) {
|
|
948
1048
|
if (replaceState) {
|
|
949
1049
|
this._futureState = state;
|
|
950
1050
|
}
|
|
951
1051
|
|
|
1052
|
+
// Remember that the platform has served us a finished game. The replay
|
|
1053
|
+
// scrubber renders reconstructed MID-game states, whose own `ended()` is
|
|
1054
|
+
// false however long ago the game finished, so it cannot be asked. See
|
|
1055
|
+
// `moneyIsPublic`.
|
|
1056
|
+
if (replaceState && ended(state)) {
|
|
1057
|
+
this.gameIsOver = true;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
952
1060
|
// if player is selecting resources, keep the state
|
|
953
1061
|
let player: any;
|
|
954
1062
|
if (this.player != null) {
|
|
@@ -974,7 +1082,8 @@ export default class Game extends Vue {
|
|
|
974
1082
|
this.playerOrder.createPieces(this.G!);
|
|
975
1083
|
this.cityCount.createPieces(this.G!);
|
|
976
1084
|
this.map.createPieces(this.G!);
|
|
977
|
-
|
|
1085
|
+
// Absent while the portrait layout is showing the box market instead.
|
|
1086
|
+
this.resources?.createPieces(this.G!);
|
|
978
1087
|
// Pieces are added imperatively, so the groups only reach their
|
|
979
1088
|
// final size here — the portrait layout must measure after this,
|
|
980
1089
|
// not on the state change that triggered it.
|
|
@@ -982,7 +1091,7 @@ export default class Game extends Vue {
|
|
|
982
1091
|
});
|
|
983
1092
|
}
|
|
984
1093
|
|
|
985
|
-
if (this.preferences.sound && this.G?.log[this.G?.log.length - 1].type == 'move') {
|
|
1094
|
+
if (playSound && this.preferences.sound && this.G?.log[this.G?.log.length - 1].type == 'move') {
|
|
986
1095
|
const move = (this.G?.log[this.G?.log.length - 1] as LogMove).move;
|
|
987
1096
|
if (move.name == MoveName.Pass && this.G.currentPlayers.includes(this.player!)) {
|
|
988
1097
|
(document.getElementById('notification')!.cloneNode(true) as HTMLAudioElement).play();
|
|
@@ -1162,6 +1271,47 @@ export default class Game extends Vue {
|
|
|
1162
1271
|
this.sendMove({ name: MoveName.BuyResource, data });
|
|
1163
1272
|
}
|
|
1164
1273
|
|
|
1274
|
+
/**
|
|
1275
|
+
* Take back a resource bought earlier in this same turn (#127): a market's empty
|
|
1276
|
+
* spaces are clickable while the buffer still holds a purchase from that source.
|
|
1277
|
+
*
|
|
1278
|
+
* This is not an engine undo — there is no Undo move any more (2.0.0). A tentative
|
|
1279
|
+
* turn lives only in this buffer, so a purchase is taken back by dropping it and
|
|
1280
|
+
* replaying the rest, exactly as `undo()` drops the last move. Removing a buy only
|
|
1281
|
+
* ever frees money and plant capacity, so the remaining buffer always replays.
|
|
1282
|
+
*/
|
|
1283
|
+
unbuyResource(payload: { resource: ResourceType, side?: 'north' | 'south', fromStorage?: boolean }) {
|
|
1284
|
+
if (this.paused || !this.committedState) {
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
const index = lastBuyIndex(this.turnMoves, payload);
|
|
1289
|
+
|
|
1290
|
+
if (index < 0) {
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
this.turnMoves.splice(index, 1);
|
|
1295
|
+
|
|
1296
|
+
if (this.turnMoves.length > 0) {
|
|
1297
|
+
const preview = this.replayTurnBuffer();
|
|
1298
|
+
this.emitter.emit('move', [...this.turnMoves]);
|
|
1299
|
+
this.replaceState(preview, false);
|
|
1300
|
+
} else {
|
|
1301
|
+
// Empty buffer: nothing to send — nothing was ever persisted for this turn,
|
|
1302
|
+
// so the last committed state IS the turn start.
|
|
1303
|
+
this.replaceState(this.committedState, false);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* Cubes the turn buffer would give back, per source — how many of each market's
|
|
1309
|
+
* empty spaces are clickable.
|
|
1310
|
+
*/
|
|
1311
|
+
get bufferedBuys(): Record<string, number> {
|
|
1312
|
+
return bufferedBuyCounts(this.turnMoves);
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1165
1315
|
bid(bid: number) {
|
|
1166
1316
|
this.sendMove({ name: MoveName.Bid, data: bid });
|
|
1167
1317
|
}
|
|
@@ -1385,15 +1535,69 @@ export default class Game extends Vue {
|
|
|
1385
1535
|
// replays (and the eventual committed log) reproduce the same times.
|
|
1386
1536
|
this.turnMoves.push({ ...move, time: Date.now() });
|
|
1387
1537
|
|
|
1538
|
+
// Preview the move locally BEFORE sending it. Without this the board — and with
|
|
1539
|
+
// it `availableMoves` — stays frozen on the last server reply, so every click
|
|
1540
|
+
// made while a response is in flight is judged against a stale list. Buy one
|
|
1541
|
+
// resource past what your plants can hold and the platform rejects the whole
|
|
1542
|
+
// replayed buffer with "Wrong argument for the command BuyResource"; the
|
|
1543
|
+
// rejected move then stays in the buffer, so every later action resends it and
|
|
1544
|
+
// fails again until the page is refreshed (#131).
|
|
1545
|
+
//
|
|
1546
|
+
// Replaying the buffer on the committed state is exact: any move with hidden
|
|
1547
|
+
// side effects commits, which ends the buffer (see util/turn-buffer).
|
|
1548
|
+
let preview: GameState | null = null;
|
|
1549
|
+
|
|
1550
|
+
if (this.committedState && this.player != undefined) {
|
|
1551
|
+
const buffered = this.turnMoves.length;
|
|
1552
|
+
preview = this.replayTurnBuffer();
|
|
1553
|
+
|
|
1554
|
+
if (this.turnMoves.length < buffered) {
|
|
1555
|
+
// The engine refused the move we just added, so it never reaches the
|
|
1556
|
+
// server and the board already shows the truth.
|
|
1557
|
+
return;
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1388
1561
|
// Send the WHOLE turn so far: the platform is stateless between calls and
|
|
1389
1562
|
// replays the buffer from the last committed (saved) state.
|
|
1390
1563
|
this.emitter.emit('move', [...this.turnMoves]);
|
|
1564
|
+
|
|
1565
|
+
// Only adopt a preview that is still TENTATIVE. A committing move (Pass, a bid
|
|
1566
|
+
// that ends an auction) replays hidden outcomes — deck draws, upkeep — on a
|
|
1567
|
+
// state whose deck and seed are stripped, so its preview would flash bogus
|
|
1568
|
+
// results; wait for the server's committed answer instead. The echo of this
|
|
1569
|
+
// same buffer lands next and is authoritative, and it owns the sound, so the
|
|
1570
|
+
// preview stays silent rather than doubling it.
|
|
1571
|
+
if (preview && preview.newTurn === false) {
|
|
1572
|
+
this.replaceState(preview, false, false);
|
|
1573
|
+
}
|
|
1391
1574
|
}
|
|
1392
1575
|
|
|
1393
1576
|
gameEnded(G: GameState) {
|
|
1394
1577
|
return ended(G);
|
|
1395
1578
|
}
|
|
1396
1579
|
|
|
1580
|
+
/**
|
|
1581
|
+
* Is every player's cash public?
|
|
1582
|
+
*
|
|
1583
|
+
* Money is hidden information while a game is running, but a finished game has
|
|
1584
|
+
* nothing left to hide — the engine's `stripSecret` reveals it and the end-game
|
|
1585
|
+
* table prints it. The replay scrubber, though, shows a RECONSTRUCTED mid-game
|
|
1586
|
+
* state, and `ended()` on that state is false however long ago the game actually
|
|
1587
|
+
* finished. So stepping back through a finished game blacked out everyone
|
|
1588
|
+
* else's cash, which is most of what makes a replay worth reviewing: you could
|
|
1589
|
+
* not tell whether someone ending the game early would have changed the result
|
|
1590
|
+
* (eric-hu, #130). Ask whether the GAME is over, not whether the step being
|
|
1591
|
+
* displayed is.
|
|
1592
|
+
*
|
|
1593
|
+
* An in-progress game stays hidden, which is the point — the reconstruction
|
|
1594
|
+
* derives every player's true cash from the log, so this gate is the only thing
|
|
1595
|
+
* standing between a mid-game replay and other people's wallets.
|
|
1596
|
+
*/
|
|
1597
|
+
get moneyIsPublic(): boolean {
|
|
1598
|
+
return !!this.G && (!!this.G.options.showMoney || this.gameIsOver || ended(this.G));
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1397
1601
|
canMove() {
|
|
1398
1602
|
return (
|
|
1399
1603
|
this.player != undefined &&
|
|
@@ -1968,6 +2172,19 @@ export default class Game extends Vue {
|
|
|
1968
2172
|
this.stacked = true;
|
|
1969
2173
|
}
|
|
1970
2174
|
|
|
2175
|
+
/**
|
|
2176
|
+
* Entering or leaving the stacked layout swaps the printed resource track for the
|
|
2177
|
+
* box market, which is a different shape entirely. `relayout` sets `stacked` last,
|
|
2178
|
+
* so the swap only reaches the DOM a tick later — by which point the row heights
|
|
2179
|
+
* it just solved for describe the component that is no longer there. Measure again
|
|
2180
|
+
* once the swap has landed. This settles after one extra pass: the second run
|
|
2181
|
+
* leaves `stacked` unchanged, so it does not re-trigger.
|
|
2182
|
+
*/
|
|
2183
|
+
@Watch('stacked')
|
|
2184
|
+
onStackedChanged() {
|
|
2185
|
+
this.scheduleRelayout();
|
|
2186
|
+
}
|
|
2187
|
+
|
|
1971
2188
|
private scheduleRelayout() {
|
|
1972
2189
|
this.$nextTick(() => this.relayout());
|
|
1973
2190
|
}
|
|
@@ -0,0 +1,274 @@
|
|
|
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
|
+
<!-- A purchase made this turn can be handed back (#127). The box
|
|
120
|
+
market has no empty spaces to click — the printed track's
|
|
121
|
+
ghosts are exactly what it replaces — so the take-back lives
|
|
122
|
+
on a chip, and stops the click reaching the buy target under
|
|
123
|
+
it. Sits in the free band above the cubes, and appears whether
|
|
124
|
+
or not the row is still buyable: being unable to afford the
|
|
125
|
+
next cube is precisely when you want the last one back. -->
|
|
126
|
+
<g v-if="canUnbuy(row)" class="canClick" @click.stop="$emit('unbuyResource', row.move)">
|
|
127
|
+
<rect
|
|
128
|
+
:x="chipBox(row).hx"
|
|
129
|
+
:y="chipBox(row).hy"
|
|
130
|
+
:width="chipBox(row).hw"
|
|
131
|
+
:height="chipBox(row).hh"
|
|
132
|
+
fill="transparent"
|
|
133
|
+
pointer-events="all"
|
|
134
|
+
/>
|
|
135
|
+
<rect
|
|
136
|
+
:x="chipBox(row).x"
|
|
137
|
+
:y="chipBox(row).y"
|
|
138
|
+
:width="chipBox(row).w"
|
|
139
|
+
:height="chipBox(row).h"
|
|
140
|
+
rx="10"
|
|
141
|
+
fill="#f0e6c8"
|
|
142
|
+
stroke="#6b5312"
|
|
143
|
+
stroke-width="2"
|
|
144
|
+
/>
|
|
145
|
+
<text
|
|
146
|
+
:x="chipBox(row).x + chipBox(row).w / 2"
|
|
147
|
+
:y="chipBox(row).y + chipBox(row).h / 2 + 8"
|
|
148
|
+
text-anchor="middle"
|
|
149
|
+
font-weight="700"
|
|
150
|
+
fill="#3a2c08"
|
|
151
|
+
:style="`font-size: ${chipBox(row).fontSize}px`"
|
|
152
|
+
>
|
|
153
|
+
− take back
|
|
154
|
+
</text>
|
|
155
|
+
</g>
|
|
156
|
+
|
|
157
|
+
<title>{{ row.title }}</title>
|
|
158
|
+
</g>
|
|
159
|
+
</g>
|
|
160
|
+
</template>
|
|
161
|
+
</g>
|
|
162
|
+
</template>
|
|
163
|
+
|
|
164
|
+
<script lang="ts">
|
|
165
|
+
import { GameState } from 'powergrid-engine';
|
|
166
|
+
import { Component, Prop, Vue } from 'vue-property-decorator';
|
|
167
|
+
import { BuyMove, ResourceBlock, ResourceRow, resourceBlocks } from '../../util/resource-rows';
|
|
168
|
+
import { buySourceKey } from '../../util/turn-buffer';
|
|
169
|
+
|
|
170
|
+
const BOX_W = 700;
|
|
171
|
+
const BOX_H = 104;
|
|
172
|
+
const BOX_GAP = 8;
|
|
173
|
+
/** Block title + column captions above the first box. */
|
|
174
|
+
const HEAD_H = 62;
|
|
175
|
+
const BLOCK_GAP = 30;
|
|
176
|
+
const CUBE_PITCH = 44;
|
|
177
|
+
/** Middle of the band between the price block and the cubes-left count. */
|
|
178
|
+
const CUBE_BAND_CENTRE = 420;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The portrait resource market.
|
|
182
|
+
*
|
|
183
|
+
* The printed price track is a beautiful object and an unreadable one on a phone:
|
|
184
|
+
* eight to ten columns of three-cube slots, drawn at whatever width the map author
|
|
185
|
+
* chose and then squeezed into a strip. This draws the same market as one box per
|
|
186
|
+
* buyable source, carrying only what a purchase actually needs — what the next cube
|
|
187
|
+
* costs, how many are left at that price, how many are left at all.
|
|
188
|
+
*
|
|
189
|
+
* Every number comes out of the engine state rather than the printed geometry, so the
|
|
190
|
+
* map-specific markets come along for free: Europe's nine price columns, Korea's two
|
|
191
|
+
* sides, Australia's Step-3 CO2 shift (the engine rewrites the price array and the box
|
|
192
|
+
* is only ever a view of it), India's per-step price cap, South Africa's $8 storage
|
|
193
|
+
* pool, USA recharged's $8-from-supply once the track is bare.
|
|
194
|
+
*
|
|
195
|
+
* Which rows a player may actually tap is NOT decided here: `buyableResources` is the
|
|
196
|
+
* engine's own availableMoves, so money, storage capacity, the hybrid tank and Korea's
|
|
197
|
+
* side lock are all accounted for already. The row model itself lives in
|
|
198
|
+
* `util/resource-rows`, where it can be tested against the engine's own arithmetic.
|
|
199
|
+
*/
|
|
200
|
+
@Component
|
|
201
|
+
export default class ResourceBoxes extends Vue {
|
|
202
|
+
@Prop() gameState!: GameState;
|
|
203
|
+
@Prop() buyableResources?: BuyMove[];
|
|
204
|
+
@Prop() resourceResupply?: (number | string)[];
|
|
205
|
+
@Prop() resourceResupplyNorth?: (number | string)[];
|
|
206
|
+
@Prop() isUsaRecharged?: boolean;
|
|
207
|
+
/** The seat looking at the board — only needed for Central Europe's Wien discount. */
|
|
208
|
+
@Prop() player?: number;
|
|
209
|
+
/** Purchases still sitting in this turn's buffer, per source (#127). */
|
|
210
|
+
@Prop() bufferedBuys?: Record<string, number>;
|
|
211
|
+
|
|
212
|
+
BOX_W = BOX_W;
|
|
213
|
+
BOX_H = BOX_H;
|
|
214
|
+
BOX_GAP = BOX_GAP;
|
|
215
|
+
HEAD_H = HEAD_H;
|
|
216
|
+
CUBE_BAND_CENTRE = CUBE_BAND_CENTRE;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Where a row's take-back button sits: in the gap between the price block and the
|
|
220
|
+
* cubes, and big enough for a thumb (~28 css px tall on a phone).
|
|
221
|
+
*
|
|
222
|
+
* The two pool rows carry a long label right across that gap — but they are
|
|
223
|
+
* flat-priced, so nothing sits below them, and the button drops under the label at
|
|
224
|
+
* the same size rather than shrinking.
|
|
225
|
+
*/
|
|
226
|
+
chipBox(row: ResourceRow) {
|
|
227
|
+
const labelFillsTheTop = row.label.length > 8;
|
|
228
|
+
|
|
229
|
+
return labelFillsTheTop
|
|
230
|
+
? { x: 130, y: 36, w: 140, h: 52, hx: 120, hy: 32, hw: 164, hh: 60, fontSize: 22 }
|
|
231
|
+
: { x: 130, y: 6, w: 140, h: 52, hx: 120, hy: 2, hw: 164, hh: 60, fontSize: 22 };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Does this turn's buffer hold a purchase from this row's source? */
|
|
235
|
+
canUnbuy(row: ResourceRow): boolean {
|
|
236
|
+
return !!this.bufferedBuys && !!this.bufferedBuys[buySourceKey(row.move)];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Centre the cube cluster in that band, however many cubes there are. */
|
|
240
|
+
cubeX(count: number, n: number): number {
|
|
241
|
+
return CUBE_BAND_CENTRE - ((count - 1) * CUBE_PITCH) / 2 + (n - 1) * CUBE_PITCH;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
get step(): number {
|
|
245
|
+
return this.gameState.step;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
get maxPrice(): number {
|
|
249
|
+
const table = (this.gameState.map as { maxPriceAvailable?: number[] }).maxPriceAvailable;
|
|
250
|
+
return table ? table[this.gameState.step - 1] : 16;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
get blocks(): (ResourceBlock & { y: number })[] {
|
|
254
|
+
let y = 0;
|
|
255
|
+
return resourceBlocks(this.gameState, {
|
|
256
|
+
player: this.player,
|
|
257
|
+
buyable: this.buyableResources,
|
|
258
|
+
resupply: this.resourceResupply,
|
|
259
|
+
resupplyNorth: this.resourceResupplyNorth,
|
|
260
|
+
isUsaRecharged: this.isUsaRecharged,
|
|
261
|
+
}).map((block) => {
|
|
262
|
+
const placed = { ...block, y };
|
|
263
|
+
y += HEAD_H + block.rows.length * (BOX_H + BOX_GAP) - BOX_GAP + BLOCK_GAP;
|
|
264
|
+
return placed;
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
</script>
|
|
269
|
+
|
|
270
|
+
<style lang="scss" scoped>
|
|
271
|
+
.canClick {
|
|
272
|
+
cursor: pointer;
|
|
273
|
+
}
|
|
274
|
+
</style>
|