powergrid-viewer 2.0.3 → 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 +220 -19
- package/src/components/boards/Map.vue +90 -0
- 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
|
>
|
|
@@ -279,9 +359,9 @@
|
|
|
279
359
|
@click="undo()"
|
|
280
360
|
/>
|
|
281
361
|
<LogButton transform="translate(15, 97)" @click="showLog()" />
|
|
282
|
-
<SoundButton transform="
|
|
283
|
-
<HelpButton transform="
|
|
284
|
-
<RulesButton transform="
|
|
362
|
+
<SoundButton :transform="iconButton(0)" :isOn="preferences.sound" @click="toggleSound()" />
|
|
363
|
+
<HelpButton :transform="iconButton(1)" :isOn="!preferences.disableHelp" @click="toggleHelp()" />
|
|
364
|
+
<RulesButton :transform="iconButton(2)" @click="rulesVisible = true" />
|
|
285
365
|
<!-- Only offered where it means something. Shown whenever the
|
|
286
366
|
viewport is portrait — not only while stacking is active — so
|
|
287
367
|
it can undo its own effect. Sits under Rules rather than under
|
|
@@ -289,7 +369,7 @@
|
|
|
289
369
|
on the authored board. -->
|
|
290
370
|
<LayoutButton
|
|
291
371
|
v-if="portraitViewport"
|
|
292
|
-
transform="
|
|
372
|
+
:transform="iconButton(3)"
|
|
293
373
|
:isOn="stacked"
|
|
294
374
|
@click="toggleStackLayout()"
|
|
295
375
|
/>
|
|
@@ -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';
|
|
@@ -703,7 +784,7 @@ const STACK_MAX_ROW_SCREENS = 0.72;
|
|
|
703
784
|
* readable; the buttons next to it are tap targets and want every pixel.
|
|
704
785
|
*/
|
|
705
786
|
const STACK_SLOT_MAX_SCALE: Record<string, number> = {
|
|
706
|
-
roundInfo:
|
|
787
|
+
roundInfo: 1.9,
|
|
707
788
|
playerOrder: 2.5,
|
|
708
789
|
};
|
|
709
790
|
/**
|
|
@@ -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 &&
|
|
@@ -1836,6 +1989,19 @@ export default class Game extends Vue {
|
|
|
1836
1989
|
return this.slotTransforms[name];
|
|
1837
1990
|
}
|
|
1838
1991
|
|
|
1992
|
+
/**
|
|
1993
|
+
* Placement of the icon column beside Pass / Undo / Log. On a portrait viewport
|
|
1994
|
+
* that column gains a fourth button — the layout toggle — in a space authored for
|
|
1995
|
+
* three, and at the authored pitch the fourth one hung 41 units below everything
|
|
1996
|
+
* else and crowded the market row beneath it. On portrait the icons shrink to the
|
|
1997
|
+
* 26-unit height of the text buttons next to them and re-pitch so all four end
|
|
1998
|
+
* level with Log. Landscape and desktop keep the authored positions exactly.
|
|
1999
|
+
*/
|
|
2000
|
+
iconButton(index: number): string {
|
|
2001
|
+
if (!this.portraitViewport) return `translate(110, ${13 + 41 * index})`;
|
|
2002
|
+
return `translate(110, ${13 + 30 * index}) scale(${round(26 / 30, 4)})`;
|
|
2003
|
+
}
|
|
2004
|
+
|
|
1839
2005
|
/**
|
|
1840
2006
|
* Only portrait viewports are re-laid out. A landscape phone already reads
|
|
1841
2007
|
* well (the scene's own aspect ratio is close to the screen's), so leaving
|
|
@@ -1900,18 +2066,40 @@ export default class Game extends Vue {
|
|
|
1900
2066
|
const gaps = STACK_GAP * (present.length - 1);
|
|
1901
2067
|
const usable = STACK_WIDTH - 2 * STACK_PAD - gaps;
|
|
1902
2068
|
const combined = present.reduce((sum, name) => sum + boxes[name].width, 0);
|
|
1903
|
-
//
|
|
1904
|
-
//
|
|
1905
|
-
const
|
|
1906
|
-
// A slot may decline part of that scale so a neighbour keeps the space,
|
|
1907
|
-
// and no slot may grow past the height budget for one row.
|
|
1908
|
-
const scaleOf = (name: string) =>
|
|
2069
|
+
// A slot may decline part of the row's scale so a neighbour keeps the
|
|
2070
|
+
// space, and no slot may grow past the height budget for one row.
|
|
2071
|
+
const ceilingOf = (name: string) =>
|
|
1909
2072
|
Math.min(
|
|
1910
|
-
rowScale,
|
|
1911
2073
|
STACK_SLOT_MAX_SCALE[name] ?? Infinity,
|
|
1912
2074
|
((STACK_SLOT_MAX_WIDTH[name] ?? Infinity) * usable) / boxes[name].width,
|
|
1913
|
-
heightCapFor(boxes[name].height)
|
|
2075
|
+
heightCapFor(boxes[name].height),
|
|
2076
|
+
STACK_MAX_SCALE
|
|
1914
2077
|
);
|
|
2078
|
+
// What a capped slot declines is width left on the table: without this it
|
|
2079
|
+
// became margin either side of the row. Hand it to the slots that can
|
|
2080
|
+
// still grow — the round readout gives up ~130 units beside the buttons,
|
|
2081
|
+
// and the buttons are the part a thumb needs. Repeat until it settles:
|
|
2082
|
+
// growing the free slots can push one of them into its own ceiling, and
|
|
2083
|
+
// then there is more to re-split. rowScale only ever rises here, and a
|
|
2084
|
+
// slot that caps mid-loop leaves the row shorter than solved for, so the
|
|
2085
|
+
// row can never end up wider than the space it was given.
|
|
2086
|
+
// One scale per row keeps neighbours visually consistent. The cap stops a
|
|
2087
|
+
// small strip (the turn-order token row) from being blown up absurdly.
|
|
2088
|
+
let rowScale = Math.min(usable / combined, STACK_MAX_SCALE);
|
|
2089
|
+
for (let pass = 0; pass < present.length; pass++) {
|
|
2090
|
+
let cappedWidth = 0;
|
|
2091
|
+
let freeWidth = 0;
|
|
2092
|
+
for (const name of present) {
|
|
2093
|
+
const ceiling = ceilingOf(name);
|
|
2094
|
+
if (ceiling < rowScale) cappedWidth += boxes[name].width * ceiling;
|
|
2095
|
+
else freeWidth += boxes[name].width;
|
|
2096
|
+
}
|
|
2097
|
+
if (!freeWidth) break;
|
|
2098
|
+
const grown = Math.min((usable - cappedWidth) / freeWidth, STACK_MAX_SCALE);
|
|
2099
|
+
if (grown <= rowScale + 0.0001) break;
|
|
2100
|
+
rowScale = grown;
|
|
2101
|
+
}
|
|
2102
|
+
const scaleOf = (name: string) => Math.min(rowScale, ceilingOf(name));
|
|
1915
2103
|
const rowWidth = present.reduce((sum, name) => sum + boxes[name].width * scaleOf(name), 0);
|
|
1916
2104
|
const rowHeight = Math.max(...present.map((name) => boxes[name].height * scaleOf(name)));
|
|
1917
2105
|
|
|
@@ -1933,6 +2121,19 @@ export default class Game extends Vue {
|
|
|
1933
2121
|
this.stacked = true;
|
|
1934
2122
|
}
|
|
1935
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
|
+
|
|
1936
2137
|
private scheduleRelayout() {
|
|
1937
2138
|
this.$nextTick(() => this.relayout());
|
|
1938
2139
|
}
|
|
@@ -386,6 +386,55 @@
|
|
|
386
386
|
/>
|
|
387
387
|
</template>
|
|
388
388
|
|
|
389
|
+
<!-- City tap targets, drawn LAST so they sit above the houses already built
|
|
390
|
+
there. Houses are painted after the city's own circle, and SVG hit-testing
|
|
391
|
+
takes the topmost element — so once a city held houses the active player
|
|
392
|
+
could only pick at the gray slivers between them (eric-hu, #125).
|
|
393
|
+
|
|
394
|
+
The radius reaches out to the printed region ring, which is what the eye
|
|
395
|
+
reads as the edge of the city, but never past half the distance to the
|
|
396
|
+
nearest city: the maps disagree enormously about spacing (Bremen's closest
|
|
397
|
+
pair is 90.8 apart, South Africa's Johannesburg N/S is 21), so the limit
|
|
398
|
+
has to be measured rather than picked.
|
|
399
|
+
|
|
400
|
+
Only rendered while the city can actually be acted on, so houses keep
|
|
401
|
+
their own tooltips the rest of the time. -->
|
|
402
|
+
<template v-for="city in cities">
|
|
403
|
+
<circle
|
|
404
|
+
v-if="city.connectionCost == null && (canBuild(city) || canPickRegion(city))"
|
|
405
|
+
:key="city.name + '_tap'"
|
|
406
|
+
class="canClick"
|
|
407
|
+
:r="tapRadius(city)"
|
|
408
|
+
:cx="city.x"
|
|
409
|
+
:cy="city.y"
|
|
410
|
+
fill="none"
|
|
411
|
+
pointer-events="all"
|
|
412
|
+
@click="onCityClick(city)"
|
|
413
|
+
>
|
|
414
|
+
<title>{{ city.name }}</title>
|
|
415
|
+
</circle>
|
|
416
|
+
<!-- Node-weighted tiles (Bremen) get the same treatment at the tile's own
|
|
417
|
+
size — their houses sit in slots inside the diamond. Manhattan's spaces
|
|
418
|
+
hold one house each, so a built space is never buildable again and needs
|
|
419
|
+
no overlay. -->
|
|
420
|
+
<rect
|
|
421
|
+
v-if="isNodeTile(city) && (canBuild(city) || canPickRegion(city))"
|
|
422
|
+
:key="city.name + '_tapTile'"
|
|
423
|
+
class="canClick"
|
|
424
|
+
:x="city.x - 23"
|
|
425
|
+
:y="city.y - 23"
|
|
426
|
+
width="46"
|
|
427
|
+
height="46"
|
|
428
|
+
rx="7"
|
|
429
|
+
fill="none"
|
|
430
|
+
pointer-events="all"
|
|
431
|
+
:transform="`rotate(45, ${city.x}, ${city.y})`"
|
|
432
|
+
@click="onCityClick(city)"
|
|
433
|
+
>
|
|
434
|
+
<title>{{ city.name }}</title>
|
|
435
|
+
</rect>
|
|
436
|
+
</template>
|
|
437
|
+
|
|
389
438
|
<!-- Regions where nuclear plants are banned: a nuclear-plant tile under a red
|
|
390
439
|
prohibition sign (ring + diagonal bar), matching the marker printed on
|
|
391
440
|
the physical board. -->
|
|
@@ -421,6 +470,11 @@ import { House } from '../pieces';
|
|
|
421
470
|
import { Piece, Preferences } from '../../types/ui-data';
|
|
422
471
|
import { City, Connection, Polygon } from 'powergrid-engine/src/maps';
|
|
423
472
|
|
|
473
|
+
/** The gray disc a city is drawn as, and the tap target it has always had. */
|
|
474
|
+
const CITY_RADIUS = 20;
|
|
475
|
+
/** The coloured region ring drawn around that disc — the city's visible edge. */
|
|
476
|
+
const CITY_RING_RADIUS = 25;
|
|
477
|
+
|
|
424
478
|
@Component({
|
|
425
479
|
components: {
|
|
426
480
|
House,
|
|
@@ -605,6 +659,42 @@ export default class Map extends Vue {
|
|
|
605
659
|
return markers;
|
|
606
660
|
}
|
|
607
661
|
|
|
662
|
+
/**
|
|
663
|
+
* Distance from each city to its nearest neighbour. Cached as a computed so the
|
|
664
|
+
* O(n^2) sweep runs once per board rather than once per tap target; n is at most
|
|
665
|
+
* a few dozen cities.
|
|
666
|
+
*/
|
|
667
|
+
get nearestCityDistance(): Record<string, number> {
|
|
668
|
+
const out: Record<string, number> = {};
|
|
669
|
+
const cities = this.cities ?? [];
|
|
670
|
+
for (const a of cities) {
|
|
671
|
+
let min = Infinity;
|
|
672
|
+
for (const b of cities) {
|
|
673
|
+
if (b === a) continue;
|
|
674
|
+
const d = Math.hypot(b.x - a.x, b.y - a.y);
|
|
675
|
+
if (d < min) min = d;
|
|
676
|
+
}
|
|
677
|
+
out[a.name] = min;
|
|
678
|
+
}
|
|
679
|
+
return out;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* How far a city's tap target reaches. Out to the region ring where there is room
|
|
684
|
+
* for it, but never more than halfway to the next city — on the tight maps two
|
|
685
|
+
* targets would otherwise overlap and whichever is drawn later would swallow both
|
|
686
|
+
* cities' taps. Never smaller than the disc it has always been.
|
|
687
|
+
*/
|
|
688
|
+
tapRadius(city: City): number {
|
|
689
|
+
const halfWayToNeighbour = (this.nearestCityDistance[city.name] ?? Infinity) / 2;
|
|
690
|
+
return Math.max(CITY_RADIUS, Math.min(CITY_RING_RADIUS, halfWayToNeighbour));
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** A Bremen-style node-weighted district: a diamond tile with several house slots. */
|
|
694
|
+
isNodeTile(city: City) {
|
|
695
|
+
return city.connectionCost != null && !!city.slotCosts && city.slotCosts.length >= 2;
|
|
696
|
+
}
|
|
697
|
+
|
|
608
698
|
canBuild(city: City) {
|
|
609
699
|
return !!this.buildableCities!.find((cityName) => cityName == city.name);
|
|
610
700
|
}
|