guandan.dev

guandan.dev

https://git.tonybtw.com/guandan.dev.git git://git.tonybtw.com/guandan.dev.git
14,584 bytes raw
1
import { useRef, useCallback, useState } from 'react'
2
import { motion, AnimatePresence } from 'framer-motion'
3
import { Card as Card_Type, Rank, Suit_Hearts, Suit_Diamonds, Suit_Clubs, Suit_Spades } from '../game/types'
4
import { Card } from './Card'
5
import { use_is_mobile } from '../hooks/use_is_mobile'
6
7
interface Hand_Props {
8
  cards: Card_Type[]
9
  level: Rank
10
  selected_ids: Set<number>
11
  on_card_click: (id: number) => void
12
  on_toggle_selection: (id: number) => void
13
  on_select_same_rank: (rank: number) => void
14
  on_play: () => void
15
  on_pass: () => void
16
  is_my_turn: boolean
17
  can_pass: boolean
18
}
19
20
interface Column {
21
  id: string
22
  card_ids: number[]
23
  is_custom: boolean
24
}
25
26
export function Hand({ cards, level, selected_ids, on_card_click, on_toggle_selection, on_select_same_rank, on_play, on_pass, is_my_turn, can_pass }: Hand_Props) {
27
  const is_mobile = use_is_mobile()
28
  const last_click = useRef<{ id: number; time: number } | null>(null)
29
  const [custom_columns, set_custom_columns] = useState<Map<string, number[]>>(new Map())
30
31
  // Swipe-to-select state
32
  const [is_swiping, set_is_swiping] = useState(false)
33
  const swipe_start = useRef<{ x: number; y: number } | null>(null)
34
  const swiped_cards = useRef<Set<number>>(new Set())
35
  const card_refs = useRef<Map<number, HTMLDivElement>>(new Map())
36
37
  const handle_card_click = useCallback((card: Card_Type) => {
38
    // Don't trigger click if we were swiping
39
    if (swiped_cards.current.size > 0) {
40
      swiped_cards.current.clear()
41
      return
42
    }
43
44
    const now = Date.now()
45
    const last = last_click.current
46
47
    if (last && last.id === card.Id && now - last.time < 300) {
48
      on_select_same_rank(card.Rank)
49
      last_click.current = null
50
    } else {
51
      on_card_click(card.Id)
52
      last_click.current = { id: card.Id, time: now }
53
    }
54
  }, [on_card_click, on_select_same_rank])
55
56
  // Get all card IDs in custom columns
57
  const cards_in_custom = new Set<number>()
58
  custom_columns.forEach(ids => ids.forEach(id => cards_in_custom.add(id)))
59
60
  // Filter to only cards that still exist in hand
61
  const valid_card_ids = new Set(cards.map(c => c.Id))
62
63
  // Build columns: auto-sorted cards first, then custom columns on right
64
  const columns: Column[] = []
65
  const custom_cols: Column[] = []
66
67
  // Collect custom columns (filter out cards no longer in hand)
68
  custom_columns.forEach((card_ids, col_id) => {
69
    const valid_ids = card_ids.filter(id => valid_card_ids.has(id))
70
    if (valid_ids.length > 0) {
71
      custom_cols.push({ id: col_id, card_ids: valid_ids, is_custom: true })
72
    }
73
  })
74
75
  // Auto-group remaining cards by rank
76
  const remaining_cards = cards.filter(c => !cards_in_custom.has(c.Id))
77
  const by_rank = new Map<number, Card_Type[]>()
78
  remaining_cards.forEach(card => {
79
    const arr = by_rank.get(card.Rank) || []
80
    arr.push(card)
81
    by_rank.set(card.Rank, arr)
82
  })
83
84
  // Sort ranks high to low
85
  const rank_order = (rank: number): number => {
86
    if (rank === 14) return 1000
87
    if (rank === 13) return 999
88
    if (rank === level) return 998
89
    if (rank === 0) return 15
90
    if (rank === 12) return 14
91
    return rank + 2
92
  }
93
94
  const sorted_ranks = Array.from(by_rank.keys()).sort((a, b) => rank_order(b) - rank_order(a))
95
  sorted_ranks.forEach(rank => {
96
    const rank_cards = by_rank.get(rank)!
97
    columns.push({
98
      id: `rank-${rank}`,
99
      card_ids: rank_cards.map(c => c.Id),
100
      is_custom: false
101
    })
102
  })
103
104
  // Add custom columns at the end (right side)
105
  columns.push(...custom_cols)
106
107
  // Card lookup
108
  const card_by_id = new Map(cards.map(c => [c.Id, c]))
109
110
  const card_width = is_mobile ? 48 : 60
111
  const card_height = is_mobile ? 67 : 84
112
  const v_overlap = is_mobile ? 18 : 28
113
  const h_gap = is_mobile ? 3 : 4
114
115
  // Move selected cards to a new custom pile
116
  const handle_create_pile = () => {
117
    if (selected_ids.size === 0) return
118
119
    const selected_array = Array.from(selected_ids)
120
    set_custom_columns(prev => {
121
      const next = new Map(prev)
122
123
      // Remove selected cards from any existing custom columns
124
      next.forEach((ids, col_id) => {
125
        const filtered = ids.filter(id => !selected_ids.has(id))
126
        if (filtered.length === 0) {
127
          next.delete(col_id)
128
        } else {
129
          next.set(col_id, filtered)
130
        }
131
      })
132
133
      // Create new column with selected cards
134
      const new_col_id = `custom-${Date.now()}`
135
      next.set(new_col_id, selected_array)
136
137
      return next
138
    })
139
  }
140
141
  // Reset all custom arrangements
142
  const handle_reset = () => {
143
    set_custom_columns(new Map())
144
  }
145
146
  // Select all cards of a given suit
147
  const handle_select_suit = (suit: number) => {
148
    const suit_cards = cards.filter(c => c.Suit === suit)
149
    if (suit_cards.length === 0) return
150
151
    // Toggle: if all are selected, deselect; otherwise select all
152
    const all_selected = suit_cards.every(c => selected_ids.has(c.Id))
153
    suit_cards.forEach(c => {
154
      if (all_selected) {
155
        on_card_click(c.Id) // deselect
156
      } else if (!selected_ids.has(c.Id)) {
157
        on_toggle_selection(c.Id) // select
158
      }
159
    })
160
  }
161
162
  // Swipe-to-select handlers
163
  const find_card_at_point = (x: number, y: number): number | null => {
164
    for (const [card_id, el] of card_refs.current) {
165
      const rect = el.getBoundingClientRect()
166
      if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
167
        return card_id
168
      }
169
    }
170
    return null
171
  }
172
173
  const handle_swipe_start = (e: React.MouseEvent | React.TouchEvent) => {
174
    const point = 'touches' in e ? e.touches[0] : e
175
    swipe_start.current = { x: point.clientX, y: point.clientY }
176
    swiped_cards.current.clear()
177
    set_is_swiping(false)
178
  }
179
180
  const handle_swipe_move = (e: React.MouseEvent | React.TouchEvent) => {
181
    if (!swipe_start.current) return
182
183
    const point = 'touches' in e ? e.touches[0] : e
184
    const dx = Math.abs(point.clientX - swipe_start.current.x)
185
    const dy = Math.abs(point.clientY - swipe_start.current.y)
186
187
    // Start swiping if moved more than 5px
188
    if (dx > 5 || dy > 5) {
189
      set_is_swiping(true)
190
    }
191
192
    if (is_swiping) {
193
      const card_id = find_card_at_point(point.clientX, point.clientY)
194
      if (card_id !== null && !swiped_cards.current.has(card_id)) {
195
        swiped_cards.current.add(card_id)
196
        on_toggle_selection(card_id)
197
      }
198
    }
199
  }
200
201
  const handle_swipe_end = () => {
202
    swipe_start.current = null
203
    set_is_swiping(false)
204
    // Don't clear swiped_cards here - let click handler check it
205
    setTimeout(() => swiped_cards.current.clear(), 50)
206
  }
207
208
  // Double-tap on custom column to dissolve it
209
  const handle_column_double_click = (col_id: string) => {
210
    if (col_id.startsWith('custom-')) {
211
      set_custom_columns(prev => {
212
        const next = new Map(prev)
213
        next.delete(col_id)
214
        return next
215
      })
216
    }
217
  }
218
219
  return (
220
    <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', width: '100%' }}>
221
      {/* Cards area with swipe detection */}
222
      <div
223
        onMouseDown={handle_swipe_start}
224
        onMouseMove={handle_swipe_move}
225
        onMouseUp={handle_swipe_end}
226
        onMouseLeave={handle_swipe_end}
227
        onTouchStart={handle_swipe_start}
228
        onTouchMove={handle_swipe_move}
229
        onTouchEnd={handle_swipe_end}
230
        style={{
231
          display: 'flex',
232
          justifyContent: 'center',
233
          padding: is_mobile ? '4px 8px' : '8px 16px',
234
          overflowX: 'auto',
235
          overflowY: 'visible',
236
          WebkitOverflowScrolling: 'touch',
237
          width: '100%',
238
          cursor: is_swiping ? 'crosshair' : 'default',
239
          userSelect: 'none',
240
        }}
241
      >
242
        <div style={{ display: 'flex', gap: h_gap, alignItems: 'flex-end' }}>
243
          {columns.map((col, col_idx) => {
244
            const col_cards = col.card_ids.map(id => card_by_id.get(id)!).filter(Boolean)
245
            const col_height = card_height + (col_cards.length - 1) * v_overlap
246
247
            return (
248
              <div
249
                key={col.id}
250
                onDoubleClick={() => handle_column_double_click(col.id)}
251
                style={{
252
                  position: 'relative',
253
                  width: card_width,
254
                  height: col_height,
255
                  borderRadius: 4,
256
                  flexShrink: 0,
257
                }}
258
              >
259
                <AnimatePresence>
260
                  {col_cards.map((card, card_idx) => {
261
                    // First card (idx 0) = bottom position, FRONT (highest z-index)
262
                    // Last card = top position, BACK (lowest z-index, only top peeks out)
263
                    const from_bottom = card_idx
264
265
                    return (
266
                      <motion.div
267
                        key={card.Id}
268
                        ref={(el) => { if (el) card_refs.current.set(card.Id, el) }}
269
                        initial={{ opacity: 0, y: 20, scale: 0.8 }}
270
                        animate={{ opacity: 1, y: 0, scale: 1 }}
271
                        exit={{ opacity: 0, y: -20, scale: 0.8 }}
272
                        transition={{ delay: (col_idx * 0.3 + card_idx) * 0.01 }}
273
                        style={{
274
                          position: 'absolute',
275
                          bottom: from_bottom * v_overlap,
276
                          left: 0,
277
                          zIndex: col_cards.length - card_idx,
278
                          cursor: 'pointer',
279
                          touchAction: 'none',
280
                        }}
281
                      >
282
                        <Card
283
                          card={card}
284
                          level={level}
285
                          selected={selected_ids.has(card.Id)}
286
                          on_click={() => handle_card_click(card)}
287
                          size={is_mobile ? "small" : "normal"}
288
                        />
289
                      </motion.div>
290
                    )
291
                  })}
292
                </AnimatePresence>
293
              </div>
294
            )
295
          })}
296
        </div>
297
      </div>
298
299
      {/* Suit filter buttons + action buttons */}
300
      <div style={{
301
        display: 'flex',
302
        gap: is_mobile ? 5 : 10,
303
        marginTop: is_mobile ? 3 : 8,
304
        justifyContent: 'center',
305
        alignItems: 'center',
306
        flexWrap: 'wrap',
307
      }}>
308
        {/* Turn indicator - mobile only, in the row */}
309
        {is_mobile && is_my_turn && cards.length > 0 && (
310
          <div style={{
311
            padding: '4px 10px',
312
            backgroundColor: '#ffc107',
313
            color: '#000',
314
            borderRadius: 4,
315
            fontWeight: 'bold',
316
            fontSize: 11,
317
          }}>
318
            Your turn
319
          </div>
320
        )}
321
322
        {/* Suit buttons */}
323
        <div style={{ display: 'flex', gap: is_mobile ? 3 : 6 }}>
324
          {[
325
            { suit: Suit_Spades, symbol: '♠', color: '#000' },
326
            { suit: Suit_Hearts, symbol: '♥', color: '#dc3545' },
327
            { suit: Suit_Clubs, symbol: '♣', color: '#000' },
328
            { suit: Suit_Diamonds, symbol: '♦', color: '#dc3545' },
329
          ].map(({ suit, symbol, color }) => (
330
            <button
331
              key={suit}
332
              onClick={() => handle_select_suit(suit)}
333
              style={{
334
                width: is_mobile ? 26 : 34,
335
                height: is_mobile ? 26 : 34,
336
                fontSize: is_mobile ? 14 : 20,
337
                backgroundColor: '#fff',
338
                color: color,
339
                border: '1px solid #ccc',
340
                borderRadius: 4,
341
                cursor: 'pointer',
342
                display: 'flex',
343
                alignItems: 'center',
344
                justifyContent: 'center',
345
                padding: 0,
346
              }}
347
            >
348
              {symbol}
349
            </button>
350
          ))}
351
        </div>
352
353
        {/* Divider */}
354
        <div style={{ width: 1, height: is_mobile ? 20 : 24, backgroundColor: '#555' }} />
355
356
        {/* Action buttons */}
357
        <button
358
          onClick={handle_create_pile}
359
          disabled={selected_ids.size === 0}
360
          style={{
361
            padding: is_mobile ? '4px 8px' : '8px 14px',
362
            fontSize: is_mobile ? 11 : 13,
363
            backgroundColor: selected_ids.size > 0 ? '#9c27b0' : '#444',
364
            color: '#fff',
365
            border: 'none',
366
            borderRadius: 4,
367
            cursor: selected_ids.size > 0 ? 'pointer' : 'default',
368
            opacity: selected_ids.size > 0 ? 1 : 0.5,
369
          }}
370
        >
371
          Pile
372
        </button>
373
        <button
374
          onClick={handle_reset}
375
          disabled={custom_columns.size === 0}
376
          style={{
377
            padding: is_mobile ? '4px 8px' : '8px 14px',
378
            fontSize: is_mobile ? 11 : 13,
379
            backgroundColor: custom_columns.size > 0 ? '#607d8b' : '#444',
380
            color: '#fff',
381
            border: 'none',
382
            borderRadius: 4,
383
            cursor: custom_columns.size > 0 ? 'pointer' : 'default',
384
            opacity: custom_columns.size > 0 ? 1 : 0.5,
385
          }}
386
        >
387
          Reset
388
        </button>
389
390
        {/* Divider */}
391
        <div style={{ width: 1, height: is_mobile ? 20 : 24, backgroundColor: '#555' }} />
392
393
        {/* Play/Pass buttons */}
394
        <button
395
          onClick={on_pass}
396
          disabled={!is_my_turn || !can_pass || cards.length === 0}
397
          style={{
398
            padding: is_mobile ? '4px 10px' : '8px 16px',
399
            fontSize: is_mobile ? 12 : 14,
400
            backgroundColor: is_my_turn && can_pass && cards.length > 0 ? '#dc3545' : '#444',
401
            color: '#fff',
402
            border: 'none',
403
            borderRadius: 4,
404
            cursor: is_my_turn && can_pass && cards.length > 0 ? 'pointer' : 'default',
405
            opacity: is_my_turn && can_pass && cards.length > 0 ? 1 : 0.5,
406
          }}
407
        >
408
          Pass
409
        </button>
410
        <button
411
          onClick={on_play}
412
          disabled={!is_my_turn || selected_ids.size === 0 || cards.length === 0}
413
          style={{
414
            padding: is_mobile ? '4px 14px' : '8px 24px',
415
            fontSize: is_mobile ? 13 : 15,
416
            fontWeight: 'bold',
417
            backgroundColor: is_my_turn && selected_ids.size > 0 && cards.length > 0 ? '#28a745' : '#444',
418
            color: '#fff',
419
            border: 'none',
420
            borderRadius: 4,
421
            cursor: is_my_turn && selected_ids.size > 0 && cards.length > 0 ? 'pointer' : 'default',
422
            opacity: is_my_turn && selected_ids.size > 0 && cards.length > 0 ? 1 : 0.5,
423
          }}
424
        >
425
          Play
426
        </button>
427
      </div>
428
    </div>
429
  )
430
}