Skip to content

Commit

Permalink
fix: refactor calculation logic, re-renders, timer speed up
Browse files Browse the repository at this point in the history
  • Loading branch information
vitalyiegorov committed Jun 4, 2023
1 parent 2f6fa71 commit a504a59
Show file tree
Hide file tree
Showing 13 changed files with 147 additions and 104 deletions.
2 changes: 1 addition & 1 deletion app/loser/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export default function Loser() {
return (
<View style={styles.container}>
<PageHeader title="Looooooser! =)" />
<Header text={'Better next time!\nLoooooser =)'} />
<Header text={'Better luck next time!\nLoooooser =)'} />

<Donation type="loser" />

Expand Down
92 changes: 37 additions & 55 deletions src/game/components/game-screen/game-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@ import {
} from '../../../@generic';
import type { CellInterface, FieldInterface, ScoredCellsInterface } from '../../../@logic';
import { MaxMistakesConstant, Sudoku, defaultSudokuConfig, emptyScoredCells } from '../../../@logic';
import { gameFinishAction, gameResetAction, gameResumeAction, gameSaveAction, gameStartAction } from '../../store/game.actions';
import { gameElapsedTimeSelector, gameMistakesSelector, gameScoreSelector } from '../../store/game.selectors';
import { gameResetAction, gameResumeAction, gameStartAction } from '../../store/game.actions';
import { gameMistakesSelector, gameScoreSelector } from '../../store/game.selectors';
import { gameFinishedThunk } from '../../store/thunks/game-finish.thunk';
import { gameMistakeThunk } from '../../store/thunks/game-mistake.thunk';
import { gameSaveThunk } from '../../store/thunks/game-save.thunk';
import { AvailableValues } from '../available-values/available-values';
import { Field } from '../field/field';
import { GameTimer } from '../game-timer/game-timer';
Expand All @@ -30,28 +33,23 @@ import { GameScreenStyles as styles } from './game-screen.styles';

/**
* We have inconsistency of state storage, field is comming from the url and score and mistakes from redux
* we need to inify this approach
* we need to unify this approach
*/
// eslint-disable-next-line max-lines-per-function
export const GameScreen = () => {
const router = useRouter();
const { field: routeField, difficulty: routeDifficulty } = useLocalSearchParams<{ field?: string; difficulty?: DifficultyEnum }>();

const dispatch = useAppDispatch();
const savedScore = useAppSelector(gameScoreSelector);
const savedMistakes = useAppSelector(gameMistakesSelector);
// TODO: Due to time ticking we render component every second, would be nice if we could avoid it
const savedTime = useAppSelector(gameElapsedTimeSelector);

const score = useAppSelector(gameScoreSelector);
const mistakes = useAppSelector(gameMistakesSelector);
const sudokuRef = useRef<Sudoku>(new Sudoku(defaultSudokuConfig));

const [field, setField] = useState<FieldInterface>([]);
const [selectedCell, setSelectedCell] = useState<CellInterface>();
const [scoredCells, setScoredCells] = useState<ScoredCellsInterface>(emptyScoredCells);
const [mistakes, setMistakes] = useState(savedMistakes);
const [score, setScore] = useState(savedScore);

const maxMistakesReached = mistakes > MaxMistakesConstant;
const maxMistakesReached = mistakes >= MaxMistakesConstant;

useEffect(() => {
if (isNotEmptyString(routeField)) {
Expand All @@ -60,8 +58,6 @@ export const GameScreen = () => {
} else if (isNotEmptyString(routeDifficulty)) {
sudokuRef.current.create(routeDifficulty);

setScore(0);
setMistakes(0);
// eslint-disable-next-line no-undefined
setSelectedCell(undefined);

Expand All @@ -87,32 +83,36 @@ export const GameScreen = () => {

const handleSelectCell = useCallback((cell: CellInterface | undefined) => {
setSelectedCell(cell);
hapticImpact(ImpactFeedbackStyle.Light);
// HINT: This is needed to clear animation on all cells
setScoredCells(emptyScoredCells);
}, []);

const handleLostGame = useCallback(() => {
hapticImpact(ImpactFeedbackStyle.Heavy);

void dispatch(gameFinishedThunk({ difficulty: sudokuRef.current.Difficulty, isWon: false }));

router.replace('loser');
}, [dispatch, router]);

const handleWonGame = useCallback(() => {
hapticImpact(ImpactFeedbackStyle.Heavy);

void dispatch(gameFinishedThunk({ difficulty: sudokuRef.current.Difficulty, isWon: true }));

// TODO: We need to wait for the animation to finish, animation finish event would fix it?
setTimeout(() => void router.replace('winner'), 10 * animationDurationConstant);
}, [dispatch, router]);

const handleCorrectValue = useCallback(
// eslint-disable-next-line max-statements
([correctCell, newScoredCells]: [CellInterface, ScoredCellsInterface]) => {
const newScore = score + sudokuRef.current.getScore(newScoredCells, savedTime, mistakes);
const sudokuString = sudokuRef.current.toString();

setScoredCells(newScoredCells);
setScore(newScore);
void dispatch(gameSaveThunk({ sudoku: sudokuRef.current, scoredCells: newScoredCells }));

if (newScoredCells.isWon) {
hapticImpact(ImpactFeedbackStyle.Heavy);

dispatch(
gameFinishAction({
difficulty: sudokuRef.current.Difficulty,
elapsedTime: savedTime,
score: newScore,
isWon: true
})
);

// TODO: We need to wait for the animation to finish, animation finish event would fix it?
setTimeout(() => void router.replace('winner'), 10 * animationDurationConstant);
handleWonGame();
} else {
hapticNotification(Haptics.NotificationFeedbackType.Success);

Expand All @@ -125,37 +125,19 @@ export const GameScreen = () => {
setSelectedCell(undefined);
}
}

dispatch(gameSaveAction({ newScore, sudokuString, mistakes }));
},
[dispatch, mistakes, router, score, savedTime]
[dispatch, handleWonGame]
);
const handleWrongValue = useCallback(() => {
const sudokuString = sudokuRef.current.toString();
const newMistakes = mistakes + 1;

setMistakes(newMistakes);
const handleWrongValue = useCallback(() => {
void dispatch(gameMistakeThunk(sudokuRef.current));

if (newMistakes < MaxMistakesConstant) {
hapticNotification(Haptics.NotificationFeedbackType.Error);
if (mistakes + 1 >= MaxMistakesConstant) {
handleLostGame();
} else {
hapticImpact(ImpactFeedbackStyle.Heavy);

dispatch(
gameFinishAction({
score,
difficulty: sudokuRef.current.Difficulty,
elapsedTime: savedTime,
isWon: false
})
);

// TODO: We need to wait for the animation to finish, animation finish event would fix it?
setTimeout(() => void router.replace('loser'), 5 * animationDurationConstant);
hapticNotification(Haptics.NotificationFeedbackType.Error);
}

dispatch(gameSaveAction({ sudokuString, newScore: score, mistakes: newMistakes }));
}, [dispatch, mistakes, router, savedTime, score]);
}, [dispatch, handleLostGame, mistakes]);

const mistakesCountTextStyles = [styles.mistakesCountText, cs(maxMistakesReached, styles.mistakesCountErrorText)];

Expand Down
37 changes: 20 additions & 17 deletions src/game/components/game-timer/game-timer.tsx
Original file line number Diff line number Diff line change
@@ -1,48 +1,51 @@
import { useFocusEffect, useRouter } from 'expo-router';
import { useRouter } from 'expo-router';
import { useCallback, useEffect, useRef } from 'react';
import { Text, View } from 'react-native';

import { isDefined } from '@rnw-community/shared';

import { getTimerText, useAppDispatch, useAppSelector, useAppStateChange } from '../../../@generic';
import { gamePauseAction, gameTickAction } from '../../store/game.actions';
import { gameElapsedTimeSelector, gameIsGameActiveSelector, gamePausedSelector } from '../../store/game.selectors';
import { gameElapsedTimeSelector, gamePausedSelector } from '../../store/game.selectors';

import { GameTimerStyles as styles } from './game-timer.styles';

type SetIntervalRef = ReturnType<typeof setInterval> | null;

export const GameTimer = () => {
const router = useRouter();

const dispatch = useAppDispatch();
const savedTime = useAppSelector(gameElapsedTimeSelector);
const paused = useAppSelector(gamePausedSelector);
const isGameActive = useAppSelector(gameIsGameActiveSelector);

const timerIntervalRef = useRef<ReturnType<typeof setInterval> | null>();
const timerIntervalRef = useRef<SetIntervalRef>();

const stopTimer = useCallback((): void => {
if (isDefined(timerIntervalRef.current)) {
clearInterval(timerIntervalRef.current);
timerIntervalRef.current = null;
}
}, []);
const routerScreenFocused = useCallback(() => {
const startTimer = useCallback(() => {
if (!paused) {
timerIntervalRef.current = setInterval(() => void dispatch(gameTickAction()), 1000);
}
}, [dispatch, paused]);
const appBecameInactive = useCallback((): void => {
if (isGameActive) {
stopTimer();
dispatch(gamePauseAction());
router.replace('pause');
timerIntervalRef.current = setInterval(() => {
dispatch(gameTickAction());
}, 1000);
}
}, [dispatch, isGameActive, router, stopTimer]);
}, [dispatch, paused, stopTimer]);
const appBecameInactive = useCallback((): void => {
stopTimer();
dispatch(gamePauseAction());
router.replace('pause');
}, [dispatch, router, stopTimer]);

useEffect(() => {
startTimer();

// HINT: We need to stop timer when game is finished
useEffect(() => void (!isGameActive && void stopTimer()), [stopTimer, isGameActive]);
// HINT: We should start timer only when we are on this screen and game is not paused
useFocusEffect(routerScreenFocused);
return () => void stopTimer();
}, [startTimer, stopTimer]);
useAppStateChange(appBecameInactive);

return (
Expand Down
3 changes: 1 addition & 2 deletions src/game/store/game.actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,5 @@ export const {
reset: gameResetAction,
pause: gamePauseAction,
resume: gameResumeAction,
tick: gameTickAction,
finish: gameFinishAction
tick: gameTickAction
} = gameSlice.actions;
1 change: 0 additions & 1 deletion src/game/store/game.selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,3 @@ export const gameScoreSelector = createSelector(gameSelector, state => state.sco
export const gameMistakesSelector = createSelector(gameSelector, state => state.mistakes);
export const gamePausedSelector = createSelector(gameSelector, state => state.paused);
export const gameElapsedTimeSelector = createSelector(gameSelector, state => state.elapsedTime);
export const gameIsGameActiveSelector = createSelector(gameSelector, state => !state.isFinished);
9 changes: 2 additions & 7 deletions src/game/store/game.slice.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { type PayloadAction, createSlice } from '@reduxjs/toolkit';

import { type HistoryRecordInterface } from '../../history';

import { initialGameState } from './game.state';

export const gameSlice = createSlice({
Expand All @@ -19,12 +17,9 @@ export const gameSlice = createSlice({
resume: state => {
state.paused = false;
},
finish: (state, _action: PayloadAction<HistoryRecordInterface>) => {
state.isFinished = true;
},
save: (state, action: PayloadAction<{ sudokuString: string; newScore: number; mistakes: number }>) => {
save: (state, action: PayloadAction<{ sudokuString: string; score: number; mistakes: number }>) => {
state.sudokuString = action.payload.sudokuString;
state.score = action.payload.newScore;
state.score = action.payload.score;
state.mistakes = action.payload.mistakes;
},
tick: state => {
Expand Down
1 change: 1 addition & 0 deletions src/game/store/game.state.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// ts-prune-ignore-next
export interface GameState {
sudokuString: string;
score: number;
Expand Down
30 changes: 30 additions & 0 deletions src/game/store/thunks/game-finish.thunk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { createAsyncThunk } from '@reduxjs/toolkit';

import { type RootState } from '../../../@app-root/store/app-root.store';
import { type HistoryRecordInterface } from '../../../history';
import { historySaveAction } from '../../../history/store/history.actions';

export const gameFinishedThunk = createAsyncThunk<boolean, HistoryRecordInterface, { state: RootState }>(
'game/finish',
(action, thunkAPI) => {
const state = thunkAPI.getState();

const { difficulty, isWon } = action;

const current = state.history.byDifficulty[difficulty];
const isBestScore = state.game.score > current.bestScore;

thunkAPI.dispatch(
historySaveAction({
difficulty,
bestScore: isBestScore ? state.game.score : current.bestScore,
bestTime: isBestScore ? state.game.elapsedTime : current.bestTime,
gamesCompleted: current.gamesCompleted + 1,
gamesLost: isWon ? current.gamesLost : current.gamesLost + 1,
gamesWon: isWon ? current.gamesWon + 1 : current.gamesWon
})
);

return true;
}
);
19 changes: 19 additions & 0 deletions src/game/store/thunks/game-mistake.thunk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { createAsyncThunk } from '@reduxjs/toolkit';

import { type RootState } from '../../../@app-root/store/app-root.store';
import { type Sudoku } from '../../../@logic';
import { gameSaveAction } from '../game.actions';

export const gameMistakeThunk = createAsyncThunk<boolean, Sudoku, { state: RootState }>('game/save', (sudoku, thunkAPI) => {
const state = thunkAPI.getState();

thunkAPI.dispatch(
gameSaveAction({
sudokuString: sudoku.toString(),
score: state.game.score,
mistakes: state.game.mistakes + 1
})
);

return true;
});
26 changes: 26 additions & 0 deletions src/game/store/thunks/game-save.thunk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { createAsyncThunk } from '@reduxjs/toolkit';

import { type RootState } from '../../../@app-root/store/app-root.store';
import { type ScoredCellsInterface, type Sudoku } from '../../../@logic';
import { gameSaveAction } from '../game.actions';

interface GameSavePayloadInterface {
sudoku: Sudoku;
scoredCells: ScoredCellsInterface;
}

export const gameSaveThunk = createAsyncThunk<boolean, GameSavePayloadInterface, { state: RootState }>('game/save', (action, thunkAPI) => {
const { sudoku } = action;

const state = thunkAPI.getState();

thunkAPI.dispatch(
gameSaveAction({
sudokuString: sudoku.toString(),
score: state.game.score + sudoku.getScore(action.scoredCells, state.game.elapsedTime, state.game.mistakes),
mistakes: state.game.mistakes
})
);

return true;
});
3 changes: 1 addition & 2 deletions src/history/interfaces/history-record.interface.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { type DifficultyEnum } from '../../@generic';
import { type GameState } from '../../game/store/game.state';

export interface HistoryRecordInterface extends Pick<GameState, 'elapsedTime' | 'score'> {
export interface HistoryRecordInterface {
isWon: boolean;
difficulty: DifficultyEnum;
}
3 changes: 3 additions & 0 deletions src/history/store/history.actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { historySlice } from './history.slice';

export const { save: historySaveAction } = historySlice.actions;
25 changes: 6 additions & 19 deletions src/history/store/history.slice.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,15 @@
import { createSlice } from '@reduxjs/toolkit';
import { type PayloadAction, createSlice } from '@reduxjs/toolkit';

import { gameFinishAction } from '../../game/store/game.actions';
import { type HistoryGameInterface } from '../interfaces/history-game.interface';

import { initialHistoryState } from './history.state';

export const historySlice = createSlice({
name: 'history',
initialState: initialHistoryState,
reducers: {},
extraReducers: builder => {
builder.addCase(gameFinishAction, (state, action) => {
const { difficulty, isWon, score, elapsedTime } = action.payload;

const current = state.byDifficulty[difficulty];
const isBestScore = score > current.bestScore;

state.byDifficulty[difficulty] = {
difficulty,
bestScore: isBestScore ? score : current.bestScore,
bestTime: isBestScore ? elapsedTime : current.bestTime,
gamesCompleted: current.gamesCompleted + 1,
gamesLost: isWon ? current.gamesLost : current.gamesLost + 1,
gamesWon: isWon ? current.gamesWon + 1 : current.gamesWon
};
});
reducers: {
save: (state, action: PayloadAction<HistoryGameInterface>) => {
state.byDifficulty[action.payload.difficulty] = action.payload;
}
}
});

0 comments on commit a504a59

Please sign in to comment.