guandan.dev

guandan.dev

https://git.tonybtw.com/guandan.dev.git git://git.tonybtw.com/guandan.dev.git
10,466 bytes raw
1
import { motion } from 'framer-motion'
2
import { Card as Card_Type, Rank, get_rank_symbol, get_suit_symbol, is_red_suit, Suit_Joker, Rank_Red_Joker } from '../game/types'
3
import { Hand } from './Hand'
4
import { Table } from './Table'
5
import { Card_Back } from './Card'
6
7
interface Play_Log_Entry {
8
  seat: number
9
  cards: Card_Type[]
10
  combo_type: string
11
  is_pass: boolean
12
}
13
14
interface Game_Props {
15
  hand: Card_Type[]
16
  level: Rank
17
  selected_ids: Set<number>
18
  on_card_click: (id: number) => void
19
  on_play: () => void
20
  on_pass: () => void
21
  table_cards: Card_Type[]
22
  combo_type: string
23
  current_turn: number
24
  my_seat: number
25
  can_pass: boolean
26
  player_card_counts: number[]
27
  team_levels: [number, number]
28
  play_log: Play_Log_Entry[]
29
  players_map: Record<number, string>
30
  last_play_seat: number | null
31
}
32
33
export function Game({
34
  hand,
35
  level,
36
  selected_ids,
37
  on_card_click,
38
  on_play,
39
  on_pass,
40
  table_cards,
41
  combo_type,
42
  current_turn,
43
  my_seat,
44
  can_pass,
45
  player_card_counts,
46
  team_levels,
47
  play_log,
48
  players_map,
49
  last_play_seat,
50
}: Game_Props) {
51
  const is_my_turn = current_turn === my_seat
52
  const relative_positions = get_relative_positions(my_seat)
53
54
  return (
55
    <div style={styles.container}>
56
      <div style={styles.info_bar}>
57
        <div style={styles.level_badge}>
58
          Level: {get_rank_symbol(level)}
59
        </div>
60
        <div style={styles.team_scores}>
61
          <span style={{ color: '#2196f3' }}>Team 1: {get_rank_symbol(team_levels[0] as Rank)}</span>
62
          <span style={{ marginLeft: 16, color: '#e91e63' }}>Team 2: {get_rank_symbol(team_levels[1] as Rank)}</span>
63
        </div>
64
      </div>
65
66
      <div style={styles.main_layout}>
67
        <div style={styles.game_area}>
68
          <div style={styles.opponent_top}>
69
            <Opponent_Hand
70
              count={player_card_counts[relative_positions.top]}
71
              is_turn={current_turn === relative_positions.top}
72
              just_played={last_play_seat === relative_positions.top}
73
              seat={relative_positions.top}
74
              name={players_map[relative_positions.top]}
75
            />
76
          </div>
77
78
          <div style={styles.middle_row}>
79
            <div style={styles.opponent_side}>
80
              <Opponent_Hand
81
                count={player_card_counts[relative_positions.left]}
82
                is_turn={current_turn === relative_positions.left}
83
                just_played={last_play_seat === relative_positions.left}
84
                seat={relative_positions.left}
85
                vertical
86
                name={players_map[relative_positions.left]}
87
              />
88
            </div>
89
90
            <div style={styles.table_area}>
91
              <Table cards={table_cards} level={level} combo_type={combo_type} last_play_seat={last_play_seat} />
92
            </div>
93
94
            <div style={styles.opponent_side}>
95
            <Opponent_Hand
96
              count={player_card_counts[relative_positions.right]}
97
              is_turn={current_turn === relative_positions.right}
98
              just_played={last_play_seat === relative_positions.right}
99
              seat={relative_positions.right}
100
              vertical
101
              name={players_map[relative_positions.right]}
102
            />
103
          </div>
104
          </div>
105
106
          <div style={styles.my_area}>
107
          <Hand
108
            cards={hand}
109
            level={level}
110
            selected_ids={selected_ids}
111
            on_card_click={on_card_click}
112
          />
113
114
          <div style={styles.actions}>
115
            <motion.button
116
              whileHover={{ scale: 1.05 }}
117
              whileTap={{ scale: 0.95 }}
118
              onClick={on_play}
119
              disabled={!is_my_turn || selected_ids.size === 0 || hand.length === 0}
120
              style={{
121
                ...styles.action_button,
122
                backgroundColor: is_my_turn && selected_ids.size > 0 && hand.length > 0 ? '#28a745' : '#444',
123
              }}
124
            >
125
              Play
126
            </motion.button>
127
            <motion.button
128
              whileHover={{ scale: 1.05 }}
129
              whileTap={{ scale: 0.95 }}
130
              onClick={on_pass}
131
              disabled={!is_my_turn || !can_pass || hand.length === 0}
132
              style={{
133
                ...styles.action_button,
134
                backgroundColor: is_my_turn && can_pass && hand.length > 0 ? '#dc3545' : '#444',
135
              }}
136
            >
137
              Pass
138
            </motion.button>
139
          </div>
140
141
          {is_my_turn && hand.length > 0 && (
142
            <motion.div
143
              initial={{ opacity: 0 }}
144
              animate={{ opacity: 1 }}
145
              style={styles.turn_indicator}
146
            >
147
              Your turn!
148
            </motion.div>
149
          )}
150
          {hand.length === 0 && (
151
            <motion.div
152
              initial={{ opacity: 0 }}
153
              animate={{ opacity: 1 }}
154
              style={{ ...styles.turn_indicator, backgroundColor: '#28a745' }}
155
            >
156
              You finished!
157
            </motion.div>
158
          )}
159
          </div>
160
        </div>
161
162
        <div style={styles.play_log}>
163
          <div style={styles.play_log_title}>Play Log</div>
164
          {play_log.map((entry, i) => (
165
            <motion.div
166
              key={i}
167
              initial={{ opacity: 0, x: 20 }}
168
              animate={{ opacity: 1, x: 0 }}
169
              style={styles.play_log_entry}
170
            >
171
              <span style={{ color: entry.seat % 2 === 0 ? '#2196f3' : '#e91e63', fontWeight: 'bold' }}>
172
                {players_map[entry.seat] || `Seat ${entry.seat + 1}`}:
173
              </span>{' '}
174
              {entry.is_pass ? (
175
                <span style={{ color: '#888' }}>Pass</span>
176
              ) : (
177
                <span>
178
                  {entry.cards.map((c, j) => (
179
                    <span key={j} style={{ color: c.Suit === Suit_Joker ? (c.Rank === Rank_Red_Joker ? '#dc3545' : '#000') : is_red_suit(c.Suit) ? '#dc3545' : '#000' }}>
180
                      {get_rank_symbol(c.Rank)}{get_suit_symbol(c.Suit)}{j < entry.cards.length - 1 ? ' ' : ''}
181
                    </span>
182
                  ))}
183
                  {entry.combo_type && <span style={{ color: '#888', marginLeft: 4 }}>({entry.combo_type})</span>}
184
                </span>
185
              )}
186
            </motion.div>
187
          ))}
188
        </div>
189
      </div>
190
    </div>
191
  )
192
}
193
194
interface Opponent_Hand_Props {
195
  count: number
196
  is_turn: boolean
197
  just_played?: boolean
198
  seat: number
199
  vertical?: boolean
200
  name?: string
201
}
202
203
function Opponent_Hand({ count, is_turn, just_played, seat, vertical, name }: Opponent_Hand_Props) {
204
  const display_count = Math.min(count, 10)
205
  const overlap = vertical ? 15 : 20
206
207
  const get_highlight_style = () => {
208
    if (just_played) return { backgroundColor: 'rgba(76, 175, 80, 0.3)', border: '2px solid #4caf50' }
209
    if (is_turn) return { backgroundColor: 'rgba(255,193,7,0.2)', border: '2px solid #ffc107' }
210
    return { backgroundColor: 'transparent', border: '2px solid transparent' }
211
  }
212
213
  return (
214
    <div
215
      style={{
216
        display: 'flex',
217
        flexDirection: vertical ? 'column' : 'row',
218
        alignItems: 'center',
219
        gap: 8,
220
        padding: 8,
221
        borderRadius: 8,
222
        transition: 'all 0.2s ease',
223
        ...get_highlight_style(),
224
      }}
225
    >
226
      <div
227
        style={{
228
          display: 'flex',
229
          flexDirection: vertical ? 'column' : 'row',
230
          position: 'relative',
231
          width: vertical ? 50 : 50 + (display_count - 1) * overlap,
232
          height: vertical ? 70 + (display_count - 1) * overlap : 70,
233
        }}
234
      >
235
        {Array.from({ length: display_count }).map((_, i) => (
236
          <div
237
            key={i}
238
            style={{
239
              position: 'absolute',
240
              left: vertical ? 0 : i * overlap,
241
              top: vertical ? i * overlap : 0,
242
              transform: 'scale(0.7)',
243
              transformOrigin: 'top left',
244
            }}
245
          >
246
            <Card_Back />
247
          </div>
248
        ))}
249
      </div>
250
      <div style={{ color: '#fff', fontSize: 12 }}>
251
        {name || `Seat ${seat + 1}`}: {count}
252
      </div>
253
    </div>
254
  )
255
}
256
257
function get_relative_positions(my_seat: number) {
258
  return {
259
    top: (my_seat + 2) % 4,
260
    left: (my_seat + 1) % 4,
261
    right: (my_seat + 3) % 4,
262
  }
263
}
264
265
const styles: Record<string, React.CSSProperties> = {
266
  container: {
267
    display: 'flex',
268
    flexDirection: 'column',
269
    height: '100vh',
270
    backgroundColor: '#0f3460',
271
  },
272
  info_bar: {
273
    display: 'flex',
274
    justifyContent: 'space-between',
275
    alignItems: 'center',
276
    padding: '12px 24px',
277
    backgroundColor: '#16213e',
278
  },
279
  level_badge: {
280
    padding: '8px 16px',
281
    backgroundColor: '#ffc107',
282
    color: '#000',
283
    borderRadius: 8,
284
    fontWeight: 'bold',
285
  },
286
  team_scores: {
287
    color: '#fff',
288
    fontSize: 14,
289
  },
290
  game_area: {
291
    flex: 1,
292
    display: 'flex',
293
    flexDirection: 'column',
294
    padding: 20,
295
  },
296
  opponent_top: {
297
    display: 'flex',
298
    justifyContent: 'center',
299
    marginBottom: 20,
300
  },
301
  middle_row: {
302
    flex: 1,
303
    display: 'flex',
304
    alignItems: 'center',
305
  },
306
  opponent_side: {
307
    width: 120,
308
    display: 'flex',
309
    justifyContent: 'center',
310
  },
311
  table_area: {
312
    flex: 1,
313
    display: 'flex',
314
    justifyContent: 'center',
315
    alignItems: 'center',
316
    backgroundColor: 'rgba(0,0,0,0.2)',
317
    borderRadius: 16,
318
    margin: '0 20px',
319
  },
320
  my_area: {
321
    display: 'flex',
322
    flexDirection: 'column',
323
    alignItems: 'center',
324
    paddingTop: 20,
325
    borderTop: '2px solid #333',
326
  },
327
  actions: {
328
    display: 'flex',
329
    gap: 16,
330
    marginTop: 16,
331
  },
332
  action_button: {
333
    padding: '12px 32px',
334
    fontSize: 16,
335
    border: 'none',
336
    borderRadius: 8,
337
    color: '#fff',
338
    cursor: 'pointer',
339
  },
340
  turn_indicator: {
341
    marginTop: 12,
342
    padding: '8px 16px',
343
    backgroundColor: '#ffc107',
344
    color: '#000',
345
    borderRadius: 8,
346
    fontWeight: 'bold',
347
  },
348
  main_layout: {
349
    display: 'flex',
350
    flex: 1,
351
    overflow: 'hidden',
352
  },
353
  play_log: {
354
    width: 220,
355
    backgroundColor: '#16213e',
356
    padding: 12,
357
    overflowY: 'auto',
358
    borderLeft: '2px solid #333',
359
  },
360
  play_log_title: {
361
    color: '#fff',
362
    fontSize: 14,
363
    fontWeight: 'bold',
364
    marginBottom: 12,
365
    paddingBottom: 8,
366
    borderBottom: '1px solid #333',
367
  },
368
  play_log_entry: {
369
    fontSize: 12,
370
    color: '#fff',
371
    padding: '6px 0',
372
    borderBottom: '1px solid rgba(255,255,255,0.1)',
373
  },
374
}