guandan.dev

guandan.dev

https://git.tonybtw.com/guandan.dev.git git://git.tonybtw.com/guandan.dev.git

Ditched playerlog, added mobile friendly css, changed dev workflow to run air on 8081.

Commit
8d287286bf838f2d0f5eb1195ffa339386cca250
Parent
28e3abb
Author
tonybanters <tonyoutoften@gmail.com>
Date
2026-01-28 05:04:32

Diff

diff --git a/client/.env.development b/client/.env.development
index 495b484..fb92615 100644
--- a/client/.env.development
+++ b/client/.env.development
@@ -1 +1 @@
-VITE_WS_URL=ws://localhost:8080/ws
+VITE_WS_URL=ws://localhost:8081/ws
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 9d5a689..c33297f 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -59,7 +59,6 @@ export default function App() {
   const [player_card_counts, set_player_card_counts] = useState([27, 27, 27, 27])
   const [team_levels, set_team_levels] = useState<[number, number]>([0, 0])
   const [error, set_error] = useState<string | null>(null)
-  const [play_log, set_play_log] = useState<Array<{ seat: number; cards: Card[]; combo_type: string; is_pass: boolean }>>([])
   const [players_map, set_players_map] = useState<Record<number, string>>({})
   const [last_play_seat, set_last_play_seat] = useState<number | null>(null)
 
@@ -90,7 +89,6 @@ export default function App() {
       set_combo_type('')
       set_selected_ids(new Set())
       set_player_card_counts([27, 27, 27, 27])
-      set_play_log([])
     })
 
     const unsub_turn = on('turn', (msg: Message) => {
@@ -102,16 +100,6 @@ export default function App() {
     const unsub_play_made = on('play_made', (msg: Message) => {
       const payload = msg.payload as Play_Made_Payload
 
-      set_play_log((prev) => {
-        const next = [...prev, {
-          seat: payload.seat,
-          cards: payload.cards || [],
-          combo_type: payload.combo_type || '',
-          is_pass: payload.is_pass,
-        }]
-        return next.slice(-8)
-      })
-
       set_last_play_seat(payload.seat)
       setTimeout(() => set_last_play_seat(null), 800)
 
@@ -239,7 +227,6 @@ export default function App() {
         can_pass={can_pass}
         player_card_counts={player_card_counts}
         team_levels={team_levels}
-        play_log={play_log}
         players_map={players_map}
         last_play_seat={last_play_seat}
       />
diff --git a/client/src/components/Card.tsx b/client/src/components/Card.tsx
index d416822..7c334c9 100644
--- a/client/src/components/Card.tsx
+++ b/client/src/components/Card.tsx
@@ -1,17 +1,26 @@
 import { motion } from 'framer-motion'
 import { Card as Card_Type, get_suit_symbol, get_rank_symbol, is_red_suit, is_wild, Rank, Rank_Red_Joker, Suit_Joker } from '../game/types'
 
+type Card_Size = 'small' | 'normal'
+
 interface Card_Props {
   card: Card_Type
   level: Rank
   selected: boolean
   on_click: () => void
+  size?: Card_Size
+}
+
+const SIZE_CONFIG = {
+  small: { width: 56, height: 80, rank_font: 14, suit_font: 16, center_font: 20, corner_rank: 12, corner_suit: 10 },
+  normal: { width: 70, height: 100, rank_font: 16, suit_font: 18, center_font: 24, corner_rank: 14, corner_suit: 12 },
 }
 
-export function Card({ card, level, selected, on_click }: Card_Props) {
+export function Card({ card, level, selected, on_click, size = 'normal' }: Card_Props) {
   const is_joker = card.Suit === Suit_Joker
   const is_red = is_joker ? card.Rank === Rank_Red_Joker : is_red_suit(card.Suit)
   const is_wild_card = is_wild(card, level)
+  const cfg = SIZE_CONFIG[size]
 
   return (
     <motion.div
@@ -23,8 +32,8 @@ export function Card({ card, level, selected, on_click }: Card_Props) {
       whileHover={{ scale: 1.08 }}
       transition={{ type: 'spring', stiffness: 400, damping: 25 }}
       style={{
-        width: 70,
-        height: 100,
+        width: cfg.width,
+        height: cfg.height,
         backgroundColor: is_wild_card ? '#fff3cd' : '#fff',
         border: is_wild_card ? '3px solid #ffc107' : '2px solid #333',
         borderRadius: 8,
@@ -45,8 +54,8 @@ export function Card({ card, level, selected, on_click }: Card_Props) {
         alignItems: 'center',
         lineHeight: 1,
       }}>
-        <span style={{ fontSize: 16, fontWeight: 'bold' }}>{is_joker ? (card.Rank === Rank_Red_Joker ? 'R' : 'B') : get_rank_symbol(card.Rank)}</span>
-        <span style={{ fontSize: 18 }}>{is_joker ? '🃏' : get_suit_symbol(card.Suit)}</span>
+        <span style={{ fontSize: cfg.rank_font, fontWeight: 'bold' }}>{is_joker ? (card.Rank === Rank_Red_Joker ? 'R' : 'B') : get_rank_symbol(card.Rank)}</span>
+        <span style={{ fontSize: cfg.suit_font }}>{is_joker ? '🃏' : get_suit_symbol(card.Suit)}</span>
       </div>
       <div style={{
         position: 'absolute',
@@ -58,15 +67,15 @@ export function Card({ card, level, selected, on_click }: Card_Props) {
         lineHeight: 1,
         transform: 'rotate(180deg)',
       }}>
-        <span style={{ fontSize: 14 }}>{is_joker ? (card.Rank === Rank_Red_Joker ? 'R' : 'B') : get_rank_symbol(card.Rank)}</span>
-        <span style={{ fontSize: 12 }}>{is_joker ? '🃏' : get_suit_symbol(card.Suit)}</span>
+        <span style={{ fontSize: cfg.corner_rank }}>{is_joker ? (card.Rank === Rank_Red_Joker ? 'R' : 'B') : get_rank_symbol(card.Rank)}</span>
+        <span style={{ fontSize: cfg.corner_suit }}>{is_joker ? '🃏' : get_suit_symbol(card.Suit)}</span>
       </div>
       <div style={{
         position: 'absolute',
         top: '50%',
         left: '50%',
         transform: 'translate(-50%, -50%)',
-        fontSize: is_joker ? 28 : 24,
+        fontSize: is_joker ? cfg.center_font + 4 : cfg.center_font,
       }}>
         {is_joker ? '🃏' : get_suit_symbol(card.Suit)}
       </div>
@@ -74,12 +83,18 @@ export function Card({ card, level, selected, on_click }: Card_Props) {
   )
 }
 
-export function Card_Back() {
+interface Card_Back_Props {
+  size?: Card_Size
+}
+
+export function Card_Back({ size = 'normal' }: Card_Back_Props) {
+  const cfg = SIZE_CONFIG[size]
+
   return (
     <div
       style={{
-        width: 70,
-        height: 100,
+        width: cfg.width,
+        height: cfg.height,
         backgroundColor: '#1e3a5f',
         border: '2px solid #0d1b2a',
         borderRadius: 8,
@@ -89,7 +104,7 @@ export function Card_Back() {
         backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 5px, rgba(255,255,255,0.1) 5px, rgba(255,255,255,0.1) 10px)',
       }}
     >
-      <div style={{ color: '#fff', fontSize: 24 }}>🀄</div>
+      <div style={{ color: '#fff', fontSize: size === 'small' ? 20 : 24 }}>🀄</div>
     </div>
   )
 }
diff --git a/client/src/components/Game.tsx b/client/src/components/Game.tsx
index 26d7dd1..c431c73 100644
--- a/client/src/components/Game.tsx
+++ b/client/src/components/Game.tsx
@@ -1,15 +1,9 @@
 import { motion } from 'framer-motion'
-import { Card as Card_Type, Rank, get_rank_symbol, get_suit_symbol, is_red_suit, Suit_Joker, Rank_Red_Joker } from '../game/types'
+import { Card as Card_Type, Rank, get_rank_symbol } from '../game/types'
 import { Hand } from './Hand'
 import { Table } from './Table'
 import { Card_Back } from './Card'
-
-interface Play_Log_Entry {
-  seat: number
-  cards: Card_Type[]
-  combo_type: string
-  is_pass: boolean
-}
+import { use_is_mobile } from '../hooks/use_is_mobile'
 
 interface Game_Props {
   hand: Card_Type[]
@@ -25,7 +19,6 @@ interface Game_Props {
   can_pass: boolean
   player_card_counts: number[]
   team_levels: [number, number]
-  play_log: Play_Log_Entry[]
   players_map: Record<number, string>
   last_play_seat: number | null
 }
@@ -44,12 +37,93 @@ export function Game({
   can_pass,
   player_card_counts,
   team_levels,
-  play_log,
   players_map,
   last_play_seat,
 }: Game_Props) {
   const is_my_turn = current_turn === my_seat
   const relative_positions = get_relative_positions(my_seat)
+  const is_mobile = use_is_mobile()
+
+  if (is_mobile) {
+    return (
+      <div style={mobile_styles.container}>
+        <div style={mobile_styles.info_bar}>
+          <div style={mobile_styles.level_badge}>
+            Lvl: {get_rank_symbol(level)}
+          </div>
+          <div style={mobile_styles.team_scores}>
+            <span style={{ color: '#2196f3' }}>T1: {get_rank_symbol(team_levels[0] as Rank)}</span>
+            <span style={{ marginLeft: 8, color: '#e91e63' }}>T2: {get_rank_symbol(team_levels[1] as Rank)}</span>
+          </div>
+        </div>
+
+        <Mobile_Opponent_Bar
+          positions={relative_positions}
+          player_card_counts={player_card_counts}
+          current_turn={current_turn}
+          last_play_seat={last_play_seat}
+          players_map={players_map}
+        />
+
+        <div style={mobile_styles.table_area}>
+          <Table cards={table_cards} level={level} combo_type={combo_type} last_play_seat={last_play_seat} />
+        </div>
+
+        <div style={mobile_styles.my_area}>
+          <Hand
+            cards={hand}
+            level={level}
+            selected_ids={selected_ids}
+            on_card_click={on_card_click}
+          />
+
+          <div style={mobile_styles.actions}>
+            <motion.button
+              whileTap={{ scale: 0.95 }}
+              onClick={on_play}
+              disabled={!is_my_turn || selected_ids.size === 0 || hand.length === 0}
+              style={{
+                ...mobile_styles.action_button,
+                backgroundColor: is_my_turn && selected_ids.size > 0 && hand.length > 0 ? '#28a745' : '#444',
+              }}
+            >
+              Play
+            </motion.button>
+            <motion.button
+              whileTap={{ scale: 0.95 }}
+              onClick={on_pass}
+              disabled={!is_my_turn || !can_pass || hand.length === 0}
+              style={{
+                ...mobile_styles.action_button,
+                backgroundColor: is_my_turn && can_pass && hand.length > 0 ? '#dc3545' : '#444',
+              }}
+            >
+              Pass
+            </motion.button>
+          </div>
+
+          {is_my_turn && hand.length > 0 && (
+            <motion.div
+              initial={{ opacity: 0 }}
+              animate={{ opacity: 1 }}
+              style={mobile_styles.turn_indicator}
+            >
+              Your turn!
+            </motion.div>
+          )}
+          {hand.length === 0 && (
+            <motion.div
+              initial={{ opacity: 0 }}
+              animate={{ opacity: 1 }}
+              style={{ ...mobile_styles.turn_indicator, backgroundColor: '#28a745' }}
+            >
+              You finished!
+            </motion.div>
+          )}
+        </div>
+      </div>
+    )
+  }
 
   return (
     <div style={styles.container}>
@@ -158,39 +232,50 @@ export function Game({
           )}
           </div>
         </div>
-
-        <div style={styles.play_log}>
-          <div style={styles.play_log_title}>Play Log</div>
-          {play_log.map((entry, i) => (
-            <motion.div
-              key={i}
-              initial={{ opacity: 0, x: 20 }}
-              animate={{ opacity: 1, x: 0 }}
-              style={styles.play_log_entry}
-            >
-              <span style={{ color: entry.seat % 2 === 0 ? '#2196f3' : '#e91e63', fontWeight: 'bold' }}>
-                {players_map[entry.seat] || `Seat ${entry.seat + 1}`}:
-              </span>{' '}
-              {entry.is_pass ? (
-                <span style={{ color: '#888' }}>Pass</span>
-              ) : (
-                <span>
-                  {entry.cards.map((c, j) => (
-                    <span key={j} style={{ color: c.Suit === Suit_Joker ? (c.Rank === Rank_Red_Joker ? '#dc3545' : '#000') : is_red_suit(c.Suit) ? '#dc3545' : '#000' }}>
-                      {get_rank_symbol(c.Rank)}{get_suit_symbol(c.Suit)}{j < entry.cards.length - 1 ? ' ' : ''}
-                    </span>
-                  ))}
-                  {entry.combo_type && <span style={{ color: '#888', marginLeft: 4 }}>({entry.combo_type})</span>}
-                </span>
-              )}
-            </motion.div>
-          ))}
-        </div>
       </div>
     </div>
   )
 }
 
+interface Mobile_Opponent_Bar_Props {
+  positions: { top: number; left: number; right: number }
+  player_card_counts: number[]
+  current_turn: number
+  last_play_seat: number | null
+  players_map: Record<number, string>
+}
+
+function Mobile_Opponent_Bar({ positions, player_card_counts, current_turn, last_play_seat, players_map }: Mobile_Opponent_Bar_Props) {
+  const opponents = [positions.left, positions.top, positions.right]
+
+  return (
+    <div style={mobile_styles.opponent_bar}>
+      {opponents.map((seat) => {
+        const is_turn = current_turn === seat
+        const just_played = last_play_seat === seat
+
+        return (
+          <div
+            key={seat}
+            style={{
+              ...mobile_styles.opponent_chip,
+              backgroundColor: just_played ? 'rgba(76, 175, 80, 0.3)' : is_turn ? 'rgba(255,193,7,0.3)' : 'rgba(255,255,255,0.1)',
+              borderColor: just_played ? '#4caf50' : is_turn ? '#ffc107' : 'transparent',
+            }}
+          >
+            <span style={{ color: seat % 2 === 0 ? '#2196f3' : '#e91e63', fontWeight: 'bold', fontSize: 12 }}>
+              {players_map[seat] || `P${seat + 1}`}
+            </span>
+            <span style={{ color: '#fff', fontSize: 11, marginLeft: 4 }}>
+              ({player_card_counts[seat]})
+            </span>
+          </div>
+        )
+      })}
+    </div>
+  )
+}
+
 interface Opponent_Hand_Props {
   count: number
   is_turn: boolean
@@ -268,45 +353,52 @@ const styles: Record<string, React.CSSProperties> = {
     flexDirection: 'column',
     height: '100vh',
     backgroundColor: '#0f3460',
+    overflow: 'hidden',
   },
   info_bar: {
     display: 'flex',
     justifyContent: 'space-between',
     alignItems: 'center',
-    padding: '12px 24px',
+    padding: '8px 12px',
     backgroundColor: '#16213e',
+    flexShrink: 0,
   },
   level_badge: {
-    padding: '8px 16px',
+    padding: '6px 12px',
     backgroundColor: '#ffc107',
     color: '#000',
     borderRadius: 8,
     fontWeight: 'bold',
+    fontSize: 14,
   },
   team_scores: {
     color: '#fff',
-    fontSize: 14,
+    fontSize: 12,
   },
   game_area: {
     flex: 1,
     display: 'flex',
     flexDirection: 'column',
-    padding: 20,
+    padding: 8,
+    minHeight: 0,
   },
   opponent_top: {
     display: 'flex',
     justifyContent: 'center',
-    marginBottom: 20,
+    marginBottom: 8,
+    flexShrink: 0,
   },
   middle_row: {
     flex: 1,
     display: 'flex',
     alignItems: 'center',
+    minHeight: 0,
   },
   opponent_side: {
-    width: 120,
+    width: 80,
     display: 'flex',
     justifyContent: 'center',
+    flexShrink: 0,
   },
   table_area: {
     flex: 1,
@@ -314,61 +406,130 @@ const styles: Record<string, React.CSSProperties> = {
     justifyContent: 'center',
     alignItems: 'center',
     backgroundColor: 'rgba(0,0,0,0.2)',
-    borderRadius: 16,
-    margin: '0 20px',
+    borderRadius: 12,
+    margin: '0 8px',
+    minHeight: 120,
   },
   my_area: {
     display: 'flex',
     flexDirection: 'column',
     alignItems: 'center',
-    paddingTop: 20,
+    paddingTop: 8,
     borderTop: '2px solid #333',
+    flexShrink: 0,
   },
   actions: {
     display: 'flex',
-    gap: 16,
-    marginTop: 16,
+    gap: 12,
+    marginTop: 8,
   },
   action_button: {
-    padding: '12px 32px',
-    fontSize: 16,
+    padding: '10px 24px',
+    fontSize: 14,
     border: 'none',
     borderRadius: 8,
     color: '#fff',
     cursor: 'pointer',
   },
   turn_indicator: {
-    marginTop: 12,
-    padding: '8px 16px',
+    marginTop: 8,
+    padding: '6px 12px',
     backgroundColor: '#ffc107',
     color: '#000',
     borderRadius: 8,
     fontWeight: 'bold',
+    fontSize: 12,
   },
   main_layout: {
     display: 'flex',
     flex: 1,
     overflow: 'hidden',
+    minHeight: 0,
   },
-  play_log: {
-    width: 220,
+}
+
+const mobile_styles: Record<string, React.CSSProperties> = {
+  container: {
+    display: 'flex',
+    flexDirection: 'column',
+    height: '100dvh',
+    backgroundColor: '#0f3460',
+    overflow: 'hidden',
+  },
+  info_bar: {
+    display: 'flex',
+    justifyContent: 'space-between',
+    alignItems: 'center',
+    padding: '6px 10px',
     backgroundColor: '#16213e',
-    padding: 12,
-    overflowY: 'auto',
-    borderLeft: '2px solid #333',
+    flexShrink: 0,
   },
-  play_log_title: {
-    color: '#fff',
-    fontSize: 14,
+  level_badge: {
+    padding: '4px 8px',
+    backgroundColor: '#ffc107',
+    color: '#000',
+    borderRadius: 6,
     fontWeight: 'bold',
-    marginBottom: 12,
+    fontSize: 12,
+  },
+  team_scores: {
+    color: '#fff',
+    fontSize: 11,
+  },
+  opponent_bar: {
+    display: 'flex',
+    justifyContent: 'center',
+    gap: 8,
+    padding: '8px 4px',
+    backgroundColor: 'rgba(0,0,0,0.2)',
+    flexShrink: 0,
+  },
+  opponent_chip: {
+    display: 'flex',
+    alignItems: 'center',
+    padding: '6px 10px',
+    borderRadius: 16,
+    border: '2px solid transparent',
+  },
+  table_area: {
+    flex: 1,
+    display: 'flex',
+    justifyContent: 'center',
+    alignItems: 'center',
+    backgroundColor: 'rgba(0,0,0,0.2)',
+    borderRadius: 8,
+    margin: 8,
+    minHeight: 100,
+  },
+  my_area: {
+    display: 'flex',
+    flexDirection: 'column',
+    alignItems: 'center',
+    paddingTop: 4,
     paddingBottom: 8,
-    borderBottom: '1px solid #333',
+    borderTop: '2px solid #333',
+    flexShrink: 0,
   },
-  play_log_entry: {
-    fontSize: 12,
+  actions: {
+    display: 'flex',
+    gap: 16,
+    marginTop: 4,
+  },
+  action_button: {
+    padding: '10px 28px',
+    fontSize: 14,
+    border: 'none',
+    borderRadius: 8,
     color: '#fff',
-    padding: '6px 0',
-    borderBottom: '1px solid rgba(255,255,255,0.1)',
+    cursor: 'pointer',
+  },
+  turn_indicator: {
+    marginTop: 6,
+    padding: '4px 10px',
+    backgroundColor: '#ffc107',
+    color: '#000',
+    borderRadius: 6,
+    fontWeight: 'bold',
+    fontSize: 11,
   },
 }
diff --git a/client/src/components/Hand.tsx b/client/src/components/Hand.tsx
index 1a49a86..b0e1bd6 100644
--- a/client/src/components/Hand.tsx
+++ b/client/src/components/Hand.tsx
@@ -1,6 +1,7 @@
 import { motion, AnimatePresence } from 'framer-motion'
 import { Card as Card_Type, Rank } from '../game/types'
 import { Card } from './Card'
+import { use_is_mobile } from '../hooks/use_is_mobile'
 
 interface Hand_Props {
   cards: Card_Type[]
@@ -10,8 +11,62 @@ interface Hand_Props {
 }
 
 export function Hand({ cards, level, selected_ids, on_card_click }: Hand_Props) {
-  const card_width = 70
-  const overlap = 35
+  const is_mobile = use_is_mobile()
+  const card_width = is_mobile ? 56 : 70
+  const card_height = is_mobile ? 80 : 100
+  const overlap = is_mobile ? 28 : 35
+
+  if (is_mobile) {
+    return (
+      <div
+        style={{
+          width: '100%',
+          overflowX: 'auto',
+          overflowY: 'hidden',
+          WebkitOverflowScrolling: 'touch',
+          padding: '8px 0',
+          minHeight: card_height + 30,
+        }}
+      >
+        <div
+          style={{
+            display: 'flex',
+            position: 'relative',
+            width: cards.length > 0 ? card_width + (cards.length - 1) * overlap + 16 : 0,
+            height: card_height + 20,
+            margin: '0 auto',
+            paddingLeft: 8,
+            paddingRight: 8,
+          }}
+        >
+          <AnimatePresence>
+            {cards.map((card, index) => (
+              <motion.div
+                key={card.Id}
+                initial={{ opacity: 0, y: 30, scale: 0.8 }}
+                animate={{ opacity: 1, y: 0, scale: 1 }}
+                exit={{ opacity: 0, y: -30, scale: 0.8 }}
+                transition={{ delay: index * 0.015 }}
+                style={{
+                  position: 'absolute',
+                  left: index * overlap,
+                  zIndex: index,
+                }}
+              >
+                <Card
+                  card={card}
+                  level={level}
+                  selected={selected_ids.has(card.Id)}
+                  on_click={() => on_card_click(card.Id)}
+                  size={is_mobile ? 'small' : 'normal'}
+                />
+              </motion.div>
+            ))}
+          </AnimatePresence>
+        </div>
+      </div>
+    )
+  }
 
   return (
     <div
@@ -27,7 +82,7 @@ export function Hand({ cards, level, selected_ids, on_card_click }: Hand_Props)
           display: 'flex',
           position: 'relative',
           width: cards.length > 0 ? card_width + (cards.length - 1) * overlap : 0,
-          height: 100,
+          height: card_height,
         }}
       >
         <AnimatePresence>
diff --git a/client/src/components/Table.tsx b/client/src/components/Table.tsx
index fb73d02..f1165e5 100644
--- a/client/src/components/Table.tsx
+++ b/client/src/components/Table.tsx
@@ -1,5 +1,6 @@
 import { Card as Card_Type, Rank } from '../game/types'
 import { Card } from './Card'
+import { use_is_mobile } from '../hooks/use_is_mobile'
 
 interface Table_Props {
   cards: Card_Type[]
@@ -9,8 +10,9 @@ interface Table_Props {
 }
 
 export function Table({ cards, level, combo_type, last_play_seat }: Table_Props) {
-  const card_width = 70
-  const overlap = 40
+  const is_mobile = use_is_mobile()
+  const card_width = is_mobile ? 56 : 70
+  const overlap = is_mobile ? 32 : 40
   const show_highlight = last_play_seat !== null && last_play_seat !== undefined && cards.length > 0
 
   return (
@@ -20,8 +22,8 @@ export function Table({ cards, level, combo_type, last_play_seat }: Table_Props)
         flexDirection: 'column',
         alignItems: 'center',
         justifyContent: 'center',
-        minHeight: 180,
-        padding: 20,
+        minHeight: is_mobile ? 100 : 180,
+        padding: is_mobile ? 8 : 20,
         borderRadius: 12,
         transition: 'background-color 0.2s ease, box-shadow 0.2s ease',
         backgroundColor: show_highlight ? 'rgba(76, 175, 80, 0.15)' : 'transparent',
@@ -33,7 +35,7 @@ export function Table({ cards, level, combo_type, last_play_seat }: Table_Props)
           display: 'flex',
           position: 'relative',
           width: cards.length > 0 ? card_width + (cards.length - 1) * overlap : 100,
-          height: 100,
+          height: is_mobile ? 80 : 100,
           justifyContent: 'center',
         }}
       >
@@ -52,6 +54,7 @@ export function Table({ cards, level, combo_type, last_play_seat }: Table_Props)
                 level={level}
                 selected={false}
                 on_click={() => {}}
+                size={is_mobile ? 'small' : 'normal'}
               />
             </div>
           ))
@@ -62,7 +65,7 @@ export function Table({ cards, level, combo_type, last_play_seat }: Table_Props)
               alignItems: 'center',
               justifyContent: 'center',
               color: '#666',
-              fontSize: 14,
+              fontSize: is_mobile ? 12 : 14,
               opacity: 0.5,
             }}
           >
@@ -73,12 +76,12 @@ export function Table({ cards, level, combo_type, last_play_seat }: Table_Props)
       {combo_type && (
         <div
           style={{
-            marginTop: 12,
-            padding: '4px 12px',
+            marginTop: is_mobile ? 6 : 12,
+            padding: is_mobile ? '3px 8px' : '4px 12px',
             backgroundColor: '#333',
             color: '#fff',
             borderRadius: 4,
-            fontSize: 12,
+            fontSize: is_mobile ? 10 : 12,
             textTransform: 'uppercase',
           }}
         >
diff --git a/client/src/hooks/use_is_mobile.ts b/client/src/hooks/use_is_mobile.ts
new file mode 100644
index 0000000..439b8e6
--- /dev/null
+++ b/client/src/hooks/use_is_mobile.ts
@@ -0,0 +1,20 @@
+import { useState, useEffect } from 'react'
+
+const MOBILE_BREAKPOINT = 768
+
+export function use_is_mobile(): boolean {
+  const [is_mobile, set_is_mobile] = useState(() =>
+    typeof window !== 'undefined' ? window.innerWidth < MOBILE_BREAKPOINT : false
+  )
+
+  useEffect(() => {
+    const handle_resize = () => {
+      set_is_mobile(window.innerWidth < MOBILE_BREAKPOINT)
+    }
+
+    window.addEventListener('resize', handle_resize)
+    return () => window.removeEventListener('resize', handle_resize)
+  }, [])
+
+  return is_mobile
+}
diff --git a/client/src/main.tsx b/client/src/main.tsx
index d18d7ec..3593812 100644
--- a/client/src/main.tsx
+++ b/client/src/main.tsx
@@ -9,13 +9,42 @@ style.textContent = `
     padding: 0;
     box-sizing: border-box;
   }
+  html, body, #root {
+    height: 100%;
+    overflow: hidden;
+  }
   body {
     font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
     background-color: #1a1a2e;
+    -webkit-tap-highlight-color: transparent;
+    -webkit-touch-callout: none;
+    touch-action: manipulation;
+  }
+  ::-webkit-scrollbar {
+    width: 4px;
+    height: 4px;
+  }
+  ::-webkit-scrollbar-track {
+    background: transparent;
+  }
+  ::-webkit-scrollbar-thumb {
+    background: rgba(255,255,255,0.2);
+    border-radius: 2px;
+  }
+  button {
+    -webkit-tap-highlight-color: transparent;
   }
 `
 document.head.appendChild(style)
 
+const viewport = document.querySelector('meta[name="viewport"]')
+if (!viewport) {
+  const meta = document.createElement('meta')
+  meta.name = 'viewport'
+  meta.content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover'
+  document.head.appendChild(meta)
+}
+
 ReactDOM.createRoot(document.getElementById('root')!).render(
   <React.StrictMode>
     <App />
diff --git a/justfile b/justfile
index 834773a..999c87b 100644
--- a/justfile
+++ b/justfile
@@ -2,10 +2,14 @@ default:
     @just --list
 
 dev:
-    just --parallel server client
+    #!/usr/bin/env bash
+    trap 'kill 0' EXIT
+    just server &
+    just client &
+    wait
 
 server:
-    cd server && air
+    cd server && PORT=8081 air
 
 client:
     cd client && npm run dev
diff --git a/mprocs.yaml b/mprocs.yaml
index fe1b418..a4c820f 100644
--- a/mprocs.yaml
+++ b/mprocs.yaml
@@ -2,6 +2,8 @@ procs:
   server:
     cmd: ["air"]
     cwd: "server"
+    env:
+      PORT: "8081"
   client:
     cmd: ["npm", "run", "dev"]
     cwd: "client"
diff --git a/server/main.go b/server/main.go
index e9a559c..d7560e1 100644
--- a/server/main.go
+++ b/server/main.go
@@ -4,9 +4,15 @@ import (
 	"guandanbtw/room"
 	"log"
 	"net/http"
+	"os"
 )
 
 func main() {
+	port := os.Getenv("PORT")
+	if port == "" {
+		port = "8080"
+	}
+
 	hub := room.New_Hub()
 	go hub.Run()
 
@@ -14,6 +20,6 @@ func main() {
 
 	http.Handle("/", http.FileServer(http.Dir("../client/dist")))
 
-	log.Println("server starting on :8080")
-	log.Fatal(http.ListenAndServe(":8080", nil))
+	log.Println("server starting on :" + port)
+	log.Fatal(http.ListenAndServe(":"+port, nil))
 }