Skip to content

Commit

Permalink
Merge pull request #1 from fgandellini/obstacles
Browse files Browse the repository at this point in the history
Add support for obstacles
  • Loading branch information
fgandellini authored Jun 9, 2024
2 parents 363a2bb + 272d6e6 commit 24e7cae
Show file tree
Hide file tree
Showing 23 changed files with 651 additions and 244 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ When it receives the `move` action, it checks if the game is won or lost and tra

## 🕹️ Gameplay

The game starts with a Welcome screen where the user can select the game theme and difficulty.
The game starts with a Welcome screen where the user can select the game theme, the board size and the number of obstacles to place on the board.

![Welcome Screen](https://github.com/fgandellini/2048/blob/main/screenshots/WelcomeScreen.png?raw=true)

Expand Down Expand Up @@ -122,7 +122,7 @@ The game engine is tested using [vitest](https://vitest.dev/).
To run its tests in watch mode run:

```sh
$ npm t --workspace @2048/game-engine
$ npm run test:watch --workspace @2048/game-engine
```

#### React Application
Expand All @@ -132,7 +132,7 @@ The React application is tested using [Jest](https://jestjs.io/) and [Testing Li
To run its tests in watch mode run:

```sh
$ npm t --workspace @2048/react
$ npm run test:watch --workspace @2048/react
```

## 🙏 Credits
Expand Down
1 change: 1 addition & 0 deletions apps/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"scripts": {
"dev": "parcel src/index.html",
"test": "jest",
"test:watch": "jest --watch",
"build": "parcel build --public-url=. src/index.html"
},
"keywords": [
Expand Down
2 changes: 1 addition & 1 deletion apps/react/src/components/board/Board.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ describe('Board', () => {
expect(container.getElementsByClassName('board').length).toBe(1)
expect(container.getElementsByClassName('board-classic').length).toBe(1)
expect(container.getElementsByClassName('tile').length).toBe(1)
expect(container.getElementsByClassName('empty-tile').length).toBe(3)
expect(container.getElementsByClassName('empty-cell').length).toBe(3)
})
})
39 changes: 29 additions & 10 deletions apps/react/src/components/board/Board.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { Board as GameBoard } from '@2048/game-engine/src/board'
import {
Board as GameBoard,
isObstacle,
isTile,
} from '@2048/game-engine/src/board'
import { useEffect } from 'react'
import { Theme, getTheme } from '../../game-logic/theme-manager'
import { Tile } from './Tile'
Expand All @@ -16,7 +20,7 @@ type Props = {
* When the component is mounted, it sets the size of the
* board based on the size of the grid using CSS custom properties.
*
* The board is rendered as a grid of tiles.
* The board is rendered as a grid of tiles, obstacles or empty cells.
* Empty tiles are used to fill the (CSS) grid.
*/

Expand All @@ -33,15 +37,30 @@ export const Board = ({ theme, board }: Props) => {
className={`board board-${theme}`}
style={{ backgroundColor: getTheme(theme).boardColor }}
>
{board.grid.map((tile, index) =>
tile !== null ? (
<Tile key={index} theme={theme} value={tile.value} />
) : (
<EmptyTile key={index} />
),
)}
{board.grid.map((cell, index) => {
if (isTile(cell)) {
return <Tile key={index} theme={theme} value={cell.value} />
}
if (isObstacle(cell)) {
return <Obstacle key={index} theme={theme} />
}
return <EmptyCell key={index} />
})}
</div>
)
}

const EmptyTile = () => <div className="empty-tile" />
const EmptyCell = () => <div className="empty-cell" />

type ObstacleProps = { theme: Theme }
export const Obstacle = ({ theme }: ObstacleProps) => {
if (theme === 'classic') {
return <div className="obstacle obstacle-classic">X</div>
}
if (theme === 'plants') {
return <div className="obstacle obstacle-plants">🪨</div>
}
if (theme === 'blind') {
return <div className="obstacle obstacle-blind" />
}
}
12 changes: 3 additions & 9 deletions apps/react/src/components/board/Tile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const ClassicTile = ({ value }: { value: number }) => {
const theme = getTheme('classic')
const color = theme.tileColors.get(value)
return (
<div className="tile" style={{ color, borderColor: color }}>
<div className="tile tile-classic" style={{ color, borderColor: color }}>
{value}
</div>
)
Expand All @@ -41,7 +41,7 @@ const BlindTile = ({ value }: { value: number }) => {
const color = theme.tileColors.get(value)
return (
<div
className="tile"
className="tile tile-blind"
style={{ backgroundColor: color, borderColor: color }}
></div>
)
Expand All @@ -51,13 +51,7 @@ const PlantTile = ({ value }: { value: number }) => {
const theme = getTheme('plants')
const color = theme.tileColors.get(value)
return (
<div
className="tile"
style={{
borderColor: color,
fontSize: 'xx-large',
}}
>
<div className="tile tile-plants" style={{ borderColor: color }}>
{theme.tileIcons.get(value)}
</div>
)
Expand Down
41 changes: 41 additions & 0 deletions apps/react/src/components/welcome/ObstaclesSelector.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import '@testing-library/jest-dom'
import { render, screen } from '@testing-library/react'
import { ObstaclesSelector } from './ObstaclesSelector'

describe('ObstaclesSelector', () => {
test('Renders the obstacles selector', async () => {
const { container } = render(
<ObstaclesSelector obstacles={0} onSelectObstacles={() => {}} />,
)

expect(container.getElementsByClassName('obstacles-selector').length).toBe(
1,
)
expect(screen.getByText('SELECT OBSTACLES')).toBeInTheDocument()
expect(screen.getByText('Peaceful (none)')).toBeInTheDocument()
expect(screen.getByText('Easy (2)')).toBeInTheDocument()
expect(screen.getByText('Normal (4)')).toBeInTheDocument()
expect(screen.getByText('Hard (8)')).toBeInTheDocument()
})

test('Allows to select the number of obstacles', async () => {
const onSelectObstacles = jest.fn()

render(
<ObstaclesSelector obstacles={0} onSelectObstacles={onSelectObstacles} />,
)

const easy = screen.getByText('Easy (2)')
const normal = screen.getByText('Normal (4)')
const hard = screen.getByText('Hard (8)')

easy.click()
expect(onSelectObstacles).toHaveBeenCalledWith(2)

normal.click()
expect(onSelectObstacles).toHaveBeenCalledWith(4)

hard.click()
expect(onSelectObstacles).toHaveBeenCalledWith(8)
})
})
65 changes: 65 additions & 0 deletions apps/react/src/components/welcome/ObstaclesSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
type ObstaclesSelectorProps = {
obstacles: number
onSelectObstacles: (obstacles: number) => void
}

/**
* The obstacles selector
*
* It's responsible for rendering the obstacles selector.
* The obstacles selector allows the player to select the obstacles to include in the board.
*
* The user can choose how many obstacles to add to the board, the options are: none, 2, 4, or 8.
*/
export const ObstaclesSelector = ({
obstacles,
onSelectObstacles,
}: ObstaclesSelectorProps) => (
<div className="nes-container with-title is-dark obstacles-selector">
<h3 className="title">SELECT OBSTACLES</h3>
<label htmlFor="obstacles-0">
<input
type="radio"
className="nes-radio is-dark"
id="obstacles-0"
name="obstacles"
checked={obstacles === 0}
onChange={() => onSelectObstacles(0)}
/>
<span>Peaceful (none)</span>
</label>
<label htmlFor="obstacles-2">
<input
type="radio"
className="nes-radio is-dark"
id="obstacles-2"
name="obstacles"
checked={obstacles === 2}
onChange={() => onSelectObstacles(2)}
/>
<span>Easy (2)</span>
</label>
<label htmlFor="obstacles-4">
<input
type="radio"
className="nes-radio is-dark"
id="obstacles-4"
name="obstacles"
checked={obstacles === 4}
onChange={() => onSelectObstacles(4)}
/>
<span>Normal (4)</span>
</label>
<label htmlFor="obstacles-8">
<input
type="radio"
className="nes-radio is-dark"
id="obstacles-8"
name="obstacles"
checked={obstacles === 8}
onChange={() => onSelectObstacles(8)}
/>
<span>Hard (8)</span>
</label>
</div>
)
2 changes: 1 addition & 1 deletion apps/react/src/components/welcome/SizeSelector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ describe('SizeSelector', () => {
)

expect(container.getElementsByClassName('size-selector').length).toBe(1)
expect(screen.getByText('SELECT DIFFICULY')).toBeInTheDocument()
expect(screen.getByText('SELECT BOARD SIZE')).toBeInTheDocument()
expect(screen.getByText('Easy (8x8)')).toBeInTheDocument()
expect(screen.getByText('Normal (6x6)')).toBeInTheDocument()
expect(screen.getByText('Hard (4x4)')).toBeInTheDocument()
Expand Down
2 changes: 1 addition & 1 deletion apps/react/src/components/welcome/SizeSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const SizeSelector = ({ size, onSelectSize }: SizeSelectorProps) => {

return (
<div className="nes-container with-title is-dark size-selector">
<h3 className="title">SELECT DIFFICULY</h3>
<h3 className="title">SELECT BOARD SIZE</h3>
<label htmlFor="size-8">
<input
type="radio"
Expand Down
3 changes: 2 additions & 1 deletion apps/react/src/components/welcome/WelcomeScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ describe('WelcomeScreen', () => {

expect(onStart).toHaveBeenCalledWith({
type: 'game-started',
size: 6, // default size
theme: 'classic', // default theme
size: 6, // default size
obstacles: 0, // no obstacles
})
})
})
39 changes: 34 additions & 5 deletions apps/react/src/components/welcome/WelcomeScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { useState } from 'react'
import { useCallback, useState } from 'react'
import { GameStartedAction } from '../../game-logic/app-manager'
import { Theme } from '../../game-logic/theme-manager'
import { ObstaclesSelector } from './ObstaclesSelector'
import { SizeSelector } from './SizeSelector'
import { ThemeSelector } from './ThemeSelector'

const DEFAULT_SIZE = 6
const DEFAULT_THEME = 'classic'
const DEFAULT_SIZE = 6
const DEFAULT_OBSTACLES = 0

type Props = {
onStart: (action: GameStartedAction) => void
Expand All @@ -18,10 +20,17 @@ type Props = {
* The welcome screen allows the player to select the game size and theme.
*/
export const WelcomeScreen = ({ onStart }: Props) => {
const [size, setSize] = useState(DEFAULT_SIZE)
const [theme, setTheme] = useState<Theme>(DEFAULT_THEME)
const [size, setSize] = useState(DEFAULT_SIZE)
const [obstacles, setObstacles] = useState(DEFAULT_OBSTACLES)

const startGame = () =>
onStart({ type: 'game-started', theme, size, obstacles })

const startGame = () => onStart({ type: 'game-started', size, theme })
const canStartGame = useCallback(
() => canPlaceObstacles(size, obstacles),
[size, obstacles],
)

return (
<div className="welcome-screen">
Expand All @@ -31,9 +40,29 @@ export const WelcomeScreen = ({ onStart }: Props) => {

<SizeSelector size={size} onSelectSize={setSize} />

<button className="nes-btn is-primary is-dark" onClick={startGame}>
<ObstaclesSelector
obstacles={obstacles}
onSelectObstacles={setObstacles}
/>

<button
className={`nes-btn is-primary is-dark ${
!canStartGame() ? 'is-disabled' : ''
}`}
onClick={startGame}
disabled={!canStartGame()}
>
Start Game!
</button>
</div>
)
}

/**
* Check if the number of obstacles can be placed on the board
* @param size the size of the board
* @param obstacles the number of obstacles to place
* @returns true if the obstacles can be placed on the board, false otherwise
*/
const canPlaceObstacles = (size: number, obstacles: number) =>
size ** 2 > obstacles
3 changes: 2 additions & 1 deletion apps/react/src/game-logic/app-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ describe('app-manager (app state machine)', () => {

const newState = appReducer(initialState, {
type: 'game-started',
size: 2,
theme: 'classic',
size: 2,
obstacles: 0,
})

expect(newState).toEqual({
Expand Down
5 changes: 3 additions & 2 deletions apps/react/src/game-logic/app-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ type AppState =

export type GameStartedAction = {
type: 'game-started'
size: number
theme: Theme
size: number
obstacles: number
}

export type RestartedAction = {
Expand Down Expand Up @@ -71,7 +72,7 @@ export const appReducer = (current: AppState, action: AppActions): AppState => {
return {
state: 'in-game',
theme: action.theme,
game: startGame(action.size),
game: startGame(action.size, action.obstacles),
}
default:
return current
Expand Down
24 changes: 12 additions & 12 deletions apps/react/src/game-logic/theme-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,18 +58,18 @@ export const getTheme = <T extends Theme>(theme: T): MatchingTheme<T> => {
name: 'blind',
boardColor: '#3C3A32',
tileColors: new Map([
[1, '#000000'],
[2, '#080808'],
[4, '#101010'],
[8, '#181818'],
[16, '#202020'],
[32, '#282828'],
[64, '#303030'],
[128, '#383838'],
[256, '#404040'],
[512, '#484848'],
[1024, '#505050'],
[2048, '#585858'],
[1, '#080808'],
[2, '#101010'],
[4, '#181818'],
[8, '#202020'],
[16, '#282828'],
[32, '#303030'],
[64, '#383838'],
[128, '#404040'],
[256, '#484848'],
[512, '#505050'],
[1024, '#585858'],
[2048, '#606060'],
]),
} as any
case 'plants':
Expand Down
Loading

0 comments on commit 24e7cae

Please sign in to comment.