Merge remote-tracking branch 'template/feature/feat-and-board-rewrite' into feat/board-feature-rewrite

This commit is contained in:
thepaperpilot 2024-12-03 22:11:01 -06:00
parent 83d41428eb
commit 4ce1b60a3d
53 changed files with 4663 additions and 7541 deletions

126
package-lock.json generated
View file

@ -27,7 +27,7 @@
"vite": "^5.1.8", "vite": "^5.1.8",
"vite-plugin-pwa": "^0.20.5", "vite-plugin-pwa": "^0.20.5",
"vite-tsconfig-paths": "^4.3.0", "vite-tsconfig-paths": "^4.3.0",
"vue": "^3.5.12", "vue": "^3.5.13",
"vue-next-select": "^2.10.5", "vue-next-select": "^2.10.5",
"vue-panzoom": "https://github.com/thepaperpilot/vue-panzoom.git", "vue-panzoom": "https://github.com/thepaperpilot/vue-panzoom.git",
"vue-textarea-autosize": "^1.1.1", "vue-textarea-autosize": "^1.1.1",
@ -45,7 +45,7 @@
"eslint": "^8.57.0", "eslint": "^8.57.0",
"jsdom": "^24.0.0", "jsdom": "^24.0.0",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"typescript": "^5.4.2", "typescript": "~5.5.4",
"vitest": "^1.4.0", "vitest": "^1.4.0",
"vue-tsc": "^2.0.6" "vue-tsc": "^2.0.6"
}, },
@ -2497,49 +2497,49 @@
} }
}, },
"node_modules/@vue/compiler-core": { "node_modules/@vue/compiler-core": {
"version": "3.5.12", "version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.12.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz",
"integrity": "sha512-ISyBTRMmMYagUxhcpyEH0hpXRd/KqDU4ymofPgl2XAkY9ZhQ+h0ovEZJIiPop13UmR/54oA2cgMDjgroRelaEw==", "integrity": "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==",
"dependencies": { "dependencies": {
"@babel/parser": "^7.25.3", "@babel/parser": "^7.25.3",
"@vue/shared": "3.5.12", "@vue/shared": "3.5.13",
"entities": "^4.5.0", "entities": "^4.5.0",
"estree-walker": "^2.0.2", "estree-walker": "^2.0.2",
"source-map-js": "^1.2.0" "source-map-js": "^1.2.0"
} }
}, },
"node_modules/@vue/compiler-dom": { "node_modules/@vue/compiler-dom": {
"version": "3.5.12", "version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.12.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz",
"integrity": "sha512-9G6PbJ03uwxLHKQ3P42cMTi85lDRvGLB2rSGOiQqtXELat6uI4n8cNz9yjfVHRPIu+MsK6TE418Giruvgptckg==", "integrity": "sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==",
"dependencies": { "dependencies": {
"@vue/compiler-core": "3.5.12", "@vue/compiler-core": "3.5.13",
"@vue/shared": "3.5.12" "@vue/shared": "3.5.13"
} }
}, },
"node_modules/@vue/compiler-sfc": { "node_modules/@vue/compiler-sfc": {
"version": "3.5.12", "version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.12.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz",
"integrity": "sha512-2k973OGo2JuAa5+ZlekuQJtitI5CgLMOwgl94BzMCsKZCX/xiqzJYzapl4opFogKHqwJk34vfsaKpfEhd1k5nw==", "integrity": "sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==",
"dependencies": { "dependencies": {
"@babel/parser": "^7.25.3", "@babel/parser": "^7.25.3",
"@vue/compiler-core": "3.5.12", "@vue/compiler-core": "3.5.13",
"@vue/compiler-dom": "3.5.12", "@vue/compiler-dom": "3.5.13",
"@vue/compiler-ssr": "3.5.12", "@vue/compiler-ssr": "3.5.13",
"@vue/shared": "3.5.12", "@vue/shared": "3.5.13",
"estree-walker": "^2.0.2", "estree-walker": "^2.0.2",
"magic-string": "^0.30.11", "magic-string": "^0.30.11",
"postcss": "^8.4.47", "postcss": "^8.4.48",
"source-map-js": "^1.2.0" "source-map-js": "^1.2.0"
} }
}, },
"node_modules/@vue/compiler-ssr": { "node_modules/@vue/compiler-ssr": {
"version": "3.5.12", "version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.12.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz",
"integrity": "sha512-eLwc7v6bfGBSM7wZOGPmRavSWzNFF6+PdRhE+VFJhNCgHiF8AM7ccoqcv5kBXA2eWUfigD7byekvf/JsOfKvPA==", "integrity": "sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==",
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.12", "@vue/compiler-dom": "3.5.13",
"@vue/shared": "3.5.12" "@vue/shared": "3.5.13"
} }
}, },
"node_modules/@vue/compiler-vue2": { "node_modules/@vue/compiler-vue2": {
@ -2615,49 +2615,49 @@
} }
}, },
"node_modules/@vue/reactivity": { "node_modules/@vue/reactivity": {
"version": "3.5.12", "version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.12.tgz", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.13.tgz",
"integrity": "sha512-UzaN3Da7xnJXdz4Okb/BGbAaomRHc3RdoWqTzlvd9+WBR5m3J39J1fGcHes7U3za0ruYn/iYy/a1euhMEHvTAg==", "integrity": "sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==",
"dependencies": { "dependencies": {
"@vue/shared": "3.5.12" "@vue/shared": "3.5.13"
} }
}, },
"node_modules/@vue/runtime-core": { "node_modules/@vue/runtime-core": {
"version": "3.5.12", "version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.12.tgz", "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.13.tgz",
"integrity": "sha512-hrMUYV6tpocr3TL3Ad8DqxOdpDe4zuQY4HPY3X/VRh+L2myQO8MFXPAMarIOSGNu0bFAjh1yBkMPXZBqCk62Uw==", "integrity": "sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==",
"dependencies": { "dependencies": {
"@vue/reactivity": "3.5.12", "@vue/reactivity": "3.5.13",
"@vue/shared": "3.5.12" "@vue/shared": "3.5.13"
} }
}, },
"node_modules/@vue/runtime-dom": { "node_modules/@vue/runtime-dom": {
"version": "3.5.12", "version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.12.tgz", "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.13.tgz",
"integrity": "sha512-q8VFxR9A2MRfBr6/55Q3umyoN7ya836FzRXajPB6/Vvuv0zOPL+qltd9rIMzG/DbRLAIlREmnLsplEF/kotXKA==", "integrity": "sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==",
"dependencies": { "dependencies": {
"@vue/reactivity": "3.5.12", "@vue/reactivity": "3.5.13",
"@vue/runtime-core": "3.5.12", "@vue/runtime-core": "3.5.13",
"@vue/shared": "3.5.12", "@vue/shared": "3.5.13",
"csstype": "^3.1.3" "csstype": "^3.1.3"
} }
}, },
"node_modules/@vue/server-renderer": { "node_modules/@vue/server-renderer": {
"version": "3.5.12", "version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.12.tgz", "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.13.tgz",
"integrity": "sha512-I3QoeDDeEPZm8yR28JtY+rk880Oqmj43hreIBVTicisFTx/Dl7JpG72g/X7YF8hnQD3IFhkky5i2bPonwrTVPg==", "integrity": "sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==",
"dependencies": { "dependencies": {
"@vue/compiler-ssr": "3.5.12", "@vue/compiler-ssr": "3.5.13",
"@vue/shared": "3.5.12" "@vue/shared": "3.5.13"
}, },
"peerDependencies": { "peerDependencies": {
"vue": "3.5.12" "vue": "3.5.13"
} }
}, },
"node_modules/@vue/shared": { "node_modules/@vue/shared": {
"version": "3.5.12", "version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.12.tgz", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz",
"integrity": "sha512-L2RPSAwUFbgZH20etwrXyVyCBu9OxRSi8T/38QsvnkJyvq2LufW2lDCOzm7t/U9C1mkhJGWYfCuFBCmIuNivrg==" "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ=="
}, },
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.13.0", "version": "8.13.0",
@ -5693,9 +5693,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.4.47", "version": "8.4.49",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
"integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@ -5712,7 +5712,7 @@
], ],
"dependencies": { "dependencies": {
"nanoid": "^3.3.7", "nanoid": "^3.3.7",
"picocolors": "^1.1.0", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
"engines": { "engines": {
@ -6853,9 +6853,9 @@
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.6.3", "version": "5.5.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
"devOptional": true, "devOptional": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@ -7233,15 +7233,15 @@
"dev": true "dev": true
}, },
"node_modules/vue": { "node_modules/vue": {
"version": "3.5.12", "version": "3.5.13",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.12.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz",
"integrity": "sha512-CLVZtXtn2ItBIi/zHZ0Sg1Xkb7+PU32bJJ8Bmy7ts3jxXTcbfsEfBivFYYWz1Hur+lalqGAh65Coin0r+HRUfg==", "integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==",
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.12", "@vue/compiler-dom": "3.5.13",
"@vue/compiler-sfc": "3.5.12", "@vue/compiler-sfc": "3.5.13",
"@vue/runtime-dom": "3.5.12", "@vue/runtime-dom": "3.5.13",
"@vue/server-renderer": "3.5.12", "@vue/server-renderer": "3.5.13",
"@vue/shared": "3.5.12" "@vue/shared": "3.5.13"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "*" "typescript": "*"

View file

@ -34,7 +34,7 @@
"vite": "^5.1.8", "vite": "^5.1.8",
"vite-plugin-pwa": "^0.20.5", "vite-plugin-pwa": "^0.20.5",
"vite-tsconfig-paths": "^4.3.0", "vite-tsconfig-paths": "^4.3.0",
"vue": "^3.5.12", "vue": "^3.5.13",
"vue-next-select": "^2.10.5", "vue-next-select": "^2.10.5",
"vue-panzoom": "https://github.com/thepaperpilot/vue-panzoom.git", "vue-panzoom": "https://github.com/thepaperpilot/vue-panzoom.git",
"vue-textarea-autosize": "^1.1.1", "vue-textarea-autosize": "^1.1.1",
@ -52,7 +52,7 @@
"eslint": "^8.57.0", "eslint": "^8.57.0",
"jsdom": "^24.0.0", "jsdom": "^24.0.0",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"typescript": "^5.4.2", "typescript": "~5.5.4",
"vitest": "^1.4.0", "vitest": "^1.4.0",
"vue-tsc": "^2.0.6" "vue-tsc": "^2.0.6"
}, },

View file

@ -14,6 +14,10 @@ button.feature,
transition: all 0.5s, z-index 0s 0.5s; transition: all 0.5s, z-index 0s 0.5s;
} }
.feature button {
position: relative;
}
button.can, button.can,
.can button { .can button {
background-color: var(--layer-color); background-color: var(--layer-color);

View file

@ -174,39 +174,3 @@
.row.mergeAdjacent > .table:last-child > .col.mergeAdjacent > :first-child .feature button { .row.mergeAdjacent > .table:last-child > .col.mergeAdjacent > :first-child .feature button {
border-radius: 0 0 0 var(--border-radius); border-radius: 0 0 0 var(--border-radius);
} }
.row-grid.mergeAdjacent > .feature:not(.dontMerge),
.row-grid.mergeAdjacent > .tooltip-container > .feature:not(.dontMerge) {
margin-left: 0;
margin-right: 0;
margin-bottom: 0;
margin-top: 0;
border-radius: 0;
}
.row-grid.mergeAdjacent > .feature:not(.dontMerge):last-child,
.row-grid.mergeAdjacent > .tooltip-container:last-child > .feature:not(.dontMerge) {
border-radius: 0 0 0 0;
}
.row-grid.mergeAdjacent > .feature:not(.dontMerge):first-child,
.row-grid.mergeAdjacent > .tooltip-container:first-child > .feature:not(.dontMerge) {
border-radius: 0 0 0 0;
}
.table-grid > .row-grid.mergeAdjacent:last-child > .feature:not(.dontMerge):first-child {
border-radius: 0 0 0 var(--border-radius);
}
.table-grid > .row-grid.mergeAdjacent:first-child > .feature:not(.dontMerge):last-child {
border-radius: 0 var(--border-radius) 0 0;
}
.table-grid > .row-grid.mergeAdjacent:first-child > .feature:not(.dontMerge):first-child {
border-radius: var(--border-radius) 0 0 0;
}
.table-grid > .row-grid.mergeAdjacent:last-child > .feature:not(.dontMerge):last-child {
border-radius: 0 0 var(--border-radius) 0;
}

View file

@ -18,12 +18,18 @@
updates! updates!
</div> </div>
<br /> <br />
<div> <div v-if="discordLink && discordName">
<a :href="discordLink" class="game-over-modal-discord-link"> <a :href="discordLink" class="game-over-modal-discord-link">
<span class="material-icons game-over-modal-discord">discord</span> <span class="material-icons game-over-modal-discord">discord</span>
{{ discordName }} {{ discordName }}
</a> </a>
</div> </div>
<div v-else>
<a href="https://discord.gg/yJ4fjnjU54" class="game-over-modal-discord-link">
<span class="material-icons game-over-modal-discord">discord</span>
Profectus & Friends
</a>
</div>
<Toggle title="Autosave" v-model="autosave" /> <Toggle title="Autosave" v-model="autosave" />
</div> </div>
</template> </template>

View file

@ -3,7 +3,7 @@ import { Achievement } from "features/achievements/achievement";
import type { Clickable, ClickableOptions } from "features/clickables/clickable"; import type { Clickable, ClickableOptions } from "features/clickables/clickable";
import { createClickable } from "features/clickables/clickable"; import { createClickable } from "features/clickables/clickable";
import { Conversion } from "features/conversion"; import { Conversion } from "features/conversion";
import { getFirstFeature, type OptionsFunc, type Replace } from "features/feature"; import { getFirstFeature } from "features/feature";
import { displayResource, Resource } from "features/resources/resource"; import { displayResource, Resource } from "features/resources/resource";
import type { Tree, TreeNode, TreeNodeOptions } from "features/trees/tree"; import type { Tree, TreeNode, TreeNodeOptions } from "features/trees/tree";
import { createTreeNode } from "features/trees/tree"; import { createTreeNode } from "features/trees/tree";
@ -21,6 +21,7 @@ import { processGetter } from "util/computed";
import { render, Renderable, renderCol } from "util/vue"; import { render, Renderable, renderCol } from "util/vue";
import type { ComputedRef, MaybeRef, MaybeRefOrGetter } from "vue"; import type { ComputedRef, MaybeRef, MaybeRefOrGetter } from "vue";
import { computed, ref, unref } from "vue"; import { computed, ref, unref } from "vue";
import { JSX } from "vue/jsx-runtime";
import "./common.css"; import "./common.css";
/** An object that configures a {@link ResetButton} */ /** An object that configures a {@link ResetButton} */
@ -61,24 +62,37 @@ export interface ResetButtonOptions extends ClickableOptions {
* It will show how much can be converted currently, and can show when that amount will go up, as well as handle only being clickable when a sufficient amount of currency can be gained. * It will show how much can be converted currently, and can show when that amount will go up, as well as handle only being clickable when a sufficient amount of currency can be gained.
* Assumes this button is associated with a specific node on a tree, and triggers that tree's reset propagation. * Assumes this button is associated with a specific node on a tree, and triggers that tree's reset propagation.
*/ */
export type ResetButton = Replace< export interface ResetButton extends Clickable {
Clickable, /** The conversion the button uses to calculate how much resources will be gained on click */
{ conversion: Conversion;
resetDescription: MaybeRef<string>; /** The tree this reset button is apart of */
showNextAt: MaybeRef<boolean>; tree: Tree;
minimumGain: MaybeRef<DecimalSource>; /** The specific tree node associated with this reset button */
} treeNode: TreeNode;
>; /**
* Text to display on low conversion amounts, describing what "resetting" is in this context.
* Defaults to "Reset for ".
*/
resetDescription?: MaybeRef<string>;
/** Whether or not to show how much currency would be required to make the gain amount increase. */
showNextAt?: MaybeRef<boolean>;
/**
* When {@link canClick} is left to its default, minimumGain is used to only enable the reset button when a sufficient amount of currency to gain is available.
*/
minimumGain?: MaybeRef<DecimalSource>;
/** A persistent ref to track how much time has passed since the last time this tree node was reset. */
resetTime?: Persistent<DecimalSource>;
}
/** /**
* Lazily creates a reset button with the given options. * Lazily creates a reset button with the given options.
* @param optionsFunc A function that returns the options object for this reset button. * @param optionsFunc A function that returns the options object for this reset button.
*/ */
export function createResetButton<T extends ClickableOptions & ResetButtonOptions>( export function createResetButton<T extends ClickableOptions & ResetButtonOptions>(
optionsFunc: OptionsFunc<T> optionsFunc: () => T
) { ) {
const resetButton = createClickable(feature => { const resetButton = createClickable(() => {
const options = optionsFunc.call(feature, feature); const options = optionsFunc();
const { const {
conversion, conversion,
tree, tree,
@ -113,41 +127,43 @@ export function createResetButton<T extends ClickableOptions & ResetButtonOption
), ),
display: display:
processGetter(display) ?? processGetter(display) ??
computed(() => ( computed(
<span> (): JSX.Element => (
{unref(resetButton.resetDescription)} <span>
<b> {unref(resetButton.resetDescription)}
{displayResource( <b>
conversion.gainResource,
Decimal.max(
unref(conversion.actualGain),
unref(resetButton.minimumGain)
)
)}
</b>{" "}
{conversion.gainResource.displayName}
{unref(resetButton.showNextAt as MaybeRef<boolean>) != null ? (
<div>
<br />
{unref<boolean>(conversion.buyMax) ? "Next:" : "Req:"}{" "}
{displayResource( {displayResource(
conversion.baseResource, conversion.gainResource,
!unref<boolean>(conversion.buyMax) && Decimal.max(
Decimal.gte(unref(conversion.actualGain), 1) unref(conversion.actualGain),
? unref(conversion.currentAt) unref(resetButton.minimumGain)
: unref(conversion.nextAt) )
)}{" "} )}
{conversion.baseResource.displayName} </b>{" "}
</div> {conversion.gainResource.displayName}
) : null} {unref(resetButton.showNextAt) != null ? (
</span> <div>
)), <br />
onClick: function (e) { {unref(conversion.buyMax) ? "Next:" : "Req:"}{" "}
{displayResource(
conversion.baseResource,
!unref<boolean>(conversion.buyMax) &&
Decimal.gte(unref(conversion.actualGain), 1)
? unref(conversion.currentAt)
: unref(conversion.nextAt)
)}{" "}
{conversion.baseResource.displayName}
</div>
) : null}
</span>
)
),
onClick: function (e?: MouseEvent | TouchEvent) {
if (unref(resetButton.canClick) === false) { if (unref(resetButton.canClick) === false) {
return; return;
} }
conversion.convert(); conversion.convert();
tree.reset(resetButton.treeNode); tree.reset(treeNode);
if (resetTime) { if (resetTime) {
resetTime.value = resetTime[DefaultValue]; resetTime.value = resetTime[DefaultValue];
} }
@ -173,21 +189,23 @@ export interface LayerTreeNodeOptions extends TreeNodeOptions {
} }
/** A tree node that is associated with a given layer, and which opens the layer when clicked. */ /** A tree node that is associated with a given layer, and which opens the layer when clicked. */
export type LayerTreeNode = Replace< export interface LayerTreeNode extends TreeNode {
TreeNode, /** The ID of the layer this tree node is associated with */
{ layerID: string;
layerID: string; /** Whether or not to append the layer to the tabs list.
append: MaybeRef<boolean>; * If set to false, then the tree node will instead always remove all tabs to its right and then add the layer tab.
} * Defaults to true.
>; */
append?: MaybeRef<boolean>;
}
/** /**
* Lazily creates a tree node that's associated with a specific layer, with the given options. * Lazily creates a tree node that's associated with a specific layer, with the given options.
* @param optionsFunc A function that returns the options object for this tree node. * @param optionsFunc A function that returns the options object for this tree node.
*/ */
export function createLayerTreeNode<T extends LayerTreeNodeOptions>(optionsFunc: OptionsFunc<T>) { export function createLayerTreeNode<T extends LayerTreeNodeOptions>(optionsFunc: () => T) {
const layerTreeNode = createTreeNode(feature => { const layerTreeNode = createTreeNode(() => {
const options = optionsFunc.call(feature, feature); const options = optionsFunc();
const { display, append, layerID, ...props } = options; const { display, append, layerID, ...props } = options;
return { return {

424
src/data/layers/board.tsx Normal file
View file

@ -0,0 +1,424 @@
import { createUpgrade } from "features/clickables/upgrade";
import { createResource } from "features/resources/resource";
import Board from "game/boards/Board.vue";
import CircleProgress from "game/boards/CircleProgress.vue";
import SVGNode from "game/boards/SVGNode.vue";
import SquareProgress from "game/boards/SquareProgress.vue";
import {
Draggable,
MakeDraggableOptions,
NodePosition,
makeDraggable,
placeInAvailableSpace,
setupActions,
setupDraggableNode,
setupUniqueIds
} from "game/boards/board";
import type { BaseLayer } from "game/layers";
import { createLayer } from "game/layers";
import { persistent } from "game/persistence";
import { createCostRequirement } from "game/requirements";
import { render } from "util/vue";
import { ComponentPublicInstance, computed, ref, watch } from "vue";
import { setupSelectable } from "../common";
const board = createLayer("board", function (this: BaseLayer) {
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 & { draggable: Draggable<number | "cNode"> };
type NodeTypes = ANode | BNode;
const board = ref<ComponentPublicInstance<typeof Board>>();
const { select, deselect, selected } = setupSelectable<number>();
const {
select: selectAction,
deselect: deselectAction,
selected: selectedAction
} = setupSelectable<number>();
watch(selected, selected => {
if (selected == null) {
deselectAction();
}
});
const {
startDrag,
endDrag,
drag,
nodeBeingDragged,
hasDragged,
receivingNodes,
receivingNode,
dragDelta
} = setupDraggableNode<number | "cnode">({
board,
getPosition(id) {
return nodesById.value[id] ?? (cNode as CNode).draggable.position.value;
},
setPosition(id, position) {
const node = nodesById.value[id] ?? (cNode as CNode).draggable.position.value;
node.x = position.x;
node.y = position.y;
}
});
// 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<(ANode | BNode)[]>([
{ id: 0, x: 0, y: 0, z: 0, links: [], type: "anode" }
]);
const nodesById = computed<Record<string, NodeTypes>>(() =>
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);
}
deselect();
}
function mouseUpNode(e: MouseEvent | TouchEvent, node: NodeTypes) {
if (!hasDragged.value) {
endDrag();
if (typeof node.id === "number") {
select(node.id);
}
e.stopPropagation();
}
}
function translate(node: NodePosition, isDragging: boolean) {
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 rotate(rotation: number) {
return ` rotate(${rotation}deg) `;
}
function scale(nodeOrBool: NodeTypes | boolean) {
const isSelected =
typeof nodeOrBool === "boolean" ? nodeOrBool : selected.value === nodeOrBool.id;
return isSelected ? " scale(1.2)" : "";
}
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 (
<SVGNode
style={`transform: ${translate(node, nodeBeingDragged.value === node.id)}${opacity(
node
)}${zIndex(node)}`}
onMouseDown={e => mouseDownNode(e, node)}
onMouseUp={e => mouseUpNode(e, node)}
>
<g style={`transform: ${scale(node)}`}>
{receivingNodes.value.includes(node.id) && (
<circle
r="58"
fill="var(--background)"
stroke={receivingNode.value === node.id ? "#0F0" : "#0F03"}
stroke-width="2"
/>
)}
<CircleProgress r={54.5} progress={0.5} stroke="var(--accent2)" />
<circle
r="50"
fill="var(--raised-background)"
stroke="var(--outline)"
stroke-width="4"
/>
</g>
{selected.value === node.id && selectedAction.value === 0 && (
<text y="140" fill="var(--foreground)" class="node-text">
Spawn B Node
</text>
)}
<text fill="var(--foreground)" class="node-text">
A
</text>
</SVGNode>
);
};
const aActions = setupActions({
node: () => nodesById.value[selected.value ?? ""],
shouldShowActions: node => node.type === "anode",
actions(node) {
return [
p => (
<g
style={`transform: ${translate(p, selectedAction.value === 0)}${scale(
selectedAction.value === 0
)}`}
onClick={() => {
if (selectedAction.value === 0) {
spawnBNode(node as ANode);
} else {
selectAction(0);
}
}}
>
<circle fill="black" r="20"></circle>
<text fill="white" class="material-icons" x="-12" y="12">
add
</text>
</g>
)
];
},
distance: 100
});
const sqrtTwo = Math.sqrt(2);
const renderBNode = function (node: BNode) {
return (
<SVGNode
style={`transform: ${translate(node, nodeBeingDragged.value === node.id)}${opacity(
node
)}${zIndex(node)}`}
onMouseDown={e => mouseDownNode(e, node)}
onMouseUp={e => mouseUpNode(e, node)}
>
<g style={`transform: ${scale(node)}${rotate(45)}`}>
{receivingNodes.value.includes(node.id) && (
<rect
width={50 * sqrtTwo + 16}
height={50 * sqrtTwo + 16}
style={`translate(${(-50 * sqrtTwo + 16) / 2}, ${
(-50 * sqrtTwo + 16) / 2
})`}
fill="var(--background)"
stroke={receivingNode.value === node.id ? "#0F0" : "#0F03"}
stroke-width="2"
/>
)}
<SquareProgress
size={50 * sqrtTwo + 9}
progress={0.5}
stroke="var(--accent2)"
/>
<rect
width={50 * sqrtTwo}
height={50 * sqrtTwo}
style={`transform: translate(${(-50 * sqrtTwo) / 2}px, ${
(-50 * sqrtTwo) / 2
}px)`}
fill="var(--raised-background)"
stroke="var(--outline)"
stroke-width="4"
/>
</g>
{selected.value === node.id && selectedAction.value === 0 && (
<text y="140" fill="var(--foreground)" class="node-text">
Spawn A Node
</text>
)}
<text fill="var(--foreground)" class="node-text">
B
</text>
</SVGNode>
);
};
const bActions = setupActions({
node: () => nodesById.value[selected.value ?? ""],
shouldShowActions: node => node.type === "bnode",
actions(node) {
return [
p => (
<g
style={`transform: ${translate(p, selectedAction.value === 0)}${scale(
selectedAction.value === 0
)}`}
onClick={() => {
if (selectedAction.value === 0) {
spawnANode(node as BNode);
} else {
selectAction(0);
}
}}
>
<circle fill="white" r="20"></circle>
<text fill="black" class="material-icons" x="-12" y="12">
add
</text>
</g>
)
];
},
distance: 100
});
function spawnANode(parent: ANode | BNode) {
const node: ANode = {
x: parent.x,
y: parent.y,
z: nextId.value,
type: "anode",
links: [parent.id],
id: nextId.value
};
placeInAvailableSpace(node, nodes.value);
nodes.value.push(node);
}
function spawnBNode(parent: ANode | BNode) {
const node: BNode = {
x: parent.x,
y: parent.y,
z: nextId.value,
type: "bnode",
links: [parent.id],
id: nextId.value
};
placeInAvailableSpace(node, nodes.value);
nodes.value.push(node);
}
const points = createResource(10);
const cNode = createUpgrade(() => ({
display: <h1>C</h1>,
// Purposefully not using noPersist
requirements: createCostRequirement(() => ({ cost: 10, resource: points })),
style: {
x: "100px",
y: "100px",
"--layer-color": "var(--accent1)"
},
// no-op to prevent purchasing while dragging
onHold: () => {}
}));
makeDraggable<number | "cnode", MakeDraggableOptions<number | "cnode">>(cNode, () => ({
id: "cnode",
endDrag,
startDrag,
hasDragged,
nodeBeingDragged,
dragDelta,
onMouseUp: cNode.purchase
}));
const dNodesPerAxis = 50;
const dNodes = (
<>
{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 (
<path
fill="var(--bought)"
style={`transform: translate(${x}px, ${y}px) scale(0.05)`}
d="M62.43,122.88h-1.98c0-16.15-6.04-30.27-18.11-42.34C30.27,68.47,16.16,62.43,0,62.43v-1.98 c16.16,0,30.27-6.04,42.34-18.14C54.41,30.21,60.45,16.1,60.45,0h1.98c0,16.15,6.04,30.27,18.11,42.34 c12.07,12.07,26.18,18.11,42.34,18.11v1.98c-16.15,0-30.27,6.04-42.34,18.11C68.47,92.61,62.43,106.72,62.43,122.88L62.43,122.88z"
/>
);
})}
</>
);
const links = computed(() => (
<>
{nodes.value
.reduce(
(acc, curr) => [
...acc,
...curr.links.map(l => ({ from: curr, to: nodesById.value[l] }))
],
[] as { from: NodeTypes; to: NodeTypes }[]
)
.map(link => (
<line
stroke="white"
stroke-width={4}
x1={
nodeBeingDragged.value === link.from.id
? dragDelta.value.x + link.from.x
: link.from.x
}
y1={
nodeBeingDragged.value === link.from.id
? dragDelta.value.y + link.from.y
: link.from.y
}
x2={
nodeBeingDragged.value === link.to.id
? dragDelta.value.x + link.to.x
: link.to.x
}
y2={
nodeBeingDragged.value === link.to.id
? dragDelta.value.y + link.to.y
: link.to.y
}
/>
))}
</>
));
const nextId = setupUniqueIds(() => nodes.value);
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: "Board",
color: "var(--accent1)",
display: () => (
<>
<Board
onDrag={drag}
onMouseDown={deselect}
onMouseUp={endDrag}
onMouseLeave={endDrag}
ref={board}
style={{ height: "600px" }}
>
<SVGNode>
{dNodes}
{links.value}
</SVGNode>
{nodes.value.map(renderNode)}
{render(cNode)}
<SVGNode>
{aActions.value}
{bActions.value}
</SVGNode>
</Board>
</>
),
boardNodes: nodes,
cNode,
selected: persistent(selected)
};
});
export default board;

View file

@ -5,8 +5,7 @@ import { branchedResetPropagation, createTree, Tree } from "features/trees/tree"
import { globalBus } from "game/events"; import { globalBus } from "game/events";
import type { BaseLayer, Layer } from "game/layers"; import type { BaseLayer, Layer } from "game/layers";
import { createLayer } from "game/layers"; import { createLayer } from "game/layers";
import type { Player } from "game/player"; import player, { Player } from "game/player";
import player from "game/player";
import type { DecimalSource } from "util/bignum"; import type { DecimalSource } from "util/bignum";
import Decimal, { format, formatTime } from "util/bignum"; import Decimal, { format, formatTime } from "util/bignum";
import { render } from "util/vue"; import { render } from "util/vue";
@ -31,17 +30,21 @@ export const main = createLayer("main", function (this: BaseLayer) {
}); });
const oomps = trackOOMPS(points, pointGain); const oomps = trackOOMPS(points, pointGain);
// Note: Casting as generic tree to avoid recursive type definitions
const tree = createTree(() => ({ const tree = createTree(() => ({
nodes: [[prestige.treeNode]], nodes: [[prestige.treeNode]],
branches: [], branches: [],
onReset() { onReset() {
points.value = toRaw(this.resettingNode.value) === toRaw(prestige.treeNode) ? 0 : 10; points.value = toRaw(tree.resettingNode.value) === toRaw(prestige.treeNode) ? 0 : 10;
best.value = points.value; best.value = points.value;
total.value = points.value; total.value = points.value;
}, },
resetPropagation: branchedResetPropagation resetPropagation: branchedResetPropagation
})) as Tree; })) as Tree;
// Note: layers don't _need_ a reference to everything,
// but I'd recommend it over trying to remember what does and doesn't need to be included.
// Officially all you need are anything with persistency or that you want to access elsewhere
return { return {
name: "Tree", name: "Tree",
links: tree.links, links: tree.links,

View file

@ -1,5 +1,5 @@
<template> <template>
<div <button
:style="{ :style="{
backgroundImage: (unref(earned) && unref(image) && `url(${image})`) || '' backgroundImage: (unref(earned) && unref(image) && `url(${image})`) || ''
}" }"
@ -11,7 +11,7 @@
}" }"
> >
<Component /> <Component />
</div> </button>
</template> </template>
<script setup lang="tsx"> <script setup lang="tsx">

View file

@ -1,5 +1,5 @@
import Select from "components/fields/Select.vue"; import Select from "components/fields/Select.vue";
import { OptionsFunc, Replace, Visibility } from "features/feature"; import { Visibility } from "features/feature";
import { globalBus } from "game/events"; import { globalBus } from "game/events";
import "game/notifications"; import "game/notifications";
import type { Persistent } from "game/persistence"; import type { Persistent } from "game/persistence";
@ -14,7 +14,7 @@ import {
} from "game/requirements"; } from "game/requirements";
import settings, { registerSettingField } from "game/settings"; import settings, { registerSettingField } from "game/settings";
import { camelToTitle } from "util/common"; import { camelToTitle } from "util/common";
import { ProcessedRefOrGetter, processGetter } from "util/computed"; import { processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy } from "util/proxies";
import { import {
isJSXElement, isJSXElement,
@ -46,7 +46,7 @@ export enum AchievementDisplay {
* An object that configures an {@link Achievement}. * An object that configures an {@link Achievement}.
*/ */
export interface AchievementOptions extends VueFeatureOptions { export interface AchievementOptions extends VueFeatureOptions {
/** The requirement(s) to earn this achievement. Can be left null if using {@link BaseAchievement.complete}. */ /** The requirement(s) to earn this achievement. Can be left null if using {@link Achievement.complete}. */
requirements?: Requirements; requirements?: Requirements;
/** The display to use for this achievement. */ /** The display to use for this achievement. */
display?: display?:
@ -69,10 +69,26 @@ export interface AchievementOptions extends VueFeatureOptions {
onComplete?: VoidFunction; onComplete?: VoidFunction;
} }
/** /** An object that represents a feature with requirements that is passively earned upon meeting certain requirements. */
* The properties that are added onto a processed {@link AchievementOptions} to create an {@link Achievement}. export interface Achievement extends VueFeature {
*/ /** The requirement(s) to earn this achievement. */
export interface BaseAchievement extends VueFeature { requirements?: Requirements;
/** A function that is called when the achievement is completed. */
onComplete?: VoidFunction;
/** The display to use for this achievement. */
display?:
| MaybeRef<Renderable>
| {
requirement?: MaybeRef<Renderable>;
effectDisplay?: MaybeRef<Renderable>;
optionsDisplay?: MaybeRef<Renderable>;
};
/** Toggles a smaller design for the feature. */
small?: MaybeRef<boolean>;
/** An image to display as the background for this achievement. */
image?: MaybeRef<string>;
/** Whether or not to display a notification popup when this achievement is earned. */
showPopups: MaybeRef<boolean>;
/** Whether or not this achievement has been earned. */ /** Whether or not this achievement has been earned. */
earned: Persistent<boolean>; earned: Persistent<boolean>;
/** A function to complete this achievement. */ /** A function to complete this achievement. */
@ -81,32 +97,14 @@ export interface BaseAchievement extends VueFeature {
type: typeof AchievementType; type: typeof AchievementType;
} }
/** An object that represents a feature with requirements that is passively earned upon meeting certain requirements. */
export type Achievement = Replace<
Replace<AchievementOptions, BaseAchievement>,
{
display?:
| MaybeRef<Renderable>
| {
requirement?: MaybeRef<Renderable>;
effectDisplay?: MaybeRef<Renderable>;
optionsDisplay?: MaybeRef<Renderable>;
};
image: ProcessedRefOrGetter<AchievementOptions["image"]>;
showPopups: MaybeRef<boolean>;
}
>;
/** /**
* Lazily creates an achievement with the given options. * Lazily creates an achievement with the given options.
* @param optionsFunc Achievement options. * @param optionsFunc Achievement options.
*/ */
export function createAchievement<T extends AchievementOptions>( export function createAchievement<T extends AchievementOptions>(optionsFunc?: () => T) {
optionsFunc?: OptionsFunc<T, BaseAchievement, Achievement>
) {
const earned = persistent<boolean>(false, false); const earned = persistent<boolean>(false, false);
return createLazyProxy(feature => { return createLazyProxy(() => {
const options = optionsFunc?.call(feature, feature as Achievement) ?? ({} as T); const options = optionsFunc?.() ?? ({} as T);
const { requirements, display, small, image, showPopups, onComplete, ...props } = options; const { requirements, display, small, image, showPopups, onComplete, ...props } = options;
const vueFeature = vueFeatureMixin("achievement", options, () => ( const vueFeature = vueFeatureMixin("achievement", options, () => (
@ -195,7 +193,7 @@ export function createAchievement<T extends AchievementOptions>(
toast.info( toast.info(
<div> <div>
<h3>Achievement earned!</h3> <h3>Achievement earned!</h3>
<div>{Display}</div> <div>{Display()}</div>
</div> </div>
); );
} }

View file

@ -1,8 +1,7 @@
import Bar from "features/bars/Bar.vue"; import Bar from "features/bars/Bar.vue";
import type { OptionsFunc, Replace } from "features/feature";
import type { DecimalSource } from "util/bignum"; import type { DecimalSource } from "util/bignum";
import { Direction } from "util/common"; import { Direction } from "util/common";
import { ProcessedRefOrGetter, processGetter } from "util/computed"; import { processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy } from "util/proxies";
import { Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue"; import { Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
import { CSSProperties, MaybeRef, MaybeRefOrGetter } from "vue"; import { CSSProperties, MaybeRef, MaybeRefOrGetter } from "vue";
@ -34,37 +33,37 @@ export interface BarOptions extends VueFeatureOptions {
display?: MaybeRefOrGetter<Renderable>; display?: MaybeRefOrGetter<Renderable>;
} }
/** /** An object that represents a feature that displays some sort of progress or completion or resource with a cap. */
* The properties that are added onto a processed {@link BarOptions} to create a {@link Bar}. export interface Bar extends VueFeature {
*/ /** The width of the bar. */
export interface BaseBar extends VueFeature { width: MaybeRef<number>;
/** The height of the bar. */
height: MaybeRef<number>;
/** The direction in which the bar progresses. */
direction: MaybeRef<Direction>;
/** CSS to apply to the bar's border. */
borderStyle?: MaybeRef<CSSProperties>;
/** CSS to apply to the bar's base. */
baseStyle?: MaybeRef<CSSProperties>;
/** CSS to apply to the bar's text. */
textStyle?: MaybeRef<CSSProperties>;
/** CSS to apply to the bar's fill. */
fillStyle?: MaybeRef<CSSProperties>;
/** The progress value of the bar, from 0 to 1. */
progress: MaybeRef<DecimalSource>;
/** The display to use for this bar. */
display?: MaybeRef<Renderable>;
/** A symbol that helps identify features of the same type. */ /** A symbol that helps identify features of the same type. */
type: typeof BarType; type: typeof BarType;
} }
/** An object that represents a feature that displays some sort of progress or completion or resource with a cap. */
export type Bar = Replace<
Replace<BarOptions, BaseBar>,
{
width: ProcessedRefOrGetter<BarOptions["width"]>;
height: ProcessedRefOrGetter<BarOptions["height"]>;
direction: ProcessedRefOrGetter<BarOptions["direction"]>;
borderStyle: ProcessedRefOrGetter<BarOptions["borderStyle"]>;
baseStyle: ProcessedRefOrGetter<BarOptions["baseStyle"]>;
textStyle: ProcessedRefOrGetter<BarOptions["textStyle"]>;
fillStyle: ProcessedRefOrGetter<BarOptions["fillStyle"]>;
progress: ProcessedRefOrGetter<BarOptions["progress"]>;
display?: MaybeRef<Renderable>;
}
>;
/** /**
* Lazily creates a bar with the given options. * Lazily creates a bar with the given options.
* @param optionsFunc Bar options. * @param optionsFunc Bar options.
*/ */
export function createBar<T extends BarOptions>(optionsFunc: OptionsFunc<T, BaseBar, Bar>) { export function createBar<T extends BarOptions>(optionsFunc: () => T) {
return createLazyProxy(feature => { return createLazyProxy(() => {
const options = optionsFunc?.call(feature, feature as Bar); const options = optionsFunc?.();
const { const {
width, width,
height, height,

View file

@ -1,5 +1,4 @@
import Toggle from "components/fields/Toggle.vue"; import Toggle from "components/fields/Toggle.vue";
import type { OptionsFunc, Replace } from "features/feature";
import { isVisible } from "features/feature"; import { isVisible } from "features/feature";
import type { Reset } from "features/reset"; import type { Reset } from "features/reset";
import { globalBus } from "game/events"; import { globalBus } from "game/events";
@ -54,10 +53,37 @@ export interface ChallengeOptions extends VueFeatureOptions {
onEnter?: VoidFunction; onEnter?: VoidFunction;
} }
/** /** An object that represents a feature that can be entered and exited, and have one or more completions with scaling requirements. */
* The properties that are added onto a processed {@link ChallengeOptions} to create a {@link Challenge}. export interface Challenge extends VueFeature {
*/ /** The reset function for this challenge. */
export interface BaseChallenge extends VueFeature { reset?: Reset;
/** The requirement(s) to complete this challenge. */
requirements: Requirements;
/** A function that is called when the challenge is completed. */
onComplete?: VoidFunction;
/** A function that is called when the challenge is exited. */
onExit?: VoidFunction;
/** A function that is called when the challenge is entered. */
onEnter?: VoidFunction;
/** Whether this challenge can be started. */
canStart?: MaybeRef<boolean>;
/** The maximum number of times the challenge can be completed. */
completionLimit?: MaybeRef<DecimalSource>;
/** The display to use for this challenge. */
display?:
| MaybeRef<Renderable>
| {
/** A header to appear at the top of the display. */
title?: MaybeRef<Renderable>;
/** The main text that appears in the display. */
description: MaybeRef<Renderable>;
/** A description of the current goal for this challenge. If unspecified then the requirements will be displayed automatically based on {@link requirements}. */
goal?: MaybeRef<Renderable>;
/** A description of what will change upon completing this challenge. */
reward?: MaybeRef<Renderable>;
/** A description of the current effect of this challenge. */
effectDisplay?: MaybeRef<Renderable>;
};
/** The current amount of times this challenge can be completed. */ /** The current amount of times this challenge can be completed. */
canComplete: Ref<DecimalSource>; canComplete: Ref<DecimalSource>;
/** The current number of times this challenge has been completed. */ /** The current number of times this challenge has been completed. */
@ -79,35 +105,15 @@ export interface BaseChallenge extends VueFeature {
type: typeof ChallengeType; type: typeof ChallengeType;
} }
/** An object that represents a feature that can be entered and exited, and have one or more completions with scaling requirements. */
export type Challenge = Replace<
Replace<ChallengeOptions, BaseChallenge>,
{
canStart: MaybeRef<boolean>;
completionLimit: MaybeRef<DecimalSource>;
display?:
| MaybeRef<Renderable>
| {
title?: MaybeRef<Renderable>;
description: MaybeRef<Renderable>;
goal?: MaybeRef<Renderable>;
reward?: MaybeRef<Renderable>;
effectDisplay?: MaybeRef<Renderable>;
};
}
>;
/** /**
* Lazily creates a challenge with the given options. * Lazily creates a challenge with the given options.
* @param optionsFunc Challenge options. * @param optionsFunc Challenge options.
*/ */
export function createChallenge<T extends ChallengeOptions>( export function createChallenge<T extends ChallengeOptions>(optionsFunc: () => T) {
optionsFunc: OptionsFunc<T, BaseChallenge, Challenge>
) {
const completions = persistent<DecimalSource>(0); const completions = persistent<DecimalSource>(0);
const active = persistent<boolean>(false, false); const active = persistent<boolean>(false, false);
return createLazyProxy(feature => { return createLazyProxy(() => {
const options = optionsFunc.call(feature, feature as Challenge); const options = optionsFunc();
const { const {
requirements, requirements,
canStart, canStart,

View file

@ -1,13 +1,13 @@
import ClickableVue from "features/clickables/Clickable.vue"; import ClickableVue from "features/clickables/Clickable.vue";
import { findFeatures, OptionsFunc, Replace } from "features/feature"; import { findFeatures } from "features/feature";
import { globalBus } from "game/events"; import { globalBus } from "game/events";
import { persistent } from "game/persistence"; import { persistent } from "game/persistence";
import Decimal, { DecimalSource } from "lib/break_eternity"; import Decimal, { DecimalSource } from "lib/break_eternity";
import { Unsubscribe } from "nanoevents"; import { Unsubscribe } from "nanoevents";
import { Direction } from "util/common"; import { Direction } from "util/common";
import { ProcessedRefOrGetter, processGetter } from "util/computed"; import { processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy } from "util/proxies";
import { render, VueFeature, vueFeatureMixin } from "util/vue"; import { render, Renderable, VueFeature, vueFeatureMixin } from "util/vue";
import { computed, MaybeRef, MaybeRefOrGetter, Ref, ref, unref } from "vue"; import { computed, MaybeRef, MaybeRefOrGetter, Ref, ref, unref } from "vue";
import { JSX } from "vue/jsx-runtime"; import { JSX } from "vue/jsx-runtime";
import { Bar, BarOptions, createBar } from "../bars/bar"; import { Bar, BarOptions, createBar } from "../bars/bar";
@ -30,10 +30,18 @@ export interface ActionOptions extends Omit<ClickableOptions, "onClick" | "onHol
barOptions?: Partial<BarOptions>; barOptions?: Partial<BarOptions>;
} }
/** /** An object that represents a feature that can be clicked upon, and then has a cooldown before it can be clicked again. */
* The properties that are added onto a processed {@link ActionOptions} to create an {@link Action}. export interface Action extends VueFeature {
*/ /** The cooldown during which the action cannot be performed again, in seconds. */
export interface BaseAction extends VueFeature { duration: MaybeRef<DecimalSource>;
/** Whether or not the action should perform automatically when the cooldown is finished. */
autoStart: MaybeRef<boolean>;
/** Whether or not the action may be performed. */
canClick: MaybeRef<boolean>;
/** The display to use for this action. */
display?: MaybeRef<Renderable>;
/** A function that is called when the action is clicked. */
onClick: (amount: DecimalSource) => void;
/** Whether or not the player is holding down the action. Actions will be considered clicked as soon as the cooldown completes when being held down. */ /** Whether or not the player is holding down the action. Actions will be considered clicked as soon as the cooldown completes when being held down. */
isHolding: Ref<boolean>; isHolding: Ref<boolean>;
/** The current amount of progress through the cooldown. */ /** The current amount of progress through the cooldown. */
@ -46,28 +54,14 @@ export interface BaseAction extends VueFeature {
type: typeof ActionType; type: typeof ActionType;
} }
/** An object that represents a feature that can be clicked upon, and then has a cooldown before it can be clicked again. */
export type Action = Replace<
Replace<ActionOptions, BaseAction>,
{
duration: ProcessedRefOrGetter<ActionOptions["duration"]>;
autoStart: MaybeRef<boolean>;
canClick: MaybeRef<boolean>;
display: ProcessedRefOrGetter<ActionOptions["display"]>;
onClick: VoidFunction;
}
>;
/** /**
* Lazily creates an action with the given options. * Lazily creates an action with the given options.
* @param optionsFunc Action options. * @param optionsFunc Action options.
*/ */
export function createAction<T extends ActionOptions>( export function createAction<T extends ActionOptions>(optionsFunc?: () => T) {
optionsFunc?: OptionsFunc<T, BaseAction, Action>
) {
const progress = persistent<DecimalSource>(0); const progress = persistent<DecimalSource>(0);
return createLazyProxy(feature => { return createLazyProxy(() => {
const options = optionsFunc?.call(feature, feature as Action) ?? ({} as T); const options = optionsFunc?.() ?? ({} as T);
const { style, duration, canClick, autoStart, display, barOptions, onClick, ...props } = const { style, duration, canClick, autoStart, display, barOptions, onClick, ...props } =
options; options;
@ -169,7 +163,7 @@ export function createAction<T extends ActionOptions>(
} }
} }
} }
} satisfies Action satisfies Replace<Clickable, { type: typeof ActionType }>; } satisfies Action satisfies Omit<Clickable, "type"> & { type: typeof ActionType };
return action; return action;
}); });

View file

@ -1,5 +1,4 @@
import Clickable from "features/clickables/Clickable.vue"; import Clickable from "features/clickables/Clickable.vue";
import type { OptionsFunc, Replace } from "features/feature";
import type { BaseLayer } from "game/layers"; import type { BaseLayer } from "game/layers";
import type { Unsubscribe } from "nanoevents"; import type { Unsubscribe } from "nanoevents";
import { processGetter } from "util/computed"; import { processGetter } from "util/computed";
@ -31,32 +30,27 @@ export interface ClickableOptions extends VueFeatureOptions {
onHold?: VoidFunction; onHold?: VoidFunction;
} }
/** /** An object that represents a feature that can be clicked or held down. */
* The properties that are added onto a processed {@link ClickableOptions} to create an {@link Clickable}. export interface Clickable extends VueFeature {
*/ /** A function that is called when the clickable is clicked. */
export interface BaseClickable extends VueFeature { onClick?: (e?: MouseEvent | TouchEvent) => void;
/** A function that is called when the clickable is held down. */
onHold?: VoidFunction;
/** Whether or not the clickable may be clicked. */
canClick: MaybeRef<boolean>;
/** The display to use for this clickable. */
display?: MaybeRef<Renderable>;
/** A symbol that helps identify features of the same type. */ /** A symbol that helps identify features of the same type. */
type: typeof ClickableType; type: typeof ClickableType;
} }
/** An object that represents a feature that can be clicked or held down. */
export type Clickable = Replace<
Replace<ClickableOptions, BaseClickable>,
{
canClick: MaybeRef<boolean>;
display?: MaybeRef<Renderable>;
}
>;
/** /**
* Lazily creates a clickable with the given options. * Lazily creates a clickable with the given options.
* @param optionsFunc Clickable options. * @param optionsFunc Clickable options.
*/ */
export function createClickable<T extends ClickableOptions>( export function createClickable<T extends ClickableOptions>(optionsFunc?: () => T) {
optionsFunc?: OptionsFunc<T, BaseClickable, Clickable> return createLazyProxy(() => {
) { const options = optionsFunc?.() ?? ({} as T);
return createLazyProxy(feature => {
const options = optionsFunc?.call(feature, feature as Clickable) ?? ({} as T);
const { canClick, display: _display, onClick: onClick, onHold: onHold, ...props } = options; const { canClick, display: _display, onClick: onClick, onHold: onHold, ...props } = options;
let display: MaybeRef<Renderable> | undefined = undefined; let display: MaybeRef<Renderable> | undefined = undefined;

View file

@ -1,5 +1,4 @@
import Clickable from "features/clickables/Clickable.vue"; import Clickable from "features/clickables/Clickable.vue";
import type { OptionsFunc, Replace } from "features/feature";
import { Visibility } from "features/feature"; import { Visibility } from "features/feature";
import { DefaultValue, Persistent, persistent } from "game/persistence"; import { DefaultValue, Persistent, persistent } from "game/persistence";
import { import {
@ -23,7 +22,7 @@ import { ClickableOptions } from "./clickable";
export const RepeatableType = Symbol("Repeatable"); export const RepeatableType = Symbol("Repeatable");
/** An object that configures a {@link Repeatable}. */ /** An object that configures a {@link Repeatable}. */
export interface RepeatableOptions extends Omit<ClickableOptions, "display" | "canClick"> { export interface RepeatableOptions extends ClickableOptions {
/** The requirement(s) to increase this repeatable. */ /** The requirement(s) to increase this repeatable. */
requirements: Requirements; requirements: Requirements;
/** The maximum amount obtainable for this repeatable. */ /** The maximum amount obtainable for this repeatable. */
@ -37,7 +36,7 @@ export interface RepeatableOptions extends Omit<ClickableOptions, "display" | "c
/** A header to appear at the top of the display. */ /** A header to appear at the top of the display. */
title?: MaybeRefOrGetter<Renderable>; title?: MaybeRefOrGetter<Renderable>;
/** The main text that appears in the display. */ /** The main text that appears in the display. */
description?: MaybeRefOrGetter<Renderable>; description: MaybeRefOrGetter<Renderable>;
/** A description of the current effect of this repeatable, based off its amount. */ /** A description of the current effect of this repeatable, based off its amount. */
effectDisplay?: MaybeRefOrGetter<Renderable>; effectDisplay?: MaybeRefOrGetter<Renderable>;
/** Whether or not to show the current amount of this repeatable at the bottom of the display. */ /** Whether or not to show the current amount of this repeatable at the bottom of the display. */
@ -45,10 +44,20 @@ export interface RepeatableOptions extends Omit<ClickableOptions, "display" | "c
}; };
} }
/** /** An object that represents a feature with multiple "levels" with scaling requirements. */
* The properties that are added onto a processed {@link RepeatableOptions} to create a {@link Repeatable}. export interface Repeatable extends VueFeature {
*/ /** The requirement(s) to increase this repeatable. */
export interface BaseRepeatable extends VueFeature { requirements: Requirements;
/** The maximum amount obtainable for this repeatable. */
limit: MaybeRef<DecimalSource>;
/** The initial amount this repeatable has on a new save / after reset. */
initialAmount?: DecimalSource;
/** The display to use for this repeatable. */
display?: MaybeRef<Renderable>;
/** Whether or not the repeatable may be clicked. */
canClick: Ref<boolean>;
/** A function that is called when the repeatable is clicked. */
onClick: (event?: MouseEvent | TouchEvent) => void;
/** The current amount this repeatable has. */ /** The current amount this repeatable has. */
amount: Persistent<DecimalSource>; amount: Persistent<DecimalSource>;
/** Whether or not this repeatable's amount is at it's limit. */ /** Whether or not this repeatable's amount is at it's limit. */
@ -59,27 +68,14 @@ export interface BaseRepeatable extends VueFeature {
type: typeof RepeatableType; type: typeof RepeatableType;
} }
/** An object that represents a feature with multiple "levels" with scaling requirements. */
export type Repeatable = Replace<
Replace<RepeatableOptions, BaseRepeatable>,
{
limit: MaybeRef<DecimalSource>;
display?: MaybeRef<Renderable>;
canClick: Ref<boolean>;
onClick: (event?: MouseEvent | TouchEvent) => void;
}
>;
/** /**
* Lazily creates a repeatable with the given options. * Lazily creates a repeatable with the given options.
* @param optionsFunc Repeatable options. * @param optionsFunc Repeatable options.
*/ */
export function createRepeatable<T extends RepeatableOptions>( export function createRepeatable<T extends RepeatableOptions>(optionsFunc: () => T) {
optionsFunc: OptionsFunc<T, BaseRepeatable, Repeatable>
) {
const amount = persistent<DecimalSource>(0); const amount = persistent<DecimalSource>(0);
return createLazyProxy(feature => { return createLazyProxy(() => {
const options = optionsFunc.call(feature, feature as Repeatable); const options = optionsFunc();
const { const {
requirements: _requirements, requirements: _requirements,
display: _display, display: _display,
@ -130,8 +126,7 @@ export function createRepeatable<T extends RepeatableOptions>(
const showAmount = processGetter(_display.showAmount); const showAmount = processGetter(_display.showAmount);
const Title = title == null ? null : () => render(title, el => <h3>{el}</h3>); const Title = title == null ? null : () => render(title, el => <h3>{el}</h3>);
const Description = const Description = () => render(description, el => <>{el}</>);
description == null ? null : () => render(description, el => <>{el}</>);
const EffectDisplay = const EffectDisplay =
effectDisplay == null ? null : () => render(effectDisplay, el => <>{el}</>); effectDisplay == null ? null : () => render(effectDisplay, el => <>{el}</>);
@ -142,7 +137,7 @@ export function createRepeatable<T extends RepeatableOptions>(
<Title /> <Title />
</div> </div>
)} )}
{Description == null ? null : <Description />} <Description />
{showAmount === false ? null : ( {showAmount === false ? null : (
<div> <div>
<br /> <br />
@ -181,6 +176,7 @@ export function createRepeatable<T extends RepeatableOptions>(
...vueFeature, ...vueFeature,
amount, amount,
requirements, requirements,
initialAmount,
limit: processGetter(limit) ?? Decimal.dInf, limit: processGetter(limit) ?? Decimal.dInf,
classes: computed(() => { classes: computed(() => {
const currClasses = unref(vueFeature.classes) || {}; const currClasses = unref(vueFeature.classes) || {};

View file

@ -1,4 +1,3 @@
import type { OptionsFunc, Replace } from "features/feature";
import { findFeatures } from "features/feature"; import { findFeatures } from "features/feature";
import { Layer } from "game/layers"; import { Layer } from "game/layers";
import type { Persistent } from "game/persistence"; import type { Persistent } from "game/persistence";
@ -17,6 +16,7 @@ import { Renderable, VueFeature, VueFeatureOptions, render, vueFeatureMixin } fr
import type { MaybeRef, MaybeRefOrGetter, Ref } from "vue"; import type { MaybeRef, MaybeRefOrGetter, Ref } from "vue";
import { computed, unref } from "vue"; import { computed, unref } from "vue";
import Clickable from "./Clickable.vue"; import Clickable from "./Clickable.vue";
import { ClickableOptions } from "./clickable";
/** A symbol used to identify {@link Upgrade} features. */ /** A symbol used to identify {@link Upgrade} features. */
export const UpgradeType = Symbol("Upgrade"); export const UpgradeType = Symbol("Upgrade");
@ -24,7 +24,7 @@ export const UpgradeType = Symbol("Upgrade");
/** /**
* An object that configures a {@link Upgrade}. * An object that configures a {@link Upgrade}.
*/ */
export interface UpgradeOptions extends VueFeatureOptions { export interface UpgradeOptions extends VueFeatureOptions, ClickableOptions {
/** The display to use for this upgrade. */ /** The display to use for this upgrade. */
display?: display?:
| MaybeRefOrGetter<Renderable> | MaybeRefOrGetter<Renderable>
@ -42,48 +42,33 @@ export interface UpgradeOptions extends VueFeatureOptions {
onPurchase?: VoidFunction; onPurchase?: VoidFunction;
} }
/** /** An object that represents a feature that can be purchased a single time. */
* The properties that are added onto a processed {@link UpgradeOptions} to create an {@link Upgrade}. export interface Upgrade extends VueFeature {
*/ /** The requirements to purchase this upgrade. */
export interface BaseUpgrade extends VueFeature { requirements: Requirements;
/** The display to use for this upgrade. */
display?: MaybeRef<Renderable>;
/** Whether or not this upgrade has been purchased. */ /** Whether or not this upgrade has been purchased. */
bought: Persistent<boolean>; bought: Persistent<boolean>;
/** Whether or not the upgrade can currently be purchased. */ /** Whether or not the upgrade can currently be purchased. */
canPurchase: Ref<boolean>; canPurchase: Ref<boolean>;
/** A function that is called when the upgrade is purchased. */
onPurchase?: VoidFunction;
/** Purchase the upgrade */ /** Purchase the upgrade */
purchase: VoidFunction; purchase: VoidFunction;
/** A symbol that helps identify features of the same type. */ /** A symbol that helps identify features of the same type. */
type: typeof UpgradeType; type: typeof UpgradeType;
} }
/** An object that represents a feature that can be purchased a single time. */
export type Upgrade = Replace<
Replace<UpgradeOptions, BaseUpgrade>,
{
display?:
| MaybeRef<Renderable>
| {
/** A header to appear at the top of the display. */
title?: MaybeRef<Renderable>;
/** The main text that appears in the display. */
description: MaybeRef<Renderable>;
/** A description of the current effect of the achievement. Useful when the effect changes dynamically. */
effectDisplay?: MaybeRef<Renderable>;
};
}
>;
/** /**
* Lazily creates an upgrade with the given options. * Lazily creates an upgrade with the given options.
* @param optionsFunc Upgrade options. * @param optionsFunc Upgrade options.
*/ */
export function createUpgrade<T extends UpgradeOptions>( export function createUpgrade<T extends UpgradeOptions>(optionsFunc: () => T) {
optionsFunc: OptionsFunc<T, BaseUpgrade, Upgrade>
) {
const bought = persistent<boolean>(false, false); const bought = persistent<boolean>(false, false);
return createLazyProxy(feature => { return createLazyProxy(() => {
const options = optionsFunc.call(feature, feature as Upgrade); const options = optionsFunc();
const { requirements: _requirements, display: _display, ...props } = options; const { requirements: _requirements, display: _display, onHold, ...props } = options;
if (options.classes == null) { if (options.classes == null) {
options.classes = computed(() => ({ bought: unref(upgrade.bought) })); options.classes = computed(() => ({ bought: unref(upgrade.bought) }));
@ -97,6 +82,7 @@ export function createUpgrade<T extends UpgradeOptions>(
const vueFeature = vueFeatureMixin("upgrade", options, () => ( const vueFeature = vueFeatureMixin("upgrade", options, () => (
<Clickable <Clickable
onClick={upgrade.purchase} onClick={upgrade.purchase}
onHold={upgrade.onHold}
canClick={upgrade.canPurchase} canClick={upgrade.canPurchase}
display={upgrade.display} display={upgrade.display}
/> />
@ -150,6 +136,7 @@ export function createUpgrade<T extends UpgradeOptions>(
canPurchase: computed(() => !bought.value && requirementsMet(requirements)), canPurchase: computed(() => !bought.value && requirementsMet(requirements)),
requirements, requirements,
display, display,
onHold,
purchase() { purchase() {
if (!unref(upgrade.canPurchase)) { if (!unref(upgrade.canPurchase)) {
return; return;

View file

@ -1,4 +1,3 @@
import type { OptionsFunc, Replace } from "features/feature";
import type { Resource } from "features/resources/resource"; import type { Resource } from "features/resources/resource";
import Formula from "game/formulas/formulas"; import Formula from "game/formulas/formulas";
import { InvertibleFormula, InvertibleIntegralFormula } from "game/formulas/types"; import { InvertibleFormula, InvertibleIntegralFormula } from "game/formulas/types";
@ -77,27 +76,64 @@ export interface ConversionOptions {
/** /**
* The properties that are added onto a processed {@link ConversionOptions} to create a {@link Conversion}. * The properties that are added onto a processed {@link ConversionOptions} to create a {@link Conversion}.
*/ */
export interface BaseConversion { export interface Conversion {
/** /**
* The function that performs the actual conversion. * The formula used to determine how much {@link gainResource} should be earned by this converting.
*/
formula: InvertibleFormula;
/**
* How much of the output resource the conversion can currently convert for.
* Typically this will be set for you in a conversion constructor.
*/
currentGain: MaybeRef<DecimalSource>;
/**
* The absolute amount the output resource will be changed by.
* Typically this will be set for you in a conversion constructor.
* This will differ from {@link currentGain} in the cases where the conversion isn't just adding the converted amount to the output resource.
*/
actualGain: MaybeRef<DecimalSource>;
/**
* The amount of the input resource currently being required in order to produce the {@link currentGain}.
* That is, if it went below this value then {@link currentGain} would decrease.
* Typically this will be set for you in a conversion constructor.
*/
currentAt: MaybeRef<DecimalSource>;
/**
* The amount of the input resource required to make {@link currentGain} increase.
* Typically this will be set for you in a conversion constructor.
*/
nextAt: MaybeRef<DecimalSource>;
/**
* The input {@link features/resources/resource.Resource} for this conversion.
*/
baseResource: Resource;
/**
* The output {@link features/resources/resource.Resource} for this conversion. i.e. the resource being generated.
*/
gainResource: Resource;
/**
* Whether or not to cap the amount of the output resource gained by converting at 1.
* Defaults to true.
*/
buyMax: MaybeRef<boolean>;
/**
* The function that performs the actual conversion from {@link baseResource} to {@link gainResource}.
* Typically this will be set for you in a conversion constructor.
*/ */
convert: VoidFunction; convert: VoidFunction;
/**
* The function that spends the {@link baseResource} as part of the conversion.
* Defaults to setting the {@link baseResource} amount to 0.
*/
spend: (amountGained: DecimalSource) => void;
/**
* A callback that happens after a conversion has been completed.
* Receives the amount gained via conversion.
* This will not be called whenever using currentGain without calling convert (e.g. passive generation)
*/
onConvert?: (amountGained: DecimalSource) => void;
} }
/** An object that converts one {@link features/resources/resource.Resource} into another at a given rate. */
export type Conversion = Replace<
Replace<ConversionOptions, BaseConversion>,
{
formula: InvertibleFormula;
currentGain: MaybeRef<DecimalSource>;
actualGain: MaybeRef<DecimalSource>;
currentAt: MaybeRef<DecimalSource>;
nextAt: MaybeRef<DecimalSource>;
buyMax: MaybeRef<boolean>;
spend: (amountGained: DecimalSource) => void;
}
>;
/** /**
* Lazily creates a conversion with the given options. * Lazily creates a conversion with the given options.
* You typically shouldn't use this function directly. Instead use one of the other conversion constructors, which will then call this. * You typically shouldn't use this function directly. Instead use one of the other conversion constructors, which will then call this.
@ -105,11 +141,9 @@ export type Conversion = Replace<
* @see {@link createCumulativeConversion}. * @see {@link createCumulativeConversion}.
* @see {@link createIndependentConversion}. * @see {@link createIndependentConversion}.
*/ */
export function createConversion<T extends ConversionOptions>( export function createConversion<T extends ConversionOptions>(optionsFunc: () => T) {
optionsFunc: OptionsFunc<T, BaseConversion, Conversion> return createLazyProxy(() => {
) { const options = optionsFunc();
return createLazyProxy(feature => {
const options = optionsFunc.call(feature, feature as Conversion);
const { const {
baseResource, baseResource,
gainResource, gainResource,
@ -187,9 +221,7 @@ export function createConversion<T extends ConversionOptions>(
* This is equivalent to just calling createConversion directly. * This is equivalent to just calling createConversion directly.
* @param optionsFunc Conversion options. * @param optionsFunc Conversion options.
*/ */
export function createCumulativeConversion<S extends ConversionOptions>( export function createCumulativeConversion<T extends ConversionOptions>(optionsFunc: () => T) {
optionsFunc: OptionsFunc<S, BaseConversion, Conversion>
) {
return createConversion(optionsFunc); return createConversion(optionsFunc);
} }
@ -198,47 +230,46 @@ export function createCumulativeConversion<S extends ConversionOptions>(
* This is similar to the behavior of "static" layers in The Modding Tree. * This is similar to the behavior of "static" layers in The Modding Tree.
* @param optionsFunc Converison options. * @param optionsFunc Converison options.
*/ */
export function createIndependentConversion<S extends ConversionOptions>( export function createIndependentConversion<T extends ConversionOptions>(optionsFunc: () => T) {
optionsFunc: OptionsFunc<S, BaseConversion, Conversion> const conversion = createConversion(() => {
) { const options = optionsFunc();
return createConversion(feature => {
const conversion = optionsFunc.call(feature, feature);
conversion.buyMax ??= false; options.buyMax ??= false;
conversion.currentGain ??= computed(() => { options.currentGain ??= computed(() => {
let gain = Decimal.floor(feature.formula.evaluate(conversion.baseResource.value)).max( let gain = Decimal.floor(conversion.formula.evaluate(options.baseResource.value)).max(
conversion.gainResource.value options.gainResource.value
); );
if (unref(conversion.buyMax as MaybeRef<boolean>) === false) { if (unref(options.buyMax as MaybeRef<boolean>) === false) {
gain = gain.min(Decimal.add(conversion.gainResource.value, 1)); gain = gain.min(Decimal.add(options.gainResource.value, 1));
} }
return gain; return gain;
}); });
conversion.actualGain ??= computed(() => { options.actualGain ??= computed(() => {
let gain = Decimal.sub( let gain = Decimal.sub(
feature.formula.evaluate(conversion.baseResource.value), conversion.formula.evaluate(options.baseResource.value),
conversion.gainResource.value options.gainResource.value
) )
.floor() .floor()
.max(0); .max(0);
if (unref(conversion.buyMax as MaybeRef<boolean>) === false) { if (unref(options.buyMax as MaybeRef<boolean>) === false) {
gain = gain.min(1); gain = gain.min(1);
} }
return gain; return gain;
}); });
conversion.convert ??= function () { options.convert ??= function () {
const amountGained = unref(feature.actualGain); const amountGained = unref(conversion.actualGain);
conversion.gainResource.value = unref(feature.currentGain); options.gainResource.value = unref(conversion.currentGain);
feature.spend(amountGained); conversion.spend(amountGained);
feature.onConvert?.(amountGained); conversion.onConvert?.(amountGained);
}; };
return conversion; return options;
}); });
return conversion;
} }
/** /**

View file

@ -2,18 +2,6 @@ import Decimal from "util/bignum";
import { Renderable, renderCol, VueFeature } from "util/vue"; import { Renderable, renderCol, VueFeature } from "util/vue";
import { computed, isRef, MaybeRef, Ref, unref } from "vue"; import { computed, isRef, MaybeRef, Ref, unref } from "vue";
/** Utility type that is S, with any properties from T that aren't already present in S */
export type Replace<T, S> = S & Omit<T, keyof S>;
/**
* Utility function for a function that returns an object of a given type,
* with "this" bound to what the type will eventually be processed into.
* Intended for making lazily evaluated objects.
*/
export type OptionsFunc<T, R = unknown, S = R> = (obj: S) => OptionsObject<T, R, S>;
export type OptionsObject<T, R = unknown, S = R> = T & Partial<R> & ThisType<T & S>;
let id = 0; let id = 0;
/** /**
* Gets a unique ID to give to each feature, used for any sort of system that needs to identify * Gets a unique ID to give to each feature, used for any sort of system that needs to identify
@ -54,11 +42,11 @@ export function isType<T extends symbol>(object: unknown, type: T): object is {
* @param obj The object to traverse * @param obj The object to traverse
* @param types The feature types that will be searched for * @param types The feature types that will be searched for
*/ */
export function findFeatures(obj: Record<string, unknown>, ...types: symbol[]): unknown[] { export function findFeatures(obj: object, ...types: symbol[]): unknown[] {
const objects: unknown[] = []; const objects: unknown[] = [];
const handleObject = (obj: Record<string, unknown>) => { const handleObject = (obj: object) => {
Object.keys(obj).forEach(key => { Object.keys(obj).forEach(key => {
const value = obj[key]; const value: unknown = obj[key as keyof typeof obj];
if (value != null && typeof value === "object") { if (value != null && typeof value === "object") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
if (types.includes((value as Record<string, any>).type)) { if (types.includes((value as Record<string, any>).type)) {

View file

@ -1,26 +0,0 @@
<template>
<div class="table-grid">
<Cells />
</div>
</template>
<script setup lang="tsx">
import "components/common/table.css";
import themes from "data/themes";
import type { Grid } from "features/grids/grid";
import settings from "game/settings";
import { render } from "util/vue";
import { computed, unref } from "vue";
const props = defineProps<{
rows: Grid["rows"];
cols: Grid["cols"];
cells: Grid["cells"];
}>();
const mergeAdjacent = computed(() => themes[settings.theme].mergeAdjacent);
const Cells = () => new Array(unref(props.rows)).fill(0).map((_, row) => <div class={{ "row-grid": true, mergeAdjacent: mergeAdjacent.value }}>
{new Array(unref(props.cols)).map((_, col) => render(props.cells[row][col]))}
</div>);
</script>

View file

@ -1,51 +0,0 @@
<template>
<button
:class="{ tile: true, can: unref(canClick), locked: !unref(canClick) }"
@click="onClick"
@mousedown="start"
@mouseleave="stop"
@mouseup="stop"
@touchstart.passive="start"
@touchend.passive="stop"
@touchcancel.passive="stop"
>
<div v-if="title"><Title /></div>
<Component style="white-space: pre-line" />
</button>
</template>
<script setup lang="tsx">
import "components/common/features.css";
import {
render,
setupHoldToClick
} from "util/vue";
import { toRef, unref } from "vue";
import { GridCell } from "./grid";
const props = defineProps<{
onClick: GridCell["onClick"];
onHold: GridCell["onHold"];
display: GridCell["display"];
title: GridCell["title"];
canClick: GridCell["canClick"];
}>();
const { start, stop } = setupHoldToClick(toRef(props, "onClick"), toRef(props, "onHold"));
const Title = () => props.title == null ? <></> : render(props.title);
const Component = () => render(props.display);
</script>
<style scoped>
.tile {
min-height: 80px;
width: 80px;
font-size: 10px;
background-color: var(--layer-color);
}
.tile > * {
pointer-events: none;
}
</style>

View file

@ -1,16 +1,16 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import type { OptionsFunc, Replace } from "features/feature"; import { getUniqueID, Visibility } from "features/feature";
import { Visibility } from "features/feature";
import Grid from "features/grids/Grid.vue";
import type { Persistent, State } from "game/persistence"; import type { Persistent, State } from "game/persistence";
import { persistent } from "game/persistence"; import { persistent } from "game/persistence";
import { isFunction } from "util/common"; import { isFunction } from "util/common";
import { ProcessedRefOrGetter, processGetter } from "util/computed"; import { processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy } from "util/proxies";
import { Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue"; import { isJSXElement, render, Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
import type { CSSProperties, MaybeRef, MaybeRefOrGetter, Ref } from "vue"; import type { CSSProperties, MaybeRef, MaybeRefOrGetter, Ref } from "vue";
import { computed, unref } from "vue"; import { computed, isRef, unref } from "vue";
import GridCell from "./GridCell.vue"; import Column from "components/layout/Column.vue";
import Row from "components/layout/Row.vue";
import Clickable from "features/clickables/Clickable.vue";
/** A symbol used to identify {@link Grid} features. */ /** A symbol used to identify {@link Grid} features. */
export const GridType = Symbol("Grid"); export const GridType = Symbol("Grid");
@ -38,8 +38,6 @@ export interface GridCell extends VueFeature {
startState: State; startState: State;
/** The persistent state of this cell. */ /** The persistent state of this cell. */
state: State; state: State;
/** A header to appear at the top of the display. */
title?: MaybeRef<Renderable>;
/** The main text that appears in the display. */ /** The main text that appears in the display. */
display: MaybeRef<Renderable>; display: MaybeRef<Renderable>;
/** A function that is called when the cell is clicked. */ /** A function that is called when the cell is clicked. */
@ -56,30 +54,49 @@ export interface GridOptions extends VueFeatureOptions {
rows: MaybeRefOrGetter<number>; rows: MaybeRefOrGetter<number>;
/** The number of columns in the grid. */ /** The number of columns in the grid. */
cols: MaybeRefOrGetter<number>; cols: MaybeRefOrGetter<number>;
/** A MaybeRefOrGetter to determine the visibility of a cell. */ /** A getter for the visibility of a cell. */
getVisibility?: CellMaybeRefOrGetter<Visibility | boolean>; getVisibility?: CellMaybeRefOrGetter<Visibility | boolean>;
/** A MaybeRefOrGetter to determine if a cell can be clicked. */ /** A getter for if a cell can be clicked. */
getCanClick?: CellMaybeRefOrGetter<boolean>; getCanClick?: CellMaybeRefOrGetter<boolean>;
/** A MaybeRefOrGetter to get the initial persistent state of a cell. */ /** A getter for the initial persistent state of a cell. */
getStartState: MaybeRefOrGetter<State> | ((row: number, col: number) => State); getStartState: MaybeRefOrGetter<State> | ((row: number, col: number) => State);
/** A MaybeRefOrGetter to get the CSS styles for a cell. */ /** A getter for the CSS styles for a cell. */
getStyle?: CellMaybeRefOrGetter<CSSProperties>; getStyle?: CellMaybeRefOrGetter<CSSProperties>;
/** A MaybeRefOrGetter to get the CSS classes for a cell. */ /** A getter for the CSS classes for a cell. */
getClasses?: CellMaybeRefOrGetter<Record<string, boolean>>; getClasses?: CellMaybeRefOrGetter<Record<string, boolean>>;
/** A MaybeRefOrGetter to get the title component for a cell. */ /** A getter for the display component for a cell. */
getTitle?: CellMaybeRefOrGetter<MaybeRefOrGetter<Renderable>>; getDisplay: CellMaybeRefOrGetter<Renderable> | {
/** A MaybeRefOrGetter to get the display component for a cell. */ getTitle?: CellMaybeRefOrGetter<Renderable>;
getDisplay: CellMaybeRefOrGetter<MaybeRefOrGetter<Renderable>>; getDescription: CellMaybeRefOrGetter<Renderable>
};
/** A function that is called when a cell is clicked. */ /** A function that is called when a cell is clicked. */
onClick?: (row: number, col: number, state: State, e?: MouseEvent | TouchEvent) => void; onClick?: (row: number, col: number, state: State, e?: MouseEvent | TouchEvent) => void;
/** A function that is called when a cell is held down. */ /** A function that is called when a cell is held down. */
onHold?: (row: number, col: number, state: State) => void; onHold?: (row: number, col: number, state: State) => void;
} }
/** /** An object that represents a feature that is a grid of cells that all behave according to the same rules. */
* The properties that are added onto a processed {@link BoardOptions} to create a {@link Board}. export interface Grid extends VueFeature {
*/ /** A function that is called when a cell is clicked. */
export interface BaseGrid extends VueFeature { onClick?: (row: number, col: number, state: State, e?: MouseEvent | TouchEvent) => void;
/** A function that is called when a cell is held down. */
onHold?: (row: number, col: number, state: State) => void;
/** A getter for determine the visibility of a cell. */
getVisibility?: ProcessedCellRefOrGetter<Visibility | boolean>;
/** A getter for determine if a cell can be clicked. */
getCanClick?: ProcessedCellRefOrGetter<boolean>;
/** The number of rows in the grid. */
rows: MaybeRef<number>;
/** The number of columns in the grid. */
cols: MaybeRef<number>;
/** A getter for the initial persistent state of a cell. */
getStartState: MaybeRef<State> | ((row: number, col: number) => State);
/** A getter for the CSS styles for a cell. */
getStyle?: ProcessedCellRefOrGetter<CSSProperties>;
/** A getter for the CSS classes for a cell. */
getClasses?: ProcessedCellRefOrGetter<Record<string, boolean>>;
/** A getter for the display component for a cell. */
getDisplay: ProcessedCellRefOrGetter<Renderable>;
/** Get the auto-generated ID for identifying a specific cell of this grid that appears in the DOM. Will not persist between refreshes or updates. */ /** Get the auto-generated ID for identifying a specific cell of this grid that appears in the DOM. Will not persist between refreshes or updates. */
getID: (row: number, col: number, state: State) => string; getID: (row: number, col: number, state: State) => string;
/** Get the persistent state of the given cell. */ /** Get the persistent state of the given cell. */
@ -94,39 +111,18 @@ export interface BaseGrid extends VueFeature {
type: typeof GridType; type: typeof GridType;
} }
/** An object that represents a feature that is a grid of cells that all behave according to the same rules. */
export type Grid = Replace<
Replace<GridOptions, BaseGrid>,
{
getVisibility: ProcessedCellRefOrGetter<Visibility | boolean>;
getCanClick: ProcessedCellRefOrGetter<boolean>;
rows: ProcessedRefOrGetter<GridOptions["rows"]>;
cols: ProcessedRefOrGetter<GridOptions["cols"]>;
getStartState: MaybeRef<State> | ((row: number, col: number) => State);
getStyle: ProcessedCellRefOrGetter<GridOptions["getStyle"]>;
getClasses: ProcessedCellRefOrGetter<GridOptions["getClasses"]>;
getTitle: ProcessedCellRefOrGetter<GridOptions["getTitle"]>;
getDisplay: ProcessedCellRefOrGetter<GridOptions["getDisplay"]>;
}
>;
function getCellRowHandler(grid: Grid, row: number) { function getCellRowHandler(grid: Grid, row: number) {
return new Proxy({} as GridCell[], { return new Proxy({} as GridCell[], {
get(target, key) { get(target, key) {
if (key === "isProxy") {
return true;
}
if (typeof key !== "string") {
return;
}
if (key === "length") { if (key === "length") {
return unref(grid.cols); return unref(grid.cols);
} }
if (typeof key !== "number" && typeof key !== "string") {
return;
}
const keyNum = parseInt(key); const keyNum = typeof key === "number" ? key : parseInt(key);
if (!Number.isFinite(keyNum) || keyNum >= unref(grid.cols)) { if (Number.isFinite(keyNum) && keyNum < unref(grid.cols)) {
if (keyNum in target) { if (keyNum in target) {
return target[keyNum]; return target[keyNum];
} }
@ -144,20 +140,20 @@ function getCellRowHandler(grid: Grid, row: number) {
if (key === "length") { if (key === "length") {
return true; return true;
} }
if (typeof key !== "string") { if (typeof key !== "number" && typeof key !== "string") {
return false; return false;
} }
const keyNum = parseInt(key); const keyNum = typeof key === "number" ? key : parseInt(key);
if (!Number.isFinite(keyNum) || keyNum >= unref(grid.cols)) { if (!Number.isFinite(keyNum) || keyNum >= unref(grid.cols)) {
return false; return false;
} }
return true; return true;
}, },
getOwnPropertyDescriptor(target, key) { getOwnPropertyDescriptor(target, key) {
if (typeof key !== "string") { if (typeof key !== "number" && typeof key !== "string") {
return; return;
} }
const keyNum = parseInt(key); const keyNum = typeof key === "number" ? key : parseInt(key);
if (key !== "length" && (!Number.isFinite(keyNum) || keyNum >= unref(grid.cols))) { if (key !== "length" && (!Number.isFinite(keyNum) || keyNum >= unref(grid.cols))) {
return; return;
} }
@ -200,8 +196,6 @@ function getCellHandler(grid: Grid, row: number, col: number): GridCell {
// The typing in this function is absolutely atrocious in order to support custom properties // The typing in this function is absolutely atrocious in order to support custom properties
get(target, key, receiver) { get(target, key, receiver) {
switch (key) { switch (key) {
case "isProxy":
return true;
case "wrappers": case "wrappers":
return []; return [];
case VueFeature: case VueFeature:
@ -219,32 +213,28 @@ function getCellHandler(grid: Grid, row: number, col: number): GridCell {
case "state": { case "state": {
return grid.getState(row, col); return grid.getState(row, col);
} }
case "id":
return target.id = target.id ?? getUniqueID("gridcell");
case "components": case "components":
return [ return [
computed(() => ( computed(() => (
<GridCell <Clickable
onClick={receiver.onClick} onClick={receiver.onClick}
onHold={receiver.onHold} onHold={receiver.onHold}
display={receiver.display} display={receiver.display}
title={receiver.title}
canClick={receiver.canClick} canClick={receiver.canClick}
/> />
)) ))
]; ];
} }
let prop = (grid as any)[key]; if (typeof key === "symbol") {
return (grid as any)[key];
if (isFunction(prop)) {
return () => prop.call(receiver, row, col, grid.getState(row, col));
}
if (prop != null || typeof key === "symbol") {
return prop;
} }
key = key.slice(0, 1).toUpperCase() + key.slice(1); key = key.slice(0, 1).toUpperCase() + key.slice(1);
prop = (grid as any)[`get${key}`]; let prop = (grid as any)[`get${key}`];
if (isFunction(prop)) { if (isFunction(prop)) {
if (!(key in cache)) { if (!(key in cache)) {
cache[key] = computed(() => cache[key] = computed(() =>
@ -263,14 +253,24 @@ function getCellHandler(grid: Grid, row: number, col: number): GridCell {
return prop; return prop;
} }
// Revert key change
key = key.slice(0, 1).toLowerCase() + key.slice(1);
prop = (grid as any)[key];
if (isFunction(prop)) {
return () => prop.call(receiver, row, col, grid.getState(row, col));
}
return (grid as any)[key]; return (grid as any)[key];
}, },
set(target, key, value) { set(target, key, value) {
console.log("!!?", key, value)
if (typeof key !== "string") { if (typeof key !== "string") {
return false; return false;
} }
key = `set${key.slice(0, 1).toUpperCase() + key.slice(1)}`; key = `set${key.slice(0, 1).toUpperCase() + key.slice(1)}`;
if (key in grid && isFunction((grid as any)[key]) && (grid as any)[key].length < 3) { console.log(key, grid[key])
if (key in grid && isFunction((grid as any)[key]) && (grid as any)[key].length <= 3) {
(grid as any)[key].call(grid, row, col, value); (grid as any)[key].call(grid, row, col, value);
return true; return true;
} else { } else {
@ -296,6 +296,12 @@ function getCellHandler(grid: Grid, row: number, col: number): GridCell {
}); });
} }
function convertCellMaybeRefOrGetter<T>(
value: NonNullable<CellMaybeRefOrGetter<T>>
): ProcessedCellRefOrGetter<T>;
function convertCellMaybeRefOrGetter<T>(
value: CellMaybeRefOrGetter<T> | undefined
): ProcessedCellRefOrGetter<T> | undefined;
function convertCellMaybeRefOrGetter<T>( function convertCellMaybeRefOrGetter<T>(
value: CellMaybeRefOrGetter<T> value: CellMaybeRefOrGetter<T>
): ProcessedCellRefOrGetter<T> { ): ProcessedCellRefOrGetter<T> {
@ -309,10 +315,10 @@ function convertCellMaybeRefOrGetter<T>(
* Lazily creates a grid with the given options. * Lazily creates a grid with the given options.
* @param optionsFunc Grid options. * @param optionsFunc Grid options.
*/ */
export function createGrid<T extends GridOptions>(optionsFunc: OptionsFunc<T, BaseGrid, Grid>) { export function createGrid<T extends GridOptions>(optionsFunc: () => T) {
const cellState = persistent<Record<number, Record<number, State>>>({}, false); const cellState = persistent<Record<number, Record<number, State>>>({}, false);
return createLazyProxy(feature => { return createLazyProxy(() => {
const options = optionsFunc.call(feature, feature as Grid); const options = optionsFunc();
const { const {
rows, rows,
cols, cols,
@ -321,40 +327,57 @@ export function createGrid<T extends GridOptions>(optionsFunc: OptionsFunc<T, Ba
getStartState, getStartState,
getStyle, getStyle,
getClasses, getClasses,
getTitle, getDisplay: _getDisplay,
getDisplay,
onClick, onClick,
onHold, onHold,
...props ...props
} = options; } = options;
let getDisplay;
if (typeof _getDisplay === "object" && !isRef(_getDisplay) && !isJSXElement(_getDisplay)) {
const { getTitle, getDescription } = _getDisplay;
const getProcessedTitle = convertCellMaybeRefOrGetter(getTitle);
const getProcessedDescription = convertCellMaybeRefOrGetter(getDescription);
getDisplay = function(row: number, col: number, state: State) {
const title = typeof getProcessedTitle === "function" ? getProcessedTitle(row, col, state) : unref(getProcessedTitle);
const description = typeof getProcessedDescription === "function" ? getProcessedDescription(row, col, state) : unref(getProcessedDescription);
return <>
{title}
{description}
</>;
}
} else {
getDisplay = convertCellMaybeRefOrGetter(_getDisplay);
}
const grid = { const grid = {
type: GridType, type: GridType,
...(props as Omit<typeof props, keyof VueFeature | keyof GridOptions>), ...(props as Omit<typeof props, keyof VueFeature | keyof GridOptions>),
...vueFeatureMixin("grid", options, () => ( ...vueFeatureMixin("grid", options, () => (
<Grid rows={grid.rows} cols={grid.cols} cells={grid.cells} /> <Column>
)), {new Array(unref(grid.rows)).fill(0).map((_, row) => (
<Row>
{new Array(unref(grid.cols)).fill(0).map((_, col) =>
render(grid.cells[row][col]))}
</Row>))}
</Column>)),
cellState, cellState,
cells: new Proxy({} as Record<number, GridCell[]>, { cells: new Proxy({} as GridCell[][], {
get(target, key: PropertyKey) { get(target, key: PropertyKey) {
if (key === "isProxy") {
return true;
}
if (key === "length") { if (key === "length") {
return unref(grid.rows); return unref(grid.rows);
} }
if (typeof key !== "string") { if (typeof key !== "number" && typeof key !== "string") {
return; return;
} }
const keyNum = parseInt(key); const keyNum = typeof key === "number" ? key : parseInt(key);
if (!Number.isFinite(keyNum) || keyNum >= unref(grid.rows)) { if (Number.isFinite(keyNum) && keyNum < unref(grid.rows)) {
if (keyNum in target) { if (!(keyNum in target)) {
return target[keyNum]; target[keyNum] = getCellRowHandler(grid, keyNum);
} }
return (target[keyNum] = getCellRowHandler(grid, keyNum)); return target[keyNum];
} }
}, },
set(target, key, value) { set(target, key, value) {
@ -368,20 +391,20 @@ export function createGrid<T extends GridOptions>(optionsFunc: OptionsFunc<T, Ba
if (key === "length") { if (key === "length") {
return true; return true;
} }
if (typeof key !== "string") { if (typeof key !== "number" && typeof key !== "string") {
return false; return false;
} }
const keyNum = parseInt(key); const keyNum = typeof key === "number" ? key : parseInt(key);
if (!Number.isFinite(keyNum) || keyNum >= unref(grid.rows)) { if (!Number.isFinite(keyNum) || keyNum >= unref(grid.rows)) {
return false; return false;
} }
return true; return true;
}, },
getOwnPropertyDescriptor(target, key) { getOwnPropertyDescriptor(target, key) {
if (typeof key !== "string") { if (typeof key !== "number" && typeof key !== "string") {
return; return;
} }
const keyNum = parseInt(key); const keyNum = typeof key === "number" ? key : parseInt(key);
if ( if (
key !== "length" && key !== "length" &&
(!Number.isFinite(keyNum) || keyNum >= unref(grid.rows)) (!Number.isFinite(keyNum) || keyNum >= unref(grid.rows))
@ -399,15 +422,16 @@ export function createGrid<T extends GridOptions>(optionsFunc: OptionsFunc<T, Ba
cols: processGetter(cols), cols: processGetter(cols),
getVisibility: convertCellMaybeRefOrGetter(getVisibility ?? true), getVisibility: convertCellMaybeRefOrGetter(getVisibility ?? true),
getCanClick: convertCellMaybeRefOrGetter(getCanClick ?? true), getCanClick: convertCellMaybeRefOrGetter(getCanClick ?? true),
getStartState: processGetter(getStartState), getStartState: typeof getStartState === "function" && getStartState.length > 0 ?
getStartState : processGetter(getStartState),
getStyle: convertCellMaybeRefOrGetter(getStyle), getStyle: convertCellMaybeRefOrGetter(getStyle),
getClasses: convertCellMaybeRefOrGetter(getClasses), getClasses: convertCellMaybeRefOrGetter(getClasses),
getTitle: convertCellMaybeRefOrGetter(getTitle), getDisplay,
getDisplay: convertCellMaybeRefOrGetter(getDisplay),
getID: function (row: number, col: number): string { getID: function (row: number, col: number): string {
return grid.id + "-" + row + "-" + col; return grid.id + "-" + row + "-" + col;
}, },
getState: function (row: number, col: number): State { getState: function (row: number, col: number): State {
cellState.value[row] ??= {};
if (cellState.value[row][col] != null) { if (cellState.value[row][col] != null) {
return cellState.value[row][col]; return cellState.value[row][col];
} }
@ -421,7 +445,7 @@ export function createGrid<T extends GridOptions>(optionsFunc: OptionsFunc<T, Ba
onClick == null onClick == null
? undefined ? undefined
: function (row, col, state, e) { : function (row, col, state, e) {
if (grid.cells[row][col].canClick) { if (grid.cells[row][col].canClick !== false) {
onClick.call(grid, row, col, state, e); onClick.call(grid, row, col, state, e);
} }
}, },
@ -429,7 +453,7 @@ export function createGrid<T extends GridOptions>(optionsFunc: OptionsFunc<T, Ba
onHold == null onHold == null
? undefined ? undefined
: function (row, col, state) { : function (row, col, state) {
if (grid.cells[row][col].canClick) { if (grid.cells[row][col].canClick !== false) {
onHold.call(grid, row, col, state); onHold.call(grid, row, col, state);
} }
} }

View file

@ -1,18 +1,12 @@
import Hotkey from "components/Hotkey.vue"; import Hotkey from "components/Hotkey.vue";
import { hasWon } from "data/projEntry"; import { hasWon } from "data/projEntry";
import type { OptionsFunc, Replace } from "features/feature";
import { findFeatures } from "features/feature"; import { findFeatures } from "features/feature";
import { globalBus } from "game/events"; import { globalBus } from "game/events";
import player from "game/player"; import player from "game/player";
import { registerInfoComponent } from "game/settings"; import { registerInfoComponent } from "game/settings";
import { import { processGetter } from "util/computed";
processGetter,
type MaybeRefOrGetter,
type UnwrapRef,
type MaybeRef
} from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy } from "util/proxies";
import { shallowReactive, unref } from "vue"; import { MaybeRef, MaybeRefOrGetter, shallowReactive, unref } from "vue";
/** A dictionary of all hotkeys. */ /** A dictionary of all hotkeys. */
export const hotkeys: Record<string, Hotkey | undefined> = shallowReactive({}); export const hotkeys: Record<string, Hotkey | undefined> = shallowReactive({});
@ -33,34 +27,29 @@ export interface HotkeyOptions {
onPress: (e?: MouseEvent | TouchEvent) => void; onPress: (e?: MouseEvent | TouchEvent) => void;
} }
/** /** An object that represents a hotkey shortcut that performs an action upon a key sequence being pressed. */
* The properties that are added onto a processed {@link HotkeyOptions} to create an {@link Hotkey}. export interface Hotkey {
*/ /** Whether or not this hotkey is currently enabled. */
export interface BaseHotkey { enabled: MaybeRef<boolean>;
/** The key tied to this hotkey */
key: string;
/** The description of this hotkey, to display in the settings. */
description: MaybeRef<string>;
/** What to do upon pressing the key. */
onPress: (e?: MouseEvent | TouchEvent) => void;
/** A symbol that helps identify features of the same type. */ /** A symbol that helps identify features of the same type. */
type: typeof HotkeyType; type: typeof HotkeyType;
} }
/** An object that represents a hotkey shortcut that performs an action upon a key sequence being pressed. */
export type Hotkey = Replace<
Replace<HotkeyOptions, BaseHotkey>,
{
enabled: MaybeRef<boolean>;
description: UnwrapRef<HotkeyOptions["description"]>;
}
>;
const uppercaseNumbers = [")", "!", "@", "#", "$", "%", "^", "&", "*", "("]; const uppercaseNumbers = [")", "!", "@", "#", "$", "%", "^", "&", "*", "("];
/** /**
* Lazily creates a hotkey with the given options. * Lazily creates a hotkey with the given options.
* @param optionsFunc Hotkey options. * @param optionsFunc Hotkey options.
*/ */
export function createHotkey<T extends HotkeyOptions>( export function createHotkey<T extends HotkeyOptions>(optionsFunc: () => T) {
optionsFunc: OptionsFunc<T, BaseHotkey, Hotkey> return createLazyProxy(() => {
) { const options = optionsFunc();
return createLazyProxy(feature => {
const options = optionsFunc.call(feature, feature as Hotkey);
const { enabled, description, key, onPress, ...props } = options; const { enabled, description, key, onPress, ...props } = options;
const hotkey = { const hotkey = {
@ -119,7 +108,7 @@ document.onkeydown = function (e) {
keysToCheck.push("ctrl+" + e.key); keysToCheck.push("ctrl+" + e.key);
} }
const hotkey = hotkeys[keysToCheck.find(key => key in hotkeys) ?? ""]; const hotkey = hotkeys[keysToCheck.find(key => key in hotkeys) ?? ""];
if (hotkey != null && unref(hotkey.enabled)) { if (hotkey != null && unref(hotkey.enabled) !== false) {
e.preventDefault(); e.preventDefault();
hotkey.onPress(); hotkey.onPress();
} }

View file

@ -15,8 +15,8 @@
<Title /> <Title />
</button> </button>
<CollapseTransition> <CollapseTransition>
<div v-if="!unref(collapsed)" class="body" :style="{ backgroundColor: unref(color) }"> <div v-if="!unref(collapsed)" class="body" :style="unref(bodyStyle)">
<Body :style="unref(bodyStyle)" /> <Body />
</div> </div>
</CollapseTransition> </CollapseTransition>
</div> </div>
@ -79,6 +79,8 @@ const stacked = computed(() => themes[settings.theme].mergeAdjacent);
width: auto; width: auto;
text-align: left; text-align: left;
padding-left: 30px; padding-left: 30px;
border-radius: 0;
margin: 00;
} }
.infobox:not(.stacked) .title { .infobox:not(.stacked) .title {
@ -117,21 +119,15 @@ const stacked = computed(() => themes[settings.theme].mergeAdjacent);
.body { .body {
transition-duration: 0.5s; transition-duration: 0.5s;
border-radius: 5px; padding: 8px;
border-top-left-radius: 0; width: 100%;
display: block;
box-sizing: border-box;
background-color: var(--background);
border-radius: 0 0 var(--feature-margin) var(--feature-margin);
} }
.infobox:not(.stacked) .body { .infobox:not(.stacked) .body {
padding: 4px; padding: 4px;
} }
.body > * {
padding: 8px;
width: 100%;
display: block;
box-sizing: border-box;
border-radius: 5px;
border-top-left-radius: 0;
background-color: var(--background);
}
</style> </style>

View file

@ -1,11 +1,10 @@
import type { OptionsFunc, Replace } from "features/feature";
import Infobox from "features/infoboxes/Infobox.vue"; import Infobox from "features/infoboxes/Infobox.vue";
import type { Persistent } from "game/persistence"; import type { Persistent } from "game/persistence";
import { persistent } from "game/persistence"; import { persistent } from "game/persistence";
import { ProcessedRefOrGetter, processGetter } from "util/computed"; import { processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy } from "util/proxies";
import { Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue"; import { Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
import { CSSProperties, MaybeRefOrGetter } from "vue"; import { CSSProperties, MaybeRef, MaybeRefOrGetter } from "vue";
/** A symbol used to identify {@link Infobox} features. */ /** A symbol used to identify {@link Infobox} features. */
export const InfoboxType = Symbol("Infobox"); export const InfoboxType = Symbol("Infobox");
@ -14,7 +13,7 @@ export const InfoboxType = Symbol("Infobox");
* An object that configures an {@link Infobox}. * An object that configures an {@link Infobox}.
*/ */
export interface InfoboxOptions extends VueFeatureOptions { export interface InfoboxOptions extends VueFeatureOptions {
/** The background color of the Infobox. */ /** The background color of the Infobox. Defaults to the layer color. */
color?: MaybeRefOrGetter<string>; color?: MaybeRefOrGetter<string>;
/** CSS to apply to the title of the infobox. */ /** CSS to apply to the title of the infobox. */
titleStyle?: MaybeRefOrGetter<CSSProperties>; titleStyle?: MaybeRefOrGetter<CSSProperties>;
@ -26,38 +25,32 @@ export interface InfoboxOptions extends VueFeatureOptions {
display: MaybeRefOrGetter<Renderable>; display: MaybeRefOrGetter<Renderable>;
} }
/** /** An object that represents a feature that displays information in a collapsible way. */
* The properties that are added onto a processed {@link InfoboxOptions} to create an {@link Infobox}. export interface Infobox extends VueFeature {
*/ /** The background color of the Infobox. */
export interface BaseInfobox extends VueFeature { color?: MaybeRef<string>;
/** CSS to apply to the title of the infobox. */
titleStyle?: MaybeRef<CSSProperties>;
/** CSS to apply to the body of the infobox. */
bodyStyle?: MaybeRef<CSSProperties>;
/** A header to appear at the top of the display. */
title: MaybeRef<Renderable>;
/** The main text that appears in the display. */
display: MaybeRef<Renderable>;
/** Whether or not this infobox is collapsed. */ /** Whether or not this infobox is collapsed. */
collapsed: Persistent<boolean>; collapsed: Persistent<boolean>;
/** A symbol that helps identify features of the same type. */ /** A symbol that helps identify features of the same type. */
type: typeof InfoboxType; type: typeof InfoboxType;
} }
/** An object that represents a feature that displays information in a collapsible way. */
export type Infobox = Replace<
Replace<InfoboxOptions, BaseInfobox>,
{
color: ProcessedRefOrGetter<InfoboxOptions["color"]>;
titleStyle: ProcessedRefOrGetter<InfoboxOptions["titleStyle"]>;
bodyStyle: ProcessedRefOrGetter<InfoboxOptions["bodyStyle"]>;
title: ProcessedRefOrGetter<InfoboxOptions["title"]>;
display: ProcessedRefOrGetter<InfoboxOptions["display"]>;
}
>;
/** /**
* Lazily creates an infobox with the given options. * Lazily creates an infobox with the given options.
* @param optionsFunc Infobox options. * @param optionsFunc Infobox options.
*/ */
export function createInfobox<T extends InfoboxOptions>( export function createInfobox<T extends InfoboxOptions>(optionsFunc: () => T) {
optionsFunc: OptionsFunc<T, BaseInfobox, Infobox>
) {
const collapsed = persistent<boolean>(false, false); const collapsed = persistent<boolean>(false, false);
return createLazyProxy(feature => { return createLazyProxy(() => {
const options = optionsFunc.call(feature, feature as Infobox); const options = optionsFunc();
const { color, titleStyle, bodyStyle, title, display, ...props } = options; const { color, titleStyle, bodyStyle, title, display, ...props } = options;
const infobox = { const infobox = {
@ -74,7 +67,7 @@ export function createInfobox<T extends InfoboxOptions>(
/> />
)), )),
collapsed, collapsed,
color: processGetter(color), color: processGetter(color) ?? "--layer-color",
titleStyle: processGetter(titleStyle), titleStyle: processGetter(titleStyle),
bodyStyle: processGetter(bodyStyle), bodyStyle: processGetter(bodyStyle),
title: processGetter(title), title: processGetter(title),

View file

@ -15,22 +15,30 @@
<script setup lang="ts"> <script setup lang="ts">
import type { FeatureNode } from "game/layers"; import type { FeatureNode } from "game/layers";
import { BoundsInjectionKey, NodesInjectionKey } from "game/layers"; import { BoundsInjectionKey, NodesInjectionKey } from "game/layers";
import { computed, inject, onMounted, ref, unref, watch } from "vue"; import { computed, inject, onMounted, ref, shallowRef, unref, watch } from "vue";
import LinkVue from "./Link.vue"; import LinkVue from "./Link.vue";
import { Links } from "./links"; import { Links } from "./links";
const props = defineProps<{ links: Links["links"] }>(); const props = defineProps<{ links: Links["links"] }>();
const resizeListener = ref<Element | null>(null); function updateBounds() {
boundingRect.value = resizeListener.value?.getBoundingClientRect();
}
const resizeObserver = new ResizeObserver(updateBounds);
const resizeListener = shallowRef<HTMLElement | null>(null);
const nodes = inject(NodesInjectionKey, ref<Record<string, FeatureNode | undefined>>({})); const nodes = inject(NodesInjectionKey, ref<Record<string, FeatureNode | undefined>>({}));
const outerBoundingRect = inject(BoundsInjectionKey, ref<DOMRect | undefined>(undefined)); const outerBoundingRect = inject(BoundsInjectionKey, ref<DOMRect | undefined>(undefined));
const boundingRect = ref<DOMRect | undefined>(resizeListener.value?.getBoundingClientRect()); const boundingRect = ref<DOMRect | undefined>(resizeListener.value?.getBoundingClientRect());
watch( watch(outerBoundingRect, updateBounds);
outerBoundingRect, onMounted(() => {
() => (boundingRect.value = resizeListener.value?.getBoundingClientRect()) const resListener = resizeListener.value;
); if (resListener != null) {
onMounted(() => (boundingRect.value = resizeListener.value?.getBoundingClientRect())); resizeObserver.observe(resListener);
}
updateBounds();
});
const validLinks = computed(() => { const validLinks = computed(() => {
const n = nodes.value; const n = nodes.value;
@ -42,23 +50,14 @@ const validLinks = computed(() => {
</script> </script>
<style scoped> <style scoped>
.resize-listener { .resize-listener, svg {
position: absolute;
top: 0px;
left: 0;
right: -4px;
bottom: 5px;
z-index: -10;
pointer-events: none;
}
svg {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
width: 100%;
height: 100%;
z-index: -10; z-index: -10;
pointer-events: none; pointer-events: none;
margin: 0;
width: 100%;
height: 100%;
} }
</style> </style>

View file

@ -1,16 +1,15 @@
import type { OptionsFunc, Replace } from "features/feature";
import type { Position } from "game/layers"; import type { Position } from "game/layers";
import { processGetter } from "util/computed"; import { processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy } from "util/proxies";
import { VueFeature, vueFeatureMixin } from "util/vue"; import { VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
import type { MaybeRef, MaybeRefOrGetter, SVGAttributes } from "vue"; import { unref, type MaybeRef, type MaybeRefOrGetter, type SVGAttributes } from "vue";
import Links from "./Links.vue"; import Links from "./Links.vue";
/** A symbol used to identify {@link Links} features. */ /** A symbol used to identify {@link Links} features. */
export const LinksType = Symbol("Links"); export const LinksType = Symbol("Links");
/** Represents a link between two nodes. It will be displayed as an SVG line, and can take any appropriate properties for an SVG line element. */ /** Represents a link between two nodes. It will be displayed as an SVG line, and can take any appropriate properties for an SVG line element. */
export interface Link extends SVGAttributes { export interface Link extends /* @vue-ignore */ SVGAttributes {
startNode: { id: string }; startNode: { id: string };
endNode: { id: string }; endNode: { id: string };
offsetStart?: Position; offsetStart?: Position;
@ -18,40 +17,35 @@ export interface Link extends SVGAttributes {
} }
/** An object that configures a {@link Links}. */ /** An object that configures a {@link Links}. */
export interface LinksOptions { export interface LinksOptions extends VueFeatureOptions {
/** The list of links to display. */ /** The list of links to display. */
links: MaybeRefOrGetter<Link[]>; links: MaybeRefOrGetter<Link[]>;
} }
/** /** An object that represents a list of links between nodes, which are the elements in the DOM for any renderable feature. */
* The properties that are added onto a processed {@link LinksOptions} to create an {@link Links}. export interface Links extends VueFeature {
*/ /** The list of links to display. */
export interface BaseLinks extends VueFeature { links: MaybeRef<Link[]>;
/** A symbol that helps identify features of the same type. */ /** A symbol that helps identify features of the same type. */
type: typeof LinksType; type: typeof LinksType;
} }
/** An object that represents a list of links between nodes, which are the elements in the DOM for any renderable feature. */
export type Links = Replace<
Replace<LinksOptions, BaseLinks>,
{
links: MaybeRef<Link[]>;
}
>;
/** /**
* Lazily creates links with the given options. * Lazily creates links with the given options.
* @param optionsFunc Links options. * @param optionsFunc Links options.
*/ */
export function createLinks<T extends LinksOptions>(optionsFunc: OptionsFunc<T, BaseLinks, Links>) { export function createLinks<T extends LinksOptions>(optionsFunc: () => T) {
return createLazyProxy(feature => { return createLazyProxy(() => {
const options = optionsFunc?.call(feature, feature as Links); const options = optionsFunc?.();
const { links, ...props } = options; const { links, style: _style, ...props } = options;
const style = processGetter(_style);
options.style = () => ({ position: "static", ...(unref(style) ?? {}) });
const retLinks = { const retLinks = {
type: LinksType, type: LinksType,
...(props as Omit<typeof props, keyof VueFeature | keyof LinksOptions>), ...(props as Omit<typeof props, keyof VueFeature | keyof LinksOptions>),
...vueFeatureMixin("links", {}, () => <Links links={retLinks.links} />), ...vueFeatureMixin("links", options, () => <Links links={retLinks.links} />),
links: processGetter(links) links: processGetter(links)
} satisfies Links; } satisfies Links;

View file

@ -24,7 +24,6 @@ const resizeObserver = new ResizeObserver(updateBounds);
const resizeListener = shallowRef<HTMLElement | null>(null); const resizeListener = shallowRef<HTMLElement | null>(null);
onMounted(() => { onMounted(() => {
// ResizeListener exists because ResizeObserver's don't work when told to observe an SVG element
const resListener = resizeListener.value; const resListener = resizeListener.value;
if (resListener != null) { if (resListener != null) {
resizeObserver.observe(resListener); resizeObserver.observe(resListener);

View file

@ -1,8 +1,6 @@
import { Application } from "@pixi/app"; import { Application } from "@pixi/app";
import type { EmitterConfigV3 } from "@pixi/particle-emitter"; import type { EmitterConfigV3 } from "@pixi/particle-emitter";
import { Emitter, upgradeConfig } from "@pixi/particle-emitter"; import { Emitter, upgradeConfig } from "@pixi/particle-emitter";
import type { OptionsFunc, Replace } from "features/feature";
import { ProcessedRefOrGetter } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy } from "util/proxies";
import { VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue"; import { VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
import { Ref, shallowRef } from "vue"; import { Ref, shallowRef } from "vue";
@ -22,9 +20,14 @@ export interface ParticlesOptions extends VueFeatureOptions {
} }
/** /**
* The properties that are added onto a processed {@link ParticlesOptions} to create an {@link Particles}. * An object that represents a feature that display particle effects on the screen.
* The config should typically be gotten by designing the effect using the [online particle effect editor](https://pixijs.io/pixi-particles-editor/) and passing it into the {@link upgradeConfig} from @pixi/particle-emitter.
*/ */
export interface BaseParticles extends VueFeature { export interface Particles extends VueFeature {
/** A function that is called when the particles canvas is resized. */
onContainerResized?: (boundingRect: DOMRect) => void;
/** A function that is called whenever the particles element is reloaded during development. For restarting particle effects. */
onHotReload?: VoidFunction;
/** The Pixi.JS Application powering this particles canvas. */ /** The Pixi.JS Application powering this particles canvas. */
app: Ref<null | Application>; app: Ref<null | Application>;
/** /**
@ -37,27 +40,13 @@ export interface BaseParticles extends VueFeature {
type: typeof ParticlesType; type: typeof ParticlesType;
} }
/**
* An object that represents a feature that display particle effects on the screen.
* The config should typically be gotten by designing the effect using the [online particle effect editor](https://pixijs.io/pixi-particles-editor/) and passing it into the {@link upgradeConfig} from @pixi/particle-emitter.
*/
export type Particles = Replace<
Replace<ParticlesOptions, BaseParticles>,
{
classes: ProcessedRefOrGetter<ParticlesOptions["classes"]>;
style: ProcessedRefOrGetter<ParticlesOptions["style"]>;
}
>;
/** /**
* Lazily creates particles with the given options. * Lazily creates particles with the given options.
* @param optionsFunc Particles options. * @param optionsFunc Particles options.
*/ */
export function createParticles<T extends ParticlesOptions>( export function createParticles<T extends ParticlesOptions>(optionsFunc?: () => T) {
optionsFunc?: OptionsFunc<T, BaseParticles, Particles> return createLazyProxy(() => {
) { const options = optionsFunc?.() ?? ({} as T);
return createLazyProxy(feature => {
const options = optionsFunc?.call(feature, feature as Particles) ?? ({} as T);
const { onContainerResized, onHotReload, ...props } = options; const { onContainerResized, onHotReload, ...props } = options;
let emittersToAdd: { let emittersToAdd: {

View file

@ -1,4 +1,3 @@
import type { OptionsFunc, Replace } from "features/feature";
import { globalBus } from "game/events"; import { globalBus } from "game/events";
import Formula from "game/formulas/formulas"; import Formula from "game/formulas/formulas";
import type { BaseLayer } from "game/layers"; import type { BaseLayer } from "game/layers";
@ -11,9 +10,9 @@ import {
} from "game/persistence"; } from "game/persistence";
import type { Unsubscribe } from "nanoevents"; import type { Unsubscribe } from "nanoevents";
import Decimal from "util/bignum"; import Decimal from "util/bignum";
import { processGetter, type MaybeRefOrGetter, type UnwrapRef } from "util/computed"; import { processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy } from "util/proxies";
import { isRef, unref } from "vue"; import { isRef, MaybeRef, MaybeRefOrGetter, unref } from "vue";
/** A symbol used to identify {@link Reset} features. */ /** A symbol used to identify {@link Reset} features. */
export const ResetType = Symbol("Reset"); export const ResetType = Symbol("Reset");
@ -28,31 +27,25 @@ export interface ResetOptions {
onReset?: VoidFunction; onReset?: VoidFunction;
} }
/** /** An object that represents a reset mechanic, which resets progress back to its initial state. */
* The properties that are added onto a processed {@link ResetOptions} to create an {@link Reset}. export interface Reset {
*/ /** List of things to reset. Can include objects which will be recursed over for persistent values. */
export interface BaseReset { thingsToReset: MaybeRef<unknown[]>;
/** A function that is called when the reset is performed. */
onReset?: VoidFunction;
/** Trigger the reset. */ /** Trigger the reset. */
reset: VoidFunction; reset: VoidFunction;
/** A symbol that helps identify features of the same type. */ /** A symbol that helps identify features of the same type. */
type: typeof ResetType; type: typeof ResetType;
} }
/** An object that represents a reset mechanic, which resets progress back to its initial state. */
export type Reset = Replace<
Replace<ResetOptions, BaseReset>,
{
thingsToReset: UnwrapRef<ResetOptions["thingsToReset"]>;
}
>;
/** /**
* Lazily creates a reset with the given options. * Lazily creates a reset with the given options.
* @param optionsFunc Reset options. * @param optionsFunc Reset options.
*/ */
export function createReset<T extends ResetOptions>(optionsFunc: OptionsFunc<T, BaseReset, Reset>) { export function createReset<T extends ResetOptions>(optionsFunc: () => T) {
return createLazyProxy(feature => { return createLazyProxy(() => {
const options = optionsFunc.call(feature, feature as Reset); const options = optionsFunc();
const { thingsToReset, onReset, ...props } = options; const { thingsToReset, onReset, ...props } = options;
const reset = { const reset = {

View file

@ -3,16 +3,12 @@
<div <div
class="main-display-container" class="main-display-container"
:class="classes ?? {}" :class="classes ?? {}"
:style="[{ height: `${(effectRef?.$el.clientHeight ?? 0) + 50}px` }, style ?? {}]" :style="[{ height: `${(displayRef?.clientHeight ?? 0) + 20}px` }, style ?? {}]">
> <div class="main-display" ref="displayRef">
<div class="main-display">
<span v-if="showPrefix">You have </span> <span v-if="showPrefix">You have </span>
<ResourceVue :resource="resource" :color="color || 'white'" /> <ResourceVue :resource="resource" :color="color || 'white'" />
{{ resource.displayName {{ resource.displayName }}<!-- remove whitespace -->
}}<!-- remove whitespace --> <span v-if="effectDisplay">, <Effect /></span>
<span v-if="effectDisplay"
>, <Effect ref="effectRef"
/></span>
</div> </div>
</div> </div>
</Sticky> </Sticky>
@ -24,7 +20,7 @@ import type { Resource } from "features/resources/resource";
import ResourceVue from "features/resources/Resource.vue"; import ResourceVue from "features/resources/Resource.vue";
import Decimal from "util/bignum"; import Decimal from "util/bignum";
import { Renderable } from "util/vue"; import { Renderable } from "util/vue";
import { ComponentPublicInstance, computed, MaybeRefOrGetter, ref, StyleValue, toValue } from "vue"; import { computed, MaybeRefOrGetter, ref, StyleValue, toValue } from "vue";
const props = defineProps<{ const props = defineProps<{
resource: Resource; resource: Resource;
@ -34,7 +30,7 @@ const props = defineProps<{
effectDisplay?: MaybeRefOrGetter<Renderable>; effectDisplay?: MaybeRefOrGetter<Renderable>;
}>(); }>();
const effectRef = ref<ComponentPublicInstance | null>(null); const displayRef = ref<Element | null>(null);
const Effect = () => toValue(props.effectDisplay); const Effect = () => toValue(props.effectDisplay);

View file

@ -9,12 +9,13 @@ import { getNotifyStyle } from "game/notifications";
import { render } from "util/vue"; import { render } from "util/vue";
import { computed, unref } from "vue"; import { computed, unref } from "vue";
import { TabButton } from "./tabFamily"; import { TabButton } from "./tabFamily";
import themes from "data/themes";
import settings from "game/settings";
const props = defineProps<{ const props = defineProps<{
display: TabButton["display"]; display: TabButton["display"];
glowColor: TabButton["glowColor"]; glowColor: TabButton["glowColor"];
active?: boolean; active?: boolean;
floating?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@ -28,12 +29,16 @@ const glowColorStyle = computed(() => {
if (color == null || color === "") { if (color == null || color === "") {
return {}; return {};
} }
if (props.floating) { if (floating.value) {
return getNotifyStyle(color); return getNotifyStyle(color);
} }
return { boxShadow: `0px 9px 5px -6px ${color}` }; return { boxShadow: `0px 9px 5px -6px ${color}` };
}); });
const floating = computed(() => {
return themes[settings.theme].floatingTabs;
});
function selectTab() { function selectTab() {
emit("selectTab"); emit("selectTab");
} }

View file

@ -6,15 +6,7 @@
:style="unref(buttonContainerStyle)" :style="unref(buttonContainerStyle)"
> >
<div class="tab-buttons" :class="{ floating }"> <div class="tab-buttons" :class="{ floating }">
<TabButton <TabButtons />
v-for="(button, id) in unref(tabs)"
@selectTab="selected.value = id"
:floating="floating"
:key="id"
:active="unref(button.tab) === unref(activeTab)"
:display="button.display"
:glowColor="button.glowColor"
/>
</div> </div>
</Sticky> </Sticky>
<Component v-if="unref(activeTab) != null" /> <Component v-if="unref(activeTab) != null" />
@ -23,28 +15,22 @@
<script setup lang="ts"> <script setup lang="ts">
import Sticky from "components/layout/Sticky.vue"; import Sticky from "components/layout/Sticky.vue";
import themes from "data/themes"; import { isType } from "features/feature";
import TabButton from "features/tabs/TabButton.vue";
import settings from "game/settings";
import { render } from "util/vue"; import { render } from "util/vue";
import type { Component } from "vue"; import type { Component } from "vue";
import { computed, unref } from "vue"; import { computed, unref } from "vue";
import { TabFamily } from "./tabFamily";
import { TabType } from "./tab"; import { TabType } from "./tab";
import { isType } from "features/feature"; import { TabFamily } from "./tabFamily";
import themes from "data/themes";
import settings from "game/settings";
const props = defineProps<{ const props = defineProps<{
activeTab: TabFamily["activeTab"]; activeTab: TabFamily["activeTab"];
selected: TabFamily["selected"];
tabs: TabFamily["tabs"]; tabs: TabFamily["tabs"];
buttonContainerClasses: TabFamily["buttonContainerClasses"]; buttonContainerClasses: TabFamily["buttonContainerClasses"];
buttonContainerStyle: TabFamily["buttonContainerStyle"]; buttonContainerStyle: TabFamily["buttonContainerStyle"];
}>(); }>();
const floating = computed(() => {
return themes[settings.theme].floatingTabs;
});
const Component = () => { const Component = () => {
const activeTab = unref(props.activeTab); const activeTab = unref(props.activeTab);
if (activeTab == null) { if (activeTab == null) {
@ -53,6 +39,12 @@ const Component = () => {
return render(activeTab); return render(activeTab);
}; };
const floating = computed(() => {
return themes[settings.theme].floatingTabs;
});
const TabButtons = () => Object.values(props.tabs).map(tab => render(tab));
const tabClasses = computed(() => { const tabClasses = computed(() => {
const activeTab = unref(props.activeTab); const activeTab = unref(props.activeTab);
if (isType(activeTab, TabType)) { if (isType(activeTab, TabType)) {
@ -136,6 +128,10 @@ const tabStyle = computed(() => {
z-index: 4; z-index: 4;
} }
.tab-buttons > * {
margin: 0;
}
.layer-tab .layer-tab
> .tab-family-container:first-child:nth-last-child(3) > .tab-family-container:first-child:nth-last-child(3)
> .tab-buttons-container > .tab-buttons-container

View file

@ -1,8 +1,7 @@
import type { OptionsFunc, Replace } from "features/feature"; import { processGetter } from "util/computed";
import { ProcessedRefOrGetter, processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy } from "util/proxies";
import { render, Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue"; import { render, Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
import { MaybeRefOrGetter } from "vue"; import { MaybeRef, MaybeRefOrGetter } from "vue";
import { JSX } from "vue/jsx-runtime"; import { JSX } from "vue/jsx-runtime";
/** A symbol used to identify {@link Tab} features. */ /** A symbol used to identify {@link Tab} features. */
@ -17,31 +16,23 @@ export interface TabOptions extends VueFeatureOptions {
} }
/** /**
* The properties that are added onto a processed {@link TabOptions} to create an {@link Tab}. * An object representing a tab of content in a tabbed interface.
* @see {@link TabFamily}
*/ */
export interface BaseTab extends VueFeature { export interface Tab extends VueFeature {
/** The display to use for this tab. */
display: MaybeRef<Renderable>;
/** A symbol that helps identify features of the same type. */ /** A symbol that helps identify features of the same type. */
type: typeof TabType; type: typeof TabType;
} }
/**
* An object representing a tab of content in a tabbed interface.
* @see {@link TabFamily}
*/
export type Tab = Replace<
Replace<TabOptions, BaseTab>,
{
display: ProcessedRefOrGetter<TabOptions["display"]>;
}
>;
/** /**
* Lazily creates a tab with the given options. * Lazily creates a tab with the given options.
* @param optionsFunc Tab options. * @param optionsFunc Tab options.
*/ */
export function createTab<T extends TabOptions>(optionsFunc: OptionsFunc<T, BaseTab, Tab>) { export function createTab<T extends TabOptions>(optionsFunc: () => T) {
return createLazyProxy(feature => { return createLazyProxy(() => {
const options = optionsFunc?.call(feature, feature as Tab) ?? ({} as T); const options = optionsFunc?.() ?? ({} as T);
const { display, ...props } = options; const { display, ...props } = options;
const tab = { const tab = {

View file

@ -1,11 +1,10 @@
import type { OptionsFunc, Replace } from "features/feature";
import { isVisible } from "features/feature"; import { isVisible } from "features/feature";
import { Tab } from "features/tabs/tab"; import { Tab } from "features/tabs/tab";
import TabButton from "features/tabs/TabButton.vue"; import TabButton from "features/tabs/TabButton.vue";
import TabFamily from "features/tabs/TabFamily.vue"; import TabFamily from "features/tabs/TabFamily.vue";
import type { Persistent } from "game/persistence"; import type { Persistent } from "game/persistence";
import { persistent } from "game/persistence"; import { persistent } from "game/persistence";
import { ProcessedRefOrGetter, processGetter } from "util/computed"; import { processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy } from "util/proxies";
import { Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue"; import { Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
import type { CSSProperties, MaybeRef, MaybeRefOrGetter, Ref } from "vue"; import type { CSSProperties, MaybeRef, MaybeRefOrGetter, Ref } from "vue";
@ -28,26 +27,20 @@ export interface TabButtonOptions extends VueFeatureOptions {
glowColor?: MaybeRefOrGetter<string>; glowColor?: MaybeRefOrGetter<string>;
} }
/**
* The properties that are added onto a processed {@link TabButtonOptions} to create an {@link TabButton}.
*/
export interface BaseTabButton extends VueFeature {
/** A symbol that helps identify features of the same type. */
type: typeof TabButtonType;
}
/** /**
* An object that represents a button that can be clicked to change tabs in a tabbed interface. * An object that represents a button that can be clicked to change tabs in a tabbed interface.
* @see {@link TabFamily} * @see {@link TabFamily}
*/ */
export type TabButton = Replace< export interface TabButton extends VueFeature {
Replace<TabButtonOptions, BaseTabButton>, /** The tab to display when this button is clicked. */
{ tab: Tab | MaybeRef<Renderable>;
tab: Tab | MaybeRef<Renderable>; /** The label on this button. */
display: ProcessedRefOrGetter<TabButtonOptions["display"]>; display: MaybeRef<Renderable>;
glowColor: ProcessedRefOrGetter<TabButtonOptions["glowColor"]>; /** The color of the glow effect to display when this button is active. */
} glowColor?: MaybeRef<string>;
>; /** A symbol that helps identify features of the same type. */
type: typeof TabButtonType;
}
/** /**
* An object that configures a {@link TabFamily}. * An object that configures a {@link TabFamily}.
@ -60,11 +53,16 @@ export interface TabFamilyOptions extends VueFeatureOptions {
} }
/** /**
* The properties that are added onto a processed {@link TabFamilyOptions} to create an {@link TabFamily}. * An object that represents a tabbed interface.
* @see {@link TabFamily}
*/ */
export interface BaseTabFamily extends VueFeature { export interface TabFamily extends VueFeature {
/** A dictionary of CSS classes to apply to the list of buttons for changing tabs. */
buttonContainerClasses?: MaybeRef<Record<string, boolean>>;
/** CSS to apply to the list of buttons for changing tabs. */
buttonContainerStyle?: MaybeRef<CSSProperties>;
/** All the tabs within this family. */ /** All the tabs within this family. */
tabs: Record<string, TabButtonOptions>; tabs: Record<string, TabButton>;
/** The currently active tab, if any. */ /** The currently active tab, if any. */
activeTab: Ref<Tab | MaybeRef<Renderable> | null>; activeTab: Ref<Tab | MaybeRef<Renderable> | null>;
/** The name of the tab that is currently active. */ /** The name of the tab that is currently active. */
@ -73,32 +71,21 @@ export interface BaseTabFamily extends VueFeature {
type: typeof TabFamilyType; type: typeof TabFamilyType;
} }
/**
* An object that represents a tabbed interface.
* @see {@link TabFamily}
*/
export type TabFamily = Replace<
Replace<TabFamilyOptions, BaseTabFamily>,
{
tabs: Record<string, TabButton>;
}
>;
/** /**
* Lazily creates a tab family with the given options. * Lazily creates a tab family with the given options.
* @param optionsFunc Tab family options. * @param optionsFunc Tab family options.
*/ */
export function createTabFamily<T extends TabFamilyOptions>( export function createTabFamily<T extends TabFamilyOptions>(
tabs: Record<string, () => TabButtonOptions>, tabs: Record<string, () => TabButtonOptions>,
optionsFunc?: OptionsFunc<T, BaseTabFamily, TabFamily> optionsFunc?: () => T
) { ) {
if (Object.keys(tabs).length === 0) { if (Object.keys(tabs).length === 0) {
console.error("Cannot create tab family with 0 tabs"); console.error("Cannot create tab family with 0 tabs");
} }
const selected = persistent(Object.keys(tabs)[0], false); const selected = persistent(Object.keys(tabs)[0], false);
return createLazyProxy(feature => { return createLazyProxy(() => {
const options = optionsFunc?.call(feature, feature as TabFamily) ?? ({} as T); const options = optionsFunc?.() ?? ({} as T);
const { buttonContainerClasses, buttonContainerStyle, ...props } = options; const { buttonContainerClasses, buttonContainerStyle, ...props } = options;
const tabFamily = { const tabFamily = {
@ -107,7 +94,6 @@ export function createTabFamily<T extends TabFamilyOptions>(
...vueFeatureMixin("tabFamily", options, () => ( ...vueFeatureMixin("tabFamily", options, () => (
<TabFamily <TabFamily
activeTab={tabFamily.activeTab} activeTab={tabFamily.activeTab}
selected={tabFamily.selected}
tabs={tabFamily.tabs} tabs={tabFamily.tabs}
buttonContainerClasses={tabFamily.buttonContainerClasses} buttonContainerClasses={tabFamily.buttonContainerClasses}
buttonContainerStyle={tabFamily.buttonContainerStyle} buttonContainerStyle={tabFamily.buttonContainerStyle}
@ -120,7 +106,13 @@ export function createTabFamily<T extends TabFamilyOptions>(
const tabButton = { const tabButton = {
type: TabButtonType, type: TabButtonType,
...(props as Omit<typeof props, keyof VueFeature | keyof TabButtonOptions>), ...(props as Omit<typeof props, keyof VueFeature | keyof TabButtonOptions>),
...vueFeatureMixin("tabButton", options), ...vueFeatureMixin("tabButton", options, () =>
<TabButton
display={tabButton.display}
glowColor={tabButton.glowColor}
active={unref(tabButton.tab) === unref(tabFamily.activeTab)}
onSelectTab={() => tabFamily.selected.value = tab}
/>),
tab: processGetter(buttonTab), tab: processGetter(buttonTab),
glowColor: processGetter(glowColor), glowColor: processGetter(glowColor),
display: processGetter(display) display: processGetter(display)

View file

@ -9,7 +9,7 @@
import "components/common/table.css"; import "components/common/table.css";
import Links from "features/links/Links.vue"; import Links from "features/links/Links.vue";
import type { Tree } from "features/trees/tree"; import type { Tree } from "features/trees/tree";
import { joinJSX, render } from "util/vue"; import { render } from "util/vue";
import { unref } from "vue"; import { unref } from "vue";
const props = defineProps<{ const props = defineProps<{

View file

@ -1,5 +1,5 @@
<template> <template>
<div <button
:style="{ :style="{
backgroundColor: unref(color), backgroundColor: unref(color),
boxShadow: `-4px -4px 4px rgba(0, 0, 0, 0.25) inset, 0 0 20px ${unref( boxShadow: `-4px -4px 4px rgba(0, 0, 0, 0.25) inset, 0 0 20px ${unref(
@ -19,7 +19,7 @@
@touchcancel.passive="stop" @touchcancel.passive="stop"
> >
<Component /> <Component />
</div> </button>
</template> </template>
<script setup lang="tsx"> <script setup lang="tsx">
@ -53,7 +53,6 @@ const { start, stop } = setupHoldToClick(toRef(props, "onClick"), toRef(props, "
font-size: 40px; font-size: 40px;
color: rgba(0, 0, 0, 0.5); color: rgba(0, 0, 0, 0.5);
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.25); text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.25);
box-shadow: -4px -4px 4px rgba(0, 0, 0, 0.25) inset, 0px 0px 20px var(--background);
display: flex; display: flex;
} }

View file

@ -1,13 +1,13 @@
import type { OptionsFunc, Replace } from "features/feature";
import { Link } from "features/links/links"; import { Link } from "features/links/links";
import type { Reset } from "features/reset"; import type { Reset } from "features/reset";
import type { Resource } from "features/resources/resource"; import type { Resource } from "features/resources/resource";
import { displayResource } from "features/resources/resource"; import { displayResource } from "features/resources/resource";
import Tree from "features/trees/Tree.vue"; import Tree from "features/trees/Tree.vue";
import TreeNode from "features/trees/TreeNode.vue"; import TreeNode from "features/trees/TreeNode.vue";
import { noPersist } from "game/persistence";
import type { DecimalSource } from "util/bignum"; import type { DecimalSource } from "util/bignum";
import Decimal, { format, formatWhole } from "util/bignum"; import Decimal, { format, formatWhole } from "util/bignum";
import { ProcessedRefOrGetter, processGetter } from "util/computed"; import { processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy } from "util/proxies";
import { Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue"; import { Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
import type { MaybeRef, MaybeRefOrGetter, Ref } from "vue"; import type { MaybeRef, MaybeRefOrGetter, Ref } from "vue";
@ -41,31 +41,32 @@ export interface TreeNodeOptions extends VueFeatureOptions {
/** /**
* The properties that are added onto a processed {@link TreeNodeOptions} to create an {@link TreeNode}. * The properties that are added onto a processed {@link TreeNodeOptions} to create an {@link TreeNode}.
*/ */
export interface BaseTreeNode extends VueFeature { export interface TreeNode extends VueFeature {
/** Whether or not this tree node can be clicked. */
canClick?: MaybeRef<boolean>;
/** The background color for this node. */
color?: MaybeRef<string>;
/** The label to display on this tree node. */
display?: MaybeRef<Renderable>;
/** The color of the glow effect shown to notify the user there's something to do with this node. */
glowColor?: MaybeRef<string>;
/** 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;
/** A symbol that helps identify features of the same type. */ /** A symbol that helps identify features of the same type. */
type: typeof TreeNodeType; type: typeof TreeNodeType;
} }
/** An object that represents a node on a tree. */
export type TreeNode = Replace<
TreeNodeOptions & BaseTreeNode,
{
canClick: MaybeRef<boolean>;
color: ProcessedRefOrGetter<TreeNodeOptions["color"]>;
display: ProcessedRefOrGetter<TreeNodeOptions["display"]>;
glowColor: ProcessedRefOrGetter<TreeNodeOptions["glowColor"]>;
}
>;
/** /**
* Lazily creates a tree node with the given options. * Lazily creates a tree node with the given options.
* @param optionsFunc Tree Node options. * @param optionsFunc Tree Node options.
*/ */
export function createTreeNode<T extends TreeNodeOptions>( export function createTreeNode<T extends TreeNodeOptions>(optionsFunc?: () => T) {
optionsFunc?: OptionsFunc<T, BaseTreeNode, TreeNode> return createLazyProxy(() => {
) { const options = optionsFunc?.() ?? ({} as T);
return createLazyProxy(feature => {
const options = optionsFunc?.call(feature, feature as TreeNode) ?? ({} as T);
const { canClick, color, display, glowColor, onClick, onHold, ...props } = options; const { canClick, color, display, glowColor, onClick, onHold, ...props } = options;
const treeNode = { const treeNode = {
@ -131,9 +132,21 @@ export interface TreeOptions extends VueFeatureOptions {
onReset?: (node: TreeNode) => void; onReset?: (node: TreeNode) => void;
} }
export interface BaseTree extends VueFeature { export interface Tree extends VueFeature {
/** The nodes within the tree, in a 2D array. */
nodes: MaybeRef<TreeNode[][]>;
/** Nodes to show on the left side of the tree. */
leftSideNodes?: MaybeRef<TreeNode[]>;
/** Nodes to show on the right side of the tree. */
rightSideNodes?: MaybeRef<TreeNode[]>;
/** The branches between nodes within this tree. */
branches?: MaybeRef<TreeBranch[]>;
/** 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;
/** The link objects for each of the branches of the tree. */ /** The link objects for each of the branches of the tree. */
links: Ref<Link[]>; links: MaybeRef<Link[]>;
/** Cause a reset on this node and propagate it through the tree according to {@link TreeOptions.resetPropagation}. */ /** Cause a reset on this node and propagate it through the tree according to {@link TreeOptions.resetPropagation}. */
reset: (node: TreeNode) => void; reset: (node: TreeNode) => void;
/** A flag that is true while the reset is still propagating through the tree. */ /** A flag that is true while the reset is still propagating through the tree. */
@ -144,35 +157,29 @@ export interface BaseTree extends VueFeature {
type: typeof TreeType; 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<TreeOptions["nodes"]>;
leftSideNodes: ProcessedRefOrGetter<TreeOptions["leftSideNodes"]>;
rightSideNodes: ProcessedRefOrGetter<TreeOptions["rightSideNodes"]>;
branches: ProcessedRefOrGetter<TreeOptions["branches"]>;
}
>;
/** /**
* Lazily creates a tree with the given options. * Lazily creates a tree with the given options.
* @param optionsFunc Tree options. * @param optionsFunc Tree options.
*/ */
export function createTree<T extends TreeOptions>(optionsFunc: OptionsFunc<T, BaseTree, Tree>) { export function createTree<T extends TreeOptions>(optionsFunc: () => T) {
return createLazyProxy(feature => { return createLazyProxy(() => {
const options = optionsFunc.call(feature, feature as Tree); const options = optionsFunc();
const { const {
branches, branches: _branches,
nodes, nodes,
leftSideNodes, leftSideNodes,
rightSideNodes, rightSideNodes,
reset,
resetPropagation, resetPropagation,
onReset, onReset,
style: _style,
...props ...props
} = options; } = options;
const style = processGetter(_style);
options.style = () => ({ position: "static", ...(unref(style) ?? {}) });
const branches = _branches == null ? undefined : processGetter(_branches);
const tree = { const tree = {
type: TreeType, type: TreeType,
...(props as Omit<typeof props, keyof VueFeature | keyof TreeOptions>), ...(props as Omit<typeof props, keyof VueFeature | keyof TreeOptions>),
@ -184,25 +191,23 @@ export function createTree<T extends TreeOptions>(optionsFunc: OptionsFunc<T, Ba
branches={tree.branches} branches={tree.branches}
/> />
)), )),
branches: processGetter(branches), branches,
isResetting: ref(false), isResetting: ref(false),
resettingNode: shallowRef<TreeNode | null>(null), resettingNode: shallowRef<TreeNode | null>(null),
nodes: processGetter(nodes), nodes: processGetter(nodes),
leftSideNodes: processGetter(leftSideNodes), leftSideNodes: processGetter(leftSideNodes),
rightSideNodes: processGetter(rightSideNodes), rightSideNodes: processGetter(rightSideNodes),
links: processGetter(branches) ?? [], links: branches == null ? [] : noPersist(branches),
resetPropagation, resetPropagation,
onReset, onReset,
reset: reset: function (node: TreeNode) {
reset ?? tree.isResetting.value = true;
function (node: TreeNode) { tree.resettingNode.value = node;
tree.isResetting.value = true; tree.resetPropagation?.(tree, node);
tree.resettingNode.value = node; tree.onReset?.(node);
tree.resetPropagation?.(tree, node); tree.isResetting.value = false;
tree.onReset?.(node); tree.resettingNode.value = null;
tree.isResetting.value = false; }
tree.resettingNode.value = null;
}
} satisfies Tree; } satisfies Tree;
return tree; return tree;

View file

@ -12,7 +12,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SVGAttributes } from "vue"; import type { SVGAttributes } from "vue";
interface CircleProgressProps extends SVGAttributes { interface CircleProgressProps extends /* @vue-ignore */ SVGAttributes {
r: number; r: number;
progress: number; progress: number;
stroke: string; stroke: string;

View file

@ -2,10 +2,11 @@
<div <div
class="board-node" class="board-node"
:style="`transform: translate(calc(${unref(position).x}px - 50%), ${unref(position).y}px);`" :style="`transform: translate(calc(${unref(position).x}px - 50%), ${unref(position).y}px);`"
@mousedown="e => mouseDown(e)" @click.capture.stop="() => {}"
@touchstart.passive="e => mouseDown(e)" @mousedown="mouseDown"
@mouseup="e => mouseUp(e)" @touchstart.passive="mouseDown"
@touchend.passive="e => mouseUp(e)" @mouseup.capture="mouseUp"
@touchend.passive="mouseUp"
> >
<slot /> <slot />
</div> </div>

View file

@ -14,7 +14,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SVGAttributes } from "vue"; import type { SVGAttributes } from "vue";
interface SquareProgressProps extends SVGAttributes { interface SquareProgressProps extends /* @vue-ignore */ SVGAttributes {
size: number; size: number;
progress: number; progress: number;
stroke: string; stroke: string;

View file

@ -1,14 +1,15 @@
import Board from "./Board.vue";
import Draggable from "./Draggable.vue";
import { globalBus } from "game/events"; import { globalBus } from "game/events";
import { Persistent, persistent } from "game/persistence"; import { DefaultValue, Persistent, persistent } from "game/persistence";
import type { PanZoom } from "panzoom"; import type { PanZoom } from "panzoom";
import { Direction, isFunction } from "util/common"; import { Direction, isFunction } from "util/common";
import { processGetter } from "util/computed"; import { processGetter } from "util/computed";
import { createLazyProxy, runAfterEvaluation } from "util/proxies";
import { Renderable, VueFeature } from "util/vue"; import { Renderable, VueFeature } from "util/vue";
import type { ComponentPublicInstance, MaybeRef, MaybeRefOrGetter, Ref } from "vue"; import type { ComponentPublicInstance, ComputedRef, MaybeRef, MaybeRefOrGetter, Ref } from "vue";
import { computed, ref, unref, watchEffect } from "vue"; import { computed, ref, unref, watchEffect } from "vue";
import panZoom from "vue-panzoom"; import panZoom from "vue-panzoom";
import Board from "./Board.vue";
import Draggable from "./Draggable.vue";
// Register panzoom so it can be used in Board.vue // Register panzoom so it can be used in Board.vue
globalBus.on("setupVue", app => panZoom.install(app)); globalBus.on("setupVue", app => panZoom.install(app));
@ -254,46 +255,85 @@ export interface MakeDraggableOptions<T> {
initialPosition?: NodePosition; initialPosition?: NodePosition;
} }
/** Contains all the data tied to making a vue feature draggable */
export interface Draggable<T> extends MakeDraggableOptions<T> {
/** The current position of the node on the board. */
position: Persistent<NodePosition>;
/** The current position, plus the current offset from being dragged. */
computedPosition: ComputedRef<NodePosition>;
}
/** /**
* Makes a vue feature draggable on a Board. * Makes a vue feature draggable on a Board.
* @param element The vue feature to make draggable. * @param element The vue feature to make draggable.
* @param options The options to configure the dragging behavior. * @param options The options to configure the dragging behavior.
*/ */
export function makeDraggable<T>( export function makeDraggable<T, S extends MakeDraggableOptions<T>>(
element: VueFeature, element: VueFeature,
options: MakeDraggableOptions<T> optionsFunc: () => S
): asserts element is VueFeature & { position: Persistent<NodePosition> } { ): asserts element is VueFeature & { draggable: Draggable<T> } {
const position = persistent(options.initialPosition ?? { x: 0, y: 0 }); const position = persistent<NodePosition>({ x: 0, y: 0 });
(element as VueFeature & { position: Persistent<NodePosition> }).position = position; const draggable = createLazyProxy(() => {
const computedPosition = computed(() => { const options = optionsFunc();
if (options.nodeBeingDragged.value === options.id) { const { id, nodeBeingDragged, hasDragged, dragDelta, startDrag, endDrag, onMouseDown, onMouseUp, initialPosition, ...props } = options;
return {
x: position.value.x + options.dragDelta.value.x, position[DefaultValue] = initialPosition ?? position[DefaultValue];
y: position.value.y + options.dragDelta.value.y
}; const draggable = {
} ...(props as Omit<typeof props, keyof VueFeature | keyof MakeDraggableOptions<S>>),
return position.value; id,
nodeBeingDragged,
hasDragged,
dragDelta,
startDrag,
endDrag,
onMouseDown(e: MouseEvent | TouchEvent) {
if (onMouseDown?.(e) === false) {
return;
}
if (nodeBeingDragged.value == null) {
startDrag(e, id);
}
},
onMouseUp(e: MouseEvent | TouchEvent) {
// The element we're mapping may have their own click listeners, so we need to stop
// the propagation regardless, and can't rely on them passing through to the board.
endDrag();
if (!hasDragged.value) {
onMouseUp?.(e);
}
e.stopPropagation();
},
initialPosition,
position,
computedPosition: computed(() => {
if (nodeBeingDragged.value === id) {
return {
x: position.value.x + dragDelta.value.x,
y: position.value.y + dragDelta.value.y
};
}
return position.value;
})
} satisfies Draggable<T>;
return draggable;
}); });
function handleMouseDown(e: MouseEvent | TouchEvent) { runAfterEvaluation(element, el => {
if (options.onMouseDown?.(e) === false) { draggable.id; // Ensure draggable gets evaluated
return; (el as VueFeature & { draggable: Draggable<T> }).draggable = draggable;
} element.wrappers.push(el => (
<Draggable
if (options.nodeBeingDragged.value == null) { mouseDown={draggable.onMouseDown}
options.startDrag(e, options.id); mouseUp={draggable.onMouseUp}
} position={draggable.computedPosition}
} >
{el}
function handleMouseUp(e: MouseEvent | TouchEvent) { </Draggable>
options.onMouseUp?.(e); ));
} });
element.wrappers.push(el => (
<Draggable mouseDown={handleMouseDown} mouseUp={handleMouseUp} position={computedPosition}>
{el}
</Draggable>
));
} }
/** An object that configures how to setup a list of actions using {@link setupActions}. */ /** An object that configures how to setup a list of actions using {@link setupActions}. */

View file

@ -1,8 +1,8 @@
import { Resource } from "features/resources/resource"; import { Resource } from "features/resources/resource";
import { NonPersistent } from "game/persistence"; import { NonPersistent } from "game/persistence";
import Decimal, { DecimalSource, format } from "util/bignum"; import Decimal, { DecimalSource, format } from "util/bignum";
import { MaybeRefOrGetter, MaybeRef, processGetter } from "util/computed"; import { processGetter } from "util/computed";
import { Ref, computed, ref, unref } from "vue"; import { MaybeRef, MaybeRefOrGetter, Ref, computed, ref, unref } from "vue";
import * as ops from "./operations"; import * as ops from "./operations";
import type { import type {
EvaluateFunction, EvaluateFunction,

View file

@ -1,14 +1,13 @@
import Modal from "components/modals/Modal.vue"; import Modal from "components/modals/Modal.vue";
import type { OptionsFunc, Replace } from "features/feature";
import { globalBus } from "game/events"; import { globalBus } from "game/events";
import type { Persistent } from "game/persistence"; import type { Persistent } from "game/persistence";
import { persistent } from "game/persistence"; import { persistent } from "game/persistence";
import player from "game/player"; import player from "game/player";
import type { Emitter } from "nanoevents"; import type { Emitter } from "nanoevents";
import { createNanoEvents } from "nanoevents"; import { createNanoEvents } from "nanoevents";
import { ProcessedRefOrGetter, processGetter } from "util/computed"; import { processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy } from "util/proxies";
import { Renderable } from "util/vue"; import { render, Renderable } from "util/vue";
import { import {
computed, computed,
type CSSProperties, type CSSProperties,
@ -163,20 +162,45 @@ export interface BaseLayer {
} }
/** An unit of game content. Displayed to the user as a tab or modal. */ /** An unit of game content. Displayed to the user as a tab or modal. */
export type Layer = Replace< export interface Layer extends BaseLayer {
Replace<LayerOptions, BaseLayer>, /** The color of the layer, used to theme the entire layer's display. */
{ color?: MaybeRef<string>;
color?: ProcessedRefOrGetter<LayerOptions["color"]>; /**
display: ProcessedRefOrGetter<LayerOptions["display"]>; * The layout of this layer's features.
classes?: ProcessedRefOrGetter<LayerOptions["classes"]>; * When the layer is open in {@link game/player.PlayerData.tabs}, this is the content that is displayed.
style?: ProcessedRefOrGetter<LayerOptions["style"]>; */
name: MaybeRef<string>; display: MaybeRef<Renderable>;
minWidth: MaybeRef<string | number>; /** An object of classes that should be applied to the display. */
minimizable: MaybeRef<boolean>; classes?: MaybeRef<Record<string, boolean>>;
minimizedDisplay?: ProcessedRefOrGetter<LayerOptions["minimizedDisplay"]>; /** Styles that should be applied to the display. */
forceHideGoBack?: ProcessedRefOrGetter<LayerOptions["forceHideGoBack"]>; style?: MaybeRef<CSSProperties>;
} /**
>; * The name of the layer, used on minimized tabs.
* Defaults to {@link BaseLayer.id}.
*/
name?: MaybeRef<string>;
/**
* Whether or not the layer can be minimized.
* Defaults to true.
*/
minimizable?: MaybeRef<boolean>;
/**
* 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?: MaybeRef<Renderable>;
/**
* 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?: MaybeRef<boolean>;
/**
* A CSS min-width value that is applied to the layer.
* Can be a number, in which case the unit is assumed to be px.
* Defaults to 600px.
*/
minWidth?: MaybeRef<number | string>;
}
/** /**
* When creating layers, this object a map of layer ID to a set of any created persistent refs in order to check they're all included in the final layer object. * When creating layers, this object a map of layer ID to a set of any created persistent refs in order to check they're all included in the final layer object.
@ -193,7 +217,7 @@ export const addingLayers: string[] = [];
*/ */
export function createLayer<T extends LayerOptions>( export function createLayer<T extends LayerOptions>(
id: string, id: string,
optionsFunc: OptionsFunc<T, BaseLayer> optionsFunc: (layer: BaseLayer) => T & ThisType<Layer & Omit<T, keyof Layer>>
) { ) {
return createLazyProxy(() => { return createLazyProxy(() => {
const emitter = createNanoEvents<LayerEvents>(); const emitter = createNanoEvents<LayerEvents>();
@ -208,7 +232,7 @@ export function createLayer<T extends LayerOptions>(
minimized: persistent(false, false) minimized: persistent(false, false)
} satisfies BaseLayer; } satisfies BaseLayer;
const options = optionsFunc.call(baseLayer, baseLayer); const options = optionsFunc(baseLayer);
const { const {
color, color,
display, display,
@ -357,7 +381,7 @@ export function setupLayerModal(layer: Layer): {
onUpdate:modelValue={value => (showModal.value = value)} onUpdate:modelValue={value => (showModal.value = value)}
v-slots={{ v-slots={{
header: () => <h2>{unref(layer.name)}</h2>, header: () => <h2>{unref(layer.name)}</h2>,
body: unref(layer.display) body: () => render(layer.display)
}} }}
/> />
)) ))

View file

@ -1,5 +1,4 @@
import "components/common/modifiers.css"; import "components/common/modifiers.css";
import type { OptionsFunc } from "features/feature";
import settings from "game/settings"; import settings from "game/settings";
import type { DecimalSource } from "util/bignum"; import type { DecimalSource } from "util/bignum";
import Decimal, { formatSmall } from "util/bignum"; import Decimal, { formatSmall } from "util/bignum";
@ -59,13 +58,10 @@ export interface AdditiveModifierOptions {
* @param optionsFunc Additive modifier options. * @param optionsFunc Additive modifier options.
*/ */
export function createAdditiveModifier<T extends AdditiveModifierOptions, S = OperationModifier<T>>( export function createAdditiveModifier<T extends AdditiveModifierOptions, S = OperationModifier<T>>(
optionsFunc: OptionsFunc<T> optionsFunc: () => T
) { ) {
return createLazyProxy(feature => { return createLazyProxy(() => {
const { addend, description, enabled, smallerIsBetter } = optionsFunc.call( const { addend, description, enabled, smallerIsBetter } = optionsFunc();
feature,
feature
);
const processedAddend = processGetter(addend); const processedAddend = processGetter(addend);
const processedDescription = processGetter(description); const processedDescription = processGetter(description);
@ -123,12 +119,9 @@ export interface MultiplicativeModifierOptions {
export function createMultiplicativeModifier< export function createMultiplicativeModifier<
T extends MultiplicativeModifierOptions, T extends MultiplicativeModifierOptions,
S = OperationModifier<T> S = OperationModifier<T>
>(optionsFunc: OptionsFunc<T>) { >(optionsFunc: () => T) {
return createLazyProxy(feature => { return createLazyProxy(() => {
const { multiplier, description, enabled, smallerIsBetter } = optionsFunc.call( const { multiplier, description, enabled, smallerIsBetter } = optionsFunc();
feature,
feature
);
const processedMultiplier = processGetter(multiplier); const processedMultiplier = processGetter(multiplier);
const processedDescription = processGetter(description); const processedDescription = processGetter(description);
@ -187,10 +180,10 @@ export interface ExponentialModifierOptions {
export function createExponentialModifier< export function createExponentialModifier<
T extends ExponentialModifierOptions, T extends ExponentialModifierOptions,
S = OperationModifier<T> S = OperationModifier<T>
>(optionsFunc: OptionsFunc<T>) { >(optionsFunc: () => T) {
return createLazyProxy(feature => { return createLazyProxy(() => {
const { exponent, description, enabled, supportLowNumbers, smallerIsBetter } = const { exponent, description, enabled, supportLowNumbers, smallerIsBetter } =
optionsFunc.call(feature, feature); optionsFunc();
const processedExponent = processGetter(exponent); const processedExponent = processGetter(exponent);
const processedDescription = processGetter(description); const processedDescription = processGetter(description);

View file

@ -258,7 +258,7 @@ globalBus.on("addLayer", (layer: Layer, saveData: Record<string, unknown>) => {
Object.keys(obj).forEach(key => { Object.keys(obj).forEach(key => {
let value = obj[key]; let value = obj[key];
if (value != null && typeof value === "object") { if (value != null && typeof value === "object") {
if ((value as Record<PropertyKey, unknown>)[SkipPersistence] === true) { if (SkipPersistence in value && value[SkipPersistence] === true) {
return; return;
} }
if (ProxyState in value) { if (ProxyState in value) {
@ -364,7 +364,7 @@ globalBus.on("addLayer", (layer: Layer, saveData: Record<string, unknown>) => {
return; return;
} }
console.error( console.error(
`Created persistent ref in ${layer.id} without registering it to the layer!`, `Created persistent ref in "${layer.id}" without registering it to the layer!`,
"Make sure to include everything persistent in the returned object.\n\nCreated at:\n" + "Make sure to include everything persistent in the returned object.\n\nCreated at:\n" +
persistent[StackTrace] persistent[StackTrace]
); );

View file

@ -1,4 +1,4 @@
import { isVisible, OptionsFunc, Replace, Visibility } from "features/feature"; import { isVisible, Visibility } from "features/feature";
import { displayResource, Resource } from "features/resources/resource"; import { displayResource, Resource } from "features/resources/resource";
import Decimal, { DecimalSource } from "lib/break_eternity"; import Decimal, { DecimalSource } from "lib/break_eternity";
import { processGetter } from "util/computed"; import { processGetter } from "util/computed";
@ -65,7 +65,7 @@ export interface CostRequirementOptions {
*/ */
visibility?: MaybeRefOrGetter<Visibility.Visible | Visibility.None | boolean>; visibility?: MaybeRefOrGetter<Visibility.Visible | Visibility.None | boolean>;
/** /**
* Pass-through to {@link Requirement.requiresPay}. If not set to false, the default {@link pay} function will remove {@link cost} from {@link resource}. * Pass-through to {@link Requirement["requiresPay"]}. If not set to false, the default {@link pay} function will remove {@link cost} from {@link resource}.
*/ */
requiresPay?: MaybeRefOrGetter<boolean>; requiresPay?: MaybeRefOrGetter<boolean>;
/** /**
@ -88,26 +88,42 @@ export interface CostRequirementOptions {
pay?: (amount?: DecimalSource) => void; pay?: (amount?: DecimalSource) => void;
} }
export type CostRequirement = Replace< export interface CostRequirement extends Requirement {
Requirement & CostRequirementOptions, /**
{ * The resource that will be checked for meeting the {@link cost}.
cost: MaybeRef<DecimalSource> | GenericFormula; */
visibility: MaybeRef<Visibility.Visible | Visibility.None | boolean>; resource: Resource;
requiresPay: MaybeRef<boolean>; /**
cumulativeCost: MaybeRef<boolean>; * 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.
canMaximize: MaybeRef<boolean>; */
} cost: MaybeRef<DecimalSource> | GenericFormula;
>; /**
* 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: MaybeRef<boolean>;
/**
* Upper limit on levels that can be performed at once. Defaults to 1.
*/
maxBulkAmount?: MaybeRef<DecimalSource>;
/**
* 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?: MaybeRef<number>;
/**
* Pass-through to {@link Requirement.pay}. May be required for maximizing support.
* @see {@link cost} for restrictions on maximizing support.
*/
pay?: (amount?: DecimalSource) => void;
}
/** /**
* Lazily creates a requirement with the given options, that is based on meeting an amount of a resource. * Lazily creates a requirement with the given options, that is based on meeting an amount of a resource.
* @param optionsFunc Cost requirement options. * @param optionsFunc Cost requirement options.
*/ */
export function createCostRequirement<T extends CostRequirementOptions>( export function createCostRequirement<T extends CostRequirementOptions>(optionsFunc: () => T) {
optionsFunc: OptionsFunc<T>
) {
return createLazyProxy(feature => { return createLazyProxy(feature => {
const options = optionsFunc.call(feature, feature); const options = optionsFunc.call(feature);
const { const {
visibility, visibility,
cost, cost,

View file

@ -1,7 +1,4 @@
import { Application } from "@pixi/app"; import { BatchRenderer, extensions } from "@pixi/core";
import { BatchRenderer, Renderer } from "@pixi/core";
import { TickerPlugin } from "@pixi/ticker"; import { TickerPlugin } from "@pixi/ticker";
Application.registerPlugin(TickerPlugin); extensions.add(TickerPlugin, BatchRenderer);
Renderer.registerPlugin("batch", BatchRenderer);

View file

@ -1,13 +1,7 @@
import { isFunction } from "util/common"; import { isFunction } from "util/common";
import type { ComputedRef, MaybeRef, Ref, UnwrapRef } from "vue"; import type { ComputedRef } from "vue";
import { computed } from "vue"; import { computed } from "vue";
export type ProcessedRefOrGetter<T> = T extends () => infer S
? Ref<S>
: T extends undefined
? undefined
: MaybeRef<NonNullable<UnwrapRef<T>>>;
export function processGetter<T>(obj: T): T extends () => infer S ? ComputedRef<S> : T { export function processGetter<T>(obj: T): T extends () => infer S ? ComputedRef<S> : T {
if (isFunction(obj)) { if (isFunction(obj)) {
return computed(obj) as ReturnType<typeof processGetter<T>>; return computed(obj) as ReturnType<typeof processGetter<T>>;

View file

@ -1,34 +1,7 @@
import type { Persistent } from "game/persistence";
import { NonPersistent } from "game/persistence"; import { NonPersistent } from "game/persistence";
import Decimal from "util/bignum";
export const ProxyState = Symbol("ProxyState"); export const ProxyState = Symbol("ProxyState");
export const ProxyPath = Symbol("ProxyPath"); export const AfterEvaluation = Symbol("AfterEvaluation");
export type ProxiedWithState<T> =
NonNullable<T> extends Record<PropertyKey, unknown>
? NonNullable<T> extends Decimal
? T
: {
[K in keyof T]: ProxiedWithState<T[K]>;
} & {
[ProxyState]: T;
[ProxyPath]: string[];
}
: T;
export type Proxied<T> =
NonNullable<T> extends Record<PropertyKey, unknown>
? NonNullable<T> extends Persistent<infer S>
? NonPersistent<S>
: NonNullable<T> extends Decimal
? T
: {
[K in keyof T]: Proxied<T[K]>;
} & {
[ProxyState]: T;
}
: T;
// Takes a function that returns an object and pretends to be that object // Takes a function that returns an object and pretends to be that object
// Note that the object is lazily calculated // Note that the object is lazily calculated
@ -39,23 +12,36 @@ export function createLazyProxy<T extends object, S extends T>(
const obj: S & Partial<T> = baseObject; const obj: S & Partial<T> = baseObject;
let calculated = false; let calculated = false;
let calculating = false; let calculating = false;
const toBeEvaluated: ((proxy: S & T) => void)[] = [];
function calculateObj(): T { function calculateObj(): T {
if (!calculated) { if (!calculated) {
if (calculating) { if (calculating) {
console.error("Cyclical dependency detected. Cannot evaluate lazy proxy."); throw new Error("Cyclical dependency detected. Cannot evaluate lazy proxy.");
} }
calculating = true; calculating = true;
Object.assign(obj, objectFunc.call(obj, obj)); Object.assign(obj, objectFunc.call(obj, obj));
calculated = true; calculated = true;
toBeEvaluated.forEach(cb => cb(obj));
} }
return obj as S & T; return obj as S & T;
} }
function runAfterEvaluation(cb: (proxy: S & T) => void) {
if (calculated) {
cb(obj);
} else {
toBeEvaluated.push(cb);
}
}
return new Proxy(obj, { return new Proxy(obj, {
get(target, key) { get(target, key) {
if (key === ProxyState) { if (key === ProxyState) {
return calculateObj(); return calculateObj();
} }
if (key === AfterEvaluation) {
return runAfterEvaluation;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const val = (calculateObj() as any)[key]; const val = (calculateObj() as any)[key];
if (val != null && typeof val === "object" && NonPersistent in val) { if (val != null && typeof val === "object" && NonPersistent in val) {
@ -70,7 +56,7 @@ export function createLazyProxy<T extends object, S extends T>(
return true; return true;
}, },
has(target, key) { has(target, key) {
if (key === ProxyState) { if (key === ProxyState || key === AfterEvaluation) {
return true; return true;
} }
return Reflect.has(calculateObj(), key); return Reflect.has(calculateObj(), key);
@ -87,3 +73,11 @@ export function createLazyProxy<T extends object, S extends T>(
} }
}) as S & T; }) as S & T;
} }
export function runAfterEvaluation<T extends object>(maybeProxy: T, callback: (object: T) => void) {
if (AfterEvaluation in maybeProxy) {
(maybeProxy[AfterEvaluation] as (callback: (object: T) => void) => void)(callback);
} else {
callback(maybeProxy);
}
}

View file

@ -1,6 +1,6 @@
import { type OptionsFunc } from "features/feature"; import { type OptionsFunc } from "features/feature";
import { processGetter } from "util/computed"; import { processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy, runAfterEvaluation } from "util/proxies";
import type { VueFeature } from "util/vue"; import type { VueFeature } from "util/vue";
import { MaybeRef, MaybeRefOrGetter, unref } from "vue"; import { MaybeRef, MaybeRefOrGetter, unref } from "vue";
import MarkNode from "./MarkNode.vue"; import MarkNode from "./MarkNode.vue";
@ -25,10 +25,10 @@ export interface Mark {
*/ */
export function addMark<T extends MarkOptions>( export function addMark<T extends MarkOptions>(
element: VueFeature, element: VueFeature,
optionsFunc: OptionsFunc<T, Mark, Mark> optionsFunc: OptionsFunc<T, Mark>
) { ): asserts element is VueFeature & { mark: Mark } {
const mark = createLazyProxy(feature => { const mark = createLazyProxy(() => {
const options = optionsFunc.call(feature, feature as Mark); const options = optionsFunc();
const { mark, ...props } = options; const { mark, ...props } = options;
return { return {
@ -37,9 +37,11 @@ export function addMark<T extends MarkOptions>(
} satisfies Mark; } satisfies Mark;
}); });
element.wrappers.push(el => runAfterEvaluation(element, el => {
Boolean(unref(mark.mark)) ? <MarkNode mark={mark.mark}>{el}</MarkNode> : <>{el}</> mark.mark; // Ensure mark gets evaluated
); (element as VueFeature & { mark: Mark }).mark = mark;
el.wrappers.push(el =>
return mark; Boolean(unref(mark.mark)) ? <MarkNode mark={mark.mark}>{el}</MarkNode> : <>{el}</>
);
});
} }

View file

@ -1,8 +1,8 @@
import { isVisible, type OptionsFunc, type Replace } from "features/feature"; import { isVisible, type OptionsFunc } from "features/feature";
import { deletePersistent, persistent } from "game/persistence"; import { deletePersistent, persistent } from "game/persistence";
import { Direction } from "util/common"; import { Direction } from "util/common";
import { ProcessedRefOrGetter, processGetter } from "util/computed"; import { processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy, runAfterEvaluation } from "util/proxies";
import { Renderable, vueFeatureMixin, type VueFeature, type VueFeatureOptions } from "util/vue"; import { Renderable, vueFeatureMixin, type VueFeature, type VueFeatureOptions } from "util/vue";
import { MaybeRef, MaybeRefOrGetter, type Ref } from "vue"; import { MaybeRef, MaybeRefOrGetter, type Ref } from "vue";
import Tooltip from "wrappers/tooltips/Tooltip.vue"; import Tooltip from "wrappers/tooltips/Tooltip.vue";
@ -30,26 +30,22 @@ export interface TooltipOptions extends VueFeatureOptions {
yoffset?: MaybeRefOrGetter<string>; yoffset?: MaybeRefOrGetter<string>;
} }
/** /** An object that represents a tooltip that appears when hovering over an element. */
* The properties that are added onto a processed {@link TooltipOptions} to create an {@link Tooltip}. export interface Tooltip extends VueFeature {
*/ /** Whether or not this tooltip can be pinned, meaning it'll stay visible even when not hovered. */
export interface BaseTooltip extends VueFeature { pinnable?: boolean;
/** The text to display inside the tooltip. */
display: MaybeRef<Renderable>;
/** The direction in which to display the tooltip */
direction?: MaybeRef<Direction>;
/** The x offset of the tooltip, in px. */
xoffset?: MaybeRef<string>;
/** The y offset of the tooltip, in px. */
yoffset?: MaybeRef<string>;
/** Whether or not this tooltip is currently pinned. Undefined if {@link pinnable} is false. */
pinned?: Ref<boolean>; pinned?: Ref<boolean>;
} }
/** An object that represents a tooltip that appears when hovering over an element. */
export type Tooltip = Replace<
Replace<TooltipOptions, BaseTooltip>,
{
pinnable: boolean;
pinned?: Ref<boolean>;
display: MaybeRef<Renderable>;
direction: MaybeRef<Direction>;
xoffset?: ProcessedRefOrGetter<TooltipOptions["xoffset"]>;
yoffset?: ProcessedRefOrGetter<TooltipOptions["yoffset"]>;
}
>;
/** /**
* Creates a tooltip on the given element with the given options. * Creates a tooltip on the given element with the given options.
* @param element The renderable feature to display the tooltip on. * @param element The renderable feature to display the tooltip on.
@ -57,11 +53,11 @@ export type Tooltip = Replace<
*/ */
export function addTooltip<T extends TooltipOptions>( export function addTooltip<T extends TooltipOptions>(
element: VueFeature, element: VueFeature,
optionsFunc: OptionsFunc<T, BaseTooltip, Tooltip> optionsFunc: OptionsFunc<T, Tooltip>
) { ): asserts element is VueFeature & { tooltip: Tooltip } {
const pinned = persistent<boolean>(false, false); const pinned = persistent<boolean>(false, false);
const tooltip = createLazyProxy(feature => { const tooltip = createLazyProxy(() => {
const options = optionsFunc.call(feature, feature as Tooltip); const options = optionsFunc();
const { pinnable, display, direction, xoffset, yoffset, ...props } = options; const { pinnable, display, direction, xoffset, yoffset, ...props } = options;
if (pinnable === false) { if (pinnable === false) {
@ -82,23 +78,25 @@ export function addTooltip<T extends TooltipOptions>(
return tooltip; return tooltip;
}); });
element.wrappers.push(el => runAfterEvaluation(element, el => {
isVisible(tooltip.visibility ?? true) ? ( tooltip.id; // Ensure tooltip gets evaluated
<Tooltip (el as VueFeature & { tooltip: Tooltip }).tooltip = tooltip;
pinned={tooltip.pinned} el.wrappers.push(el =>
display={tooltip.display} isVisible(tooltip.visibility ?? true) ? (
classes={tooltip.classes} <Tooltip
style={tooltip.style} pinned={tooltip.pinned}
direction={tooltip.direction} display={tooltip.display}
xoffset={tooltip.xoffset} classes={tooltip.classes}
yoffset={tooltip.yoffset} style={tooltip.style}
> direction={tooltip.direction}
{el} xoffset={tooltip.xoffset}
</Tooltip> yoffset={tooltip.yoffset}
) : ( >
<>{el}</> {el}
) </Tooltip>
); ) : (
<>{el}</>
return tooltip; )
);
});
} }

File diff suppressed because it is too large Load diff

View file

@ -9,11 +9,10 @@ import {
} from "game/modifiers"; } from "game/modifiers";
import Decimal, { DecimalSource } from "util/bignum"; import Decimal, { DecimalSource } from "util/bignum";
import { WithRequired } from "util/common"; import { WithRequired } from "util/common";
import { MaybeRefOrGetter } from "util/computed";
import { beforeAll, describe, expect, test } from "vitest"; import { beforeAll, describe, expect, test } from "vitest";
import { Ref, ref, unref } from "vue"; import { MaybeRefOrGetter, Ref, ref, unref } from "vue";
import "../utils"; import "../utils";
import { MaybeRefOrGetter<Renderable>, render } from "util/vue"; import { render, Renderable } from "util/vue";
export type ModifierConstructorOptions = { export type ModifierConstructorOptions = {
[S in "addend" | "multiplier" | "exponent"]: MaybeRefOrGetter<DecimalSource>; [S in "addend" | "multiplier" | "exponent"]: MaybeRefOrGetter<DecimalSource>;