import Collapsible from "components/layout/Collapsible.vue"; import { GenericAchievement } from "features/achievements/achievement"; import type { Clickable, ClickableOptions, GenericClickable } from "features/clickables/clickable"; import { createClickable } from "features/clickables/clickable"; import type { GenericConversion } from "features/conversion"; import type { CoercableComponent, JSXFunction, OptionsFunc, Replace } from "features/feature"; import { jsx, setDefault } from "features/feature"; import { Resource, displayResource } from "features/resources/resource"; import type { GenericTree, GenericTreeNode, TreeNode, TreeNodeOptions } from "features/trees/tree"; import { createTreeNode } from "features/trees/tree"; import type { GenericFormula } from "game/formulas/types"; import type { Modifier } from "game/modifiers"; import type { Persistent } from "game/persistence"; import { DefaultValue, persistent } from "game/persistence"; import player from "game/player"; import settings from "game/settings"; import type { DecimalSource } from "util/bignum"; import Decimal, { format, formatSmall, formatTime } from "util/bignum"; import { WithRequired, camelToTitle } from "util/common"; import type { Computable, GetComputableType, GetComputableTypeWithDefault, ProcessedComputable } from "util/computed"; import { convertComputable, processComputable } from "util/computed"; import { getFirstFeature, renderColJSX, renderJSX } from "util/vue"; import type { ComputedRef, Ref } from "vue"; import { computed, unref } from "vue"; import "./common.css"; /** An object that configures a {@link ResetButton} */ export interface ResetButtonOptions extends ClickableOptions { /** The conversion the button uses to calculate how much resources will be gained on click */ conversion: GenericConversion; /** The tree this reset button is apart of */ tree: GenericTree; /** The specific tree node associated with this reset button */ treeNode: GenericTreeNode; /** * Text to display on low conversion amounts, describing what "resetting" is in this context. * Defaults to "Reset for ". */ resetDescription?: Computable; /** Whether or not to show how much currency would be required to make the gain amount increase. */ showNextAt?: Computable; /** * The content to display on the button. * By default, this includes the reset description, and amount of currency to be gained. */ display?: Computable; /** * Whether or not this button can currently be clicked. * Defaults to checking the current gain amount is greater than {@link minimumGain} */ canClick?: Computable; /** * When {@link canClick} is left to its default, minimumGain is used to only enable the reset button when a sufficient amount of currency to gain is available. */ minimumGain?: Computable; /** A persistent ref to track how much time has passed since the last time this tree node was reset. */ resetTime?: Persistent; } /** * A button that is used to control a conversion. * It will show how much can be converted currently, and can show when that amount will go up, as well as handle only being clickable when a sufficient amount of currency can be gained. * Assumes this button is associated with a specific node on a tree, and triggers that tree's reset propagation. */ export type ResetButton = Replace< Clickable, { resetDescription: GetComputableTypeWithDefault>; showNextAt: GetComputableTypeWithDefault; display: GetComputableTypeWithDefault>; canClick: GetComputableTypeWithDefault>; minimumGain: GetComputableTypeWithDefault; onClick: (event?: MouseEvent | TouchEvent) => void; } >; /** A type that matches any valid {@link ResetButton} object. */ export type GenericResetButton = Replace< GenericClickable & ResetButton, { resetDescription: ProcessedComputable; showNextAt: ProcessedComputable; display: ProcessedComputable; canClick: ProcessedComputable; minimumGain: ProcessedComputable; } >; /** * Lazily creates a reset button with the given options. * @param optionsFunc A function that returns the options object for this reset button. */ export function createResetButton( optionsFunc: OptionsFunc ): ResetButton { return createClickable(feature => { const resetButton = optionsFunc.call(feature, feature); processComputable(resetButton as T, "showNextAt"); setDefault(resetButton, "showNextAt", true); setDefault(resetButton, "minimumGain", 1); if (resetButton.resetDescription == null) { resetButton.resetDescription = computed(() => Decimal.lt(resetButton.conversion.gainResource.value, 1e3) ? "Reset for " : "" ); } else { processComputable(resetButton as T, "resetDescription"); } if (resetButton.display == null) { resetButton.display = jsx(() => ( {unref(resetButton.resetDescription as ProcessedComputable)} {displayResource( resetButton.conversion.gainResource, Decimal.max( unref(resetButton.conversion.actualGain), unref(resetButton.minimumGain as ProcessedComputable) ) )} {" "} {resetButton.conversion.gainResource.displayName} {unref(resetButton.showNextAt) != null ? (

{unref(resetButton.conversion.buyMax) ? "Next:" : "Req:"}{" "} {displayResource( resetButton.conversion.baseResource, unref(resetButton.conversion.buyMax) || Decimal.lt(unref(resetButton.conversion.actualGain), 1) ? unref(resetButton.conversion.currentAt) : unref(resetButton.conversion.nextAt) )}{" "} {resetButton.conversion.baseResource.displayName}
) : null}
)); } if (resetButton.canClick == null) { resetButton.canClick = computed(() => Decimal.gte( unref(resetButton.conversion.actualGain), unref(resetButton.minimumGain as ProcessedComputable) ) ); } const onClick = resetButton.onClick; resetButton.onClick = function (event?: MouseEvent | TouchEvent) { if (unref(resetButton.canClick) === false) { return; } resetButton.conversion.convert(); resetButton.tree.reset(resetButton.treeNode); if (resetButton.resetTime) { resetButton.resetTime.value = resetButton.resetTime[DefaultValue]; } onClick?.(event); }; return resetButton; }) as unknown as ResetButton; } /** An object that configures a {@link LayerTreeNode} */ export interface LayerTreeNodeOptions extends TreeNodeOptions { /** The ID of the layer this tree node is associated with */ layerID: string; /** The color to display this tree node as */ color: Computable; // marking as required /** Whether or not to append the layer to the tabs list. * If set to false, then the tree node will instead always remove all tabs to its right and then add the layer tab. * Defaults to true. */ append?: Computable; } /** A tree node that is associated with a given layer, and which opens the layer when clicked. */ export type LayerTreeNode = Replace< TreeNode, { display: GetComputableTypeWithDefault; append: GetComputableType; } >; /** A type that matches any valid {@link LayerTreeNode} object. */ export type GenericLayerTreeNode = Replace< LayerTreeNode, { display: ProcessedComputable; append?: ProcessedComputable; } >; /** * Lazily creates a tree node that's associated with a specific layer, with the given options. * @param optionsFunc A function that returns the options object for this tree node. */ export function createLayerTreeNode( optionsFunc: OptionsFunc ): LayerTreeNode { return createTreeNode(feature => { const options = optionsFunc.call(feature, feature); setDefault(options, "display", camelToTitle(options.layerID)); processComputable(options as T, "append"); return { ...options, onClick: unref((options as unknown as GenericLayerTreeNode).append) ? function () { if (player.tabs.includes(options.layerID)) { const index = player.tabs.lastIndexOf(options.layerID); player.tabs.splice(index, 1); } else { player.tabs.push(options.layerID); } } : function () { player.tabs.splice(1, 1, options.layerID); } }; }) as unknown as LayerTreeNode; } /** An option object for a modifier display as a single section. **/ export interface Section { /** The header for this modifier. **/ title: Computable; /** A subtitle for this modifier, e.g. to explain the context for the modifier. **/ subtitle?: Computable; /** The modifier to be displaying in this section. **/ modifier: WithRequired; /** The base value being modified. **/ base?: Computable; /** The unit of measurement for the base. **/ unit?: string; /** The label to call the base amount. Defaults to "Base". **/ baseText?: Computable; /** Whether or not this section should be currently visible to the player. **/ visible?: Computable; /** Determines if numbers larger or smaller than the base should be displayed as red. */ smallerIsBetter?: boolean; } /** * Takes an array of modifier "sections", and creates a JSXFunction that can render all those sections, and allow each section to be collapsed. * Also returns a list of persistent refs that are used to control which sections are currently collapsed. * @param sectionsFunc A function that returns the sections to display. */ export function createCollapsibleModifierSections( sectionsFunc: () => Section[] ): [JSXFunction, Persistent>] { const sections: Section[] = []; const processed: | { base: ProcessedComputable[]; baseText: ProcessedComputable[]; visible: ProcessedComputable[]; title: ProcessedComputable[]; subtitle: ProcessedComputable[]; } | Record = {}; let calculated = false; function calculateSections() { if (!calculated) { sections.push(...sectionsFunc()); processed.base = sections.map(s => convertComputable(s.base)); processed.baseText = sections.map(s => convertComputable(s.baseText)); processed.visible = sections.map(s => convertComputable(s.visible)); processed.title = sections.map(s => convertComputable(s.title)); processed.subtitle = sections.map(s => convertComputable(s.subtitle)); calculated = true; } return sections; } const collapsed = persistent>({}, false); const jsxFunc = jsx(() => { const sections = calculateSections(); let firstVisibleSection = true; const sectionJSX = sections.map((s, i) => { if (unref(processed.visible[i]) === false) return null; const header = (

(collapsed.value[i] = !collapsed.value[i])} style="cursor: pointer" > {unref(processed.title[i])} {unref(processed.subtitle[i]) != null ? ( ({unref(processed.subtitle[i])}) ) : null}

); const modifiers = unref(collapsed.value[i]) ? null : ( <>
{renderJSX(unref(processed.baseText[i]) ?? "Base")} {format(unref(processed.base[i]) ?? 1)} {s.unit}
{s.modifier.description == null ? null : renderJSX(unref(s.modifier.description))} ); const hasPreviousSection = !firstVisibleSection; firstVisibleSection = false; const base = unref(processed.base[i]) ?? 1; const total = s.modifier.apply(base); return ( <> {hasPreviousSection ?
: null}
{header}
{modifiers}
Total {formatSmall(total)} {s.unit}
); }); return <>{sectionJSX}; }); return [jsxFunc, collapsed]; } /** * Creates an HTML string for a span that writes some given text in a given color. * @param textToColor The content to change the color of * @param color The color to change the content to look like. Defaults to the current theme's accent 2 variable. */ export function colorText(textToColor: string, color = "var(--accent2)"): JSX.Element { return {textToColor}; } /** * Creates a collapsible display of a list of achievements * @param achievements A dictionary of the achievements to display, inserted in the order from easiest to hardest */ export function createCollapsibleAchievements(achievements: Record) { // Achievements are typically defined from easiest to hardest, and we want to show hardest first const orderedAchievements = Object.values(achievements).reverse(); const collapseAchievements = persistent(true, false); const lockedAchievements = computed(() => orderedAchievements.filter(m => m.earned.value === false) ); const { firstFeature, collapsedContent, hasCollapsedContent } = getFirstFeature( orderedAchievements, m => m.earned.value ); const display = jsx(() => { const achievementsToDisplay = [...lockedAchievements.value]; if (firstFeature.value) { achievementsToDisplay.push(firstFeature.value); } return renderColJSX( ...achievementsToDisplay, jsx(() => ( )) ); }); return { collapseAchievements: collapseAchievements, display }; } /** * Utility function for getting an ETA for when a target will be reached by a resource with a known (and assumed consistent) gain. * @param resource The resource that will be increasing over time. * @param rate The rate at which the resource is increasing. * @param target The target amount of the resource to estimate the duration until. */ export function estimateTime( resource: Resource, rate: Computable, target: Computable ) { const processedRate = convertComputable(rate); const processedTarget = convertComputable(target); return computed(() => { const currRate = unref(processedRate); const currTarget = unref(processedTarget); if (Decimal.gte(resource.value, currTarget)) { return "Now"; } else if (Decimal.lte(currRate, 0)) { return "Never"; } return formatTime(Decimal.sub(currTarget, resource.value).div(currRate)); }); } /** * Utility function for displaying the result of a formula such that it will, when told to, preview how the formula's result will change. * Requires a formula with a single variable inside. * @param formula The formula to display the result of. * @param showPreview Whether or not to preview how the formula's result will change. * @param previewAmount The amount to _add_ to the current formula's variable amount to preview the change in result. */ export function createFormulaPreview( formula: GenericFormula, showPreview: Computable, previewAmount: Computable = 1 ) { const processedShowPreview = convertComputable(showPreview); const processedPreviewAmount = convertComputable(previewAmount); if (!formula.hasVariable()) { throw new Error("Cannot create formula preview if the formula does not have a variable"); } return jsx(() => { if (unref(processedShowPreview)) { const curr = formatSmall(formula.evaluate()); const preview = formatSmall( formula.evaluate( Decimal.add( unref(formula.innermostVariable ?? 0), unref(processedPreviewAmount) ) ) ); return ( <> {curr} → {preview} ); } return <>{formatSmall(formula.evaluate())}; }); }