feat: initialize mobile blackjack app with authentication and game features

This commit is contained in:
2025-12-20 23:10:06 +01:00
commit 1160c3a713
19 changed files with 1107 additions and 0 deletions

256
src/screens/TableScreen.js Normal file
View File

@@ -0,0 +1,256 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Animated, Pressable, StyleSheet, Text, View } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { WS_URL } from '../api';
import { colors, fonts } from '../theme';
import CasinoButton from '../components/CasinoButton';
import Chip from '../components/Chip';
import DealerArea from '../components/DealerArea';
import Seat from '../components/Seat';
import TableBackground from '../components/TableBackground';
export default function TableScreen({ token, tableId, user, onLeave }) {
const [table, setTable] = useState(null);
const [balance, setBalance] = useState(user.balance);
const [message, setMessage] = useState('');
const [betAmount, setBetAmount] = useState(10);
const wsRef = useRef(null);
const pulse = useRef(new Animated.Value(0)).current;
useEffect(() => {
const ws = new WebSocket(WS_URL);
wsRef.current = ws;
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'hello', token }));
ws.send(JSON.stringify({ type: 'join', tableId }));
};
ws.onmessage = (event) => {
const payload = JSON.parse(event.data);
if (payload.type === 'table_state') {
setTable(payload.table);
if (payload.table.minBet) {
setBetAmount((prev) => {
const clamped = Math.max(payload.table.minBet, Math.min(prev, payload.table.maxBet));
return clamped;
});
}
}
if (payload.type === 'balance') {
setBalance(payload.balance);
}
if (payload.type === 'error') {
setMessage(payload.message);
setTimeout(() => setMessage(''), 2400);
}
};
ws.onclose = () => {
wsRef.current = null;
};
return () => {
if (ws.readyState === 1) {
ws.send(JSON.stringify({ type: 'leave' }));
}
ws.close();
};
}, [tableId, token]);
const mySeat = useMemo(() => table?.seats?.find((seat) => seat.isYou), [table]);
const isMyTurn = table?.phase === 'playing' && table?.currentSeatIndex === mySeat?.index;
const bettingLocked = ['playing', 'dealer', 'payout'].includes(table?.phase);
useEffect(() => {
if (!isMyTurn) {
pulse.stopAnimation();
pulse.setValue(0);
return;
}
Animated.loop(
Animated.sequence([
Animated.timing(pulse, { toValue: 1, duration: 700, useNativeDriver: true }),
Animated.timing(pulse, { toValue: 0, duration: 700, useNativeDriver: true })
])
).start();
}, [isMyTurn, pulse]);
const pulseStyle = {
transform: [
{
scale: pulse.interpolate({
inputRange: [0, 1],
outputRange: [1, 1.04]
})
}
],
opacity: pulse.interpolate({ inputRange: [0, 1], outputRange: [0.85, 1] })
};
const send = (payload) => {
if (wsRef.current?.readyState === 1) {
wsRef.current.send(JSON.stringify(payload));
}
};
const adjustBet = (delta) => {
if (!table) {
return;
}
setBetAmount((prev) => {
const next = prev + delta;
return Math.max(table.minBet, Math.min(table.maxBet, next));
});
};
return (
<LinearGradient colors={[colors.backgroundTop, colors.backgroundBottom]} style={styles.container}>
<View style={styles.topBar}>
<View>
<Text style={styles.tableTitle}>Asztal {tableId}</Text>
<Text style={styles.balance}>Egyenleg: {balance} Ft</Text>
</View>
<CasinoButton label="Kilépek" onPress={onLeave} variant="red" />
</View>
<TableBackground>
<View style={styles.dealerArea}>
<DealerArea hand={table?.dealerHand || []} />
</View>
<View style={styles.seatsLayer}>
{table?.seats?.map((seat) => {
const position = seatPositions[seat.index];
if (!position) {
return null;
}
return (
<View key={seat.index} style={[styles.seatPosition, position]}>
<Seat seat={seat} highlight={table.currentSeatIndex === seat.index} />
</View>
);
})}
</View>
</TableBackground>
<View style={styles.controls}>
<View style={styles.betRow}>
<Text style={styles.sectionLabel}>Tét</Text>
<View style={styles.betControls}>
<Pressable onPress={() => adjustBet(-10)} style={[styles.betAdjust, bettingLocked && styles.betAdjustDisabled]} disabled={bettingLocked}>
<Text style={styles.betAdjustText}>-</Text>
</Pressable>
<Chip label={`${betAmount}`} color="red" />
<Pressable onPress={() => adjustBet(10)} style={[styles.betAdjust, bettingLocked && styles.betAdjustDisabled]} disabled={bettingLocked}>
<Text style={styles.betAdjustText}>+</Text>
</Pressable>
</View>
<CasinoButton label="Tét" onPress={() => send({ type: 'bet', amount: betAmount })} variant="gold" disabled={bettingLocked} />
<CasinoButton label="Kész" onPress={() => send({ type: 'ready' })} variant="green" disabled={bettingLocked} />
</View>
<Animated.View style={[styles.actionRow, isMyTurn && pulseStyle]}>
<CasinoButton label="Hit" onPress={() => send({ type: 'action', action: 'hit' })} variant="gold" disabled={!isMyTurn} />
<CasinoButton label="Stand" onPress={() => send({ type: 'action', action: 'stand' })} variant="gold" disabled={!isMyTurn} />
<CasinoButton label="Double" onPress={() => send({ type: 'action', action: 'double' })} variant="gold" disabled={!isMyTurn} />
</Animated.View>
{message ? <Text style={styles.message}>{message}</Text> : null}
</View>
</LinearGradient>
);
}
const seatPositions = {
0: { bottom: 10, left: '38%' },
1: { bottom: 20, left: '8%' },
2: { bottom: 20, right: '8%' },
3: { top: '52%', left: 0 },
4: { top: '52%', right: 0 },
5: { top: '22%', left: '6%' },
6: { top: '22%', right: '6%' }
};
const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: 40
},
topBar: {
paddingHorizontal: 20,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center'
},
tableTitle: {
color: colors.goldBright,
fontSize: 20,
fontFamily: fonts.display,
letterSpacing: 2
},
balance: {
color: colors.muted,
marginTop: 4,
fontFamily: fonts.mono
},
dealerArea: {
alignItems: 'center'
},
seatsLayer: {
flex: 1,
position: 'relative'
},
seatPosition: {
position: 'absolute',
width: 140
},
controls: {
paddingHorizontal: 20,
paddingBottom: 24
},
betRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
marginBottom: 12
},
betControls: {
flexDirection: 'row',
alignItems: 'center',
gap: 6
},
betAdjust: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: 'rgba(255,255,255,0.12)',
alignItems: 'center',
justifyContent: 'center'
},
betAdjustText: {
color: colors.text,
fontSize: 18,
fontWeight: '700'
},
betAdjustDisabled: {
opacity: 0.4
},
actionRow: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 10
},
sectionLabel: {
color: colors.text,
fontSize: 14,
fontFamily: fonts.body
},
message: {
color: colors.goldBright,
textAlign: 'center',
marginTop: 10,
fontFamily: fonts.body
}
});