Skip to content

Commit

Permalink
feat: added row/col/group completion animation
Browse files Browse the repository at this point in the history
  • Loading branch information
vitalyiegorov committed May 28, 2023
1 parent 0494eee commit 788b51c
Show file tree
Hide file tree
Showing 18 changed files with 117 additions and 40 deletions.
2 changes: 1 addition & 1 deletion app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion ios/suuudokuuu/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.3.5</string>
<string>1.4.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
Expand Down
2 changes: 1 addition & 1 deletion ios/suuudokuuu/Supporting/Expo.plist
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<key>EXUpdatesLaunchWaitMs</key>
<integer>0</integer>
<key>EXUpdatesRuntimeVersion</key>
<string>1.3.5</string>
<string>1.4.0</string>
<key>EXUpdatesURL</key>
<string>https://u.expo.dev/4a70028a-5f9e-4ab6-9389-82d8b8b6c833</string>
</dict>
Expand Down
2 changes: 1 addition & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions src/@generic/constants/animation.constant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const animationDurationConstant = 150;
2 changes: 1 addition & 1 deletion src/@generic/styles/media-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/game/components/field-cell/cell.styles.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
59 changes: 47 additions & 12 deletions src/game/components/field-cell/cell.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -19,6 +22,7 @@ const isGroupEnd = (index: number): boolean => {
interface Props {
cell: CellInterface;
onSelect: OnEventFn<CellInterface | undefined>;
isScored: boolean;
isActive: boolean;
isActiveValue: boolean;
isCellHighlighted: boolean;
Expand All @@ -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);

Expand All @@ -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 (
<ReanimatedPressable style={cellStyles} onPress={handlePress}>
<Text style={textStyles}>{value}</Text>
<Animated.Text style={textStyles}>{value}</Animated.Text>
</ReanimatedPressable>
);
};
Expand Down
4 changes: 2 additions & 2 deletions src/game/components/field/field.styles.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/game/components/field/field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<CellInterface | undefined>;
}

export const Field = ({ field, selectedCell, onSelect }: Props) => {
export const Field = ({ field, selectedCell, onSelect, scoredCells }: Props) => {
return (
<View style={styles.wrapper}>
{field.map(row => (
<View key={`row-${row[0].y}`} style={styles.row}>
{row.map(cell => (
<Cell
isScored={isScoredCell(cell, scoredCells)}
key={`cell-${cell.y}-${cell.x}`}
cell={cell}
onSelect={onSelect}
Expand Down
14 changes: 7 additions & 7 deletions src/game/components/game-screen/game-screen.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
/* eslint-disable react-native/no-raw-text */
import { ImpactFeedbackStyle } from 'expo-haptics';
import { useRouter } from 'expo-router';
import { useCallback, useEffect } from 'react';
import { Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';

import { Alert, BlackButton, PageHeader, useAppDispatch, useAppSelector, hapticImpact } from '../../../@generic';
import { Alert, BlackButton, PageHeader, useAppDispatch, useAppSelector } from '../../../@generic';
import { animationDurationConstant } from '../../../@generic/constants/animation.constant';
import { MaxMistakesConstant } from '../../constants/max-mistakes.constant';
import { type CellInterface } from '../../interfaces/cell.interface';
import { gameResetAction, gameSelectCellAction } from '../../store/game.actions';
import {
gameFieldSelector,
gameMistakesSelector,
gameScoredCellsSelector,
gameScoreSelector,
gameSelectedCellSelector,
gameStartedAtSelector
Expand All @@ -32,16 +32,16 @@ export const GameScreen = () => {
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');
}
};
Expand Down Expand Up @@ -80,7 +80,7 @@ export const GameScreen = () => {
</View>
<BlackButton text="Exit" onPress={handleExit} />
</View>
<Field field={field} selectedCell={selectedCell} onSelect={handleSelectCell} />
<Field field={field} selectedCell={selectedCell} onSelect={handleSelectCell} scoredCells={scoredCells} />
<GameTimer startedAt={startedAt} />
<AvailableValues />
</SafeAreaView>
Expand Down
8 changes: 4 additions & 4 deletions src/game/interfaces/game.interface.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -18,7 +17,7 @@ export interface GameInterface {
filledField: FieldInterface;
gameField: FieldInterface;
selectedCell?: CellInterface;
selectedValue?: number;
scoredCells?: CellInterface;
}

export const emptyGame: GameInterface = {
Expand All @@ -31,5 +30,6 @@ export const emptyGame: GameInterface = {
filledField: [],
gameField: [],
mistakes: 0,
score: 0
score: 0,
scoredCells: undefined
};
39 changes: 35 additions & 4 deletions src/game/store/actions/game-select-value.action.ts
Original file line number Diff line number Diff line change
@@ -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<boolean, number, { dispatch: AppDispatch; state: RootState }>(
'game/selectValue',
Expand All @@ -27,18 +39,37 @@ export const gameSelectValueAction = createAsyncThunk<boolean, number, { dispatc
await hapticNotification(Haptics.NotificationFeedbackType.Success);

thunkAPI.dispatch(gameIncreaseScoreAction(score));
// TODO: We should not finish the game on each correct step
thunkAPI.dispatch(gameFinishAction());

const newState2 = thunkAPI.getState().game;

const blankCell = { ...newCell, value: BlankCellValueConstant };
const scoredCells = createCell(-1, -1, BlankCellValueConstant);
if (!hasValueInColumn(blankCell, newState2.gameField)) {
scoredCells.x = blankCell.x;
}
if (!hasValueInRow(blankCell, newState2.gameField)) {
scoredCells.y = blankCell.y;
}
if (!hasValueInGroup(blankCell, newState2.gameField)) {
scoredCells.group = blankCell.group;
}
// TODO: Add win animation, probably lose animation?
thunkAPI.dispatch(gameSetScoredCellsAction(scoredCells));

if (!hasBlankCells(newState.gameField)[0]) {
await hapticImpact(ImpactFeedbackStyle.Heavy);
thunkAPI.dispatch(historyRecordAction(newState2));
}

return true;
} else {
thunkAPI.dispatch(gameMadeAMistakeAction());
await hapticNotification(Haptics.NotificationFeedbackType.Error);
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
if (state.mistakes >= MaxMistakesConstant) {
await hapticImpact(ImpactFeedbackStyle.Heavy);
}
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/game/store/game.actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ export const {
madeAMistake: gameMadeAMistakeAction,
finish: gameFinishAction,
reset: gameResetAction,
increaseScore: gameIncreaseScoreAction
increaseScore: gameIncreaseScoreAction,
setScoredCells: gameSetScoredCellsAction
} = gameSlice.actions;
1 change: 1 addition & 0 deletions src/game/store/game.selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
3 changes: 3 additions & 0 deletions src/game/store/game.slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export const gameSlice = createSlice({
},
madeAMistake: state => {
state.mistakes++;
},
setScoredCells: (state, action: PayloadAction<CellInterface>) => {
state.scoredCells = action.payload;
}
}
});
4 changes: 2 additions & 2 deletions src/game/utils/field/has-value-in-column.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down

0 comments on commit 788b51c

Please sign in to comment.