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:
commit
b6f8ea9768
27 files changed
+4147
No files matched your search
+41
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 |
Binary file not shown.
|
After Width: | Height: | Size: 384 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,7 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: ['react-native-reanimated/plugin'],
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type RootStackParamList = {
|
||||
Setup: undefined;
|
||||
Game: undefined;
|
||||
Score: undefined;
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -0,0 +1,2 @@
|
||||
export { useGameStore } from './gameStore';
|
||||
export type { GameSettings, RollSource } from './gameStore';
|
||||
@@ -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);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user