From d4cd1c0ca45894ff5fe031809b1a1a66927e38b0 Mon Sep 17 00:00:00 2001 From: Alexis LUCAS Date: Mon, 1 Apr 2024 19:39:33 +0200 Subject: [PATCH 1/3] Added dice pool game mode - Used it by default for now, front changes will come later --- packages/common/src/classes/DicePool.ts | 40 +++++++++++++++++ packages/common/src/classes/GameState.ts | 45 +++++++++++++++++--- packages/common/src/classes/Player.ts | 9 ++++ packages/common/src/interfaces/IDicePool.ts | 3 ++ packages/common/src/interfaces/IGameState.ts | 3 ++ 5 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 packages/common/src/classes/DicePool.ts create mode 100644 packages/common/src/interfaces/IDicePool.ts diff --git a/packages/common/src/classes/DicePool.ts b/packages/common/src/classes/DicePool.ts new file mode 100644 index 0000000..b3ad808 --- /dev/null +++ b/packages/common/src/classes/DicePool.ts @@ -0,0 +1,40 @@ +import { type IDicePool } from '../interfaces/IDicePool' +import { getRandomIntInclusive } from '../utils' + +export class DicePool implements IDicePool { + pool: number[] + + constructor(pool?: number[]) { + this.pool = pool ?? this.generateDicePool(3) + } + + public getAndRemoveDieFromPool() { + const index = getRandomIntInclusive(0, this.pool.length - 1) + const die = this.pool[index] + this.pool.splice(index, 1) + return die + } + + public putDieBackInPool(die: number, occurrences: number) { + for (let occ = 0; occ < occurrences; occ++) { + this.pool.push(die) + } + } + + public toJson(): IDicePool { + return { + pool: this.pool + } + } + + // Could be in `random.ts`? + private generateDicePool(occurrencesPerValue: number) { + const pool: number[] = [] + for (let die = 1; die <= 6; die++) { + for (let occ = 0; occ < occurrencesPerValue; occ++) { + pool.push(die) + } + } + return pool + } +} diff --git a/packages/common/src/classes/GameState.ts b/packages/common/src/classes/GameState.ts index 9b5202b..67273aa 100644 --- a/packages/common/src/classes/GameState.ts +++ b/packages/common/src/classes/GameState.ts @@ -7,17 +7,22 @@ import { type BoType } from '../types' import { coinflip, getRandomDice, getWinHistory } from '../utils' +import { DicePool } from './DicePool' import { Log } from './Log' import { Player } from './Player' interface GameStateConstructorArg extends Partial< - Omit + Omit< + IGameState, + 'playerOne' | 'playerTwo' | 'logs' | 'nextPlayer' | 'dicePool' + > > { playerOne: Player playerTwo: Player nextPlayer?: Player logs?: Log[] + dicePool?: DicePool } export class GameState implements IGameState { @@ -31,6 +36,8 @@ export class GameState implements IGameState { outcome!: Outcome outcomeHistory: OutcomeHistory rematchVote?: string + gameMode: 'normal' | 'dice-pool' + dicePool?: DicePool constructor({ playerOne, @@ -39,6 +46,8 @@ export class GameState implements IGameState { outcome, rematchVote, winnerId, + gameMode = 'dice-pool', + dicePool, boType = 'indefinite', logs = [], spectators = [], @@ -52,6 +61,8 @@ export class GameState implements IGameState { this.outcomeHistory = outcomeHistory this.boType = boType this.winnerId = winnerId + this.gameMode = gameMode + this.dicePool = dicePool // Only assign these if we have one // otherwise they will be assigned in the initialize() method @@ -67,14 +78,22 @@ export class GameState implements IGameState { initialize(previousGameState?: IGameState) { this.outcome = 'ongoing' + // Ideally we would use a discriminated union to enforce having `dicePool` + // when `gameMode` is `dice-pool`, but it doesn't seem to be possible with + // classes. Also, if we move this to a function, we lose the type guard + // ability. + if (this.gameMode === 'dice-pool') { + this.dicePool = new DicePool() + } + const isPlayerOneStarting = coinflip() if (isPlayerOneStarting) { this.nextPlayer = this.playerOne - this.playerOne.dice = getRandomDice() + this.playerOne.dice = this.getRandomDice() } else { this.nextPlayer = this.playerTwo - this.playerTwo.dice = getRandomDice() + this.playerTwo.dice = this.getRandomDice() } if (previousGameState !== undefined) { @@ -104,11 +123,14 @@ export class GameState implements IGameState { )} column.` ) - playerTwo.removeDice(play.dice, play.column) + const removedDice = playerTwo.removeDice(play.dice, play.column) if (playerOne.areColumnsFilled()) { this.whoWins() } else { + if (this.gameMode === 'dice-pool' && this.dicePool !== undefined) { + this.dicePool.putDieBackInPool(play.dice, removedDice) + } this.nextTurn(play.author, giveNextDice) } } @@ -126,6 +148,13 @@ export class GameState implements IGameState { return false } + private getRandomDice() { + if (this.gameMode === 'dice-pool' && this.dicePool !== undefined) { + return this.dicePool.getAndRemoveDieFromPool() + } + return getRandomDice() + } + private getColumnName(column: number) { if (column === 0) { return 'left' @@ -154,7 +183,7 @@ export class GameState implements IGameState { playerOne.dice = undefined if (giveNextDice) { - playerTwo.dice = getRandomDice() + playerTwo.dice = this.getRandomDice() } this.nextPlayer = playerTwo @@ -217,6 +246,7 @@ export class GameState implements IGameState { playerTwo, nextPlayer, logs, + dicePool, ...rest }: IGameState) { return new GameState({ @@ -224,7 +254,8 @@ export class GameState implements IGameState { playerOne: Player.fromJson(playerOne), playerTwo: Player.fromJson(playerTwo), nextPlayer: Player.fromJson(nextPlayer), - logs: logs.map((iLog) => Log.fromJson(iLog)) + logs: logs.map((iLog) => Log.fromJson(iLog)), + dicePool: dicePool !== undefined ? new DicePool(dicePool.pool) : dicePool }) } @@ -233,6 +264,8 @@ export class GameState implements IGameState { playerOne: this.playerOne.toJson(), playerTwo: this.playerTwo.toJson(), logs: this.logs.map((log) => log.toJson()), + gameMode: this.gameMode, + dicePool: this.dicePool?.toJson(), outcome: this.outcome, nextPlayer: this.nextPlayer.toJson(), rematchVote: this.rematchVote, diff --git a/packages/common/src/classes/Player.ts b/packages/common/src/classes/Player.ts index 3c47523..0fc7d86 100644 --- a/packages/common/src/classes/Player.ts +++ b/packages/common/src/classes/Player.ts @@ -49,14 +49,23 @@ export class Player { } } + /** + * Returns how many dice have been removed from column + */ removeDice(dice: number, column: number) { if (column < 0 || column > 2) { throw new Error('Invalid column. Value must be between 0 and 2.') } + const columnSizeBefore = this.columns[column].length + this.columns[column] = this.columns[column].filter( (existingDice) => existingDice !== dice ) + + const columnSizeAfter = this.columns[column].length + + return columnSizeBefore - columnSizeAfter } areColumnsFilled(): boolean { diff --git a/packages/common/src/interfaces/IDicePool.ts b/packages/common/src/interfaces/IDicePool.ts new file mode 100644 index 0000000..d4f1023 --- /dev/null +++ b/packages/common/src/interfaces/IDicePool.ts @@ -0,0 +1,3 @@ +export interface IDicePool { + pool: number[] +} diff --git a/packages/common/src/interfaces/IGameState.ts b/packages/common/src/interfaces/IGameState.ts index 24fc15a..eb759b6 100644 --- a/packages/common/src/interfaces/IGameState.ts +++ b/packages/common/src/interfaces/IGameState.ts @@ -1,4 +1,5 @@ import { type BoType, type Outcome, type OutcomeHistory } from '../types' +import { type IDicePool } from './IDicePool' import { type ILog } from './ILog' import { type IPlayer } from './IPlayer' @@ -13,4 +14,6 @@ export interface IGameState { outcome: Outcome outcomeHistory: OutcomeHistory rematchVote?: string + gameMode: 'normal' | 'dice-pool' + dicePool?: IDicePool } From 79ddbe543b6064724c4ab0316c563999273f92f8 Mon Sep 17 00:00:00 2001 From: Alexis LUCAS Date: Tue, 2 Apr 2024 01:12:28 +0200 Subject: [PATCH 2/3] Added front for game mode - Added game mode in game settings and menu - Added info explaining differences between game modes - Added popover component - Added translations - Added game mode in lobby - Bumped dev dependencies --- apps/front/package.json | 1 + apps/front/src/components/Button.tsx | 9 +- apps/front/src/components/Footer.tsx | 9 +- apps/front/src/components/Game.tsx | 9 +- .../components/GameContext/useGameSetup.ts | 9 +- apps/front/src/components/GameMode.tsx | 19 + .../GameSettings/GameSettingsModal.tsx | 24 +- .../src/components/GameSettings/options.ts | 19 +- .../src/components/Popover/InfoPopover.tsx | 19 + apps/front/src/components/Popover/Popover.tsx | 31 ++ apps/front/src/components/Popover/index.ts | 2 + .../components/SideBar/SideBarContainer.tsx | 9 +- apps/front/src/translations/resources/en.json | 6 + apps/front/src/translations/resources/fr.json | 6 + apps/front/src/utils/api.ts | 4 +- apps/worker/package.json | 2 +- apps/worker/src/endpoints/init.ts | 12 +- package.json | 4 +- packages/common/src/classes/DicePool.ts | 1 - packages/common/src/classes/GameState.ts | 15 +- packages/common/src/classes/Lobby.ts | 19 +- packages/common/src/interfaces/IGameState.ts | 9 +- packages/common/src/interfaces/ILobby.ts | 3 +- packages/common/src/types/gameSettings.ts | 2 + pnpm-lock.yaml | 412 ++++++++++++++++-- 25 files changed, 579 insertions(+), 76 deletions(-) create mode 100644 apps/front/src/components/GameMode.tsx create mode 100644 apps/front/src/components/Popover/InfoPopover.tsx create mode 100644 apps/front/src/components/Popover/Popover.tsx create mode 100644 apps/front/src/components/Popover/index.ts diff --git a/apps/front/package.json b/apps/front/package.json index b3bb797..5dce6b6 100644 --- a/apps/front/package.json +++ b/apps/front/package.json @@ -13,6 +13,7 @@ "@headlessui/react": "^1.7.18", "@heroicons/react": "^2.1.1", "@knucklebones/common": "workspace:*", + "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-toggle-group": "^1.0.4", "@use-gesture/react": "^10.3.0", "clsx": "^2.1.0", diff --git a/apps/front/src/components/Button.tsx b/apps/front/src/components/Button.tsx index 084d9b1..d5eb772 100644 --- a/apps/front/src/components/Button.tsx +++ b/apps/front/src/components/Button.tsx @@ -1,5 +1,6 @@ import type * as React from 'react' import { clsx } from 'clsx' +import { IconWrapper } from './IconWrapper' export interface ButtonProps { // Not a fan of having this prop @@ -46,13 +47,9 @@ export function Button({ props.className )} > - {leftIcon !== undefined && ( -
{leftIcon}
- )} + {leftIcon !== undefined && {leftIcon}}
{children}
- {rightIcon !== undefined && ( -
{rightIcon}
- )} + {rightIcon !== undefined && {rightIcon}} ) } diff --git a/apps/front/src/components/Footer.tsx b/apps/front/src/components/Footer.tsx index ebe1fbc..06f3556 100644 --- a/apps/front/src/components/Footer.tsx +++ b/apps/front/src/components/Footer.tsx @@ -1,5 +1,6 @@ import { Trans } from 'react-i18next' import { CodeBracketIcon, EnvelopeIcon } from '@heroicons/react/24/outline' +import { IconWrapper } from './IconWrapper' export function Footer() { return ( @@ -23,9 +24,9 @@ export function Footer() { target='_blank' rel='noreferrer' > -
+ -
+ -
+ -
+
diff --git a/apps/front/src/components/Game.tsx b/apps/front/src/components/Game.tsx index c43ddc4..2d37467 100644 --- a/apps/front/src/components/Game.tsx +++ b/apps/front/src/components/Game.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import { useIsOnMobile } from '../hooks/detectDevice' import { useNoIndex } from '../hooks/useNoIndex' import { useGameWhileLoading } from './GameContext' +import { GameMode } from './GameMode' import { GameOutcome } from './GameOutcome' import { HowToPlayModal } from './HowToPlay' import { Loading } from './Loading' @@ -23,20 +24,18 @@ export function Game() { const { errorMessage, clearErrorMessage } = gameStore - // Pas tip top je trouve, mais virtuellement ça marche - const gameOutcome = - return ( <> - {isOnMobile && gameOutcome} + {isOnMobile && } +
- {!isOnMobile && gameOutcome} + {!isOnMobile && }
diff --git a/apps/front/src/components/GameContext/useGameSetup.ts b/apps/front/src/components/GameContext/useGameSetup.ts index 9492e87..71761ca 100644 --- a/apps/front/src/components/GameContext/useGameSetup.ts +++ b/apps/front/src/components/GameContext/useGameSetup.ts @@ -55,7 +55,11 @@ export function useGameSetup() { if (readyState === ReadyState.OPEN) { initGame( { roomKey, playerId }, - { playerType: 'human', boType: state?.boType } + { + playerType: 'human', + boType: state?.boType, + gameMode: state?.gameMode ?? 'classic' + } ) .then(async () => { // À déplacer côté serveur @@ -65,7 +69,8 @@ export function useGameSetup() { { playerType: 'ai', difficulty: state?.difficulty, - boType: state?.boType + boType: state?.boType, + gameMode: state?.gameMode } ) } diff --git a/apps/front/src/components/GameMode.tsx b/apps/front/src/components/GameMode.tsx new file mode 100644 index 0000000..fd51db4 --- /dev/null +++ b/apps/front/src/components/GameMode.tsx @@ -0,0 +1,19 @@ +import { useTranslation } from 'react-i18next' +import { useGame } from './GameContext' +import { InfoPopover } from './Popover' +import { Text } from './Text' + +export function GameMode() { + const { gameMode } = useGame() + const { t } = useTranslation() + + return ( +
+ + {t('game-settings.game-mode.label')} :{' '} + {t(`game-settings.game-mode.${gameMode}`)} + + {t(`game-settings.game-mode.info`)} +
+ ) +} diff --git a/apps/front/src/components/GameSettings/GameSettingsModal.tsx b/apps/front/src/components/GameSettings/GameSettingsModal.tsx index 5bde88e..a2ffbde 100644 --- a/apps/front/src/components/GameSettings/GameSettingsModal.tsx +++ b/apps/front/src/components/GameSettings/GameSettingsModal.tsx @@ -5,20 +5,24 @@ import { v4 as uuidv4 } from 'uuid' import { type GameSettings, type Difficulty, - type PlayerType + type PlayerType, + type GameMode } from '@knucklebones/common' import { Button } from '../Button' import { Modal, type ModalProps } from '../Modal' +import { InfoPopover } from '../Popover' import { type Option, ToggleGroup } from '../ToggleGroup' import { getBoTypeOptions, getDifficultyOptions, convertToBoType, - type StringBoType + type StringBoType, + getGameModeOptions } from './options' interface GameSettingProps { label: string + info?: string options: Array> value: T onValueChange(value: T): void @@ -26,13 +30,17 @@ interface GameSettingProps { function GameSetting({ label, + info, options, value, onValueChange }: GameSettingProps) { return (
- +
+ + {info !== undefined && {info}} +
{/* Accessibility? */} ('medium') const [boType, setBoType] = React.useState('indefinite') + const [gameMode, setGameMode] = React.useState('classic') const { t } = useTranslation() return ( @@ -78,6 +87,14 @@ export function GameSettingsModal({ onValueChange={setBoType} options={getBoTypeOptions()} /> + + + + {t(`game-settings.game-mode.info`)} + + ) } diff --git a/apps/front/src/components/PlayerBoard/ColumnScore.tsx b/apps/front/src/components/PlayerBoard/ColumnScore.tsx index 600788d..8900dc7 100644 --- a/apps/front/src/components/PlayerBoard/ColumnScore.tsx +++ b/apps/front/src/components/PlayerBoard/ColumnScore.tsx @@ -16,7 +16,7 @@ interface CountedDiceProps { function CountedDice({ value, count }: CountedDiceProps) { if (count === 1) { - return + return } return ( // TODO: div (dice) shouldn't be in p @@ -24,7 +24,7 @@ function CountedDice({ value, count }: CountedDiceProps) { ( {Array.from({ length: count }).map((_, i) => ( - + {i + 1 < count && +} ))} diff --git a/apps/front/src/components/SideBar/SideBarContainer.tsx b/apps/front/src/components/SideBar/SideBarContainer.tsx index 2bf4c86..8298566 100644 --- a/apps/front/src/components/SideBar/SideBarContainer.tsx +++ b/apps/front/src/components/SideBar/SideBarContainer.tsx @@ -81,7 +81,7 @@ export function SideBarContainer({
Knucklebones Logo