Merge remote-tracking branch 'template/main'

This commit is contained in:
thepaperpilot 2022-07-13 23:40:26 -05:00
commit 38e495fe01
40 changed files with 1038 additions and 11867 deletions

View file

@ -1,3 +0,0 @@
{
"CurrentProjectSetting": null
}

View file

@ -1,10 +0,0 @@
{
"ExpandedNodes": [
"",
"\\src",
"\\src\\typings",
"\\src\\util"
],
"SelectedNode": "\\src\\App.vue",
"PreviewInSolutionExplorer": false
}

Binary file not shown.

23
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,23 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "pwa-node",
"request": "launch",
"name": "Debug Current Test File",
"autoAttachChildProcesses": true,
"skipFiles": [
"<node_internals>/**",
"**/node_modules/**"
],
"program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
"args": [
"run",
"${relativeFile}"
],
"smartStep": true,
"console": "integratedTerminal"
}
]
}

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"vitest.commandLine": "npx vitest"
}

View file

@ -6,6 +6,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.5.0] - 2022-06-27
### Added
- Projects now cache for offline play, and show notification when an update is available
- Projects can now be "installed" as a Progressive Web App
- Conversions can now be given a custom spend function, which defaults to setting the base resource amount to 0
- Components for displaying Floor and Square Root symbols
### Changed
- **BREAKING** Several projInfo properties now default to empty strings, to prevent things like reusing project IDs
- **BREAKING** Replaced vue-cli-service with vite (should not break most projects)
- Updated dependencies
- Made all type-only imports explicit
- setupPassiveGeneration now works properly on independent conversions
- setupPassiveGeneration now takes an option cap it can't go over
- Improved typing for PlayerData.layers
- Options Functions have an improved `this` type - it now includes the options themselves
- Removed v-show being used in data/common.tsx
### Tests
- Implement Jest, and running tests automatically on push
- Tests written for utils/common.ts
## [0.4.2] - 2022-05-23
### Added
- costModifier to conversions

View file

@ -13,7 +13,7 @@ npm install
### Hosts dev server and hot-reloads modules as they're changed
```
npm starts
npm start
```
### Compiles and minifies for production
@ -30,3 +30,8 @@ npm run preview
```
npm run lint
```
### Runs the tests using vite-jest
```
npm run test
```

View file

@ -1,7 +0,0 @@
module.exports = {
preset: "vite-jest",
testEnvironment: "jest-environment-jsdom",
moduleNameMapper: {
"^./../([^.].*)$": "$1"
}
};

12178
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,20 +1,20 @@
{
"name": "profectus",
"version": "0.4.2",
"version": "0.5.0",
"private": true,
"scripts": {
"start": "vite",
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vite-jest --no-cache"
"test": "vitest run",
"testw": "vitest"
},
"dependencies": {
"@pixi/particle-emitter": "^5.0.4",
"@vitejs/plugin-vue": "^2.3.3",
"@vitejs/plugin-vue-jsx": "^1.3.10",
"is-plain-object": "^5.0.0",
"lodash.clonedeep": "^4.5.0",
"lz-string": "^1.4.4",
"nanoevents": "^6.0.2",
"pixi.js": "^6.3.0",
@ -32,25 +32,16 @@
"devDependencies": {
"@ivanv/vue-collapse-transition": "^1.0.2",
"@rushstack/eslint-patch": "^1.1.0",
"@types/jest": "^28.1.3",
"@types/lodash.clonedeep": "^4.5.6",
"@types/lz-string": "^1.3.34",
"@vue/eslint-config-prettier": "^7.0.0",
"@vue/eslint-config-typescript": "^10.0.0",
"babel-jest": "^28.1.1",
"eslint": "^8.6.0",
"jest": "^27.5.1",
"jest-environment-jsdom": "^27.5.1",
"jsdom": "^20.0.0",
"prettier": "^2.5.1",
"typescript": "~4.5.5",
"vite-jest": "^0.1.4",
"typescript": "^4.7.4",
"vitest": "^0.17.1",
"vue-tsc": "^0.38.1"
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
],
"engines": {
"node": "16.x"
}

View file

@ -9,13 +9,14 @@ import { computed, inject, onUnmounted, ref, toRefs, unref, watch } from "vue";
const _props = defineProps<{ id: string }>();
const props = toRefs(_props);
const register = inject(RegisterNodeInjectionKey);
const unregister = inject(UnregisterNodeInjectionKey);
// eslint-disable-next-line @typescript-eslint/no-empty-function
const register = inject(RegisterNodeInjectionKey, () => {});
// eslint-disable-next-line @typescript-eslint/no-empty-function
const unregister = inject(UnregisterNodeInjectionKey, () => {});
const node = ref<HTMLElement | null>(null);
const parentNode = computed(() => node.value && node.value.parentElement);
if (register && unregister) {
watch([parentNode, props.id], ([newNode, newID], [prevNode, prevID]) => {
if (prevNode) {
unregister(unref(prevID));
@ -26,13 +27,11 @@ if (register && unregister) {
});
onUnmounted(() => unregister(unref(props.id)));
}
</script>
<style scoped>
.node {
position: absolute;
z-index: -10;
top: 0;
left: 0;
width: 100%;

42
src/components/Notif.vue Normal file
View file

@ -0,0 +1,42 @@
<template>
<div class="notif">!</div>
</template>
<script setup lang="ts"></script>
<style scoped>
.notif {
position: absolute;
top: 0;
left: 5px;
z-index: 10;
pointer-events: none;
color: var(--accent3);
font-size: x-large;
animation: 1s linear infinite bounce;
border-radius: var(--border-radius);
background: var(--locked);
}
@keyframes bounce {
0% {
animation-timing-function: cubic-bezier(0.1361, 0.2514, 0.2175, 0.8786);
transform: translate(0, 0px) scaleY(1);
}
37% {
animation-timing-function: cubic-bezier(0.7674, 0.1844, 0.8382, 0.7157);
transform: translate(0, -20px) scaleY(1);
}
72% {
animation-timing-function: cubic-bezier(0.1118, 0.2149, 0.2172, 0.941);
transform: translate(0, 0px) scaleY(1);
}
87% {
animation-timing-function: cubic-bezier(0.7494, 0.2259, 0.8209, 0.6963);
transform: translate(0, 10px) scaleY(0.602);
}
100% {
transform: translate(0, 0px) scaleY(1);
}
}
</style>

View file

@ -1,5 +1,6 @@
<template>
<span style="white-space: nowrap;">
<span style="font-size: larger; font-family: initial">&radic;</span><span style="text-decoration: overline">&nbsp;<slot />&nbsp;</span>
<span style="white-space: nowrap">
<span style="font-size: larger; font-family: initial">&radic;</span
><span style="text-decoration: overline"><slot /></span>
</span>
</template>

View file

@ -25,18 +25,44 @@ import type { 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<string>;
/** Whether or not to show how much currency would be required to make the gain amount increase. */
showNextAt?: Computable<boolean>;
/**
* The content to display on the button.
* By default, this includes the reset description, and amount of currency to be gained.
*/
display?: Computable<CoercableComponent>;
/**
* Whether or not this button can currently be clicked.
* Defaults to checking the current gain amount is greater than {@link minimumGain}
*/
canClick?: Computable<boolean>;
/**
* 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<DecimalSource>;
/** A persistent ref to track how much time has passed since the last time this tree node was reset. */
resetTime?: Persistent<DecimalSource>;
}
/**
* 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<T extends ResetButtonOptions> = Replace<
Clickable<T>,
{
@ -49,6 +75,7 @@ export type ResetButton<T extends ResetButtonOptions> = Replace<
}
>;
/** A type that matches any valid {@link ResetButton} object. */
export type GenericResetButton = Replace<
GenericClickable & ResetButton<ResetButtonOptions>,
{
@ -60,6 +87,10 @@ export type GenericResetButton = Replace<
}
>;
/**
* 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<T extends ClickableOptions & ResetButtonOptions>(
optionsFunc: OptionsFunc<T>
): ResetButton<T> {
@ -136,12 +167,24 @@ export function createResetButton<T extends ClickableOptions & ResetButtonOption
}) as unknown as ResetButton<T>;
}
/** 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<string>; // marking as required
/**
* The content to display in the tree node.
* Defaults to the layer's ID
*/
display?: Computable<CoercableComponent>;
/** 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<boolean>;
}
/** A tree node that is associated with a given layer, and which opens the layer when clicked. */
export type LayerTreeNode<T extends LayerTreeNodeOptions> = Replace<
TreeNode<T>,
{
@ -149,6 +192,7 @@ export type LayerTreeNode<T extends LayerTreeNodeOptions> = Replace<
append: GetComputableType<T["append"]>;
}
>;
/** A type that matches any valid {@link LayerTreeNode} object. */
export type GenericLayerTreeNode = Replace<
LayerTreeNode<LayerTreeNodeOptions>,
{
@ -157,6 +201,10 @@ export type GenericLayerTreeNode = Replace<
}
>;
/**
* 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<T extends LayerTreeNodeOptions>(
optionsFunc: OptionsFunc<T>
): LayerTreeNode<T> {
@ -184,6 +232,18 @@ export function createLayerTreeNode<T extends LayerTreeNodeOptions>(
}) as unknown as LayerTreeNode<T>;
}
/**
* 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 sections An array of options objects for each section to display.
* @param sections.title The header for this modifier.
* @param sections.subtitle A subtitle for this modifier, e.g. to explain the context for the modifier.
* @param sections.modifier The modifier to be displaying in this section.
* @param sections.base The base value being modified.
* @param sections.unit The unit of measurement for the base.
* @param sections.baseText The label to call the base amount.
* @param sections.visible Whether or not this section should be currently visible to the player.
*/
export function createCollapsibleModifierSections(
sections: {
title: string;
@ -248,3 +308,12 @@ export function createCollapsibleModifierSections(
});
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)"): string {
return `<span style="color: ${color}">${textToColor}</span>`;
}

View file

@ -99,15 +99,27 @@ export const main = createLayer("main", () => {
};
});
/**
* Given a player save data object being loaded, return a list of layers that should currently be enabled.
* If your project does not use dynamic layers, this should just return all layers.
*/
export const getInitialLayers = (
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
player: Partial<PlayerData>
): Array<GenericLayer> => [main, f, c, a];
/**
* A computed ref whose value is true whenever the game is over.
*/
export const hasWon = computed(() => {
return Decimal.gt(main.points.value, 25);
});
/**
* Given a player save data object being loaded with a different version, update the save data object to match the structure of the current version.
* @param oldVersion The version of the save being loaded in
* @param player The save data being loaded in
*/
/* eslint-disable @typescript-eslint/no-unused-vars */
export function fixOldSave(
oldVersion: string | undefined,

View file

@ -1,3 +1,4 @@
/** A object of all CSS variables determined by the current theme. */
export interface ThemeVars {
"--foreground": string;
"--background": string;
@ -19,14 +20,20 @@ export interface ThemeVars {
"--feature-margin": string;
}
/** An object representing a theme the player can use to change the look of the game. */
export interface Theme {
/** The values of the theme's CSS variables. */
variables: ThemeVars;
/** Whether or not tabs should "float" in the center of their container. */
floatingTabs: boolean;
/** Whether or not adjacent features should merge together - removing the margin between them, and only applying the border radius to the first and last elements in the row or column. */
mergeAdjacent: boolean;
/** Whether or not to show a pin icon on pinned tooltips. */
showPin: boolean;
}
declare module "@vue/runtime-dom" {
/** Make CSS properties accept any CSS variables usually controlled by a theme. */
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface CSSProperties extends Partial<ThemeVars> {}
@ -62,6 +69,7 @@ const defaultTheme: Theme = {
showPin: true
};
/** An enum of all available themes and their internal IDs. The keys are their display names. */
export enum Themes {
Classic = "classic",
Paper = "paper",
@ -69,6 +77,7 @@ export enum Themes {
Aqua = "aqua"
}
/** A dictionary of all available themes. */
export default {
classic: defaultTheme,
paper: {

View file

@ -13,6 +13,8 @@ import {
import "game/notifications";
import type { Persistent } from "game/persistence";
import { persistent } from "game/persistence";
import player from "game/player";
import settings from "game/settings";
import type {
Computable,
GetComputableType,
@ -99,6 +101,7 @@ export function createAchievement<T extends AchievementOptions>(
if (achievement.shouldEarn) {
const genericAchievement = achievement as GenericAchievement;
watchEffect(() => {
if (settings.active !== player.id) return;
if (
!genericAchievement.earned.value &&
unref(genericAchievement.visibility) === Visibility.Visible &&

View file

@ -21,12 +21,9 @@
unref(borderStyle) ?? {}
]"
>
<component
v-if="component"
class="overlayText"
:style="unref(textStyle)"
:is="component"
/>
<span v-if="component" class="overlayText" :style="unref(textStyle)">
<component :is="component" />
</span>
</div>
<div
class="border"

View file

@ -24,8 +24,9 @@ export type BuyableDisplay =
| CoercableComponent
| {
title?: CoercableComponent;
description: CoercableComponent;
description?: CoercableComponent;
effectDisplay?: CoercableComponent;
showAmount?: boolean;
};
export interface BuyableOptions {
@ -168,17 +169,8 @@ export function createBuyable<T extends BuyableOptions>(
if (currDisplay != null && buyable.cost != null && buyable.resource != null) {
const genericBuyable = buyable as GenericBuyable;
const Title = coerceComponent(currDisplay.title || "", "h3");
const Description = coerceComponent(currDisplay.description);
const Description = coerceComponent(currDisplay.description || "");
const EffectDisplay = coerceComponent(currDisplay.effectDisplay || "");
const amountDisplay =
unref(genericBuyable.purchaseLimit) === Decimal.dInf ? (
<>Amount: {formatWhole(genericBuyable.amount.value)}</>
) : (
<>
Amount: {formatWhole(genericBuyable.amount.value)} /{" "}
{formatWhole(unref(genericBuyable.purchaseLimit))}
</>
);
return (
<span>
@ -187,11 +179,20 @@ export function createBuyable<T extends BuyableOptions>(
<Title />
</div>
) : null}
<Description />
{currDisplay.description ? <Description /> : null}
{currDisplay.showAmount === false ? null : (
<div>
<br />
{amountDisplay}
{unref(genericBuyable.purchaseLimit) === Decimal.dInf ? (
<>Amount: {formatWhole(genericBuyable.amount.value)}</>
) : (
<>
Amount: {formatWhole(genericBuyable.amount.value)} /{" "}
{formatWhole(unref(genericBuyable.purchaseLimit))}
</>
)}
</div>
)}
{currDisplay.effectDisplay ? (
<div>
<br />

View file

@ -12,12 +12,10 @@ import { createLazyProxy } from "util/proxies";
import type { Ref } from "vue";
import { computed, unref } from "vue";
/**
* An object that configures a {@link conversion}.
*/
/** An object that configures a {@link Conversion}. */
export interface ConversionOptions {
/**
* The scaling function that is used to determine the rate of conversion from one {@link resource} to the other.
* The scaling function that is used to determine the rate of conversion from one {@link features/resources/resource.Resource} to the other.
*/
scaling: ScalingFunction;
/**
@ -43,11 +41,11 @@ export interface ConversionOptions {
*/
nextAt?: Computable<DecimalSource>;
/**
* The input {@link resource} for this conversion.
* The input {@link features/resources/resource.Resource} for this conversion.
*/
baseResource: Resource;
/**
* The output {@link resource} for this conversion. i.e. the resource being generated.
* The output {@link features/resources/resource.Resource} for this conversion. i.e. the resource being generated.
*/
gainResource: Resource;
/**
@ -78,7 +76,7 @@ export interface ConversionOptions {
/**
* An additional modifier that will be applied to the gain amounts.
* Must be reversible in order to correctly calculate {@link nextAt}.
* @see {@link createSequentialModifier} if you want to apply multiple modifiers.
* @see {@link game/modifiers.createSequentialModifier} if you want to apply multiple modifiers.
*/
gainModifier?: WithRequired<Modifier, "revert">;
/**
@ -86,7 +84,7 @@ export interface ConversionOptions {
* That is to say, this modifier will be applied to the amount of baseResource before going into the scaling function.
* A cost modifier of x0.5 would give gain amounts equal to the player having half the baseResource they actually have.
* Must be reversible in order to correctly calculate {@link nextAt}.
* @see {@link createSequentialModifier} if you want to apply multiple modifiers.
* @see {@link game/modifiers.createSequentialModifier} if you want to apply multiple modifiers.
*/
costModifier?: WithRequired<Modifier, "revert">;
}
@ -101,9 +99,7 @@ export interface BaseConversion {
convert: VoidFunction;
}
/**
* An object that converts one {@link resource} into another at a given rate.
*/
/** An object that converts one {@link features/resources/resource.Resource} into another at a given rate. */
export type Conversion<T extends ConversionOptions> = Replace<
T & BaseConversion,
{
@ -117,9 +113,7 @@ export type Conversion<T extends ConversionOptions> = Replace<
}
>;
/**
* A type that matches any {@link conversion} object.
*/
/** A type that matches any valid {@link Conversion} object. */
export type GenericConversion = Replace<
Conversion<ConversionOptions>,
{

View file

@ -5,12 +5,12 @@ import { isRef } from "vue";
/**
* A symbol to use as a key for a vue component a feature can be rendered with
* @see {@link VueFeature}
* @see {@link util/vue.VueFeature}
*/
export const Component = Symbol("Component");
/**
* A symbol to use as a key for a prop gathering function that a feature can use to send to its component
* @see {@link VueFeature}
* @see {@link util/vue.VueFeature}
*/
export const GatherProps = Symbol("GatherProps");

View file

@ -14,8 +14,9 @@
<script setup lang="ts">
import type { Link } from "features/links/links";
import type { FeatureNode } from "game/layers";
import { BoundsInjectionKey, NodesInjectionKey } from "game/layers";
import { computed, inject, ref, toRef, watch } from "vue";
import { computed, inject, onMounted, ref, toRef, watch } from "vue";
import LinkVue from "./Link.vue";
const _props = defineProps<{ links?: Link[] }>();
@ -23,15 +24,14 @@ const links = toRef(_props, "links");
const resizeListener = ref<Element | null>(null);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const nodes = inject(NodesInjectionKey)!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const outerBoundingRect = inject(BoundsInjectionKey)!;
const boundingRect = ref<DOMRect | undefined>(undefined);
const nodes = inject(NodesInjectionKey, ref<Record<string, FeatureNode | undefined>>({}));
const outerBoundingRect = inject(BoundsInjectionKey, ref<DOMRect | undefined>(undefined));
const boundingRect = ref<DOMRect | undefined>(resizeListener.value?.getBoundingClientRect());
watch(
[outerBoundingRect],
outerBoundingRect,
() => (boundingRect.value = resizeListener.value?.getBoundingClientRect())
);
onMounted(() => (boundingRect.value = resizeListener.value?.getBoundingClientRect()));
const validLinks = computed(() => {
const n = nodes.value;

View file

@ -6,6 +6,7 @@ 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 {
@ -132,6 +133,7 @@ export function createMilestone<T extends MilestoneOptions>(
if (milestone.shouldEarn) {
const genericMilestone = milestone as GenericMilestone;
watchEffect(() => {
if (settings.active !== player.id) return;
if (
!genericMilestone.earned.value &&
unref(genericMilestone.visibility) === Visibility.Visible &&

View file

@ -46,7 +46,7 @@ export default defineComponent({
backgroundAlpha: 0
});
resizeListener.value?.appendChild(app.value.view);
props.onInit(app.value as Application);
props.onInit?.(app.value as Application);
}
updateBounds();
if (props.onHotReload) {

View file

@ -1,21 +1,30 @@
<template>
<div>
<Sticky>
<div
class="main-display-container"
:style="{ height: `${(effectRef?.$el.clientHeight ?? 0) + 50}px` }"
>
<div class="main-display">
<span v-if="showPrefix">You have </span>
<ResourceVue :resource="resource" :color="color || 'white'" />
{{ resource.displayName
}}<!-- remove whitespace -->
<span v-if="effectComponent">, <component :is="effectComponent" /></span>
<br /><br />
<span v-if="effectComponent"
>, <component :is="effectComponent" ref="effectRef"
/></span>
</div>
</div>
</Sticky>
</template>
<script setup lang="ts">
import Sticky from "components/layout/Sticky.vue";
import type { CoercableComponent } from "features/feature";
import type { Resource } from "features/resources/resource";
import ResourceVue from "features/resources/Resource.vue";
import Decimal from "util/bignum";
import { computeOptionalComponent } from "util/vue";
import type { Ref, StyleValue } from "vue";
import { ComponentPublicInstance, ref, Ref, StyleValue } from "vue";
import { computed, toRefs } from "vue";
const _props = defineProps<{
@ -27,6 +36,8 @@ const _props = defineProps<{
}>();
const props = toRefs(_props);
const effectRef = ref<ComponentPublicInstance | null>(null);
const effectComponent = computeOptionalComponent(
props.effectDisplay as Ref<CoercableComponent | undefined>
);
@ -35,3 +46,11 @@ const showPrefix = computed(() => {
return Decimal.lt(props.resource.value, "1e1000");
});
</script>
<style>
.main-display-container {
vertical-align: middle;
margin-bottom: 20px;
display: flex;
}
</style>

View file

@ -11,7 +11,11 @@
tabStyle ?? []
]"
>
<Sticky class="tab-buttons-container">
<Sticky
class="tab-buttons-container"
:class="unref(buttonContainerClasses)"
:style="unref(buttonContainerStyle)"
>
<div class="tab-buttons" :class="{ floating }">
<TabButton
v-for="(button, id) in unref(tabs)"
@ -61,7 +65,9 @@ export default defineComponent({
required: true
},
style: processedPropType<StyleValue>(String, Object, Array),
classes: processedPropType<Record<string, boolean>>(Object)
classes: processedPropType<Record<string, boolean>>(Object),
buttonContainerStyle: processedPropType<StyleValue>(String, Object, Array),
buttonContainerClasses: processedPropType<Record<string, boolean>>(Object)
},
components: {
Sticky,

View file

@ -56,6 +56,8 @@ export interface TabFamilyOptions {
visibility?: Computable<Visibility>;
classes?: Computable<Record<string, boolean>>;
style?: Computable<StyleValue>;
buttonContainerClasses?: Computable<Record<string, boolean>>;
buttonContainerStyle?: Computable<StyleValue>;
}
export interface BaseTabFamily {
@ -140,10 +142,30 @@ export function createTabFamily<T extends TabFamilyOptions>(
setDefault(tabFamily, "visibility", Visibility.Visible);
processComputable(tabFamily as T, "classes");
processComputable(tabFamily as T, "style");
processComputable(tabFamily as T, "buttonContainerClasses");
processComputable(tabFamily as T, "buttonContainerStyle");
tabFamily[GatherProps] = function (this: GenericTabFamily) {
const { visibility, activeTab, selected, tabs, style, classes } = this;
return { visibility, activeTab, selected, tabs, style: unref(style), classes };
const {
visibility,
activeTab,
selected,
tabs,
style,
classes,
buttonContainerClasses,
buttonContainerStyle
} = this;
return {
visibility,
activeTab,
selected,
tabs,
style: unref(style),
classes,
buttonContainerClasses,
buttonContainerStyle
};
};
// This is necessary because board.types is different from T and TabFamily

View file

@ -34,10 +34,10 @@
</div>
</template>
<script lang="ts">
<script lang="tsx">
import themes from "data/themes";
import type { CoercableComponent, StyleValue } from "features/feature";
import { jsx } from "features/feature";
import type { CoercableComponent } from "features/feature";
import { jsx, StyleValue } from "features/feature";
import type { Persistent } from "game/persistence";
import settings from "game/settings";
import { Direction } from "util/common";
@ -46,15 +46,15 @@ import {
coerceComponent,
computeOptionalComponent,
processedPropType,
render,
renderJSX,
unwrapRef
} from "util/vue";
import type { Component, PropType } from "vue";
import { computed, defineComponent, ref, shallowRef, toRefs, unref, watchEffect } from "vue";
import { computed, defineComponent, ref, shallowRef, toRefs, unref } from "vue";
export default defineComponent({
props: {
element: processedPropType<VueFeature>(Object),
element: Object as PropType<VueFeature>,
display: {
type: processedPropType<CoercableComponent>(Object, String, Function),
required: true
@ -73,14 +73,14 @@ export default defineComponent({
const isShown = computed(() => (unwrapRef(pinned) || isHovered.value) && comp.value);
const comp = computeOptionalComponent(display);
const elementComp = shallowRef<Component | "" | null>(null);
watchEffect(() => {
const elementComp = shallowRef<Component | "" | null>(
coerceComponent(
jsx(() => {
const currComponent = unwrapRef(element);
elementComp.value =
currComponent == null
? null
: coerceComponent(jsx(() => render(currComponent) as JSX.Element));
});
return currComponent == null ? "" : renderJSX(currComponent);
})
)
);
function togglePinned(e: MouseEvent) {
const isPinned = pinned as unknown as Persistent<boolean> | undefined; // Vue typing :/

View file

@ -70,7 +70,6 @@ export function addTooltip<T extends TooltipOptions>(
processComputable(options as T, "xoffset");
processComputable(options as T, "yoffset");
nextTick(() => {
if (options.pinnable) {
if ("pinned" in element) {
console.error(
@ -83,15 +82,16 @@ export function addTooltip<T extends TooltipOptions>(
}
}
nextTick(() => {
const elementComponent = element[Component];
element[Component] = TooltipComponent;
const elementGratherProps = element[GatherProps].bind(element);
const elementGatherProps = element[GatherProps].bind(element);
element[GatherProps] = function gatherTooltipProps(this: GenericTooltip) {
const { display, classes, style, direction, xoffset, yoffset, pinned } = this;
return {
element: {
[Component]: elementComponent,
[GatherProps]: elementGratherProps
[GatherProps]: elementGatherProps
},
display,
classes,

View file

@ -24,63 +24,133 @@ import { createLazyProxy } from "util/proxies";
import type { InjectionKey, Ref } from "vue";
import { ref, shallowReactive, unref } from "vue";
/** A feature's node in the DOM that has its size tracked. */
export interface FeatureNode {
rect: DOMRect;
observer: MutationObserver;
element: HTMLElement;
}
/**
* An injection key that a {@link ContextComponent} will use to provide a function that registers a {@link FeatureNode} with the given id and HTML element.
*/
export const RegisterNodeInjectionKey: InjectionKey<(id: string, element: HTMLElement) => void> =
Symbol("RegisterNode");
/**
* An injection key that a {@link ContextComponent} will use to provide a function that unregisters a {@link FeatureNode} with the given id.
*/
export const UnregisterNodeInjectionKey: InjectionKey<(id: string) => void> =
Symbol("UnregisterNode");
/**
* An injection key that a {@link ContextComponent} will use to provide a ref to a map of all currently registered {@link FeatureNode}s.
*/
export const NodesInjectionKey: InjectionKey<Ref<Record<string, FeatureNode | undefined>>> =
Symbol("Nodes");
/**
* An injection key that a {@link ContextComponent} will use to provide a ref to a bounding rect of the Context.
*/
export const BoundsInjectionKey: InjectionKey<Ref<DOMRect | undefined>> = Symbol("Bounds");
/** All types of events able to be sent or emitted from a layer's emitter. */
export interface LayerEvents {
// Generation
/** Sent every game tick, before the update event. Intended for "generation" type actions. */
preUpdate: (diff: number) => void;
// Actions (e.g. automation)
/** Sent every game tick. Intended for "automation" type actions. */
update: (diff: number) => void;
// Effects (e.g. milestones)
/** Sent every game tick, after the update event. Intended for checking state. */
postUpdate: (diff: number) => void;
}
/**
* A reference to all the current layers.
* It is shallow reactive so it will update when layers are added or removed, but not interfere with the existing refs within each layer.
*/
export const layers: Record<string, Readonly<GenericLayer> | undefined> = shallowReactive({});
declare global {
/** Augment the window object so the layers can be accessed from the console. */
interface Window {
layers: Record<string, Readonly<GenericLayer> | undefined>;
}
}
window.layers = layers;
declare module "@vue/runtime-dom" {
/** Augment CSS Properties to allow for setting the layer color CSS variable. */
interface CSSProperties {
"--layer-color"?: string;
}
}
/** An object representing the position of some entity. */
export interface Position {
/** The X component of the entity's position. */
x: number;
/** The Y component of the entity's position. */
y: number;
}
/**
* An object that configures a {@link Layer}.
* Even moreso than features, the developer is expected to include extra properties in this object.
* All {@link game/persistence.Persistent} refs must be included somewhere within the layer object.
*/
export interface LayerOptions {
/** The color of the layer, used to theme the entire layer's display. */
color?: Computable<string>;
/**
* The layout of this layer's features.
* When the layer is open in {@link game/player.PlayerData.tabs}, this is the content that is display.
*/
display: Computable<CoercableComponent>;
/** An object of classes that should be applied to the display. */
classes?: Computable<Record<string, boolean>>;
/** Styles that should be applied to the display. */
style?: Computable<StyleValue>;
/**
* The name of the layer, used on minimized tabs.
* Defaults to {@link BaseLayer.id}.
*/
name?: Computable<string>;
/**
* Whether or not the layer can be minimized.
* Defaults to true.
*/
minimizable?: Computable<boolean>;
/**
* Whether or not to force the go back button to be hidden.
* If true, go back will be hidden regardless of {@link data/projInfo.allowGoBack}.
*/
forceHideGoBack?: Computable<boolean>;
/**
* A CSS min-width value that is applied to the layer.
* Can be a number, in which case the unit is assumed to be px.
* Defaults to 600px.
*/
minWidth?: Computable<number | string>;
}
/** The properties that are added onto a processed {@link LayerOptions} to create a {@link Layer} */
export interface BaseLayer {
/**
* The ID of the layer.
* Populated from the {@link createLayer} parameters.
* Used for saving and tracking open tabs.
*/
id: string;
/** A persistent ref tracking if the tab is minimized or not. */
minimized: Persistent<boolean>;
/** An emitter for sending {@link LayerEvents} events for this layer. */
emitter: Emitter<LayerEvents>;
/** A function to register an event listener on {@link emitter}. */
on: OmitThisParameter<Emitter<LayerEvents>["on"]>;
emit: <K extends keyof LayerEvents>(event: K, ...args: Parameters<LayerEvents[K]>) => void;
/** A function to emit a {@link LayerEvents} event to this layer. */
emit: <K extends keyof LayerEvents>(...args: [K, ...Parameters<LayerEvents[K]>]) => void;
/** A map of {@link FeatureNode}s present in this layer's {@link ContextComponent} component. */
nodes: Ref<Record<string, FeatureNode | undefined>>;
}
/** An unit of game content. Displayed to the user as a tab or modal. */
export type Layer<T extends LayerOptions> = Replace<
T & BaseLayer,
{
@ -95,6 +165,7 @@ export type Layer<T extends LayerOptions> = Replace<
}
>;
/** A type that matches any valid {@link Layer} object. */
export type GenericLayer = Replace<
Layer<LayerOptions>,
{
@ -104,8 +175,19 @@ export type GenericLayer = Replace<
}
>;
/**
* When creating layers, this object a map of layer ID to a set of any created persistent refs in order to check they're all included in the final layer object.
*/
export const persistentRefs: Record<string, Set<Persistent>> = {};
/**
* When creating layers, this array stores the layers currently being created, as a stack.
*/
export const addingLayers: string[] = [];
/**
* Lazily creates a layer with the given options.
* @param id The ID this layer will have. See {@link BaseLayer.id}.
* @param optionsFunc Layer options.
*/
export function createLayer<T extends LayerOptions>(
id: string,
optionsFunc: OptionsFunc<T, BaseLayer>
@ -114,7 +196,9 @@ export function createLayer<T extends LayerOptions>(
const layer = {} as T & Partial<BaseLayer>;
const emitter = (layer.emitter = createNanoEvents<LayerEvents>());
layer.on = emitter.on.bind(emitter);
layer.emit = emitter.emit.bind(emitter);
layer.emit = emitter.emit.bind(emitter) as <K extends keyof LayerEvents>(
...args: [K, ...Parameters<LayerEvents[K]>]
) => void;
layer.nodes = ref({});
layer.id = id;
@ -143,6 +227,14 @@ export function createLayer<T extends LayerOptions>(
});
}
/**
* Enables a layer object, so it will be updated every tick.
* Note that accessing a layer/its properties does NOT require it to be enabled.
* For dynamic layers you can call this function and {@link removeLayer} as necessary. Just make sure {@link data/projEntry.getInitialLayers} will provide an accurate list of layers based on the player data object.
* For static layers just make {@link data/projEntry.getInitialLayers} return all the layers.
* @param layer The layer to add.
* @param player The player data object, which will have a data object for this layer.
*/
export function addLayer(
layer: GenericLayer,
player: { layers?: Record<string, Record<string, unknown>> }
@ -166,10 +258,19 @@ export function addLayer(
globalBus.emit("addLayer", layer, player.layers[layer.id]);
}
/**
* Convenience method for getting a layer by its ID with correct typing.
* @param layerID The ID of the layer to get.
*/
export function getLayer<T extends GenericLayer>(layerID: string): T {
return layers[layerID] as T;
}
/**
* Disables a layer, so it will no longer be updated every tick.
* Note that accessing a layer/its properties does NOT require it to be enabled.
* @param layer The layer to remove.
*/
export function removeLayer(layer: GenericLayer): void {
console.info("Removing layer", layer.id);
globalBus.emit("removeLayer", layer);
@ -177,6 +278,11 @@ export function removeLayer(layer: GenericLayer): void {
layers[layer.id] = undefined;
}
/**
* Convenience method for removing and immediately re-adding a layer.
* This is useful for layers with dynamic content, to ensure persistent refs are correctly configured.
* @param layer Layer to remove and then re-add
*/
export function reloadLayer(layer: GenericLayer): void {
removeLayer(layer);
@ -184,6 +290,11 @@ export function reloadLayer(layer: GenericLayer): void {
addLayer(layer, player);
}
/**
* Utility function for creating a modal that display's a {@link LayerOptions.display}.
* Returns the modal itself, which can be rendered anywhere you need, as well as a function to open the modal.
* @param layer The layer to display in the modal.
*/
export function setupLayerModal(layer: GenericLayer): {
openModal: VoidFunction;
modal: JSXFunction;

View file

@ -126,6 +126,13 @@ const playerHandler: ProxyHandler<Record<PropertyKey, any>> = {
return Object.getOwnPropertyDescriptor(target[ProxyState], key);
}
};
declare global {
/** Augment the window object so the player can be accessed from the console. */
interface Window {
player: Player;
}
}
export default window.player = new Proxy(
{ [ProxyState]: state, [ProxyPath]: ["player"] },
playerHandler

View file

@ -30,7 +30,28 @@ watch(
},
{ deep: true }
);
declare global {
/**
* Augment the window object so the settings, and hard resetting the settings, can be accessed from the console.
*/
interface Window {
settings: Settings;
hardResetSettings: VoidFunction;
}
}
export default window.settings = state as Settings;
export const hardResetSettings = (window.hardResetSettings = () => {
const settings = {
active: "",
saves: [],
showTPS: true,
theme: Themes.Nordic
};
globalBus.emit("loadSettings", settings);
Object.assign(state, settings);
hardReset();
});
export function loadSettings(): void {
try {
@ -59,18 +80,6 @@ export function loadSettings(): void {
} catch {}
}
export const hardResetSettings = (window.hardResetSettings = () => {
const settings = {
active: "",
saves: [],
showTPS: true,
theme: Themes.Nordic
};
globalBus.emit("loadSettings", settings);
Object.assign(state, settings);
hardReset();
});
export const settingFields: CoercableComponent[] = reactive([]);
export function registerSettingField(component: CoercableComponent) {
settingFields.push(component);

View file

@ -7,6 +7,12 @@ export interface Transient {
NaNReceiver?: Record<string, unknown>;
}
declare global {
/** Augment the window object so the transient state can be accessed from the console. */
interface Window {
state: Transient;
}
}
export default window.state = shallowReactive<Transient>({
lastTenTicks: [],
hasNaN: false,

View file

@ -1,45 +1,31 @@
import App from "App.vue";
import projInfo from "data/projInfo.json";
import type { GenericLayer } from "game/layers";
import "game/notifications";
import type { PlayerData } from "game/player";
import type { Settings } from "game/settings";
import type { Transient } from "game/state";
import type { DecimalSource } from "util/bignum";
import Decimal from "util/bignum";
import { load } from "util/save";
import { useRegisterSW } from "virtual:pwa-register/vue";
import type { App as VueApp } from "vue";
import { createApp, nextTick } from "vue";
import { useToast } from "vue-toastification";
document.title = projInfo.title;
if (projInfo.id === "") {
throw "Project ID is empty! Please select a unique ID for this project in /src/data/projInfo.json";
}
declare global {
/**
* Augment the window object so the vue app and project info can be accessed from the console.
*/
interface Window {
vue: VueApp;
save: VoidFunction;
hardReset: VoidFunction;
hardResetSettings: VoidFunction;
layers: Record<string, Readonly<GenericLayer> | undefined>;
player: PlayerData;
state: Transient;
settings: Settings;
Decimal: typeof Decimal;
exponentialFormat: (num: DecimalSource, precision: number, mantissa: boolean) => string;
commaFormat: (num: DecimalSource, precision: number) => string;
regularFormat: (num: DecimalSource, precision: number) => string;
format: (num: DecimalSource, precision?: number, small?: boolean) => string;
formatWhole: (num: DecimalSource) => string;
formatTime: (s: number) => string;
toPlaces: (x: DecimalSource, precision: number, maxAccepted: DecimalSource) => string;
formatSmall: (x: DecimalSource, precision?: number) => string;
invertOOM: (x: DecimalSource) => Decimal;
projInfo: typeof projInfo;
}
/** Fix for typedoc treating import functions as taking AssertOptions instead of GlobOptions. */
interface AssertOptions {
as: string;
}
}
document.title = projInfo.title;
window.projInfo = projInfo;
if (projInfo.id === "") {
throw "Project ID is empty! Please select a unique ID for this project in /src/data/projInfo.json";
}
requestAnimationFrame(async () => {
@ -90,5 +76,3 @@ requestAnimationFrame(async () => {
startGameLoop();
});
window.projInfo = projInfo;

View file

@ -17,6 +17,21 @@ export const {
export type DecimalSource = RawDecimalSource;
declare global {
/** Augment the window object so the big num functions can be access from the console. */
interface Window {
Decimal: typeof Decimal;
exponentialFormat: (num: DecimalSource, precision: number, mantissa: boolean) => string;
commaFormat: (num: DecimalSource, precision: number) => string;
regularFormat: (num: DecimalSource, precision: number) => string;
format: (num: DecimalSource, precision?: number, small?: boolean) => string;
formatWhole: (num: DecimalSource) => string;
formatTime: (s: number) => string;
toPlaces: (x: DecimalSource, precision: number, maxAccepted: DecimalSource) => string;
formatSmall: (x: DecimalSource, precision?: number) => string;
invertOOM: (x: DecimalSource) => Decimal;
}
}
window.Decimal = Decimal;
window.exponentialFormat = exponentialFormat;
window.commaFormat = commaFormat;

View file

@ -37,6 +37,10 @@ export function processComputable<T, S extends keyof ComputableKeysOf<T>>(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
obj[key] = computed(computable.bind(obj));
} else if (isFunction(computable)) {
obj[key] = computable.bind(obj);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(obj[key] as any)[DoNotCache] = true;
}
}

View file

@ -124,6 +124,16 @@ window.onbeforeunload = () => {
save();
}
};
declare global {
/**
* Augment the window object so the save function, and the hard reset function can be access from the console.
*/
interface Window {
save: VoidFunction;
hardReset: VoidFunction;
}
}
window.save = save;
export const hardReset = (window.hardReset = async () => {
await loadSave(newSave());

View file

@ -1,5 +1,5 @@
import { jest, describe, expect, test } from "@jest/globals";
import { camelToTitle, isFunction } from "util/common";
import { describe, expect, test, vi } from "vitest";
describe("camelToTitle", () => {
test("Capitalizes first letter in single word", () =>
@ -10,7 +10,7 @@ describe("camelToTitle", () => {
});
describe("isFunction", () => {
test("Given function returns true", () => expect(isFunction(jest.fn())).toBe(true));
test("Given function returns true", () => expect(isFunction(vi.fn())).toBe(true));
// Go through all primitives and basic types
test("Given a string returns false", () => expect(isFunction("test")).toBe(false));

View file

@ -17,8 +17,8 @@
"sourceMap": true,
"baseUrl": "src",
"types": [
"jest",
"vite/client"
"vite/client",
"node"
],
"lib": [
"esnext",

View file

@ -40,5 +40,8 @@ export default defineConfig({
]
}
})
]
],
test: {
environment: "jsdom"
}
});