guandan.dev

guandan.dev

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

Updated size for mobile.

Commit
b4756ed0955b3d13e01b156f232895c549bbb742
Parent
f496a64
Author
tonybanters <tonybanters@gmail.com>
Date
2026-03-23 12:33:32

Diff

diff --git a/client/src/App.tsx b/client/src/App.tsx
index 78356c3..75592d8 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -64,6 +64,8 @@ export default function App() {
   const [error, set_error] = useState<string | null>(null)
   const [players_map, set_players_map] = useState<Record<number, string>>({})
   const [last_play_seat, set_last_play_seat] = useState<number | null>(null)
+  const [player_plays, set_player_plays] = useState<Record<number, { cards: Card[], is_pass: boolean }>>({})
+  const [leading_seat, set_leading_seat] = useState<number | null>(null)
 
   useEffect(() => {
     const unsub_room_state = on('room_state', (msg: Message) => {
@@ -92,12 +94,19 @@ export default function App() {
       set_combo_type('')
       set_selected_ids(new Set())
       set_player_card_counts([27, 27, 27, 27])
+      set_player_plays({})
+      set_leading_seat(null)
     })
 
     const unsub_turn = on('turn', (msg: Message) => {
       const payload = msg.payload as Turn_Payload
       set_current_turn(payload.seat)
       set_can_pass(payload.can_pass)
+      // When can_pass is false, this player has control (new trick starting)
+      if (!payload.can_pass) {
+        set_player_plays({})
+        set_leading_seat(null)
+      }
     })
 
     const unsub_play_made = on('play_made', (msg: Message) => {
@@ -106,9 +115,16 @@ export default function App() {
       set_last_play_seat(payload.seat)
       setTimeout(() => set_last_play_seat(null), 800)
 
+      // Track this player's play
+      set_player_plays(prev => ({
+        ...prev,
+        [payload.seat]: { cards: payload.cards, is_pass: payload.is_pass }
+      }))
+
       if (!payload.is_pass) {
         set_table_cards(payload.cards)
         set_combo_type(payload.combo_type)
+        set_leading_seat(payload.seat)
         set_player_card_counts((prev) => {
           const next = [...prev]
           next[payload.seat] -= payload.cards.length
@@ -251,6 +267,8 @@ export default function App() {
         team_levels={team_levels}
         players_map={players_map}
         last_play_seat={last_play_seat}
+        player_plays={player_plays}
+        leading_seat={leading_seat}
       />
       {error && <div style={styles.error}>{error}</div>}
     </>
diff --git a/client/src/components/Card.tsx b/client/src/components/Card.tsx
index afa4ce4..7d407a4 100644
--- a/client/src/components/Card.tsx
+++ b/client/src/components/Card.tsx
@@ -13,8 +13,8 @@ interface Card_Props {
 }
 
 const SIZE_CONFIG = {
-    small: { width: 36, height: 50, rank_font: 13, suit_font: 11 },
-    normal: { width: 56, height: 78, rank_font: 20, suit_font: 18 },
+    small: { width: 48, height: 67, rank_font: 15, suit_font: 13 },
+    normal: { width: 60, height: 84, rank_font: 22, suit_font: 20 },
 }
 
 export function Card({ card, level, selected, on_click, size = 'normal' }: Card_Props) {
diff --git a/client/src/components/Game.tsx b/client/src/components/Game.tsx
index f1ce23f..1223309 100644
--- a/client/src/components/Game.tsx
+++ b/client/src/components/Game.tsx
@@ -1,10 +1,14 @@
 import { motion } from 'framer-motion'
 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'
+import { Card } from './Card'
 import { use_is_mobile } from '../hooks/use_is_mobile'
 
+interface Player_Play {
+  cards: Card_Type[]
+  is_pass: boolean
+}
+
 interface Game_Props {
   hand: Card_Type[]
   level: Rank
@@ -22,6 +26,8 @@ interface Game_Props {
   team_levels: [number, number]
   players_map: Record<number, string>
   last_play_seat: number | null
+  player_plays: Record<number, Player_Play>
+  leading_seat: number | null
 }
 
 export function Game({
@@ -32,7 +38,6 @@ export function Game({
   on_select_same_rank,
   on_play,
   on_pass,
-  table_cards,
   combo_type,
   current_turn,
   my_seat,
@@ -40,254 +45,455 @@ export function Game({
   player_card_counts,
   team_levels,
   players_map,
-  last_play_seat,
+  player_plays,
+  leading_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>
+  return (
+    <div style={is_mobile ? mobile_styles.container : styles.container}>
+      {/* Info bar */}
+      <div style={is_mobile ? mobile_styles.info_bar : styles.info_bar}>
+        <div style={is_mobile ? mobile_styles.level_badge : styles.level_badge}>
+          Lvl: {get_rank_symbol(level)}
+        </div>
+        <div style={is_mobile ? mobile_styles.team_scores : styles.team_scores}>
+          <span style={{ color: '#64b5f6' }}>T1: {get_rank_symbol(team_levels[0] as Rank)}</span>
+          <span style={{ marginLeft: is_mobile ? 8 : 12, color: '#f48fb1' }}>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}
+      {/* Game area - relative container with absolute positioned elements */}
+      <div style={is_mobile ? mobile_styles.game_area : styles.game_area}>
+        {/* Top player badge + cards */}
+        <Player_Badge
+          seat={relative_positions.top}
+          name={players_map[relative_positions.top]}
+          count={player_card_counts[relative_positions.top]}
+          is_turn={current_turn === relative_positions.top}
+          is_leading={leading_seat === relative_positions.top}
+          position="top"
+          is_mobile={is_mobile}
+        />
+        <Played_Cards
+          play={player_plays[relative_positions.top]}
+          is_leading={leading_seat === relative_positions.top}
+          combo_type={leading_seat === relative_positions.top ? combo_type : ''}
+          level={level}
+          position="top"
+          is_mobile={is_mobile}
         />
 
-        <div style={mobile_styles.table_area}>
-          <Table cards={table_cards} level={level} combo_type={combo_type} last_play_seat={last_play_seat} />
-        </div>
+        {/* Left player badge + cards */}
+        <Player_Badge
+          seat={relative_positions.left}
+          name={players_map[relative_positions.left]}
+          count={player_card_counts[relative_positions.left]}
+          is_turn={current_turn === relative_positions.left}
+          is_leading={leading_seat === relative_positions.left}
+          position="left"
+          is_mobile={is_mobile}
+        />
+        <Played_Cards
+          play={player_plays[relative_positions.left]}
+          is_leading={leading_seat === relative_positions.left}
+          combo_type={leading_seat === relative_positions.left ? combo_type : ''}
+          level={level}
+          position="left"
+          is_mobile={is_mobile}
+        />
 
-        <div style={mobile_styles.my_area}>
-          <Hand
-            cards={hand}
-            level={level}
-            selected_ids={selected_ids}
-            on_card_click={on_card_click}
-            on_toggle_selection={on_card_click}
-            on_select_same_rank={on_select_same_rank}
-            on_play={on_play}
-            on_pass={on_pass}
-            is_my_turn={is_my_turn}
-            can_pass={can_pass}
-          />
-
-          {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>
+        {/* Right player badge + cards */}
+        <Player_Badge
+          seat={relative_positions.right}
+          name={players_map[relative_positions.right]}
+          count={player_card_counts[relative_positions.right]}
+          is_turn={current_turn === relative_positions.right}
+          is_leading={leading_seat === relative_positions.right}
+          position="right"
+          is_mobile={is_mobile}
+        />
+        <Played_Cards
+          play={player_plays[relative_positions.right]}
+          is_leading={leading_seat === relative_positions.right}
+          combo_type={leading_seat === relative_positions.right ? combo_type : ''}
+          level={level}
+          position="right"
+          is_mobile={is_mobile}
+        />
+
+        {/* My played cards - at bottom center of game area */}
+        <My_Played_Cards
+          play={player_plays[my_seat]}
+          is_leading={leading_seat === my_seat}
+          combo_type={leading_seat === my_seat ? combo_type : ''}
+          level={level}
+          is_mobile={is_mobile}
+        />
       </div>
-    )
+
+      {/* My area at bottom */}
+      <div style={is_mobile ? mobile_styles.my_area : styles.my_area}>
+        <Hand
+          cards={hand}
+          level={level}
+          selected_ids={selected_ids}
+          on_card_click={on_card_click}
+          on_toggle_selection={on_card_click}
+          on_select_same_rank={on_select_same_rank}
+          on_play={on_play}
+          on_pass={on_pass}
+          is_my_turn={is_my_turn}
+          can_pass={can_pass}
+        />
+
+        {/* Turn indicator - desktop only (mobile shows in Hand button row) */}
+        {!is_mobile && is_my_turn && hand.length > 0 && (
+          <motion.div
+            initial={{ opacity: 0 }}
+            animate={{ opacity: 1 }}
+            style={styles.turn_indicator}
+          >
+            Your turn!
+          </motion.div>
+        )}
+        {hand.length === 0 && (
+          <motion.div
+            initial={{ opacity: 0 }}
+            animate={{ opacity: 1 }}
+            style={{ ...(is_mobile ? mobile_styles.turn_indicator : styles.turn_indicator), backgroundColor: '#28a745' }}
+          >
+            You finished!
+          </motion.div>
+        )}
+      </div>
+    </div>
+  )
+}
+
+interface Player_Badge_Props {
+  seat: number
+  name?: string
+  count: number
+  is_turn: boolean
+  is_leading: boolean
+  position: 'top' | 'left' | 'right'
+  is_mobile: boolean
+}
+
+function Player_Badge({ seat, name, count, is_turn, is_leading, position, is_mobile }: Player_Badge_Props) {
+  const get_position_style = (): React.CSSProperties => {
+    const base: React.CSSProperties = {
+      position: 'absolute',
+      zIndex: 10,
+    }
+
+    if (position === 'top') {
+      return {
+        ...base,
+        top: is_mobile ? 2 : 8,
+        left: '50%',
+        transform: 'translateX(-50%)',
+      }
+    }
+    if (position === 'left') {
+      return {
+        ...base,
+        left: is_mobile ? 2 : 8,
+        top: '40%',
+        transform: 'translateY(-50%)',
+      }
+    }
+    // right
+    return {
+      ...base,
+      right: is_mobile ? 2 : 8,
+      top: '40%',
+      transform: 'translateY(-50%)',
+    }
   }
 
-  return (
-    <div style={styles.container}>
-      <div style={styles.info_bar}>
-        <div style={styles.level_badge}>
-          Level: {get_rank_symbol(level)}
+  const get_border_color = () => {
+    if (is_leading) return '#4caf50'
+    if (is_turn) return '#ffc107'
+    return 'rgba(255,255,255,0.2)'
+  }
+
+  const get_bg_color = () => {
+    if (is_leading) return 'rgba(76, 175, 80, 0.3)'
+    if (is_turn) return 'rgba(255, 193, 7, 0.3)'
+    return 'rgba(0, 0, 0, 0.6)'
+  }
+
+  // Mobile: minimal floating badge with no border/background
+  if (is_mobile) {
+    return (
+      <div
+        style={{
+          ...get_position_style(),
+          display: 'flex',
+          flexDirection: 'column',
+          alignItems: 'center',
+          textShadow: '0 1px 3px rgba(0,0,0,0.8)',
+        }}
+      >
+        <div style={{
+          color: seat % 2 === 0 ? '#64b5f6' : '#f48fb1',
+          fontSize: 9,
+          fontWeight: 'bold',
+          maxWidth: 50,
+          overflow: 'hidden',
+          textOverflow: 'ellipsis',
+          whiteSpace: 'nowrap',
+        }}>
+          {name || `P${seat + 1}`}
         </div>
-        <div style={styles.team_scores}>
-          <span style={{ color: '#2196f3' }}>Team 1: {get_rank_symbol(team_levels[0] as Rank)}</span>
-          <span style={{ marginLeft: 16, color: '#e91e63' }}>Team 2: {get_rank_symbol(team_levels[1] as Rank)}</span>
+        <div style={{
+          color: is_turn ? '#ffc107' : is_leading ? '#4caf50' : '#fff',
+          fontSize: 12,
+          fontWeight: 'bold',
+          lineHeight: 1,
+        }}>
+          {count}
         </div>
       </div>
+    )
+  }
 
-      <div style={styles.main_layout}>
-        <div style={styles.game_area}>
-          <div style={styles.opponent_top}>
-            <Opponent_Hand
-              count={player_card_counts[relative_positions.top]}
-              is_turn={current_turn === relative_positions.top}
-              just_played={last_play_seat === relative_positions.top}
-              seat={relative_positions.top}
-              name={players_map[relative_positions.top]}
-            />
-          </div>
-
-          <div style={styles.middle_row}>
-            <div style={styles.opponent_side}>
-              <Opponent_Hand
-                count={player_card_counts[relative_positions.left]}
-                is_turn={current_turn === relative_positions.left}
-                just_played={last_play_seat === relative_positions.left}
-                seat={relative_positions.left}
-                name={players_map[relative_positions.left]}
-              />
-            </div>
-
-            <div style={styles.table_area}>
-              <Table cards={table_cards} level={level} combo_type={combo_type} last_play_seat={last_play_seat} />
-            </div>
-
-            <div style={styles.opponent_side}>
-              <Opponent_Hand
-                count={player_card_counts[relative_positions.right]}
-                is_turn={current_turn === relative_positions.right}
-                just_played={last_play_seat === relative_positions.right}
-                seat={relative_positions.right}
-                name={players_map[relative_positions.right]}
-              />
-            </div>
-          </div>
-
-          <div style={styles.my_area}>
-            <Hand
-              cards={hand}
-              level={level}
-              selected_ids={selected_ids}
-              on_card_click={on_card_click}
-              on_toggle_selection={on_card_click}
-              on_select_same_rank={on_select_same_rank}
-              on_play={on_play}
-              on_pass={on_pass}
-              is_my_turn={is_my_turn}
-              can_pass={can_pass}
-            />
-
-          {is_my_turn && hand.length > 0 && (
-            <motion.div
-              initial={{ opacity: 0 }}
-              animate={{ opacity: 1 }}
-              style={styles.turn_indicator}
-            >
-              Your turn!
-            </motion.div>
-          )}
-          {hand.length === 0 && (
-            <motion.div
-              initial={{ opacity: 0 }}
-              animate={{ opacity: 1 }}
-              style={{ ...styles.turn_indicator, backgroundColor: '#28a745' }}
-            >
-              You finished!
-            </motion.div>
-          )}
-          </div>
-        </div>
+  // Desktop: boxed badge
+  return (
+    <div
+      style={{
+        ...get_position_style(),
+        display: 'flex',
+        flexDirection: 'column',
+        alignItems: 'center',
+        padding: '6px 12px',
+        borderRadius: 10,
+        border: `2px solid ${get_border_color()}`,
+        backgroundColor: get_bg_color(),
+        minWidth: 50,
+      }}
+    >
+      <div style={{
+        color: seat % 2 === 0 ? '#64b5f6' : '#f48fb1',
+        fontSize: 12,
+        fontWeight: 'bold',
+        maxWidth: 70,
+        overflow: 'hidden',
+        textOverflow: 'ellipsis',
+        whiteSpace: 'nowrap',
+      }}>
+        {name || `P${seat + 1}`}
+      </div>
+      <div style={{
+        color: '#fff',
+        fontSize: 18,
+        fontWeight: 'bold',
+        lineHeight: 1.2,
+      }}>
+        {count}
       </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>
+interface Played_Cards_Props {
+  play?: Player_Play
+  is_leading: boolean
+  combo_type: string
+  level: Rank
+  position: 'top' | 'left' | 'right'
+  is_mobile: boolean
 }
 
-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]
+function Played_Cards({ play, is_leading, combo_type, level, position, is_mobile }: Played_Cards_Props) {
+  if (!play) return null
+
+  const get_position_style = (): React.CSSProperties => {
+    const base: React.CSSProperties = {
+      position: 'absolute',
+      zIndex: 5,
+      display: 'flex',
+      flexDirection: 'row',
+      alignItems: 'center',
+    }
+
+    // Cards appear toward the center from the badge
+    if (position === 'top') {
+      return {
+        ...base,
+        top: is_mobile ? 28 : 70,
+        left: '50%',
+        transform: 'translateX(-50%)',
+      }
+    }
+    if (position === 'left') {
+      return {
+        ...base,
+        left: is_mobile ? 45 : 80,
+        top: '40%',
+        transform: 'translateY(-50%)',
+      }
+    }
+    // right
+    return {
+      ...base,
+      right: is_mobile ? 45 : 80,
+      top: '40%',
+      transform: 'translateY(-50%)',
+    }
+  }
+
+  if (play.is_pass) {
+    return (
+      <div style={get_position_style()}>
+        <div style={{
+          color: '#aaa',
+          fontSize: is_mobile ? 11 : 16,
+          fontStyle: 'italic',
+          backgroundColor: 'rgba(0,0,0,0.4)',
+          padding: is_mobile ? '2px 8px' : '4px 12px',
+          borderRadius: 4,
+        }}>
+          Pass
+        </div>
+      </div>
+    )
+  }
+
+  // Less overlap for played cards so all cards are visible
+  const card_overlap = is_mobile ? -20 : -28
 
   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',
-            }}
+    <div style={get_position_style()}>
+      <div style={{
+        display: 'flex',
+        flexDirection: 'row',
+        // No container border on mobile, only on desktop for leading
+        padding: (!is_mobile && is_leading) ? 4 : 0,
+        borderRadius: 6,
+        border: (!is_mobile && is_leading) ? '2px solid #4caf50' : 'none',
+        backgroundColor: (!is_mobile && is_leading) ? 'rgba(76, 175, 80, 0.15)' : 'transparent',
+      }}>
+        {play.cards.map((card, idx) => (
+          <motion.div
+            key={card.Id}
+            initial={{ opacity: 0, scale: 0.5, y: position === 'top' ? -20 : 0, x: position === 'left' ? -20 : position === 'right' ? 20 : 0 }}
+            animate={{ opacity: 1, scale: 1, y: 0, x: 0 }}
+            transition={{ delay: idx * 0.03 }}
+            style={{ marginLeft: idx > 0 ? card_overlap : 0 }}
           >
-            <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>
-        )
-      })}
+            <Card
+              card={card}
+              level={level}
+              selected={false}
+              on_click={() => {}}
+              size="small"
+            />
+          </motion.div>
+        ))}
+      </div>
+      {is_leading && combo_type && (
+        <div style={{
+          marginLeft: is_mobile ? 4 : 8,
+          padding: is_mobile ? '2px 4px' : '3px 8px',
+          backgroundColor: 'rgba(0,0,0,0.7)',
+          color: '#fff',
+          fontSize: is_mobile ? 8 : 12,
+          borderRadius: 4,
+        }}>
+          {combo_type}
+        </div>
+      )}
     </div>
   )
 }
 
-interface Opponent_Hand_Props {
-  count: number
-  is_turn: boolean
-  just_played?: boolean
-  seat: number
-  name?: string
+interface My_Played_Cards_Props {
+  play?: Player_Play
+  is_leading: boolean
+  combo_type: string
+  level: Rank
+  is_mobile: boolean
 }
 
-function Opponent_Hand({ count, is_turn, just_played, seat, name }: Opponent_Hand_Props) {
-  const get_highlight_style = () => {
-    if (just_played) return { backgroundColor: 'rgba(76, 175, 80, 0.3)', border: '2px solid #4caf50' }
-    if (is_turn) return { backgroundColor: 'rgba(255,193,7,0.2)', border: '2px solid #ffc107' }
-    return { backgroundColor: 'transparent', border: '2px solid transparent' }
+function My_Played_Cards({ play, is_leading, combo_type, level, is_mobile }: My_Played_Cards_Props) {
+  if (!play) return null
+
+  const base_style: React.CSSProperties = {
+    position: 'absolute',
+    zIndex: 5,
+    display: 'flex',
+    flexDirection: 'row',
+    alignItems: 'center',
+    bottom: is_mobile ? 8 : 16,
+    left: '50%',
+    transform: 'translateX(-50%)',
   }
 
-  return (
-    <div
-      style={{
-        display: 'flex',
-        flexDirection: 'column',
-        alignItems: 'center',
-        gap: 4,
-        padding: 8,
-        borderRadius: 8,
-        transition: 'all 0.2s ease',
-        ...get_highlight_style(),
-      }}
-    >
-      <div style={{ position: 'relative' }}>
-        <Card_Back size="small" />
-        <div
-          style={{
-            position: 'absolute',
-            top: '50%',
-            left: '50%',
-            transform: 'translate(-50%, -50%)',
-            backgroundColor: 'rgba(0,0,0,0.7)',
-            color: '#fff',
-            fontWeight: 'bold',
-            fontSize: 16,
-            padding: '4px 8px',
-            borderRadius: 4,
-            minWidth: 24,
-            textAlign: 'center',
-          }}
-        >
-          {count}
+  if (play.is_pass) {
+    return (
+      <div style={base_style}>
+        <div style={{
+          color: '#aaa',
+          fontSize: is_mobile ? 11 : 16,
+          fontStyle: 'italic',
+          backgroundColor: 'rgba(0,0,0,0.4)',
+          padding: is_mobile ? '2px 8px' : '4px 12px',
+          borderRadius: 4,
+        }}>
+          Pass
         </div>
       </div>
-      <div style={{ color: '#fff', fontSize: 11 }}>
-        {name || `P${seat + 1}`}
+    )
+  }
+
+  // Less overlap for played cards so all cards are visible
+  const card_overlap = is_mobile ? -20 : -28
+
+  return (
+    <div style={base_style}>
+      <div style={{
+        display: 'flex',
+        flexDirection: 'row',
+        padding: is_leading ? (is_mobile ? 2 : 4) : 0,
+        borderRadius: 6,
+        border: is_leading ? '2px solid #4caf50' : 'none',
+        backgroundColor: is_leading ? 'rgba(76, 175, 80, 0.15)' : 'transparent',
+      }}>
+        {play.cards.map((card, idx) => (
+          <motion.div
+            key={card.Id}
+            initial={{ opacity: 0, scale: 0.5, y: 20 }}
+            animate={{ opacity: 1, scale: 1, y: 0 }}
+            transition={{ delay: idx * 0.03 }}
+            style={{ marginLeft: idx > 0 ? card_overlap : 0 }}
+          >
+            <Card
+              card={card}
+              level={level}
+              selected={false}
+              on_click={() => {}}
+              size="small"
+            />
+          </motion.div>
+        ))}
       </div>
+      {is_leading && combo_type && (
+        <div style={{
+          marginLeft: is_mobile ? 4 : 8,
+          padding: is_mobile ? '2px 4px' : '3px 8px',
+          backgroundColor: 'rgba(0,0,0,0.7)',
+          color: '#fff',
+          fontSize: is_mobile ? 8 : 12,
+          borderRadius: 4,
+        }}>
+          {combo_type}
+        </div>
+      )}
     </div>
   )
 }
@@ -312,17 +518,17 @@ const styles: Record<string, React.CSSProperties> = {
     display: 'flex',
     justifyContent: 'space-between',
     alignItems: 'center',
-    padding: '8px 12px',
+    padding: '4px 12px',
     backgroundColor: '#16213e',
     flexShrink: 0,
   },
   level_badge: {
-    padding: '6px 12px',
+    padding: '3px 8px',
     backgroundColor: '#ffc107',
     color: '#000',
-    borderRadius: 8,
+    borderRadius: 6,
     fontWeight: 'bold',
-    fontSize: 14,
+    fontSize: 12,
   },
   team_scores: {
     color: '#fff',
@@ -330,61 +536,28 @@ const styles: Record<string, React.CSSProperties> = {
   },
   game_area: {
     flex: 1,
-    display: 'flex',
-    flexDirection: 'column',
-    padding: 8,
+    position: 'relative',
     minHeight: 0,
-  },
-  opponent_top: {
-    display: 'flex',
-    justifyContent: 'center',
-    marginBottom: 8,
-    flexShrink: 0,
-  },
-  middle_row: {
-    flex: 1,
-    display: 'flex',
-    alignItems: 'center',
-    minHeight: 0,
-  },
-  opponent_side: {
-    width: 80,
-    display: 'flex',
-    justifyContent: 'center',
-    flexShrink: 0,
-  },
-  table_area: {
-    flex: 1,
-    display: 'flex',
-    justifyContent: 'center',
-    alignItems: 'center',
-    backgroundColor: 'rgba(0,0,0,0.2)',
-    borderRadius: 12,
-    margin: '0 8px',
-    minHeight: 120,
+    overflow: 'hidden',
   },
   my_area: {
     display: 'flex',
     flexDirection: 'column',
     alignItems: 'center',
-    paddingTop: 8,
-    borderTop: '2px solid #333',
+    paddingTop: 4,
+    paddingBottom: 8,
+    borderTop: '1px solid rgba(255,255,255,0.1)',
     flexShrink: 0,
+    backgroundColor: 'rgba(0,0,0,0.2)',
   },
   turn_indicator: {
-    marginTop: 8,
-    padding: '6px 12px',
+    marginTop: 4,
+    padding: '4px 10px',
     backgroundColor: '#ffc107',
     color: '#000',
-    borderRadius: 8,
+    borderRadius: 6,
     fontWeight: 'bold',
-    fontSize: 12,
-  },
-  main_layout: {
-    display: 'flex',
-    flex: 1,
-    overflow: 'hidden',
-    minHeight: 0,
+    fontSize: 11,
   },
 }
 
@@ -400,63 +573,43 @@ const mobile_styles: Record<string, React.CSSProperties> = {
     display: 'flex',
     justifyContent: 'space-between',
     alignItems: 'center',
-    padding: '6px 10px',
+    padding: '3px 8px',
     backgroundColor: '#16213e',
     flexShrink: 0,
   },
   level_badge: {
-    padding: '4px 8px',
+    padding: '2px 6px',
     backgroundColor: '#ffc107',
     color: '#000',
     borderRadius: 6,
     fontWeight: 'bold',
-    fontSize: 12,
+    fontSize: 10,
   },
   team_scores: {
     color: '#fff',
-    fontSize: 11,
+    fontSize: 10,
   },
-  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: {
+  game_area: {
     flex: 1,
-    display: 'flex',
-    justifyContent: 'center',
-    alignItems: 'center',
-    backgroundColor: 'rgba(0,0,0,0.2)',
-    borderRadius: 8,
-    margin: 8,
-    minHeight: 100,
+    position: 'relative',
+    minHeight: 0,
+    overflow: 'hidden',
   },
   my_area: {
     display: 'flex',
     flexDirection: 'column',
     alignItems: 'center',
-    paddingTop: 4,
-    paddingBottom: 8,
-    borderTop: '2px solid #333',
+    paddingTop: 2,
+    paddingBottom: 4,
     flexShrink: 0,
   },
   turn_indicator: {
-    marginTop: 6,
-    padding: '4px 10px',
-    backgroundColor: '#ffc107',
-    color: '#000',
-    borderRadius: 6,
+    marginTop: 2,
+    padding: '2px 6px',
+    backgroundColor: '#28a745',
+    color: '#fff',
+    borderRadius: 4,
     fontWeight: 'bold',
-    fontSize: 11,
+    fontSize: 9,
   },
 }
diff --git a/client/src/components/Hand.tsx b/client/src/components/Hand.tsx
index 8bc6262..fbef81e 100644
--- a/client/src/components/Hand.tsx
+++ b/client/src/components/Hand.tsx
@@ -107,10 +107,10 @@ export function Hand({ cards, level, selected_ids, on_card_click, on_toggle_sele
   // Card lookup
   const card_by_id = new Map(cards.map(c => [c.Id, c]))
 
-  const card_width = is_mobile ? 36 : 56
-  const card_height = is_mobile ? 50 : 78
-  const v_overlap = is_mobile ? 18 : 30
-  const h_gap = is_mobile ? 2 : 3
+  const card_width = is_mobile ? 48 : 60
+  const card_height = is_mobile ? 67 : 84
+  const v_overlap = is_mobile ? 18 : 28
+  const h_gap = is_mobile ? 3 : 4
 
   // Move selected cards to a new custom pile
   const handle_create_pile = () => {
@@ -284,7 +284,7 @@ export function Hand({ cards, level, selected_ids, on_card_click, on_toggle_sele
                           level={level}
                           selected={selected_ids.has(card.Id)}
                           on_click={() => handle_card_click(card)}
-                          size="small"
+                          size={is_mobile ? "small" : "normal"}
                         />
                       </motion.div>
                     )
@@ -299,14 +299,28 @@ export function Hand({ cards, level, selected_ids, on_card_click, on_toggle_sele
       {/* Suit filter buttons + action buttons */}
       <div style={{
         display: 'flex',
-        gap: is_mobile ? 6 : 10,
-        marginTop: is_mobile ? 4 : 8,
+        gap: is_mobile ? 5 : 10,
+        marginTop: is_mobile ? 3 : 8,
         justifyContent: 'center',
         alignItems: 'center',
         flexWrap: 'wrap',
       }}>
+        {/* Turn indicator - mobile only, in the row */}
+        {is_mobile && is_my_turn && cards.length > 0 && (
+          <div style={{
+            padding: '4px 10px',
+            backgroundColor: '#ffc107',
+            color: '#000',
+            borderRadius: 4,
+            fontWeight: 'bold',
+            fontSize: 11,
+          }}>
+            Your turn
+          </div>
+        )}
+
         {/* Suit buttons */}
-        <div style={{ display: 'flex', gap: is_mobile ? 4 : 6 }}>
+        <div style={{ display: 'flex', gap: is_mobile ? 3 : 6 }}>
           {[
             { suit: Suit_Spades, symbol: '♠', color: '#000' },
             { suit: Suit_Hearts, symbol: '♥', color: '#dc3545' },
@@ -317,17 +331,18 @@ export function Hand({ cards, level, selected_ids, on_card_click, on_toggle_sele
               key={suit}
               onClick={() => handle_select_suit(suit)}
               style={{
-                width: is_mobile ? 28 : 34,
-                height: is_mobile ? 28 : 34,
-                fontSize: is_mobile ? 16 : 20,
+                width: is_mobile ? 26 : 34,
+                height: is_mobile ? 26 : 34,
+                fontSize: is_mobile ? 14 : 20,
                 backgroundColor: '#fff',
                 color: color,
                 border: '1px solid #ccc',
-                borderRadius: 6,
+                borderRadius: 4,
                 cursor: 'pointer',
                 display: 'flex',
                 alignItems: 'center',
                 justifyContent: 'center',
+                padding: 0,
               }}
             >
               {symbol}
@@ -343,28 +358,28 @@ export function Hand({ cards, level, selected_ids, on_card_click, on_toggle_sele
           onClick={handle_create_pile}
           disabled={selected_ids.size === 0}
           style={{
-            padding: is_mobile ? '6px 10px' : '8px 14px',
+            padding: is_mobile ? '4px 8px' : '8px 14px',
             fontSize: is_mobile ? 11 : 13,
             backgroundColor: selected_ids.size > 0 ? '#9c27b0' : '#444',
             color: '#fff',
             border: 'none',
-            borderRadius: 6,
+            borderRadius: 4,
             cursor: selected_ids.size > 0 ? 'pointer' : 'default',
             opacity: selected_ids.size > 0 ? 1 : 0.5,
           }}
         >
-          New Pile
+          Pile
         </button>
         <button
           onClick={handle_reset}
           disabled={custom_columns.size === 0}
           style={{
-            padding: is_mobile ? '6px 10px' : '8px 14px',
+            padding: is_mobile ? '4px 8px' : '8px 14px',
             fontSize: is_mobile ? 11 : 13,
             backgroundColor: custom_columns.size > 0 ? '#607d8b' : '#444',
             color: '#fff',
             border: 'none',
-            borderRadius: 6,
+            borderRadius: 4,
             cursor: custom_columns.size > 0 ? 'pointer' : 'default',
             opacity: custom_columns.size > 0 ? 1 : 0.5,
           }}
@@ -380,12 +395,12 @@ export function Hand({ cards, level, selected_ids, on_card_click, on_toggle_sele
           onClick={on_pass}
           disabled={!is_my_turn || !can_pass || cards.length === 0}
           style={{
-            padding: is_mobile ? '6px 12px' : '8px 16px',
+            padding: is_mobile ? '4px 10px' : '8px 16px',
             fontSize: is_mobile ? 12 : 14,
             backgroundColor: is_my_turn && can_pass && cards.length > 0 ? '#dc3545' : '#444',
             color: '#fff',
             border: 'none',
-            borderRadius: 6,
+            borderRadius: 4,
             cursor: is_my_turn && can_pass && cards.length > 0 ? 'pointer' : 'default',
             opacity: is_my_turn && can_pass && cards.length > 0 ? 1 : 0.5,
           }}
@@ -396,13 +411,13 @@ export function Hand({ cards, level, selected_ids, on_card_click, on_toggle_sele
           onClick={on_play}
           disabled={!is_my_turn || selected_ids.size === 0 || cards.length === 0}
           style={{
-            padding: is_mobile ? '6px 16px' : '8px 24px',
+            padding: is_mobile ? '4px 14px' : '8px 24px',
             fontSize: is_mobile ? 13 : 15,
             fontWeight: 'bold',
             backgroundColor: is_my_turn && selected_ids.size > 0 && cards.length > 0 ? '#28a745' : '#444',
             color: '#fff',
             border: 'none',
-            borderRadius: 6,
+            borderRadius: 4,
             cursor: is_my_turn && selected_ids.size > 0 && cards.length > 0 ? 'pointer' : 'default',
             opacity: is_my_turn && selected_ids.size > 0 && cards.length > 0 ? 1 : 0.5,
           }}
diff --git a/client/src/hooks/use_is_mobile.ts b/client/src/hooks/use_is_mobile.ts
index 439b8e6..f9bf62b 100644
--- a/client/src/hooks/use_is_mobile.ts
+++ b/client/src/hooks/use_is_mobile.ts
@@ -1,15 +1,17 @@
 import { useState, useEffect } from 'react'
 
-const MOBILE_BREAKPOINT = 768
+// Detect mobile: small width OR small height (catches landscape phones)
+function check_is_mobile(): boolean {
+  if (typeof window === 'undefined') return false
+  return window.innerWidth < 768 || window.innerHeight < 500
+}
 
 export function use_is_mobile(): boolean {
-  const [is_mobile, set_is_mobile] = useState(() =>
-    typeof window !== 'undefined' ? window.innerWidth < MOBILE_BREAKPOINT : false
-  )
+  const [is_mobile, set_is_mobile] = useState(check_is_mobile)
 
   useEffect(() => {
     const handle_resize = () => {
-      set_is_mobile(window.innerWidth < MOBILE_BREAKPOINT)
+      set_is_mobile(check_is_mobile())
     }
 
     window.addEventListener('resize', handle_resize)