bug fix
This commit is contained in:
1 parent
9e9f81ba57
commit
ee3765aa1f
8 files changed
+246
-51
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user