diff --git a/app.json b/app.json index 30aceda..c4e698a 100644 --- a/app.json +++ b/app.json @@ -3,7 +3,7 @@ "name": "suuudokuuu", "slug": "suuudokuuu", "scheme": "myapp", - "version": "1.3.5", + "version": "1.4.0", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", diff --git a/ios/suuudokuuu/Info.plist b/ios/suuudokuuu/Info.plist index 745a7e6..de3ee74 100644 --- a/ios/suuudokuuu/Info.plist +++ b/ios/suuudokuuu/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.3.5 + 1.4.0 CFBundleSignature ???? CFBundleURLTypes diff --git a/ios/suuudokuuu/Supporting/Expo.plist b/ios/suuudokuuu/Supporting/Expo.plist index 7eac422..91501c4 100644 --- a/ios/suuudokuuu/Supporting/Expo.plist +++ b/ios/suuudokuuu/Supporting/Expo.plist @@ -9,7 +9,7 @@ EXUpdatesLaunchWaitMs 0 EXUpdatesRuntimeVersion - 1.3.5 + 1.4.0 EXUpdatesURL https://u.expo.dev/4a70028a-5f9e-4ab6-9389-82d8b8b6c833 diff --git a/readme.md b/readme.md index 497bda3..fd7238b 100644 --- a/readme.md +++ b/readme.md @@ -22,7 +22,7 @@ Sudoku game to help Ukraine win the war against Russia. - [ ] add number flying to its stop? - [ ] add more fun to winner page(ZSU, Ukraine, donation CTA) - [ ] add more fun to looser page(ZSU, Ukraine, donation CTA) - - [ ] animation when finishing full row/col/group(score multiplies) + - [x] animation when finishing full row/col/group(score multiplies) - [ ] add successful run count and longest run count history on main screen? - [ ] add donation CTA on main screen - [x] add game logic: diff --git a/src/@generic/constants/animation.constant.ts b/src/@generic/constants/animation.constant.ts new file mode 100644 index 0000000..8ee089e --- /dev/null +++ b/src/@generic/constants/animation.constant.ts @@ -0,0 +1 @@ +export const animationDurationConstant = 150; diff --git a/src/@generic/styles/media-queries.ts b/src/@generic/styles/media-queries.ts index 9985385..5e6f78c 100644 --- a/src/@generic/styles/media-queries.ts +++ b/src/@generic/styles/media-queries.ts @@ -2,4 +2,4 @@ import { Dimensions } from 'react-native'; export const isMobileScreen = () => Dimensions.get('screen').width < 600; export const isTabletScreen = () => Dimensions.get('screen').width > 600; -export const isDesktopScreen = () => Dimensions.get('screen').width > 1000; +export const isDesktopScreen = () => Dimensions.get('screen').width > 1200; diff --git a/src/game/components/available-values-item/available-values-item.styles.ts b/src/game/components/available-values-item/available-values-item.styles.ts index 62c7818..e904037 100644 --- a/src/game/components/available-values-item/available-values-item.styles.ts +++ b/src/game/components/available-values-item/available-values-item.styles.ts @@ -1,6 +1,6 @@ import { StyleSheet } from 'react-native'; -import { Colors } from '../../../@generic/styles/theme'; +import { Colors } from '../../../@generic'; import { CellFontSizeConstant, CellSizeConstant } from '../../constants/dimensions.contant'; const progressHeight = 2; diff --git a/src/game/components/field-cell/cell.styles.ts b/src/game/components/field-cell/cell.styles.ts index 8751936..7346172 100644 --- a/src/game/components/field-cell/cell.styles.ts +++ b/src/game/components/field-cell/cell.styles.ts @@ -1,6 +1,6 @@ import { StyleSheet } from 'react-native'; -import { Colors } from '../../../@generic/styles/theme'; +import { Colors } from '../../../@generic'; import { CellSizeConstant, CellFontSizeConstant } from '../../constants/dimensions.contant'; // TODO: Add style theming support diff --git a/src/game/components/field-cell/cell.tsx b/src/game/components/field-cell/cell.tsx index 12b6c1a..e4e379f 100644 --- a/src/game/components/field-cell/cell.tsx +++ b/src/game/components/field-cell/cell.tsx @@ -1,10 +1,13 @@ import { cs, type OnEventFn } from '@rnw-community/shared'; -import { memo } from 'react'; -import { Pressable, Text } from 'react-native'; -import Reanimated, { interpolateColor, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; +import { memo, useEffect } from 'react'; +import { Pressable } from 'react-native'; +import Animated, { useSharedValue } from 'react-native-reanimated'; +import Reanimated, { interpolate, interpolateColor, useAnimatedStyle, useDerivedValue, withTiming } from 'react-native-reanimated'; import { Colors } from '../../../@generic'; +import { animationDurationConstant } from '../../../@generic/constants/animation.constant'; import { BlankCellValueConstant } from '../../constants/blank-cell-value.constant'; +import { CellFontSizeConstant } from '../../constants/dimensions.contant'; import { FieldGroupWidthConstant, FieldSizeConstant } from '../../constants/field.constant'; import { type CellInterface } from '../../interfaces/cell.interface'; @@ -19,6 +22,7 @@ const isGroupEnd = (index: number): boolean => { interface Props { cell: CellInterface; onSelect: OnEventFn; + isScored: boolean; isActive: boolean; isActiveValue: boolean; isCellHighlighted: boolean; @@ -34,17 +38,43 @@ const getCellBgColor = (isActiveValue: boolean, isCellHighlighted: boolean) => { return Colors.white; }; -const CellComponent = ({ cell, onSelect, isActive, isActiveValue, isCellHighlighted }: Props) => { +// TODO: We need animations logic improvements +const CellComponent = ({ cell, onSelect, isActive, isActiveValue, isCellHighlighted, isScored = true }: Props) => { const value = cell.value === BlankCellValueConstant ? '' : cell.value.toString(); const isLastRow = cell.y === 8; const isLastCol = cell.x === 8; const backgroundColor = getCellBgColor(isActiveValue, isCellHighlighted); - const progress = useSharedValue(0); - const animatedStyles = useAnimatedStyle(() => ({ - backgroundColor: interpolateColor(progress.value, [0, 1], [backgroundColor, Colors.cell.active]) - })); - progress.value = withTiming(isActive ? 1 : 0, { duration: 200 }); + const activeAnimation = useDerivedValue(() => { + return withTiming(isActive ? 1 : 0, { duration: animationDurationConstant }); + }); + const scoreAnimation = useSharedValue(0); + + useEffect(() => { + if (isScored) { + scoreAnimation.value = 0; + scoreAnimation.value = withTiming(1, { duration: 8 * animationDurationConstant }); + } + }, [isScored]); + + const cellAnimatedStyles = useAnimatedStyle( + () => ({ + backgroundColor: interpolateColor(activeAnimation.value, [0, 1], [backgroundColor, Colors.cell.active]) + }), + [backgroundColor] + ); + const textAnimatedStyles = useAnimatedStyle( + () => ({ + color: interpolateColor(scoreAnimation.value, [0, 0.5, 1], [Colors.black, Colors.cell.highlightedText, Colors.black]), + fontSize: interpolate( + scoreAnimation.value, + [0, 0.5, 1], + [CellFontSizeConstant, CellFontSizeConstant * 2, CellFontSizeConstant] + ), + transform: [{ rotate: `${interpolate(scoreAnimation.value, [0, 1], [0, 360])}deg` }] + }), + [backgroundColor] + ); const handlePress = () => void onSelect(isActive ? undefined : cell); @@ -54,13 +84,18 @@ const CellComponent = ({ cell, onSelect, isActive, isActiveValue, isCellHighligh cs(isGroupEnd(cell.y), styles.cellGroupYEnd), cs(isLastRow, styles.cellLastRow), cs(isLastCol, styles.cellLastCol), - animatedStyles + cellAnimatedStyles + ]; + const textStyles = [ + styles.cellText, + cs(isActiveValue, styles.cellActiveValueText), + cs(isActive, styles.cellActiveText), + textAnimatedStyles ]; - const textStyles = [styles.cellText, cs(isActiveValue, styles.cellActiveValueText), cs(isActive, styles.cellActiveText)]; return ( - {value} + {value} ); }; diff --git a/src/game/components/field/field.styles.ts b/src/game/components/field/field.styles.ts index 33f4741..73ec632 100644 --- a/src/game/components/field/field.styles.ts +++ b/src/game/components/field/field.styles.ts @@ -1,6 +1,6 @@ import { Platform, StyleSheet } from 'react-native'; -import { isMobileScreen } from '../../../@generic'; +import { isMobileScreen, isTabletScreen } from '../../../@generic'; export const FieldStyles = StyleSheet.create({ row: { @@ -10,7 +10,7 @@ export const FieldStyles = StyleSheet.create({ alignItems: 'center', flex: 5, flexDirection: 'column', - justifyContent: isMobileScreen() ? 'center' : 'flex-end', + justifyContent: isMobileScreen() || isTabletScreen() ? 'center' : 'flex-end', margin: 'auto', ...(Platform.OS === 'web' && { flex: 7 diff --git a/src/game/components/field/field.tsx b/src/game/components/field/field.tsx index e29bf5b..d787e3a 100644 --- a/src/game/components/field/field.tsx +++ b/src/game/components/field/field.tsx @@ -17,19 +17,24 @@ const isSameCell = (cell: CellInterface, selectedCell?: CellInterface): boolean const isSameCellValue = (cell: CellInterface, selectedCell?: CellInterface): boolean => isDefined(selectedCell) && cell.value === selectedCell?.value && cell.value !== BlankCellValueConstant; +const isScoredCell = (cell: CellInterface, scoredCells?: CellInterface): boolean => + isDefined(scoredCells) && (scoredCells.x === cell.x || scoredCells.y === cell.y || scoredCells.group === cell.group); + interface Props { + scoredCells?: CellInterface; field: FieldInterface; selectedCell?: CellInterface; onSelect: OnEventFn; } -export const Field = ({ field, selectedCell, onSelect }: Props) => { +export const Field = ({ field, selectedCell, onSelect, scoredCells }: Props) => { return ( {field.map(row => ( {row.map(cell => ( { const mistakes = useAppSelector(gameMistakesSelector); const currentScore = useAppSelector(gameScoreSelector); const startedAt = useAppSelector(gameStartedAtSelector); + const scoredCells = useAppSelector(gameScoredCellsSelector); const handleWin = async () => { if (!hasBlankCells(field)[0]) { - await hapticImpact(ImpactFeedbackStyle.Heavy); - router.push('winner'); + // HINT: We need to wait for the animation to finish + setTimeout(() => void router.push('winner'), 10 * animationDurationConstant); } }; const handleLose = async () => { if (mistakes >= MaxMistakesConstant) { - await hapticImpact(ImpactFeedbackStyle.Heavy); router.push('loser'); } }; @@ -80,7 +80,7 @@ export const GameScreen = () => { - + diff --git a/src/game/interfaces/game.interface.ts b/src/game/interfaces/game.interface.ts index 17f4dbe..b9dbc6a 100644 --- a/src/game/interfaces/game.interface.ts +++ b/src/game/interfaces/game.interface.ts @@ -1,5 +1,4 @@ -import { InitialDateConstant } from '../../@generic/constants/date.constant'; -import { DifficultyEnum } from '../../@generic/enums/difficulty.enum'; +import { InitialDateConstant, DifficultyEnum } from '../../@generic'; import { type CellInterface } from './cell.interface'; import { type FieldInterface } from './field.interface'; @@ -18,7 +17,7 @@ export interface GameInterface { filledField: FieldInterface; gameField: FieldInterface; selectedCell?: CellInterface; - selectedValue?: number; + scoredCells?: CellInterface; } export const emptyGame: GameInterface = { @@ -31,5 +30,6 @@ export const emptyGame: GameInterface = { filledField: [], gameField: [], mistakes: 0, - score: 0 + score: 0, + scoredCells: undefined }; diff --git a/src/game/store/actions/game-select-value.action.ts b/src/game/store/actions/game-select-value.action.ts index 7a29027..5fa40da 100644 --- a/src/game/store/actions/game-select-value.action.ts +++ b/src/game/store/actions/game-select-value.action.ts @@ -1,14 +1,26 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import { isDefined } from '@rnw-community/shared'; import * as Haptics from 'expo-haptics'; +import { ImpactFeedbackStyle } from 'expo-haptics'; import { type AppDispatch, type RootState } from '../../../@app-root'; -import { hapticNotification } from '../../../@generic'; -import { historyRecordAction } from '../../../history/store/history.actions'; +import { hapticImpact, hapticNotification } from '../../../@generic'; +import { historyRecordAction } from '../../../history'; import { BlankCellValueConstant } from '../../constants/blank-cell-value.constant'; +import { MaxMistakesConstant } from '../../constants/max-mistakes.constant'; import { calculateScore } from '../../utils/calculate-score.util'; +import { createCell } from '../../utils/create-cell.util'; import { hasBlankCells } from '../../utils/field/has-blank-cells.util'; -import { gameFinishAction, gameIncreaseScoreAction, gameMadeAMistakeAction, gameSetValueAction } from '../game.actions'; +import { hasValueInColumn } from '../../utils/field/has-value-in-column.util'; +import { hasValueInGroup } from '../../utils/field/has-value-in-group.util'; +import { hasValueInRow } from '../../utils/field/has-value-in-row.util'; +import { + gameFinishAction, + gameIncreaseScoreAction, + gameMadeAMistakeAction, + gameSetScoredCellsAction, + gameSetValueAction +} from '../game.actions'; export const gameSelectValueAction = createAsyncThunk( 'game/selectValue', @@ -27,10 +39,27 @@ export const gameSelectValueAction = createAsyncThunk= MaxMistakesConstant) { + await hapticImpact(ImpactFeedbackStyle.Heavy); + } } } diff --git a/src/game/store/game.actions.ts b/src/game/store/game.actions.ts index b00a6a1..5affbc3 100644 --- a/src/game/store/game.actions.ts +++ b/src/game/store/game.actions.ts @@ -7,5 +7,6 @@ export const { madeAMistake: gameMadeAMistakeAction, finish: gameFinishAction, reset: gameResetAction, - increaseScore: gameIncreaseScoreAction + increaseScore: gameIncreaseScoreAction, + setScoredCells: gameSetScoredCellsAction } = gameSlice.actions; diff --git a/src/game/store/game.selectors.ts b/src/game/store/game.selectors.ts index 7fb2562..313504e 100644 --- a/src/game/store/game.selectors.ts +++ b/src/game/store/game.selectors.ts @@ -11,3 +11,4 @@ export const gameMistakesSelector = createSelector(gameSelector, state => state. export const gameStartedAtSelector = createSelector(gameSelector, state => new Date(state.startedAt)); export const gameEndedAtSelector = createSelector(gameSelector, state => new Date(state.endedAt)); export const gameScoreSelector = createSelector(gameSelector, state => state.score); +export const gameScoredCellsSelector = createSelector(gameSelector, state => state.scoredCells); diff --git a/src/game/store/game.slice.ts b/src/game/store/game.slice.ts index cd7d716..81af0bd 100644 --- a/src/game/store/game.slice.ts +++ b/src/game/store/game.slice.ts @@ -40,6 +40,9 @@ export const gameSlice = createSlice({ }, madeAMistake: state => { state.mistakes++; + }, + setScoredCells: (state, action: PayloadAction) => { + state.scoredCells = action.payload; } } }); diff --git a/src/game/utils/field/has-value-in-column.util.ts b/src/game/utils/field/has-value-in-column.util.ts index 65c9b1e..f72e119 100644 --- a/src/game/utils/field/has-value-in-column.util.ts +++ b/src/game/utils/field/has-value-in-column.util.ts @@ -2,8 +2,8 @@ import { type CellInterface } from '../../interfaces/cell.interface'; import { type FieldInterface } from '../../interfaces/field.interface'; export const hasValueInColumn = (cell: CellInterface, field: FieldInterface): boolean => { - for (let row = 0; row < field.length; row++) { - if (field[row][cell.x].value === cell.value) { + for (let y = 0; y < field.length; y++) { + if (field[y][cell.x].value === cell.value) { return true; } }