guandan.dev

guandan.dev

https://git.tonybtw.com/guandan.dev.git git://git.tonybtw.com/guandan.dev.git
8,732 bytes raw
1
import { useCallback, useEffect, useState } from 'react'
2
import { use_websocket } from './hooks/use_websocket'
3
import { Lobby } from './components/Lobby'
4
import { Game } from './components/Game'
5
import {
6
  Card,
7
  Player_Info,
8
  Rank,
9
  Rank_Two,
10
  Message,
11
} from './game/types'
12
import {
13
  find_same_rank,
14
} from './game/combos'
15
16
interface Deal_Cards_Payload {
17
  cards: Card[]
18
  level: Rank
19
}
20
21
interface Room_State_Payload {
22
  room_id: string
23
  players: Player_Info[]
24
  game_active: boolean
25
  your_id: string
26
}
27
28
interface Turn_Payload {
29
  player_id: string
30
  seat: number
31
  can_pass: boolean
32
}
33
34
interface Play_Made_Payload {
35
  player_id: string
36
  seat: number
37
  cards: Card[]
38
  combo_type: string
39
  is_pass: boolean
40
}
41
42
interface Error_Payload {
43
  message: string
44
}
45
46
export default function App() {
47
  const ws_url = import.meta.env.VITE_WS_URL
48
  const { connected, send, on } = use_websocket(ws_url)
49
50
  const [room_id, set_room_id] = useState<string | null>(null)
51
  const [players, set_players] = useState<Player_Info[]>([])
52
  const [game_active, set_game_active] = useState(false)
53
54
  const [hand, set_hand] = useState<Card[]>([])
55
  const [level, set_level] = useState<Rank>(Rank_Two)
56
  const [selected_ids, set_selected_ids] = useState<Set<number>>(new Set())
57
  const [current_turn, set_current_turn] = useState(0)
58
  const [my_seat, set_my_seat] = useState(0)
59
  const [can_pass, set_can_pass] = useState(false)
60
  const [table_cards, set_table_cards] = useState<Card[]>([])
61
  const [combo_type, set_combo_type] = useState('')
62
  const [player_card_counts, set_player_card_counts] = useState([27, 27, 27, 27])
63
  const [team_levels, set_team_levels] = useState<[number, number]>([0, 0])
64
  const [error, set_error] = useState<string | null>(null)
65
  const [players_map, set_players_map] = useState<Record<number, string>>({})
66
  const [last_play_seat, set_last_play_seat] = useState<number | null>(null)
67
  const [player_plays, set_player_plays] = useState<Record<number, { cards: Card[], is_pass: boolean }>>({})
68
  const [leading_seat, set_leading_seat] = useState<number | null>(null)
69
70
  useEffect(() => {
71
    const unsub_room_state = on('room_state', (msg: Message) => {
72
      const payload = msg.payload as Room_State_Payload
73
      set_room_id(payload.room_id)
74
      set_players(payload.players)
75
      set_game_active(payload.game_active)
76
77
      const me = payload.players.find((p) => p.id === payload.your_id)
78
      if (me) {
79
        set_my_seat(me.seat)
80
      }
81
      const pmap: Record<number, string> = {}
82
      payload.players.forEach((p) => {
83
        pmap[p.seat] = p.name
84
      })
85
      set_players_map(pmap)
86
    })
87
88
    const unsub_deal = on('deal_cards', (msg: Message) => {
89
      const payload = msg.payload as Deal_Cards_Payload
90
      set_hand(sort_cards(payload.cards, payload.level))
91
      set_level(payload.level)
92
      set_game_active(true)
93
      set_table_cards([])
94
      set_combo_type('')
95
      set_selected_ids(new Set())
96
      set_player_card_counts([27, 27, 27, 27])
97
      set_player_plays({})
98
      set_leading_seat(null)
99
    })
100
101
    const unsub_turn = on('turn', (msg: Message) => {
102
      const payload = msg.payload as Turn_Payload
103
      set_current_turn(payload.seat)
104
      set_can_pass(payload.can_pass)
105
      // When can_pass is false, this player has control (new trick starting)
106
      if (!payload.can_pass) {
107
        set_player_plays({})
108
        set_leading_seat(null)
109
      }
110
    })
111
112
    const unsub_play_made = on('play_made', (msg: Message) => {
113
      const payload = msg.payload as Play_Made_Payload
114
115
      set_last_play_seat(payload.seat)
116
      setTimeout(() => set_last_play_seat(null), 800)
117
118
      // Track this player's play
119
      set_player_plays(prev => ({
120
        ...prev,
121
        [payload.seat]: { cards: payload.cards, is_pass: payload.is_pass }
122
      }))
123
124
      if (!payload.is_pass) {
125
        set_table_cards(payload.cards)
126
        set_combo_type(payload.combo_type)
127
        set_leading_seat(payload.seat)
128
        set_player_card_counts((prev) => {
129
          const next = [...prev]
130
          next[payload.seat] -= payload.cards.length
131
          return next as [number, number, number, number]
132
        })
133
        const played_ids = new Set(payload.cards.map((c) => c.Id))
134
        set_hand((prev) => prev.filter((c) => !played_ids.has(c.Id)))
135
      }
136
    })
137
138
    const unsub_hand_end = on('hand_end', (msg: Message) => {
139
      const payload = msg.payload as { new_levels: [number, number] }
140
      set_team_levels(payload.new_levels)
141
    })
142
143
    const unsub_error = on('error', (msg: Message) => {
144
      const payload = msg.payload as Error_Payload
145
      set_error(payload.message)
146
      setTimeout(() => set_error(null), 3000)
147
    })
148
149
    return () => {
150
      unsub_room_state()
151
      unsub_deal()
152
      unsub_turn()
153
      unsub_play_made()
154
      unsub_hand_end()
155
      unsub_error()
156
    }
157
  }, [on])
158
159
  const handle_create_room = useCallback(
160
    (name: string) => {
161
      send({
162
        type: 'create_room',
163
        payload: { player_name: name },
164
      })
165
    },
166
    [send]
167
  )
168
169
  const handle_join_room = useCallback(
170
    (room_code: string, name: string) => {
171
      send({
172
        type: 'join_room',
173
        payload: { room_id: room_code, player_name: name },
174
      })
175
    },
176
    [send]
177
  )
178
179
  const handle_fill_bots = useCallback(() => {
180
    send({ type: 'fill_bots', payload: {} })
181
  }, [send])
182
183
  const handle_card_click = useCallback((id: number) => {
184
    set_selected_ids((prev) => {
185
      const next = new Set(prev)
186
      if (next.has(id)) {
187
        next.delete(id)
188
      } else {
189
        next.add(id)
190
      }
191
      return next
192
    })
193
  }, [])
194
195
  const handle_select_same_rank = useCallback((rank: number) => {
196
    set_hand((current_hand) => {
197
      const same_rank_cards = find_same_rank(current_hand, rank)
198
      set_selected_ids((prev) => {
199
        const next = new Set(prev)
200
        // Toggle: if all are selected, deselect all; otherwise select all
201
        const all_selected = same_rank_cards.every(c => prev.has(c.Id))
202
        if (all_selected) {
203
          same_rank_cards.forEach(c => next.delete(c.Id))
204
        } else {
205
          same_rank_cards.forEach(c => next.add(c.Id))
206
        }
207
        return next
208
      })
209
      return current_hand
210
    })
211
  }, [])
212
213
  const handle_play = useCallback(() => {
214
    if (selected_ids.size === 0) return
215
216
    send({
217
      type: 'play_cards',
218
      payload: { card_ids: Array.from(selected_ids) },
219
    })
220
221
    set_selected_ids(new Set())
222
  }, [send, selected_ids])
223
224
  const handle_pass = useCallback(() => {
225
    send({ type: 'pass', payload: {} })
226
  }, [send])
227
228
  if (!connected) {
229
    return (
230
      <div style={styles.connecting}>
231
        <div>Connecting...</div>
232
      </div>
233
    )
234
  }
235
236
  if (!game_active) {
237
    return (
238
      <>
239
        <Lobby
240
          room_id={room_id}
241
          players={players}
242
          on_create_room={handle_create_room}
243
          on_join_room={handle_join_room}
244
          on_fill_bots={handle_fill_bots}
245
        />
246
        {error && <div style={styles.error}>{error}</div>}
247
      </>
248
    )
249
  }
250
251
  return (
252
    <>
253
      <Game
254
        hand={hand}
255
        level={level}
256
        selected_ids={selected_ids}
257
        on_card_click={handle_card_click}
258
        on_select_same_rank={handle_select_same_rank}
259
        on_play={handle_play}
260
        on_pass={handle_pass}
261
        table_cards={table_cards}
262
        combo_type={combo_type}
263
        current_turn={current_turn}
264
        my_seat={my_seat}
265
        can_pass={can_pass}
266
        player_card_counts={player_card_counts}
267
        team_levels={team_levels}
268
        players_map={players_map}
269
        last_play_seat={last_play_seat}
270
        player_plays={player_plays}
271
        leading_seat={leading_seat}
272
      />
273
      {error && <div style={styles.error}>{error}</div>}
274
    </>
275
  )
276
}
277
278
function sort_cards(cards: Card[], level: Rank): Card[] {
279
  return [...cards].sort((a, b) => {
280
    const va = card_sort_value(a, level)
281
    const vb = card_sort_value(b, level)
282
    if (va !== vb) return va - vb
283
    return a.Suit - b.Suit
284
  })
285
}
286
287
function card_sort_value(card: Card, level: Rank): number {
288
  if (card.Rank === 14) return 100
289
  if (card.Rank === 13) return 99
290
  if (card.Rank === level) return 98
291
292
  const base_order = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
293
  return base_order[card.Rank] ?? 0
294
}
295
296
const styles: Record<string, React.CSSProperties> = {
297
  connecting: {
298
    display: 'flex',
299
    justifyContent: 'center',
300
    alignItems: 'center',
301
    height: '100vh',
302
    backgroundColor: '#1a1a2e',
303
    color: '#fff',
304
    fontSize: 24,
305
  },
306
  error: {
307
    position: 'fixed',
308
    bottom: 20,
309
    left: '50%',
310
    transform: 'translateX(-50%)',
311
    padding: '12px 24px',
312
    backgroundColor: '#dc3545',
313
    color: '#fff',
314
    borderRadius: 8,
315
    zIndex: 1000,
316
  },
317
}