Profectus/src/features/clickables/action.tsx

190 lines
7.5 KiB
TypeScript
Raw Normal View History

Feature rewrite - Removed `jsx()` and `JSXFunction`. You can now use `JSX.Element` like any other `Computable` value - `joinJSX` now always requires a joiner. Just pass the array of elements or wrap them in `<>` and `</>` if there's no joiner - Removed `coerceComponent`, `computeComponent`, and `computeOptionalComponent`; just use the `render` function now - It's recommended to now do `<MyComponent />` instead of `<component :is="myComponent" />` - All features no longer take the options as a type parameter, and all generic forms have been removed as a result - Fixed `forceHideGoBack` not being respected - Removed `deepUnref` as now things don't get unreffed before being passed into vue components by default - Moved MarkNode to new wrapper, and removed existing `mark` properties - Moved Tooltip to new wrapper, and made it take an options function instead of raw object - VueFeature component now wraps all vue features, and applies styling, classes, and visibility in the wrapping div. It also adds the Node component so features don't need to - `mergeAdjacent` now works with grids (perhaps should've used scss to reduce the amount of css this took) - `CoercableComponent` renamed to `Renderable` since it should be used with `render` - Replaced `isCoercableComponent` with `isJSXElement` - Replaced `Computable` and `ProcessedComputable` with the vue built-ins `MaybeRefOrGetter` and `MaybeRef` - `convertComputable` renamed to `processGetter` - Also removed `GetComputableTypeWithDefault` and `GetComputableType`, which can similarly be replaced - `dontMerge` is now a property on rows and columns rather than an undocumented css class you'd have to include on every feature within the row or column - Fixed saves manager not being imported in addiction warning component - Created `vueFeatureMixin` for simplifying the vue specific parts of a feature. Passes the component's properties in explicitly and directly from the feature itself - All features should now return an object that includes props typed to omit the options object and satisfies the feature. This will ensure type correctness and pass-through custom properties. (see existing features for more thorough examples of changes) - Replaced decorators with mixins, which won't require casting. Bonus amount decorators converted into generic bonus amount mixin. Removed effect decorator - All `render` functions now return `JSX.Element`. The `JSX` variants (e.g. `renderJSX`) (except `joinJSX`) have been removed - Moved all features that use the clickable component into the clickable folder - Removed `small` property from clickable, since its a single css rule (`min-height: unset`) (you could add a small css class and pass small to any vue feature's classes property, though) - Upgrades now use the clickable component - Added ConversionType symbol - Removed setDefault, just use `??=` - Added isType function that uses a type symbol to check - General cleanup
2024-11-19 08:32:45 -06:00
import ClickableVue from "features/clickables/Clickable.vue";
import { findFeatures, OptionsFunc, Replace } from "features/feature";
import { globalBus } from "game/events";
import { persistent } from "game/persistence";
import Decimal, { DecimalSource } from "lib/break_eternity";
import { Unsubscribe } from "nanoevents";
import { Direction } from "util/common";
import { ProcessedRefOrGetter, processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies";
import { render, VueFeature, vueFeatureMixin } from "util/vue";
import { computed, MaybeRef, MaybeRefOrGetter, Ref, ref, unref } from "vue";
import { JSX } from "vue/jsx-runtime";
import { Bar, BarOptions, createBar } from "../bars/bar";
import { type Clickable, ClickableOptions } from "./clickable";
/** A symbol used to identify {@link Action} features. */
export const ActionType = Symbol("Action");
/**
* An object that configures an {@link Action}.
*/
export interface ActionOptions extends Omit<ClickableOptions, "onClick" | "onHold"> {
/** The cooldown during which the action cannot be performed again, in seconds. */
duration: MaybeRefOrGetter<DecimalSource>;
/** Whether or not the action should perform automatically when the cooldown is finished. */
autoStart?: MaybeRefOrGetter<boolean>;
/** A function that is called when the action is clicked. */
onClick: (amount: DecimalSource) => void;
/** A pass-through to the {@link Bar} used to display the cooldown progress for the action. */
barOptions?: Partial<BarOptions>;
}
/**
* The properties that are added onto a processed {@link ActionOptions} to create an {@link Action}.
*/
export interface BaseAction extends VueFeature {
/** Whether or not the player is holding down the action. Actions will be considered clicked as soon as the cooldown completes when being held down. */
isHolding: Ref<boolean>;
/** The current amount of progress through the cooldown. */
progress: Ref<DecimalSource>;
/** The bar used to display the current cooldown progress. */
progressBar: Bar;
/** Update the cooldown the specified number of seconds */
update: (diff: number) => void;
/** A symbol that helps identify features of the same type. */
type: typeof ActionType;
}
/** An object that represents a feature that can be clicked upon, and then has a cooldown before it can be clicked again. */
export type Action = Replace<
Replace<ActionOptions, BaseAction>,
{
duration: ProcessedRefOrGetter<ActionOptions["duration"]>;
autoStart: MaybeRef<boolean>;
canClick: MaybeRef<boolean>;
display: ProcessedRefOrGetter<ActionOptions["display"]>;
onClick: VoidFunction;
}
>;
/**
* Lazily creates an action with the given options.
* @param optionsFunc Action options.
*/
export function createAction<T extends ActionOptions>(
optionsFunc?: OptionsFunc<T, BaseAction, Action>
) {
const progress = persistent<DecimalSource>(0);
return createLazyProxy(feature => {
const options = optionsFunc?.call(feature, feature as Action) ?? ({} as T);
const { style, duration, canClick, autoStart, display, barOptions, onClick, ...props } =
options;
const processedCanClick = processGetter(canClick) ?? true;
const processedStyle = processGetter(style);
const progressBar = createBar(() => ({
direction: Direction.Right,
width: 100,
height: 10,
borderStyle: { borderColor: "black" },
baseStyle: { marginTop: "-1px" },
progress: (): DecimalSource => Decimal.div(progress.value, unref(action.duration)),
...(barOptions as Omit<typeof barOptions, keyof VueFeature>)
}));
let Component: () => JSX.Element;
if (typeof display === "object" && "description" in display) {
const title = processGetter(display.title);
const description = processGetter(display.description);
const Title = () => (title == null ? <></> : render(title, el => <h3>{el}</h3>));
const Description = () => render(description, el => <div>{el}</div>);
Component = () => {
return (
<span>
{title != null ? (
<div>
<Title />
</div>
) : null}
<Description />
</span>
);
};
} else if (display != null) {
const processedDisplay = processGetter(display);
Component = () => render(processedDisplay);
}
const action = {
type: ActionType,
...(props as Omit<typeof props, keyof VueFeature | keyof ActionOptions>),
...vueFeatureMixin(
"action",
{
...options,
style: () => ({
cursor: Decimal.gte(progress.value, unref(action.duration))
? "pointer"
: "progress",
display: "flex",
flexDirection: "column",
...unref(processedStyle)
})
},
() => (
<ClickableVue
canClick={action.canClick}
onClick={action.onClick}
display={action.display}
/>
)
),
progress,
isHolding: ref(false),
duration: processGetter(duration),
canClick: computed(
(): boolean =>
unref(processedCanClick) && Decimal.gte(progress.value, unref(action.duration))
),
autoStart: processGetter(autoStart) ?? false,
display: computed(() => (
<>
<div style="flex-grow: 1" />
{display == null ? null : <Component />}
<div style="flex-grow: 1" />
{render(progressBar)}
</>
)),
progressBar,
onClick: function () {
if (unref(action.canClick) === false) {
return;
}
const amount = Decimal.div(progress.value, unref(action.duration));
onClick?.call(action, amount);
progress.value = 0;
},
update: function (diff) {
const duration = unref(action.duration);
if (Decimal.gte(progress.value, duration)) {
progress.value = duration;
} else {
progress.value = Decimal.add(progress.value, diff);
if (action.isHolding.value || unref<boolean>(action.autoStart)) {
action.onClick();
}
}
}
} satisfies Action satisfies Replace<Clickable, { type: typeof ActionType }>;
return action;
});
}
const listeners: Record<string, Unsubscribe | undefined> = {};
globalBus.on("addLayer", layer => {
const actions: Action[] = findFeatures(layer, ActionType) as Action[];
listeners[layer.id] = layer.on("postUpdate", (diff: number) => {
actions.forEach(action => action.update(diff));
});
});
globalBus.on("removeLayer", layer => {
// unsubscribe from postUpdate
listeners[layer.id]?.();
listeners[layer.id] = undefined;
});