diff --git a/src/features/achievements/achievement.tsx b/src/features/achievements/achievement.tsx index 4776fd1..baa86ca 100644 --- a/src/features/achievements/achievement.tsx +++ b/src/features/achievements/achievement.tsx @@ -1,3 +1,4 @@ +import { isArray } from "@vue/shared"; import AchievementComponent from "features/achievements/Achievement.vue"; import { CoercableComponent, @@ -5,7 +6,6 @@ import { GatherProps, GenericComponent, getUniqueID, - isVisible, OptionsFunc, Replace, setDefault, @@ -16,6 +16,12 @@ import "game/notifications"; import type { Persistent } from "game/persistence"; import { persistent } from "game/persistence"; import player from "game/player"; +import { + createBooleanRequirement, + createVisibilityRequirement, + Requirements, + requirementsMet +} from "game/requirements"; import settings from "game/settings"; import type { Computable, @@ -31,28 +37,50 @@ import { useToast } from "vue-toastification"; const toast = useToast(); +/** A symbol used to identify {@link Achievement} features. */ export const AchievementType = Symbol("Achievement"); +/** + * An object that configures an {@link Achievement}. + */ export interface AchievementOptions { + /** Whether this achievement should be visible. */ visibility?: Computable; - shouldEarn?: () => boolean; + /** The requirement(s) to earn this achievement. Can be left null if using {@link BaseAchievement.complete}. */ + requirements?: Requirements; + /** The display to use for this achievement. */ display?: Computable; + /** Shows a marker on the corner of the feature. */ mark?: Computable; + /** An image to display as the background for this achievement. */ image?: Computable; + /** CSS to apply to this feature. */ style?: Computable; + /** Dictionary of CSS classes to apply to this feature. */ classes?: Computable>; + /** A function that is called when the achievement is completed. */ onComplete?: VoidFunction; } +/** + * The properties that are added onto a processed {@link AchievementOptions} to create an {@link Achievement}. + */ export interface BaseAchievement { + /** An auto-generated ID for identifying achievements that appear in the DOM. Will not persist between refreshes or updates. */ id: string; + /** Whether or not this achievement has been earned. */ earned: Persistent; + /** A function to complete this achievement. */ complete: VoidFunction; + /** A symbol that helps identify features of the same type. */ type: typeof AchievementType; + /** The Vue component used to render this feature. */ [Component]: GenericComponent; + /** A function to gather the props the vue component requires for this feature. */ [GatherProps]: () => Record; } +/** An object that represents a feature with that is passively earned upon meeting certain requirements. */ export type Achievement = Replace< T & BaseAchievement, { @@ -65,6 +93,7 @@ export type Achievement = Replace< } >; +/** A type that matches any valid {@link Achievement} object. */ export type GenericAchievement = Replace< Achievement, { @@ -72,6 +101,10 @@ export type GenericAchievement = Replace< } >; +/** + * Lazily creates a achievement with the given options. + * @param optionsFunc Achievement options. + */ export function createAchievement( optionsFunc?: OptionsFunc ): Achievement { @@ -85,6 +118,21 @@ export function createAchievement( achievement.earned = earned; achievement.complete = function () { earned.value = true; + const genericAchievement = achievement as GenericAchievement; + genericAchievement.onComplete?.(); + if (genericAchievement.display != null) { + const Display = coerceComponent(unref(genericAchievement.display)); + toast.info( +
+

Achievement earned!

+
+ {/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */} + {/* @ts-ignore */} + +
+
+ ); + } }; processComputable(achievement as T, "visibility"); @@ -100,30 +148,19 @@ export function createAchievement( return { visibility, display, earned, image, style: unref(style), classes, mark, id }; }; - if (achievement.shouldEarn) { + if (achievement.requirements) { const genericAchievement = achievement as GenericAchievement; + const requirements = [ + createVisibilityRequirement(genericAchievement), + createBooleanRequirement(() => !genericAchievement.earned.value), + ...(isArray(achievement.requirements) + ? achievement.requirements + : [achievement.requirements]) + ]; watchEffect(() => { if (settings.active !== player.id) return; - if ( - !genericAchievement.earned.value && - isVisible(genericAchievement.visibility) && - genericAchievement.shouldEarn?.() - ) { - genericAchievement.earned.value = true; - genericAchievement.onComplete?.(); - if (genericAchievement.display != null) { - const Display = coerceComponent(unref(genericAchievement.display)); - toast.info( -
-

Achievement earned!

-
- {/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */} - {/* @ts-ignore */} - -
-
- ); - } + if (requirementsMet(requirements)) { + genericAchievement.complete(); } }); }