diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 1e064d62..87cc9b7b 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,5 +1,18 @@ # Changelog +## 3.1.0 + +### New Features + +- Added a match reminder to the roll display for oracle rolls that care about dice matching (ask the oracle moves) +- Added asset clock and counter controls working for assets that use them (ex: Snub Fighter Ability #3, or Marked Ability #2). + +### Changes + +- Re-added missing roll buttons for assets in the move dialog + +### Bug Fixes + ## 3.0.0 ### New Features diff --git a/src/components/features/ProgressTrack/ProgressTrack.tsx b/src/components/features/ProgressTrack/ProgressTrack.tsx index 8cf0115d..4b1af4f7 100644 --- a/src/components/features/ProgressTrack/ProgressTrack.tsx +++ b/src/components/features/ProgressTrack/ProgressTrack.tsx @@ -139,7 +139,8 @@ export function ProgressTrack(props: ProgressTracksProps) { rollTrackProgress( trackType, label || "", - Math.min(Math.floor(value / 4), 10) + Math.min(Math.floor(value / 4), 10), + move?._id ?? "" ); } }; diff --git a/src/components/features/assets/AssetCard/AssetCard.tsx b/src/components/features/assets/AssetCard/AssetCard.tsx index ed1f5e98..c6400c9c 100644 --- a/src/components/features/assets/AssetCard/AssetCard.tsx +++ b/src/components/features/assets/AssetCard/AssetCard.tsx @@ -7,6 +7,7 @@ import { AssetControls } from "./AssetControls"; import { AssetHeader } from "./AssetHeader"; import { AssetNameAndDescription } from "./AssetNameAndDescription"; import { ForwardedRef, ReactNode, forwardRef } from "react"; +import { Datasworn } from "@datasworn/core"; export interface AssetCardProps { assetId: string; @@ -49,6 +50,39 @@ const AssetCardComponent = ( return null; } + const assetControls: Record< + string, + Datasworn.AssetControlField | Datasworn.AssetAbilityControlField + > = { ...asset.controls }; + const assetOptions = { ...asset.options }; + + asset.abilities.forEach((ability, index) => { + const isEnabled = ability.enabled || storedAsset?.enabledAbilities[index]; + + if (isEnabled) { + const controls = ability.controls ?? {}; + const options = ability.options ?? {}; + Object.keys(controls).forEach((controlKey) => { + assetControls[controlKey] = controls[controlKey]; + }); + Object.keys(options).forEach((optionKey) => { + assetOptions[optionKey] = options[optionKey]; + }); + + const enhanceControls = ability.enhance_asset?.controls ?? {}; + Object.keys(enhanceControls).forEach((controlKey) => { + const enhancement = enhanceControls[controlKey]; + const assetControl = assetControls[controlKey]; + if (assetControl?.field_type === enhancement.field_type) { + assetControls[controlKey] = { + ...assetControl, + ...(enhancement as Partial), + }; + } + }); + } + }); + return ( @@ -89,7 +123,7 @@ const AssetCardComponent = ( onAbilityToggle={onAssetAbilityToggle} /> diff --git a/src/components/features/assets/AssetCard/AssetControl.tsx b/src/components/features/assets/AssetCard/AssetControl.tsx index e1c8a947..3b515f1b 100644 --- a/src/components/features/assets/AssetCard/AssetControl.tsx +++ b/src/components/features/assets/AssetCard/AssetControl.tsx @@ -11,10 +11,12 @@ import { import { Track } from "components/features/Track"; import { AssetControls } from "./AssetControls"; import { AssetDocument } from "api-calls/assets/_asset.type"; +import { AssetControlCounter } from "./AssetControlCounter"; +import { AssetControlClock } from "./AssetControlClock"; export interface AssetControlProps { controlId: string; - control: Datasworn.AssetControlField; + control: Datasworn.AssetControlField | Datasworn.AssetAbilityControlField; storedAsset?: AssetDocument; onControlChange?: ( controlKey: string, @@ -147,6 +149,49 @@ export function AssetControl(props: AssetControlProps) { ); } + case "text": { + return ( + + onControlChange && onControlChange(controlId, evt.target.value) + } + variant={"standard"} + sx={{ mt: 0.5 }} + fullWidth + /> + ); + } + case "clock": { + return ( + onControlChange(controlId, value) + : undefined + } + /> + ); + } + case "counter": { + return ( + onControlChange(controlId, value) + : undefined + } + /> + ); + } } return null; diff --git a/src/components/features/assets/AssetCard/AssetControlClock.tsx b/src/components/features/assets/AssetCard/AssetControlClock.tsx new file mode 100644 index 00000000..1b5971d6 --- /dev/null +++ b/src/components/features/assets/AssetCard/AssetControlClock.tsx @@ -0,0 +1,55 @@ +import { Datasworn } from "@datasworn/core"; +import { Box, Typography } from "@mui/material"; +import { useDebouncedState } from "hooks/useDebouncedState"; +import { useStore } from "stores/store"; +import { ClockCircle } from "components/features/charactersAndCampaigns/Clocks/ClockCircle"; + +export interface AssetControlClockProps { + value?: number; + field: Datasworn.ClockField; + onChange?: (value: number) => void; +} + +export function AssetControlClock(props: AssetControlClockProps) { + const { value, field, onChange } = props; + + const [localValue, setLocalValue] = useDebouncedState( + (value) => onChange && onChange(value), + value ?? field.value, + 500 + ); + + const announce = useStore((store) => store.appState.announce); + + const handleIncrement = () => { + setLocalValue((prev) => { + const newValue = prev + 1; + if (typeof field.max === "number" && field.max < newValue) { + announce( + `Cannot increase ${field.label} beyond ${field.max}. Resetting field to 0` + ); + return 0; + } + announce(`Increased ${field.label} by 1 for a total of ${newValue}`); + return newValue; + }); + }; + + return ( + + theme.fontFamilyTitle} + color={"textSecondary"} + > + {field.label} + + + + + ); +} diff --git a/src/components/features/assets/AssetCard/AssetControlCounter.tsx b/src/components/features/assets/AssetCard/AssetControlCounter.tsx new file mode 100644 index 00000000..9d08eb39 --- /dev/null +++ b/src/components/features/assets/AssetCard/AssetControlCounter.tsx @@ -0,0 +1,131 @@ +import { Datasworn } from "@datasworn/core"; +import { Box, ButtonBase, Typography } from "@mui/material"; +import { useDebouncedState } from "hooks/useDebouncedState"; +import { useStore } from "stores/store"; +import AddIcon from "@mui/icons-material/Add"; +import SubtractIcon from "@mui/icons-material/Remove"; + +export interface AssetControlCounterProps { + value?: number; + field: Datasworn.CounterField; + onChange?: (value: number) => void; +} + +export function AssetControlCounter(props: AssetControlCounterProps) { + const { value, field, onChange } = props; + + const [localValue, setLocalValue] = useDebouncedState( + (value) => onChange && onChange(value), + value ?? field.value, + 500 + ); + + const announce = useStore((store) => store.appState.announce); + + const handleDecrement = () => { + setLocalValue((prev) => { + const newValue = prev - 1; + if (typeof field.min === "number" && field.min > newValue) { + announce(`Cannot decrease ${field.label} beyond ${field.max}`); + return prev; + } + announce(`Decreased ${field.label} by 1 for a total of ${newValue}`); + return newValue; + }); + }; + + const handleIncrement = () => { + setLocalValue((prev) => { + const newValue = prev + 1; + if (typeof field.max === "number" && field.max < newValue) { + announce(`Cannot increase ${field.label} beyond ${field.max}`); + return prev; + } + announce(`Increased ${field.label} by 1 for a total of ${newValue}`); + return newValue; + }); + }; + + return ( +
+ + theme.fontFamilyTitle} + sx={(theme) => ({ + bgcolor: (theme) => + theme.palette.mode === "light" + ? theme.palette.darkGrey.light + : theme.palette.grey[400], + color: + theme.palette.mode === "light" + ? theme.palette.darkGrey.contrastText + : theme.palette.grey[800], + px: 0.5, + })} + > + {field.label} + + {onChange && ( + ({ + bgcolor: theme.palette.mode === "light" ? "grey.300" : "grey.600", + color: theme.palette.mode === "light" ? "grey.700" : "grey.200", + transition: theme.transitions.create(["background-color"], { + duration: theme.transitions.duration.shortest, + }), + "&:hover": { + bgcolor: + theme.palette.mode === "light" ? "grey.400" : "grey.700", + }, + })} + > + + + )} + + + {localValue > 0 ? "+" : ""} + {localValue} + + {onChange && ( + ({ + bgcolor: theme.palette.mode === "light" ? "grey.300" : "grey.600", + color: theme.palette.mode === "light" ? "grey.700" : "grey.200", + transition: theme.transitions.create(["background-color"], { + duration: theme.transitions.duration.shortest, + }), + "&:hover": { + bgcolor: + theme.palette.mode === "light" ? "grey.400" : "grey.700", + }, + })} + > + + + )} + +
+ ); +} diff --git a/src/components/features/assets/AssetCard/AssetControls.tsx b/src/components/features/assets/AssetCard/AssetControls.tsx index e75bbc09..050a8e42 100644 --- a/src/components/features/assets/AssetCard/AssetControls.tsx +++ b/src/components/features/assets/AssetCard/AssetControls.tsx @@ -4,7 +4,12 @@ import { AssetControl } from "./AssetControl"; import { Stack } from "@mui/material"; export interface AssetControlsProps { - controls: Record | undefined; + controls: + | Record< + string, + Datasworn.AssetControlField | Datasworn.AssetAbilityControlField + > + | undefined; storedAsset?: AssetDocument; row?: boolean; onControlChange?: ( diff --git a/src/components/features/assets/AssetCard/AssetOptions.tsx b/src/components/features/assets/AssetCard/AssetOptions.tsx index a3d9c4d1..0c69c468 100644 --- a/src/components/features/assets/AssetCard/AssetOptions.tsx +++ b/src/components/features/assets/AssetCard/AssetOptions.tsx @@ -4,27 +4,28 @@ import { Stack } from "@mui/material"; import { AssetDocument } from "api-calls/assets/_asset.type"; export interface AssetOptionsProps { - asset: Datasworn.Asset; storedAsset?: AssetDocument; + options: Record< + string, + Datasworn.AssetOptionField | Datasworn.AssetAbilityOptionField + >; onAssetOptionChange?: (assetOptionKey: string, value: string) => void; } export function AssetOptions(props: AssetOptionsProps) { - const { asset, storedAsset, onAssetOptionChange } = props; + const { options, storedAsset, onAssetOptionChange } = props; - const assetOptions = asset.options; - - if (!assetOptions) { + if (Object.keys(options).length === 0) { return null; } return ( - {Object.keys(assetOptions) + {Object.keys(options) .sort((o1, o2) => { - const option1 = assetOptions[o1]; - const option2 = assetOptions[o2]; + const option1 = options[o1]; + const option2 = options[o2]; return option1.label.localeCompare(option2.label); }) @@ -33,7 +34,7 @@ export function AssetOptions(props: AssetOptionsProps) { storedAsset={storedAsset} key={assetOptionKey} assetOptionKey={assetOptionKey} - assetOption={assetOptions[assetOptionKey]} + assetOption={options[assetOptionKey]} onAssetOptionChange={onAssetOptionChange} /> ))} diff --git a/src/components/features/characters/StatComponent.tsx b/src/components/features/characters/StatComponent.tsx index d17c4828..62f29b0f 100644 --- a/src/components/features/characters/StatComponent.tsx +++ b/src/components/features/characters/StatComponent.tsx @@ -16,11 +16,14 @@ export interface StatComponentProps { updateTrack?: (newValue: number) => Promise; disableRoll?: boolean; sx?: SxProps; - moveName?: string; + moveInfo?: { + name: string; + id: string; + }; } export function StatComponent(props: StatComponentProps) { - const { label, value, updateTrack, disableRoll, moveName, sx } = props; + const { label, value, updateTrack, disableRoll, moveInfo, sx } = props; const [inputValue, setInputValue] = useState(value + ""); const [isInputFocused, setIsInputFocused] = useState(false); @@ -102,7 +105,7 @@ export function StatComponent(props: StatComponentProps) { component={updateTrack || disableRoll ? "div" : ButtonBase} onClick={() => { if (!(updateTrack || disableRoll)) { - rollStat(label, value, moveName, adds); + rollStat(label, value, moveInfo, adds); resetAdds({ adds: 0 }).catch(() => {}); } }} diff --git a/src/components/features/charactersAndCampaigns/Clocks/ClockCircle.tsx b/src/components/features/charactersAndCampaigns/Clocks/ClockCircle.tsx index 0f98869e..dcddcc9e 100644 --- a/src/components/features/charactersAndCampaigns/Clocks/ClockCircle.tsx +++ b/src/components/features/charactersAndCampaigns/Clocks/ClockCircle.tsx @@ -19,15 +19,20 @@ export function ClockCircle(props: ClockCircleProps) { }> ) => { const { children, onClick, sx } = props; + const ariaLabel = `Clock with ${segments} segments. ${value} filled.`; if (onClick) { return ( - + {children} ); } - return {children}; + return ( + + {children} + + ); }; return ( diff --git a/src/components/features/charactersAndCampaigns/LinkedDialog/LinkedDialogContent/MoveDialogContent/MoveAssetControl.tsx b/src/components/features/charactersAndCampaigns/LinkedDialog/LinkedDialogContent/MoveDialogContent/MoveAssetControl.tsx new file mode 100644 index 00000000..c6853160 --- /dev/null +++ b/src/components/features/charactersAndCampaigns/LinkedDialog/LinkedDialogContent/MoveDialogContent/MoveAssetControl.tsx @@ -0,0 +1,76 @@ +import { Datasworn } from "@datasworn/core"; +import { Chip, Stack } from "@mui/material"; +import { AssetDocument } from "api-calls/assets/_asset.type"; +import { StatComponent } from "components/features/characters/StatComponent"; +import { useStore } from "stores/store"; + +export interface MoveAssetControlProps { + move: Datasworn.Move; + control: string; +} + +export function MoveAssetControl(props: MoveAssetControlProps) { + const { control, move } = props; + + const isInCharacterSheet = useStore( + (store) => store.characters.currentCharacter.currentCharacterId + ); + + const characterAssets = useStore( + (store) => store.characters.currentCharacter.assets.assets + ); + const campaignAssets = useStore( + (store) => store.campaigns.currentCampaign.assets.assets + ); + + const assetMap = useStore((store) => store.rules.assetMaps.assetMap); + + if (!isInCharacterSheet) { + return ; + } + + const matchingAssets: { values: AssetDocument; asset: Datasworn.Asset }[] = + []; + Object.values({ ...characterAssets, ...campaignAssets }).forEach( + (storedAsset) => { + const asset = assetMap[storedAsset.id]; + if (asset) { + if (asset.controls?.[control]?.field_type === "condition_meter") { + matchingAssets.push({ values: storedAsset, asset }); + } + } + } + ); + + const getAssetValue = (asset: Datasworn.Asset, values: AssetDocument) => { + const storedValue = values.controlValues?.[control]; + if (typeof storedValue === "number") { + return storedValue; + } + const defaultValue = asset.controls?.[control]?.value; + if (typeof defaultValue === "number") { + return defaultValue; + } + return 0; + }; + + if (matchingAssets.length > 0) { + return ( + + {matchingAssets.map((asset, index) => ( + + ))} + + ); + } + + return ; +} diff --git a/src/components/features/charactersAndCampaigns/LinkedDialog/LinkedDialogContent/MoveDialogContent/MoveRollers.tsx b/src/components/features/charactersAndCampaigns/LinkedDialog/LinkedDialogContent/MoveDialogContent/MoveRollers.tsx index 7a773418..1295e128 100644 --- a/src/components/features/charactersAndCampaigns/LinkedDialog/LinkedDialogContent/MoveDialogContent/MoveRollers.tsx +++ b/src/components/features/charactersAndCampaigns/LinkedDialog/LinkedDialogContent/MoveDialogContent/MoveRollers.tsx @@ -2,6 +2,7 @@ import { Datasworn } from "@datasworn/core"; import { Chip, Stack } from "@mui/material"; import { StatComponent } from "components/features/characters/StatComponent"; import { useStore } from "stores/store"; +import { MoveAssetControl } from "./MoveAssetControl"; export interface MoveRollersProps { move: Datasworn.Move; @@ -70,7 +71,10 @@ export function MoveRollers(props: MoveRollersProps) { key={stat} label={statRules[stat].label} value={characterStats[stat]} - moveName={move.name} + moveInfo={{ + name: move.name, + id: move._id, + }} /> ) : ( ) : ( ( - ))} diff --git a/src/components/features/charactersAndCampaigns/RollDisplay/RollDisplay.tsx b/src/components/features/charactersAndCampaigns/RollDisplay/RollDisplay.tsx index 01f4925a..650496a5 100644 --- a/src/components/features/charactersAndCampaigns/RollDisplay/RollDisplay.tsx +++ b/src/components/features/charactersAndCampaigns/RollDisplay/RollDisplay.tsx @@ -6,6 +6,7 @@ import { RollResult } from "./RollResult"; import { getRollResultLabel } from "./getRollResultLabel"; import { RollContainer } from "./RollContainer"; import { ReactNode } from "react"; +import { useStore } from "stores/store"; export interface RollDisplayProps { roll: Roll; @@ -17,6 +18,8 @@ export interface RollDisplayProps { export function RollDisplay(props: RollDisplayProps) { const { roll, onClick, isExpanded, actions } = props; + const oracles = useStore((store) => store.rules.oracleMaps.oracleRollableMap); + return ( ({ @@ -105,7 +108,17 @@ export function RollDisplay(props: RollDisplayProps) { /> - + )} @@ -129,3 +142,7 @@ export function RollDisplay(props: RollDisplayProps) { ); } +// A bit hacky, check if the last two digits of the number are equal to each other. +function checkIfMatch(num: number) { + return num % 10 === Math.floor(num / 10) % 10; +} diff --git a/src/hooks/useDebouncedState.ts b/src/hooks/useDebouncedState.ts index 8e826732..2d84af2e 100644 --- a/src/hooks/useDebouncedState.ts +++ b/src/hooks/useDebouncedState.ts @@ -1,10 +1,16 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { + SetStateAction, + useCallback, + useEffect, + useRef, + useState, +} from "react"; export function useDebouncedState( persistChanges: (state: State) => void, initialState: State, delay = 2000 -): [State, (value: State) => void] { +): [State, (value: SetStateAction) => void] { const [state, setState] = useState(initialState); const stateRef = useRef(state); @@ -42,9 +48,19 @@ export function useDebouncedState( // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const setStateCallback = useCallback((newState: State) => { - setState(newState); - stateRef.current = newState; + const setStateCallback = useCallback((newState: SetStateAction) => { + if (typeof newState === "function") { + setState((prevState) => { + const newStateValue = (newState as (prevState: State) => State)( + prevState + ); + stateRef.current = newStateValue; + return newStateValue; + }); + } else { + setState(newState); + stateRef.current = newState; + } }, []); return [state, setStateCallback]; diff --git a/src/pages/Homebrew/HomebrewEditorPage/AssetsSection/AssetPreviewCard.tsx b/src/pages/Homebrew/HomebrewEditorPage/AssetsSection/AssetPreviewCard.tsx index 0892d5ed..26641998 100644 --- a/src/pages/Homebrew/HomebrewEditorPage/AssetsSection/AssetPreviewCard.tsx +++ b/src/pages/Homebrew/HomebrewEditorPage/AssetsSection/AssetPreviewCard.tsx @@ -179,7 +179,10 @@ export function AssetPreviewCard(props: AssetPreviewCardProps) { })} > - {}} /> + {}} + /> {}} /> diff --git a/src/pages/Homebrew/HomebrewEditorPage/AssetsSection/Assets/AssetCardPreview.tsx b/src/pages/Homebrew/HomebrewEditorPage/AssetsSection/Assets/AssetCardPreview.tsx index d47fe61b..8b17c2b9 100644 --- a/src/pages/Homebrew/HomebrewEditorPage/AssetsSection/Assets/AssetCardPreview.tsx +++ b/src/pages/Homebrew/HomebrewEditorPage/AssetsSection/Assets/AssetCardPreview.tsx @@ -151,7 +151,10 @@ export function AssetCardPreview(props: AssetCardPreviewProps) { })} > - {}} /> + {}} + /> {}} /> diff --git a/src/stores/appState/rollers/rollOracle.ts b/src/stores/appState/rollers/rollOracle.ts index aa340e8e..42073a64 100644 --- a/src/stores/appState/rollers/rollOracle.ts +++ b/src/stores/appState/rollers/rollOracle.ts @@ -83,6 +83,7 @@ export function rollOracle( gmsOnly, roll: rolls, result: resultString, + oracleId: oracle._id, }; } diff --git a/src/stores/appState/useRoller.ts b/src/stores/appState/useRoller.ts index c5a18e17..25c62e6e 100644 --- a/src/stores/appState/useRoller.ts +++ b/src/stores/appState/useRoller.ts @@ -41,7 +41,7 @@ export function useRoller() { ( label: string, modifier: number, - moveName?: string, + move?: { name: string; id: string }, adds?: number, showSnackbar = true ) => { @@ -84,8 +84,9 @@ export function useRoller() { if (adds) { statRoll.adds = adds; } - if (moveName) { - statRoll.moveName = moveName; + if (move) { + statRoll.moveName = move.name; + statRoll.moveId = move.id; } addRollToLog({ @@ -100,7 +101,7 @@ export function useRoller() { if (showSnackbar) { let announcement = `Rolled ${ - moveName ? moveName + " using stat " + label : label + move ? move.name + " using stat " + label : label }.`; if (matchedNegativeMomentum) { announcement += ` On your action die you rolled a ${ @@ -123,7 +124,7 @@ export function useRoller() { verboseScreenReaderRolls ? announcement : `Rolled ${ - moveName ? moveName + "using stat" + label : label + move ? move.name + "using stat" + label : label }. Your action die had a total of ${actionTotal} against ${challenge1} and ${challenge2}, for a ${getRollResultLabel( result )}` @@ -209,7 +210,8 @@ export function useRoller() { ( trackType: TrackTypes | LEGACY_TrackTypes, trackLabel: string, - trackProgress: number + trackProgress: number, + moveId: string ) => { const challenge1 = getRoll(10); const challenge2 = getRoll(10); @@ -234,6 +236,7 @@ export function useRoller() { characterId, uid, gmsOnly: false, + moveId, }; addRollToLog({ @@ -274,6 +277,7 @@ export function useRoller() { roll: Array.isArray(result.roll) ? result.roll[0] : result.roll, result: result.result, oracleTitle: result.rollLabel, + oracleId: oracleId, rollLabel: clockTitle, timestamp: new Date(), characterId, diff --git a/src/types/DieRolls.type.ts b/src/types/DieRolls.type.ts index d2aaef4d..b92e8673 100644 --- a/src/types/DieRolls.type.ts +++ b/src/types/DieRolls.type.ts @@ -25,6 +25,7 @@ export interface BaseRoll { export interface StatRoll extends BaseRoll { type: ROLL_TYPE.STAT; moveName?: string; + moveId?: string; action: number; challenge1: number; challenge2: number; @@ -40,6 +41,7 @@ export interface OracleTableRoll extends BaseRoll { roll: number | number[]; result: string; oracleCategoryName?: string; + oracleId?: string; } export interface TrackProgressRoll extends BaseRoll { @@ -49,6 +51,7 @@ export interface TrackProgressRoll extends BaseRoll { trackProgress: number; result: ROLL_RESULT; trackType: TrackTypes | LEGACY_TrackTypes; + moveId?: string; } export interface ClockProgressionRoll extends BaseRoll { @@ -56,6 +59,7 @@ export interface ClockProgressionRoll extends BaseRoll { roll: number; oracleTitle: string; result: string; + oracleId?: string; } export type Roll =