@bedard/hexboard 0.0.1
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/.github/workflows/build.yml +65 -0
- package/.github/workflows/release.yml +0 -0
- package/.vscode/settings.json +5 -0
- package/LICENSE +21 -0
- package/README.md +8 -0
- package/dist/index.d.ts +127 -0
- package/dist/index.js +1056 -0
- package/eslint.config.js +85 -0
- package/index.html +22 -0
- package/package.json +58 -0
- package/src/lib/components/hexboard/Hexboard.vue +865 -0
- package/src/lib/components/hexboard/constants.ts +389 -0
- package/src/lib/components/hexboard/dom.ts +19 -0
- package/src/lib/components/hexboard/geometry.ts +59 -0
- package/src/lib/components/hexboard/haptics.ts +56 -0
- package/src/lib/components/hexboard/pieces/Celtic.vue +22 -0
- package/src/lib/components/hexboard/pieces/Fantasy.vue +22 -0
- package/src/lib/components/hexboard/pieces/Gioco.vue +22 -0
- package/src/lib/components/hexboard/pieces/Spatial.vue +22 -0
- package/src/lib/components/hexboard/pieces/index.ts +4 -0
- package/src/lib/components/hexboard/types.ts +28 -0
- package/src/lib/index.ts +1 -0
- package/src/sandbox/App.vue +28 -0
- package/src/sandbox/components/Button.vue +8 -0
- package/src/sandbox/components/icons/Github.vue +3 -0
- package/src/sandbox/components/icons/Menu.vue +3 -0
- package/src/sandbox/components/icons/X.vue +3 -0
- package/src/sandbox/index.ts +5 -0
- package/src/sandbox/tailwind.css +59 -0
- package/src/sandbox/views/HomeToolbar.vue +80 -0
- package/src/tests/example.test.tsx +18 -0
- package/src/tests/hexboard.test.tsx +832 -0
- package/src/tests/utils.ts +26 -0
- package/tsconfig.json +30 -0
- package/tsconfig.node.json +10 -0
- package/vite.config.ts +42 -0
- package/vite.sandbox.config.ts +21 -0
- package/vitest.config.ts +35 -0
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<svg
|
|
4
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
5
|
+
ref="svgEl"
|
|
6
|
+
:style="{ cursor }"
|
|
7
|
+
:viewBox="`0 0 ${box} ${box}`"
|
|
8
|
+
>
|
|
9
|
+
<!-- backdrop -->
|
|
10
|
+
<path
|
|
11
|
+
:d="d(perimeter)"
|
|
12
|
+
:fill="normalizedOptions.colors[1]"
|
|
13
|
+
:style="{ pointerEvents: 'none' }"
|
|
14
|
+
/>
|
|
15
|
+
|
|
16
|
+
<!-- positions -->
|
|
17
|
+
<path
|
|
18
|
+
v-for="(pos, index) in board"
|
|
19
|
+
v-bind="
|
|
20
|
+
active
|
|
21
|
+
? {
|
|
22
|
+
onClick: (evt) => onClickPosition(index, evt),
|
|
23
|
+
onMouseenter: () => onMouseenter(index),
|
|
24
|
+
onMouseleave: () => onMouseleave(),
|
|
25
|
+
onPointerdown: (evt) => onPointerdownPosition(index, evt),
|
|
26
|
+
onPointerup: (evt) => onPointerupPosition(index, evt),
|
|
27
|
+
}
|
|
28
|
+
: {}
|
|
29
|
+
"
|
|
30
|
+
:d="d(flipped ? pos[4] : pos[3])"
|
|
31
|
+
:data-hexboard-position="index"
|
|
32
|
+
:data-testid="`position-${indexToPosition(index)}`"
|
|
33
|
+
:fill="normalizedOptions.colors[board[index][0]]"
|
|
34
|
+
:key="index"
|
|
35
|
+
/>
|
|
36
|
+
|
|
37
|
+
<!-- highlighted positions -->
|
|
38
|
+
<path
|
|
39
|
+
v-for="highlightIndex in highlight"
|
|
40
|
+
:d="d(flipped ? board[highlightIndex][4] : board[highlightIndex][3])"
|
|
41
|
+
:data-testid="`highlight-${indexToPosition(highlightIndex)}`"
|
|
42
|
+
:fill="normalizedOptions.highlightColor"
|
|
43
|
+
:key="`highlight-${highlightIndex}`"
|
|
44
|
+
:style="{ pointerEvents: 'none' }"
|
|
45
|
+
/>
|
|
46
|
+
|
|
47
|
+
<!-- selected position -->
|
|
48
|
+
<path
|
|
49
|
+
v-if="typeof currentSelected === 'number'"
|
|
50
|
+
:d="d(flipped ? board[currentSelected][4] : board[currentSelected][3])"
|
|
51
|
+
:data-testid="`selected-${indexToPosition(currentSelected)}`"
|
|
52
|
+
:fill="normalizedOptions.selectedColor"
|
|
53
|
+
ref="selectedEl"
|
|
54
|
+
:style="{ pointerEvents: 'none' }"
|
|
55
|
+
/>
|
|
56
|
+
|
|
57
|
+
<!-- labels -->
|
|
58
|
+
<template v-if="normalizedOptions.labels">
|
|
59
|
+
<text
|
|
60
|
+
v-for="([text, p, positionFlipped], i) in labels"
|
|
61
|
+
v-text="text"
|
|
62
|
+
dominant-baseline="central"
|
|
63
|
+
text-anchor="middle"
|
|
64
|
+
:data-testid="`label-${text}`"
|
|
65
|
+
:key="`label-${i}`"
|
|
66
|
+
:style="{
|
|
67
|
+
fill: getLabelFill(text),
|
|
68
|
+
fontSize: '.5px',
|
|
69
|
+
pointerEvents: 'none',
|
|
70
|
+
userSelect: 'none',
|
|
71
|
+
}"
|
|
72
|
+
:x="x(flipped ? positionFlipped[0] : p[0])"
|
|
73
|
+
:y="y(flipped ? positionFlipped[1] : p[1])"
|
|
74
|
+
/>
|
|
75
|
+
</template>
|
|
76
|
+
|
|
77
|
+
<!-- pieces -->
|
|
78
|
+
<template v-if="currentHexchess">
|
|
79
|
+
<Component
|
|
80
|
+
v-for="{ piece, index } in currentPieces"
|
|
81
|
+
:data-piece-type="piece"
|
|
82
|
+
:data-testid="`piece-${indexToPosition(index)}`"
|
|
83
|
+
:height="pieceSize"
|
|
84
|
+
:is="pieces"
|
|
85
|
+
:key="`piece-${indexToPosition(index)}`"
|
|
86
|
+
:style="{ pointerEvents: 'none' }"
|
|
87
|
+
:type="piece"
|
|
88
|
+
:width="pieceSize"
|
|
89
|
+
:x="x(board[index][flipped ? 2 : 1][0] - pieceSize / 2)"
|
|
90
|
+
:y="y(board[index][flipped ? 2 : 1][1] + pieceSize / 2)"
|
|
91
|
+
/>
|
|
92
|
+
</template>
|
|
93
|
+
|
|
94
|
+
<!-- targets -->
|
|
95
|
+
<circle
|
|
96
|
+
v-for="targetIndex in currentTargets"
|
|
97
|
+
:cx="x(board[targetIndex][flipped ? 2 : 1][0])"
|
|
98
|
+
:cy="y(board[targetIndex][flipped ? 2 : 1][1])"
|
|
99
|
+
:data-testid="`target-${indexToPosition(targetIndex)}`"
|
|
100
|
+
:fill="normalizedOptions.targetColor"
|
|
101
|
+
:key="`target-${indexToPosition(targetIndex)}`"
|
|
102
|
+
:r="0.3"
|
|
103
|
+
:style="{ pointerEvents: 'none' }"
|
|
104
|
+
/>
|
|
105
|
+
</svg>
|
|
106
|
+
|
|
107
|
+
<!-- draggable piece -->
|
|
108
|
+
<svg
|
|
109
|
+
v-if="dragPiece"
|
|
110
|
+
data-testid="drag-piece"
|
|
111
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
112
|
+
:style="{
|
|
113
|
+
height: svgRect.height + 'px',
|
|
114
|
+
left: '0px',
|
|
115
|
+
pointerEvents: 'none',
|
|
116
|
+
position: 'fixed',
|
|
117
|
+
top: '0px',
|
|
118
|
+
transform: `translate(${dragCoords.x}px, ${dragCoords.y}px) scale(1.1)`,
|
|
119
|
+
width: svgRect.width + 'px',
|
|
120
|
+
willChange: 'transform',
|
|
121
|
+
}"
|
|
122
|
+
:viewBox="`0 0 ${box} ${box}`"
|
|
123
|
+
>
|
|
124
|
+
<Component
|
|
125
|
+
:height="pieceSize"
|
|
126
|
+
:is="pieces"
|
|
127
|
+
:style="{ pointerEvents: 'none' }"
|
|
128
|
+
:type="dragPiece"
|
|
129
|
+
:width="pieceSize"
|
|
130
|
+
:x="x(pieceSize / -2)"
|
|
131
|
+
:y="y(pieceSize / 2)"
|
|
132
|
+
/>
|
|
133
|
+
</svg>
|
|
134
|
+
|
|
135
|
+
<!-- promotion -->
|
|
136
|
+
<div
|
|
137
|
+
v-if="typeof staging.selected === 'number'"
|
|
138
|
+
:style="{
|
|
139
|
+
height: promotionRect.height + 'px',
|
|
140
|
+
left: promotionRect.left + 'px',
|
|
141
|
+
position: 'fixed',
|
|
142
|
+
top: promotionRect.top + 'px',
|
|
143
|
+
width: promotionRect.width + 'px',
|
|
144
|
+
}"
|
|
145
|
+
@pointerup.stop
|
|
146
|
+
>
|
|
147
|
+
<slot
|
|
148
|
+
name="promotion"
|
|
149
|
+
:b="promotionPieces.b"
|
|
150
|
+
:cancel="cancelPromotion"
|
|
151
|
+
:file="indexToPosition(staging.selected)[0]"
|
|
152
|
+
:n="promotionPieces.n"
|
|
153
|
+
:promote
|
|
154
|
+
:q="promotionPieces.q"
|
|
155
|
+
:r="promotionPieces.r"
|
|
156
|
+
:rank="Number(indexToPosition(staging.selected).slice(1))"
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</template>
|
|
161
|
+
|
|
162
|
+
<script lang="ts" setup>
|
|
163
|
+
import {
|
|
164
|
+
type Color,
|
|
165
|
+
Hexchess,
|
|
166
|
+
isPromotionPosition,
|
|
167
|
+
type Piece,
|
|
168
|
+
position as indexToPosition,
|
|
169
|
+
San,
|
|
170
|
+
} from '@bedard/hexchess'
|
|
171
|
+
import {
|
|
172
|
+
type Component,
|
|
173
|
+
computed,
|
|
174
|
+
h,
|
|
175
|
+
onMounted,
|
|
176
|
+
onUnmounted,
|
|
177
|
+
shallowRef,
|
|
178
|
+
useTemplateRef,
|
|
179
|
+
watch,
|
|
180
|
+
} from 'vue'
|
|
181
|
+
|
|
182
|
+
import {
|
|
183
|
+
board,
|
|
184
|
+
box,
|
|
185
|
+
defaultOptions,
|
|
186
|
+
initialPosition,
|
|
187
|
+
labels,
|
|
188
|
+
perimeter,
|
|
189
|
+
pieceSize,
|
|
190
|
+
} from './constants'
|
|
191
|
+
import { d } from './dom'
|
|
192
|
+
import { x, y } from './geometry'
|
|
193
|
+
import { hapticConfirm } from './haptics'
|
|
194
|
+
import GiocoPieces from './pieces/Gioco.vue'
|
|
195
|
+
import type { HexboardOptions } from './types'
|
|
196
|
+
|
|
197
|
+
//
|
|
198
|
+
// props
|
|
199
|
+
//
|
|
200
|
+
|
|
201
|
+
const props = withDefaults(
|
|
202
|
+
defineProps<{
|
|
203
|
+
active?: boolean
|
|
204
|
+
autoselect?: boolean
|
|
205
|
+
flipped?: boolean
|
|
206
|
+
hexchess?: Hexchess
|
|
207
|
+
highlight?: number[]
|
|
208
|
+
ignoreTurn?: boolean
|
|
209
|
+
options?: Partial<HexboardOptions>
|
|
210
|
+
pieces?: Component
|
|
211
|
+
playing?: Color | boolean
|
|
212
|
+
position?: string
|
|
213
|
+
}>(),
|
|
214
|
+
{
|
|
215
|
+
active: false,
|
|
216
|
+
autoselect: false,
|
|
217
|
+
flipped: false,
|
|
218
|
+
hexchess: () => Hexchess.init(),
|
|
219
|
+
highlight: () => [],
|
|
220
|
+
ignoreTurn: false,
|
|
221
|
+
options: () => ({}),
|
|
222
|
+
pieces: () => GiocoPieces,
|
|
223
|
+
playing: false,
|
|
224
|
+
position: initialPosition,
|
|
225
|
+
},
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
//
|
|
229
|
+
// events
|
|
230
|
+
//
|
|
231
|
+
|
|
232
|
+
const emit = defineEmits<{
|
|
233
|
+
clickPosition: [position: number]
|
|
234
|
+
move: [san: San]
|
|
235
|
+
}>()
|
|
236
|
+
|
|
237
|
+
//
|
|
238
|
+
// models
|
|
239
|
+
//
|
|
240
|
+
|
|
241
|
+
const mouseoverPosition = defineModel<number | null>('mouseover-position', {
|
|
242
|
+
default: null,
|
|
243
|
+
required: false,
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
const selected = defineModel<number | null>('selected', {
|
|
247
|
+
default: null,
|
|
248
|
+
required: false,
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
const targets = defineModel<number[]>('targets', {
|
|
252
|
+
default: () => [],
|
|
253
|
+
required: false,
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
//
|
|
257
|
+
// state
|
|
258
|
+
//
|
|
259
|
+
|
|
260
|
+
/** current pointer coordinates */
|
|
261
|
+
const pointerCoords = shallowRef({ x: 0, y: 0 })
|
|
262
|
+
|
|
263
|
+
/** fen position of pointerdown */
|
|
264
|
+
const pointerdownPosition = shallowRef<number | null>(null)
|
|
265
|
+
|
|
266
|
+
/** rect of promotion anchor element */
|
|
267
|
+
const promotionRect = shallowRef<DOMRect>(new DOMRect())
|
|
268
|
+
|
|
269
|
+
/** staging display data */
|
|
270
|
+
const staging = shallowRef<{
|
|
271
|
+
hexchess: Hexchess | null
|
|
272
|
+
promotionEl: Element | null
|
|
273
|
+
promotionFrom: number | null
|
|
274
|
+
promotionTo: number | null
|
|
275
|
+
selected: number | null
|
|
276
|
+
}>({
|
|
277
|
+
hexchess: null,
|
|
278
|
+
promotionEl: null,
|
|
279
|
+
promotionFrom: null,
|
|
280
|
+
promotionTo: null,
|
|
281
|
+
selected: null,
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
/** svg rect */
|
|
285
|
+
const svgEl = useTemplateRef('svgEl')
|
|
286
|
+
|
|
287
|
+
/** rect of svg element on pointerdown */
|
|
288
|
+
const svgRect = shallowRef<DOMRect>(new DOMRect())
|
|
289
|
+
|
|
290
|
+
/** flag to skip click handling after promotion cancel */
|
|
291
|
+
let skipNextClick = false
|
|
292
|
+
|
|
293
|
+
//
|
|
294
|
+
// computed
|
|
295
|
+
//
|
|
296
|
+
|
|
297
|
+
/** current targets */
|
|
298
|
+
const currentTargets = computed(() => {
|
|
299
|
+
if (staging.value.hexchess) {
|
|
300
|
+
return []
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return targets.value
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
/** current hexchess state */
|
|
307
|
+
const currentHexchess = computed(() => {
|
|
308
|
+
if (staging.value.hexchess) {
|
|
309
|
+
return staging.value.hexchess
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (props.hexchess) {
|
|
313
|
+
return props.hexchess
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return Hexchess.init()
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
/** current pieces */
|
|
320
|
+
const currentPieces = computed(() => {
|
|
321
|
+
return currentHexchess.value.board.reduce<{ piece: Piece, index: number }[]>(
|
|
322
|
+
(acc, piece, index) => {
|
|
323
|
+
if (piece && index !== pointerdownPosition.value) {
|
|
324
|
+
acc.push({ piece, index })
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return acc
|
|
328
|
+
},
|
|
329
|
+
[],
|
|
330
|
+
)
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
/** current selected position */
|
|
334
|
+
const currentSelected = computed(() => {
|
|
335
|
+
if (typeof staging.value.selected === 'number') {
|
|
336
|
+
return null
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return selected.value
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
/** cursor type */
|
|
343
|
+
const cursor = computed(() => {
|
|
344
|
+
if (dragPiece.value) {
|
|
345
|
+
return 'grabbing' // global cursor
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (
|
|
349
|
+
!props.active
|
|
350
|
+
|| mouseoverPosition.value === null
|
|
351
|
+
|| staging.value.hexchess
|
|
352
|
+
) {
|
|
353
|
+
return undefined
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// If piece is selected and hovering over a target, show pointer
|
|
357
|
+
if (
|
|
358
|
+
selected.value !== null
|
|
359
|
+
&& targets.value.includes(mouseoverPosition.value)
|
|
360
|
+
) {
|
|
361
|
+
const selectedPiece = currentHexchess.value?.board[selected.value]
|
|
362
|
+
if (selectedPiece) {
|
|
363
|
+
const selectedPieceColor: Color
|
|
364
|
+
= selectedPiece === selectedPiece.toLowerCase() ? 'b' : 'w'
|
|
365
|
+
const isSelectedTurn = currentHexchess.value?.turn === selectedPieceColor
|
|
366
|
+
|
|
367
|
+
// Allow moving if playing both colors, or if it's the selected piece's turn
|
|
368
|
+
if (
|
|
369
|
+
(props.playing === true || isSelectedTurn)
|
|
370
|
+
&& isPlayingPosition(selected.value)
|
|
371
|
+
) {
|
|
372
|
+
return 'pointer'
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (!mouseoverPiece.value) {
|
|
378
|
+
return undefined
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// When playing both colors, any piece is draggable
|
|
382
|
+
if (props.playing === true) {
|
|
383
|
+
return 'grab'
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// When playing a single color, check if piece is draggable (must be their turn)
|
|
387
|
+
if (
|
|
388
|
+
props.playing
|
|
389
|
+
&& mouseoverColor.value === currentHexchess.value?.turn
|
|
390
|
+
&& props.playing === mouseoverColor.value
|
|
391
|
+
) {
|
|
392
|
+
return 'grab'
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return 'pointer'
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
/** coordinates of drag transformation */
|
|
399
|
+
const dragCoords = computed(() => {
|
|
400
|
+
return {
|
|
401
|
+
x: pointerCoords.value.x - svgRect.value.width / 2,
|
|
402
|
+
y: pointerCoords.value.y - svgRect.value.height / 2,
|
|
403
|
+
}
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
/** piece being dragged */
|
|
407
|
+
const dragPiece = computed(() => {
|
|
408
|
+
if (
|
|
409
|
+
!props.hexchess
|
|
410
|
+
|| staging.value.hexchess
|
|
411
|
+
|| pointerdownPosition.value === null
|
|
412
|
+
) {
|
|
413
|
+
return null
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return props.hexchess.board[pointerdownPosition.value]
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
/** normalized options */
|
|
420
|
+
const normalizedOptions = computed(() => {
|
|
421
|
+
return { ...defaultOptions, ...props.options }
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
/** color of piece at mouseover position */
|
|
425
|
+
const mouseoverColor = computed<Color | null>(() => {
|
|
426
|
+
if (!mouseoverPiece.value) {
|
|
427
|
+
return null
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return mouseoverPiece.value === mouseoverPiece.value.toLowerCase()
|
|
431
|
+
? 'b'
|
|
432
|
+
: 'w'
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
/** piece at mouseover position */
|
|
436
|
+
const mouseoverPiece = computed(() => {
|
|
437
|
+
if (mouseoverPosition.value === null) {
|
|
438
|
+
return null
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return currentHexchess.value?.board[mouseoverPosition.value] ?? null
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
/** promotion piece components for the promoting color */
|
|
445
|
+
const promotionPieces = computed(() => {
|
|
446
|
+
const piece = staging.value.hexchess?.board[staging.value.selected ?? -1]
|
|
447
|
+
const isWhite = piece === piece?.toUpperCase()
|
|
448
|
+
|
|
449
|
+
const createPiece = (type: string) => {
|
|
450
|
+
return (attrs: Record<string, unknown>) =>
|
|
451
|
+
h(props.pieces, { ...attrs, type })
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return {
|
|
455
|
+
n: createPiece(isWhite ? 'N' : 'n'),
|
|
456
|
+
b: createPiece(isWhite ? 'B' : 'b'),
|
|
457
|
+
r: createPiece(isWhite ? 'R' : 'r'),
|
|
458
|
+
q: createPiece(isWhite ? 'Q' : 'q'),
|
|
459
|
+
}
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
//
|
|
463
|
+
// lifecycle
|
|
464
|
+
//
|
|
465
|
+
|
|
466
|
+
onMounted(() => {
|
|
467
|
+
if (props.active) {
|
|
468
|
+
listen()
|
|
469
|
+
}
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
onUnmounted(unlisten)
|
|
473
|
+
|
|
474
|
+
//
|
|
475
|
+
// watchers
|
|
476
|
+
//
|
|
477
|
+
|
|
478
|
+
watch(cursor, (val) => {
|
|
479
|
+
document.body.style.setProperty(
|
|
480
|
+
'cursor',
|
|
481
|
+
val === 'grabbing' ? 'grabbing' : null,
|
|
482
|
+
)
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
watch(
|
|
486
|
+
() => props.active,
|
|
487
|
+
val => (val ? listen() : unlisten()),
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
//
|
|
491
|
+
// methods
|
|
492
|
+
//
|
|
493
|
+
|
|
494
|
+
/** attempt to move piece from source to target position */
|
|
495
|
+
function attemptMove(san: San, evt?: MouseEvent) {
|
|
496
|
+
// Check if target is valid
|
|
497
|
+
if (!targets.value.includes(san.to)) {
|
|
498
|
+
return
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const piece = props.hexchess?.board[san.from]
|
|
502
|
+
|
|
503
|
+
if (!piece) {
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const pieceColor = piece === piece.toLowerCase() ? 'b' : 'w'
|
|
508
|
+
|
|
509
|
+
const isCurrentTurn = props.hexchess?.turn === pieceColor
|
|
510
|
+
|
|
511
|
+
// Check if this is a pawn promotion move
|
|
512
|
+
if (
|
|
513
|
+
props.hexchess
|
|
514
|
+
&& (piece === 'p' || piece === 'P')
|
|
515
|
+
&& isPromotionPosition(san.to, pieceColor)
|
|
516
|
+
) {
|
|
517
|
+
const clone = props.hexchess.clone()
|
|
518
|
+
clone.board[san.from] = null
|
|
519
|
+
clone.board[san.to] = piece
|
|
520
|
+
staging.value = {
|
|
521
|
+
hexchess: clone,
|
|
522
|
+
promotionEl: evt?.target instanceof Element ? evt.target : null,
|
|
523
|
+
promotionFrom: san.from,
|
|
524
|
+
promotionTo: san.to,
|
|
525
|
+
selected: san.to,
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (evt?.target instanceof Element) {
|
|
529
|
+
promotionRect.value = evt.target.getBoundingClientRect()
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Only call onPieceMove if playing this color and it's their turn (or ignoreTurn is true)
|
|
536
|
+
if (isPlayingPosition(san.from) && (props.ignoreTurn || isCurrentTurn)) {
|
|
537
|
+
onPieceMove(san)
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** check if user is playing the color at a position */
|
|
542
|
+
function isPlayingPosition(index: number): boolean {
|
|
543
|
+
const piece = props.hexchess?.board[index]
|
|
544
|
+
|
|
545
|
+
if (!piece) {
|
|
546
|
+
return false
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const pieceColor: Color = piece === piece.toLowerCase() ? 'b' : 'w'
|
|
550
|
+
|
|
551
|
+
return props.playing === true || props.playing === pieceColor
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** get fill color of label */
|
|
555
|
+
function getLabelFill(text: string) {
|
|
556
|
+
if (mouseoverPosition.value === null) {
|
|
557
|
+
return normalizedOptions.value.labelColor
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (
|
|
561
|
+
indexToPosition(mouseoverPosition.value)?.startsWith(text)
|
|
562
|
+
|| indexToPosition(mouseoverPosition.value)?.endsWith(text)
|
|
563
|
+
) {
|
|
564
|
+
return normalizedOptions.value.labelActiveColor
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return normalizedOptions.value.labelInactiveColor
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** listen for events */
|
|
571
|
+
function listen() {
|
|
572
|
+
pointerCoords.value = { x: 0, y: 0 }
|
|
573
|
+
|
|
574
|
+
window.addEventListener('keyup', onKeyupWindow)
|
|
575
|
+
window.addEventListener('pointermove', onPointermoveWindow)
|
|
576
|
+
window.addEventListener('pointerup', onPointerupWindow)
|
|
577
|
+
window.addEventListener('resize', measurePromotionRect)
|
|
578
|
+
window.addEventListener('scroll', measurePromotionRect)
|
|
579
|
+
window.addEventListener('touchmove', onTouchmoveWindow, { passive: false })
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** measure promotion element rect */
|
|
583
|
+
function measurePromotionRect() {
|
|
584
|
+
promotionRect.value
|
|
585
|
+
= staging.value.promotionEl?.getBoundingClientRect() ?? new DOMRect()
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** click position */
|
|
589
|
+
function onClickPosition(index: number, evt: MouseEvent) {
|
|
590
|
+
if (!props.active) {
|
|
591
|
+
return
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Skip this click if we just canceled a promotion (handled by pointerup)
|
|
595
|
+
if (skipNextClick) {
|
|
596
|
+
skipNextClick = false
|
|
597
|
+
return
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// If staging a promotion, cancel it unless clicking on the promotion position
|
|
601
|
+
if (staging.value.hexchess) {
|
|
602
|
+
if (staging.value.selected !== index) {
|
|
603
|
+
cancelPromotion()
|
|
604
|
+
}
|
|
605
|
+
return
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// If there's a selected piece and clicking a target, attempt to move
|
|
609
|
+
if (selected.value !== null && targets.value.includes(index)) {
|
|
610
|
+
const san = new San({ from: selected.value, to: index })
|
|
611
|
+
attemptMove(san, evt)
|
|
612
|
+
return
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// If autoselect is enabled and clicking an unoccupied position, deselect
|
|
616
|
+
if (props.autoselect && !props.hexchess.board[index]) {
|
|
617
|
+
selected.value = null
|
|
618
|
+
targets.value = []
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
emit('clickPosition', index)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** keyup window */
|
|
625
|
+
function onKeyupWindow(evt: KeyboardEvent) {
|
|
626
|
+
if (evt.key === 'Escape') {
|
|
627
|
+
// If staging a promotion, cancel it
|
|
628
|
+
if (staging.value.hexchess) {
|
|
629
|
+
cancelPromotion()
|
|
630
|
+
return
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Otherwise deselect if autoselect is enabled
|
|
634
|
+
if (props.autoselect) {
|
|
635
|
+
selected.value = null
|
|
636
|
+
targets.value = []
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** handle piece move */
|
|
642
|
+
function onPieceMove(san: San) {
|
|
643
|
+
emit('move', san)
|
|
644
|
+
|
|
645
|
+
resetState()
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** pointerup position */
|
|
649
|
+
function onPointerupPosition(index: number, evt: PointerEvent) {
|
|
650
|
+
evt.stopPropagation()
|
|
651
|
+
|
|
652
|
+
// Check if we're dropping a piece on a valid target (drag and drop)
|
|
653
|
+
if (pointerdownPosition.value !== null) {
|
|
654
|
+
// On touch devices, pointerup fires on the element where touch started, not where it ended.
|
|
655
|
+
// Use elementFromPoint to find the actual target position.
|
|
656
|
+
let targetIndex = index
|
|
657
|
+
const elementUnderPointer = document.elementFromPoint(
|
|
658
|
+
evt.clientX,
|
|
659
|
+
evt.clientY,
|
|
660
|
+
)
|
|
661
|
+
const posAttr = elementUnderPointer?.getAttribute('data-hexboard-position')
|
|
662
|
+
|
|
663
|
+
if (posAttr !== null) {
|
|
664
|
+
targetIndex = Number(posAttr)
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const san = new San({ from: pointerdownPosition.value, to: targetIndex })
|
|
668
|
+
attemptMove(san, evt)
|
|
669
|
+
|
|
670
|
+
// If staging a promotion, don't reset
|
|
671
|
+
if (staging.value.hexchess) {
|
|
672
|
+
return
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Keep selection but reset drag state
|
|
676
|
+
pointerdownPosition.value = null
|
|
677
|
+
svgRect.value = new DOMRect()
|
|
678
|
+
return
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Check if clicking on a target while a piece is selected (click to move)
|
|
682
|
+
if (selected.value !== null && targets.value.includes(index)) {
|
|
683
|
+
const san = new San({ from: selected.value, to: index })
|
|
684
|
+
attemptMove(san, evt)
|
|
685
|
+
|
|
686
|
+
// If staging a promotion, don't reset
|
|
687
|
+
if (staging.value.hexchess) {
|
|
688
|
+
return
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Move was made, reset state
|
|
692
|
+
return
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// If staging a promotion and clicking on a non-target, cancel the promotion
|
|
696
|
+
if (staging.value.hexchess) {
|
|
697
|
+
cancelPromotion()
|
|
698
|
+
return
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// If clicking on any piece, keep the selection (it was set in pointerdown)
|
|
702
|
+
if (props.hexchess?.board[index]) {
|
|
703
|
+
pointerdownPosition.value = null
|
|
704
|
+
svgRect.value = new DOMRect()
|
|
705
|
+
return
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
resetState()
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** cancel promotion and restore original selection */
|
|
712
|
+
function cancelPromotion() {
|
|
713
|
+
const from = staging.value.promotionFrom
|
|
714
|
+
|
|
715
|
+
staging.value = {
|
|
716
|
+
hexchess: null,
|
|
717
|
+
promotionEl: null,
|
|
718
|
+
promotionFrom: null,
|
|
719
|
+
promotionTo: null,
|
|
720
|
+
selected: null,
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Keep the original piece selected
|
|
724
|
+
if (typeof from === 'number') {
|
|
725
|
+
selected.value = from
|
|
726
|
+
targets.value = props.hexchess.movesFrom(from).map(san => san.to) ?? []
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
pointerdownPosition.value = null
|
|
730
|
+
skipNextClick = true
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** pointerdown on position */
|
|
734
|
+
function onPointerdownPosition(index: number, evt: PointerEvent) {
|
|
735
|
+
evt.preventDefault()
|
|
736
|
+
|
|
737
|
+
hapticConfirm()
|
|
738
|
+
|
|
739
|
+
// Don't start new interactions during promotion
|
|
740
|
+
if (staging.value.hexchess) {
|
|
741
|
+
return
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const piece = props.hexchess?.board[index]
|
|
745
|
+
|
|
746
|
+
if (!piece) {
|
|
747
|
+
return
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (props.autoselect) {
|
|
751
|
+
selected.value = index
|
|
752
|
+
targets.value = props.hexchess?.movesFrom(index).map(san => san.to) ?? []
|
|
753
|
+
|
|
754
|
+
if (normalizedOptions.value.haptics) {
|
|
755
|
+
hapticConfirm()
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (!isPlayingPosition(index)) {
|
|
760
|
+
return
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Only allow dragging if it's the piece's turn (or ignoreTurn is true)
|
|
764
|
+
const pieceColor: Color = piece === piece.toLowerCase() ? 'b' : 'w'
|
|
765
|
+
const isCurrentTurn = props.hexchess?.turn === pieceColor
|
|
766
|
+
|
|
767
|
+
if (!props.ignoreTurn && !isCurrentTurn) {
|
|
768
|
+
return
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
pointerdownPosition.value = index
|
|
772
|
+
pointerCoords.value = { x: evt.clientX, y: evt.clientY }
|
|
773
|
+
|
|
774
|
+
if (svgEl.value instanceof Element) {
|
|
775
|
+
svgRect.value = svgEl.value.getBoundingClientRect()
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** mouseenter position */
|
|
780
|
+
function onMouseenter(index: number) {
|
|
781
|
+
mouseoverPosition.value = index
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** mouseleave position */
|
|
785
|
+
function onMouseleave() {
|
|
786
|
+
mouseoverPosition.value = null
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** pointermove window */
|
|
790
|
+
function onPointermoveWindow(evt: MouseEvent) {
|
|
791
|
+
if (!props.active) {
|
|
792
|
+
return
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
pointerCoords.value = { x: evt.clientX, y: evt.clientY }
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/** touchmove window - prevent scrolling while dragging */
|
|
799
|
+
function onTouchmoveWindow(evt: TouchEvent) {
|
|
800
|
+
if (pointerdownPosition.value !== null) {
|
|
801
|
+
evt.preventDefault()
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** pointerup window */
|
|
806
|
+
function onPointerupWindow() {
|
|
807
|
+
// If staging a promotion, cancel it but keep the original piece selected
|
|
808
|
+
if (staging.value.hexchess) {
|
|
809
|
+
cancelPromotion()
|
|
810
|
+
return
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// If dragging a piece, keep the selection but reset drag state
|
|
814
|
+
if (pointerdownPosition.value !== null) {
|
|
815
|
+
pointerdownPosition.value = null
|
|
816
|
+
svgRect.value = new DOMRect()
|
|
817
|
+
return
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
resetState()
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/** promote piece */
|
|
824
|
+
function promote(promotion: 'n' | 'b' | 'r' | 'q') {
|
|
825
|
+
if (
|
|
826
|
+
typeof staging.value.promotionFrom === 'number'
|
|
827
|
+
&& isPlayingPosition(staging.value.promotionFrom)
|
|
828
|
+
) {
|
|
829
|
+
const san = new San({
|
|
830
|
+
from: staging.value.promotionFrom ?? 0,
|
|
831
|
+
to: staging.value.promotionTo ?? 0,
|
|
832
|
+
promotion: promotion,
|
|
833
|
+
})
|
|
834
|
+
|
|
835
|
+
onPieceMove(san)
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** reset state */
|
|
840
|
+
function resetState() {
|
|
841
|
+
document.body.style.setProperty('cursor', null)
|
|
842
|
+
pointerdownPosition.value = null
|
|
843
|
+
selected.value = null
|
|
844
|
+
staging.value = {
|
|
845
|
+
hexchess: null,
|
|
846
|
+
promotionEl: null,
|
|
847
|
+
promotionFrom: null,
|
|
848
|
+
promotionTo: null,
|
|
849
|
+
selected: null,
|
|
850
|
+
}
|
|
851
|
+
svgRect.value = new DOMRect()
|
|
852
|
+
targets.value = []
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/** stop listening for events */
|
|
856
|
+
function unlisten() {
|
|
857
|
+
resetState()
|
|
858
|
+
window.removeEventListener('keyup', onKeyupWindow)
|
|
859
|
+
window.removeEventListener('pointermove', onPointermoveWindow)
|
|
860
|
+
window.removeEventListener('pointerup', onPointerupWindow)
|
|
861
|
+
window.removeEventListener('resize', measurePromotionRect)
|
|
862
|
+
window.removeEventListener('scroll', measurePromotionRect)
|
|
863
|
+
window.removeEventListener('touchmove', onTouchmoveWindow)
|
|
864
|
+
}
|
|
865
|
+
</script>
|