guandan.dev
guandan.dev
https://git.tonybtw.com/guandan.dev.git
git://git.tonybtw.com/guandan.dev.git
Updated size for mobile.
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)