Merge remote-tracking branch 'template/main'

This commit is contained in:
thepaperpilot 2023-04-03 00:36:11 -05:00
commit 26772e832e
7 changed files with 350 additions and 426 deletions

View file

@ -1,10 +1,10 @@
import Collapsible from "components/layout/Collapsible.vue"; import Collapsible from "components/layout/Collapsible.vue";
import { GenericAchievement } from "features/achievements/achievement";
import type { Clickable, ClickableOptions, GenericClickable } from "features/clickables/clickable"; import type { Clickable, ClickableOptions, GenericClickable } from "features/clickables/clickable";
import { createClickable } from "features/clickables/clickable"; import { createClickable } from "features/clickables/clickable";
import type { GenericConversion } from "features/conversion"; import type { GenericConversion } from "features/conversion";
import type { CoercableComponent, JSXFunction, OptionsFunc, Replace } from "features/feature"; import type { CoercableComponent, JSXFunction, OptionsFunc, Replace } from "features/feature";
import { jsx, setDefault } from "features/feature"; import { jsx, setDefault } from "features/feature";
import { GenericMilestone } from "features/milestones/milestone";
import { displayResource, Resource } from "features/resources/resource"; import { displayResource, Resource } from "features/resources/resource";
import type { GenericTree, GenericTreeNode, TreeNode, TreeNodeOptions } from "features/trees/tree"; import type { GenericTree, GenericTreeNode, TreeNode, TreeNodeOptions } from "features/trees/tree";
import { createTreeNode } from "features/trees/tree"; import { createTreeNode } from "features/trees/tree";
@ -384,35 +384,35 @@ export function colorText(textToColor: string, color = "var(--accent2)"): JSX.El
} }
/** /**
* Creates a collapsible display of a list of milestones * Creates a collapsible display of a list of achievements
* @param milestones A dictionary of the milestones to display, inserted in the order from easiest to hardest * @param achievements A dictionary of the achievements to display, inserted in the order from easiest to hardest
*/ */
export function createCollapsibleMilestones(milestones: Record<string, GenericMilestone>) { export function createCollapsibleAchievements(achievements: Record<string, GenericAchievement>) {
// Milestones are typically defined from easiest to hardest, and we want to show hardest first // Achievements are typically defined from easiest to hardest, and we want to show hardest first
const orderedMilestones = Object.values(milestones).reverse(); const orderedAchievements = Object.values(achievements).reverse();
const collapseMilestones = persistent<boolean>(true, false); const collapseAchievements = persistent<boolean>(true, false);
const lockedMilestones = computed(() => const lockedAchievements = computed(() =>
orderedMilestones.filter(m => m.earned.value === false) orderedAchievements.filter(m => m.earned.value === false)
); );
const { firstFeature, collapsedContent, hasCollapsedContent } = getFirstFeature( const { firstFeature, collapsedContent, hasCollapsedContent } = getFirstFeature(
orderedMilestones, orderedAchievements,
m => m.earned.value m => m.earned.value
); );
const display = jsx(() => { const display = jsx(() => {
const milestonesToDisplay = [...lockedMilestones.value]; const achievementsToDisplay = [...lockedAchievements.value];
if (firstFeature.value) { if (firstFeature.value) {
milestonesToDisplay.push(firstFeature.value); achievementsToDisplay.push(firstFeature.value);
} }
return renderColJSX( return renderColJSX(
...milestonesToDisplay, ...achievementsToDisplay,
jsx(() => ( jsx(() => (
<Collapsible <Collapsible
collapsed={collapseMilestones} collapsed={collapseAchievements}
content={collapsedContent} content={collapsedContent}
display={ display={
collapseMilestones.value collapseAchievements.value
? "Show other completed milestones" ? "Show other completed achievements"
: "Hide other completed milestones" : "Hide other completed achievements"
} }
v-show={unref(hasCollapsedContent)} v-show={unref(hasCollapsedContent)}
/> />
@ -420,7 +420,7 @@ export function createCollapsibleMilestones(milestones: Record<string, GenericMi
); );
}); });
return { return {
collapseMilestones, collapseAchievements: collapseAchievements,
display display
}; };
} }

View file

@ -13,24 +13,27 @@
achievement: true, achievement: true,
locked: !unref(earned), locked: !unref(earned),
bought: unref(earned), bought: unref(earned),
small: unref(small),
...unref(classes) ...unref(classes)
}" }"
> >
<component v-if="component" :is="component" /> <component v-if="comp" :is="comp" />
<MarkNode :mark="unref(mark)" /> <MarkNode :mark="unref(mark)" />
<Node :id="id" /> <Node :id="id" />
</div> </div>
</template> </template>
<script lang="ts"> <script lang="tsx">
import "components/common/features.css"; import "components/common/features.css";
import MarkNode from "components/MarkNode.vue"; import MarkNode from "components/MarkNode.vue";
import Node from "components/Node.vue"; import Node from "components/Node.vue";
import type { CoercableComponent } from "features/feature"; import { CoercableComponent, jsx } from "features/feature";
import { Visibility, isHidden, isVisible } from "features/feature"; import { Visibility, isHidden, isVisible } from "features/feature";
import { computeOptionalComponent, processedPropType } from "util/vue"; import { displayRequirements, Requirements } from "game/requirements";
import type { StyleValue } from "vue"; import { coerceComponent, computeOptionalComponent, isCoercableComponent, processedPropType, unwrapRef } from "util/vue";
import { Component, shallowRef, StyleValue, UnwrapRef, watchEffect } from "vue";
import { defineComponent, toRefs, unref } from "vue"; import { defineComponent, toRefs, unref } from "vue";
import { GenericAchievement } from "./achievement";
export default defineComponent({ export default defineComponent({
props: { props: {
@ -38,15 +41,17 @@ export default defineComponent({
type: processedPropType<Visibility | boolean>(Number, Boolean), type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true required: true
}, },
display: processedPropType<CoercableComponent>(Object, String, Function), display: processedPropType<UnwrapRef<GenericAchievement["display"]>>(Object, String, Function),
earned: { earned: {
type: processedPropType<boolean>(Boolean), type: processedPropType<boolean>(Boolean),
required: true required: true
}, },
requirements: processedPropType<Requirements>(Object, Array),
image: processedPropType<string>(String), image: processedPropType<string>(String),
style: processedPropType<StyleValue>(String, Object, Array), style: processedPropType<StyleValue>(String, Object, Array),
classes: processedPropType<Record<string, boolean>>(Object), classes: processedPropType<Record<string, boolean>>(Object),
mark: processedPropType<boolean | string>(Boolean, String), mark: processedPropType<boolean | string>(Boolean, String),
small: processedPropType<boolean>(Boolean),
id: { id: {
type: String, type: String,
required: true required: true
@ -57,10 +62,44 @@ export default defineComponent({
MarkNode MarkNode
}, },
setup(props) { setup(props) {
const { display } = toRefs(props); const { display, requirements } = toRefs(props);
const comp = shallowRef<Component | string>("");
watchEffect(() => {
const currDisplay = unwrapRef(display);
if (currDisplay == null) {
comp.value = "";
return;
}
if (isCoercableComponent(currDisplay)) {
comp.value = coerceComponent(currDisplay);
return;
}
const Requirement = currDisplay.requirement ? coerceComponent(currDisplay.requirement, "h3") : displayRequirements(unwrapRef(requirements) ?? []);
const EffectDisplay = coerceComponent(currDisplay.effectDisplay || "", "b");
const OptionsDisplay = coerceComponent(currDisplay.optionsDisplay || "", "span");
comp.value = coerceComponent(
jsx(() => (
<span>
<Requirement />
{currDisplay.effectDisplay != null ? (
<div>
<EffectDisplay />
</div>
) : null}
{currDisplay.optionsDisplay != null ? (
<div class="equal-spaced">
<OptionsDisplay />
</div>
) : null}
</span>
))
);
});
return { return {
component: computeOptionalComponent(display), comp,
unref, unref,
Visibility, Visibility,
isVisible, isVisible,
@ -78,4 +117,31 @@ export default defineComponent({
color: white; color: white;
text-shadow: 0 0 2px #000000; text-shadow: 0 0 2px #000000;
} }
.achievement:not(.small) {
width: calc(100% - 10px);
min-width: 120px;
padding-left: 5px;
padding-right: 5px;
background-color: var(--locked);
border-width: 4px;
border-radius: 5px;
color: rgba(0, 0, 0, 0.5);
font-size: unset;
text-shadow: unset;
}
.achievement.done {
background-color: var(--bought);
cursor: default;
}
.achievement :deep(.equal-spaced) {
display: flex;
justify-content: center;
}
.achievement :deep(.equal-spaced > *) {
margin: auto;
}
</style> </style>

View file

@ -1,3 +1,6 @@
import { computed } from "@vue/reactivity";
import { isArray } from "@vue/shared";
import Select from "components/fields/Select.vue";
import AchievementComponent from "features/achievements/Achievement.vue"; import AchievementComponent from "features/achievements/Achievement.vue";
import { import {
CoercableComponent, CoercableComponent,
@ -5,18 +8,27 @@ import {
GatherProps, GatherProps,
GenericComponent, GenericComponent,
getUniqueID, getUniqueID,
isVisible, jsx,
OptionsFunc, OptionsFunc,
Replace, Replace,
setDefault, setDefault,
StyleValue, StyleValue,
Visibility Visibility
} from "features/feature"; } from "features/feature";
import { globalBus } from "game/events";
import "game/notifications"; import "game/notifications";
import type { Persistent } from "game/persistence"; import type { Persistent } from "game/persistence";
import { persistent } from "game/persistence"; import { persistent } from "game/persistence";
import player from "game/player"; import player from "game/player";
import settings from "game/settings"; import {
createBooleanRequirement,
createVisibilityRequirement,
displayRequirements,
Requirements,
requirementsMet
} from "game/requirements";
import settings, { registerSettingField } from "game/settings";
import { camelToTitle } from "util/common";
import type { import type {
Computable, Computable,
GetComputableType, GetComputableType,
@ -25,34 +37,79 @@ import type {
} from "util/computed"; } from "util/computed";
import { processComputable } from "util/computed"; import { processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy } from "util/proxies";
import { coerceComponent } from "util/vue"; import { coerceComponent, isCoercableComponent } from "util/vue";
import { unref, watchEffect } from "vue"; import { unref, watchEffect } from "vue";
import { useToast } from "vue-toastification"; import { useToast } from "vue-toastification";
const toast = useToast(); const toast = useToast();
/** A symbol used to identify {@link Achievement} features. */
export const AchievementType = Symbol("Achievement"); export const AchievementType = Symbol("Achievement");
/** Modes for only displaying some achievements. */
export enum AchievementDisplay {
All = "all",
//Last = "last",
Configurable = "configurable",
Incomplete = "incomplete",
None = "none"
}
/**
* An object that configures an {@link Achievement}.
*/
export interface AchievementOptions { export interface AchievementOptions {
/** Whether this achievement should be visible. */
visibility?: Computable<Visibility | boolean>; visibility?: Computable<Visibility | boolean>;
shouldEarn?: () => boolean; /** The requirement(s) to earn this achievement. Can be left null if using {@link BaseAchievement.complete}. */
display?: Computable<CoercableComponent>; requirements?: Requirements;
/** The display to use for this achievement. */
display?: Computable<
| CoercableComponent
| {
/** Description of the requirement(s) for this achievement. If unspecified then the requirements will be displayed automatically based on {@link requirements}. */
requirement?: CoercableComponent;
/** Description of what will change (if anything) for achieving this. */
effectDisplay?: CoercableComponent;
/** Any additional things to display on this achievement, such as a toggle for it's effect. */
optionsDisplay?: CoercableComponent;
}
>;
/** Shows a marker on the corner of the feature. */
mark?: Computable<boolean | string>; mark?: Computable<boolean | string>;
/** Toggles a smaller design for the feature. */
small?: Computable<boolean>;
/** An image to display as the background for this achievement. */
image?: Computable<string>; image?: Computable<string>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>; style?: Computable<StyleValue>;
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>; classes?: Computable<Record<string, boolean>>;
/** Whether or not to display a notification popup when this achievement is earned. */
showPopups?: Computable<boolean>;
/** A function that is called when the achievement is completed. */
onComplete?: VoidFunction; onComplete?: VoidFunction;
} }
/**
* The properties that are added onto a processed {@link AchievementOptions} to create an {@link Achievement}.
*/
export interface BaseAchievement { export interface BaseAchievement {
/** An auto-generated ID for identifying achievements that appear in the DOM. Will not persist between refreshes or updates. */
id: string; id: string;
/** Whether or not this achievement has been earned. */
earned: Persistent<boolean>; earned: Persistent<boolean>;
/** A function to complete this achievement. */
complete: VoidFunction; complete: VoidFunction;
/** A symbol that helps identify features of the same type. */
type: typeof AchievementType; type: typeof AchievementType;
/** The Vue component used to render this feature. */
[Component]: GenericComponent; [Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>; [GatherProps]: () => Record<string, unknown>;
} }
/** An object that represents a feature with that is passively earned upon meeting certain requirements. */
export type Achievement<T extends AchievementOptions> = Replace< export type Achievement<T extends AchievementOptions> = Replace<
T & BaseAchievement, T & BaseAchievement,
{ {
@ -62,16 +119,23 @@ export type Achievement<T extends AchievementOptions> = Replace<
image: GetComputableType<T["image"]>; image: GetComputableType<T["image"]>;
style: GetComputableType<T["style"]>; style: GetComputableType<T["style"]>;
classes: GetComputableType<T["classes"]>; classes: GetComputableType<T["classes"]>;
showPopups: GetComputableTypeWithDefault<T["showPopups"], true>;
} }
>; >;
/** A type that matches any valid {@link Achievement} object. */
export type GenericAchievement = Replace< export type GenericAchievement = Replace<
Achievement<AchievementOptions>, Achievement<AchievementOptions>,
{ {
visibility: ProcessedComputable<Visibility | boolean>; visibility: ProcessedComputable<Visibility | boolean>;
showPopups: ProcessedComputable<boolean>;
} }
>; >;
/**
* Lazily creates a achievement with the given options.
* @param optionsFunc Achievement options.
*/
export function createAchievement<T extends AchievementOptions>( export function createAchievement<T extends AchievementOptions>(
optionsFunc?: OptionsFunc<T, BaseAchievement, GenericAchievement> optionsFunc?: OptionsFunc<T, BaseAchievement, GenericAchievement>
): Achievement<T> { ): Achievement<T> {
@ -85,45 +149,114 @@ export function createAchievement<T extends AchievementOptions>(
achievement.earned = earned; achievement.earned = earned;
achievement.complete = function () { achievement.complete = function () {
earned.value = true; earned.value = true;
const genericAchievement = achievement as GenericAchievement;
genericAchievement.onComplete?.();
if (
genericAchievement.display != null &&
unref(genericAchievement.showPopups) === true
) {
const display = unref(genericAchievement.display);
let Display;
if (isCoercableComponent(display)) {
Display = coerceComponent(display);
} else if (display.requirement != null) {
Display = coerceComponent(display.requirement);
} else {
Display = displayRequirements(genericAchievement.requirements ?? []);
}
toast.info(
<div>
<h3>Achievement earned!</h3>
<div>
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
{/* @ts-ignore */}
<Display />
</div>
</div>
);
}
}; };
processComputable(achievement as T, "visibility"); processComputable(achievement as T, "visibility");
setDefault(achievement, "visibility", Visibility.Visible); setDefault(achievement, "visibility", Visibility.Visible);
const visibility = achievement.visibility as ProcessedComputable<Visibility | boolean>;
achievement.visibility = computed(() => {
const display = unref((achievement as GenericAchievement).display);
switch (settings.msDisplay) {
default:
case AchievementDisplay.All:
return unref(visibility);
case AchievementDisplay.Configurable:
if (
unref(achievement.earned) &&
!(
display != null &&
typeof display == "object" &&
"optionsDisplay" in (display as Record<string, unknown>)
)
) {
return Visibility.None;
}
return unref(visibility);
case AchievementDisplay.Incomplete:
if (unref(achievement.earned)) {
return Visibility.None;
}
return unref(visibility);
case AchievementDisplay.None:
return Visibility.None;
}
});
processComputable(achievement as T, "display"); processComputable(achievement as T, "display");
processComputable(achievement as T, "mark"); processComputable(achievement as T, "mark");
processComputable(achievement as T, "small");
processComputable(achievement as T, "image"); processComputable(achievement as T, "image");
processComputable(achievement as T, "style"); processComputable(achievement as T, "style");
processComputable(achievement as T, "classes"); processComputable(achievement as T, "classes");
processComputable(achievement as T, "showPopups");
setDefault(achievement, "showPopups", true);
achievement[GatherProps] = function (this: GenericAchievement) { achievement[GatherProps] = function (this: GenericAchievement) {
const { visibility, display, earned, image, style, classes, mark, id } = this; const {
return { visibility, display, earned, image, style: unref(style), classes, mark, id }; visibility,
display,
requirements,
earned,
image,
style,
classes,
mark,
small,
id
} = this;
return {
visibility,
display,
requirements,
earned,
image,
style: unref(style),
classes,
mark,
small,
id
};
}; };
if (achievement.shouldEarn) { if (achievement.requirements) {
const genericAchievement = achievement as GenericAchievement; const genericAchievement = achievement as GenericAchievement;
const requirements = [
createVisibilityRequirement(genericAchievement),
createBooleanRequirement(() => !genericAchievement.earned.value),
...(isArray(achievement.requirements)
? achievement.requirements
: [achievement.requirements])
];
watchEffect(() => { watchEffect(() => {
if (settings.active !== player.id) return; if (settings.active !== player.id) return;
if ( if (requirementsMet(requirements)) {
!genericAchievement.earned.value && genericAchievement.complete();
isVisible(genericAchievement.visibility) &&
genericAchievement.shouldEarn?.()
) {
genericAchievement.earned.value = true;
genericAchievement.onComplete?.();
if (genericAchievement.display != null) {
const Display = coerceComponent(unref(genericAchievement.display));
toast.info(
<div>
<h3>Achievement earned!</h3>
<div>
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
{/* @ts-ignore */}
<Display />
</div>
</div>
);
}
} }
}); });
} }
@ -131,3 +264,34 @@ export function createAchievement<T extends AchievementOptions>(
return achievement as unknown as Achievement<T>; return achievement as unknown as Achievement<T>;
}); });
} }
declare module "game/settings" {
interface Settings {
msDisplay: AchievementDisplay;
}
}
globalBus.on("loadSettings", settings => {
setDefault(settings, "msDisplay", AchievementDisplay.All);
});
const msDisplayOptions = Object.values(AchievementDisplay).map(option => ({
label: camelToTitle(option),
value: option
}));
registerSettingField(
jsx(() => (
<Select
title={jsx(() => (
<span class="option-title">
Show achievements
<desc>Select which achievements to display based on criterias.</desc>
</span>
))}
options={msDisplayOptions}
onUpdate:modelValue={value => (settings.msDisplay = value as AchievementDisplay)}
modelValue={settings.msDisplay}
/>
))
);

View file

@ -36,47 +36,87 @@ import { createLazyProxy } from "util/proxies";
import type { Ref, WatchStopHandle } from "vue"; import type { Ref, WatchStopHandle } from "vue";
import { computed, unref, watch } from "vue"; import { computed, unref, watch } from "vue";
export const ChallengeType = Symbol("ChallengeType"); /** A symbol used to identify {@link Challenge} features. */
export const ChallengeType = Symbol("Challenge");
/**
* An object that configures a {@link Challenge}.
*/
export interface ChallengeOptions { export interface ChallengeOptions {
/** Whether this challenge should be visible. */
visibility?: Computable<Visibility | boolean>; visibility?: Computable<Visibility | boolean>;
/** Whether this challenge can be started. */
canStart?: Computable<boolean>; canStart?: Computable<boolean>;
/** The reset function for this challenge. */
reset?: GenericReset; reset?: GenericReset;
/** The requirement(s) to complete this challenge. */
requirements: Requirements; requirements: Requirements;
/** Whether or not completing this challenge should grant multiple completions if requirements met. Requires {@link requirements} to be a requirement or array of requirements with {@link Requirement.canMaximize} true. */
maximize?: Computable<boolean>; maximize?: Computable<boolean>;
/** The maximum number of times the challenge can be completed. */
completionLimit?: Computable<DecimalSource>; completionLimit?: Computable<DecimalSource>;
/** Shows a marker on the corner of the feature. */
mark?: Computable<boolean | string>; mark?: Computable<boolean | string>;
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>; classes?: Computable<Record<string, boolean>>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>; style?: Computable<StyleValue>;
/** The display to use for this challenge. */
display?: Computable< display?: Computable<
| CoercableComponent | CoercableComponent
| { | {
/** A header to appear at the top of the display. */
title?: CoercableComponent; title?: CoercableComponent;
/** The main text that appears in the display. */
description: CoercableComponent; description: CoercableComponent;
/** A description of the current goal for this challenge. */
goal?: CoercableComponent; goal?: CoercableComponent;
/** A description of what will change upon completing this challenge. */
reward?: CoercableComponent; reward?: CoercableComponent;
/** A description of the current effect of this challenge. */
effectDisplay?: CoercableComponent; effectDisplay?: CoercableComponent;
} }
>; >;
/** A function that is called when the challenge is completed. */
onComplete?: VoidFunction; onComplete?: VoidFunction;
/** A function that is called when the challenge is exited. */
onExit?: VoidFunction; onExit?: VoidFunction;
/** A function that is called when the challenge is entered. */
onEnter?: VoidFunction; onEnter?: VoidFunction;
} }
/**
* The properties that are added onto a processed {@link ChallengeOptions} to create a {@link Challenge}.
*/
export interface BaseChallenge { export interface BaseChallenge {
/** An auto-generated ID for identifying challenges that appear in the DOM. Will not persist between refreshes or updates. */
id: string; id: string;
/** The current amount of times this challenge can be completed. */
canComplete: Ref<DecimalSource>; canComplete: Ref<DecimalSource>;
/** The current number of times this challenge has been completed. */
completions: Persistent<DecimalSource>; completions: Persistent<DecimalSource>;
/** Whether or not this challenge has been completed. */
completed: Ref<boolean>; completed: Ref<boolean>;
/** Whether or not this challenge's completion count is at its limit. */
maxed: Ref<boolean>; maxed: Ref<boolean>;
/** Whether or not this challenge is currently active. */
active: Persistent<boolean>; active: Persistent<boolean>;
/** A function to enter or leave the challenge. */
toggle: VoidFunction; toggle: VoidFunction;
/**
* A function to complete this challenge.
* @param remainInChallenge - Optional parameter to specify if the challenge should remain active after completion.
*/
complete: (remainInChallenge?: boolean) => void; complete: (remainInChallenge?: boolean) => void;
/** A symbol that helps identify features of the same type. */
type: typeof ChallengeType; type: typeof ChallengeType;
/** The Vue component used to render this feature. */
[Component]: GenericComponent; [Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>; [GatherProps]: () => Record<string, unknown>;
} }
/** An object that represents a feature that can be entered and exited, and have one or more completions with scaling requirements. */
export type Challenge<T extends ChallengeOptions> = Replace< export type Challenge<T extends ChallengeOptions> = Replace<
T & BaseChallenge, T & BaseChallenge,
{ {
@ -92,6 +132,7 @@ export type Challenge<T extends ChallengeOptions> = Replace<
} }
>; >;
/** A type that matches any valid {@link Challenge} object. */
export type GenericChallenge = Replace< export type GenericChallenge = Replace<
Challenge<ChallengeOptions>, Challenge<ChallengeOptions>,
{ {
@ -102,6 +143,10 @@ export type GenericChallenge = Replace<
} }
>; >;
/**
* Lazily creates a challenge with the given options.
* @param optionsFunc Challenge options.
*/
export function createChallenge<T extends ChallengeOptions>( export function createChallenge<T extends ChallengeOptions>(
optionsFunc: OptionsFunc<T, BaseChallenge, GenericChallenge> optionsFunc: OptionsFunc<T, BaseChallenge, GenericChallenge>
): Challenge<T> { ): Challenge<T> {
@ -248,6 +293,12 @@ export function createChallenge<T extends ChallengeOptions>(
}); });
} }
/**
* This will automatically complete a challenge when it's requirements are met.
* @param challenge The challenge to auto-complete
* @param autoActive Whether or not auto-completing should currently occur
* @param exitOnComplete Whether or not to exit the challenge after auto-completion
*/
export function setupAutoComplete( export function setupAutoComplete(
challenge: GenericChallenge, challenge: GenericChallenge,
autoActive: Computable<boolean> = true, autoActive: Computable<boolean> = true,
@ -264,19 +315,27 @@ export function setupAutoComplete(
); );
} }
/**
* Utility for taking an array of challenges where only one may be active at a time, and giving a ref to the one currently active (or null if none are active)
* @param challenges The list of challenges that are mutually exclusive
*/
export function createActiveChallenge( export function createActiveChallenge(
challenges: GenericChallenge[] challenges: GenericChallenge[]
): Ref<GenericChallenge | undefined> { ): Ref<GenericChallenge | null> {
return computed(() => challenges.find(challenge => challenge.active.value)); return computed(() => challenges.find(challenge => challenge.active.value) ?? null);
} }
/**
* Utility for reporting if any challenge in a list is currently active. Intended for preventing entering a challenge if another is already active.
* @param challenges List of challenges that are mutually exclusive
*/
export function isAnyChallengeActive( export function isAnyChallengeActive(
challenges: GenericChallenge[] | Ref<GenericChallenge | undefined> challenges: GenericChallenge[] | Ref<GenericChallenge | null>
): Ref<boolean> { ): Ref<boolean> {
if (isArray(challenges)) { if (isArray(challenges)) {
challenges = createActiveChallenge(challenges); challenges = createActiveChallenge(challenges);
} }
return computed(() => (challenges as Ref<GenericChallenge | undefined>).value != null); return computed(() => (challenges as Ref<GenericChallenge | null>).value != null);
} }
declare module "game/settings" { declare module "game/settings" {

View file

@ -1,128 +0,0 @@
<template>
<div
v-if="isVisible(visibility)"
:style="[
{
visibility: isHidden(visibility) ? 'hidden' : undefined
},
unref(style) ?? {}
]"
:class="{ feature: true, milestone: true, done: unref(earned), ...unref(classes) }"
>
<component :is="unref(comp)" />
<Node :id="id" />
</div>
</template>
<script lang="tsx">
import "components/common/features.css";
import Node from "components/Node.vue";
import type { StyleValue } from "features/feature";
import { isHidden, isVisible, jsx, Visibility } from "features/feature";
import type { GenericMilestone } from "features/milestones/milestone";
import { coerceComponent, isCoercableComponent, processedPropType, unwrapRef } from "util/vue";
import type { Component, UnwrapRef } from "vue";
import { defineComponent, shallowRef, toRefs, unref, watchEffect } from "vue";
export default defineComponent({
props: {
visibility: {
type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true
},
display: {
type: processedPropType<UnwrapRef<GenericMilestone["display"]>>(
String,
Object,
Function
),
required: true
},
style: processedPropType<StyleValue>(String, Object, Array),
classes: processedPropType<Record<string, boolean>>(Object),
earned: {
type: processedPropType<boolean>(Boolean),
required: true
},
id: {
type: String,
required: true
}
},
components: {
Node
},
setup(props) {
const { display } = toRefs(props);
const comp = shallowRef<Component | string>("");
watchEffect(() => {
const currDisplay = unwrapRef(display);
if (currDisplay == null) {
comp.value = "";
return;
}
if (isCoercableComponent(currDisplay)) {
comp.value = coerceComponent(currDisplay);
return;
}
const Requirement = coerceComponent(currDisplay.requirement, "h3");
const EffectDisplay = coerceComponent(currDisplay.effectDisplay || "", "b");
const OptionsDisplay = coerceComponent(currDisplay.optionsDisplay || "", "span");
comp.value = coerceComponent(
jsx(() => (
<span>
<Requirement />
{currDisplay.effectDisplay != null ? (
<div>
<EffectDisplay />
</div>
) : null}
{currDisplay.optionsDisplay != null ? (
<div class="equal-spaced">
<OptionsDisplay />
</div>
) : null}
</span>
))
);
});
return {
comp,
unref,
Visibility,
isVisible,
isHidden
};
}
});
</script>
<style scoped>
.milestone {
width: calc(100% - 10px);
min-width: 120px;
padding-left: 5px;
padding-right: 5px;
background-color: var(--locked);
border-width: 4px;
border-radius: 5px;
color: rgba(0, 0, 0, 0.5);
}
.milestone.done {
background-color: var(--bought);
cursor: default;
}
.milestone :deep(.equal-spaced) {
display: flex;
justify-content: center;
}
.milestone :deep(.equal-spaced > *) {
margin: auto;
}
</style>

View file

@ -1,235 +0,0 @@
import Select from "components/fields/Select.vue";
import type {
CoercableComponent,
GenericComponent,
OptionsFunc,
Replace,
StyleValue
} from "features/feature";
import {
Component,
GatherProps,
getUniqueID,
isVisible,
jsx,
setDefault,
Visibility
} from "features/feature";
import MilestoneComponent from "features/milestones/Milestone.vue";
import { globalBus } from "game/events";
import "game/notifications";
import type { Persistent } from "game/persistence";
import { persistent } from "game/persistence";
import player from "game/player";
import settings, { registerSettingField } from "game/settings";
import { camelToTitle } from "util/common";
import type {
Computable,
GetComputableType,
GetComputableTypeWithDefault,
ProcessedComputable
} from "util/computed";
import { processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies";
import { coerceComponent, isCoercableComponent } from "util/vue";
import { computed, unref, watchEffect } from "vue";
import { useToast } from "vue-toastification";
const toast = useToast();
export const MilestoneType = Symbol("Milestone");
export enum MilestoneDisplay {
All = "all",
//Last = "last",
Configurable = "configurable",
Incomplete = "incomplete",
None = "none"
}
export interface MilestoneOptions {
visibility?: Computable<Visibility | boolean>;
shouldEarn?: () => boolean;
style?: Computable<StyleValue>;
classes?: Computable<Record<string, boolean>>;
display?: Computable<
| CoercableComponent
| {
requirement: CoercableComponent;
effectDisplay?: CoercableComponent;
optionsDisplay?: CoercableComponent;
}
>;
showPopups?: Computable<boolean>;
onComplete?: VoidFunction;
}
export interface BaseMilestone {
id: string;
earned: Persistent<boolean>;
complete: VoidFunction;
type: typeof MilestoneType;
[Component]: GenericComponent;
[GatherProps]: () => Record<string, unknown>;
}
export type Milestone<T extends MilestoneOptions> = Replace<
T & BaseMilestone,
{
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
style: GetComputableType<T["style"]>;
classes: GetComputableType<T["classes"]>;
display: GetComputableType<T["display"]>;
showPopups: GetComputableType<T["showPopups"]>;
}
>;
export type GenericMilestone = Replace<
Milestone<MilestoneOptions>,
{
visibility: ProcessedComputable<Visibility | boolean>;
}
>;
export function createMilestone<T extends MilestoneOptions>(
optionsFunc?: OptionsFunc<T, BaseMilestone, GenericMilestone>
): Milestone<T> {
const earned = persistent<boolean>(false, false);
return createLazyProxy(() => {
const milestone = optionsFunc?.() ?? ({} as ReturnType<NonNullable<typeof optionsFunc>>);
milestone.id = getUniqueID("milestone-");
milestone.type = MilestoneType;
milestone[Component] = MilestoneComponent as GenericComponent;
milestone.earned = earned;
milestone.complete = function () {
const genericMilestone = milestone as GenericMilestone;
earned.value = true;
genericMilestone.onComplete?.();
if (genericMilestone.display != null && unref(genericMilestone.showPopups) === true) {
const display = unref(genericMilestone.display);
const Display = coerceComponent(
isCoercableComponent(display) ? display : display.requirement
);
toast(
<>
<h3>Milestone earned!</h3>
<div>
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
{/* @ts-ignore */}
<Display />
</div>
</>
);
}
};
processComputable(milestone as T, "visibility");
setDefault(milestone, "visibility", Visibility.Visible);
const visibility = milestone.visibility as ProcessedComputable<Visibility | boolean>;
milestone.visibility = computed(() => {
const display = unref((milestone as GenericMilestone).display);
switch (settings.msDisplay) {
default:
case MilestoneDisplay.All:
return unref(visibility);
case MilestoneDisplay.Configurable:
if (
unref(milestone.earned) &&
!(
display != null &&
typeof display == "object" &&
"optionsDisplay" in (display as Record<string, unknown>)
)
) {
return Visibility.None;
}
return unref(visibility);
case MilestoneDisplay.Incomplete:
if (unref(milestone.earned)) {
return Visibility.None;
}
return unref(visibility);
case MilestoneDisplay.None:
return Visibility.None;
}
});
processComputable(milestone as T, "style");
processComputable(milestone as T, "classes");
processComputable(milestone as T, "display");
processComputable(milestone as T, "showPopups");
milestone[GatherProps] = function (this: GenericMilestone) {
const { visibility, display, style, classes, earned, id } = this;
return { visibility, display, style: unref(style), classes, earned, id };
};
if (milestone.shouldEarn) {
const genericMilestone = milestone as GenericMilestone;
watchEffect(() => {
if (settings.active !== player.id) return;
if (
!genericMilestone.earned.value &&
isVisible(genericMilestone.visibility) &&
genericMilestone.shouldEarn?.()
) {
genericMilestone.earned.value = true;
genericMilestone.onComplete?.();
if (
genericMilestone.display != null &&
unref(genericMilestone.showPopups) === true
) {
const display = unref(genericMilestone.display);
const Display = coerceComponent(
isCoercableComponent(display) ? display : display.requirement
);
toast(
<>
<h3>Milestone earned!</h3>
<div>
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
{/* @ts-ignore */}
<Display />
</div>
</>
);
}
}
});
}
return milestone as unknown as Milestone<T>;
});
}
declare module "game/settings" {
interface Settings {
msDisplay: MilestoneDisplay;
}
}
globalBus.on("loadSettings", settings => {
setDefault(settings, "msDisplay", MilestoneDisplay.All);
});
const msDisplayOptions = Object.values(MilestoneDisplay).map(option => ({
label: camelToTitle(option),
value: option
}));
registerSettingField(
jsx(() => (
<Select
title={jsx(() => (
<span class="option-title">
Show milestones
<desc>Select which milestones to display based on criterias.</desc>
</span>
))}
options={msDisplayOptions}
onUpdate:modelValue={value => (settings.msDisplay = value as MilestoneDisplay)}
modelValue={settings.msDisplay}
/>
))
);

View file

@ -1,8 +1,6 @@
import { globalBus } from "game/events"; import { globalBus } from "game/events";
import { NonPersistent, Persistent, State } from "game/persistence"; import type { Persistent, State } from "game/persistence";
import { persistent } from "game/persistence"; import { NonPersistent, persistent } from "game/persistence";
import player from "game/player";
import settings from "game/settings";
import type { DecimalSource } from "util/bignum"; import type { DecimalSource } from "util/bignum";
import Decimal, { format, formatWhole } from "util/bignum"; import Decimal, { format, formatWhole } from "util/bignum";
import type { ProcessedComputable } from "util/computed"; import type { ProcessedComputable } from "util/computed";