From 143b0773e7d23b55effd4fafc4752edc61d5e773 Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Sat, 17 Feb 2024 20:16:00 -0600 Subject: [PATCH 01/82] Add eslint workflow action and CONTRIBUTING.md that says to lint first --- .forgejo/workflows/test.yaml | 1 + .github/workflows/test.yml | 1 + .vscode/settings.json | 2 +- CONTRIBUTING.md | 31 +++++++++++++++++++++++++++++++ package.json | 4 +++- src/App.vue | 2 +- src/features/action.tsx | 2 +- src/features/tooltips/tooltip.ts | 2 +- src/features/trees/tree.ts | 2 +- src/game/formulas/operations.ts | 4 ++++ src/game/modifiers.tsx | 15 +++++++++++---- src/game/requirements.tsx | 4 +++- 12 files changed, 59 insertions(+), 11 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/.forgejo/workflows/test.yaml b/.forgejo/workflows/test.yaml index 33df8d8..7c48ad6 100644 --- a/.forgejo/workflows/test.yaml +++ b/.forgejo/workflows/test.yaml @@ -19,3 +19,4 @@ jobs: - run: npm ci - run: npm run build --if-present - run: npm test + - run: npm run lint diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c41d085..8d6b548 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,3 +19,4 @@ jobs: - run: npm ci - run: npm run build --if-present - run: npm test + - run: npm run lint diff --git a/.vscode/settings.json b/.vscode/settings.json index d46602a..65fe597 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { "vitest.commandLine": "npx vitest", "editor.codeActionsOnSave": { - "source.fixAll.eslint": true + "source.fixAll.eslint": "explicit" }, "editor.defaultFormatter": "esbenp.prettier-vscode", "git.ignoreLimitWarning": true, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..818ba84 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,31 @@ +# Contributing to Profectus + +Thank you for considering contributing to Profectus! We appreciate your interest in improving our project. Please take a moment to review the following guidelines to streamline the contribution process. + +## Getting Started + +For detailed instructions on setting up local development environment, please refer to the [Setup Guide](https://moddingtree.com/guide/getting-started/setup). + +## Contributing + +Make sure to open your PR on Incremental Social - the GitHub repo is just a mirror! + +### Code Review + +All PRs must be reviewed and approved by at least one of the project maintainers before merging. Please be patient during the review process and be open to feedback. + +### Testing + +Ensure that your changes pass all existing tests and, if applicable, add new tests to cover the changes you've made. Run `npm run test` to run all the tests. + +## Code Style + +We use ESLint and Prettier to enforce consistent code style throughout the project. Before submitting a PR, run `npm run lint:fix` to automatically fix any linting issues. + +## Issue Reporting + +If you encounter a bug or have a suggestion for improvement, please open an issue on Incremental Social. Provide as much detail as possible, including an example repo or steps to reproduce the issue if applicable. + +## License + +By contributing to Profectus, you agree that your contributions will be licensed under the project's [LICENSE](./LICENSE). diff --git a/package.json b/package.json index 3c1c415..f0ce56b 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "preview": "vite preview", "test": "vitest run", "testw": "vitest", - "serve": "vite preview --host" + "serve": "vite preview --host", + "lint": "eslint src --max-warnings 0", + "lint:fix": "eslint --fix --max-warnings 0 src" }, "dependencies": { "@fontsource/material-icons": "^4.5.4", diff --git a/src/App.vue b/src/App.vue index 6a365ef..40e21de 100644 --- a/src/App.vue +++ b/src/App.vue @@ -19,7 +19,7 @@ import Error from "components/Error.vue"; import { jsx } from "features/feature"; import state from "game/state"; import { coerceComponent, render } from "util/vue"; -import { CSSProperties, watch } from "vue"; +import { CSSProperties } from "vue"; import { computed, toRef, unref } from "vue"; import Game from "./components/Game.vue"; import GameOverScreen from "./components/GameOverScreen.vue"; diff --git a/src/features/action.tsx b/src/features/action.tsx index 1fbb8d3..2919a9e 100644 --- a/src/features/action.tsx +++ b/src/features/action.tsx @@ -31,7 +31,7 @@ import { coerceComponent, isCoercableComponent, render } from "util/vue"; import { computed, Ref, ref, unref } from "vue"; import { BarOptions, createBar, GenericBar } from "./bars/bar"; import { ClickableOptions } from "./clickables/clickable"; -import { Decorator, GenericDecorator } from "./decorators/common"; +import { GenericDecorator } from "./decorators/common"; /** A symbol used to identify {@link Action} features. */ export const ActionType = Symbol("Action"); diff --git a/src/features/tooltips/tooltip.ts b/src/features/tooltips/tooltip.ts index 54d782c..8d65efd 100644 --- a/src/features/tooltips/tooltip.ts +++ b/src/features/tooltips/tooltip.ts @@ -1,6 +1,6 @@ import type { CoercableComponent, GenericComponent, Replace, StyleValue } from "features/feature"; import { Component, GatherProps, setDefault } from "features/feature"; -import { deletePersistent, Persistent, persistent } from "game/persistence"; +import { persistent } from "game/persistence"; import { Direction } from "util/common"; import type { Computable, diff --git a/src/features/trees/tree.ts b/src/features/trees/tree.ts index da77f60..6d09fc9 100644 --- a/src/features/trees/tree.ts +++ b/src/features/trees/tree.ts @@ -1,4 +1,4 @@ -import { Decorator, GenericDecorator } from "features/decorators/common"; +import { GenericDecorator } from "features/decorators/common"; import type { CoercableComponent, GenericComponent, diff --git a/src/game/formulas/operations.ts b/src/game/formulas/operations.ts index 586319e..7210cfb 100644 --- a/src/game/formulas/operations.ts +++ b/src/game/formulas/operations.ts @@ -552,7 +552,9 @@ export function tetrate( export function invertTetrate( value: DecimalSource, base: FormulaSource, + // eslint-disable-next-line @typescript-eslint/no-unused-vars height: FormulaSource, + // eslint-disable-next-line @typescript-eslint/no-unused-vars payload: FormulaSource ) { if (hasVariable(base)) { @@ -576,6 +578,7 @@ export function invertIteratedExp( value: DecimalSource, lhs: FormulaSource, height: FormulaSource, + // eslint-disable-next-line @typescript-eslint/no-unused-vars payload: FormulaSource ) { if (hasVariable(lhs)) { @@ -626,6 +629,7 @@ export function invertLayeradd( value: DecimalSource, lhs: FormulaSource, diff: FormulaSource, + // eslint-disable-next-line @typescript-eslint/no-unused-vars base: FormulaSource ) { if (hasVariable(lhs)) { diff --git a/src/game/modifiers.tsx b/src/game/modifiers.tsx index 1ee3905..2d2ccdf 100644 --- a/src/game/modifiers.tsx +++ b/src/game/modifiers.tsx @@ -296,10 +296,17 @@ export function createSequentialModifier< : undefined, getFormula: modifiers.every(m => m.getFormula != null) ? (gain: FormulaSource) => - modifiers - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - .reduce((acc, curr) => Formula.if(acc, curr.enabled ?? true, - acc => curr.getFormula!(acc), acc => acc), gain) + modifiers.reduce( + (acc, curr) => + Formula.if( + acc, + curr.enabled ?? true, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + acc => curr.getFormula!(acc), + acc => acc + ), + gain + ) : undefined, enabled: modifiers.some(m => m.enabled != null) ? computed(() => modifiers.filter(m => unref(m.enabled) !== false).length > 0) diff --git a/src/game/requirements.tsx b/src/game/requirements.tsx index ea82a64..363fccd 100644 --- a/src/game/requirements.tsx +++ b/src/game/requirements.tsx @@ -222,7 +222,9 @@ export function createCostRequirement( Decimal.gte( req.resource.value, unref(req.cost as ProcessedComputable) - ) ? 1 : 0 + ) + ? 1 + : 0 ); } From 4092cd6d56de40d55bbce9df9c9246bdf863a2b2 Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Tue, 13 Feb 2024 06:48:56 -0600 Subject: [PATCH 02/82] Add regression test for modifier.getFormula respecting enabled --- src/game/modifiers.tsx | 18 +++++++++++++----- tests/game/modifiers.test.ts | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/game/modifiers.tsx b/src/game/modifiers.tsx index 1ee3905..d80b45a 100644 --- a/src/game/modifiers.tsx +++ b/src/game/modifiers.tsx @@ -276,8 +276,12 @@ export function createExponentialModifier( export function createSequentialModifier< T extends Modifier[], S = T extends WithRequired[] - ? WithRequired - : Omit, "invert"> + ? T extends WithRequired[] + ? WithRequired + : Omit, "invert"> + : T extends WithRequired[] + ? WithRequired + : Omit, "invert"> >(modifiersFunc: () => T): S { return createLazyProxy(() => { const modifiers = modifiersFunc(); @@ -296,10 +300,14 @@ export function createSequentialModifier< : undefined, getFormula: modifiers.every(m => m.getFormula != null) ? (gain: FormulaSource) => - modifiers + modifiers.reduce((acc, curr) => { + if (curr.enabled == null || curr.enabled === true) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return curr.getFormula!(acc); + } // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - .reduce((acc, curr) => Formula.if(acc, curr.enabled ?? true, - acc => curr.getFormula!(acc), acc => acc), gain) + return Formula.if(acc, curr.enabled, acc => curr.getFormula!(acc)); + }, gain) : undefined, enabled: modifiers.some(m => m.enabled != null) ? computed(() => modifiers.filter(m => unref(m.enabled) !== false).length > 0) diff --git a/tests/game/modifiers.test.ts b/tests/game/modifiers.test.ts index d5e186d..fdf0f67 100644 --- a/tests/game/modifiers.test.ts +++ b/tests/game/modifiers.test.ts @@ -199,6 +199,20 @@ describe("Sequential Modifiers", () => { // So long as one is true or undefined, enable should be true expect(unref(modifier.enabled)).toBe(true); }); + test("respects enabled", () => { + const value = ref(10); + const enabled = ref(false); + const modifier = createSequentialModifier(() => [ + createMultiplicativeModifier(() => ({ multiplier: 5, enabled })) + ]); + expect(modifier.getFormula(Formula.variable(value)).evaluate()).compare_tolerance( + value.value + ); + enabled.value = true; + expect(modifier.getFormula(Formula.variable(value)).evaluate()).not.compare_tolerance( + value.value + ); + }); }); describe("applies smallerIsBetter correctly", () => { From 2e0e221010ba4d0d792acafec14383962e804824 Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Tue, 20 Feb 2024 08:32:03 -0600 Subject: [PATCH 03/82] Made modifier typing a lot less nasty --- src/game/modifiers.tsx | 55 +++++++++++++++--------------------- src/util/common.ts | 8 ++++++ tests/game/modifiers.test.ts | 12 ++++---- 3 files changed, 37 insertions(+), 38 deletions(-) diff --git a/src/game/modifiers.tsx b/src/game/modifiers.tsx index d80b45a..ada19dc 100644 --- a/src/game/modifiers.tsx +++ b/src/game/modifiers.tsx @@ -4,7 +4,7 @@ import { jsx } from "features/feature"; import settings from "game/settings"; import type { DecimalSource } from "util/bignum"; import Decimal, { formatSmall } from "util/bignum"; -import type { WithRequired } from "util/common"; +import type { OmitOptional, OptionalKeys, RequiredKeys, WithRequired } from "util/common"; import type { Computable, ProcessedComputable } from "util/computed"; import { convertComputable } from "util/computed"; import { createLazyProxy } from "util/proxies"; @@ -38,16 +38,11 @@ export interface Modifier { description?: ProcessedComputable; } -/** - * Utility type used to narrow down a modifier type that will have a description and/or enabled property based on optional parameters, T and S (respectively). - */ -export type ModifierFromOptionalParams = undefined extends T - ? undefined extends S - ? Omit, "description" | "enabled"> - : Omit, "description"> - : undefined extends S - ? Omit, "enabled"> - : WithRequired; +/** Utility type that represents the output of all modifiers that represent a single operation. */ +export type OperationModifier = WithRequired< + Modifier, + "invert" | "getFormula" | Extract, keyof Modifier> +>; /** An object that configures an additive modifier via {@link createAdditiveModifier}. */ export interface AdditiveModifierOptions { @@ -65,9 +60,9 @@ export interface AdditiveModifierOptions { * Create a modifier that adds some value to the input value. * @param optionsFunc Additive modifier options. */ -export function createAdditiveModifier( +export function createAdditiveModifier>( optionsFunc: OptionsFunc -): ModifierFromOptionalParams { +) { return createLazyProxy(feature => { const { addend, description, enabled, smallerIsBetter } = optionsFunc.call( feature, @@ -111,7 +106,7 @@ export function createAdditiveModifier( )) }; - }) as unknown as ModifierFromOptionalParams; + }) as S; } /** An object that configures an multiplicative modifier via {@link createMultiplicativeModifier}. */ @@ -130,9 +125,10 @@ export interface MultiplicativeModifierOptions { * Create a modifier that multiplies the input value by some value. * @param optionsFunc Multiplicative modifier options. */ -export function createMultiplicativeModifier( - optionsFunc: OptionsFunc -): ModifierFromOptionalParams { +export function createMultiplicativeModifier< + T extends MultiplicativeModifierOptions, + S = OperationModifier +>(optionsFunc: OptionsFunc) { return createLazyProxy(feature => { const { multiplier, description, enabled, smallerIsBetter } = optionsFunc.call( feature, @@ -175,7 +171,7 @@ export function createMultiplicativeModifier )) }; - }) as unknown as ModifierFromOptionalParams; + }) as S; } /** An object that configures an exponential modifier via {@link createExponentialModifier}. */ @@ -196,9 +192,10 @@ export interface ExponentialModifierOptions { * Create a modifier that raises the input value to the power of some value. * @param optionsFunc Exponential modifier options. */ -export function createExponentialModifier( - optionsFunc: OptionsFunc -): ModifierFromOptionalParams { +export function createExponentialModifier< + T extends ExponentialModifierOptions, + S = OperationModifier +>(optionsFunc: OptionsFunc) { return createLazyProxy(feature => { const { exponent, description, enabled, supportLowNumbers, smallerIsBetter } = optionsFunc.call(feature, feature); @@ -263,7 +260,7 @@ export function createExponentialModifier( )) }; - }) as unknown as ModifierFromOptionalParams; + }) as S; } /** @@ -274,15 +271,9 @@ export function createExponentialModifier( * @see {@link createModifierSection}. */ export function createSequentialModifier< - T extends Modifier[], - S = T extends WithRequired[] - ? T extends WithRequired[] - ? WithRequired - : Omit, "invert"> - : T extends WithRequired[] - ? WithRequired - : Omit, "invert"> ->(modifiersFunc: () => T): S { + T extends Modifier, + S = WithRequired, keyof Modifier>> +>(modifiersFunc: () => T[]) { return createLazyProxy(() => { const modifiers = modifiersFunc(); @@ -325,7 +316,7 @@ export function createSequentialModifier< )) : undefined }; - }) as unknown as S; + }) as S; } /** An object that configures a modifier section via {@link createModifierSection}. */ diff --git a/src/util/common.ts b/src/util/common.ts index dbbe233..00847e6 100644 --- a/src/util/common.ts +++ b/src/util/common.ts @@ -1,3 +1,11 @@ +export type RequiredKeys = { + [K in keyof T]-?: NonNullable extends Pick ? never : K; +}[keyof T]; +export type OptionalKeys = { + [K in keyof T]-?: NonNullable extends Pick ? K : never; +}[keyof T]; + +export type OmitOptional = Pick>; export type WithRequired = T & { [P in K]-?: T[P] }; export type ArrayElements> = T extends ReadonlyArray diff --git a/tests/game/modifiers.test.ts b/tests/game/modifiers.test.ts index fdf0f67..3b2812f 100644 --- a/tests/game/modifiers.test.ts +++ b/tests/game/modifiers.test.ts @@ -133,14 +133,14 @@ describe("Exponential Modifiers", () => testModifiers(createExponentialModifier, "exponent", Decimal.pow)); describe("Sequential Modifiers", () => { - function createModifier( + function createModifier>( value: Computable, - options: Partial = {} - ): WithRequired { + options?: T + ) { return createSequentialModifier(() => [ - createAdditiveModifier(() => ({ ...options, addend: value })), - createMultiplicativeModifier(() => ({ ...options, multiplier: value })), - createExponentialModifier(() => ({ ...options, exponent: value })) + createAdditiveModifier(() => ({ ...(options ?? {}), addend: value })), + createMultiplicativeModifier(() => ({ ...(options ?? {}), multiplier: value })), + createExponentialModifier(() => ({ ...(options ?? {}), exponent: value })) ]); } From 1e2b20a70ff09eafe4ec1555f4e8b8fb0c7835a2 Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Tue, 20 Feb 2024 15:10:59 +0000 Subject: [PATCH 04/82] PR feedback --- tests/game/modifiers.test.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/game/modifiers.test.ts b/tests/game/modifiers.test.ts index 3b2812f..dd019e1 100644 --- a/tests/game/modifiers.test.ts +++ b/tests/game/modifiers.test.ts @@ -205,13 +205,10 @@ describe("Sequential Modifiers", () => { const modifier = createSequentialModifier(() => [ createMultiplicativeModifier(() => ({ multiplier: 5, enabled })) ]); - expect(modifier.getFormula(Formula.variable(value)).evaluate()).compare_tolerance( - value.value - ); + const formula = modifier.getFormula(Formula.variable(value)); + expect(formula.evaluate()).compare_tolerance(value.value); enabled.value = true; - expect(modifier.getFormula(Formula.variable(value)).evaluate()).not.compare_tolerance( - value.value - ); + expect(formula.evaluate()).not.compare_tolerance(value.value); }); }); From a39e65852d196a122bc598121012edb5a6d2ca42 Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Tue, 20 Feb 2024 19:23:14 -0600 Subject: [PATCH 05/82] Remove unused imports --- src/game/modifiers.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/game/modifiers.tsx b/src/game/modifiers.tsx index ada19dc..55efccb 100644 --- a/src/game/modifiers.tsx +++ b/src/game/modifiers.tsx @@ -4,7 +4,7 @@ import { jsx } from "features/feature"; import settings from "game/settings"; import type { DecimalSource } from "util/bignum"; import Decimal, { formatSmall } from "util/bignum"; -import type { OmitOptional, OptionalKeys, RequiredKeys, WithRequired } from "util/common"; +import type { RequiredKeys, WithRequired } from "util/common"; import type { Computable, ProcessedComputable } from "util/computed"; import { convertComputable } from "util/computed"; import { createLazyProxy } from "util/proxies"; From d3faec6a6629d241a9d95f2321ad6f5436e5c130 Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Sat, 17 Feb 2024 19:04:55 -0600 Subject: [PATCH 06/82] Add Nodes to the text that can disappear in projEntry --- src/data/projEntry.tsx | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/data/projEntry.tsx b/src/data/projEntry.tsx index e4640b6..f69ac8b 100644 --- a/src/data/projEntry.tsx +++ b/src/data/projEntry.tsx @@ -1,3 +1,4 @@ +import Node from "components/Node.vue"; import Spacer from "components/layout/Spacer.vue"; import { jsx } from "features/feature"; import { createResource, trackBest, trackOOMPS, trackTotal } from "features/resources/resource"; @@ -48,19 +49,35 @@ export const main = createLayer("main", function (this: BaseLayer) { links: tree.links, display: jsx(() => ( <> - {player.devSpeed === 0 ?
Game Paused
: null} + {player.devSpeed === 0 ? ( +
+ Game Paused + +
+ ) : null} {player.devSpeed != null && player.devSpeed !== 0 && player.devSpeed !== 1 ? ( -
Dev Speed: {format(player.devSpeed)}x
+
+ Dev Speed: {format(player.devSpeed)}x + +
) : null} {player.offlineTime != null && player.offlineTime !== 0 ? ( -
Offline Time: {formatTime(player.offlineTime)}
+
+ Offline Time: {formatTime(player.offlineTime)} + +
) : null}
{Decimal.lt(points.value, "1e1000") ? You have : null}

{format(points.value)}

{Decimal.lt(points.value, "1e1e6") ? points : null}
- {Decimal.gt(pointGain.value, 0) ?
({oomps.value})
: null} + {Decimal.gt(pointGain.value, 0) ? ( +
+ ({oomps.value}) + +
+ ) : null} {render(tree)} From 1f22f506dd978a352a62a50a7a95209da3a30344 Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Tue, 13 Feb 2024 08:25:32 -0600 Subject: [PATCH 07/82] Add tests for tree reset propagation --- src/features/trees/tree.ts | 2 +- tests/features/tree.test.ts | 111 ++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 tests/features/tree.test.ts diff --git a/src/features/trees/tree.ts b/src/features/trees/tree.ts index 8ec317f..4e43bbf 100644 --- a/src/features/trees/tree.ts +++ b/src/features/trees/tree.ts @@ -224,7 +224,7 @@ export interface BaseTree { id: string; /** The link objects for each of the branches of the tree. */ links: Ref; - /** Cause a reset on this node and propagate it through the tree according to {@link resetPropagation}. */ + /** Cause a reset on this node and propagate it through the tree according to {@link TreeOptions.resetPropagation}. */ reset: (node: GenericTreeNode) => void; /** A flag that is true while the reset is still propagating through the tree. */ isResetting: Ref; diff --git a/tests/features/tree.test.ts b/tests/features/tree.test.ts new file mode 100644 index 0000000..206988f --- /dev/null +++ b/tests/features/tree.test.ts @@ -0,0 +1,111 @@ +import { beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import { Ref, ref } from "vue"; +import "../utils"; +import { + createTree, + createTreeNode, + defaultResetPropagation, + invertedResetPropagation, + branchedResetPropagation +} from "features/trees/tree"; +import { createReset, GenericReset } from "features/reset"; + +describe("Reset propagation", () => { + let shouldReset: Ref, shouldNotReset: Ref; + let goodReset: GenericReset, badReset: GenericReset; + beforeAll(() => { + shouldReset = ref(false); + shouldNotReset = ref(false); + goodReset = createReset(() => ({ + thingsToReset: [], + onReset() { + shouldReset.value = true; + } + })); + badReset = createReset(() => ({ + thingsToReset: [], + onReset() { + shouldNotReset.value = true; + } + })); + }); + beforeEach(() => { + shouldReset.value = false; + shouldNotReset.value = false; + }); + test("No resets", () => { + expect(() => { + const a = createTreeNode(() => ({})); + const b = createTreeNode(() => ({})); + const c = createTreeNode(() => ({})); + const tree = createTree(() => ({ + nodes: [[a], [b], [c]] + })); + tree.reset(a); + }).not.toThrowError(); + }); + + test("Do not propagate resets", () => { + const a = createTreeNode(() => ({ reset: badReset })); + const b = createTreeNode(() => ({ reset: badReset })); + const c = createTreeNode(() => ({ reset: badReset })); + const tree = createTree(() => ({ + nodes: [[a], [b], [c]] + })); + tree.reset(b); + expect(shouldNotReset.value).toBe(false); + }); + + test("Default propagation", () => { + const a = createTreeNode(() => ({ reset: goodReset })); + const b = createTreeNode(() => ({})); + const c = createTreeNode(() => ({ reset: badReset })); + const tree = createTree(() => ({ + nodes: [[a], [b], [c]], + resetPropagation: defaultResetPropagation + })); + tree.reset(b); + expect(shouldReset.value).toBe(true); + expect(shouldNotReset.value).toBe(false); + }); + + test("Inverted propagation", () => { + const a = createTreeNode(() => ({ reset: badReset })); + const b = createTreeNode(() => ({})); + const c = createTreeNode(() => ({ reset: goodReset })); + const tree = createTree(() => ({ + nodes: [[a], [b], [c]], + resetPropagation: invertedResetPropagation + })); + tree.reset(b); + expect(shouldReset.value).toBe(true); + expect(shouldNotReset.value).toBe(false); + }); + + test("Branched propagation", () => { + const a = createTreeNode(() => ({ reset: badReset })); + const b = createTreeNode(() => ({})); + const c = createTreeNode(() => ({ reset: goodReset })); + const tree = createTree(() => ({ + nodes: [[a, b, c]], + resetPropagation: branchedResetPropagation, + branches: [{ startNode: b, endNode: c }] + })); + tree.reset(b); + expect(shouldReset.value).toBe(true); + expect(shouldNotReset.value).toBe(false); + }); + + test("Branched propagation not bi-directional", () => { + const a = createTreeNode(() => ({ reset: badReset })); + const b = createTreeNode(() => ({})); + const c = createTreeNode(() => ({ reset: badReset })); + const tree = createTree(() => ({ + nodes: [[a, b, c]], + resetPropagation: branchedResetPropagation, + branches: [{ startNode: c, endNode: b }] + })); + tree.reset(b); + expect(shouldNotReset.value).toBe(false); + }); +}); From 64fad5c74af04b9547482b94aa47c746d9a4cb89 Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Tue, 20 Feb 2024 22:16:20 -0600 Subject: [PATCH 08/82] PR Feedback --- CONTRIBUTING.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 818ba84..4fc4ea1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,9 +6,13 @@ Thank you for considering contributing to Profectus! We appreciate your interest For detailed instructions on setting up local development environment, please refer to the [Setup Guide](https://moddingtree.com/guide/getting-started/setup). +## Issue Reporting + +If you encounter a bug or have a suggestion for improvement, please open an issue on Incremental Social. Provide as much detail as possible, including an example repo or steps to reproduce the issue if applicable. + ## Contributing -Make sure to open your PR on Incremental Social - the GitHub repo is just a mirror! +Make sure to open your PR on [Incremental Social](https://code.incremental.social/profectus/Profectus) - the GitHub repo is just a mirror! ### Code Review @@ -18,14 +22,10 @@ All PRs must be reviewed and approved by at least one of the project maintainers Ensure that your changes pass all existing tests and, if applicable, add new tests to cover the changes you've made. Run `npm run test` to run all the tests. -## Code Style +### Code Style We use ESLint and Prettier to enforce consistent code style throughout the project. Before submitting a PR, run `npm run lint:fix` to automatically fix any linting issues. -## Issue Reporting - -If you encounter a bug or have a suggestion for improvement, please open an issue on Incremental Social. Provide as much detail as possible, including an example repo or steps to reproduce the issue if applicable. - ## License By contributing to Profectus, you agree that your contributions will be licensed under the project's [LICENSE](./LICENSE). From f7a8fbbb110e6c1fe742a56d2cd2b54842e2d262 Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Tue, 20 Feb 2024 22:38:49 -0600 Subject: [PATCH 09/82] Lint --- src/features/trees/tree.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/trees/tree.ts b/src/features/trees/tree.ts index f47d690..5a03189 100644 --- a/src/features/trees/tree.ts +++ b/src/features/trees/tree.ts @@ -346,11 +346,11 @@ export const branchedResetPropagation = function ( const next: GenericTreeNode[] = []; for (const node of current) { for (const link of links.filter(link => link.startNode === node)) { - if ([...reset, ...current].includes(link.endNode)) continue + if ([...reset, ...current].includes(link.endNode)) continue; next.push(link.endNode); link.endNode.reset?.reset(); } - }; + } reset.push(...current); current = next; } From b40d4bef32ee6e9947e2a4b2399951cc4ba252d1 Mon Sep 17 00:00:00 2001 From: escapee Date: Wed, 21 Feb 2024 11:13:17 -0800 Subject: [PATCH 10/82] Allow both cases in shift+hotkeys --- src/features/hotkey.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/hotkey.tsx b/src/features/hotkey.tsx index 51fafbb..80eebbd 100644 --- a/src/features/hotkey.tsx +++ b/src/features/hotkey.tsx @@ -108,7 +108,7 @@ document.onkeydown = function (e) { if (e.ctrlKey) { key = "ctrl+" + key; } - const hotkey = hotkeys[key]; + const hotkey = hotkeys[key] ?? hotkeys[key.toLowerCase()]; if (hotkey && unref(hotkey.enabled)) { e.preventDefault(); hotkey.onPress(); From 424bde0cddc9b20f1d57e2ad5af94fefee1669b5 Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Wed, 28 Feb 2024 23:19:11 -0600 Subject: [PATCH 11/82] WIP on rewriting board --- src/data/common.css | 9 + src/data/projEntry.tsx | 417 +++++++++++++--- src/features/boards/Board.vue | 321 +++--------- src/features/boards/BoardLink.vue | 80 --- src/features/boards/BoardNode.vue | 339 ------------- src/features/boards/BoardNodeAction.vue | 109 ---- src/features/boards/CircleProgress.vue | 29 ++ src/features/boards/SVGNode.vue | 27 + src/features/boards/SquareProgress.vue | 30 ++ src/features/boards/board.ts | 631 ------------------------ src/features/boards/board.tsx | 317 ++++++++++++ 11 files changed, 820 insertions(+), 1489 deletions(-) delete mode 100644 src/features/boards/BoardLink.vue delete mode 100644 src/features/boards/BoardNode.vue delete mode 100644 src/features/boards/BoardNodeAction.vue create mode 100644 src/features/boards/CircleProgress.vue create mode 100644 src/features/boards/SVGNode.vue create mode 100644 src/features/boards/SquareProgress.vue delete mode 100644 src/features/boards/board.ts create mode 100644 src/features/boards/board.tsx diff --git a/src/data/common.css b/src/data/common.css index 728c160..1d13f96 100644 --- a/src/data/common.css +++ b/src/data/common.css @@ -7,3 +7,12 @@ .modifier-toggle.collapsed { transform: translate(-5px, -5px) rotate(-90deg); } + +.node-text { + text-anchor: middle; + dominant-baseline: middle; + font-family: monospace; + font-size: 200%; + pointer-events: none; + filter: drop-shadow(3px 3px 2px var(--tooltip-background)); +} diff --git a/src/data/projEntry.tsx b/src/data/projEntry.tsx index f69ac8b..d256f4e 100644 --- a/src/data/projEntry.tsx +++ b/src/data/projEntry.tsx @@ -1,92 +1,371 @@ -import Node from "components/Node.vue"; -import Spacer from "components/layout/Spacer.vue"; +import Board from "features/boards/Board.vue"; +import CircleProgress from "features/boards/CircleProgress.vue"; +import SVGNode from "features/boards/SVGNode.vue"; +import SquareProgress from "features/boards/SquareProgress.vue"; +import { + NodePosition, + placeInAvailableSpace, + setupActions, + setupDraggableNode, + setupSelectable, + setupUniqueIds +} from "features/boards/board"; import { jsx } from "features/feature"; -import { createResource, trackBest, trackOOMPS, trackTotal } from "features/resources/resource"; -import type { GenericTree } from "features/trees/tree"; -import { branchedResetPropagation, createTree } from "features/trees/tree"; -import { globalBus } from "game/events"; import type { BaseLayer, GenericLayer } from "game/layers"; import { createLayer } from "game/layers"; +import { persistent } from "game/persistence"; import type { Player } from "game/player"; -import player from "game/player"; -import type { DecimalSource } from "util/bignum"; -import Decimal, { format, formatTime } from "util/bignum"; -import { render } from "util/vue"; -import { computed, toRaw } from "vue"; +import { ComponentPublicInstance, computed, ref, watch } from "vue"; import prestige from "./layers/prestige"; +type ANode = NodePosition & { id: number; links: number[]; type: "anode" }; +type BNode = NodePosition & { id: number; links: number[]; type: "bnode" }; +type NodeTypes = ANode | BNode; + /** * @hidden */ export const main = createLayer("main", function (this: BaseLayer) { - const points = createResource(10); - const best = trackBest(points); - const total = trackTotal(points); + const board = ref>(); - const pointGain = computed(() => { - // eslint-disable-next-line prefer-const - let gain = new Decimal(1); - return gain; - }); - globalBus.on("update", diff => { - points.value = Decimal.add(points.value, Decimal.times(pointGain.value, diff)); - }); - const oomps = trackOOMPS(points, pointGain); + const { select, deselect, selected } = setupSelectable(); + const { + select: selectAction, + deselect: deselectAction, + selected: selectedAction + } = setupSelectable(); - const tree = createTree(() => ({ - nodes: [[prestige.treeNode]], - branches: [], - onReset() { - points.value = toRaw(this.resettingNode.value) === toRaw(prestige.treeNode) ? 0 : 10; - best.value = points.value; - total.value = points.value; + watch(selected, selected => { + if (selected == null) { + deselectAction(); + } + }); + + const { + startDrag, + endDrag, + drag, + nodeBeingDragged, + hasDragged, + receivingNodes, + receivingNode, + dragDelta + } = setupDraggableNode({ + board, + isDraggable: function (node) { + return nodes.value.includes(node); + } + }); + + // a nodes can be slotted into b nodes to draw a branch between them, with limited connections + // a nodes can be selected and have an action to spawn a b node, and vice versa + // Newly spawned nodes should find a safe spot to spawn, and display a link to their creator + // a nodes use all the stuff circles used to have, and b diamonds + // c node also exists but is a single Upgrade element that cannot be selected, but can be dragged + // d nodes are a performance test - 1000 simple nodes that have no interactions + // Make all nodes animate in (decorator? `fadeIn(feature)?) + const nodes = persistent([{ id: 0, x: 0, y: 0, links: [], type: "anode" }]); + const nodesById = computed>(() => + nodes.value.reduce((acc, curr) => ({ ...acc, [curr.id]: curr }), {}) + ); + function mouseDownNode(e: MouseEvent | TouchEvent, node: NodeTypes) { + if (nodeBeingDragged.value == null) { + startDrag(e, node); + } + deselect(); + } + function mouseUpNode(e: MouseEvent | TouchEvent, node: NodeTypes) { + if (!hasDragged.value) { + endDrag(); + select(node); + e.stopPropagation(); + } + } + function getTranslateString(node: NodePosition, overrideSelected?: boolean) { + const isSelected = overrideSelected == null ? selected.value === node : overrideSelected; + const isDragging = !isSelected && nodeBeingDragged.value === node; + let x = node.x; + let y = node.y; + if (isDragging) { + x += dragDelta.value.x; + y += dragDelta.value.y; + } + return ` translate(${x}px,${y}px)`; + } + function getRotateString(rotation: number) { + return ` rotate(${rotation}deg) `; + } + function getScaleString(node: NodePosition, overrideSelected?: boolean) { + const isSelected = overrideSelected == null ? selected.value === node : overrideSelected; + return isSelected ? " scale(1.2)" : ""; + } + function getOpacityString(node: NodePosition, overrideSelected?: boolean) { + const isSelected = overrideSelected == null ? selected.value === node : overrideSelected; + const isDragging = !isSelected && nodeBeingDragged.value === node; + if (isDragging) { + return "; opacity: 0.5;"; + } + return ""; + } + + const renderANode = function (node: ANode) { + return ( + mouseDownNode(e, node)} + onMouseUp={e => mouseUpNode(e, node)} + > + + {receivingNodes.value.includes(node) && ( + + )} + + + + {selected.value === node && selectedAction.value === 0 && ( + + Spawn B Node + + )} + + A + + + ); + }; + const aActions = setupActions({ + node: selected, + shouldShowActions: () => selected.value?.type === "anode", + actions(node) { + return [ + p => ( + { + if (selectedAction.value === 0) { + spawnBNode(node); + } else { + selectAction(0); + } + }} + > + + + add + + + ) + ]; }, - resetPropagation: branchedResetPropagation - })) as GenericTree; + distance: 100 + }); + const sqrtTwo = Math.sqrt(2); + const renderBNode = function (node: BNode) { + return ( + mouseDownNode(e, node)} + onMouseUp={e => mouseUpNode(e, node)} + > + + {receivingNodes.value.includes(node) && ( + + )} + + + + {selected.value === node && selectedAction.value === 0 && ( + + Spawn A Node + + )} + + B + + + ); + }; + const bActions = setupActions({ + node: selected, + shouldShowActions: () => selected.value?.type === "bnode", + actions(node) { + return [ + p => ( + { + if (selectedAction.value === 0) { + spawnANode(node); + } else { + selectAction(0); + } + }} + > + + + add + + + ) + ]; + }, + distance: 100 + }); + function spawnANode(parent: NodeTypes) { + const node: ANode = { + x: parent.x, + y: parent.y, + type: "anode", + links: [parent.id], + id: nextId.value + }; + placeInAvailableSpace(node, nodes.value); + nodes.value.push(node); + } + function spawnBNode(parent: NodeTypes) { + const node: BNode = { + x: parent.x, + y: parent.y, + type: "bnode", + links: [parent.id], + id: nextId.value + }; + placeInAvailableSpace(node, nodes.value); + nodes.value.push(node); + } + + // const cNode = createUpgrade(() => ({ + // requirements: createCostRequirement(() => ({ cost: 10, resource: points })), + // style: { + // x: "100px", + // y: "100px" + // } + // })); + // makeDraggable(cNode); // TODO make decorator + + // const dNodes; + + const links = jsx(() => ( + <> + {nodes.value + .reduce( + (acc, curr) => [ + ...acc, + ...curr.links.map(l => ({ from: curr, to: nodesById.value[l] })) + ], + [] as { from: NodeTypes; to: NodeTypes }[] + ) + .map(link => ( + + ))} + + )); + + const nextId = setupUniqueIds(() => nodes.value); + + function filterNodes(n: NodeTypes) { + return n !== nodeBeingDragged.value && n !== selected.value; + } + + function renderNode(node: NodeTypes | undefined) { + if (node == undefined) { + return undefined; + } else if (node.type === "anode") { + return renderANode(node); + } else if (node.type === "bnode") { + return renderBNode(node); + } + } return { name: "Tree", - links: tree.links, display: jsx(() => ( <> - {player.devSpeed === 0 ? ( -
- Game Paused - -
- ) : null} - {player.devSpeed != null && player.devSpeed !== 0 && player.devSpeed !== 1 ? ( -
- Dev Speed: {format(player.devSpeed)}x - -
- ) : null} - {player.offlineTime != null && player.offlineTime !== 0 ? ( -
- Offline Time: {formatTime(player.offlineTime)} - -
- ) : null} -
- {Decimal.lt(points.value, "1e1000") ? You have : null} -

{format(points.value)}

- {Decimal.lt(points.value, "1e1e6") ? points : null} -
- {Decimal.gt(pointGain.value, 0) ? ( -
- ({oomps.value}) - -
- ) : null} - - {render(tree)} + + {links()} + {nodes.value.filter(filterNodes).map(renderNode)} + + {aActions()} + {bActions()} + + {renderNode(selected.value)} + {renderNode(nodeBeingDragged.value)} + )), - points, - best, - total, - oomps, - tree + boardNodes: nodes + // cNode }; }); diff --git a/src/features/boards/Board.vue b/src/features/boards/Board.vue index 218b302..0ded9a1 100644 --- a/src/features/boards/Board.vue +++ b/src/features/boards/Board.vue @@ -1,278 +1,75 @@ + + diff --git a/src/features/boards/BoardLink.vue b/src/features/boards/BoardLink.vue deleted file mode 100644 index 5dacc66..0000000 --- a/src/features/boards/BoardLink.vue +++ /dev/null @@ -1,80 +0,0 @@ - - - - - diff --git a/src/features/boards/BoardNode.vue b/src/features/boards/BoardNode.vue deleted file mode 100644 index 6a32f37..0000000 --- a/src/features/boards/BoardNode.vue +++ /dev/null @@ -1,339 +0,0 @@ - - - - - - - diff --git a/src/features/boards/BoardNodeAction.vue b/src/features/boards/BoardNodeAction.vue deleted file mode 100644 index c65727a..0000000 --- a/src/features/boards/BoardNodeAction.vue +++ /dev/null @@ -1,109 +0,0 @@ - - - - - - - diff --git a/src/features/boards/CircleProgress.vue b/src/features/boards/CircleProgress.vue new file mode 100644 index 0000000..abe748d --- /dev/null +++ b/src/features/boards/CircleProgress.vue @@ -0,0 +1,29 @@ + + + + + diff --git a/src/features/boards/SVGNode.vue b/src/features/boards/SVGNode.vue new file mode 100644 index 0000000..d36155b --- /dev/null +++ b/src/features/boards/SVGNode.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/src/features/boards/SquareProgress.vue b/src/features/boards/SquareProgress.vue new file mode 100644 index 0000000..7e83c5d --- /dev/null +++ b/src/features/boards/SquareProgress.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/src/features/boards/board.ts b/src/features/boards/board.ts deleted file mode 100644 index 8a9026f..0000000 --- a/src/features/boards/board.ts +++ /dev/null @@ -1,631 +0,0 @@ -import BoardComponent from "features/boards/Board.vue"; -import type { GenericComponent, OptionsFunc, Replace, StyleValue } from "features/feature"; -import { - Component, - findFeatures, - GatherProps, - getUniqueID, - setDefault, - Visibility -} from "features/feature"; -import { globalBus } from "game/events"; -import { DefaultValue, deletePersistent, Persistent, State } from "game/persistence"; -import { persistent } from "game/persistence"; -import type { Unsubscribe } from "nanoevents"; -import { Direction, isFunction } from "util/common"; -import type { - Computable, - GetComputableType, - GetComputableTypeWithDefault, - ProcessedComputable -} from "util/computed"; -import { processComputable } from "util/computed"; -import { createLazyProxy } from "util/proxies"; -import { computed, isRef, ref, Ref, unref } from "vue"; -import panZoom from "vue-panzoom"; -import type { Link } from "../links/links"; - -globalBus.on("setupVue", app => panZoom.install(app)); - -/** A symbol used to identify {@link Board} features. */ -export const BoardType = Symbol("Board"); - -/** - * A type representing a computable value for a node on the board. Used for node types to return different values based on the given node and the state of the board. - */ -export type NodeComputable = - | Computable - | ((node: BoardNode, ...args: S) => T); - -/** Ways to display progress of an action with a duration. */ -export enum ProgressDisplay { - Outline = "Outline", - Fill = "Fill" -} - -/** Node shapes. */ -export enum Shape { - Circle = "Circle", - Diamond = "Triangle" -} - -/** An object representing a node on the board. */ -export interface BoardNode { - id: number; - position: { - x: number; - y: number; - }; - type: string; - state?: State; - pinned?: boolean; -} - -/** An object representing a link between two nodes on the board. */ -export interface BoardNodeLink extends Omit { - startNode: BoardNode; - endNode: BoardNode; - stroke: string; - strokeWidth: number; - pulsing?: boolean; -} - -/** An object representing a label for a node. */ -export interface NodeLabel { - text: string; - color?: string; - pulsing?: boolean; -} - -/** The persistent data for a board. */ -export type BoardData = { - nodes: BoardNode[]; - selectedNode: number | null; - selectedAction: string | null; -}; - -/** - * An object that configures a {@link NodeType}. - */ -export interface NodeTypeOptions { - /** The title to display for the node. */ - title: NodeComputable; - /** An optional label for the node. */ - label?: NodeComputable; - /** The size of the node - diameter for circles, width and height for squares. */ - size: NodeComputable; - /** CSS to apply to this node. */ - style?: NodeComputable; - /** Dictionary of CSS classes to apply to this node. */ - classes?: NodeComputable>; - /** Whether the node is draggable or not. */ - draggable?: NodeComputable; - /** The shape of the node. */ - shape: NodeComputable; - /** Whether the node can accept another node being dropped upon it. */ - canAccept?: NodeComputable; - /** The progress value of the node, from 0 to 1. */ - progress?: NodeComputable; - /** How the progress should be displayed on the node. */ - progressDisplay?: NodeComputable; - /** The color of the progress indicator. */ - progressColor?: NodeComputable; - /** The fill color of the node. */ - fillColor?: NodeComputable; - /** The outline color of the node. */ - outlineColor?: NodeComputable; - /** The color of the title text. */ - titleColor?: NodeComputable; - /** The list of action options for the node. */ - actions?: BoardNodeActionOptions[]; - /** The arc between each action, in radians. */ - actionDistance?: NodeComputable; - /** A function that is called when the node is clicked. */ - onClick?: (node: BoardNode) => void; - /** A function that is called when a node is dropped onto this node. */ - onDrop?: (node: BoardNode, otherNode: BoardNode) => void; - /** A function that is called for each node of this type every tick. */ - update?: (node: BoardNode, diff: number) => void; -} - -/** - * The properties that are added onto a processed {@link NodeTypeOptions} to create a {@link NodeType}. - */ -export interface BaseNodeType { - /** The nodes currently on the board of this type. */ - nodes: Ref; -} - -/** An object that represents a type of node that can appear on a board. It will handle getting properties and callbacks for every node of that type. */ -export type NodeType = Replace< - T & BaseNodeType, - { - title: GetComputableType; - label: GetComputableType; - size: GetComputableTypeWithDefault; - style: GetComputableType; - classes: GetComputableType; - draggable: GetComputableTypeWithDefault; - shape: GetComputableTypeWithDefault; - canAccept: GetComputableTypeWithDefault; - progress: GetComputableType; - progressDisplay: GetComputableTypeWithDefault; - progressColor: GetComputableTypeWithDefault; - fillColor: GetComputableType; - outlineColor: GetComputableType; - titleColor: GetComputableType; - actions?: GenericBoardNodeAction[]; - actionDistance: GetComputableTypeWithDefault; - } ->; - -/** A type that matches any valid {@link NodeType} object. */ -export type GenericNodeType = Replace< - NodeType, - { - size: NodeComputable; - draggable: NodeComputable; - shape: NodeComputable; - canAccept: NodeComputable; - progressDisplay: NodeComputable; - progressColor: NodeComputable; - actionDistance: NodeComputable; - } ->; - -/** - * An object that configures a {@link BoardNodeAction}. - */ -export interface BoardNodeActionOptions { - /** A unique identifier for the action. */ - id: string; - /** Whether this action should be visible. */ - visibility?: NodeComputable; - /** The icon to display for the action. */ - icon: NodeComputable; - /** The fill color of the action. */ - fillColor?: NodeComputable; - /** The tooltip text to display for the action. */ - tooltip: NodeComputable; - /** The confirmation label that appears under the action. */ - confirmationLabel?: NodeComputable; - /** An array of board node links associated with the action. They appear when the action is focused. */ - links?: NodeComputable; - /** A function that is called when the action is clicked. */ - onClick: (node: BoardNode) => void; -} - -/** - * The properties that are added onto a processed {@link BoardNodeActionOptions} to create an {@link BoardNodeAction}. - */ -export interface BaseBoardNodeAction { - links?: Ref; -} - -/** An object that represents an action that can be taken upon a node. */ -export type BoardNodeAction = Replace< - T & BaseBoardNodeAction, - { - visibility: GetComputableTypeWithDefault; - icon: GetComputableType; - fillColor: GetComputableType; - tooltip: GetComputableType; - confirmationLabel: GetComputableTypeWithDefault; - links: GetComputableType; - } ->; - -/** A type that matches any valid {@link BoardNodeAction} object. */ -export type GenericBoardNodeAction = Replace< - BoardNodeAction, - { - visibility: NodeComputable; - confirmationLabel: NodeComputable; - } ->; - -/** - * An object that configures a {@link Board}. - */ -export interface BoardOptions { - /** Whether this board should be visible. */ - visibility?: Computable; - /** The height of the board. Defaults to 100% */ - height?: Computable; - /** The width of the board. Defaults to 100% */ - width?: Computable; - /** Dictionary of CSS classes to apply to this feature. */ - classes?: Computable>; - /** CSS to apply to this feature. */ - style?: Computable; - /** A function that returns an array of initial board nodes, without IDs. */ - startNodes: () => Omit[]; - /** A dictionary of node types that can appear on the board. */ - types: Record; - /** The persistent state of the board. */ - state?: Computable; - /** An array of board node links to display. */ - links?: Computable; -} - -/** - * The properties that are added onto a processed {@link BoardOptions} to create a {@link Board}. - */ -export interface BaseBoard { - /** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */ - id: string; - /** All the nodes currently on the board. */ - nodes: Ref; - /** The currently selected node, if any. */ - selectedNode: Ref; - /** The currently selected action, if any. */ - selectedAction: Ref; - /** The currently being dragged node, if any. */ - draggingNode: Ref; - /** If dragging a node, the node it's currently being hovered over, if any. */ - receivingNode: Ref; - /** The current mouse position, if over the board. */ - mousePosition: Ref<{ x: number; y: number } | null>; - /** Places a node in the nearest empty space in the given direction with the specified space around it. */ - placeInAvailableSpace: (node: BoardNode, radius?: number, direction?: Direction) => void; - /** A symbol that helps identify features of the same type. */ - type: typeof BoardType; - /** The Vue component used to render this feature. */ - [Component]: GenericComponent; - /** A function to gather the props the vue component requires for this feature. */ - [GatherProps]: () => Record; -} - -/** An object that represents a feature that is a zoomable, pannable board with various nodes upon it. */ -export type Board = Replace< - T & BaseBoard, - { - visibility: GetComputableTypeWithDefault; - types: Record; - height: GetComputableType; - width: GetComputableType; - classes: GetComputableType; - style: GetComputableType; - state: GetComputableTypeWithDefault>; - links: GetComputableTypeWithDefault>; - } ->; - -/** A type that matches any valid {@link Board} object. */ -export type GenericBoard = Replace< - Board, - { - visibility: ProcessedComputable; - state: ProcessedComputable; - links: ProcessedComputable; - } ->; - -/** - * Lazily creates a board with the given options. - * @param optionsFunc Board options. - */ -export function createBoard( - optionsFunc: OptionsFunc -): Board { - const state = persistent( - { - nodes: [], - selectedNode: null, - selectedAction: null - }, - false - ); - - return createLazyProxy(feature => { - const board = optionsFunc.call(feature, feature); - board.id = getUniqueID("board-"); - board.type = BoardType; - board[Component] = BoardComponent as GenericComponent; - - if (board.state) { - deletePersistent(state); - processComputable(board as T, "state"); - } else { - state[DefaultValue] = { - nodes: board.startNodes().map((n, i) => { - (n as BoardNode).id = i; - return n as BoardNode; - }), - selectedNode: null, - selectedAction: null - }; - board.state = state; - } - - board.nodes = computed(() => unref(processedBoard.state).nodes); - board.selectedNode = computed({ - get() { - return ( - processedBoard.nodes.value.find( - node => node.id === unref(processedBoard.state).selectedNode - ) || null - ); - }, - set(node) { - if (isRef(processedBoard.state)) { - processedBoard.state.value = { - ...processedBoard.state.value, - selectedNode: node?.id ?? null - }; - } else { - processedBoard.state.selectedNode = node?.id ?? null; - } - } - }); - board.selectedAction = computed({ - get() { - const selectedNode = processedBoard.selectedNode.value; - if (selectedNode == null) { - return null; - } - const type = processedBoard.types[selectedNode.type]; - if (type.actions == null) { - return null; - } - return ( - type.actions.find( - action => action.id === unref(processedBoard.state).selectedAction - ) || null - ); - }, - set(action) { - if (isRef(processedBoard.state)) { - processedBoard.state.value = { - ...processedBoard.state.value, - selectedAction: action?.id ?? null - }; - } else { - processedBoard.state.selectedAction = action?.id ?? null; - } - } - }); - board.mousePosition = ref(null); - if (board.links) { - processComputable(board as T, "links"); - } else { - board.links = computed(() => { - if (processedBoard.selectedAction.value == null) { - return null; - } - if ( - processedBoard.selectedAction.value.links && - processedBoard.selectedNode.value - ) { - return getNodeProperty( - processedBoard.selectedAction.value.links, - processedBoard.selectedNode.value - ); - } - return null; - }); - } - board.draggingNode = ref(null); - board.receivingNode = ref(null); - processComputable(board as T, "visibility"); - setDefault(board, "visibility", Visibility.Visible); - processComputable(board as T, "width"); - setDefault(board, "width", "100%"); - processComputable(board as T, "height"); - setDefault(board, "height", "100%"); - processComputable(board as T, "classes"); - processComputable(board as T, "style"); - - for (const type in board.types) { - const nodeType: NodeTypeOptions & Partial = board.types[type]; - - processComputable(nodeType as NodeTypeOptions, "title"); - processComputable(nodeType as NodeTypeOptions, "label"); - processComputable(nodeType as NodeTypeOptions, "size"); - setDefault(nodeType, "size", 50); - processComputable(nodeType as NodeTypeOptions, "style"); - processComputable(nodeType as NodeTypeOptions, "classes"); - processComputable(nodeType as NodeTypeOptions, "draggable"); - setDefault(nodeType, "draggable", false); - processComputable(nodeType as NodeTypeOptions, "shape"); - setDefault(nodeType, "shape", Shape.Circle); - processComputable(nodeType as NodeTypeOptions, "canAccept"); - setDefault(nodeType, "canAccept", false); - processComputable(nodeType as NodeTypeOptions, "progress"); - processComputable(nodeType as NodeTypeOptions, "progressDisplay"); - setDefault(nodeType, "progressDisplay", ProgressDisplay.Fill); - processComputable(nodeType as NodeTypeOptions, "progressColor"); - setDefault(nodeType, "progressColor", "none"); - processComputable(nodeType as NodeTypeOptions, "fillColor"); - processComputable(nodeType as NodeTypeOptions, "outlineColor"); - processComputable(nodeType as NodeTypeOptions, "titleColor"); - processComputable(nodeType as NodeTypeOptions, "actionDistance"); - setDefault(nodeType, "actionDistance", Math.PI / 6); - nodeType.nodes = computed(() => - unref(processedBoard.state).nodes.filter(node => node.type === type) - ); - setDefault(nodeType, "onClick", function (node: BoardNode) { - unref(processedBoard.state).selectedNode = node.id; - }); - - if (nodeType.actions) { - for (const action of nodeType.actions) { - processComputable(action, "visibility"); - setDefault(action, "visibility", Visibility.Visible); - processComputable(action, "icon"); - processComputable(action, "fillColor"); - processComputable(action, "tooltip"); - processComputable(action, "confirmationLabel"); - setDefault(action, "confirmationLabel", { text: "Tap again to confirm" }); - processComputable(action, "links"); - } - } - } - - function setDraggingNode(node: BoardNode | null) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - board.draggingNode!.value = node; - } - function setReceivingNode(node: BoardNode | null) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - board.receivingNode!.value = node; - } - - board.placeInAvailableSpace = function ( - node: BoardNode, - radius = 100, - direction = Direction.Right - ) { - const nodes = processedBoard.nodes.value - .slice() - .filter(n => { - // Exclude self - if (n === node) { - return false; - } - - // Exclude nodes that aren't within the corridor we'll be moving within - if ( - (direction === Direction.Down || direction === Direction.Up) && - Math.abs(n.position.x - node.position.x) > radius - ) { - return false; - } - if ( - (direction === Direction.Left || direction === Direction.Right) && - Math.abs(n.position.y - node.position.y) > radius - ) { - return false; - } - - // Exclude nodes in the wrong direction - return !( - (direction === Direction.Right && - n.position.x < node.position.x - radius) || - (direction === Direction.Left && n.position.x > node.position.x + radius) || - (direction === Direction.Up && n.position.y > node.position.y + radius) || - (direction === Direction.Down && n.position.y < node.position.y - radius) - ); - }) - .sort( - direction === Direction.Right - ? (a, b) => a.position.x - b.position.x - : direction === Direction.Left - ? (a, b) => b.position.x - a.position.x - : direction === Direction.Up - ? (a, b) => b.position.y - a.position.y - : (a, b) => a.position.y - b.position.y - ); - for (let i = 0; i < nodes.length; i++) { - const nodeToCheck = nodes[i]; - const distance = - direction === Direction.Right || direction === Direction.Left - ? Math.abs(node.position.x - nodeToCheck.position.x) - : Math.abs(node.position.y - nodeToCheck.position.y); - - // If we're too close to this node, move further - if (distance < radius) { - if (direction === Direction.Right) { - node.position.x = nodeToCheck.position.x + radius; - } else if (direction === Direction.Left) { - node.position.x = nodeToCheck.position.x - radius; - } else if (direction === Direction.Up) { - node.position.y = nodeToCheck.position.y - radius; - } else if (direction === Direction.Down) { - node.position.y = nodeToCheck.position.y + radius; - } - } else if (i > 0 && distance > radius) { - // If we're further from this node than the radius, then the nodes are past us and we can early exit - break; - } - } - }; - - board[GatherProps] = function (this: GenericBoard) { - const { - nodes, - types, - state, - visibility, - width, - height, - style, - classes, - links, - selectedAction, - selectedNode, - mousePosition, - draggingNode, - receivingNode - } = this; - return { - nodes, - types, - state, - visibility, - width, - height, - style: unref(style), - classes, - links, - selectedAction, - selectedNode, - mousePosition, - draggingNode, - receivingNode, - setDraggingNode, - setReceivingNode - }; - }; - - // This is necessary because board.types is different from T and Board - const processedBoard = board as unknown as Board; - return processedBoard; - }); -} - -/** - * Gets the value of a property for a specified node. - * @param property The property to find the value of - * @param node The node to get the property of - */ -export function getNodeProperty( - property: NodeComputable, - node: BoardNode, - ...args: S -): T { - return isFunction>(property) - ? property(node, ...args) - : unref(property); -} - -/** - * Utility to get an ID for a node that is guaranteed unique. - * @param board The board feature to generate an ID for - */ -export function getUniqueNodeID(board: GenericBoard): number { - let id = 0; - board.nodes.value.forEach(node => { - if (node.id >= id) { - id = node.id + 1; - } - }); - return id; -} - -const listeners: Record = {}; -globalBus.on("addLayer", layer => { - const boards: GenericBoard[] = findFeatures(layer, BoardType) as GenericBoard[]; - listeners[layer.id] = layer.on("postUpdate", diff => { - boards.forEach(board => { - Object.values(board.types).forEach(type => - type.nodes.value.forEach(node => type.update?.(node, diff)) - ); - }); - }); -}); -globalBus.on("removeLayer", layer => { - // unsubscribe from postUpdate - listeners[layer.id]?.(); - listeners[layer.id] = undefined; -}); diff --git a/src/features/boards/board.tsx b/src/features/boards/board.tsx new file mode 100644 index 0000000..ac61615 --- /dev/null +++ b/src/features/boards/board.tsx @@ -0,0 +1,317 @@ +import Board from "features/boards/Board.vue"; +import { jsx } from "features/feature"; +import { globalBus } from "game/events"; +import type { PanZoom } from "panzoom"; +import { Direction, isFunction } from "util/common"; +import type { Computable, ProcessedComputable } from "util/computed"; +import { convertComputable } from "util/computed"; +import type { ComponentPublicInstance, Ref } from "vue"; +import { computed, ref, unref, watchEffect } from "vue"; +import panZoom from "vue-panzoom"; + +globalBus.on("setupVue", app => panZoom.install(app)); + +export type NodePosition = { x: number; y: number }; + +/** + * A type representing a computable value for a node on the board. Used for node types to return different values based on the given node and the state of the board. + */ +export type NodeComputable = + | Computable + | ((node: T, ...args: S) => R); + +/** + * Gets the value of a property for a specified node. + * @param property The property to find the value of + * @param node The node to get the property of + */ +export function unwrapNodeRef( + property: NodeComputable, + node: T, + ...args: S +): R { + return isFunction>(property) + ? property(node, ...args) + : unref(property); +} + +export function setupUniqueIds(nodes: Computable<{ id: number }[]>) { + const processedNodes = convertComputable(nodes); + return computed(() => Math.max(-1, ...unref(processedNodes).map(node => node.id)) + 1); +} + +export function setupSelectable() { + const selected = ref(); + return { + select: function (node: T) { + selected.value = node; + }, + deselect: function () { + selected.value = undefined; + }, + selected + }; +} + +export function setupDraggableNode(options: { + board: Ref | undefined>; + receivingNodes?: NodeComputable; + dropAreaRadius?: NodeComputable; + isDraggable?: NodeComputable; + onDrop?: (acceptingNode: S, draggingNode: T) => void; +}) { + const nodeBeingDragged = ref(); + const receivingNode = ref(); + const hasDragged = ref(false); + const mousePosition = ref(); + const lastMousePosition = ref({ x: 0, y: 0 }); + const dragDelta = ref({ x: 0, y: 0 }); + const isDraggable = options.isDraggable ?? true; + const receivingNodes = computed(() => + nodeBeingDragged.value == null + ? [] + : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + unwrapNodeRef(options.receivingNodes ?? [], nodeBeingDragged.value!) + ); + const dropAreaRadius = options.dropAreaRadius ?? 50; + + watchEffect(() => { + if (nodeBeingDragged.value != null && !unwrapNodeRef(isDraggable, nodeBeingDragged.value)) { + result.endDrag(); + } + }); + + watchEffect(() => { + const node = nodeBeingDragged.value; + if (node == null) { + return null; + } + + const position = { + x: node.x + dragDelta.value.x, + y: node.y + dragDelta.value.y + }; + let smallestDistance = Number.MAX_VALUE; + + receivingNode.value = unref(receivingNodes).reduce((smallest: S | undefined, curr: S) => { + if ((curr as S | T) === node) { + return smallest; + } + + const distanceSquared = + Math.pow(position.x - curr.x, 2) + Math.pow(position.y - curr.y, 2); + const size = unwrapNodeRef(dropAreaRadius, curr); + if (distanceSquared > smallestDistance || distanceSquared > size * size) { + return smallest; + } + + smallestDistance = distanceSquared; + return curr; + }, undefined); + }); + + const result = { + nodeBeingDragged, + receivingNode, + hasDragged, + mousePosition, + lastMousePosition, + dragDelta, + receivingNodes, + startDrag: function (e: MouseEvent | TouchEvent, node?: T) { + e.preventDefault(); + e.stopPropagation(); + + let clientX, clientY; + if ("touches" in e) { + if (e.touches.length === 1) { + clientX = e.touches[0].clientX; + clientY = e.touches[0].clientY; + } else { + return; + } + } else { + clientX = e.clientX; + clientY = e.clientY; + } + lastMousePosition.value = { + x: clientX, + y: clientY + }; + dragDelta.value = { x: 0, y: 0 }; + hasDragged.value = false; + + if (node != null && unwrapNodeRef(isDraggable, node)) { + nodeBeingDragged.value = node; + } + }, + endDrag: function () { + if (nodeBeingDragged.value == null) { + return; + } + if (receivingNode.value == null) { + nodeBeingDragged.value.x += Math.round(dragDelta.value.x / 25) * 25; + nodeBeingDragged.value.y += Math.round(dragDelta.value.y / 25) * 25; + } + + if (receivingNode.value != null) { + options.onDrop?.(receivingNode.value, nodeBeingDragged.value); + } + + nodeBeingDragged.value = undefined; + }, + drag: function (e: MouseEvent | TouchEvent) { + const panZoomInstance = options.board.value?.panZoomInstance as PanZoom | undefined; + if (panZoomInstance == null) { + return; + } + + const { x, y, scale } = panZoomInstance.getTransform(); + + let clientX, clientY; + if ("touches" in e) { + if (e.touches.length === 1) { + clientX = e.touches[0].clientX; + clientY = e.touches[0].clientY; + } else { + result.endDrag(); + mousePosition.value = undefined; + return; + } + } else { + clientX = e.clientX; + clientY = e.clientY; + } + + mousePosition.value = { + x: (clientX - x) / scale, + y: (clientY - y) / scale + }; + + dragDelta.value = { + x: dragDelta.value.x + (clientX - lastMousePosition.value.x) / scale, + y: dragDelta.value.y + (clientY - lastMousePosition.value.y) / scale + }; + lastMousePosition.value = { + x: clientX, + y: clientY + }; + + if (Math.abs(dragDelta.value.x) > 10 || Math.abs(dragDelta.value.y) > 10) { + hasDragged.value = true; + } + + if (nodeBeingDragged.value != null) { + e.preventDefault(); + e.stopPropagation(); + } + } + }; + return result; +} + +export function setupActions(options: { + node: Computable; + shouldShowActions?: NodeComputable; + actions: NodeComputable JSX.Element)[]>; + distance: NodeComputable; + arcLength?: NodeComputable; +}) { + const node = convertComputable(options.node); + return jsx(() => { + const currNode = unref(node); + if (currNode == null) { + return ""; + } + + const actions = unwrapNodeRef(options.actions, currNode); + const shouldShow = unwrapNodeRef(options.shouldShowActions, currNode) ?? true; + if (!shouldShow) { + return <>{actions.map(f => f(currNode))}; + } + + const distance = unwrapNodeRef(options.distance, currNode); + const arcLength = unwrapNodeRef(options.arcLength, currNode) ?? Math.PI / 6; + const firstAngle = Math.PI / 2 - ((actions.length - 1) / 2) * arcLength; + return ( + <> + {actions.map((f, index) => + f({ + x: currNode.x + Math.cos(firstAngle + index * arcLength) * distance, + y: currNode.y + Math.sin(firstAngle + index * arcLength) * distance + }) + )} + + ); + }); +} + +export function placeInAvailableSpace( + nodeToPlace: T, + nodes: T[], + radius = 100, + direction = Direction.Right +) { + nodes = nodes + .filter(n => { + // Exclude self + if (n === nodeToPlace) { + return false; + } + + // Exclude nodes that aren't within the corridor we'll be moving within + if ( + (direction === Direction.Down || direction === Direction.Up) && + Math.abs(n.x - nodeToPlace.x) > radius + ) { + return false; + } + if ( + (direction === Direction.Left || direction === Direction.Right) && + Math.abs(n.y - nodeToPlace.y) > radius + ) { + return false; + } + + // Exclude nodes in the wrong direction + return !( + (direction === Direction.Right && n.x < nodeToPlace.x - radius) || + (direction === Direction.Left && n.x > nodeToPlace.x + radius) || + (direction === Direction.Up && n.y > nodeToPlace.y + radius) || + (direction === Direction.Down && n.y < nodeToPlace.y - radius) + ); + }) + .sort( + direction === Direction.Right + ? (a, b) => a.x - b.x + : direction === Direction.Left + ? (a, b) => b.x - a.x + : direction === Direction.Up + ? (a, b) => b.y - a.y + : (a, b) => a.y - b.y + ); + + for (let i = 0; i < nodes.length; i++) { + const nodeToCheck = nodes[i]; + const distance = + direction === Direction.Right || direction === Direction.Left + ? Math.abs(nodeToPlace.x - nodeToCheck.x) + : Math.abs(nodeToPlace.y - nodeToCheck.y); + + // If we're too close to this node, move further + if (distance < radius) { + if (direction === Direction.Right) { + nodeToPlace.x = nodeToCheck.x + radius; + } else if (direction === Direction.Left) { + nodeToPlace.x = nodeToCheck.x - radius; + } else if (direction === Direction.Up) { + nodeToPlace.y = nodeToCheck.y - radius; + } else if (direction === Direction.Down) { + nodeToPlace.y = nodeToCheck.y + radius; + } + } else if (i > 0 && distance > radius) { + // If we're further from this node than the radius, then the nodes are past us and we can early exit + break; + } + } +} From 1acfde134b9e4923643a9ed1282797d4d040da5b Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Sun, 3 Mar 2024 19:59:26 -0600 Subject: [PATCH 12/82] Add support for rendering VueFeatures in boards --- src/data/layers/prestige.tsx | 73 ------------- src/data/projEntry.tsx | 172 ++++++++++++++++++------------ src/features/boards/Draggable.vue | 37 +++++++ src/features/boards/board.tsx | 110 ++++++++++++++----- 4 files changed, 225 insertions(+), 167 deletions(-) delete mode 100644 src/data/layers/prestige.tsx create mode 100644 src/features/boards/Draggable.vue diff --git a/src/data/layers/prestige.tsx b/src/data/layers/prestige.tsx deleted file mode 100644 index 6e3cb69..0000000 --- a/src/data/layers/prestige.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/** - * @module - * @hidden - */ -import { main } from "data/projEntry"; -import { createCumulativeConversion } from "features/conversion"; -import { jsx } from "features/feature"; -import { createHotkey } from "features/hotkey"; -import { createReset } from "features/reset"; -import MainDisplay from "features/resources/MainDisplay.vue"; -import { createResource } from "features/resources/resource"; -import { addTooltip } from "features/tooltips/tooltip"; -import { createResourceTooltip } from "features/trees/tree"; -import { BaseLayer, createLayer } from "game/layers"; -import type { DecimalSource } from "util/bignum"; -import { render } from "util/vue"; -import { createLayerTreeNode, createResetButton } from "../common"; - -const id = "p"; -const layer = createLayer(id, function (this: BaseLayer) { - const name = "Prestige"; - const color = "#4BDC13"; - const points = createResource(0, "prestige points"); - - const conversion = createCumulativeConversion(() => ({ - formula: x => x.div(10).sqrt(), - baseResource: main.points, - gainResource: points - })); - - const reset = createReset(() => ({ - thingsToReset: (): Record[] => [layer] - })); - - const treeNode = createLayerTreeNode(() => ({ - layerID: id, - color, - reset - })); - const tooltip = addTooltip(treeNode, { - display: createResourceTooltip(points), - pinnable: true - }); - - const resetButton = createResetButton(() => ({ - conversion, - tree: main.tree, - treeNode - })); - - const hotkey = createHotkey(() => ({ - description: "Reset for prestige points", - key: "p", - onPress: resetButton.onClick - })); - - return { - name, - color, - points, - tooltip, - display: jsx(() => ( - <> - - {render(resetButton)} - - )), - treeNode, - hotkey - }; -}); - -export default layer; diff --git a/src/data/projEntry.tsx b/src/data/projEntry.tsx index d256f4e..6762d45 100644 --- a/src/data/projEntry.tsx +++ b/src/data/projEntry.tsx @@ -4,6 +4,7 @@ import SVGNode from "features/boards/SVGNode.vue"; import SquareProgress from "features/boards/SquareProgress.vue"; import { NodePosition, + makeDraggable, placeInAvailableSpace, setupActions, setupDraggableNode, @@ -11,24 +12,29 @@ import { setupUniqueIds } from "features/boards/board"; import { jsx } from "features/feature"; +import { createResource } from "features/resources/resource"; +import { createUpgrade } from "features/upgrades/upgrade"; import type { BaseLayer, GenericLayer } from "game/layers"; import { createLayer } from "game/layers"; -import { persistent } from "game/persistence"; +import { Persistent, persistent } from "game/persistence"; import type { Player } from "game/player"; +import { createCostRequirement } from "game/requirements"; +import { render } from "util/vue"; import { ComponentPublicInstance, computed, ref, watch } from "vue"; -import prestige from "./layers/prestige"; - -type ANode = NodePosition & { id: number; links: number[]; type: "anode" }; -type BNode = NodePosition & { id: number; links: number[]; type: "bnode" }; -type NodeTypes = ANode | BNode; +import "./common.css"; /** * @hidden */ export const main = createLayer("main", function (this: BaseLayer) { + type ANode = NodePosition & { id: number; links: number[]; type: "anode" }; + type BNode = NodePosition & { id: number; links: number[]; type: "bnode" }; + type CNode = typeof cNode & { position: Persistent }; + type NodeTypes = ANode | BNode; + const board = ref>(); - const { select, deselect, selected } = setupSelectable(); + const { select, deselect, selected } = setupSelectable(); const { select: selectAction, deselect: deselectAction, @@ -50,10 +56,15 @@ export const main = createLayer("main", function (this: BaseLayer) { receivingNodes, receivingNode, dragDelta - } = setupDraggableNode({ + } = setupDraggableNode({ board, - isDraggable: function (node) { - return nodes.value.includes(node); + getPosition(id) { + return nodesById.value[id] ?? (cNode as CNode).position.value; + }, + setPosition(id, position) { + const node = nodesById.value[id] ?? (cNode as CNode).position.value; + node.x = position.x; + node.y = position.y; } }); @@ -64,26 +75,26 @@ export const main = createLayer("main", function (this: BaseLayer) { // c node also exists but is a single Upgrade element that cannot be selected, but can be dragged // d nodes are a performance test - 1000 simple nodes that have no interactions // Make all nodes animate in (decorator? `fadeIn(feature)?) - const nodes = persistent([{ id: 0, x: 0, y: 0, links: [], type: "anode" }]); + const nodes = persistent<(ANode | BNode)[]>([{ id: 0, x: 0, y: 0, links: [], type: "anode" }]); const nodesById = computed>(() => nodes.value.reduce((acc, curr) => ({ ...acc, [curr.id]: curr }), {}) ); function mouseDownNode(e: MouseEvent | TouchEvent, node: NodeTypes) { if (nodeBeingDragged.value == null) { - startDrag(e, node); + startDrag(e, node.id); } deselect(); } function mouseUpNode(e: MouseEvent | TouchEvent, node: NodeTypes) { if (!hasDragged.value) { endDrag(); - select(node); + if (typeof node.id === "number") { + select(node.id); + } e.stopPropagation(); } } - function getTranslateString(node: NodePosition, overrideSelected?: boolean) { - const isSelected = overrideSelected == null ? selected.value === node : overrideSelected; - const isDragging = !isSelected && nodeBeingDragged.value === node; + function getTranslateString(node: NodePosition, isDragging: boolean) { let x = node.x; let y = node.y; if (isDragging) { @@ -95,13 +106,13 @@ export const main = createLayer("main", function (this: BaseLayer) { function getRotateString(rotation: number) { return ` rotate(${rotation}deg) `; } - function getScaleString(node: NodePosition, overrideSelected?: boolean) { - const isSelected = overrideSelected == null ? selected.value === node : overrideSelected; + function getScaleString(nodeOrBool: NodeTypes | boolean) { + const isSelected = + typeof nodeOrBool === "boolean" ? nodeOrBool : selected.value === nodeOrBool.id; return isSelected ? " scale(1.2)" : ""; } - function getOpacityString(node: NodePosition, overrideSelected?: boolean) { - const isSelected = overrideSelected == null ? selected.value === node : overrideSelected; - const isDragging = !isSelected && nodeBeingDragged.value === node; + function getOpacityString(node: NodeTypes) { + const isDragging = selected.value !== node.id && nodeBeingDragged.value === node.id; if (isDragging) { return "; opacity: 0.5;"; } @@ -111,16 +122,19 @@ export const main = createLayer("main", function (this: BaseLayer) { const renderANode = function (node: ANode) { return ( mouseDownNode(e, node)} onMouseUp={e => mouseUpNode(e, node)} > - - {receivingNodes.value.includes(node) && ( + + {receivingNodes.value.includes(node.id) && ( )} @@ -132,7 +146,7 @@ export const main = createLayer("main", function (this: BaseLayer) { stroke-width="4" /> - {selected.value === node && selectedAction.value === 0 && ( + {selected.value === node.id && selectedAction.value === 0 && ( Spawn B Node @@ -144,8 +158,8 @@ export const main = createLayer("main", function (this: BaseLayer) { ); }; const aActions = setupActions({ - node: selected, - shouldShowActions: () => selected.value?.type === "anode", + node: () => nodesById.value[selected.value ?? ""], + shouldShowActions: node => node.type === "anode", actions(node) { return [ p => ( @@ -153,10 +167,10 @@ export const main = createLayer("main", function (this: BaseLayer) { style={`transform: ${getTranslateString( p, selectedAction.value === 0 - )}${getScaleString(p, selectedAction.value === 0)}`} + )}${getScaleString(selectedAction.value === 0)}`} onClick={() => { if (selectedAction.value === 0) { - spawnBNode(node); + spawnBNode(node as ANode); } else { selectAction(0); } @@ -176,16 +190,15 @@ export const main = createLayer("main", function (this: BaseLayer) { const renderBNode = function (node: BNode) { return ( mouseDownNode(e, node)} onMouseUp={e => mouseUpNode(e, node)} > - - {receivingNodes.value.includes(node) && ( + + {receivingNodes.value.includes(node.id) && ( )} @@ -213,7 +226,7 @@ export const main = createLayer("main", function (this: BaseLayer) { stroke-width="4" /> - {selected.value === node && selectedAction.value === 0 && ( + {selected.value === node.id && selectedAction.value === 0 && ( Spawn A Node @@ -225,8 +238,8 @@ export const main = createLayer("main", function (this: BaseLayer) { ); }; const bActions = setupActions({ - node: selected, - shouldShowActions: () => selected.value?.type === "bnode", + node: () => nodesById.value[selected.value ?? ""], + shouldShowActions: node => node.type === "bnode", actions(node) { return [ p => ( @@ -234,10 +247,10 @@ export const main = createLayer("main", function (this: BaseLayer) { style={`transform: ${getTranslateString( p, selectedAction.value === 0 - )}${getScaleString(p, selectedAction.value === 0)}`} + )}${getScaleString(selectedAction.value === 0)}`} onClick={() => { if (selectedAction.value === 0) { - spawnANode(node); + spawnANode(node as BNode); } else { selectAction(0); } @@ -253,7 +266,7 @@ export const main = createLayer("main", function (this: BaseLayer) { }, distance: 100 }); - function spawnANode(parent: NodeTypes) { + function spawnANode(parent: ANode | BNode) { const node: ANode = { x: parent.x, y: parent.y, @@ -264,7 +277,7 @@ export const main = createLayer("main", function (this: BaseLayer) { placeInAvailableSpace(node, nodes.value); nodes.value.push(node); } - function spawnBNode(parent: NodeTypes) { + function spawnBNode(parent: ANode | BNode) { const node: BNode = { x: parent.x, y: parent.y, @@ -276,14 +289,29 @@ export const main = createLayer("main", function (this: BaseLayer) { nodes.value.push(node); } - // const cNode = createUpgrade(() => ({ - // requirements: createCostRequirement(() => ({ cost: 10, resource: points })), - // style: { - // x: "100px", - // y: "100px" - // } - // })); - // makeDraggable(cNode); // TODO make decorator + const points = createResource(10); + const cNode = createUpgrade(() => ({ + display: "

C

", + // Purposefully not using noPersist + requirements: createCostRequirement(() => ({ cost: 10, resource: points })), + style: { + x: "100px", + y: "100px" + } + })); + makeDraggable(cNode, { + id: "cnode", + endDrag, + startDrag, + hasDragged, + nodeBeingDragged, + dragDelta, + onMouseUp() { + if (!hasDragged.value) { + cNode.purchase(); + } + } + }); // const dNodes; @@ -302,22 +330,22 @@ export const main = createLayer("main", function (this: BaseLayer) { stroke="white" stroke-width={4} x1={ - nodeBeingDragged.value === link.from + nodeBeingDragged.value === link.from.id ? dragDelta.value.x + link.from.x : link.from.x } y1={ - nodeBeingDragged.value === link.from + nodeBeingDragged.value === link.from.id ? dragDelta.value.y + link.from.y : link.from.y } x2={ - nodeBeingDragged.value === link.to + nodeBeingDragged.value === link.to.id ? dragDelta.value.x + link.to.x : link.to.x } y2={ - nodeBeingDragged.value === link.to + nodeBeingDragged.value === link.to.id ? dragDelta.value.y + link.to.y : link.to.y } @@ -328,22 +356,30 @@ export const main = createLayer("main", function (this: BaseLayer) { const nextId = setupUniqueIds(() => nodes.value); - function filterNodes(n: NodeTypes) { + function filterNodes(n: number | "cnode") { return n !== nodeBeingDragged.value && n !== selected.value; } - function renderNode(node: NodeTypes | undefined) { - if (node == undefined) { + function renderNodeById(id: number | "cnode" | undefined) { + if (id == null) { return undefined; - } else if (node.type === "anode") { + } + return renderNode(nodesById.value[id] ?? cNode); + } + + function renderNode(node: NodeTypes | typeof cNode) { + if (node.type === "anode") { return renderANode(node); } else if (node.type === "bnode") { return renderBNode(node); + } else { + return render(node); } } return { name: "Tree", + color: "var(--accent1)", display: jsx(() => ( <> {links()} - {nodes.value.filter(filterNodes).map(renderNode)} + {nodes.value.filter(n => filterNodes(n.id)).map(renderNode)} + {filterNodes("cnode") && render(cNode)} {aActions()} {bActions()} - {renderNode(selected.value)} - {renderNode(nodeBeingDragged.value)} + {renderNodeById(selected.value)} + {renderNodeById(nodeBeingDragged.value)} )), - boardNodes: nodes - // cNode + boardNodes: nodes, + cNode, + selected: persistent(selected) }; }); @@ -376,7 +414,7 @@ export const main = createLayer("main", function (this: BaseLayer) { export const getInitialLayers = ( /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ player: Partial -): Array => [main, prestige]; +): Array => [main]; /** * A computed ref whose value is true whenever the game is over. diff --git a/src/features/boards/Draggable.vue b/src/features/boards/Draggable.vue new file mode 100644 index 0000000..9d59db6 --- /dev/null +++ b/src/features/boards/Draggable.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/src/features/boards/board.tsx b/src/features/boards/board.tsx index ac61615..20501d8 100644 --- a/src/features/boards/board.tsx +++ b/src/features/boards/board.tsx @@ -1,12 +1,15 @@ import Board from "features/boards/Board.vue"; -import { jsx } from "features/feature"; +import Draggable from "features/boards/Draggable.vue"; +import { Component, GatherProps, GenericComponent, jsx } from "features/feature"; import { globalBus } from "game/events"; +import { Persistent, persistent } from "game/persistence"; import type { PanZoom } from "panzoom"; import { Direction, isFunction } from "util/common"; import type { Computable, ProcessedComputable } from "util/computed"; import { convertComputable } from "util/computed"; +import { VueFeature } from "util/vue"; import type { ComponentPublicInstance, Ref } from "vue"; -import { computed, ref, unref, watchEffect } from "vue"; +import { computed, nextTick, ref, unref, watchEffect } from "vue"; import panZoom from "vue-panzoom"; globalBus.on("setupVue", app => panZoom.install(app)); @@ -53,20 +56,20 @@ export function setupSelectable() { }; } -export function setupDraggableNode(options: { +export function setupDraggableNode(options: { board: Ref | undefined>; - receivingNodes?: NodeComputable; - dropAreaRadius?: NodeComputable; - isDraggable?: NodeComputable; - onDrop?: (acceptingNode: S, draggingNode: T) => void; + getPosition: (node: T) => NodePosition; + setPosition: (node: T, position: NodePosition) => void; + receivingNodes?: NodeComputable; + dropAreaRadius?: NodeComputable; + onDrop?: (acceptingNode: T, draggingNode: T) => void; }) { const nodeBeingDragged = ref(); - const receivingNode = ref(); + const receivingNode = ref(); const hasDragged = ref(false); const mousePosition = ref(); const lastMousePosition = ref({ x: 0, y: 0 }); const dragDelta = ref({ x: 0, y: 0 }); - const isDraggable = options.isDraggable ?? true; const receivingNodes = computed(() => nodeBeingDragged.value == null ? [] @@ -75,31 +78,26 @@ export function setupDraggableNode { - if (nodeBeingDragged.value != null && !unwrapNodeRef(isDraggable, nodeBeingDragged.value)) { - result.endDrag(); - } - }); - watchEffect(() => { const node = nodeBeingDragged.value; if (node == null) { return null; } + const originalPosition = options.getPosition(node); const position = { - x: node.x + dragDelta.value.x, - y: node.y + dragDelta.value.y + x: originalPosition.x + dragDelta.value.x, + y: originalPosition.y + dragDelta.value.y }; let smallestDistance = Number.MAX_VALUE; - receivingNode.value = unref(receivingNodes).reduce((smallest: S | undefined, curr: S) => { - if ((curr as S | T) === node) { + receivingNode.value = unref(receivingNodes).reduce((smallest: T | undefined, curr: T) => { + if ((curr as T) === node) { return smallest; } - const distanceSquared = - Math.pow(position.x - curr.x, 2) + Math.pow(position.y - curr.y, 2); + const { x, y } = options.getPosition(curr); + const distanceSquared = Math.pow(position.x - x, 2) + Math.pow(position.y - y, 2); const size = unwrapNodeRef(dropAreaRadius, curr); if (distanceSquared > smallestDistance || distanceSquared > size * size) { return smallest; @@ -118,7 +116,7 @@ export function setupDraggableNode( + element: T, + options: { + id: S; + nodeBeingDragged: Ref; + hasDragged: Ref; + dragDelta: Ref; + startDrag: (e: MouseEvent | TouchEvent, id: S) => void; + endDrag: VoidFunction; + onMouseDown?: (e: MouseEvent | TouchEvent) => boolean | void; + onMouseUp?: (e: MouseEvent | TouchEvent) => boolean | void; + initialPosition?: NodePosition; + } +): asserts element is T & { position: Persistent } { + const position = persistent(options.initialPosition ?? { x: 0, y: 0 }); + (element as T & { position: Persistent }).position = position; + const computedPosition = computed(() => { + if (options.nodeBeingDragged.value === options.id) { + return { + x: position.value.x + options.dragDelta.value.x, + y: position.value.y + options.dragDelta.value.y + }; + } + return position.value; + }); + + function handleMouseDown(e: MouseEvent | TouchEvent) { + if (options.onMouseDown?.(e) === false) { + return; + } + + if (options.nodeBeingDragged.value == null) { + options.startDrag(e, options.id); + } + } + + function handleMouseUp(e: MouseEvent | TouchEvent) { + options.onMouseUp?.(e); + } + + nextTick(() => { + const elementComponent = element[Component]; + const elementGatherProps = element[GatherProps].bind(element); + element[Component] = Draggable as GenericComponent; + element[GatherProps] = function gatherTooltipProps(this: typeof options) { + return { + element: { + [Component]: elementComponent, + [GatherProps]: elementGatherProps + }, + mouseDown: handleMouseDown, + mouseUp: handleMouseUp, + position: computedPosition + }; + }.bind(options); + }); +} + export function setupActions(options: { node: Computable; shouldShowActions?: NodeComputable; From f0e831ee8fea2aee319f37b722593dda259b95c3 Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Sun, 3 Mar 2024 20:26:00 -0600 Subject: [PATCH 13/82] Add cnodes --- src/data/projEntry.tsx | 20 +++++++++++++++++++- src/features/boards/SVGNode.vue | 1 + 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/data/projEntry.tsx b/src/data/projEntry.tsx index 6762d45..140fb55 100644 --- a/src/data/projEntry.tsx +++ b/src/data/projEntry.tsx @@ -313,6 +313,21 @@ export const main = createLayer("main", function (this: BaseLayer) { } }); + const dNodesPerAxis = 50; + const dNodes = jsx(() => + new Array(dNodesPerAxis * dNodesPerAxis).fill(0).map((_, i) => { + const x = (Math.floor(i / dNodesPerAxis) - dNodesPerAxis / 2) * 100; + const y = ((i % dNodesPerAxis) - dNodesPerAxis / 2) * 100; + return ( + + ); + }) + ); + // const dNodes; const links = jsx(() => ( @@ -389,7 +404,10 @@ export const main = createLayer("main", function (this: BaseLayer) { onMouseLeave={endDrag} ref={board} > - {links()} + + {dNodes()} + {links()} + {nodes.value.filter(n => filterNodes(n.id)).map(renderNode)} {filterNodes("cnode") && render(cNode)} diff --git a/src/features/boards/SVGNode.vue b/src/features/boards/SVGNode.vue index d36155b..a0d1b14 100644 --- a/src/features/boards/SVGNode.vue +++ b/src/features/boards/SVGNode.vue @@ -23,5 +23,6 @@ svg { cursor: pointer; transition-duration: 0s; overflow: visible; + position: absolute; } From aca56f6af6e8a05e019806d7d82c5e736c590dbd Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Sun, 3 Mar 2024 22:17:06 -0600 Subject: [PATCH 14/82] Use z-index to avoid changing render order --- src/data/projEntry.tsx | 106 ++++++++++++++++++++--------------------- 1 file changed, 53 insertions(+), 53 deletions(-) diff --git a/src/data/projEntry.tsx b/src/data/projEntry.tsx index 140fb55..8a3f858 100644 --- a/src/data/projEntry.tsx +++ b/src/data/projEntry.tsx @@ -27,8 +27,8 @@ import "./common.css"; * @hidden */ export const main = createLayer("main", function (this: BaseLayer) { - type ANode = NodePosition & { id: number; links: number[]; type: "anode" }; - type BNode = NodePosition & { id: number; links: number[]; type: "bnode" }; + type ANode = NodePosition & { id: number; links: number[]; type: "anode"; z: number }; + type BNode = NodePosition & { id: number; links: number[]; type: "bnode"; z: number }; type CNode = typeof cNode & { position: Persistent }; type NodeTypes = ANode | BNode; @@ -75,11 +75,20 @@ export const main = createLayer("main", function (this: BaseLayer) { // c node also exists but is a single Upgrade element that cannot be selected, but can be dragged // d nodes are a performance test - 1000 simple nodes that have no interactions // Make all nodes animate in (decorator? `fadeIn(feature)?) - const nodes = persistent<(ANode | BNode)[]>([{ id: 0, x: 0, y: 0, links: [], type: "anode" }]); + const nodes = persistent<(ANode | BNode)[]>([ + { id: 0, x: 0, y: 0, z: 0, links: [], type: "anode" } + ]); const nodesById = computed>(() => nodes.value.reduce((acc, curr) => ({ ...acc, [curr.id]: curr }), {}) ); function mouseDownNode(e: MouseEvent | TouchEvent, node: NodeTypes) { + const oldZ = node.z; + nodes.value.forEach(node => { + if (node.z > oldZ) { + node.z--; + } + }); + node.z = nextId.value; if (nodeBeingDragged.value == null) { startDrag(e, node.id); } @@ -94,7 +103,7 @@ export const main = createLayer("main", function (this: BaseLayer) { e.stopPropagation(); } } - function getTranslateString(node: NodePosition, isDragging: boolean) { + function translate(node: NodePosition, isDragging: boolean) { let x = node.x; let y = node.y; if (isDragging) { @@ -103,33 +112,38 @@ export const main = createLayer("main", function (this: BaseLayer) { } return ` translate(${x}px,${y}px)`; } - function getRotateString(rotation: number) { + function rotate(rotation: number) { return ` rotate(${rotation}deg) `; } - function getScaleString(nodeOrBool: NodeTypes | boolean) { + function scale(nodeOrBool: NodeTypes | boolean) { const isSelected = typeof nodeOrBool === "boolean" ? nodeOrBool : selected.value === nodeOrBool.id; return isSelected ? " scale(1.2)" : ""; } - function getOpacityString(node: NodeTypes) { + function opacity(node: NodeTypes) { const isDragging = selected.value !== node.id && nodeBeingDragged.value === node.id; if (isDragging) { return "; opacity: 0.5;"; } return ""; } + function zIndex(node: NodeTypes) { + if (selected.value === node.id || nodeBeingDragged.value === node.id) { + return "; z-index: 100000000"; + } + return "; z-index: " + node.z; + } const renderANode = function (node: ANode) { return ( mouseDownNode(e, node)} onMouseUp={e => mouseUpNode(e, node)} > - + {receivingNodes.value.includes(node.id) && ( ( { if (selectedAction.value === 0) { spawnBNode(node as ANode); @@ -190,14 +203,13 @@ export const main = createLayer("main", function (this: BaseLayer) { const renderBNode = function (node: BNode) { return ( mouseDownNode(e, node)} onMouseUp={e => mouseUpNode(e, node)} > - + {receivingNodes.value.includes(node.id) && ( ( { if (selectedAction.value === 0) { spawnANode(node as BNode); @@ -270,6 +281,7 @@ export const main = createLayer("main", function (this: BaseLayer) { const node: ANode = { x: parent.x, y: parent.y, + z: nextId.value, type: "anode", links: [parent.id], id: nextId.value @@ -281,6 +293,7 @@ export const main = createLayer("main", function (this: BaseLayer) { const node: BNode = { x: parent.x, y: parent.y, + z: nextId.value, type: "bnode", links: [parent.id], id: nextId.value @@ -314,21 +327,21 @@ export const main = createLayer("main", function (this: BaseLayer) { }); const dNodesPerAxis = 50; - const dNodes = jsx(() => - new Array(dNodesPerAxis * dNodesPerAxis).fill(0).map((_, i) => { - const x = (Math.floor(i / dNodesPerAxis) - dNodesPerAxis / 2) * 100; - const y = ((i % dNodesPerAxis) - dNodesPerAxis / 2) * 100; - return ( - - ); - }) - ); - - // const dNodes; + const dNodes = jsx(() => ( + <> + {new Array(dNodesPerAxis * dNodesPerAxis).fill(0).map((_, i) => { + const x = (Math.floor(i / dNodesPerAxis) - dNodesPerAxis / 2) * 100; + const y = ((i % dNodesPerAxis) - dNodesPerAxis / 2) * 100; + return ( + + ); + })} + + )); const links = jsx(() => ( <> @@ -371,17 +384,6 @@ export const main = createLayer("main", function (this: BaseLayer) { const nextId = setupUniqueIds(() => nodes.value); - function filterNodes(n: number | "cnode") { - return n !== nodeBeingDragged.value && n !== selected.value; - } - - function renderNodeById(id: number | "cnode" | undefined) { - if (id == null) { - return undefined; - } - return renderNode(nodesById.value[id] ?? cNode); - } - function renderNode(node: NodeTypes | typeof cNode) { if (node.type === "anode") { return renderANode(node); @@ -408,14 +410,12 @@ export const main = createLayer("main", function (this: BaseLayer) { {dNodes()} {links()} - {nodes.value.filter(n => filterNodes(n.id)).map(renderNode)} - {filterNodes("cnode") && render(cNode)} + {nodes.value.map(renderNode)} + {render(cNode)} {aActions()} {bActions()} - {renderNodeById(selected.value)} - {renderNodeById(nodeBeingDragged.value)} )), From 3fd8375031de265f8cf6f307190c52986ec74fb9 Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Sun, 3 Mar 2024 22:17:16 -0600 Subject: [PATCH 15/82] Perf optimization --- src/features/boards/board.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/features/boards/board.tsx b/src/features/boards/board.tsx index 20501d8..b687998 100644 --- a/src/features/boards/board.tsx +++ b/src/features/boards/board.tsx @@ -160,7 +160,7 @@ export function setupDraggableNode(options: { }, drag: function (e: MouseEvent | TouchEvent) { const panZoomInstance = options.board.value?.panZoomInstance as PanZoom | undefined; - if (panZoomInstance == null) { + if (panZoomInstance == null || nodeBeingDragged.value == null) { return; } @@ -199,10 +199,8 @@ export function setupDraggableNode(options: { hasDragged.value = true; } - if (nodeBeingDragged.value != null) { - e.preventDefault(); - e.stopPropagation(); - } + e.preventDefault(); + e.stopPropagation(); } }; return result; From 17b878e3be553d491cfa3194be4f220d054c913b Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Sun, 3 Mar 2024 22:31:20 -0600 Subject: [PATCH 16/82] Fix upgrade purchasing on drag --- src/data/projEntry.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/data/projEntry.tsx b/src/data/projEntry.tsx index 8a3f858..2fa532d 100644 --- a/src/data/projEntry.tsx +++ b/src/data/projEntry.tsx @@ -309,7 +309,8 @@ export const main = createLayer("main", function (this: BaseLayer) { requirements: createCostRequirement(() => ({ cost: 10, resource: points })), style: { x: "100px", - y: "100px" + y: "100px", + pointerEvents: "none" } })); makeDraggable(cNode, { From cfba55d2c6b686fea119895402b3ebb6003c851b Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Fri, 16 Feb 2024 13:17:40 -0600 Subject: [PATCH 17/82] Add galaxy api --- src/components/SavesManager.vue | 85 +++-------- src/game/settings.ts | 14 +- src/lib/galaxy.js | 248 ++++++++++++++++++++++++++++++++ src/main.ts | 1 + src/util/galaxy.ts | 134 +++++++++++++++++ src/util/save.ts | 70 +++++++-- 6 files changed, 464 insertions(+), 88 deletions(-) create mode 100644 src/lib/galaxy.js create mode 100644 src/util/galaxy.ts diff --git a/src/components/SavesManager.vue b/src/components/SavesManager.vue index b1bf7e0..b0b0f73 100644 --- a/src/components/SavesManager.vue +++ b/src/components/SavesManager.vue @@ -63,9 +63,18 @@ import type { Player } from "game/player"; import player, { stringifySave } from "game/player"; import settings from "game/settings"; import LZString from "lz-string"; -import { getUniqueID, loadSave, newSave, save } from "util/save"; +import { + clearCachedSave, + clearCachedSaves, + decodeSave, + getCachedSave, + getUniqueID, + loadSave, + newSave, + save +} from "util/save"; import type { ComponentPublicInstance } from "vue"; -import { computed, nextTick, ref, shallowReactive, watch } from "vue"; +import { computed, nextTick, ref, watch } from "vue"; import Draggable from "vuedraggable"; import Select from "./fields/Select.vue"; import Text from "./fields/Text.vue"; @@ -90,16 +99,8 @@ watch(saveToImport, importedSave => { if (importedSave) { nextTick(() => { try { - if (importedSave[0] === "{") { - // plaintext. No processing needed - } else if (importedSave[0] === "e") { - // Assumed to be base64, which starts with e - importedSave = decodeURIComponent(escape(atob(importedSave))); - } else if (importedSave[0] === "ᯡ") { - // Assumed to be lz, which starts with ᯡ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - importedSave = LZString.decompressFromUTF16(importedSave)!; - } else { + importedSave = decodeSave(importedSave) ?? ""; + if (importedSave === "") { console.warn("Unable to determine preset encoding", importedSave); importingFailed.value = true; return; @@ -139,48 +140,10 @@ let bank = ref( }, []) ); -const cachedSaves = shallowReactive>({}); -function getCachedSave(id: string) { - if (cachedSaves[id] == null) { - let save = localStorage.getItem(id); - if (save == null) { - cachedSaves[id] = { error: `Save doesn't exist in localStorage`, id }; - } else if (save === "dW5kZWZpbmVk") { - cachedSaves[id] = { error: `Save is undefined`, id }; - } else { - try { - if (save[0] === "{") { - // plaintext. No processing needed - } else if (save[0] === "e") { - // Assumed to be base64, which starts with e - save = decodeURIComponent(escape(atob(save))); - } else if (save[0] === "ᯡ") { - // Assumed to be lz, which starts with ᯡ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - save = LZString.decompressFromUTF16(save)!; - } else { - console.warn("Unable to determine preset encoding", save); - importingFailed.value = true; - cachedSaves[id] = { error: "Unable to determine preset encoding", id }; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return cachedSaves[id]!; - } - cachedSaves[id] = { ...JSON.parse(save), id }; - } catch (error) { - cachedSaves[id] = { error, id }; - console.warn( - `SavesManager: Failed to load info about save with id ${id}:\n${error}\n${save}` - ); - } - } - } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return cachedSaves[id]!; -} // Wipe cache whenever the modal is opened watch(isOpen, isOpen => { if (isOpen) { - Object.keys(cachedSaves).forEach(key => delete cachedSaves[key]); + clearCachedSaves(); } }); @@ -235,18 +198,18 @@ function duplicateSave(id: string) { function deleteSave(id: string) { settings.saves = settings.saves.filter((save: string) => save !== id); localStorage.removeItem(id); - cachedSaves[id] = undefined; + clearCachedSave(id); } function openSave(id: string) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion saves.value[player.id]!.time = player.time; save(); - cachedSaves[player.id] = undefined; + clearCachedSave(player.id); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion loadSave(saves.value[id]!); // Delete cached version in case of opening it again - cachedSaves[id] = undefined; + clearCachedSave(id); } function newFromPreset(preset: string) { @@ -256,16 +219,8 @@ function newFromPreset(preset: string) { selectedPreset.value = null; }); - if (preset[0] === "{") { - // plaintext. No processing needed - } else if (preset[0] === "e") { - // Assumed to be base64, which starts with e - preset = decodeURIComponent(escape(atob(preset))); - } else if (preset[0] === "ᯡ") { - // Assumed to be lz, which starts with ᯡ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - preset = LZString.decompressFromUTF16(preset)!; - } else { + preset = decodeSave(preset) ?? ""; + if (preset === "") { console.warn("Unable to determine preset encoding", preset); return; } @@ -287,7 +242,7 @@ function editSave(id: string, newName: string) { save(); } else { save(currSave as Player); - cachedSaves[id] = undefined; + clearCachedSave(id); } } } diff --git a/src/game/settings.ts b/src/game/settings.ts index 0748d68..6f3a435 100644 --- a/src/game/settings.ts +++ b/src/game/settings.ts @@ -3,7 +3,7 @@ import { Themes } from "data/themes"; import type { CoercableComponent } from "features/feature"; import { globalBus } from "game/events"; import LZString from "lz-string"; -import { hardReset } from "util/save"; +import { decodeSave, hardReset } from "util/save"; import { reactive, watch } from "vue"; /** The player's settings object. */ @@ -78,16 +78,8 @@ export function loadSettings(): void { try { let item: string | null = localStorage.getItem(projInfo.id); if (item != null && item !== "") { - if (item[0] === "{") { - // plaintext. No processing needed - } else if (item[0] === "e") { - // Assumed to be base64, which starts with e - item = decodeURIComponent(escape(atob(item))); - } else if (item[0] === "ᯡ") { - // Assumed to be lz, which starts with ᯡ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - item = LZString.decompressFromUTF16(item)!; - } else { + item = decodeSave(item); + if (item == null) { console.warn("Unable to determine settings encoding", item); return; } diff --git a/src/lib/galaxy.js b/src/lib/galaxy.js new file mode 100644 index 0000000..11db155 --- /dev/null +++ b/src/lib/galaxy.js @@ -0,0 +1,248 @@ +/** + * The Galaxy API defines actions and responses for interacting with the Galaxy platform. + * + * @typedef {object} SupportsAction + * @property {"supports"} action - The action type. + * @property {boolean} saving - If your game auto-saves or allows the user to make/load game saves from within the UI. + * @property {boolean} save_manager - If your game has a complete save manager integrated into it. + */ + +/** + * The save list action sends a retrieval request to Galaxy to get the player's cloud save list. + * + * @typedef {object} SaveListAction + * @property {"save_list"} action - The action type. + */ + +/** + * The save action creates a cloud save and puts it into a certain save slot. + * + * @typedef {object} SaveAction + * @property {"save"} action - The action type. + * @property {number} slot - The save slot number. Must be an integer between 0 and 10, inclusive. + * @property {string} [label] - The optional label of the save file. + * @property {string} data - The actual save data. + */ + +/** + * The load action sends a retrieval request to Galaxy to get the cloud save data inside a certain save slot. + * + * @typedef {object} LoadAction + * @property {"load"} action - The action type. + * @property {number} slot - The save slot number. + */ + +/** + * The Galaxy action can be one of SupportsAction, SaveListAction, SaveAction, or LoadAction. + * + * @typedef {SupportsAction | SaveListAction | SaveAction | LoadAction} GalaxyAction + */ + +/** + * The info response is sent when the page loads. + * + * @typedef {object} InfoResponse + * @property {"info"} type - The response type. + * @property {boolean} galaxy - Whether you're talking to Galaxy. + * @property {number} api_version - The version of the API. + * @property {string} theme_preference - The player's theme preference. + * @property {boolean} logged_in - Whether the player is logged in. + */ + +/** + * The save list response is requested by the save_list action. + * + * @typedef {object} SaveListResponse + * @property {"save_list"} type - The response type. + * @property {Record} list - A list of saves. + * @property {boolean} error - Whether the action encountered an error. + * @property {("no_account" | "server_error")} [message] - Reason for the error. + */ + +/** + * The save content response is requested by the load action. + * + * @typedef {object} SaveContentResponse + * @property {"save_content"} type - The response type. + * @property {boolean} error - Whether the action encountered an error. + * @property {("no_account" | "empty_slot" | "invalid_slot" | "server_error")} [message] - Reason for the error. + * @property {number} slot - The save slot number. + * @property {string} [label] - The save's label. + * @property {string} [content] - The save's actual data. + */ + +/** + * The saved response is requested by the save action. + * + * @typedef {object} SavedResponse + * @property {"saved"} type - The response type. + * @property {boolean} error - Whether the action encountered an error. + * @property {number} slot - The save slot number. + * @property {("no_account" | "too_big" | "invalid_slot" | "server_error")} [message] - Reason for the error. + */ + +/** + * The GalaxyResponse can be one of InfoResponse, SaveListResponse, SaveContentResponse, or SavedResponse. + * + * @typedef {InfoResponse | SaveListResponse | SaveContentResponse | SavedResponse} GalaxyResponse + */ + +/** + * The GalaxyApi interface defines methods and properties for interacting with the Galaxy platform. + * + * @typedef {object} GalaxyApi + * @property {string[]} acceptedOrigins - Accepted origins. + * @property {boolean} [supportsSaving] - Whether saving is supported. + * @property {boolean} [supportsSaveManager] - Whether save manager is supported. + * @property {boolean} [ignoreApiVersion] - Whether to ignore API version. + * @property {function(GalaxyApi): void} [onLoggedInChanged] - Function to handle logged in changes. + * @property {string} origin - Origin of the API. + * @property {number} apiVersion - Version of the API. + * @property {boolean} loggedIn - Whether the player is logged in. + * @property {function(GalaxyAction): void} postMessage - Method to post a message. + * @property {function(): Promise>} getSaveList - Method to get the save list. + * @property {function(number, string, string?): Promise} save - Method to save data. + * @property {function(number): Promise<{ content: string; label?: string; slot: number }>} load - Method to load data. + */ + + +/** + * Initialize the Galaxy API. + * @param {Object} [options] - An object of options that configure the API + * @param {string[]} [options.acceptedOrigins] - A list of domains that the API trusts messages from. Defaults to `['https://galaxy.click']`. + * @param {boolean} [options.supportsSaving] - Indicates to Galaxy that this game supports saving. Defaults to false. + * @param {boolean} [options.supportsSaveManager] - Indicates to Galaxy that this game supports a saves manager. Defaults to false. + * @param {boolean} [options.ignoreApiVersion] - Ignores the api_version property received from Galaxy. By default this value is false, meaning if an unknown API version is encountered, the API will fail to initialize. + * @param {(galaxy: GalaxyApi) => void} [options.onLoggedInChanged] - A callback for when the logged in status of the player changes after the initialization. + * @returns {Promise} + */ +export function initGalaxy({ + acceptedOrigins, + supportsSaving, + supportsSaveManager, + ignoreApiVersion, + onLoggedInChanged +}) { + return new Promise((accept, reject) => { + acceptedOrigins = acceptedOrigins ?? ["https://galaxy.click"]; + if (acceptedOrigins.includes(window.origin)) { + // Callbacks to resolve promises + /** @type function(SaveListResponse["list"]):void */ + let saveListAccept, + /** @type function(string?):void */ + saveListReject; + /** @type Record */ + const saveCallbacks = {}; + /** @type Record */ + const loadCallbacks = {}; + + /** @type GalaxyApi */ + const galaxy = { + acceptedOrigins, + supportsSaving, + supportsSaveManager, + ignoreApiVersion, + onLoggedInChanged, + origin: window.origin, + apiVersion: 0, + loggedIn: false, + postMessage: function (message) { + window.top?.postMessage(message, galaxy.origin); + }, + getSaveList: function () { + if (saveListAccept != null || saveListReject != null) { + return Promise.reject("save_list action already in progress."); + } + galaxy.postMessage({ action: "save_list" }); + return new Promise((accept, reject) => { + saveListAccept = accept; + saveListReject = reject; + }); + }, + save: function (slot, content, label) { + if (slot in saveCallbacks) { + return Promise.reject(`save action for slot ${slot} already in progress.`); + } + galaxy.postMessage({ action: "save", slot, content, label }); + return new Promise((accept, reject) => { + saveCallbacks[slot] = { accept, reject }; + }); + }, + load: function (slot) { + if (slot in loadCallbacks) { + return Promise.reject(`load action for slot ${slot} already in progress.`); + } + galaxy.postMessage({ action: "load", slot }); + return new Promise((accept, reject) => { + loadCallbacks[slot] = { accept, reject }; + }); + } + }; + + window.addEventListener("message", e => { + if (e.origin === galaxy.origin) { + console.log("Received message from Galaxy", e.data); + /** @type GalaxyResponse */ + const data = e.data; + + switch (data.type) { + case "info": { + const { galaxy: isGalaxy, api_version, logged_in } = data; + // Ignoring isGalaxy check in case other accepted origins send it as false + if (api_version !== 1 && galaxy.ignoreApiVersion !== true) { + reject(`API version not recognized: ${api_version}`); + } else { + // Info responses may be sent again if the information gets updated + // Specifically, we care if logged_in gets changed + // We can use the api_version to determine if this is the first + // info response or a new one. + const firstInfoResponse = galaxy.apiVersion === 0; + galaxy.apiVersion = api_version; + galaxy.loggedIn = logged_in; + galaxy.origin = e.origin; + if (firstInfoResponse) { + accept(galaxy); + } else { + galaxy.onLoggedInChanged?.(galaxy); + } + } + break; + } + case "save_list": { + const { list, error, message } = data; + if (error === true) { + saveListReject(message); + } else { + saveListAccept(list); + } + saveListAccept = saveListReject = null; + break; + } + case "save_content": { + const { content, label, slot, error, message } = data; + if (error === true) { + loadCallbacks[slot]?.reject(message); + } else { + loadCallbacks[slot]?.accept({ slot, content, label }); + } + delete loadCallbacks[slot]; + break; + } + case "saved": { + const { slot, error, message } = data; + if (error === true) { + saveCallbacks[slot]?.reject(message); + } else { + saveCallbacks[slot]?.accept(slot); + } + delete saveCallbacks[slot]; + break; + } + } + } + }); + } else { + reject(`Project is not running on an accepted origin: ${window.origin}`); + } + }); +} diff --git a/src/main.ts b/src/main.ts index e416fa5..3b5de9f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,6 +8,7 @@ import { useRegisterSW } from "virtual:pwa-register/vue"; import type { App as VueApp } from "vue"; import { createApp, nextTick } from "vue"; import { useToast } from "vue-toastification"; +import "util/galaxy"; declare global { /** diff --git a/src/util/galaxy.ts b/src/util/galaxy.ts new file mode 100644 index 0000000..544ec3d --- /dev/null +++ b/src/util/galaxy.ts @@ -0,0 +1,134 @@ +import player, { Player } from "game/player"; +import settings from "game/settings"; +import { GalaxyApi, initGalaxy } from "lib/galaxy"; +import { decodeSave, loadSave, save } from "./save"; +import { setupInitialStore } from "./save"; +import { ref } from "vue"; + +export const galaxy = ref(); +export const conflictingSaves = ref([]); + +export function sync() { + if (galaxy.value == null || !galaxy.value.loggedIn) { + return; + } + if (conflictingSaves.value.length > 0) { + // Pause syncing while resolving conflicted saves + return; + } + galaxy.value.getSaveList().then(syncSaves); +} + +// Setup Galaxy API +initGalaxy({ + supportsSaving: true, + supportsSaveManager: true, + onLoggedInChanged +}).then(g => { + galaxy.value = g; + onLoggedInChanged(g); +}); + +function onLoggedInChanged(g: GalaxyApi) { + if (g.loggedIn !== true) { + return; + } + if (conflictingSaves.value.length > 0) { + // Pause syncing while resolving conflicted saves + return; + } + + g.getSaveList().then(list => { + const saves = syncSaves(list); + + // If our current save has under a minute of playtime, load the cloud save with the most recent time. + if (player.timePlayed < 60 && saves.length > 0) { + const longestSave = saves.reduce((acc, curr) => + acc.content.time < curr.content.time ? curr : acc + ); + loadSave(longestSave.content); + } + }); +} + +function syncSaves( + list: Record< + number, + { + label: string; + content: string; + } + > +) { + return ( + Object.keys(list) + .map(slot => { + const { label, content } = list[slot as unknown as number]; + try { + return { slot: parseInt(slot), label, content: JSON.parse(content) }; + } catch (e) { + return null; + } + }) + .filter( + n => + n != null && + typeof n.content.id === "string" && + typeof n.content.time === "number" && + typeof n.content.timePlayed === "number" + ) as { + slot: number; + label?: string; + content: Partial & { id: string; time: number; timePlayed: number }; + }[] + ).filter(cloudSave => { + if (cloudSave.label != null) { + cloudSave.content.name = cloudSave.label; + } + const localSaveId = settings.saves.find(id => id === cloudSave.content.id); + if (localSaveId == undefined) { + settings.saves.push(cloudSave.content.id); + save(setupInitialStore(cloudSave.content)); + } else { + try { + const localSave = JSON.parse( + decodeSave(localStorage.getItem(localSaveId) ?? "") ?? "" + ) as Partial | null; + if (localSave == null) { + return false; + } + localSave.id = localSaveId; + localSave.time = localSave.time ?? 0; + localSave.timePlayed = localSave.timePlayed ?? 0; + + const timePlayedDiff = Math.abs( + localSave.timePlayed - cloudSave.content.timePlayed + ); + const timeDiff = Math.abs(localSave.time - cloudSave.content.time); + // If their last played time and total time played are both within a minute, just use the newer save (very unlikely to be coincidence) + // Otherwise, ask the player + if (timePlayedDiff < 60 && timeDiff < 60) { + if (localSave.time < cloudSave.content.time) { + save(setupInitialStore(cloudSave.content)); + if (settings.active === localSaveId) { + loadSave(cloudSave.content); + } + } else { + galaxy.value?.save( + cloudSave.slot, + JSON.stringify(cloudSave.content), + cloudSave.label ?? null + ); + // Update cloud save content for the return value + cloudSave.content = localSave as Player; + } + } else { + conflictingSaves.value.push(localSaveId); + } + } catch (e) { + return false; + } + } + return true; + }); +} diff --git a/src/util/save.ts b/src/util/save.ts index 4137e5b..9d51016 100644 --- a/src/util/save.ts +++ b/src/util/save.ts @@ -1,10 +1,12 @@ +import { LoadablePlayerData } from "components/SavesManager.vue"; import projInfo from "data/projInfo.json"; import { globalBus } from "game/events"; import type { Player } from "game/player"; import player, { stringifySave } from "game/player"; import settings, { loadSettings } from "game/settings"; import LZString from "lz-string"; -import { ref } from "vue"; +import { ref, shallowReactive } from "vue"; +import { sync } from "./galaxy"; export function setupInitialStore(player: Partial = {}): Player { return Object.assign( @@ -29,6 +31,7 @@ export function setupInitialStore(player: Partial = {}): Player { export function save(playerData?: Player): string { const stringifiedSave = LZString.compressToUTF16(stringifySave(playerData ?? player)); localStorage.setItem((playerData ?? player).id, stringifiedSave); + sync(); return stringifiedSave; } @@ -42,17 +45,9 @@ export async function load(): Promise { await loadSave(newSave()); return; } - if (save[0] === "{") { - // plaintext. No processing needed - } else if (save[0] === "e") { - // Assumed to be base64, which starts with e - save = decodeURIComponent(escape(atob(save))); - } else if (save[0] === "ᯡ") { - // Assumed to be lz, which starts with ᯡ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - save = LZString.decompressFromUTF16(save)!; - } else { - throw `Unable to determine save encoding`; + save = decodeSave(save); + if (save == null) { + throw "Unable to determine save encoding"; } const player = JSON.parse(save); if (player.modID !== projInfo.id) { @@ -67,6 +62,23 @@ export async function load(): Promise { } } +export function decodeSave(save: string) { + if (save[0] === "{") { + // plaintext. No processing needed + } else if (save[0] === "e") { + // Assumed to be base64, which starts with e + save = decodeURIComponent(escape(atob(save))); + } else if (save[0] === "ᯡ") { + // Assumed to be lz, which starts with ᯡ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + save = LZString.decompressFromUTF16(save)!; + } else { + console.warn("Unable to determine preset encoding", save); + return null; + } + return save; +} + export function newSave(): Player { const id = getUniqueID(); const player = setupInitialStore({ id }); @@ -127,6 +139,40 @@ export async function loadSave(playerObj: Partial): Promise { globalBus.emit("onLoad"); } +const cachedSaves = shallowReactive>({}); +export function getCachedSave(id: string) { + if (cachedSaves[id] == null) { + let save = localStorage.getItem(id); + if (save == null) { + cachedSaves[id] = { error: `Save doesn't exist in localStorage`, id }; + } else if (save === "dW5kZWZpbmVk") { + cachedSaves[id] = { error: `Save is undefined`, id }; + } else { + try { + save = decodeSave(save); + if (save == null) { + console.warn("Unable to determine preset encoding", save); + cachedSaves[id] = { error: "Unable to determine preset encoding", id }; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return cachedSaves[id]!; + } + cachedSaves[id] = { ...JSON.parse(save), id }; + } catch (error) { + cachedSaves[id] = { error, id }; + console.warn(`Failed to load info about save with id ${id}:\n${error}\n${save}`); + } + } + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return cachedSaves[id]!; +} +export function clearCachedSaves() { + Object.keys(cachedSaves).forEach(key => delete cachedSaves[key]); +} +export function clearCachedSave(id: string) { + cachedSaves[id] = undefined; +} + setInterval(() => { if (player.autosave) { save(); From ece7ed2923ec29cdb4661918cfa964340bc8a8b3 Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Sat, 17 Feb 2024 10:23:18 -0600 Subject: [PATCH 18/82] Add save conflict resolver --- src/App.vue | 4 +- src/components/Modal.vue | 9 +- src/components/NaNScreen.vue | 2 +- src/components/Nav.vue | 2 +- src/components/saves/CloudSaveResolver.vue | 186 ++++++++++++++++++++ src/components/{ => saves}/Save.vue | 44 +++-- src/components/{ => saves}/SavesManager.vue | 4 +- src/util/galaxy.ts | 84 ++++++--- src/util/save.ts | 2 +- 9 files changed, 291 insertions(+), 46 deletions(-) create mode 100644 src/components/saves/CloudSaveResolver.vue rename src/components/{ => saves}/Save.vue (81%) rename src/components/{ => saves}/SavesManager.vue (99%) diff --git a/src/App.vue b/src/App.vue index 40e21de..7e7a0aa 100644 --- a/src/App.vue +++ b/src/App.vue @@ -8,6 +8,7 @@ + @@ -16,10 +17,11 @@ + + + + diff --git a/src/components/Save.vue b/src/components/saves/Save.vue similarity index 81% rename from src/components/Save.vue rename to src/components/saves/Save.vue index 77d2988..d8af7d0 100644 --- a/src/components/Save.vue +++ b/src/components/saves/Save.vue @@ -1,7 +1,7 @@ - diff --git a/src/features/trees/tree.ts b/src/features/trees/tree.ts deleted file mode 100644 index 1148cf0..0000000 --- a/src/features/trees/tree.ts +++ /dev/null @@ -1,387 +0,0 @@ -import { GenericDecorator } from "features/decorators/common"; -import type { - CoercableComponent, - GenericComponent, - OptionsFunc, - Replace, - StyleValue -} from "features/feature"; -import { Component, GatherProps, getUniqueID, setDefault, Visibility } from "features/feature"; -import type { Link } from "features/links/links"; -import type { GenericReset } from "features/reset"; -import type { Resource } from "features/resources/resource"; -import { displayResource } from "features/resources/resource"; -import TreeComponent from "features/trees/Tree.vue"; -import TreeNodeComponent from "features/trees/TreeNode.vue"; -import type { DecimalSource } from "util/bignum"; -import Decimal, { format, formatWhole } from "util/bignum"; -import type { - Computable, - GetComputableType, - GetComputableTypeWithDefault, - ProcessedComputable -} from "util/computed"; -import { convertComputable, processComputable } from "util/computed"; -import { createLazyProxy } from "util/proxies"; -import type { Ref } from "vue"; -import { computed, ref, shallowRef, unref } from "vue"; - -/** A symbol used to identify {@link TreeNode} features. */ -export const TreeNodeType = Symbol("TreeNode"); -/** A symbol used to identify {@link Tree} features. */ -export const TreeType = Symbol("Tree"); - -/** - * An object that configures a {@link TreeNode}. - */ -export interface TreeNodeOptions { - /** Whether this tree node should be visible. */ - visibility?: Computable; - /** Whether or not this tree node can be clicked. */ - canClick?: Computable; - /** The background color for this node. */ - color?: Computable; - /** The label to display on this tree node. */ - display?: Computable; - /** The color of the glow effect shown to notify the user there's something to do with this node. */ - glowColor?: Computable; - /** Dictionary of CSS classes to apply to this feature. */ - classes?: Computable>; - /** CSS to apply to this feature. */ - style?: Computable; - /** Shows a marker on the corner of the feature. */ - mark?: Computable; - /** A reset object attached to this node, used for propagating resets through the tree. */ - reset?: GenericReset; - /** A function that is called when the tree node is clicked. */ - onClick?: (e?: MouseEvent | TouchEvent) => void; - /** A function that is called when the tree node is held down. */ - onHold?: VoidFunction; -} - -/** - * The properties that are added onto a processed {@link TreeNodeOptions} to create an {@link TreeNode}. - */ -export interface BaseTreeNode { - /** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */ - id: string; - /** A symbol that helps identify features of the same type. */ - type: typeof TreeNodeType; - /** The Vue component used to render this feature. */ - [Component]: GenericComponent; - /** A function to gather the props the vue component requires for this feature. */ - [GatherProps]: () => Record; -} - -/** An object that represents a node on a tree. */ -export type TreeNode = Replace< - T & BaseTreeNode, - { - visibility: GetComputableTypeWithDefault; - canClick: GetComputableTypeWithDefault; - color: GetComputableType; - display: GetComputableType; - glowColor: GetComputableType; - classes: GetComputableType; - style: GetComputableType; - mark: GetComputableType; - } ->; - -/** A type that matches any valid {@link TreeNode} object. */ -export type GenericTreeNode = Replace< - TreeNode, - { - visibility: ProcessedComputable; - canClick: ProcessedComputable; - } ->; - -/** - * Lazily creates a tree node with the given options. - * @param optionsFunc Tree Node options. - */ -export function createTreeNode( - optionsFunc?: OptionsFunc, - ...decorators: GenericDecorator[] -): TreeNode { - const decoratedData = decorators.reduce( - (current, next) => Object.assign(current, next.getPersistentData?.()), - {} - ); - return createLazyProxy(feature => { - const treeNode = - optionsFunc?.call(feature, feature) ?? - ({} as ReturnType>); - treeNode.id = getUniqueID("treeNode-"); - treeNode.type = TreeNodeType; - treeNode[Component] = TreeNodeComponent as GenericComponent; - - for (const decorator of decorators) { - decorator.preConstruct?.(treeNode); - } - - Object.assign(decoratedData); - - processComputable(treeNode as T, "visibility"); - setDefault(treeNode, "visibility", Visibility.Visible); - processComputable(treeNode as T, "canClick"); - setDefault(treeNode, "canClick", true); - processComputable(treeNode as T, "color"); - processComputable(treeNode as T, "display"); - processComputable(treeNode as T, "glowColor"); - processComputable(treeNode as T, "classes"); - processComputable(treeNode as T, "style"); - processComputable(treeNode as T, "mark"); - - for (const decorator of decorators) { - decorator.postConstruct?.(treeNode); - } - - if (treeNode.onClick) { - const onClick = treeNode.onClick.bind(treeNode); - treeNode.onClick = function (e) { - if ( - unref(treeNode.canClick as ProcessedComputable) !== false - ) { - onClick(e); - } - }; - } - if (treeNode.onHold) { - const onHold = treeNode.onHold.bind(treeNode); - treeNode.onHold = function () { - if ( - unref(treeNode.canClick as ProcessedComputable) !== false - ) { - onHold(); - } - }; - } - - const decoratedProps = decorators.reduce( - (current, next) => Object.assign(current, next.getGatheredProps?.(treeNode)), - {} - ); - treeNode[GatherProps] = function (this: GenericTreeNode) { - const { - display, - visibility, - style, - classes, - onClick, - onHold, - color, - glowColor, - canClick, - mark, - id - } = this; - return { - display, - visibility, - style, - classes, - onClick, - onHold, - color, - glowColor, - canClick, - mark, - id, - ...decoratedProps - }; - }; - - return treeNode as unknown as TreeNode; - }); -} - -/** Represents a branch between two nodes in a tree. */ -export interface TreeBranch extends Omit { - startNode: GenericTreeNode; - endNode: GenericTreeNode; -} - -/** - * An object that configures a {@link Tree}. - */ -export interface TreeOptions { - /** Whether this clickable should be visible. */ - visibility?: Computable; - /** The nodes within the tree, in a 2D array. */ - nodes: Computable; - /** Nodes to show on the left side of the tree. */ - leftSideNodes?: Computable; - /** Nodes to show on the right side of the tree. */ - rightSideNodes?: Computable; - /** The branches between nodes within this tree. */ - branches?: Computable; - /** How to propagate resets through the tree. */ - resetPropagation?: ResetPropagation; - /** A function that is called when a node within the tree is reset. */ - onReset?: (node: GenericTreeNode) => void; -} - -export interface BaseTree { - /** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */ - id: string; - /** The link objects for each of the branches of the tree. */ - links: Ref; - /** Cause a reset on this node and propagate it through the tree according to {@link TreeOptions.resetPropagation}. */ - reset: (node: GenericTreeNode) => void; - /** A flag that is true while the reset is still propagating through the tree. */ - isResetting: Ref; - /** A reference to the node that caused the currently propagating reset. */ - resettingNode: Ref; - /** A symbol that helps identify features of the same type. */ - type: typeof TreeType; - /** The Vue component used to render this feature. */ - [Component]: GenericComponent; - /** A function to gather the props the vue component requires for this feature. */ - [GatherProps]: () => Record; -} - -/** An object that represents a feature that is a tree of nodes with branches between them. Contains support for reset mechanics that can propagate through the tree. */ -export type Tree = Replace< - T & BaseTree, - { - visibility: GetComputableTypeWithDefault; - nodes: GetComputableType; - leftSideNodes: GetComputableType; - rightSideNodes: GetComputableType; - branches: GetComputableType; - } ->; - -/** A type that matches any valid {@link Tree} object. */ -export type GenericTree = Replace< - Tree, - { - visibility: ProcessedComputable; - } ->; - -/** - * Lazily creates a tree with the given options. - * @param optionsFunc Tree options. - */ -export function createTree( - optionsFunc: OptionsFunc -): Tree { - return createLazyProxy(feature => { - const tree = optionsFunc.call(feature, feature); - tree.id = getUniqueID("tree-"); - tree.type = TreeType; - tree[Component] = TreeComponent as GenericComponent; - - tree.isResetting = ref(false); - tree.resettingNode = shallowRef(null); - - tree.reset = function (node) { - const genericTree = tree as GenericTree; - genericTree.isResetting.value = true; - genericTree.resettingNode.value = node; - genericTree.resetPropagation?.(genericTree, node); - genericTree.onReset?.(node); - genericTree.isResetting.value = false; - genericTree.resettingNode.value = null; - }; - tree.links = computed(() => { - const genericTree = tree as GenericTree; - return unref(genericTree.branches) ?? []; - }); - - processComputable(tree as T, "visibility"); - setDefault(tree, "visibility", Visibility.Visible); - processComputable(tree as T, "nodes"); - processComputable(tree as T, "leftSideNodes"); - processComputable(tree as T, "rightSideNodes"); - processComputable(tree as T, "branches"); - - tree[GatherProps] = function (this: GenericTree) { - const { nodes, leftSideNodes, rightSideNodes, branches } = this; - return { nodes, leftSideNodes, rightSideNodes, branches }; - }; - - return tree as unknown as Tree; - }); -} - -/** A function that is used to propagate resets through a tree. */ -export type ResetPropagation = { - (tree: GenericTree, resettingNode: GenericTreeNode): void; -}; - -/** Propagate resets down the tree by resetting every node in a lower row. */ -export const defaultResetPropagation = function ( - tree: GenericTree, - resettingNode: GenericTreeNode -): void { - const nodes = unref(tree.nodes); - const row = nodes.findIndex(nodes => nodes.includes(resettingNode)) - 1; - for (let x = row; x >= 0; x--) { - nodes[x].forEach(node => node.reset?.reset()); - } -}; - -/** Propagate resets down the tree by resetting every node in a lower row. */ -export const invertedResetPropagation = function ( - tree: GenericTree, - resettingNode: GenericTreeNode -): void { - const nodes = unref(tree.nodes); - const row = nodes.findIndex(nodes => nodes.includes(resettingNode)) + 1; - for (let x = row; x < nodes.length; x++) { - nodes[x].forEach(node => node.reset?.reset()); - } -}; - -/** Propagate resets down the branches of the tree. */ -export const branchedResetPropagation = function ( - tree: GenericTree, - resettingNode: GenericTreeNode -): void { - const links = unref(tree.branches); - if (links == null) return; - const reset: GenericTreeNode[] = []; - let current = [resettingNode]; - while (current.length !== 0) { - const next: GenericTreeNode[] = []; - for (const node of current) { - for (const link of links.filter(link => link.startNode === node)) { - if ([...reset, ...current].includes(link.endNode)) continue; - next.push(link.endNode); - link.endNode.reset?.reset(); - } - } - reset.push(...current); - current = next; - } -}; - -/** - * Utility for creating a tooltip for a tree node that displays a resource-based unlock requirement, and after unlock shows the amount of another resource. - * It sounds oddly specific, but comes up a lot. - */ -export function createResourceTooltip( - resource: Resource, - requiredResource: Resource | null = null, - requirement: Computable = 0 -): Ref { - const req = convertComputable(requirement); - return computed(() => { - if (requiredResource == null || Decimal.gte(resource.value, unref(req))) { - return displayResource(resource) + " " + resource.displayName; - } - return `Reach ${ - Decimal.eq(requiredResource.precision, 0) - ? formatWhole(unref(req)) - : format(unref(req), requiredResource.precision) - } ${requiredResource.displayName} to unlock (You have ${ - Decimal.eq(requiredResource.precision, 0) - ? formatWhole(requiredResource.value) - : format(requiredResource.value, requiredResource.precision) - })`; - }); -} diff --git a/src/features/trees/tree.tsx b/src/features/trees/tree.tsx new file mode 100644 index 0000000..7d2ded6 --- /dev/null +++ b/src/features/trees/tree.tsx @@ -0,0 +1,279 @@ +import type { OptionsFunc, Replace } from "features/feature"; +import { Link } from "features/links/links"; +import type { Reset } from "features/reset"; +import type { Resource } from "features/resources/resource"; +import { displayResource } from "features/resources/resource"; +import Tree from "features/trees/Tree.vue"; +import TreeNode from "features/trees/TreeNode.vue"; +import type { DecimalSource } from "util/bignum"; +import Decimal, { format, formatWhole } from "util/bignum"; +import { ProcessedRefOrGetter, processGetter } from "util/computed"; +import { createLazyProxy } from "util/proxies"; +import { Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue"; +import type { MaybeRef, MaybeRefOrGetter, Ref } from "vue"; +import { computed, ref, shallowRef, unref } from "vue"; + +/** A symbol used to identify {@link TreeNode} features. */ +export const TreeNodeType = Symbol("TreeNode"); +/** A symbol used to identify {@link Tree} features. */ +export const TreeType = Symbol("Tree"); + +/** + * An object that configures a {@link TreeNode}. + */ +export interface TreeNodeOptions extends VueFeatureOptions { + /** Whether or not this tree node can be clicked. */ + canClick?: MaybeRefOrGetter; + /** The background color for this node. */ + color?: MaybeRefOrGetter; + /** The label to display on this tree node. */ + display?: MaybeRefOrGetter; + /** The color of the glow effect shown to notify the user there's something to do with this node. */ + glowColor?: MaybeRefOrGetter; + /** A reset object attached to this node, used for propagating resets through the tree. */ + reset?: Reset; + /** A function that is called when the tree node is clicked. */ + onClick?: (e?: MouseEvent | TouchEvent) => void; + /** A function that is called when the tree node is held down. */ + onHold?: VoidFunction; +} + +/** + * The properties that are added onto a processed {@link TreeNodeOptions} to create an {@link TreeNode}. + */ +export interface BaseTreeNode extends VueFeature { + /** A symbol that helps identify features of the same type. */ + type: typeof TreeNodeType; +} + +/** An object that represents a node on a tree. */ +export type TreeNode = Replace< + TreeNodeOptions & BaseTreeNode, + { + canClick: MaybeRef; + color: ProcessedRefOrGetter; + display: ProcessedRefOrGetter; + glowColor: ProcessedRefOrGetter; + } +>; + +/** + * Lazily creates a tree node with the given options. + * @param optionsFunc Tree Node options. + */ +export function createTreeNode( + optionsFunc?: OptionsFunc +) { + return createLazyProxy(feature => { + const options = optionsFunc?.call(feature, feature as TreeNode) ?? ({} as T); + const { canClick, color, display, glowColor, onClick, onHold, ...props } = options; + + const treeNode = { + type: TreeNodeType, + ...(props as Omit), + ...vueFeatureMixin("treeNode", options, () => ( + + )), + canClick: processGetter(canClick) ?? true, + color: processGetter(color), + display: processGetter(display), + glowColor: processGetter(glowColor), + onClick: + onClick == null + ? undefined + : function (e) { + if (unref(treeNode.canClick) !== false) { + onClick.call(treeNode, e); + } + }, + onHold: + onHold == null + ? undefined + : function () { + if (unref(treeNode.canClick) !== false) { + onHold.call(treeNode); + } + } + } satisfies TreeNode; + + return treeNode; + }); +} + +/** Represents a branch between two nodes in a tree. */ +export interface TreeBranch extends Omit { + startNode: TreeNode; + endNode: TreeNode; +} + +/** + * An object that configures a {@link Tree}. + */ +export interface TreeOptions extends VueFeatureOptions { + /** The nodes within the tree, in a 2D array. */ + nodes: MaybeRefOrGetter; + /** Nodes to show on the left side of the tree. */ + leftSideNodes?: MaybeRefOrGetter; + /** Nodes to show on the right side of the tree. */ + rightSideNodes?: MaybeRefOrGetter; + /** The branches between nodes within this tree. */ + branches?: MaybeRefOrGetter; + /** How to propagate resets through the tree. */ + resetPropagation?: ResetPropagation; + /** A function that is called when a node within the tree is reset. */ + onReset?: (node: TreeNode) => void; +} + +export interface BaseTree extends VueFeature { + /** The link objects for each of the branches of the tree. */ + links: Ref; + /** Cause a reset on this node and propagate it through the tree according to {@link TreeOptions.resetPropagation}. */ + reset: (node: TreeNode) => void; + /** A flag that is true while the reset is still propagating through the tree. */ + isResetting: Ref; + /** A reference to the node that caused the currently propagating reset. */ + resettingNode: Ref; + /** A symbol that helps identify features of the same type. */ + type: typeof TreeType; +} + +/** An object that represents a feature that is a tree of nodes with branches between them. Contains support for reset mechanics that can propagate through the tree. */ +export type Tree = Replace< + TreeOptions & BaseTree, + { + nodes: ProcessedRefOrGetter; + leftSideNodes: ProcessedRefOrGetter; + rightSideNodes: ProcessedRefOrGetter; + branches: ProcessedRefOrGetter; + } +>; + +/** + * Lazily creates a tree with the given options. + * @param optionsFunc Tree options. + */ +export function createTree(optionsFunc: OptionsFunc) { + return createLazyProxy(feature => { + const options = optionsFunc.call(feature, feature as Tree); + const { + branches, + nodes, + leftSideNodes, + rightSideNodes, + reset, + resetPropagation, + onReset, + ...props + } = options; + + const tree = { + type: TreeType, + ...(props as Omit), + ...vueFeatureMixin("tree", options, () => ( + + )), + branches: processGetter(branches), + isResetting: ref(false), + resettingNode: shallowRef(null), + nodes: processGetter(nodes), + leftSideNodes: processGetter(leftSideNodes), + rightSideNodes: processGetter(rightSideNodes), + links: processGetter(branches) ?? [], + resetPropagation, + onReset, + reset: + reset ?? + function (node: TreeNode) { + tree.isResetting.value = true; + tree.resettingNode.value = node; + tree.resetPropagation?.(tree, node); + tree.onReset?.(node); + tree.isResetting.value = false; + tree.resettingNode.value = null; + } + } satisfies Tree; + + return tree; + }); +} + +/** A function that is used to propagate resets through a tree. */ +export type ResetPropagation = { + (tree: Tree, resettingNode: TreeNode): void; +}; + +/** Propagate resets down the tree by resetting every node in a lower row. */ +export const defaultResetPropagation = function (tree: Tree, resettingNode: TreeNode): void { + const nodes = unref(tree.nodes); + const row = nodes.findIndex(nodes => nodes.includes(resettingNode)) - 1; + for (let x = row; x >= 0; x--) { + nodes[x].forEach(node => node.reset?.reset()); + } +}; + +/** Propagate resets down the tree by resetting every node in a lower row. */ +export const invertedResetPropagation = function (tree: Tree, resettingNode: TreeNode): void { + const nodes = unref(tree.nodes); + const row = nodes.findIndex(nodes => nodes.includes(resettingNode)) + 1; + for (let x = row; x < nodes.length; x++) { + nodes[x].forEach(node => node.reset?.reset()); + } +}; + +/** Propagate resets down the branches of the tree. */ +export const branchedResetPropagation = function (tree: Tree, resettingNode: TreeNode): void { + const links = unref(tree.branches); + if (links == null) return; + const reset: TreeNode[] = []; + let current = [resettingNode]; + while (current.length !== 0) { + const next: TreeNode[] = []; + for (const node of current) { + for (const link of links.filter(link => link.startNode === node)) { + if ([...reset, ...current].includes(link.endNode)) continue; + next.push(link.endNode); + link.endNode.reset?.reset(); + } + } + reset.push(...current); + current = next; + } +}; + +/** + * Utility for creating a tooltip for a tree node that displays a resource-based unlock requirement, and after unlock shows the amount of another resource. + * It sounds oddly specific, but comes up a lot. + */ +export function createResourceTooltip( + resource: Resource, + requiredResource: Resource | null = null, + requirement: MaybeRefOrGetter = 0 +): Ref { + const req = processGetter(requirement); + return computed(() => { + if (requiredResource == null || Decimal.gte(resource.value, unref(req))) { + return displayResource(resource) + " " + resource.displayName; + } + return `Reach ${ + Decimal.eq(requiredResource.precision, 0) + ? formatWhole(unref(req)) + : format(unref(req), requiredResource.precision) + } ${requiredResource.displayName} to unlock (You have ${ + Decimal.eq(requiredResource.precision, 0) + ? formatWhole(requiredResource.value) + : format(requiredResource.value, requiredResource.precision) + })`; + }); +} diff --git a/src/features/upgrades/Upgrade.vue b/src/features/upgrades/Upgrade.vue deleted file mode 100644 index b2e3b5e..0000000 --- a/src/features/upgrades/Upgrade.vue +++ /dev/null @@ -1,98 +0,0 @@ - - - - - diff --git a/src/features/upgrades/upgrade.ts b/src/features/upgrades/upgrade.ts deleted file mode 100644 index a3075e7..0000000 --- a/src/features/upgrades/upgrade.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { GenericDecorator } from "features/decorators/common"; -import type { - CoercableComponent, - GenericComponent, - OptionsFunc, - Replace, - StyleValue -} from "features/feature"; -import { - Component, - GatherProps, - Visibility, - findFeatures, - getUniqueID, - setDefault -} from "features/feature"; -import UpgradeComponent from "features/upgrades/Upgrade.vue"; -import type { GenericLayer } from "game/layers"; -import type { Persistent } from "game/persistence"; -import { persistent } from "game/persistence"; -import { - Requirements, - createVisibilityRequirement, - payRequirements, - requirementsMet -} from "game/requirements"; -import { isFunction } from "util/common"; -import type { - Computable, - GetComputableType, - GetComputableTypeWithDefault, - ProcessedComputable -} from "util/computed"; -import { processComputable } from "util/computed"; -import { createLazyProxy } from "util/proxies"; -import type { Ref } from "vue"; -import { computed, unref } from "vue"; - -/** A symbol used to identify {@link Upgrade} features. */ -export const UpgradeType = Symbol("Upgrade"); - -/** - * An object that configures a {@link Upgrade}. - */ -export interface UpgradeOptions { - /** Whether this clickable should be visible. */ - visibility?: Computable; - /** Dictionary of CSS classes to apply to this feature. */ - classes?: Computable>; - /** CSS to apply to this feature. */ - style?: Computable; - /** Shows a marker on the corner of the feature. */ - mark?: Computable; - /** The display to use for this clickable. */ - display?: Computable< - | CoercableComponent - | { - /** A header to appear at the top of the display. */ - title?: CoercableComponent; - /** The main text that appears in the display. */ - description: CoercableComponent; - /** A description of the current effect of the achievement. Useful when the effect changes dynamically. */ - effectDisplay?: CoercableComponent; - } - >; - /** The requirements to purchase this upgrade. */ - requirements: Requirements; - /** A function that is called when the upgrade is purchased. */ - onPurchase?: VoidFunction; -} - -/** - * The properties that are added onto a processed {@link UpgradeOptions} to create an {@link Upgrade}. - */ -export interface BaseUpgrade { - /** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */ - id: string; - /** Whether or not this upgrade has been purchased. */ - bought: Persistent; - /** Whether or not the upgrade can currently be purchased. */ - canPurchase: Ref; - /** Purchase the upgrade */ - purchase: VoidFunction; - /** A symbol that helps identify features of the same type. */ - type: typeof UpgradeType; - /** The Vue component used to render this feature. */ - [Component]: GenericComponent; - /** A function to gather the props the vue component requires for this feature. */ - [GatherProps]: () => Record; -} - -/** An object that represents a feature that can be purchased a single time. */ -export type Upgrade = Replace< - T & BaseUpgrade, - { - visibility: GetComputableTypeWithDefault; - classes: GetComputableType; - style: GetComputableType; - display: GetComputableType; - requirements: GetComputableType; - mark: GetComputableType; - } ->; - -/** A type that matches any valid {@link Upgrade} object. */ -export type GenericUpgrade = Replace< - Upgrade, - { - visibility: ProcessedComputable; - } ->; - -/** - * Lazily creates an upgrade with the given options. - * @param optionsFunc Upgrade options. - */ -export function createUpgrade( - optionsFunc: OptionsFunc, - ...decorators: GenericDecorator[] -): Upgrade { - const bought = persistent(false, false); - const decoratedData = decorators.reduce( - (current, next) => Object.assign(current, next.getPersistentData?.()), - {} - ); - return createLazyProxy(feature => { - const upgrade = optionsFunc.call(feature, feature); - upgrade.id = getUniqueID("upgrade-"); - upgrade.type = UpgradeType; - upgrade[Component] = UpgradeComponent as GenericComponent; - - for (const decorator of decorators) { - decorator.preConstruct?.(upgrade); - } - - upgrade.bought = bought; - Object.assign(upgrade, decoratedData); - - upgrade.canPurchase = computed( - () => !bought.value && requirementsMet(upgrade.requirements) - ); - upgrade.purchase = function () { - const genericUpgrade = upgrade as GenericUpgrade; - if (!unref(genericUpgrade.canPurchase)) { - return; - } - payRequirements(upgrade.requirements); - bought.value = true; - genericUpgrade.onPurchase?.(); - }; - - const visibilityRequirement = createVisibilityRequirement(upgrade as GenericUpgrade); - if (Array.isArray(upgrade.requirements)) { - upgrade.requirements.unshift(visibilityRequirement); - } else { - upgrade.requirements = [visibilityRequirement, upgrade.requirements]; - } - - processComputable(upgrade as T, "visibility"); - setDefault(upgrade, "visibility", Visibility.Visible); - processComputable(upgrade as T, "classes"); - processComputable(upgrade as T, "style"); - processComputable(upgrade as T, "display"); - processComputable(upgrade as T, "mark"); - - for (const decorator of decorators) { - decorator.postConstruct?.(upgrade); - } - - const decoratedProps = decorators.reduce( - (current, next) => Object.assign(current, next.getGatheredProps?.(upgrade)), - {} - ); - upgrade[GatherProps] = function (this: GenericUpgrade) { - const { - display, - visibility, - style, - classes, - requirements, - canPurchase, - bought, - mark, - id, - purchase - } = this; - return { - display, - visibility, - style: unref(style), - classes, - requirements, - canPurchase, - bought, - mark, - id, - purchase, - ...decoratedProps - }; - }; - - return upgrade as unknown as Upgrade; - }); -} - -/** - * Utility to auto purchase a list of upgrades whenever they're affordable. - * @param layer The layer the upgrades are apart of - * @param autoActive Whether or not the upgrades should currently be auto-purchasing - * @param upgrades The specific upgrades to upgrade. If unspecified, uses all upgrades on the layer. - */ -export function setupAutoPurchase( - layer: GenericLayer, - autoActive: Computable, - upgrades: GenericUpgrade[] = [] -): void { - upgrades = - upgrades.length === 0 ? (findFeatures(layer, UpgradeType) as GenericUpgrade[]) : upgrades; - const isAutoActive: ProcessedComputable = isFunction(autoActive) - ? computed(autoActive) - : autoActive; - layer.on("update", () => { - if (unref(isAutoActive)) { - upgrades.forEach(upgrade => upgrade.purchase()); - } - }); -} diff --git a/src/game/boards/Draggable.vue b/src/game/boards/Draggable.vue index b181b8b..9b4a2fa 100644 --- a/src/game/boards/Draggable.vue +++ b/src/game/boards/Draggable.vue @@ -7,23 +7,17 @@ @mouseup="e => mouseUp(e)" @touchend.passive="e => mouseUp(e)" > - + diff --git a/src/game/boards/board.tsx b/src/game/boards/board.tsx index 5b27d02..15b9274 100644 --- a/src/game/boards/board.tsx +++ b/src/game/boards/board.tsx @@ -1,15 +1,13 @@ import Board from "./Board.vue"; import Draggable from "./Draggable.vue"; -import { Component, GatherProps, GenericComponent, jsx } from "features/feature"; import { globalBus } from "game/events"; import { Persistent, persistent } from "game/persistence"; import type { PanZoom } from "panzoom"; import { Direction, isFunction } from "util/common"; -import type { Computable, ProcessedComputable } from "util/computed"; -import { convertComputable } from "util/computed"; -import { VueFeature } from "util/vue"; -import type { ComponentPublicInstance, Ref } from "vue"; -import { computed, nextTick, ref, unref, watchEffect } from "vue"; +import { processGetter } from "util/computed"; +import { Renderable, VueFeature } from "util/vue"; +import type { ComponentPublicInstance, MaybeRef, MaybeRefOrGetter, Ref } from "vue"; +import { computed, ref, unref, watchEffect } from "vue"; import panZoom from "vue-panzoom"; // Register panzoom so it can be used in Board.vue @@ -19,10 +17,10 @@ globalBus.on("setupVue", app => panZoom.install(app)); export type NodePosition = { x: number; y: number }; /** - * A type representing a computable value for a node on the board. Used for node types to return different values based on the given node and the state of the board. + * A type representing a MaybeRefOrGetter value for a node on the board. Used for node types to return different values based on the given node and the state of the board. */ -export type NodeComputable = - | Computable +export type NodeMaybeRefOrGetter = + | MaybeRefOrGetter | ((node: T, ...args: S) => R); /** @@ -31,11 +29,11 @@ export type NodeComputable = * @param node The node to get the property of */ export function unwrapNodeRef( - property: NodeComputable, + property: NodeMaybeRefOrGetter, node: T, ...args: S ): R { - return isFunction>(property) + return isFunction>(property) ? property(node, ...args) : unref(property); } @@ -45,8 +43,8 @@ export function unwrapNodeRef( * @param nodes The list of current nodes with IDs as properties * @returns A computed ref that will give the value of the next unique ID */ -export function setupUniqueIds(nodes: Computable<{ id: number }[]>) { - const processedNodes = convertComputable(nodes); +export function setupUniqueIds(nodes: MaybeRefOrGetter<{ id: number }[]>) { + const processedNodes = processGetter(nodes); return computed(() => Math.max(-1, ...unref(processedNodes).map(node => node.id)) + 1); } @@ -59,9 +57,9 @@ export interface DraggableNodeOptions { /** Setter function to update the position of a node. */ setPosition: (node: T, position: NodePosition) => void; /** A list of nodes that the currently dragged node can be dropped upon. */ - receivingNodes?: NodeComputable; + receivingNodes?: NodeMaybeRefOrGetter; /** The maximum distance (in pixels, before zoom) away a node can be and still drop onto a receiving node. */ - dropAreaRadius?: NodeComputable; + dropAreaRadius?: NodeMaybeRefOrGetter; /** A callback for when a node gets dropped upon a receiving node. */ onDrop?: (acceptingNode: T, draggingNode: T) => void; } @@ -261,12 +259,12 @@ export interface MakeDraggableOptions { * @param element The vue feature to make draggable. * @param options The options to configure the dragging behavior. */ -export function makeDraggable( - element: T, - options: MakeDraggableOptions -): asserts element is T & { position: Persistent } { +export function makeDraggable( + element: VueFeature, + options: MakeDraggableOptions +): asserts element is VueFeature & { position: Persistent } { const position = persistent(options.initialPosition ?? { x: 0, y: 0 }); - (element as T & { position: Persistent }).position = position; + (element as VueFeature & { position: Persistent }).position = position; const computedPosition = computed(() => { if (options.nodeBeingDragged.value === options.id) { return { @@ -291,36 +289,25 @@ export function makeDraggable( options.onMouseUp?.(e); } - nextTick(() => { - const elementComponent = element[Component]; - const elementGatherProps = element[GatherProps].bind(element); - element[Component] = Draggable as GenericComponent; - element[GatherProps] = function gatherTooltipProps(this: typeof options) { - return { - element: { - [Component]: elementComponent, - [GatherProps]: elementGatherProps - }, - mouseDown: handleMouseDown, - mouseUp: handleMouseUp, - position: computedPosition - }; - }.bind(options); - }); + element.wrappers.push(el => ( + + {el} + + )); } /** An object that configures how to setup a list of actions using {@link setupActions}. */ export interface SetupActionsOptions { /** The node to display actions upon, or undefined when the actions should be hidden. */ - node: Computable; + node: MaybeRefOrGetter; /** Whether or not to currently display the actions. */ - shouldShowActions?: NodeComputable; + shouldShowActions?: NodeMaybeRefOrGetter; /** The list of actions to display. Actions are arbitrary JSX elements. */ - actions: NodeComputable JSX.Element)[]>; + actions: NodeMaybeRefOrGetter Renderable)[]>; /** The distance from the node to place the actions. */ - distance: NodeComputable; + distance: NodeMaybeRefOrGetter; /** The arc length to place between actions, in radians. */ - arcLength?: NodeComputable; + arcLength?: NodeMaybeRefOrGetter; } /** @@ -329,8 +316,8 @@ export interface SetupActionsOptions { * @returns A JSX function to render the actions. */ export function setupActions(options: SetupActionsOptions) { - const node = convertComputable(options.node); - return jsx(() => { + const node = processGetter(options.node) as MaybeRef; + return computed(() => { const currNode = unref(node); if (currNode == null) { return ""; @@ -404,10 +391,10 @@ export function placeInAvailableSpace( direction === Direction.Right ? (a, b) => a.x - b.x : direction === Direction.Left - ? (a, b) => b.x - a.x - : direction === Direction.Up - ? (a, b) => b.y - a.y - : (a, b) => a.y - b.y + ? (a, b) => b.x - a.x + : direction === Direction.Up + ? (a, b) => b.y - a.y + : (a, b) => a.y - b.y ); for (let i = 0; i < nodes.length; i++) { diff --git a/src/game/events.ts b/src/game/events.ts index 9e74714..c89cca3 100644 --- a/src/game/events.ts +++ b/src/game/events.ts @@ -1,7 +1,7 @@ import type { Settings } from "game/settings"; import { createNanoEvents } from "nanoevents"; import type { App } from "vue"; -import type { GenericLayer } from "./layers"; +import type { Layer } from "./layers"; import state from "./state"; /** All types of events able to be sent or emitted from the global event bus. */ @@ -11,12 +11,12 @@ export interface GlobalEvents { * @param layer The layer being added. * @param saveData The layer's save data object within player. */ - addLayer: (layer: GenericLayer, saveData: Record) => void; + addLayer: (layer: Layer, saveData: Record) => void; /** * Sent whenever a layer is removed. * @param layer The layer being removed. */ - removeLayer: (layer: GenericLayer) => void; + removeLayer: (layer: Layer) => void; /** * Sent every game tick. Runs the life cycle of the project. * @param diff The delta time since last tick, in ms. diff --git a/src/game/formulas/formulas.ts b/src/game/formulas/formulas.ts index d9b0a78..03a53a7 100644 --- a/src/game/formulas/formulas.ts +++ b/src/game/formulas/formulas.ts @@ -1,7 +1,7 @@ import { Resource } from "features/resources/resource"; import { NonPersistent } from "game/persistence"; import Decimal, { DecimalSource, format } from "util/bignum"; -import { Computable, ProcessedComputable, convertComputable } from "util/computed"; +import { MaybeRefOrGetter, MaybeRef, processGetter } from "util/computed"; import { Ref, computed, ref, unref } from "vue"; import * as ops from "./operations"; import type { @@ -60,7 +60,7 @@ export abstract class InternalFormula | undefined; + public readonly innermostVariable: MaybeRef | undefined; constructor(options: FormulaOptions) { let readonlyProperties; @@ -93,7 +93,7 @@ export abstract class InternalFormula; + variable: MaybeRef; }): InternalFormulaProperties { return { inputs: [variable] as T, @@ -207,7 +207,7 @@ export abstract class InternalFormula): InvertibleIntegralFormula { + public static constant(value: MaybeRef): InvertibleIntegralFormula { return new Formula({ inputs: [value] }); } @@ -215,7 +215,7 @@ export abstract class InternalFormula): InvertibleIntegralFormula { + public static variable(value: MaybeRef): InvertibleIntegralFormula { return new Formula({ variable: value }); } @@ -248,11 +248,11 @@ export abstract class InternalFormula, + start: MaybeRefOrGetter, formulaModifier: (value: InvertibleIntegralFormula) => GenericFormula ) { const formula = formulaModifier(Formula.variable(0)); - const processedStart = convertComputable(start); + const processedStart = processGetter(start); function evalStep(lhs: DecimalSource) { if (Decimal.lt(lhs, unref(processedStart))) { return lhs; @@ -293,7 +293,7 @@ export abstract class InternalFormula, + condition: MaybeRefOrGetter, formulaModifier: (value: InvertibleIntegralFormula) => GenericFormula, elseFormulaModifier?: (value: InvertibleIntegralFormula) => GenericFormula ) { @@ -301,7 +301,7 @@ export abstract class InternalFormula, + condition: MaybeRefOrGetter, formulaModifier: (value: InvertibleIntegralFormula) => GenericFormula, elseFormulaModifier?: (value: InvertibleIntegralFormula) => GenericFormula ) { @@ -909,20 +909,20 @@ export abstract class InternalFormula, + start: MaybeRefOrGetter, formulaModifier: (value: InvertibleIntegralFormula) => GenericFormula ) { return Formula.step(this, start, formulaModifier); } public if( - condition: Computable, + condition: MaybeRefOrGetter, formulaModifier: (value: InvertibleIntegralFormula) => GenericFormula ) { return Formula.if(this, condition, formulaModifier); } public conditional( - condition: Computable, + condition: MaybeRefOrGetter, formulaModifier: (value: InvertibleIntegralFormula) => GenericFormula ) { return Formula.if(this, condition, formulaModifier); @@ -1443,13 +1443,13 @@ export function findNonInvertible(formula: GenericFormula): GenericFormula | nul export function calculateMaxAffordable( formula: GenericFormula, resource: Resource, - cumulativeCost: Computable = true, - directSum?: Computable, - maxBulkAmount: Computable = Decimal.dInf + cumulativeCost: MaybeRefOrGetter = true, + directSum?: MaybeRefOrGetter, + maxBulkAmount: MaybeRefOrGetter = Decimal.dInf ) { - const computedCumulativeCost = convertComputable(cumulativeCost); - const computedDirectSum = convertComputable(directSum); - const computedmaxBulkAmount = convertComputable(maxBulkAmount); + const computedCumulativeCost = processGetter(cumulativeCost); + const computedDirectSum = processGetter(directSum); + const computedmaxBulkAmount = processGetter(maxBulkAmount); return computed(() => { const maxBulkAmount = unref(computedmaxBulkAmount); if (Decimal.eq(maxBulkAmount, 1)) { diff --git a/src/game/formulas/types.d.ts b/src/game/formulas/types.d.ts index cc185a9..3f98f55 100644 --- a/src/game/formulas/types.d.ts +++ b/src/game/formulas/types.d.ts @@ -1,10 +1,10 @@ import { InternalFormula } from "game/formulas/formulas"; import { DecimalSource } from "util/bignum"; -import { ProcessedComputable } from "util/computed"; +import { MaybeRef } from "util/computed"; // eslint-disable-next-line @typescript-eslint/no-explicit-any type GenericFormula = InternalFormula; -type FormulaSource = ProcessedComputable | GenericFormula; +type FormulaSource = MaybeRef | GenericFormula; type InvertibleFormula = GenericFormula & { invert: NonNullable; }; @@ -38,7 +38,7 @@ type SubstitutionFunction = ( ) => GenericFormula; type VariableFormulaOptions = { - variable: ProcessedComputable; + variable: MaybeRef; description?: string; }; type ConstantFormulaOptions = { @@ -67,7 +67,7 @@ type InternalFormulaProperties = { internalIntegrate?: IntegrateFunction; internalIntegrateInner?: IntegrateFunction; applySubstitution?: SubstitutionFunction; - innermostVariable?: ProcessedComputable; + innermostVariable?: MaybeRef; description?: string; }; diff --git a/src/game/layers.tsx b/src/game/layers.tsx index 7e82ead..a5f3fe2 100644 --- a/src/game/layers.tsx +++ b/src/game/layers.tsx @@ -1,28 +1,26 @@ import Modal from "components/modals/Modal.vue"; -import type { - CoercableComponent, - JSXFunction, - OptionsFunc, - Replace, - StyleValue -} from "features/feature"; -import { jsx, setDefault } from "features/feature"; +import type { OptionsFunc, Replace } from "features/feature"; import { globalBus } from "game/events"; import type { Persistent } from "game/persistence"; import { persistent } from "game/persistence"; import player from "game/player"; import type { Emitter } from "nanoevents"; import { createNanoEvents } from "nanoevents"; -import type { - Computable, - GetComputableType, - GetComputableTypeWithDefault, - ProcessedComputable -} from "util/computed"; -import { processComputable } from "util/computed"; +import { ProcessedRefOrGetter, processGetter } from "util/computed"; import { createLazyProxy } from "util/proxies"; -import { computed, InjectionKey, Ref } from "vue"; -import { ref, shallowReactive, unref } from "vue"; +import { Renderable } from "util/vue"; +import { + computed, + type CSSProperties, + InjectionKey, + MaybeRef, + MaybeRefOrGetter, + Ref, + ref, + shallowReactive, + unref +} from "vue"; +import { JSX } from "vue/jsx-runtime"; /** A feature's node in the DOM that has its size tracked. */ export interface FeatureNode { @@ -74,12 +72,12 @@ export interface LayerEvents { * 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 | undefined> = shallowReactive({}); +export const layers: Record> = shallowReactive({}); declare global { /** Augment the window object so the layers can be accessed from the console. */ interface Window { - layers: Record | undefined>; + layers: Record | undefined>; } } window.layers = layers; @@ -106,42 +104,42 @@ export interface Position { */ export interface LayerOptions { /** The color of the layer, used to theme the entire layer's display. */ - color?: Computable; + color?: MaybeRefOrGetter; /** * The layout of this layer's features. * When the layer is open in {@link game/player.PlayerData.tabs}, this is the content that is displayed. */ - display: Computable; + display: MaybeRefOrGetter; /** An object of classes that should be applied to the display. */ - classes?: Computable>; + classes?: MaybeRefOrGetter>; /** Styles that should be applied to the display. */ - style?: Computable; + style?: MaybeRefOrGetter; /** * The name of the layer, used on minimized tabs. * Defaults to {@link BaseLayer.id}. */ - name?: Computable; + name?: MaybeRefOrGetter; /** * Whether or not the layer can be minimized. * Defaults to true. */ - minimizable?: Computable; + minimizable?: MaybeRefOrGetter; /** * The layout of this layer's features. * When the layer is open in {@link game/player.PlayerData.tabs}, but the tab is {@link Layer.minimized} this is the content that is displayed. */ - minimizedDisplay?: Computable; + minimizedDisplay?: MaybeRefOrGetter; /** * 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; + forceHideGoBack?: MaybeRefOrGetter; /** * 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; + minWidth?: MaybeRefOrGetter; } /** The properties that are added onto a processed {@link LayerOptions} to create a {@link Layer} */ @@ -165,28 +163,18 @@ export interface BaseLayer { } /** An unit of game content. Displayed to the user as a tab or modal. */ -export type Layer = Replace< - T & BaseLayer, +export type Layer = Replace< + Replace, { - color: GetComputableType; - display: GetComputableType; - classes: GetComputableType; - style: GetComputableType; - name: GetComputableTypeWithDefault; - minWidth: GetComputableTypeWithDefault; - minimizable: GetComputableTypeWithDefault; - minimizedDisplay: GetComputableType; - forceHideGoBack: GetComputableType; - } ->; - -/** A type that matches any valid {@link Layer} object. */ -export type GenericLayer = Replace< - Layer, - { - name: ProcessedComputable; - minWidth: ProcessedComputable; - minimizable: ProcessedComputable; + color?: ProcessedRefOrGetter; + display: ProcessedRefOrGetter; + classes?: ProcessedRefOrGetter; + style?: ProcessedRefOrGetter; + name: MaybeRef; + minWidth: MaybeRef; + minimizable: MaybeRef; + minimizedDisplay?: ProcessedRefOrGetter; + forceHideGoBack?: ProcessedRefOrGetter; } >; @@ -206,72 +194,85 @@ export const addingLayers: string[] = []; export function createLayer( id: string, optionsFunc: OptionsFunc -): Layer { +) { return createLazyProxy(() => { - const layer = {} as T & Partial; - const emitter = (layer.emitter = createNanoEvents()); - layer.on = emitter.on.bind(emitter); - layer.emit = emitter.emit.bind(emitter) as ( - ...args: [K, ...Parameters] - ) => void; - layer.nodes = ref({}); - layer.id = id; - + const emitter = createNanoEvents(); addingLayers.push(id); persistentRefs[id] = new Set(); - layer.minimized = persistent(false, false); - Object.assign(layer, optionsFunc.call(layer, layer as BaseLayer)); + + const baseLayer = { + id, + emitter, + ...emitter, + nodes: ref({}), + minimized: persistent(false, false) + } satisfies BaseLayer; + + const options = optionsFunc.call(baseLayer, baseLayer); + const { + color, + display, + classes, + style: _style, + name, + forceHideGoBack, + minWidth, + minimizable, + minimizedDisplay, + ...props + } = options; if ( addingLayers[addingLayers.length - 1] == null || addingLayers[addingLayers.length - 1] !== id ) { throw new Error( - `Adding layers stack in invalid state. This should not happen\nStack: ${addingLayers}\nTrying to pop ${layer.id}` + `Adding layers stack in invalid state. This should not happen\nStack: ${addingLayers}\nTrying to pop ${id}` ); } addingLayers.pop(); - processComputable(layer as T, "color"); - processComputable(layer as T, "display"); - processComputable(layer as T, "classes"); - processComputable(layer as T, "style"); - processComputable(layer as T, "name"); - setDefault(layer, "name", layer.id); - processComputable(layer as T, "minWidth"); - setDefault(layer, "minWidth", 600); - processComputable(layer as T, "minimizable"); - setDefault(layer, "minimizable", true); - processComputable(layer as T, "minimizedDisplay"); + const style = processGetter(_style); - const style = layer.style as ProcessedComputable | undefined; - layer.style = computed(() => { - let width = unref(layer.minWidth as ProcessedComputable); - if (typeof width === "number" || !Number.isNaN(parseInt(width))) { - width = width + "px"; - } - return [ - unref(style) ?? "", - layer.minimized?.value - ? { - flexGrow: "0", - flexShrink: "0", - width: "60px", - minWidth: "", - flexBasis: "", - margin: "0" - } - : { - flexGrow: "", - flexShrink: "", - width: "", - minWidth: width, - flexBasis: width, - margin: "" - } - ]; - }) as Ref; + const layer = { + ...baseLayer, + ...(props as Omit), + color: processGetter(color), + display: processGetter(display), + classes: processGetter(classes), + style: computed((): CSSProperties => { + let width = unref(layer.minWidth); + if (typeof width === "number" || !Number.isNaN(parseInt(width))) { + width = width + "px"; + } + return { + ...unref(style), + ...(baseLayer.minimized.value + ? { + flexGrow: "0", + flexShrink: "0", + width: "60px", + minWidth: "", + flexBasis: "", + margin: "0" + } + : { + flexGrow: "", + flexShrink: "", + width: "", + minWidth: width, + flexBasis: width, + margin: "" + }) + }; + }), + name: processGetter(name) ?? id, + forceHideGoBack: processGetter(forceHideGoBack), + minWidth: processGetter(minWidth) ?? 600, + minimizable: processGetter(minimizable) ?? true, + minimizedDisplay: processGetter(minimizedDisplay) + } satisfies Layer; - return layer as unknown as Layer; + return layer; }); } @@ -284,11 +285,11 @@ export function createLayer( * @param player The player data object, which will have a data object for this layer. */ export function addLayer( - layer: GenericLayer, + layer: Layer, player: { layers?: Record> } ): void { console.info("Adding layer", layer.id); - if (layers[layer.id]) { + if (layers[layer.id] != null) { console.error( "Attempted to add layer with same ID as existing layer", layer.id, @@ -297,7 +298,7 @@ export function addLayer( return; } - setDefault(player, "layers", {}); + player.layers ??= {}; if (player.layers[layer.id] == null) { player.layers[layer.id] = {}; } @@ -310,7 +311,7 @@ export function addLayer( * Convenience method for getting a layer by its ID with correct typing. * @param layerID The ID of the layer to get. */ -export function getLayer(layerID: string): T { +export function getLayer(layerID: string): T { return layers[layerID] as T; } @@ -319,11 +320,11 @@ export function getLayer(layerID: string): T { * 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 { +export function removeLayer(layer: Layer): void { console.info("Removing layer", layer.id); globalBus.emit("removeLayer", layer); - layers[layer.id] = undefined; + delete layers[layer.id]; } /** @@ -331,7 +332,7 @@ export function removeLayer(layer: GenericLayer): void { * 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 { +export function reloadLayer(layer: Layer): void { removeLayer(layer); // Re-create layer @@ -343,14 +344,14 @@ export function reloadLayer(layer: GenericLayer): void { * 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): { +export function setupLayerModal(layer: Layer): { openModal: VoidFunction; - modal: JSXFunction; + modal: Ref; } { const showModal = ref(false); return { openModal: () => (showModal.value = true), - modal: jsx(() => ( + modal: computed(() => ( (showModal.value = value)} diff --git a/src/game/modifiers.tsx b/src/game/modifiers.tsx index 55efccb..995b300 100644 --- a/src/game/modifiers.tsx +++ b/src/game/modifiers.tsx @@ -1,15 +1,13 @@ import "components/common/modifiers.css"; -import type { CoercableComponent, OptionsFunc } from "features/feature"; -import { jsx } from "features/feature"; +import type { OptionsFunc } from "features/feature"; import settings from "game/settings"; import type { DecimalSource } from "util/bignum"; import Decimal, { formatSmall } from "util/bignum"; import type { RequiredKeys, WithRequired } from "util/common"; -import type { Computable, ProcessedComputable } from "util/computed"; -import { convertComputable } from "util/computed"; +import { processGetter } from "util/computed"; import { createLazyProxy } from "util/proxies"; -import { renderJSX } from "util/vue"; -import { computed, unref } from "vue"; +import { render, Renderable } from "util/vue"; +import { computed, MaybeRef, MaybeRefOrGetter, unref } from "vue"; import Formula from "./formulas/formulas"; import { FormulaSource, GenericFormula } from "./formulas/types"; @@ -30,12 +28,12 @@ export interface Modifier { * Whether or not this modifier should be considered enabled. * Typically for use with modifiers passed into {@link createSequentialModifier}. */ - enabled?: ProcessedComputable; + enabled?: MaybeRef; /** * A description of this modifier. * @see {@link createModifierSection}. */ - description?: ProcessedComputable; + description?: MaybeRef; } /** Utility type that represents the output of all modifiers that represent a single operation. */ @@ -47,11 +45,11 @@ export type OperationModifier = WithRequired< /** An object that configures an additive modifier via {@link createAdditiveModifier}. */ export interface AdditiveModifierOptions { /** The amount to add to the input value. */ - addend: Computable; + addend: MaybeRefOrGetter; /** Description of what this modifier is doing. */ - description?: Computable; - /** A computable that will be processed and passed directly into the returned modifier. */ - enabled?: Computable; + description?: MaybeRefOrGetter; + /** A MaybeRefOrGetter that will be processed and passed directly into the returned modifier. */ + enabled?: MaybeRefOrGetter; /** Determines if numbers larger or smaller than 0 should be displayed as red. */ smallerIsBetter?: boolean; } @@ -69,25 +67,22 @@ export function createAdditiveModifier Decimal.add(gain, unref(processedAddend)), invert: (gain: DecimalSource) => Decimal.sub(gain, unref(processedAddend)), getFormula: (gain: FormulaSource) => Formula.add(gain, processedAddend), enabled: processedEnabled, description: - description == null + processedDescription == null ? undefined - : jsx(() => ( + : computed(() => (
- {unref(processedDescription) != null ? ( - - {/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */} - {renderJSX(unref(processedDescription)!)} - - ) : null} + + {render(processedDescription)} + ; + multiplier: MaybeRefOrGetter; /** Description of what this modifier is doing. */ - description?: Computable | undefined; - /** A computable that will be processed and passed directly into the returned modifier. */ - enabled?: Computable | undefined; + description?: MaybeRefOrGetter | undefined; + /** A MaybeRefOrGetter that will be processed and passed directly into the returned modifier. */ + enabled?: MaybeRefOrGetter | undefined; /** Determines if numbers larger or smaller than 1 should be displayed as red. */ smallerIsBetter?: boolean; } @@ -135,25 +130,22 @@ export function createMultiplicativeModifier< feature ); - const processedMultiplier = convertComputable(multiplier); - const processedDescription = convertComputable(description); - const processedEnabled = enabled == null ? undefined : convertComputable(enabled); + const processedMultiplier = processGetter(multiplier); + const processedDescription = processGetter(description); + const processedEnabled = enabled == null ? undefined : processGetter(enabled); return { apply: (gain: DecimalSource) => Decimal.times(gain, unref(processedMultiplier)), invert: (gain: DecimalSource) => Decimal.div(gain, unref(processedMultiplier)), getFormula: (gain: FormulaSource) => Formula.times(gain, processedMultiplier), enabled: processedEnabled, description: - description == null + processedDescription == null ? undefined - : jsx(() => ( + : computed(() => (
- {unref(processedDescription) != null ? ( - - {/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */} - {renderJSX(unref(processedDescription)!)} - - ) : null} + + {render(processedDescription)} + ; + exponent: MaybeRefOrGetter; /** Description of what this modifier is doing. */ - description?: Computable | undefined; - /** A computable that will be processed and passed directly into the returned modifier. */ - enabled?: Computable | undefined; + description?: MaybeRefOrGetter | undefined; + /** A MaybeRefOrGetter that will be processed and passed directly into the returned modifier. */ + enabled?: MaybeRefOrGetter | undefined; /** Add 1 before calculating, then remove it afterwards. This prevents low numbers from becoming lower. */ supportLowNumbers?: boolean; /** Determines if numbers larger or smaller than 1 should be displayed as red. */ @@ -200,9 +192,9 @@ export function createExponentialModifier< const { exponent, description, enabled, supportLowNumbers, smallerIsBetter } = optionsFunc.call(feature, feature); - const processedExponent = convertComputable(exponent); - const processedDescription = convertComputable(description); - const processedEnabled = enabled == null ? undefined : convertComputable(enabled); + const processedExponent = processGetter(exponent); + const processedDescription = processGetter(description); + const processedEnabled = enabled == null ? undefined : processGetter(enabled); return { apply: (gain: DecimalSource) => { let result = gain; @@ -232,17 +224,14 @@ export function createExponentialModifier< : Formula.pow(gain, processedExponent), enabled: processedEnabled, description: - description == null + processedDescription == null ? undefined - : jsx(() => ( + : computed(() => (
- {unref(processedDescription) != null ? ( - - {/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */} - {renderJSX(unref(processedDescription)!)} - {supportLowNumbers ? " (+1 effective)" : null} - - ) : null} + + {render(processedDescription)} + {supportLowNumbers ? " (+1 effective)" : null} + modifiers.filter(m => unref(m.enabled) !== false).length > 0) : undefined, description: modifiers.some(m => m.description != null) - ? jsx(() => ( - <> - {( - modifiers - .filter(m => unref(m.enabled) !== false) - .map(m => unref(m.description)) - .filter(d => d) as CoercableComponent[] - ).map(renderJSX)} - - )) + ? computed(() => + ( + modifiers + .filter(m => unref(m.enabled) !== false) + .map(m => unref(m.description)) + .filter(d => d) as MaybeRef[] + ).map(m => render(m)) + ) : undefined }; }) as S; @@ -332,7 +319,7 @@ export interface ModifierSectionOptions { /** The unit of the value being modified, if any. */ unit?: string; /** The label to use for the base value. Defaults to "Base". */ - baseText?: CoercableComponent; + baseText?: MaybeRefOrGetter; /** Determines if numbers larger or smaller than the base should be displayed as red. */ smallerIsBetter?: boolean; } @@ -352,6 +339,7 @@ export function createModifierSection({ smallerIsBetter }: ModifierSectionOptions) { const total = modifier.apply(base ?? 1); + const processedBaseText = processGetter(baseText); return (

@@ -360,13 +348,13 @@ export function createModifierSection({


- {renderJSX(baseText ?? "Base")} + {render(processedBaseText ?? "Base")} {formatSmall(base ?? 1)} {unit}
- {renderJSX(unref(modifier.description))} + {render(modifier.description)}
Total diff --git a/src/game/notifications.ts b/src/game/notifications.ts index 5a43c1a..46039b8 100644 --- a/src/game/notifications.ts +++ b/src/game/notifications.ts @@ -1,5 +1,5 @@ import { globalBus } from "game/events"; -import { convertComputable } from "util/computed"; +import { processGetter } from "util/computed"; import { trackHover, VueFeature } from "util/vue"; import { nextTick, Ref } from "vue"; import { ref, watch } from "vue"; @@ -37,7 +37,7 @@ export function createDismissableNotify( element: VueFeature, shouldNotify: Ref | (() => boolean) ): Ref { - const processedShouldNotify = convertComputable(shouldNotify) as Ref; + const processedShouldNotify = processGetter(shouldNotify) as Ref; const notifying = ref(false); nextTick(() => { notifying.value = processedShouldNotify.value; diff --git a/src/game/persistence.ts b/src/game/persistence.ts index 253481e..20c0534 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -1,14 +1,14 @@ import { globalBus } from "game/events"; -import type { GenericLayer } from "game/layers"; +import type { Layer } from "game/layers"; import { addingLayers, persistentRefs } from "game/layers"; import type { DecimalSource } from "util/bignum"; import Decimal from "util/bignum"; import { ProxyState } from "util/proxies"; import type { Ref, WritableComputedRef } from "vue"; import { computed, isReactive, isRef, ref } from "vue"; +import Formula from "./formulas/formulas"; import player from "./player"; import state from "./state"; -import Formula from "./formulas/formulas"; /** * A symbol used in {@link Persistent} objects. @@ -251,7 +251,7 @@ export function deletePersistent(persistent: Persistent) { persistent[Deleted] = true; } -globalBus.on("addLayer", (layer: GenericLayer, saveData: Record) => { +globalBus.on("addLayer", (layer: Layer, saveData: Record) => { const features: { type: typeof Symbol }[] = []; const handleObject = (obj: Record, path: string[] = []): boolean => { let foundPersistent = false; diff --git a/src/game/requirements.tsx b/src/game/requirements.tsx index 195efa8..a3b260f 100644 --- a/src/game/requirements.tsx +++ b/src/game/requirements.tsx @@ -1,26 +1,12 @@ -import { - CoercableComponent, - isVisible, - jsx, - OptionsFunc, - Replace, - setDefault, - Visibility -} from "features/feature"; +import { isVisible, OptionsFunc, Replace, Visibility } from "features/feature"; import { displayResource, Resource } from "features/resources/resource"; import Decimal, { DecimalSource } from "lib/break_eternity"; -import { - Computable, - convertComputable, - processComputable, - ProcessedComputable -} from "util/computed"; +import { processGetter } from "util/computed"; import { createLazyProxy } from "util/proxies"; -import { joinJSX, renderJSX } from "util/vue"; -import { computed, unref } from "vue"; -import { JSX } from "vue/jsx-runtime"; +import { joinJSX, render, Renderable } from "util/vue"; +import { computed, MaybeRef, MaybeRefOrGetter, unref } from "vue"; import Formula, { calculateCost, calculateMaxAffordable } from "./formulas/formulas"; -import type { GenericFormula } from "./formulas/types"; +import type { GenericFormula, InvertibleIntegralFormula } from "./formulas/types"; import { DefaultValue, Persistent } from "./persistence"; /** @@ -31,27 +17,27 @@ export interface Requirement { /** * The display for this specific requirement. This is used for displays multiple requirements condensed. Required if {@link visibility} can be {@link Visibility.Visible}. */ - partialDisplay?: (amount?: DecimalSource) => JSX.Element; + partialDisplay?: (amount?: DecimalSource) => Renderable; /** * The display for this specific requirement. Required if {@link visibility} can be {@link Visibility.Visible}. */ - display?: (amount?: DecimalSource) => JSX.Element; + display?: (amount?: DecimalSource) => Renderable; /** * Whether or not this requirement should be displayed in Vue Features. {@link displayRequirements} will respect this property. */ - visibility: ProcessedComputable; + visibility: MaybeRef; /** * Whether or not this requirement has been met. */ - requirementMet: ProcessedComputable; + requirementMet: MaybeRef; /** * Whether or not this requirement will need to affect the game state when whatever is using this requirement gets triggered. */ - requiresPay: ProcessedComputable; + requiresPay: MaybeRef; /** * Whether or not this requirement can have multiple levels of requirements that can be met at once. Requirement is assumed to not have multiple levels if this property not present. */ - canMaximize?: ProcessedComputable; + canMaximize?: MaybeRef; /** * Perform any effects to the game state that should happen when the requirement gets triggered. * @param amount The amount of levels of requirements to pay for. @@ -73,28 +59,28 @@ export interface CostRequirementOptions { /** * The amount of {@link resource} that must be met for this requirement. You can pass a formula, in which case maximizing will work out of the box (assuming its invertible and, for more accurate calculations, its integral is invertible). If you don't pass a formula then you can still support maximizing by passing a custom {@link pay} function. */ - cost: Computable | GenericFormula; + cost: MaybeRefOrGetter | GenericFormula; /** * Pass-through to {@link Requirement.visibility}. */ - visibility?: Computable; + visibility?: MaybeRefOrGetter; /** * Pass-through to {@link Requirement.requiresPay}. If not set to false, the default {@link pay} function will remove {@link cost} from {@link resource}. */ - requiresPay?: Computable; + requiresPay?: MaybeRefOrGetter; /** * When calculating multiple levels to be handled at once, whether it should consider resources used for each level as spent. Setting this to false causes calculations to be faster with larger numbers and supports more math functions. * @see {Formula} */ - cumulativeCost?: Computable; + cumulativeCost?: MaybeRefOrGetter; /** * Upper limit on levels that can be performed at once. Defaults to 1. */ - maxBulkAmount?: Computable; + maxBulkAmount?: MaybeRefOrGetter; /** * When calculating requirement for multiple levels, how many should be directly summed for increase accuracy. High numbers can cause lag. Defaults to 10 if cumulative cost, 0 otherwise. */ - directSum?: Computable; + directSum?: MaybeRefOrGetter; /** * Pass-through to {@link Requirement.pay}. May be required for maximizing support. * @see {@link cost} for restrictions on maximizing support. @@ -105,11 +91,11 @@ export interface CostRequirementOptions { export type CostRequirement = Replace< Requirement & CostRequirementOptions, { - cost: ProcessedComputable | GenericFormula; - visibility: ProcessedComputable; - requiresPay: ProcessedComputable; - cumulativeCost: ProcessedComputable; - canMaximize: ProcessedComputable; + cost: MaybeRef | GenericFormula; + visibility: MaybeRef; + requiresPay: MaybeRef; + cumulativeCost: MaybeRef; + canMaximize: MaybeRef; } >; @@ -119,116 +105,123 @@ export type CostRequirement = Replace< */ export function createCostRequirement( optionsFunc: OptionsFunc -): CostRequirement { +) { return createLazyProxy(feature => { - const req = optionsFunc.call(feature, feature) as T & Partial; + const options = optionsFunc.call(feature, feature); + const { + visibility, + cost, + resource, + requiresPay, + cumulativeCost, + maxBulkAmount, + directSum, + pay + } = options; - req.partialDisplay = amount => ( - ) - ? "" - : "color: var(--danger)" + const requirement = { + resource, + visibility: processGetter(visibility) ?? Visibility.Visible, + cost: processGetter(cost), + requiresPay: processGetter(requiresPay) ?? true, + cumulativeCost: processGetter(cumulativeCost) ?? true, + maxBulkAmount: processGetter(maxBulkAmount) ?? 1, + directSum: processGetter(directSum), + partialDisplay: (amount?: DecimalSource) => ( + + {displayResource( + resource, + requirement.cost instanceof Formula + ? calculateCost( + requirement.cost as InvertibleIntegralFormula, + amount ?? 1, + unref(requirement.cumulativeCost), + unref(requirement.directSum) + ) + : unref(requirement.cost as MaybeRef) + )}{" "} + {resource.displayName} + + ), + display: (amount?: DecimalSource) => ( +
+ {unref(requirement.requiresPay as MaybeRef) ? "Costs: " : "Requires: "} + {displayResource( + resource, + requirement.cost instanceof Formula + ? calculateCost( + requirement.cost as InvertibleIntegralFormula, + amount ?? 1, + unref(requirement.cumulativeCost), + unref(requirement.directSum) + ) + : unref(requirement.cost as MaybeRef) + )}{" "} + {resource.displayName} +
+ ), + canMaximize: computed(() => { + if (!(requirement.cost instanceof Formula)) { + return false; + } + const maxBulkAmount = unref(requirement.maxBulkAmount); + if (Decimal.lte(maxBulkAmount, 1)) { + return false; + } + const cumulativeCost = unref(requirement.cumulativeCost); + const directSum = unref(requirement.directSum) ?? (cumulativeCost ? 10 : 0); + if (Decimal.lte(maxBulkAmount, directSum)) { + return true; + } + if (!requirement.cost.isInvertible()) { + return false; + } + if (cumulativeCost === true && !requirement.cost.isIntegrable()) { + return false; } - > - {displayResource( - req.resource, - req.cost instanceof Formula - ? calculateCost( - req.cost, - amount ?? 1, - unref(req.cumulativeCost) as boolean, - unref(req.directSum) as number - ) - : unref(req.cost as ProcessedComputable) - )}{" "} - {req.resource.displayName} -
- ); - req.display = amount => ( -
- {unref(req.requiresPay as ProcessedComputable) ? "Costs: " : "Requires: "} - {displayResource( - req.resource, - req.cost instanceof Formula - ? calculateCost( - req.cost, - amount ?? 1, - unref(req.cumulativeCost) as boolean, - unref(req.directSum) as number - ) - : unref(req.cost as ProcessedComputable) - )}{" "} - {req.resource.displayName} -
- ); - - processComputable(req as T, "visibility"); - setDefault(req, "visibility", Visibility.Visible); - processComputable(req as T, "cost"); - processComputable(req as T, "requiresPay"); - setDefault(req, "requiresPay", true); - processComputable(req as T, "cumulativeCost"); - setDefault(req, "cumulativeCost", true); - processComputable(req as T, "maxBulkAmount"); - setDefault(req, "maxBulkAmount", 1); - processComputable(req as T, "directSum"); - setDefault(req, "pay", function (amount?: DecimalSource) { - const cost = - req.cost instanceof Formula - ? calculateCost( - req.cost, - amount ?? 1, - unref(req.cumulativeCost as ProcessedComputable), - unref(req.directSum) as number - ) - : unref(req.cost as ProcessedComputable); - req.resource.value = Decimal.sub(req.resource.value, cost).max(0); - }); - - req.canMaximize = computed(() => { - if (!(req.cost instanceof Formula)) { - return false; - } - const maxBulkAmount = unref(req.maxBulkAmount as ProcessedComputable); - if (Decimal.lte(maxBulkAmount, 1)) { - return false; - } - const cumulativeCost = unref(req.cumulativeCost as ProcessedComputable); - const directSum = - unref(req.directSum as ProcessedComputable) ?? (cumulativeCost ? 10 : 0); - if (Decimal.lte(maxBulkAmount, directSum)) { return true; - } - if (!req.cost.isInvertible()) { - return false; - } - if (cumulativeCost === true && !req.cost.isIntegrable()) { - return false; - } - return true; - }); + }), + requirementMet: + cost instanceof Formula + ? calculateMaxAffordable( + cost, + resource, + cumulativeCost ?? true, + directSum, + maxBulkAmount + ) + : computed( + (): DecimalSource => + Decimal.gte( + resource.value, + unref(requirement.cost as MaybeRef) + ) + ? 1 + : 0 + ), + pay: + pay ?? + function (amount?: DecimalSource) { + const cost = + requirement.cost instanceof Formula + ? calculateCost( + requirement.cost, + amount ?? 1, + unref(requirement.cumulativeCost), + unref(requirement.directSum) + ) + : unref(requirement.cost as MaybeRef); + resource.value = Decimal.sub(resource.value, cost).max(0); + } + } satisfies CostRequirement; - if (req.cost instanceof Formula) { - req.requirementMet = calculateMaxAffordable( - req.cost, - req.resource, - req.cumulativeCost ?? true, - req.directSum, - req.maxBulkAmount - ); - } else { - req.requirementMet = computed(() => - Decimal.gte( - req.resource.value, - unref(req.cost as ProcessedComputable) - ) - ? 1 - : 0 - ); - } - - return req as CostRequirement; + return requirement; }); } @@ -236,11 +229,11 @@ export function createCostRequirement( * Utility function for creating a requirement that a specified vue feature is visible * @param feature The feature to check the visibility of */ -export function createVisibilityRequirement(feature: { - visibility: ProcessedComputable; -}): Requirement { +export function createVisibilityRequirement( + visibility: MaybeRef +): Requirement { return createLazyProxy(() => ({ - requirementMet: computed(() => isVisible(feature.visibility)), + requirementMet: computed(() => isVisible(visibility)), visibility: Visibility.None, requiresPay: false })); @@ -252,16 +245,20 @@ export function createVisibilityRequirement(feature: { * @param display How to display this requirement to the user */ export function createBooleanRequirement( - requirement: Computable, - display?: CoercableComponent + requirement: MaybeRefOrGetter, + display?: MaybeRefOrGetter ): Requirement { - return createLazyProxy(() => ({ - requirementMet: convertComputable(requirement), - partialDisplay: display == null ? undefined : jsx(() => renderJSX(display)), - display: display == null ? undefined : jsx(() => <>Req: {renderJSX(display)}), - visibility: display == null ? Visibility.None : Visibility.Visible, - requiresPay: false - })); + return createLazyProxy(() => { + const processedDisplay = processGetter(display); + return { + requirementMet: processGetter(requirement), + partialDisplay: processedDisplay == null ? undefined : () => render(processedDisplay), + display: + processedDisplay == null ? undefined : () => <>Req: {render(processedDisplay)}, + visibility: processedDisplay == null ? Visibility.None : Visibility.Visible, + requiresPay: false + }; + }); } /** @@ -300,7 +297,7 @@ export function maxRequirementsMet(requirements: Requirements): DecimalSource { */ export function displayRequirements(requirements: Requirements, amount: DecimalSource = 1) { if (Array.isArray(requirements)) { - requirements = requirements.filter(r => isVisible(r.visibility)); + requirements = requirements.filter(r => isVisible(r.visibility ?? true)); if (requirements.length === 1) { requirements = requirements[0]; } @@ -356,9 +353,9 @@ export function payByDivision(this: CostRequirement, amount?: DecimalSource) { ? calculateCost( this.cost, amount ?? 1, - unref(this.cumulativeCost as ProcessedComputable | undefined) ?? true + unref(this.cumulativeCost as MaybeRef | undefined) ?? true ) - : unref(this.cost as ProcessedComputable); + : unref(this.cost as MaybeRef); this.resource.value = Decimal.div(this.resource.value, cost); } diff --git a/src/game/settings.ts b/src/game/settings.ts index d24b810..9360001 100644 --- a/src/game/settings.ts +++ b/src/game/settings.ts @@ -1,10 +1,11 @@ import projInfo from "data/projInfo.json"; import { Themes } from "data/themes"; -import type { CoercableComponent } from "features/feature"; import { globalBus } from "game/events"; import LZString from "lz-string"; +import { processGetter } from "util/computed"; import { decodeSave, hardReset } from "util/save"; -import { reactive, watch } from "vue"; +import { Renderable } from "util/vue"; +import { MaybeRef, MaybeRefOrGetter, reactive, watch } from "vue"; /** The player's settings object. */ export interface Settings { @@ -100,22 +101,22 @@ export function loadSettings(): void { } /** A list of fields to append to the settings modal. */ -export const settingFields: CoercableComponent[] = reactive([]); +export const settingFields: MaybeRef[] = reactive([]); /** Register a field to be displayed in the settings modal. */ -export function registerSettingField(component: CoercableComponent) { - settingFields.push(component); +export function registerSettingField(component: MaybeRefOrGetter) { + settingFields.push(processGetter(component)); } /** A list of components to show in the info modal. */ -export const infoComponents: CoercableComponent[] = reactive([]); +export const infoComponents: MaybeRef[] = reactive([]); /** Register a component to be displayed in the info modal. */ -export function registerInfoComponent(component: CoercableComponent) { - infoComponents.push(component); +export function registerInfoComponent(component: MaybeRefOrGetter) { + infoComponents.push(processGetter(component)); } /** A list of components to add to the root of the page. */ -export const gameComponents: CoercableComponent[] = reactive([]); +export const gameComponents: MaybeRef[] = reactive([]); /** Register a component to be displayed at the root of the page. */ -export function registerGameComponent(component: CoercableComponent) { - gameComponents.push(component); +export function registerGameComponent(component: MaybeRefOrGetter) { + gameComponents.push(processGetter(component)); } diff --git a/src/mixins/bonusDecorator.ts b/src/mixins/bonusDecorator.ts new file mode 100644 index 0000000..b08954e --- /dev/null +++ b/src/mixins/bonusDecorator.ts @@ -0,0 +1,29 @@ +import Decimal, { DecimalSource } from "util/bignum"; +import { processGetter } from "util/computed"; +import { MaybeRefOrGetter, Ref, computed, unref } from "vue"; + +/** Allows the addition of "bonus levels" to a feature, with an accompanying "total amount". */ +export function bonusAmountMixin( + feature: { amount: Ref }, + bonusAmount: MaybeRefOrGetter +) { + const processedBonusAmount = processGetter(bonusAmount); + return { + bonusAmount, + totalAmount: computed(() => Decimal.add(unref(feature.amount), unref(processedBonusAmount))) + }; +} + +/** Allows the addition of "bonus completions" to a feature, with an accompanying "total completions". */ +export function bonusCompletionsMixin( + feature: { completions: Ref }, + bonusCompletions: MaybeRefOrGetter +) { + const processedBonusCompletions = processGetter(bonusCompletions); + return { + bonusCompletions, + totalCompletions: computed(() => + Decimal.add(unref(feature.completions), unref(processedBonusCompletions)) + ) + }; +} diff --git a/src/util/computed.ts b/src/util/computed.ts index ff11103..869b38f 100644 --- a/src/util/computed.ts +++ b/src/util/computed.ts @@ -1,57 +1,16 @@ -import type { JSXFunction } from "features/feature"; import { isFunction } from "util/common"; -import type { Ref } from "vue"; +import type { ComputedRef, MaybeRef, Ref, UnwrapRef } from "vue"; import { computed } from "vue"; -export const DoNotCache = Symbol("DoNotCache"); +export type ProcessedRefOrGetter = T extends () => infer S + ? Ref + : T extends undefined + ? undefined + : MaybeRef>>; -export type Computable = T | Ref | (() => T); -export type ProcessedComputable = T | Ref; -export type GetComputableType = T extends { [DoNotCache]: true } - ? T - : T extends () => infer S - ? Ref - : undefined extends T - ? undefined - : T; -export type GetComputableTypeWithDefault = undefined extends T - ? S - : GetComputableType>; -export type UnwrapComputableType = T extends Ref ? S : T extends () => infer S ? S : T; - -export type ComputableKeysOf = Pick< - T, - { - [K in keyof T]: T[K] extends Computable ? K : never; - }[keyof T] ->; - -// TODO fix the typing of this function, such that casting isn't necessary and can be used to -// detect if a createX function is validly written -export function processComputable>( - obj: T, - key: S -): asserts obj is T & { [K in S]: ProcessedComputable> } { - const computable = obj[key]; - if ( - isFunction(computable) && - computable.length === 0 && - !(computable as unknown as JSXFunction)[DoNotCache] - ) { - // 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) as unknown as T[S]; - (obj[key] as unknown as JSXFunction)[DoNotCache] = true; +export function processGetter(obj: T): T extends () => infer S ? ComputedRef : T { + if (isFunction(obj)) { + return computed(obj) as ReturnType>; } -} - -export function convertComputable(obj: Computable): ProcessedComputable { - if (isFunction(obj) && !(obj as unknown as JSXFunction)[DoNotCache]) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - obj = computed(obj); - } - return obj as ProcessedComputable; + return obj as ReturnType>; } diff --git a/src/util/galaxy.ts b/src/util/galaxy.ts index b2a4d04..b59ead5 100644 --- a/src/util/galaxy.ts +++ b/src/util/galaxy.ts @@ -82,7 +82,7 @@ function syncSaves( const saves = ( Object.keys(list) .map(slot => { - const { label, content } = list[slot as unknown as number]; + const { label, content } = list[parseInt(slot)]; try { return { slot: parseInt(slot), diff --git a/src/util/proxies.ts b/src/util/proxies.ts index 7476ba1..8447931 100644 --- a/src/util/proxies.ts +++ b/src/util/proxies.ts @@ -33,9 +33,9 @@ export type Proxied = // Takes a function that returns an object and pretends to be that object // Note that the object is lazily calculated export function createLazyProxy( - objectFunc: (this: S, baseObject: S) => T & S, + objectFunc: (this: S, baseObject: S) => T, baseObject: S = {} as S -): T { +): T & S { const obj: S & Partial = baseObject; let calculated = false; let calculating = false; diff --git a/src/util/save.ts b/src/util/save.ts index cb5c9b9..806c3fe 100644 --- a/src/util/save.ts +++ b/src/util/save.ts @@ -106,7 +106,7 @@ export async function loadSave(playerObj: Partial): Promise { for (const layer in layers) { const l = layers[layer]; - if (l) { + if (l != null) { removeLayer(l); } } diff --git a/src/util/vue.tsx b/src/util/vue.tsx index 1e2afb0..b59605d 100644 --- a/src/util/vue.tsx +++ b/src/util/vue.tsx @@ -4,122 +4,119 @@ // only apply to SFCs import Col from "components/layout/Column.vue"; import Row from "components/layout/Row.vue"; -import type { CoercableComponent, GenericComponent, JSXFunction } from "features/feature"; -import { - Component as ComponentKey, - GatherProps, - Visibility, - isVisible, - jsx -} from "features/feature"; -import type { ProcessedComputable } from "util/computed"; -import { DoNotCache } from "util/computed"; -import type { Component, DefineComponent, Ref, ShallowRef, UnwrapRef } from "vue"; -import { - computed, - defineComponent, - isRef, - onUnmounted, - ref, - shallowRef, - unref, - watchEffect -} from "vue"; +import { getUniqueID, Visibility } from "features/feature"; +import VueFeatureComponent from "features/VueFeature.vue"; +import { processGetter } from "util/computed"; +import type { CSSProperties, MaybeRef, MaybeRefOrGetter, Ref } from "vue"; +import { isRef, onUnmounted, ref, unref } from "vue"; import { JSX } from "vue/jsx-runtime"; import { camelToKebab } from "./common"; -export function coerceComponent( - component: CoercableComponent, - defaultWrapper = "span" -): DefineComponent { - if (typeof component === "function") { - return defineComponent({ render: component }); - } - if (typeof component === "string") { - if (component.length > 0) { - component = component.trim(); - if (component.charAt(0) !== "<") { - component = `<${defaultWrapper}>${component}`; - } +export const VueFeature = Symbol("VueFeature"); - return defineComponent({ template: component }); - } - return defineComponent({ render: () => ({}) }); - } - return component; +export type Renderable = JSX.Element | string; + +export interface VueFeatureOptions { + /** Whether this feature should be visible. */ + visibility?: MaybeRefOrGetter; + /** Dictionary of CSS classes to apply to this feature. */ + classes?: MaybeRefOrGetter>; + /** CSS to apply to this feature. */ + style?: MaybeRefOrGetter; } export interface VueFeature { - [ComponentKey]: GenericComponent; - [GatherProps]: () => Record; + /** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */ + id: string; + /** Whether this feature should be visible. */ + visibility?: MaybeRef; + /** Dictionary of CSS classes to apply to this feature. */ + classes?: MaybeRef>; + /** CSS to apply to this feature. */ + style?: MaybeRef; + /** The components to render inside the vue feature */ + components: MaybeRef[]; + /** The components to render wrapped around the vue feature */ + wrappers: ((el: () => Renderable) => Renderable)[]; + /** Used to identify Vue Features */ + [VueFeature]: true; } -export function render(object: VueFeature | CoercableComponent): JSX.Element | DefineComponent { - if (isCoercableComponent(object)) { - if (typeof object === "function") { - return (object as JSXFunction)(); - } - return coerceComponent(object); +export function vueFeatureMixin( + featureName: string, + options: VueFeatureOptions, + component?: MaybeRefOrGetter +) { + return { + id: getUniqueID(featureName), + visibility: processGetter(options.visibility), + classes: processGetter(options.classes), + style: processGetter(options.style), + components: component == null ? [] : [processGetter(component)], + wrappers: [] as ((el: () => Renderable) => Renderable)[], + [VueFeature]: true + } satisfies VueFeature; +} + +export function render(object: VueFeature, wrapper?: (el: Renderable) => Renderable): JSX.Element; +export function render( + object: MaybeRef, + wrapper?: (el: Renderable) => T +): T; +export function render( + object: VueFeature | MaybeRef, + wrapper?: (el: Renderable) => Renderable +): Renderable; +export function render( + object: VueFeature | MaybeRef, + wrapper?: (el: Renderable) => Renderable +) { + if (typeof object === "object" && VueFeature in object) { + const { id, visibility, style, classes, components, wrappers } = object; + return ( + + ); } - const Component = object[ComponentKey]; - return ; + + object = unref(object); + return wrapper?.(object) ?? object; } -export function renderRow(...objects: (VueFeature | CoercableComponent)[]): JSX.Element { - return {objects.map(render)}; +export function renderRow(...objects: (VueFeature | MaybeRef)[]): JSX.Element { + return {objects.map(obj => render(obj))}; } -export function renderCol(...objects: (VueFeature | CoercableComponent)[]): JSX.Element { - return {objects.map(render)}; +export function renderCol(...objects: (VueFeature | MaybeRef)[]): JSX.Element { + return {objects.map(obj => render(obj))}; } -export function renderJSX(object: VueFeature | CoercableComponent): JSX.Element { - if (isCoercableComponent(object)) { - if (typeof object === "function") { - return (object as JSXFunction)(); - } - if (typeof object === "string") { - return <>{object}; - } - // TODO why is object typed as never? - const Comp = object as DefineComponent; - return ; - } - const Component = object[ComponentKey]; - return ; +export function joinJSX( + objects: (VueFeature | MaybeRef)[], + joiner: JSX.Element +): JSX.Element { + return objects.reduce( + (acc, curr) => ( + <> + {acc} + {joiner} + {render(curr)} + + ), + <> + ); } -export function renderRowJSX(...objects: (VueFeature | CoercableComponent)[]): JSX.Element { - return {objects.map(renderJSX)}; -} - -export function renderColJSX(...objects: (VueFeature | CoercableComponent)[]): JSX.Element { - return {objects.map(renderJSX)}; -} - -export function joinJSX(objects: JSX.Element[], joiner: JSX.Element): JSX.Element { - return objects.reduce((acc, curr) => ( - <> - {acc} - {joiner} - {curr} - - )); -} - -export function isCoercableComponent(component: unknown): component is CoercableComponent { - if (typeof component === "string") { - return true; - } else if (typeof component === "object") { - if (component == null) { - return false; - } - return "render" in component || "component" in component; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } else if (typeof component === "function" && (component as any)[DoNotCache] === true) { - return true; - } - return false; +export function isJSXElement(element: unknown): element is JSX.Element { + return ( + element != null && typeof element === "object" && "type" in element && "children" in element + ); } export function setupHoldToClick( @@ -158,61 +155,6 @@ export function setupHoldToClick( return { start, stop, handleHolding }; } -export function getFirstFeature< - T extends VueFeature & { visibility: ProcessedComputable } ->( - features: T[], - filter: (feature: T) => boolean -): { - firstFeature: Ref; - collapsedContent: JSXFunction; - hasCollapsedContent: Ref; -} { - const filteredFeatures = computed(() => - features.filter(feature => isVisible(feature.visibility) && filter(feature)) - ); - return { - firstFeature: computed(() => filteredFeatures.value[0]), - collapsedContent: jsx(() => renderCol(...filteredFeatures.value.slice(1))), - hasCollapsedContent: computed(() => filteredFeatures.value.length > 1) - }; -} - -export function computeComponent( - component: Ref, - defaultWrapper = "div" -): ShallowRef { - const comp = shallowRef(); - watchEffect(() => { - comp.value = coerceComponent(unref(component), defaultWrapper); - }); - return comp as ShallowRef; -} -export function computeOptionalComponent( - component: Ref, - defaultWrapper = "div" -): ShallowRef { - const comp = shallowRef(null); - watchEffect(() => { - const currComponent = unref(component); - comp.value = - currComponent === "" || currComponent == null - ? null - : coerceComponent(currComponent, defaultWrapper); - }); - return comp; -} - -export function deepUnref(refObject: T): { [K in keyof T]: UnwrapRef } { - return (Object.keys(refObject) as (keyof T)[]).reduce( - (acc, curr) => { - acc[curr] = unref(refObject[curr]) as UnwrapRef; - return acc; - }, - {} as { [K in keyof T]: UnwrapRef } - ); -} - export function setRefValue(ref: Ref>, value: T) { if (isRef(ref.value)) { ref.value.value = value; @@ -232,12 +174,10 @@ export type PropTypes = export function trackHover(element: VueFeature): Ref { const isHovered = ref(false); - const elementGatherProps = element[GatherProps].bind(element); - element[GatherProps] = () => ({ - ...elementGatherProps(), - onPointerenter: () => (isHovered.value = true), - onPointerleave: () => (isHovered.value = false) - }); + (element as unknown as { onPointerenter: VoidFunction }).onPointerenter = () => + (isHovered.value = true); + (element as unknown as { onPointerleave: VoidFunction }).onPointerleave = () => + (isHovered.value = true); return isHovered; } diff --git a/src/components/MarkNode.vue b/src/wrappers/marks/MarkNode.vue similarity index 81% rename from src/components/MarkNode.vue rename to src/wrappers/marks/MarkNode.vue index c730279..98f5218 100644 --- a/src/components/MarkNode.vue +++ b/src/wrappers/marks/MarkNode.vue @@ -1,12 +1,13 @@