guandan.dev

guandan.dev

https://git.tonybtw.com/guandan.dev.git git://git.tonybtw.com/guandan.dev.git
16,301 bytes raw
1
import { motion } from 'framer-motion'
2
import { Card as Card_Type, Rank, get_rank_symbol } from '../game/types'
3
import { Hand } from './Hand'
4
import { Card } from './Card'
5
import { use_is_mobile } from '../hooks/use_is_mobile'
6
7
interface Player_Play {
8
  cards: Card_Type[]
9
  is_pass: boolean
10
}
11
12
interface Game_Props {
13
  hand: Card_Type[]
14
  level: Rank
15
  selected_ids: Set<number>
16
  on_card_click: (id: number) => void
17
  on_select_same_rank: (rank: number) => void
18
  on_play: () => void
19
  on_pass: () => void
20
  table_cards: Card_Type[]
21
  combo_type: string
22
  current_turn: number
23
  my_seat: number
24
  can_pass: boolean
25
  player_card_counts: number[]
26
  team_levels: [number, number]
27
  players_map: Record<number, string>
28
  last_play_seat: number | null
29
  player_plays: Record<number, Player_Play>
30
  leading_seat: number | null
31
}
32
33
export function Game({
34
  hand,
35
  level,
36
  selected_ids,
37
  on_card_click,
38
  on_select_same_rank,
39
  on_play,
40
  on_pass,
41
  combo_type,
42
  current_turn,
43
  my_seat,
44
  can_pass,
45
  player_card_counts,
46
  team_levels,
47
  players_map,
48
  player_plays,
49
  leading_seat,
50
}: Game_Props) {
51
  const is_my_turn = current_turn === my_seat
52
  const relative_positions = get_relative_positions(my_seat)
53
  const is_mobile = use_is_mobile()
54
55
  return (
56
    <div style={is_mobile ? mobile_styles.container : styles.container}>
57
      {/* Info bar */}
58
      <div style={is_mobile ? mobile_styles.info_bar : styles.info_bar}>
59
        <div style={is_mobile ? mobile_styles.level_badge : styles.level_badge}>
60
          Lvl: {get_rank_symbol(level)}
61
        </div>
62
        <div style={is_mobile ? mobile_styles.team_scores : styles.team_scores}>
63
          <span style={{ color: '#64b5f6' }}>T1: {get_rank_symbol(team_levels[0] as Rank)}</span>
64
          <span style={{ marginLeft: is_mobile ? 8 : 12, color: '#f48fb1' }}>T2: {get_rank_symbol(team_levels[1] as Rank)}</span>
65
        </div>
66
      </div>
67
68
      {/* Game area - relative container with absolute positioned elements */}
69
      <div style={is_mobile ? mobile_styles.game_area : styles.game_area}>
70
        {/* Top player badge + cards */}
71
        <Player_Badge
72
          seat={relative_positions.top}
73
          name={players_map[relative_positions.top]}
74
          count={player_card_counts[relative_positions.top]}
75
          is_turn={current_turn === relative_positions.top}
76
          is_leading={leading_seat === relative_positions.top}
77
          position="top"
78
          is_mobile={is_mobile}
79
        />
80
        <Played_Cards
81
          play={player_plays[relative_positions.top]}
82
          is_leading={leading_seat === relative_positions.top}
83
          combo_type={leading_seat === relative_positions.top ? combo_type : ''}
84
          level={level}
85
          position="top"
86
          is_mobile={is_mobile}
87
        />
88
89
        {/* Left player badge + cards */}
90
        <Player_Badge
91
          seat={relative_positions.left}
92
          name={players_map[relative_positions.left]}
93
          count={player_card_counts[relative_positions.left]}
94
          is_turn={current_turn === relative_positions.left}
95
          is_leading={leading_seat === relative_positions.left}
96
          position="left"
97
          is_mobile={is_mobile}
98
        />
99
        <Played_Cards
100
          play={player_plays[relative_positions.left]}
101
          is_leading={leading_seat === relative_positions.left}
102
          combo_type={leading_seat === relative_positions.left ? combo_type : ''}
103
          level={level}
104
          position="left"
105
          is_mobile={is_mobile}
106
        />
107
108
        {/* Right player badge + cards */}
109
        <Player_Badge
110
          seat={relative_positions.right}
111
          name={players_map[relative_positions.right]}
112
          count={player_card_counts[relative_positions.right]}
113
          is_turn={current_turn === relative_positions.right}
114
          is_leading={leading_seat === relative_positions.right}
115
          position="right"
116
          is_mobile={is_mobile}
117
        />
118
        <Played_Cards
119
          play={player_plays[relative_positions.right]}
120
          is_leading={leading_seat === relative_positions.right}
121
          combo_type={leading_seat === relative_positions.right ? combo_type : ''}
122
          level={level}
123
          position="right"
124
          is_mobile={is_mobile}
125
        />
126
127
        {/* My played cards - at bottom center of game area */}
128
        <My_Played_Cards
129
          play={player_plays[my_seat]}
130
          is_leading={leading_seat === my_seat}
131
          combo_type={leading_seat === my_seat ? combo_type : ''}
132
          level={level}
133
          is_mobile={is_mobile}
134
        />
135
      </div>
136
137
      {/* My area at bottom */}
138
      <div style={is_mobile ? mobile_styles.my_area : styles.my_area}>
139
        <Hand
140
          cards={hand}
141
          level={level}
142
          selected_ids={selected_ids}
143
          on_card_click={on_card_click}
144
          on_toggle_selection={on_card_click}
145
          on_select_same_rank={on_select_same_rank}
146
          on_play={on_play}
147
          on_pass={on_pass}
148
          is_my_turn={is_my_turn}
149
          can_pass={can_pass}
150
        />
151
152
        {/* Turn indicator - desktop only (mobile shows in Hand button row) */}
153
        {!is_mobile && is_my_turn && hand.length > 0 && (
154
          <motion.div
155
            initial={{ opacity: 0 }}
156
            animate={{ opacity: 1 }}
157
            style={styles.turn_indicator}
158
          >
159
            Your turn!
160
          </motion.div>
161
        )}
162
        {hand.length === 0 && (
163
          <motion.div
164
            initial={{ opacity: 0 }}
165
            animate={{ opacity: 1 }}
166
            style={{ ...(is_mobile ? mobile_styles.turn_indicator : styles.turn_indicator), backgroundColor: '#28a745' }}
167
          >
168
            You finished!
169
          </motion.div>
170
        )}
171
      </div>
172
    </div>
173
  )
174
}
175
176
interface Player_Badge_Props {
177
  seat: number
178
  name?: string
179
  count: number
180
  is_turn: boolean
181
  is_leading: boolean
182
  position: 'top' | 'left' | 'right'
183
  is_mobile: boolean
184
}
185
186
function Player_Badge({ seat, name, count, is_turn, is_leading, position, is_mobile }: Player_Badge_Props) {
187
  const get_position_style = (): React.CSSProperties => {
188
    const base: React.CSSProperties = {
189
      position: 'absolute',
190
      zIndex: 10,
191
    }
192
193
    if (position === 'top') {
194
      return {
195
        ...base,
196
        top: is_mobile ? 2 : 8,
197
        left: '50%',
198
        transform: 'translateX(-50%)',
199
      }
200
    }
201
    if (position === 'left') {
202
      return {
203
        ...base,
204
        left: is_mobile ? 2 : 8,
205
        top: '40%',
206
        transform: 'translateY(-50%)',
207
      }
208
    }
209
    // right
210
    return {
211
      ...base,
212
      right: is_mobile ? 2 : 8,
213
      top: '40%',
214
      transform: 'translateY(-50%)',
215
    }
216
  }
217
218
  const get_border_color = () => {
219
    if (is_leading) return '#4caf50'
220
    if (is_turn) return '#ffc107'
221
    return 'rgba(255,255,255,0.2)'
222
  }
223
224
  const get_bg_color = () => {
225
    if (is_leading) return 'rgba(76, 175, 80, 0.3)'
226
    if (is_turn) return 'rgba(255, 193, 7, 0.3)'
227
    return 'rgba(0, 0, 0, 0.6)'
228
  }
229
230
  // Mobile: minimal floating badge with no border/background
231
  if (is_mobile) {
232
    return (
233
      <div
234
        style={{
235
          ...get_position_style(),
236
          display: 'flex',
237
          flexDirection: 'column',
238
          alignItems: 'center',
239
          textShadow: '0 1px 3px rgba(0,0,0,0.8)',
240
        }}
241
      >
242
        <div style={{
243
          color: seat % 2 === 0 ? '#64b5f6' : '#f48fb1',
244
          fontSize: 9,
245
          fontWeight: 'bold',
246
          maxWidth: 50,
247
          overflow: 'hidden',
248
          textOverflow: 'ellipsis',
249
          whiteSpace: 'nowrap',
250
        }}>
251
          {name || `P${seat + 1}`}
252
        </div>
253
        <div style={{
254
          color: is_turn ? '#ffc107' : is_leading ? '#4caf50' : '#fff',
255
          fontSize: 12,
256
          fontWeight: 'bold',
257
          lineHeight: 1,
258
        }}>
259
          {count}
260
        </div>
261
      </div>
262
    )
263
  }
264
265
  // Desktop: boxed badge
266
  return (
267
    <div
268
      style={{
269
        ...get_position_style(),
270
        display: 'flex',
271
        flexDirection: 'column',
272
        alignItems: 'center',
273
        padding: '6px 12px',
274
        borderRadius: 10,
275
        border: `2px solid ${get_border_color()}`,
276
        backgroundColor: get_bg_color(),
277
        minWidth: 50,
278
      }}
279
    >
280
      <div style={{
281
        color: seat % 2 === 0 ? '#64b5f6' : '#f48fb1',
282
        fontSize: 12,
283
        fontWeight: 'bold',
284
        maxWidth: 70,
285
        overflow: 'hidden',
286
        textOverflow: 'ellipsis',
287
        whiteSpace: 'nowrap',
288
      }}>
289
        {name || `P${seat + 1}`}
290
      </div>
291
      <div style={{
292
        color: '#fff',
293
        fontSize: 18,
294
        fontWeight: 'bold',
295
        lineHeight: 1.2,
296
      }}>
297
        {count}
298
      </div>
299
    </div>
300
  )
301
}
302
303
interface Played_Cards_Props {
304
  play?: Player_Play
305
  is_leading: boolean
306
  combo_type: string
307
  level: Rank
308
  position: 'top' | 'left' | 'right'
309
  is_mobile: boolean
310
}
311
312
function Played_Cards({ play, is_leading, combo_type, level, position, is_mobile }: Played_Cards_Props) {
313
  if (!play) return null
314
315
  const get_position_style = (): React.CSSProperties => {
316
    const base: React.CSSProperties = {
317
      position: 'absolute',
318
      zIndex: 5,
319
      display: 'flex',
320
      flexDirection: 'row',
321
      alignItems: 'center',
322
    }
323
324
    // Cards appear toward the center from the badge
325
    if (position === 'top') {
326
      return {
327
        ...base,
328
        top: is_mobile ? 28 : 70,
329
        left: '50%',
330
        transform: 'translateX(-50%)',
331
      }
332
    }
333
    if (position === 'left') {
334
      return {
335
        ...base,
336
        left: is_mobile ? 45 : 80,
337
        top: '40%',
338
        transform: 'translateY(-50%)',
339
      }
340
    }
341
    // right
342
    return {
343
      ...base,
344
      right: is_mobile ? 45 : 80,
345
      top: '40%',
346
      transform: 'translateY(-50%)',
347
    }
348
  }
349
350
  if (play.is_pass) {
351
    return (
352
      <div style={get_position_style()}>
353
        <div style={{
354
          color: '#aaa',
355
          fontSize: is_mobile ? 11 : 16,
356
          fontStyle: 'italic',
357
          backgroundColor: 'rgba(0,0,0,0.4)',
358
          padding: is_mobile ? '2px 8px' : '4px 12px',
359
          borderRadius: 4,
360
        }}>
361
          Pass
362
        </div>
363
      </div>
364
    )
365
  }
366
367
  // Less overlap for played cards so all cards are visible
368
  const card_overlap = is_mobile ? -20 : -28
369
370
  return (
371
    <div style={get_position_style()}>
372
      <div style={{
373
        display: 'flex',
374
        flexDirection: 'row',
375
        // No container border on mobile, only on desktop for leading
376
        padding: (!is_mobile && is_leading) ? 4 : 0,
377
        borderRadius: 6,
378
        border: (!is_mobile && is_leading) ? '2px solid #4caf50' : 'none',
379
        backgroundColor: (!is_mobile && is_leading) ? 'rgba(76, 175, 80, 0.15)' : 'transparent',
380
      }}>
381
        {play.cards.map((card, idx) => (
382
          <motion.div
383
            key={card.Id}
384
            initial={{ opacity: 0, scale: 0.5, y: position === 'top' ? -20 : 0, x: position === 'left' ? -20 : position === 'right' ? 20 : 0 }}
385
            animate={{ opacity: 1, scale: 1, y: 0, x: 0 }}
386
            transition={{ delay: idx * 0.03 }}
387
            style={{ marginLeft: idx > 0 ? card_overlap : 0 }}
388
          >
389
            <Card
390
              card={card}
391
              level={level}
392
              selected={false}
393
              on_click={() => {}}
394
              size="small"
395
            />
396
          </motion.div>
397
        ))}
398
      </div>
399
      {is_leading && combo_type && (
400
        <div style={{
401
          marginLeft: is_mobile ? 4 : 8,
402
          padding: is_mobile ? '2px 4px' : '3px 8px',
403
          backgroundColor: 'rgba(0,0,0,0.7)',
404
          color: '#fff',
405
          fontSize: is_mobile ? 8 : 12,
406
          borderRadius: 4,
407
        }}>
408
          {combo_type}
409
        </div>
410
      )}
411
    </div>
412
  )
413
}
414
415
interface My_Played_Cards_Props {
416
  play?: Player_Play
417
  is_leading: boolean
418
  combo_type: string
419
  level: Rank
420
  is_mobile: boolean
421
}
422
423
function My_Played_Cards({ play, is_leading, combo_type, level, is_mobile }: My_Played_Cards_Props) {
424
  if (!play) return null
425
426
  const base_style: React.CSSProperties = {
427
    position: 'absolute',
428
    zIndex: 5,
429
    display: 'flex',
430
    flexDirection: 'row',
431
    alignItems: 'center',
432
    bottom: is_mobile ? 8 : 16,
433
    left: '50%',
434
    transform: 'translateX(-50%)',
435
  }
436
437
  if (play.is_pass) {
438
    return (
439
      <div style={base_style}>
440
        <div style={{
441
          color: '#aaa',
442
          fontSize: is_mobile ? 11 : 16,
443
          fontStyle: 'italic',
444
          backgroundColor: 'rgba(0,0,0,0.4)',
445
          padding: is_mobile ? '2px 8px' : '4px 12px',
446
          borderRadius: 4,
447
        }}>
448
          Pass
449
        </div>
450
      </div>
451
    )
452
  }
453
454
  // Less overlap for played cards so all cards are visible
455
  const card_overlap = is_mobile ? -20 : -28
456
457
  return (
458
    <div style={base_style}>
459
      <div style={{
460
        display: 'flex',
461
        flexDirection: 'row',
462
        padding: is_leading ? (is_mobile ? 2 : 4) : 0,
463
        borderRadius: 6,
464
        border: is_leading ? '2px solid #4caf50' : 'none',
465
        backgroundColor: is_leading ? 'rgba(76, 175, 80, 0.15)' : 'transparent',
466
      }}>
467
        {play.cards.map((card, idx) => (
468
          <motion.div
469
            key={card.Id}
470
            initial={{ opacity: 0, scale: 0.5, y: 20 }}
471
            animate={{ opacity: 1, scale: 1, y: 0 }}
472
            transition={{ delay: idx * 0.03 }}
473
            style={{ marginLeft: idx > 0 ? card_overlap : 0 }}
474
          >
475
            <Card
476
              card={card}
477
              level={level}
478
              selected={false}
479
              on_click={() => {}}
480
              size="small"
481
            />
482
          </motion.div>
483
        ))}
484
      </div>
485
      {is_leading && combo_type && (
486
        <div style={{
487
          marginLeft: is_mobile ? 4 : 8,
488
          padding: is_mobile ? '2px 4px' : '3px 8px',
489
          backgroundColor: 'rgba(0,0,0,0.7)',
490
          color: '#fff',
491
          fontSize: is_mobile ? 8 : 12,
492
          borderRadius: 4,
493
        }}>
494
          {combo_type}
495
        </div>
496
      )}
497
    </div>
498
  )
499
}
500
501
function get_relative_positions(my_seat: number) {
502
  return {
503
    top: (my_seat + 2) % 4,
504
    left: (my_seat + 1) % 4,
505
    right: (my_seat + 3) % 4,
506
  }
507
}
508
509
const styles: Record<string, React.CSSProperties> = {
510
  container: {
511
    display: 'flex',
512
    flexDirection: 'column',
513
    height: '100vh',
514
    backgroundColor: '#0f3460',
515
    overflow: 'hidden',
516
  },
517
  info_bar: {
518
    display: 'flex',
519
    justifyContent: 'space-between',
520
    alignItems: 'center',
521
    padding: '4px 12px',
522
    backgroundColor: '#16213e',
523
    flexShrink: 0,
524
  },
525
  level_badge: {
526
    padding: '3px 8px',
527
    backgroundColor: '#ffc107',
528
    color: '#000',
529
    borderRadius: 6,
530
    fontWeight: 'bold',
531
    fontSize: 12,
532
  },
533
  team_scores: {
534
    color: '#fff',
535
    fontSize: 12,
536
  },
537
  game_area: {
538
    flex: 1,
539
    position: 'relative',
540
    minHeight: 0,
541
    overflow: 'hidden',
542
  },
543
  my_area: {
544
    display: 'flex',
545
    flexDirection: 'column',
546
    alignItems: 'center',
547
    paddingTop: 4,
548
    paddingBottom: 8,
549
    borderTop: '1px solid rgba(255,255,255,0.1)',
550
    flexShrink: 0,
551
    backgroundColor: 'rgba(0,0,0,0.2)',
552
  },
553
  turn_indicator: {
554
    marginTop: 4,
555
    padding: '4px 10px',
556
    backgroundColor: '#ffc107',
557
    color: '#000',
558
    borderRadius: 6,
559
    fontWeight: 'bold',
560
    fontSize: 11,
561
  },
562
}
563
564
const mobile_styles: Record<string, React.CSSProperties> = {
565
  container: {
566
    display: 'flex',
567
    flexDirection: 'column',
568
    height: '100dvh',
569
    backgroundColor: '#0f3460',
570
    overflow: 'hidden',
571
  },
572
  info_bar: {
573
    display: 'flex',
574
    justifyContent: 'space-between',
575
    alignItems: 'center',
576
    padding: '3px 8px',
577
    backgroundColor: '#16213e',
578
    flexShrink: 0,
579
  },
580
  level_badge: {
581
    padding: '2px 6px',
582
    backgroundColor: '#ffc107',
583
    color: '#000',
584
    borderRadius: 6,
585
    fontWeight: 'bold',
586
    fontSize: 10,
587
  },
588
  team_scores: {
589
    color: '#fff',
590
    fontSize: 10,
591
  },
592
  game_area: {
593
    flex: 1,
594
    position: 'relative',
595
    minHeight: 0,
596
    overflow: 'hidden',
597
  },
598
  my_area: {
599
    display: 'flex',
600
    flexDirection: 'column',
601
    alignItems: 'center',
602
    paddingTop: 2,
603
    paddingBottom: 4,
604
    flexShrink: 0,
605
  },
606
  turn_indicator: {
607
    marginTop: 2,
608
    padding: '2px 6px',
609
    backgroundColor: '#28a745',
610
    color: '#fff',
611
    borderRadius: 4,
612
    fontWeight: 'bold',
613
    fontSize: 9,
614
  },
615
}