This commit is contained in:
nessar committed 2026-04-05 13:30:42 +02:00
1 parent 9e9f81ba57
commit ee3765aa1f
8 files changed
+246 -51

No files matched your search

+2 -2
View File
@@ -1,6 +1,6 @@
# Jeu du 21 (Expo + React Native)
# Le 21 (Expo + React Native)
Application mobile du jeu du 21, structurée pour un usage production avec une stratégie de non-régression via `bun:test`.
Application mobile le 21, structurée pour un usage production avec une stratégie de non-régression via `bun:test`.
## Prérequis
+35 -35
View File
@@ -1,36 +1,36 @@
{
"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"
],
"extra": {
"eas": {
"projectId": "039ce5f3-59ab-4635-8433-92189ff0b1b1"
}
}
}
}
"expo": {
"name": "21",
"slug": "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.21"
},
"plugins": [
"expo-asset"
],
"extra": {
"eas": {
"projectId": "039ce5f3-59ab-4635-8433-92189ff0b1b1"
}
}
}
}
+66 -4
View File
@@ -9,27 +9,37 @@ type Dice3DViewerProps = {
rollSignal: number;
onRollResult: (dice: [number, number]) => void;
onReadyChange?: (ready: boolean) => void;
resetSignal?: number;
};
export function Dice3DViewer({
rollSignal,
onRollResult,
onReadyChange,
resetSignal,
}: Dice3DViewerProps) {
const webViewRef = useRef<WebView>(null);
const lastSignalRef = useRef(rollSignal);
const lastResetSignalRef = useRef<number | undefined>(resetSignal);
const autoResetAttemptsRef = useRef(0);
const onRollResultRef = useRef(onRollResult);
const lastLayoutRef = useRef({ width: 0, height: 0 });
const [htmlUri, setHtmlUri] = useState<string | null>(null);
const [webReady, setWebReady] = useState(false);
const [webViewKey, setWebViewKey] = useState(0);
const resetWebView = useCallback(() => {
setWebReady(false);
setWebViewKey((previous) => previous + 1);
}, []);
const postResize = useCallback(() => {
if (!webReady || !htmlUri) {
if (!htmlUri) {
return;
}
webViewRef.current?.postMessage(JSON.stringify({ type: 'RESIZE' }));
}, [htmlUri, webReady]);
}, [htmlUri]);
useEffect(() => {
onRollResultRef.current = onRollResult;
@@ -65,7 +75,40 @@ export function Dice3DViewer({
useEffect(() => {
postResize();
}, [postResize]);
}, [postResize, webViewKey]);
useEffect(() => {
if (resetSignal === undefined) {
return;
}
if (resetSignal === lastResetSignalRef.current) {
return;
}
lastResetSignalRef.current = resetSignal;
resetWebView();
}, [resetSignal, resetWebView]);
useEffect(() => {
if (webReady) {
autoResetAttemptsRef.current = 0;
return;
}
if (!htmlUri || autoResetAttemptsRef.current >= 2) {
return;
}
const timeoutId = setTimeout(() => {
autoResetAttemptsRef.current += 1;
resetWebView();
}, 4200);
return () => {
clearTimeout(timeoutId);
};
}, [htmlUri, resetWebView, webReady, webViewKey]);
useEffect(() => {
if (!webReady || !htmlUri) {
@@ -108,6 +151,10 @@ export function Dice3DViewer({
if (message?.type === 'READY') {
setWebReady(true);
autoResetAttemptsRef.current = 0;
requestAnimationFrame(() => {
postResize();
});
return;
}
@@ -121,9 +168,20 @@ export function Dice3DViewer({
return;
}
},
[],
[postResize],
);
const onWebError = useCallback(() => {
setWebReady(false);
if (autoResetAttemptsRef.current >= 2) {
return;
}
autoResetAttemptsRef.current += 1;
resetWebView();
}, [resetWebView]);
if (!htmlUri) {
return (
<View style={styles.loaderContainer}>
@@ -135,10 +193,14 @@ export function Dice3DViewer({
return (
<View style={styles.wrapper} onLayout={onWrapperLayout}>
<WebView
key={`dice-webview-${webViewKey}`}
ref={webViewRef}
source={{ uri: htmlUri }}
style={styles.webView}
onMessage={onWebMessage}
onLoadStart={() => setWebReady(false)}
onLoadEnd={postResize}
onError={onWebError}
originWhitelist={['*']}
javaScriptEnabled
domStorageEnabled
+1 -1
View File
@@ -26,7 +26,7 @@ export function RulesModal({ visible, onClose }: RulesModalProps) {
<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>
<Text style={styles.title}>Règles du 21</Text>
<ScrollView style={styles.scroll}>
{rules.map((rule) => (
+73
View File
@@ -59,6 +59,7 @@
let diceBox = null;
let isRolling = false;
let resizeQueued = false;
let healthCheckFailures = 0;
const extractValues = (result) => {
if (!Array.isArray(result)) {
@@ -95,9 +96,25 @@
});
await diceBox.init();
healthCheckFailures = 0;
return diceBox;
};
const resetDiceBox = async () => {
try {
const container = document.getElementById('dice-box');
if (container) {
container.innerHTML = '';
}
diceBox = null;
await ensureDiceBox();
queueResize();
} catch (error) {
console.warn('DiceBox reset fallback', error);
}
};
const resizeDiceBox = async () => {
try {
const box = await ensureDiceBox();
@@ -121,6 +138,43 @@
});
};
const hasVisibleCanvas = () => {
const container = document.getElementById('dice-box');
const canvas = container?.querySelector('canvas');
if (!container || !canvas) {
return false;
}
if (container.clientWidth < 2 || container.clientHeight < 2) {
return false;
}
const canvasRect = canvas.getBoundingClientRect();
return canvasRect.width >= 2 && canvasRect.height >= 2;
};
const runHealthCheck = async () => {
if (isRolling) {
return;
}
if (hasVisibleCanvas()) {
healthCheckFailures = 0;
return;
}
healthCheckFailures += 1;
if (healthCheckFailures >= 3) {
healthCheckFailures = 0;
await resetDiceBox();
return;
}
queueResize();
};
const rollDice = async () => {
if (isRolling) {
return;
@@ -160,6 +214,11 @@
if (message.type === 'RESIZE') {
queueResize();
return;
}
if (message.type === 'RESET') {
resetDiceBox();
}
} catch (error) {
console.warn('Bridge parse error', error);
@@ -169,6 +228,15 @@
window.addEventListener('message', onBridgeMessage);
document.addEventListener('message', onBridgeMessage);
window.addEventListener('resize', queueResize);
window.addEventListener('orientationchange', queueResize);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
queueResize();
runHealthCheck().catch((error) => {
console.warn('DiceBox health fallback', error);
});
}
});
ensureDiceBox()
.catch((error) => {
@@ -176,6 +244,11 @@
})
.finally(() => {
queueResize();
setInterval(() => {
runHealthCheck().catch((error) => {
console.warn('DiceBox health fallback', error);
});
}, 1800);
emit({ type: 'READY' });
});
</script>
+37
View File
@@ -63,6 +63,7 @@ export function GameScreen({ navigation }: GameScreenProps) {
} = useGameStore();
const [rollSignal, setRollSignal] = useState(0);
const [diceResetSignal, setDiceResetSignal] = useState(0);
const [rulesOpen, setRulesOpen] = useState(false);
const [webReady, setWebReady] = useState(false);
const rubTracker = useRef<RubTracker>(createRubTracker());
@@ -214,6 +215,12 @@ export function GameScreen({ navigation }: GameScreenProps) {
[currentPlayer?.name, receiveRollResult],
);
const resetDiceView = useCallback(() => {
setWebReady(false);
setDiceResetSignal((previous) => previous + 1);
ToastAndroid.show('Vue des dés réinitialisée.', ToastAndroid.SHORT);
}, []);
useEffect(() => {
if (!settings.enableShake || (phase !== 'playing' && phase !== 'tie-break')) {
return;
@@ -328,6 +335,7 @@ export function GameScreen({ navigation }: GameScreenProps) {
<View style={styles.diceWrapper}>
<Dice3DViewer
rollSignal={rollSignal}
resetSignal={diceResetSignal}
onRollResult={onDiceResult}
onReadyChange={setWebReady}
/>
@@ -344,6 +352,14 @@ export function GameScreen({ navigation }: GameScreenProps) {
<Text style={styles.diceStatsRolls}>{currentRollCount}/{maxRolls} lancés</Text>
<Text style={styles.diceStatsScore}>Score: {currentScore ?? '--'}</Text>
</View>
<Pressable
style={styles.diceResetButton}
onPress={resetDiceView}
hitSlop={ui.space.xs}
>
<Text style={styles.diceResetLabel}>↻</Text>
</Pressable>
</View>
<View style={styles.loadingRow}>
@@ -552,6 +568,27 @@ const styles = StyleSheet.create({
fontWeight: textWeight.extraBold,
marginTop: ui.space.xxs,
},
diceResetButton: {
position: 'absolute',
left: ui.space.xs,
bottom: ui.space.xs,
width: ui.size.controlCompact,
height: ui.size.controlCompact,
borderRadius: ui.radius.round,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: ui.color.overlayGlass,
borderWidth: 1,
borderColor: ui.color.borderAccentStrong,
opacity: 0.42,
zIndex: 4,
},
diceResetLabel: {
color: ui.color.textMuted,
fontSize: ui.fontSize.md,
lineHeight: ui.lineHeight.md,
fontWeight: textWeight.bold,
},
loadingRow: {
flexDirection: 'row',
alignItems: 'center',
+1 -1
View File
@@ -250,7 +250,7 @@ export function SetupScreen({ navigation }: SetupScreenProps) {
</View>
{playerNames.map((name, index) => (
<View key={`${name}-${index}`} style={styles.playerBlock}>
<View key={`player-${index}`} style={styles.playerBlock}>
<View style={styles.playerRow}>
<TextInput
value={name}
+31 -8
View File
@@ -278,6 +278,14 @@ const getLowestTiePlayerIds = (standings: RankedTurn[]): string[] => {
return getLowestRankedPlayerIds(standings);
};
const rotatePlayersFromIndex = (players: Player[], startIndex: number): Player[] => {
if (startIndex <= 0 || startIndex >= players.length) {
return players;
}
return [...players.slice(startIndex), ...players.slice(0, startIndex)];
};
const getInitialState = (): Omit<
GameStoreState,
| 'setupGame'
@@ -679,6 +687,13 @@ export const useGameStore = create<GameStoreState>()(
return;
}
const turnSpecials = getPotDeltaAndSpecials(
state.currentScore,
state.currentDice,
state.settings.enableMagicNumber,
currentPlayer.magicNumber,
);
const turn: TurnResult = {
playerId: currentPlayer.id,
playerName: currentPlayer.name,
@@ -686,15 +701,11 @@ export const useGameStore = create<GameStoreState>()(
dice: state.currentDice,
rollsUsed: state.currentRollCount,
probability: getRollProbability(state.currentDice),
badges: getPotDeltaAndSpecials(
state.currentScore,
state.currentDice,
state.settings.enableMagicNumber,
currentPlayer.magicNumber,
).specials,
badges: turnSpecials.specials,
};
const turns = [...state.turns, turn];
const nextPot = state.pot + turnSpecials.potDelta;
const computedFirstLimit =
state.firstPlayerRollLimit ??
(state.currentPlayerIndex === 0 ? state.currentRollCount : null);
@@ -704,6 +715,7 @@ export const useGameStore = create<GameStoreState>()(
if (shouldEndRound) {
set({
turns,
pot: nextPot,
firstPlayerRollLimit: computedFirstLimit,
pendingRoll: false,
awaitingDecision: false,
@@ -715,6 +727,7 @@ export const useGameStore = create<GameStoreState>()(
set({
turns,
pot: nextPot,
firstPlayerRollLimit: computedFirstLimit,
pendingRoll: false,
awaitingDecision: false,
@@ -1000,7 +1013,6 @@ export const useGameStore = create<GameStoreState>()(
currentDice: normalizedDice,
currentScore: score,
currentRollCount: nextRollCount,
pot: state.pot + potDelta,
pendingRoll: false,
awaitingDecision: false,
players: state.players.map((player, index) => {
@@ -1178,6 +1190,17 @@ export const useGameStore = create<GameStoreState>()(
return;
}
const lastRoundLoserId = state.roundSummary?.standings[
state.roundSummary.standings.length - 1
]?.playerId;
const nextStartingPlayerIndex = lastRoundLoserId
? state.players.findIndex((player) => player.id === lastRoundLoserId)
: -1;
const rotatedPlayers = rotatePlayersFromIndex(
state.players,
nextStartingPlayerIndex > -1 ? nextStartingPlayerIndex : 0,
);
set({
phase: 'playing',
round: state.round + 1,
@@ -1197,7 +1220,7 @@ export const useGameStore = create<GameStoreState>()(
tieBreakRound: 0,
tieBreakBaseTurns: [],
tieBreakEndedBy31: false,
players: state.players.map((player) => ({
players: rotatedPlayers.map((player) => ({
...player,
lastScore: null,
lastDice: null,