Skip to content

Commit

Permalink
feat: added score calculation and timer
Browse files Browse the repository at this point in the history
  • Loading branch information
vitalyiegorov committed May 26, 2023
1 parent 8a31d4c commit b4464dd
Show file tree
Hide file tree
Showing 34 changed files with 229 additions and 59 deletions.
6 changes: 5 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ module.exports = {
jsx: true
}
},
settings: {},
settings: {
react: {
version: 'detect'
}
},
plugins: ['react', 'react-native', 'import', 'prettier'],
rules: {
'no-void': 'off',
Expand Down
11 changes: 9 additions & 2 deletions app/game/game.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,18 @@ export const GameStyles = StyleSheet.create({
flexDirection: 'row',
justifyContent: 'space-between'
},
controlsWrapper: {
alignItems: 'center'
},
headerText: {
color: Colors.black
},
mistakesCountText: {
color: Colors.black,
fontWeight: 'bold'
},
mistakesText: {
color: Colors.black
scoreText: {
color: Colors.black,
fontWeight: 'bold'
}
});
25 changes: 21 additions & 4 deletions app/game/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,20 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { Alert } from '../../components/alert/alert';
import { AvailableValues } from '../../components/available-values/available-values';
import { BlackButton } from '../../components/black-button/black-button';
import { ElapsedTime } from '../../components/elapsed-time/elapsed-time';
import { Field } from '../../components/field/field';
import { PageHeader } from '../../components/page-header/page-header';
import { MaxMistakesConstant } from '../../constants/max-mistakes.constant';
import { useAppDispatch, useAppSelector } from '../../hooks/redux.hook';
import { type CellInterface } from '../../interfaces/cell.interface';
import { appRootResetAction, appRootSelectCellAction } from '../../store/app-root/app-root.actions';
import { appRootFieldSelector, appRootMistakesSelector, appRootSelectedCellSelector } from '../../store/app-root/app-root.selectors';
import {
appRootFieldSelector,
appRootGameStartedAtSelector,
appRootMistakesSelector,
appRootScoreSelector,
appRootSelectedCellSelector
} from '../../store/app-root/app-root.selectors';
import { hasBlankCells } from '../../utils/field/has-blank-cells.util';
import { hapticImpact } from '../../utils/haptic.utils';

Expand All @@ -27,6 +34,8 @@ export default function Game() {
const field = useAppSelector(appRootFieldSelector);
const selectedCell = useAppSelector(appRootSelectedCellSelector);
const mistakes = useAppSelector(appRootMistakesSelector);
const currentScore = useAppSelector(appRootScoreSelector);
const startedAt = useAppSelector(appRootGameStartedAtSelector);

const handleWin = async () => {
if (!hasBlankCells(field)[0]) {
Expand Down Expand Up @@ -63,12 +72,20 @@ export default function Game() {
<SafeAreaView style={styles.container}>
<PageHeader title="Be wise, be smart, be quick..." />
<View style={styles.controls}>
<Text style={styles.mistakesText}>
Mistakes: <Text style={styles.mistakesCountText}>{mistakes}</Text> / {MaxMistakesConstant}
</Text>
<View style={styles.controlsWrapper}>
<Text style={styles.headerText}>Mistakes</Text>
<Text style={styles.headerText}>
<Text style={styles.mistakesCountText}>{mistakes}</Text> / {MaxMistakesConstant}
</Text>
</View>
<View style={styles.controlsWrapper}>
<Text style={styles.headerText}>Score</Text>
<Text style={styles.scoreText}>{currentScore}</Text>
</View>
<BlackButton text="Exit" onPress={handleExit} />
</View>
<Field field={field} selectedCell={selectedCell} onSelect={handleSelectCell} />
<ElapsedTime startedAt={startedAt} />
<AvailableValues />
</SafeAreaView>
);
Expand Down
5 changes: 5 additions & 0 deletions app/winner/index.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import { View } from 'react-native';
import { useSelector } from 'react-redux';

import { Header } from '../../components/header/header';
import { PageHeader } from '../../components/page-header/page-header';
import { PlayAgainButton } from '../../components/play-again-button/play-again-button';
import { appRootScoreSelector } from '../../store/app-root/app-root.selectors';

import { WinnerStyles as styles } from './winner.styles';

export default function Winner() {
const score = useSelector(appRootScoreSelector);

return (
<View style={styles.container}>
<PageHeader title="Winner!" />
<Header text="Winner-winner, chicken dinner!" />
<Header text={`You have scored: ${score} points`} />
<PlayAgainButton />
</View>
);
Expand Down
5 changes: 3 additions & 2 deletions components/available-values/available-values.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { View } from 'react-native';
import { useSelector } from 'react-redux';

import { BlankCellValueConstant } from '../../constants/blank-cell-value.constant';
import { FieldSizeConstant } from '../../constants/field.constant';
import { useAppDispatch } from '../../hooks/redux.hook';
import { appRootSelectValueAction } from '../../store/app-root/actions/app-root-select-value.action';
import {
Expand All @@ -14,10 +15,10 @@ import { AvailableValuesItem } from '../available-values-item/available-values-i

import { AvailableValuesStyles as styles } from './available-values.styles';

const getValueProgress = (allValues: Record<number, number>, value: number) => (allValues[value] / 9) * 100;
const getValueProgress = (allValues: Record<number, number>, value: number) => (allValues[value] / FieldSizeConstant) * 100;
const getAvailableValues = (allValues: Record<number, number>) =>
Object.keys(allValues)
.filter(key => allValues[Number(key)] < 9)
.filter(key => allValues[Number(key)] < FieldSizeConstant)
.map(Number);

export const AvailableValues = () => {
Expand Down
14 changes: 14 additions & 0 deletions components/elapsed-time/elapsed-time.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { StyleSheet } from 'react-native';

import { Colors } from '../theme';

export const ElapsedTimeStyles = StyleSheet.create({
container: {
alignItems: 'center',
flex: 1,
justifyContent: 'center'
},
text: {
color: Colors.black
}
});
36 changes: 36 additions & 0 deletions components/elapsed-time/elapsed-time.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { differenceInSeconds, format, setMinutes, setSeconds } from 'date-fns';
import { useEffect, useState } from 'react';
import { Text, View } from 'react-native';

import { ElapsedTimeStyles as styles } from './elapsed-time.styles';

const getElapsedTime = (start: Date) => (end: Date) => {
const currentTime = new Date();

const remainingSeconds = differenceInSeconds(end, start);
const minutes = Math.floor(remainingSeconds / 60);
const seconds = remainingSeconds % 60;

return format(setMinutes(setSeconds(currentTime, seconds), minutes), 'mm:ss');
};

interface Props {
startedAt: Date;
}

export const ElapsedTime = ({ startedAt }: Props) => {
const [currentTime, setCurrentTime] = useState(new Date());
const elapsedFormatted = getElapsedTime(startedAt);

useEffect(() => {
const handle = setInterval(() => setCurrentTime(new Date()), 1000);

return () => clearTimeout(handle);
}, []);

return (
<View style={styles.container}>
<Text style={styles.text}>({elapsedFormatted(currentTime)})</Text>
</View>
);
};
2 changes: 1 addition & 1 deletion components/field/field.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export const FieldStyles = StyleSheet.create({
},
wrapper: {
alignItems: 'center',
flex: 5,
flex: 7,
flexDirection: 'column',
justifyContent: 'center',
margin: 'auto',
Expand Down
3 changes: 2 additions & 1 deletion components/field/field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type OnEventFn } from '@rnw-community/shared';
import { View } from 'react-native';

import { type CellInterface } from '../../interfaces/cell.interface';
import { type FieldInterface } from '../../interfaces/field.interface';
import { isCellHighlighted } from '../../utils/cell/is-cell-highlighted.util';
import { isSameCellValue } from '../../utils/cell/is-same-cell-value.util';
import { isSameCell } from '../../utils/cell/is-same-cell.util';
Expand All @@ -10,7 +11,7 @@ import { Cell } from '../field-cell/cell';
import { FieldStyles as styles } from './field.styles';

interface Props {
field: CellInterface[][];
field: FieldInterface;
selectedCell?: CellInterface;
onSelect: OnEventFn<CellInterface | undefined>;
}
Expand Down
3 changes: 3 additions & 0 deletions constants/field.constant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const FieldSizeConstant = 9;
export const FieldCellCountConstant = FieldSizeConstant * FieldSizeConstant;
export const FieldGroupSizeConstant = 3;
6 changes: 6 additions & 0 deletions constants/score.constant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const ScoreCorrectValueConstant = 500;
export const ScoreElapsedCoefficientConstant = 0.001;
export const ScoreMistakesCoefficientConstant = 0.05;
export const ScoreLastInRowCoefficientConstant = 2;
export const ScoreLastInColCoefficientConstant = 3;
export const ScoreLastInGroupCoefficientConstant = 3;
12 changes: 7 additions & 5 deletions enums/difficulty.enum.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { FieldCellCountConstant } from '../constants/field.constant';

export enum DifficultyEnum {
Newbie = 'Newbie',
Easy = 'Easy',
Expand All @@ -7,9 +9,9 @@ export enum DifficultyEnum {
}

export const difficultyValues = {
[DifficultyEnum.Newbie]: Math.ceil(9 * 9 * 0.1),
[DifficultyEnum.Easy]: Math.ceil(9 * 9 * 0.2),
[DifficultyEnum.Medium]: Math.ceil(9 * 9 * 0.4),
[DifficultyEnum.Hard]: Math.ceil(9 * 9 * 0.5),
[DifficultyEnum.Nightmare]: Math.ceil(9 * 9 * 0.7)
[DifficultyEnum.Newbie]: Math.ceil(FieldCellCountConstant * 0.1),
[DifficultyEnum.Easy]: Math.ceil(FieldCellCountConstant * 0.2),
[DifficultyEnum.Medium]: Math.ceil(FieldCellCountConstant * 0.4),
[DifficultyEnum.Hard]: Math.ceil(FieldCellCountConstant * 0.5),
[DifficultyEnum.Nightmare]: Math.ceil(FieldCellCountConstant * 0.8)
};
3 changes: 3 additions & 0 deletions interfaces/field.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { type CellInterface } from './cell.interface';

export type FieldInterface = CellInterface[][];
5 changes: 3 additions & 2 deletions interfaces/game-state.interface.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { InitialDateConstant } from '../constants/date.constant';

import { type CellInterface } from './cell.interface';
import { type FieldInterface } from './field.interface';

/**
* General game state, used for logic and persisting
Expand All @@ -11,8 +12,8 @@ export interface GameStateInterface {
calculatedAt: Date;
startedAt: Date;
completionPercent: number;
filledField: CellInterface[][];
gameField: CellInterface[][];
filledField: FieldInterface;
gameField: FieldInterface;
selectedCell?: CellInterface;
selectedValue?: number;
}
Expand Down
18 changes: 7 additions & 11 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,15 @@ Sudoku game to help Ukraine win the war against Russia.
- [ ] add gamification and percentage of completeness

### Frontend
- [ ] add game logic:
- [ ] timer
- [ ] score and its calculation based on errors, timer, row/col/group finish
Scoring logic v1:
- base is `100` points for correctly solved cell
- with each second(10 seconds?) score is decreased by `1` point(check min value of `1`)
- with each mistake score is decreased by `10` points(check min value of `1`)
- with each row/col/group solved score is increased by `100` points
- [ ] add animations
- [ ] 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)
- [ ] 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)
- [ ] add successful run count and longest run count history on main screen?
- [ ] add donation CTA on main screen
- [x] add game logic:
- [x] timer
- [x] score and its calculation based on errors, timer, row/col/group finish
- [x] optimize rendering(why does it lag? =)

#### Web
Expand All @@ -45,6 +40,7 @@ Sudoku game to help Ukraine win the war against Russia.
- [ ] setup github actions for PRs, create web, expo previews
- [ ] add e2e tests(maestro)
- [ ] setup android build and deployment
- [ ] setup [eas submit](https://docs.expo.dev/submit/eas-json/) credentials and github action
- [x] setup eas
- [x] setup iphone deployment
- [x] add github actions
7 changes: 6 additions & 1 deletion store/app-root/actions/app-root-select-value.action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { isDefined } from '@rnw-community/shared';
import * as Haptics from 'expo-haptics';

import { BlankCellValueConstant } from '../../../constants/blank-cell-value.constant';
import { calculateScore } from '../../../utils/calculate-score.util';
import { hapticNotification } from '../../../utils/haptic.utils';
import { type AppDispatch, type RootState } from '../../app.store';
import { appRootMadeAMistake, appRootSetValueAction } from '../app-root.actions';
import { appRootIncreaseScoreAction, appRootMadeAMistake, appRootSetValueAction } from '../app-root.actions';

export const appRootSelectValueAction = createAsyncThunk<boolean, number, { dispatch: AppDispatch; state: RootState }>(
'appRoot/selectValue',
Expand All @@ -19,6 +20,10 @@ export const appRootSelectValueAction = createAsyncThunk<boolean, number, { disp

await hapticNotification(Haptics.NotificationFeedbackType.Success);

thunkAPI.dispatch(
appRootIncreaseScoreAction(calculateScore(state.filledField, state.selectedCell, state.mistakes, state.startedAt))
);

return true;
} else {
thunkAPI.dispatch(appRootMadeAMistake());
Expand Down
3 changes: 2 additions & 1 deletion store/app-root/app-root.actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ export const {
selectCell: appRootSelectCellAction,
setValue: appRootSetValueAction,
madeAMistake: appRootMadeAMistake,
reset: appRootResetAction
reset: appRootResetAction,
increaseScore: appRootIncreaseScoreAction
} = appRootSlice.actions;
1 change: 1 addition & 0 deletions store/app-root/app-root.selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ export const appRootSelectedValueSelector = createSelector(appRootSelector, stat
export const appRootAvailableValuesSelector = createSelector(appRootSelector, state => getAvailableFieldValues(state.gameField));
export const appRootMistakesSelector = createSelector(appRootSelector, state => state.mistakes);
export const appRootGameStartedAtSelector = createSelector(appRootSelector, state => new Date(state.startedAt));
export const appRootScoreSelector = createSelector(appRootSelector, state => state.score);
6 changes: 5 additions & 1 deletion store/app-root/app-root.slice.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';

import { FieldSizeConstant } from '../../constants/field.constant';
import { type DifficultyEnum, difficultyValues } from '../../enums/difficulty.enum';
import { type CellInterface } from '../../interfaces/cell.interface';
import { createField } from '../../utils/field/create-field.util';
Expand All @@ -14,7 +15,7 @@ export const appRootSlice = createSlice({
load: (state, action: PayloadAction<DifficultyEnum>) => {
const blankCellsCount = difficultyValues[action.payload];

state.filledField = createField(9);
state.filledField = createField(FieldSizeConstant);
state.gameField = createGameField(state.filledField, blankCellsCount);
state.startedAt = new Date();
},
Expand All @@ -33,6 +34,9 @@ export const appRootSlice = createSlice({
state.selectedCell = state.gameField[cell.y][cell.x];
state.gameField[cell.y][cell.x].value = cell.value;
},
increaseScore: (state, action: PayloadAction<number>) => {
state.score += action.payload;
},
madeAMistake: state => {
state.mistakes++;
}
Expand Down

0 comments on commit b4464dd

Please sign in to comment.