feat: add SetupScreen and game store for managing game state

- Implement SetupScreen for player setup and game configuration.
- Create gameStore using Zustand for managing game state, including player data, game settings, and turn results.
- Add utility functions for probability calculations related to dice rolls.
- Introduce TypeScript configuration for strict type checking.
This commit is contained in:
nessar committed 2026-04-02 23:26:56 +02:00
commit b6f8ea9768
27 files changed
+4147

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android
+18
View File
@@ -0,0 +1,18 @@
import { NavigationContainer } from '@react-navigation/native';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { AppNavigator } from './src/navigation/AppNavigator';
export default function App() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<NavigationContainer>
<StatusBar style="light" />
<AppNavigator />
</NavigationContainer>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
+31
View File
@@ -0,0 +1,31 @@
{
"expo": {
"name": "Jeu du 21",
"slug": "jeu-du-21",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "dark",
"platforms": [
"android"
],
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false,
"package": "com.nessar.jeu21"
},
"plugins": [
"expo-asset"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+7
View File
@@ -0,0 +1,7 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: ['react-native-reanimated/plugin'],
};
};
+1312
View File
File diff suppressed because it is too large. Load diff
+9
View File
@@ -0,0 +1,9 @@
import 'react-native-gesture-handler';
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);
+9
View File
@@ -0,0 +1,9 @@
const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname);
if (!config.resolver.assetExts.includes('html')) {
config.resolver.assetExts.push('html');
}
module.exports = config;
+32
View File
@@ -0,0 +1,32 @@
{
"name": "21",
"version": "1.0.0",
"main": "index.ts",
"scripts": {
"start": "expo start",
"android": "expo start --android"
},
"dependencies": {
"@3d-dice/dice-box": "^1.1.4",
"@react-navigation/native": "^7.2.2",
"@react-navigation/native-stack": "^7.14.10",
"expo": "~55.0.11",
"expo-asset": "~55.0.12",
"expo-sensors": "~55.0.11",
"expo-status-bar": "~55.0.5",
"react": "19.2.0",
"react-native": "0.83.4",
"react-native-gesture-handler": "~2.30.0",
"react-native-reanimated": "4.2.1",
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.23.0",
"react-native-webview": "13.16.0",
"react-native-worklets": "0.7.2",
"zustand": "^5.0.12"
},
"devDependencies": {
"@types/react": "~19.2.14",
"typescript": "~5.9.2"
},
"private": true
}
+193
View File
@@ -0,0 +1,193 @@
import { Asset } from 'expo-asset';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ActivityIndicator, LayoutChangeEvent, StyleSheet, View } from 'react-native';
import { WebView, WebViewMessageEvent } from 'react-native-webview';
type Dice3DViewerProps = {
rollSignal: number;
onRollResult: (dice: [number, number]) => void;
onReadyChange?: (ready: boolean) => void;
};
const parseDicePayload = (payload: unknown): [number, number] | null => {
if (!Array.isArray(payload) || payload.length < 2) {
return null;
}
const first = Number(payload[0]);
const second = Number(payload[1]);
if (
!Number.isInteger(first) ||
!Number.isInteger(second) ||
first < 1 ||
first > 6 ||
second < 1 ||
second > 6
) {
return null;
}
return [first, second];
};
export function Dice3DViewer({
rollSignal,
onRollResult,
onReadyChange,
}: Dice3DViewerProps) {
const webViewRef = useRef<WebView>(null);
const lastSignalRef = useRef(rollSignal);
const onRollResultRef = useRef(onRollResult);
const lastLayoutRef = useRef({ width: 0, height: 0 });
const [htmlUri, setHtmlUri] = useState<string | null>(null);
const [webReady, setWebReady] = useState(false);
const postResize = useCallback(() => {
if (!webReady || !htmlUri) {
return;
}
webViewRef.current?.postMessage(JSON.stringify({ type: 'RESIZE' }));
}, [htmlUri, webReady]);
useEffect(() => {
onRollResultRef.current = onRollResult;
}, [onRollResult]);
useEffect(() => {
let mounted = true;
const loadHtml = async () => {
const asset = Asset.fromModule(require('../html/dice3d.html'));
await asset.downloadAsync();
const uri = asset.localUri ?? asset.uri;
if (mounted) {
setHtmlUri(uri);
}
};
loadHtml().catch(() => {
if (mounted) {
setHtmlUri(null);
}
});
return () => {
mounted = false;
};
}, []);
useEffect(() => {
onReadyChange?.(webReady);
}, [onReadyChange, webReady]);
useEffect(() => {
postResize();
}, [postResize]);
useEffect(() => {
if (!webReady || !htmlUri) {
return;
}
if (rollSignal === lastSignalRef.current) {
return;
}
lastSignalRef.current = rollSignal;
webViewRef.current?.postMessage(JSON.stringify({ type: 'ROLL' }));
}, [htmlUri, rollSignal, webReady]);
const onWrapperLayout = useCallback(
(event: LayoutChangeEvent) => {
const { width, height } = event.nativeEvent.layout;
if (width < 1 || height < 1) {
return;
}
const previous = lastLayoutRef.current;
const widthDiff = Math.abs(previous.width - width);
const heightDiff = Math.abs(previous.height - height);
if (widthDiff < 1 && heightDiff < 1) {
return;
}
lastLayoutRef.current = { width, height };
postResize();
},
[postResize],
);
const onWebMessage = useCallback(
(event: WebViewMessageEvent) => {
try {
const message = JSON.parse(event.nativeEvent.data);
if (message?.type === 'READY') {
setWebReady(true);
return;
}
if (message?.type === 'ROLL_RESULT') {
const parsed = parseDicePayload(message?.payload?.dice);
if (parsed) {
onRollResultRef.current(parsed);
}
}
} catch {
return;
}
},
[],
);
if (!htmlUri) {
return (
<View style={styles.loaderContainer}>
<ActivityIndicator size="large" color="#9CAEFF" />
</View>
);
}
return (
<View style={styles.wrapper} onLayout={onWrapperLayout}>
<WebView
ref={webViewRef}
source={{ uri: htmlUri }}
style={styles.webView}
onMessage={onWebMessage}
originWhitelist={['*']}
javaScriptEnabled
domStorageEnabled
allowFileAccess
allowUniversalAccessFromFileURLs
mixedContentMode="always"
setSupportMultipleWindows={false}
scrollEnabled={false}
/>
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
flex: 1,
borderRadius: 16,
overflow: 'hidden',
backgroundColor: 'transparent',
},
webView: {
flex: 1,
backgroundColor: 'transparent',
},
loaderContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#1A1D2A',
borderRadius: 16,
},
});
+90
View File
@@ -0,0 +1,90 @@
import { Pressable, StyleSheet, Text, View } from 'react-native';
type PlayerAvatarProps = {
name: string;
drinks: number;
lastScore: number | null;
isCurrent: boolean;
onReset: () => void;
};
export function PlayerAvatar({
name,
drinks,
lastScore,
isCurrent,
onReset,
}: PlayerAvatarProps) {
const initial = name.trim().charAt(0).toUpperCase() || '?';
return (
<View style={[styles.card, isCurrent && styles.cardCurrent]}>
<View style={[styles.avatar, isCurrent && styles.avatarCurrent]}>
<Text style={styles.avatarText}>{initial}</Text>
</View>
<Text style={styles.name} numberOfLines={1}>
{name}
</Text>
<Text style={styles.meta}>🍻 {drinks} gorgées</Text>
<Text style={styles.meta}>🎯 {lastScore ?? '--'}</Text>
<Pressable onPress={onReset} style={styles.resetButton}>
<Text style={styles.resetLabel}>Reset</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
card: {
width: 120,
borderRadius: 14,
padding: 10,
marginRight: 10,
backgroundColor: '#1B1D28',
borderWidth: 1,
borderColor: '#2B2F40',
alignItems: 'center',
},
cardCurrent: {
borderColor: '#8AA4FF',
},
avatar: {
width: 42,
height: 42,
borderRadius: 21,
backgroundColor: '#2C3042',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 8,
},
avatarCurrent: {
backgroundColor: '#5562A8',
},
avatarText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '700',
},
name: {
color: '#F2F3F8',
fontWeight: '700',
marginBottom: 4,
},
meta: {
color: '#B7BDD6',
fontSize: 12,
marginBottom: 2,
},
resetButton: {
marginTop: 8,
paddingVertical: 4,
paddingHorizontal: 10,
borderRadius: 10,
backgroundColor: '#2F354F',
},
resetLabel: {
color: '#DCE4FF',
fontSize: 12,
fontWeight: '600',
},
});
+89
View File
@@ -0,0 +1,89 @@
import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
type RulesModalProps = {
visible: boolean;
onClose: () => void;
};
const rules = [
'Lance 2 dés et forme un score à 2 chiffres : plus grand dé = dizaine, plus petit = unité.',
'Le premier joueur peut lancer jusqu’à 3 fois et choisit quand s’arrêter.',
'Les autres joueurs ont comme limite le nombre de lancers utilisé par le premier joueur.',
'Chaque joueur garde son dernier lancer, pas son meilleur lancer.',
'Le plus petit score boit tout le pot commun en fin de tour.',
'21 : meilleur score normal (+10 dans le pot).',
'31 : pire score (+10 dans le pot) et fin du tour du joueur uniquement (la manche continue).',
'Classement spécial: tous les doubles battent les non-doubles (ex: 22 > 65), sauf le 21 qui reste au-dessus.',
'Doubles : 11(+2), 22(+4), 33(+6), 44(+8), 55(+10), 66(+12) dans le pot.',
'Tombée de dé : joueur actuel boit 2 gorgées (bouton manuel).',
'Chiffre magique : défini une fois au setup pour chaque joueur (11 à 66, sauf 31).',
'En cas de départage sur les plus faibles scores, chaque joueur concerné relance 1 fois et le chiffre magique est désactivé.',
'Stats rares: 3 lancers identiques d’un joueur sur un même tour et 6 lancers identiques d’un joueur sur la partie sont signalés avec probabilité.',
];
export function RulesModal({ visible, onClose }: RulesModalProps) {
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<View style={styles.backdrop}>
<View style={styles.card}>
<Text style={styles.title}>Règles du Jeu du 21</Text>
<ScrollView style={styles.scroll}>
{rules.map((rule) => (
<Text key={rule} style={styles.ruleText}>
• {rule}
</Text>
))}
</ScrollView>
<Pressable style={styles.closeButton} onPress={onClose}>
<Text style={styles.closeLabel}>Fermer</Text>
</Pressable>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.65)',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
card: {
width: '100%',
maxHeight: '78%',
backgroundColor: '#171A25',
borderRadius: 18,
borderWidth: 1,
borderColor: '#30354B',
padding: 16,
},
title: {
color: '#F4F5FA',
fontSize: 20,
fontWeight: '700',
marginBottom: 10,
},
scroll: {
marginBottom: 12,
},
ruleText: {
color: '#D3D8EE',
marginBottom: 10,
lineHeight: 20,
},
closeButton: {
backgroundColor: '#3C4F90',
borderRadius: 12,
paddingVertical: 11,
alignItems: 'center',
},
closeLabel: {
color: '#FFFFFF',
fontWeight: '700',
},
});
+167
View File
@@ -0,0 +1,167 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
html,
body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: transparent;
}
#dice-box {
width: 100%;
height: 100%;
background: transparent;
}
#dice-box canvas {
width: 100% !important;
height: 100% !important;
background: transparent !important;
}
</style>
</head>
<body>
<div id="dice-box"></div>
<script type="module">
const emit = (payload) => {
window.ReactNativeWebView?.postMessage(JSON.stringify(payload));
};
const randomDie = () => Math.floor(Math.random() * 6) + 1;
const fallbackRoll = () => [randomDie(), randomDie()];
let diceBox = null;
let isRolling = false;
let resizeQueued = false;
const extractValues = (result) => {
if (!Array.isArray(result)) {
return null;
}
const values = result
.map((die) => Number(die?.value))
.filter((value) => Number.isInteger(value) && value >= 1 && value <= 6);
return values.length >= 2 ? values.slice(0, 2) : null;
};
const ensureDiceBox = async () => {
if (diceBox) {
return diceBox;
}
const { default: DiceBox } = await import(
'https://unpkg.com/@3d-dice/dice-box@1.1.4/dist/dice-box.es.js'
);
diceBox = new DiceBox({
container: '#dice-box',
id: 'dice-box-canvas',
origin: '',
assetPath: 'https://unpkg.com/@3d-dice/dice-box@1.1.4/dist/assets/',
theme: 'default',
offscreen: false,
scale: 10,
gravity: 1,
throwForce: 4,
spinForce: 5,
});
await diceBox.init();
return diceBox;
};
const resizeDiceBox = async () => {
try {
const box = await ensureDiceBox();
if (typeof box?.resizeWorld === 'function') {
box.resizeWorld();
}
} catch (error) {
console.warn('DiceBox resize fallback', error);
}
};
const queueResize = () => {
if (resizeQueued) {
return;
}
resizeQueued = true;
requestAnimationFrame(() => {
resizeQueued = false;
resizeDiceBox();
});
};
const rollDice = async () => {
if (isRolling) {
return;
}
isRolling = true;
try {
let diceValues = null;
try {
const box = await ensureDiceBox();
const rollResult = await box.roll('2d6');
diceValues = extractValues(rollResult);
} catch (error) {
console.warn('DiceBox roll fallback', error);
}
if (!diceValues) {
diceValues = fallbackRoll();
}
emit({ type: 'ROLL_RESULT', payload: { dice: diceValues } });
queueResize();
} finally {
isRolling = false;
}
};
const onBridgeMessage = (event) => {
try {
const message = JSON.parse(event.data || '{}');
if (message.type === 'ROLL') {
rollDice();
return;
}
if (message.type === 'RESIZE') {
queueResize();
}
} catch (error) {
console.warn('Bridge parse error', error);
}
};
window.addEventListener('message', onBridgeMessage);
document.addEventListener('message', onBridgeMessage);
window.addEventListener('resize', queueResize);
ensureDiceBox()
.catch((error) => {
console.warn('DiceBox init fallback', error);
})
.finally(() => {
queueResize();
emit({ type: 'READY' });
});
</script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { GameScreen } from '../screens/GameScreen';
import { ScoreScreen } from '../screens/ScoreScreen';
import { SetupScreen } from '../screens/SetupScreen';
import { RootStackParamList } from './types';
const Stack = createNativeStackNavigator<RootStackParamList>();
export function AppNavigator() {
return (
<Stack.Navigator
initialRouteName="Setup"
screenOptions={{
headerShown: false,
animation: 'slide_from_right',
}}
>
<Stack.Screen name="Setup" component={SetupScreen} />
<Stack.Screen name="Game" component={GameScreen} />
<Stack.Screen name="Score" component={ScoreScreen} />
</Stack.Navigator>
);
}
+5
View File
@@ -0,0 +1,5 @@
export type RootStackParamList = {
Setup: undefined;
Game: undefined;
Score: undefined;
};
+484
View File
@@ -0,0 +1,484 @@
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { Accelerometer } from 'expo-sensors';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
PanResponder,
Pressable,
SafeAreaView,
ScrollView,
StyleSheet,
Text,
ToastAndroid,
View,
} from 'react-native';
import { Dice3DViewer } from '../components/Dice3DViewer';
import { PlayerAvatar } from '../components/PlayerAvatar';
import { RulesModal } from '../components/RulesModal';
import { RootStackParamList } from '../navigation/types';
import { RollSource, useGameStore } from '../store/gameStore';
import { getRareToastMessage, isVeryRareRoll } from '../utils/probability';
type GameScreenProps = NativeStackScreenProps<RootStackParamList, 'Game'>;
type RubTracker = {
startedAt: number;
lastX: number;
lastDirection: number;
switches: number;
};
const createRubTracker = (): RubTracker => ({
startedAt: 0,
lastX: 0,
lastDirection: 0,
switches: 0,
});
export function GameScreen({ navigation }: GameScreenProps) {
const {
phase,
tieBreakPlayerIds,
tieBreakRound,
players,
round,
pot,
settings,
currentPlayerIndex,
currentRollCount,
firstPlayerRollLimit,
currentScore,
currentDice,
pendingRoll,
awaitingDecision,
requestRoll,
receiveRollResult,
stopCurrentTurn,
recordDieDropped,
resetPlayerDrinks,
} = useGameStore();
const [rollSignal, setRollSignal] = useState(0);
const [rulesOpen, setRulesOpen] = useState(false);
const [webReady, setWebReady] = useState(false);
const rubTracker = useRef<RubTracker>(createRubTracker());
const isTieBreak = phase === 'tie-break';
const currentPlayer = players[currentPlayerIndex];
const maxRolls = isTieBreak ? 1 : firstPlayerRollLimit ?? 3;
const canInitialRoll = currentRollCount === 0 && !pendingRoll;
const canReRoll = !isTieBreak && awaitingDecision && currentRollCount < maxRolls && !pendingRoll;
const canStop = !isTieBreak && awaitingDecision && currentRollCount > 0 && !pendingRoll;
const tieBreakNames = useMemo(() => {
return tieBreakPlayerIds
.map((playerId) => players.find((player) => player.id === playerId)?.name)
.filter((name): name is string => Boolean(name))
.join(', ');
}, [players, tieBreakPlayerIds]);
useEffect(() => {
if (phase === 'round-summary') {
navigation.replace('Score');
}
}, [navigation, phase]);
const launchRoll = useCallback(
(source: RollSource) => {
if (!webReady) {
ToastAndroid.show('Dés 3D en cours de chargement…', ToastAndroid.SHORT);
return;
}
const accepted = requestRoll(source);
if (!accepted) {
return;
}
setRollSignal((previous) => previous + 1);
},
[requestRoll, webReady],
);
const onDiceResult = useCallback(
(dice: [number, number]) => {
const outcome = receiveRollResult(dice);
if (!outcome) {
return;
}
const currentPlayerName = currentPlayer?.name ?? 'Joueur';
let message: string | null = null;
if (outcome.rareEvents.length > 0) {
const latestRareEvent = outcome.rareEvents[outcome.rareEvents.length - 1];
message = latestRareEvent.message;
} else if (outcome.consecutiveSameCount === 3) {
message = `📊 ${currentPlayerName}: ${outcome.score} est sorti 3 fois de suite.`;
} else if (isVeryRareRoll(outcome.probability)) {
message = getRareToastMessage(outcome.probability);
}
if (!message) {
return;
}
ToastAndroid.show(message.replace('\n', ' '), ToastAndroid.LONG);
},
[currentPlayer?.name, receiveRollResult],
);
useEffect(() => {
if (!settings.enableShake || (phase !== 'playing' && phase !== 'tie-break')) {
return;
}
let lastShakeAt = 0;
Accelerometer.setUpdateInterval(120);
const subscription = Accelerometer.addListener(({ x, y, z }) => {
const magnitude = Math.sqrt(x * x + y * y + z * z);
const delta = Math.abs(magnitude - 1);
const now = Date.now();
if (delta > 1.18 && now - lastShakeAt > 1200) {
lastShakeAt = now;
launchRoll('shake');
}
});
return () => {
subscription.remove();
};
}, [launchRoll, phase, settings.enableShake]);
const panResponder = useMemo(
() =>
PanResponder.create({
onStartShouldSetPanResponder: () => settings.enableRub,
onMoveShouldSetPanResponder: () => settings.enableRub,
onPanResponderGrant: (_, gestureState) => {
rubTracker.current = {
startedAt: Date.now(),
lastX: gestureState.moveX,
lastDirection: 0,
switches: 0,
};
},
onPanResponderMove: (_, gestureState) => {
if (!settings.enableRub) {
return;
}
const tracker = rubTracker.current;
const deltaX = gestureState.moveX - tracker.lastX;
const direction = deltaX > 4 ? 1 : deltaX < -4 ? -1 : 0;
if (direction !== 0) {
if (tracker.lastDirection !== 0 && direction !== tracker.lastDirection) {
tracker.switches += 1;
}
tracker.lastDirection = direction;
tracker.lastX = gestureState.moveX;
if (tracker.switches >= 5 && Date.now() - tracker.startedAt < 1400) {
rubTracker.current = createRubTracker();
launchRoll('rub');
}
}
},
onPanResponderRelease: () => {
rubTracker.current = createRubTracker();
},
onPanResponderTerminate: () => {
rubTracker.current = createRubTracker();
},
}),
[launchRoll, settings.enableRub],
);
const applyDieDrop = () => {
if (!currentPlayer) {
return;
}
recordDieDropped();
ToastAndroid.show(`${currentPlayer.name} boit 2 gorgées`, ToastAndroid.SHORT);
};
if (!currentPlayer) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.centered}>
<Text style={styles.headerTitle}>Aucun joueur trouvé</Text>
<Pressable style={styles.actionButton} onPress={() => navigation.replace('Setup')}>
<Text style={styles.actionLabel}>Retour setup</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container}>
<View style={styles.root}>
<View style={styles.headerRow}>
<View>
<Text style={styles.headerTitle}>Tour {round}</Text>
<Text style={styles.headerSub}>Pot commun: 🍺 {pot}</Text>
</View>
<Pressable style={styles.rulesButton} onPress={() => setRulesOpen(true)}>
<Text style={styles.rulesLabel}>Règles</Text>
</Pressable>
</View>
<View style={styles.card}>
<Text style={styles.currentPlayerLabel}>Joueur actuel</Text>
<Text style={styles.currentPlayerName}>{currentPlayer.name}</Text>
{settings.enableMagicNumber && (
<Text style={styles.metaText}>
Chiffre magique joueur: {currentPlayer.magicNumber ?? '--'}
</Text>
)}
<Text style={styles.metaText}>Lancer {currentRollCount} / {maxRolls}</Text>
{isTieBreak ? (
<Text style={styles.tieBreakText}>
Départage #{tieBreakRound} entre: {tieBreakNames || '—'} (chiffre magique désactivé)
</Text>
) : (
<Text style={styles.metaText}>
{firstPlayerRollLimit === null && currentPlayerIndex === 0
? 'Le premier joueur fixe la limite du tour (max 3).'
: `Limite active: ${maxRolls} lancer(s)`}
</Text>
)}
<Text style={styles.scoreText}>Score actuel: {currentScore ?? '--'}</Text>
<Text style={styles.diceText}>
Dés: {currentDice ? `${currentDice[0]} + ${currentDice[1]}` : '-- + --'}
</Text>
</View>
<View style={styles.diceWrapper}>
<Dice3DViewer
rollSignal={rollSignal}
onRollResult={onDiceResult}
onReadyChange={setWebReady}
/>
{settings.enableRub && (
<Pressable
style={styles.rubOverlay}
onPress={() => launchRoll('rub')}
{...panResponder.panHandlers}
/>
)}
</View>
<View style={styles.loadingRow}>
{pendingRoll ? <ActivityIndicator color="#A9B8FF" /> : <View style={styles.loadingDot} />}
<Text style={styles.loadingText}>
{pendingRoll ? 'Lancer en cours...' : ' '}
</Text>
</View>
<View style={styles.actionsRow}>
{canInitialRoll && (
<Pressable style={styles.actionButton} onPress={() => launchRoll('button')}>
<Text style={styles.actionLabel}>
{isTieBreak ? 'Lancer de départage' : 'Lancer'}
</Text>
</Pressable>
)}
{canReRoll && (
<Pressable style={styles.actionButton} onPress={() => launchRoll('button')}>
<Text style={styles.actionLabel}>Relancer</Text>
</Pressable>
)}
{canStop && (
<Pressable style={styles.actionButtonAlt} onPress={stopCurrentTurn}>
<Text style={styles.actionLabel}>S'arrêter</Text>
</Pressable>
)}
</View>
<Pressable style={styles.dropButton} onPress={applyDieDrop}>
<Text style={styles.dropLabel}>Tombée de dé (le joueur boit 2)</Text>
</Pressable>
<Text style={styles.playersTitle}>Joueurs</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
{players.map((player) => (
<PlayerAvatar
key={player.id}
name={player.name}
drinks={player.drinksConsumed}
lastScore={player.lastScore}
isCurrent={player.id === currentPlayer.id}
onReset={() => resetPlayerDrinks(player.id)}
/>
))}
</ScrollView>
</View>
<RulesModal visible={rulesOpen} onClose={() => setRulesOpen(false)} />
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0D1018',
},
root: {
flex: 1,
padding: 12,
paddingBottom: 14,
},
centered: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 24,
},
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
headerTitle: {
color: '#F2F5FD',
fontSize: 20,
fontWeight: '800',
},
headerSub: {
color: '#B5BEDB',
fontSize: 13,
},
rulesButton: {
backgroundColor: '#2F3A66',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 8,
},
rulesLabel: {
color: '#E8EEFF',
fontWeight: '700',
},
card: {
backgroundColor: '#171C2A',
borderRadius: 14,
borderWidth: 1,
borderColor: '#2D3550',
padding: 12,
marginBottom: 8,
},
currentPlayerLabel: {
color: '#B8C3E8',
marginBottom: 2,
fontSize: 12,
},
currentPlayerName: {
color: '#F4F6FF',
fontWeight: '800',
fontSize: 19,
},
metaText: {
color: '#B7C0DE',
marginTop: 2,
fontSize: 12,
},
tieBreakText: {
color: '#F7D8DF',
marginTop: 4,
fontSize: 12,
fontWeight: '700',
},
scoreText: {
color: '#EAF1FF',
fontSize: 17,
fontWeight: '700',
marginTop: 6,
},
diceText: {
color: '#C6D1F8',
marginTop: 2,
fontSize: 12,
},
diceWrapper: {
flex: 1,
minHeight: 280,
backgroundColor: '#0A0E18',
borderRadius: 16,
borderWidth: 1,
borderColor: '#253153',
overflow: 'hidden',
marginBottom: 8,
},
rubOverlay: {
...StyleSheet.absoluteFillObject,
},
loadingRow: {
flexDirection: 'row',
alignItems: 'center',
minHeight: 20,
marginBottom: 8,
},
loadingDot: {
width: 20,
height: 20,
},
loadingText: {
color: '#AEB9DE',
marginLeft: 8,
fontSize: 12,
},
actionsRow: {
flexDirection: 'row',
minHeight: 44,
marginBottom: 8,
},
actionButton: {
flex: 1,
backgroundColor: '#4C65B9',
paddingVertical: 10,
borderRadius: 12,
alignItems: 'center',
marginRight: 8,
},
actionButtonAlt: {
flex: 1,
backgroundColor: '#4B3858',
paddingVertical: 10,
borderRadius: 12,
alignItems: 'center',
},
actionLabel: {
color: '#FFFFFF',
fontWeight: '800',
fontSize: 13,
},
dropButton: {
backgroundColor: '#6B3F39',
borderRadius: 12,
paddingVertical: 10,
alignItems: 'center',
marginBottom: 8,
},
dropLabel: {
color: '#FFE4DF',
fontWeight: '700',
fontSize: 12,
},
playersTitle: {
color: '#DEE5FF',
fontWeight: '700',
marginBottom: 6,
fontSize: 14,
},
});
+238
View File
@@ -0,0 +1,238 @@
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useEffect, useMemo } from 'react';
import { Pressable, SafeAreaView, ScrollView, StyleSheet, Text, View } from 'react-native';
import Animated, {
FadeInDown,
cancelAnimation,
useAnimatedStyle,
useSharedValue,
withRepeat,
withSequence,
withTiming,
} from 'react-native-reanimated';
import { RootStackParamList } from '../navigation/types';
import { useGameStore } from '../store/gameStore';
type ScoreScreenProps = NativeStackScreenProps<RootStackParamList, 'Score'>;
export function ScoreScreen({ navigation }: ScoreScreenProps) {
const { phase, roundSummary, players, startNextRound, resetGame } = useGameStore();
const pulse = useSharedValue(1);
useEffect(() => {
if (!roundSummary && phase === 'playing') {
navigation.replace('Game');
}
}, [navigation, phase, roundSummary]);
useEffect(() => {
pulse.value = withRepeat(
withSequence(withTiming(1.1, { duration: 340 }), withTiming(1, { duration: 340 })),
-1,
true,
);
return () => {
cancelAnimation(pulse);
};
}, [pulse]);
const pulseStyle = useAnimatedStyle(() => ({
transform: [{ scale: pulse.value }],
}));
const drinksRanking = useMemo(() => {
return [...players].sort((left, right) => right.drinksConsumed - left.drinksConsumed);
}, [players]);
if (!roundSummary) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.centered}>
<Text style={styles.title}>Calcul des scores…</Text>
</View>
</SafeAreaView>
);
}
const goToNextRound = () => {
startNextRound();
navigation.replace('Game');
};
const goToSetup = () => {
resetGame();
navigation.replace('Setup');
};
return (
<SafeAreaView style={styles.container}>
<ScrollView contentContainerStyle={styles.content}>
<Text style={styles.title}>Fin du tour</Text>
<Text style={styles.subtitle}>Pot bu: {roundSummary.potValue} gorgées</Text>
<Animated.View style={[styles.loserCard, pulseStyle]}>
<Text style={styles.loserLabel}>GROS LOOSER</Text>
<Text style={styles.loserName}>{roundSummary.loserName}</Text>
<Text style={styles.winnerText}>🏆 Gagnant: {roundSummary.winnerName}</Text>
{roundSummary.endedBy31 && <Text style={styles.warning}>Tour arrêté par un 31.</Text>}
</Animated.View>
<Text style={styles.sectionTitle}>Classement du tour</Text>
{roundSummary.standings.map((entry, index) => (
<Animated.View
key={`${entry.playerId}-${entry.score}-${index}`}
entering={FadeInDown.delay(index * 40)}
style={styles.row}
>
<View>
<Text style={styles.rowTitle}>
{index + 1}. {entry.playerName}
</Text>
<Text style={styles.rowSub}>
Score {entry.score} • Lancers {entry.rollsUsed}
</Text>
</View>
<Text style={styles.badges}>{entry.badges.join(' | ') || '—'}</Text>
</Animated.View>
))}
<Text style={styles.sectionTitle}>Classement global (gorgées)</Text>
{drinksRanking.map((player, index) => (
<View style={styles.globalRow} key={player.id}>
<Text style={styles.globalText}>
{index + 1}. {player.name}
</Text>
<Text style={styles.globalText}>{player.drinksConsumed} 🍻</Text>
</View>
))}
<View style={styles.buttonsRow}>
<Pressable style={styles.buttonPrimary} onPress={goToNextRound}>
<Text style={styles.buttonLabel}>Tour suivant</Text>
</Pressable>
<Pressable style={styles.buttonSecondary} onPress={goToSetup}>
<Text style={styles.buttonLabel}>Nouveau setup</Text>
</Pressable>
</View>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0E1018',
},
content: {
padding: 16,
paddingBottom: 30,
},
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
color: '#F5F7FF',
fontWeight: '800',
fontSize: 28,
},
subtitle: {
color: '#B8C1E3',
marginBottom: 12,
},
loserCard: {
backgroundColor: '#3F2633',
borderWidth: 1,
borderColor: '#6F3A52',
borderRadius: 16,
padding: 14,
marginBottom: 14,
},
loserLabel: {
color: '#FFC7DA',
fontWeight: '800',
letterSpacing: 1,
},
loserName: {
color: '#FFF1F6',
fontSize: 24,
fontWeight: '900',
marginVertical: 4,
},
winnerText: {
color: '#F6D7E3',
fontWeight: '700',
},
warning: {
color: '#FFD4E1',
marginTop: 6,
},
sectionTitle: {
color: '#E4EBFF',
fontWeight: '800',
marginBottom: 8,
marginTop: 4,
},
row: {
backgroundColor: '#1A1F2F',
borderWidth: 1,
borderColor: '#313B58',
borderRadius: 12,
padding: 10,
marginBottom: 8,
},
rowTitle: {
color: '#F2F6FF',
fontWeight: '700',
},
rowSub: {
color: '#ADB7DB',
marginTop: 2,
},
badges: {
color: '#C9D5FF',
marginTop: 6,
fontSize: 12,
},
globalRow: {
flexDirection: 'row',
justifyContent: 'space-between',
backgroundColor: '#161A28',
borderRadius: 10,
borderWidth: 1,
borderColor: '#2B3250',
paddingHorizontal: 10,
paddingVertical: 9,
marginBottom: 6,
},
globalText: {
color: '#DDE6FF',
fontWeight: '600',
},
buttonsRow: {
marginTop: 14,
flexDirection: 'row',
},
buttonPrimary: {
flex: 1,
backgroundColor: '#516BCC',
paddingVertical: 12,
borderRadius: 12,
alignItems: 'center',
marginRight: 8,
},
buttonSecondary: {
flex: 1,
backgroundColor: '#3A405C',
paddingVertical: 12,
borderRadius: 12,
alignItems: 'center',
},
buttonLabel: {
color: '#FFFFFF',
fontWeight: '800',
},
});
+353
View File
@@ -0,0 +1,353 @@
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useMemo, useState } from 'react';
import {
Alert,
Pressable,
SafeAreaView,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
View,
} from 'react-native';
import { RulesModal } from '../components/RulesModal';
import { RootStackParamList } from '../navigation/types';
import { useGameStore } from '../store';
import type { GameSettings } from '../store';
type SetupScreenProps = NativeStackScreenProps<RootStackParamList, 'Setup'>;
const MIN_PLAYERS = 2;
const sanitizeNames = (names: string[]): string[] => {
return names.map((name) => name.trim()).filter(Boolean);
};
const parseMagic = (raw: string): number | null => {
const value = Number.parseInt(raw, 10);
if (!Number.isInteger(value)) {
return null;
}
return value;
};
export function SetupScreen({ navigation }: SetupScreenProps) {
const setupGame = useGameStore((state) => state.setupGame);
const [playerNames, setPlayerNames] = useState<string[]>(['Joueur 1', 'Joueur 2']);
const [magicNumbersByPlayer, setMagicNumbersByPlayer] = useState<string[]>(['22', '22']);
const [enableShake, setEnableShake] = useState(true);
const [enableRub, setEnableRub] = useState(true);
const [enableMagicNumber, setEnableMagicNumber] = useState(false);
const [rulesOpen, setRulesOpen] = useState(false);
const canStart = useMemo(() => sanitizeNames(playerNames).length >= MIN_PLAYERS, [playerNames]);
const updatePlayerName = (index: number, value: string) => {
setPlayerNames((previous) =>
previous.map((name, itemIndex) => (itemIndex === index ? value : name)),
);
};
const addPlayer = () => {
setPlayerNames((previous) => [...previous, `Joueur ${previous.length + 1}`]);
setMagicNumbersByPlayer((previous) => [...previous, '22']);
};
const removePlayer = (index: number) => {
if (playerNames.length <= MIN_PLAYERS) {
return;
}
setPlayerNames((previous) => previous.filter((_, itemIndex) => itemIndex !== index));
setMagicNumbersByPlayer((previous) =>
previous.filter((_, itemIndex) => itemIndex !== index),
);
};
const updatePlayerMagicNumber = (index: number, value: string) => {
setMagicNumbersByPlayer((previous) =>
previous.map((entry, itemIndex) => (itemIndex === index ? value : entry)),
);
};
const startGame = () => {
const playerEntries = playerNames
.map((name, index) => ({
name: name.trim(),
magicInput: magicNumbersByPlayer[index] ?? '',
}))
.filter((entry) => entry.name.length > 0);
const cleanNames = playerEntries.map((entry) => entry.name);
if (cleanNames.length < MIN_PLAYERS) {
Alert.alert('Joueurs insuffisants', 'Ajoute au moins 2 joueurs.');
return;
}
const parsedMagicNumbers = playerEntries.map((entry, index) => {
if (!enableMagicNumber) {
return null;
}
const parsedMagic = parseMagic(entry.magicInput);
if (!parsedMagic || parsedMagic < 11 || parsedMagic > 66 || parsedMagic === 31) {
return null;
}
return parsedMagic;
});
if (enableMagicNumber) {
const hasInvalidEntry = parsedMagicNumbers.some((value) => value === null);
if (hasInvalidEntry) {
Alert.alert(
'Chiffre magique invalide',
'Chaque joueur doit avoir une valeur entre 11 et 66, sauf 31.',
);
return;
}
}
const settings: GameSettings = {
enableShake,
enableRub,
enableMagicNumber,
};
setupGame({
playerNames: cleanNames,
magicNumbers: parsedMagicNumbers,
settings,
});
navigation.replace('Game');
};
return (
<SafeAreaView style={styles.container}>
<ScrollView contentContainerStyle={styles.content}>
<Text style={styles.title}>Jeu du 21 aux dés</Text>
<Text style={styles.subtitle}>POC Android (Expo + WebView + capteurs)</Text>
<View style={styles.card}>
<View style={styles.rowBetween}>
<Text style={styles.sectionTitle}>Joueurs</Text>
<Pressable style={styles.secondaryButton} onPress={addPlayer}>
<Text style={styles.secondaryButtonLabel}>+ Ajouter</Text>
</Pressable>
</View>
{playerNames.map((name, index) => (
<View key={`${name}-${index}`} style={styles.playerBlock}>
<View style={styles.playerRow}>
<TextInput
value={name}
onChangeText={(value) => updatePlayerName(index, value)}
style={styles.input}
placeholder={`Joueur ${index + 1}`}
placeholderTextColor="#8E95B2"
/>
{playerNames.length > MIN_PLAYERS && (
<Pressable style={styles.deleteButton} onPress={() => removePlayer(index)}>
<Text style={styles.deleteLabel}>✕</Text>
</Pressable>
)}
</View>
{enableMagicNumber && (
<View style={styles.magicRow}>
<Text style={styles.magicLabel}>Chiffre magique du joueur</Text>
<TextInput
value={magicNumbersByPlayer[index] ?? ''}
onChangeText={(value) => updatePlayerMagicNumber(index, value)}
keyboardType="number-pad"
style={styles.magicInput}
placeholder="11 à 66 (sauf 31)"
placeholderTextColor="#8E95B2"
/>
</View>
)}
</View>
))}
</View>
<View style={styles.card}>
<Text style={styles.sectionTitle}>Options de lancer</Text>
<View style={styles.optionRow}>
<Text style={styles.optionLabel}>Secouer (accéléromètre)</Text>
<Switch value={enableShake} onValueChange={setEnableShake} />
</View>
<View style={styles.optionRow}>
<Text style={styles.optionLabel}>Frotter (geste tactile)</Text>
<Switch value={enableRub} onValueChange={setEnableRub} />
</View>
<View style={styles.optionRow}>
<Text style={styles.optionLabel}>Règle Chiffre Magique</Text>
<Switch value={enableMagicNumber} onValueChange={setEnableMagicNumber} />
</View>
{enableMagicNumber && (
<Text style={styles.magicHint}>
Chaque joueur définit son propre chiffre magique.
</Text>
)}
</View>
<Pressable style={styles.rulesButton} onPress={() => setRulesOpen(true)}>
<Text style={styles.rulesLabel}>Voir les règles</Text>
</Pressable>
<Pressable
style={[styles.primaryButton, !canStart && styles.primaryButtonDisabled]}
onPress={startGame}
disabled={!canStart}
>
<Text style={styles.primaryLabel}>Démarrer la partie</Text>
</Pressable>
</ScrollView>
<RulesModal visible={rulesOpen} onClose={() => setRulesOpen(false)} />
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0F111A',
},
content: {
padding: 18,
paddingBottom: 30,
},
title: {
color: '#F6F7FB',
fontSize: 28,
fontWeight: '800',
marginBottom: 6,
},
subtitle: {
color: '#AAB2CE',
marginBottom: 14,
},
card: {
backgroundColor: '#171A25',
borderRadius: 16,
padding: 14,
borderWidth: 1,
borderColor: '#2A3043',
marginBottom: 12,
},
rowBetween: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
sectionTitle: {
color: '#EDF1FF',
fontSize: 17,
fontWeight: '700',
marginBottom: 10,
},
secondaryButton: {
backgroundColor: '#2B3350',
borderRadius: 10,
paddingHorizontal: 10,
paddingVertical: 6,
},
secondaryButtonLabel: {
color: '#D8E1FF',
fontWeight: '600',
},
playerRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 6,
},
playerBlock: {
marginBottom: 10,
},
input: {
flex: 1,
borderWidth: 1,
borderColor: '#363F5C',
backgroundColor: '#10131F',
borderRadius: 10,
color: '#F2F3FA',
paddingVertical: 10,
paddingHorizontal: 12,
},
deleteButton: {
width: 34,
height: 34,
borderRadius: 17,
alignItems: 'center',
justifyContent: 'center',
marginLeft: 8,
backgroundColor: '#4A2B35',
},
deleteLabel: {
color: '#FFDCE4',
fontWeight: '700',
},
optionRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
optionLabel: {
color: '#D8DEFA',
flex: 1,
marginRight: 12,
},
magicRow: {
marginTop: 4,
},
magicLabel: {
color: '#AEB9DF',
fontSize: 12,
marginBottom: 6,
},
magicInput: {
borderWidth: 1,
borderColor: '#363F5C',
backgroundColor: '#10131F',
borderRadius: 10,
color: '#F2F3FA',
paddingVertical: 9,
paddingHorizontal: 12,
},
magicHint: {
color: '#A8B4DB',
fontSize: 12,
marginTop: 2,
},
primaryButton: {
backgroundColor: '#4D66BA',
borderRadius: 12,
paddingVertical: 14,
alignItems: 'center',
},
primaryButtonDisabled: {
opacity: 0.45,
},
primaryLabel: {
color: '#FFFFFF',
fontWeight: '800',
fontSize: 16,
},
rulesButton: {
alignItems: 'center',
paddingVertical: 10,
},
rulesLabel: {
color: '#BDC8F0',
fontWeight: '600',
},
});
File diff suppressed because it is too large. Load diff
+2
View File
@@ -0,0 +1,2 @@
export { useGameStore } from './gameStore';
export type { GameSettings, RollSource } from './gameStore';
+22
View File
@@ -0,0 +1,22 @@
export type DicePair = [number, number];
export const getRollProbability = (dice: DicePair): number => {
return (dice[0] === dice[1] ? 1 : 2) / 36;
};
export const isVeryRareRoll = (probability: number): boolean => {
return probability <= 1 / 36;
};
const rareMessages = [
'🎲 Seulement {percent}% de chance. C’est presque magique !',
'🔥 {percent}% de chance... et ça passe !',
'😱 {percent}% de probabilité. Ce lancer est collector.',
'✨ {percent}% de chance : les dés t’aiment aujourd’hui.',
];
export const getRareToastMessage = (probability: number): string => {
const percent = (probability * 100).toFixed(1);
const template = rareMessages[Math.floor(Math.random() * rareMessages.length)];
return template.replace('{percent}', percent);
};
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}