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

View file

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

View file

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

View file

@ -174,39 +174,3 @@
.row.mergeAdjacent > .table:last-child > .col.mergeAdjacent > :first-child .feature button {
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!
</div>
<br />
<div>
<div v-if="discordLink && discordName">
<a :href="discordLink" class="game-over-modal-discord-link">
<span class="material-icons game-over-modal-discord">discord</span>
{{ discordName }}
</a>
</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" />
</div>
</template>

View file

@ -3,7 +3,7 @@ import { Achievement } from "features/achievements/achievement";
import type { Clickable, ClickableOptions } from "features/clickables/clickable";
import { createClickable } from "features/clickables/clickable";
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 type { Tree, TreeNode, TreeNodeOptions } 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 type { ComputedRef, MaybeRef, MaybeRefOrGetter } from "vue";
import { computed, ref, unref } from "vue";
import { JSX } from "vue/jsx-runtime";
import "./common.css";
/** 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.
* Assumes this button is associated with a specific node on a tree, and triggers that tree's reset propagation.
*/
export type ResetButton = Replace<
Clickable,
{
resetDescription: MaybeRef<string>;
showNextAt: MaybeRef<boolean>;
minimumGain: MaybeRef<DecimalSource>;
}
>;
export interface ResetButton extends Clickable {
/** The conversion the button uses to calculate how much resources will be gained on click */
conversion: Conversion;
/** The tree this reset button is apart of */
tree: Tree;
/** 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.
* @param optionsFunc A function that returns the options object for this reset button.
*/
export function createResetButton<T extends ClickableOptions & ResetButtonOptions>(
optionsFunc: OptionsFunc<T>
optionsFunc: () => T
) {
const resetButton = createClickable(feature => {
const options = optionsFunc.call(feature, feature);
const resetButton = createClickable(() => {
const options = optionsFunc();
const {
conversion,
tree,
@ -113,41 +127,43 @@ export function createResetButton<T extends ClickableOptions & ResetButtonOption
),
display:
processGetter(display) ??
computed(() => (
<span>
{unref(resetButton.resetDescription)}
<b>
{displayResource(
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:"}{" "}
computed(
(): JSX.Element => (
<span>
{unref(resetButton.resetDescription)}
<b>
{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) {
conversion.gainResource,
Decimal.max(
unref(conversion.actualGain),
unref(resetButton.minimumGain)
)
)}
</b>{" "}
{conversion.gainResource.displayName}
{unref(resetButton.showNextAt) != null ? (
<div>
<br />
{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) {
return;
}
conversion.convert();
tree.reset(resetButton.treeNode);
tree.reset(treeNode);
if (resetTime) {
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. */
export type LayerTreeNode = Replace<
TreeNode,
{
layerID: string;
append: MaybeRef<boolean>;
}
>;
export interface LayerTreeNode extends TreeNode {
/** The ID of the layer this tree node is associated with */
layerID: string;
/** Whether or not to append the layer to the tabs list.
* If set to false, then the tree node will instead always remove all tabs to its right and then add the layer tab.
* Defaults to true.
*/
append?: MaybeRef<boolean>;
}
/**
* Lazily creates a tree node that's associated with a specific layer, with the given options.
* @param optionsFunc A function that returns the options object for this tree node.
*/
export function createLayerTreeNode<T extends LayerTreeNodeOptions>(optionsFunc: OptionsFunc<T>) {
const layerTreeNode = createTreeNode(feature => {
const options = optionsFunc.call(feature, feature);
export function createLayerTreeNode<T extends LayerTreeNodeOptions>(optionsFunc: () => T) {
const layerTreeNode = createTreeNode(() => {
const options = optionsFunc();
const { display, append, layerID, ...props } = options;
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 type { BaseLayer, Layer } from "game/layers";
import { createLayer } from "game/layers";
import type { Player } from "game/player";
import player from "game/player";
import player, { Player } from "game/player";
import type { DecimalSource } from "util/bignum";
import Decimal, { format, formatTime } from "util/bignum";
import { render } from "util/vue";
@ -31,17 +30,21 @@ export const main = createLayer("main", function (this: BaseLayer) {
});
const oomps = trackOOMPS(points, pointGain);
// Note: Casting as generic tree to avoid recursive type definitions
const tree = createTree(() => ({
nodes: [[prestige.treeNode]],
branches: [],
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;
total.value = points.value;
},
resetPropagation: branchedResetPropagation
})) 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 {
name: "Tree",
links: tree.links,

View file

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

View file

@ -1,5 +1,5 @@
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 "game/notifications";
import type { Persistent } from "game/persistence";
@ -14,7 +14,7 @@ import {
} from "game/requirements";
import settings, { registerSettingField } from "game/settings";
import { camelToTitle } from "util/common";
import { ProcessedRefOrGetter, processGetter } from "util/computed";
import { processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies";
import {
isJSXElement,
@ -46,7 +46,7 @@ export enum AchievementDisplay {
* An object that configures an {@link Achievement}.
*/
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;
/** The display to use for this achievement. */
display?:
@ -69,10 +69,26 @@ export interface AchievementOptions extends VueFeatureOptions {
onComplete?: VoidFunction;
}
/**
* The properties that are added onto a processed {@link AchievementOptions} to create an {@link Achievement}.
*/
export interface BaseAchievement extends VueFeature {
/** An object that represents a feature with requirements that is passively earned upon meeting certain requirements. */
export interface Achievement extends VueFeature {
/** The requirement(s) to earn this achievement. */
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. */
earned: Persistent<boolean>;
/** A function to complete this achievement. */
@ -81,32 +97,14 @@ export interface BaseAchievement extends VueFeature {
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.
* @param optionsFunc Achievement options.
*/
export function createAchievement<T extends AchievementOptions>(
optionsFunc?: OptionsFunc<T, BaseAchievement, Achievement>
) {
export function createAchievement<T extends AchievementOptions>(optionsFunc?: () => T) {
const earned = persistent<boolean>(false, false);
return createLazyProxy(feature => {
const options = optionsFunc?.call(feature, feature as Achievement) ?? ({} as T);
return createLazyProxy(() => {
const options = optionsFunc?.() ?? ({} as T);
const { requirements, display, small, image, showPopups, onComplete, ...props } = options;
const vueFeature = vueFeatureMixin("achievement", options, () => (
@ -195,7 +193,7 @@ export function createAchievement<T extends AchievementOptions>(
toast.info(
<div>
<h3>Achievement earned!</h3>
<div>{Display}</div>
<div>{Display()}</div>
</div>
);
}

View file

@ -1,8 +1,7 @@
import Bar from "features/bars/Bar.vue";
import type { OptionsFunc, Replace } from "features/feature";
import type { DecimalSource } from "util/bignum";
import { Direction } from "util/common";
import { ProcessedRefOrGetter, processGetter } from "util/computed";
import { processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies";
import { Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
import { CSSProperties, MaybeRef, MaybeRefOrGetter } from "vue";
@ -34,37 +33,37 @@ export interface BarOptions extends VueFeatureOptions {
display?: MaybeRefOrGetter<Renderable>;
}
/**
* The properties that are added onto a processed {@link BarOptions} to create a {@link Bar}.
*/
export interface BaseBar extends VueFeature {
/** An object that represents a feature that displays some sort of progress or completion or resource with a cap. */
export interface Bar extends VueFeature {
/** The width of the bar. */
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. */
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.
* @param optionsFunc Bar options.
*/
export function createBar<T extends BarOptions>(optionsFunc: OptionsFunc<T, BaseBar, Bar>) {
return createLazyProxy(feature => {
const options = optionsFunc?.call(feature, feature as Bar);
export function createBar<T extends BarOptions>(optionsFunc: () => T) {
return createLazyProxy(() => {
const options = optionsFunc?.();
const {
width,
height,

View file

@ -1,5 +1,4 @@
import Toggle from "components/fields/Toggle.vue";
import type { OptionsFunc, Replace } from "features/feature";
import { isVisible } from "features/feature";
import type { Reset } from "features/reset";
import { globalBus } from "game/events";
@ -54,10 +53,37 @@ export interface ChallengeOptions extends VueFeatureOptions {
onEnter?: VoidFunction;
}
/**
* The properties that are added onto a processed {@link ChallengeOptions} to create a {@link Challenge}.
*/
export interface BaseChallenge extends VueFeature {
/** An object that represents a feature that can be entered and exited, and have one or more completions with scaling requirements. */
export interface Challenge extends VueFeature {
/** The reset function for this challenge. */
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. */
canComplete: Ref<DecimalSource>;
/** The current number of times this challenge has been completed. */
@ -79,35 +105,15 @@ export interface BaseChallenge extends VueFeature {
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.
* @param optionsFunc Challenge options.
*/
export function createChallenge<T extends ChallengeOptions>(
optionsFunc: OptionsFunc<T, BaseChallenge, Challenge>
) {
export function createChallenge<T extends ChallengeOptions>(optionsFunc: () => T) {
const completions = persistent<DecimalSource>(0);
const active = persistent<boolean>(false, false);
return createLazyProxy(feature => {
const options = optionsFunc.call(feature, feature as Challenge);
return createLazyProxy(() => {
const options = optionsFunc();
const {
requirements,
canStart,

View file

@ -1,13 +1,13 @@
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 { persistent } from "game/persistence";
import Decimal, { DecimalSource } from "lib/break_eternity";
import { Unsubscribe } from "nanoevents";
import { Direction } from "util/common";
import { ProcessedRefOrGetter, processGetter } from "util/computed";
import { processGetter } from "util/computed";
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 { JSX } from "vue/jsx-runtime";
import { Bar, BarOptions, createBar } from "../bars/bar";
@ -30,10 +30,18 @@ export interface ActionOptions extends Omit<ClickableOptions, "onClick" | "onHol
barOptions?: Partial<BarOptions>;
}
/**
* The properties that are added onto a processed {@link ActionOptions} to create an {@link Action}.
*/
export interface BaseAction extends VueFeature {
/** An object that represents a feature that can be clicked upon, and then has a cooldown before it can be clicked again. */
export interface Action extends VueFeature {
/** The cooldown during which the action cannot be performed again, in seconds. */
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. */
isHolding: Ref<boolean>;
/** The current amount of progress through the cooldown. */
@ -46,28 +54,14 @@ export interface BaseAction extends VueFeature {
type: typeof ActionType;
}
/** An object that represents a feature that can be clicked upon, and then has a cooldown before it can be clicked again. */
export type Action = Replace<
Replace<ActionOptions, BaseAction>,
{
duration: ProcessedRefOrGetter<ActionOptions["duration"]>;
autoStart: MaybeRef<boolean>;
canClick: MaybeRef<boolean>;
display: ProcessedRefOrGetter<ActionOptions["display"]>;
onClick: VoidFunction;
}
>;
/**
* Lazily creates an action with the given options.
* @param optionsFunc Action options.
*/
export function createAction<T extends ActionOptions>(
optionsFunc?: OptionsFunc<T, BaseAction, Action>
) {
export function createAction<T extends ActionOptions>(optionsFunc?: () => T) {
const progress = persistent<DecimalSource>(0);
return createLazyProxy(feature => {
const options = optionsFunc?.call(feature, feature as Action) ?? ({} as T);
return createLazyProxy(() => {
const options = optionsFunc?.() ?? ({} as T);
const { style, duration, canClick, autoStart, display, barOptions, onClick, ...props } =
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;
});

View file

@ -1,5 +1,4 @@
import Clickable from "features/clickables/Clickable.vue";
import type { OptionsFunc, Replace } from "features/feature";
import type { BaseLayer } from "game/layers";
import type { Unsubscribe } from "nanoevents";
import { processGetter } from "util/computed";
@ -31,32 +30,27 @@ export interface ClickableOptions extends VueFeatureOptions {
onHold?: VoidFunction;
}
/**
* The properties that are added onto a processed {@link ClickableOptions} to create an {@link Clickable}.
*/
export interface BaseClickable extends VueFeature {
/** An object that represents a feature that can be clicked or held down. */
export interface Clickable extends VueFeature {
/** A function that is called when the clickable is clicked. */
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. */
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.
* @param optionsFunc Clickable options.
*/
export function createClickable<T extends ClickableOptions>(
optionsFunc?: OptionsFunc<T, BaseClickable, Clickable>
) {
return createLazyProxy(feature => {
const options = optionsFunc?.call(feature, feature as Clickable) ?? ({} as T);
export function createClickable<T extends ClickableOptions>(optionsFunc?: () => T) {
return createLazyProxy(() => {
const options = optionsFunc?.() ?? ({} as T);
const { canClick, display: _display, onClick: onClick, onHold: onHold, ...props } = options;
let display: MaybeRef<Renderable> | undefined = undefined;

View file

@ -1,5 +1,4 @@
import Clickable from "features/clickables/Clickable.vue";
import type { OptionsFunc, Replace } from "features/feature";
import { Visibility } from "features/feature";
import { DefaultValue, Persistent, persistent } from "game/persistence";
import {
@ -23,7 +22,7 @@ import { ClickableOptions } from "./clickable";
export const RepeatableType = Symbol("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. */
requirements: Requirements;
/** 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. */
title?: MaybeRefOrGetter<Renderable>;
/** 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. */
effectDisplay?: MaybeRefOrGetter<Renderable>;
/** 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
};
}
/**
* The properties that are added onto a processed {@link RepeatableOptions} to create a {@link Repeatable}.
*/
export interface BaseRepeatable extends VueFeature {
/** An object that represents a feature with multiple "levels" with scaling requirements. */
export interface Repeatable extends VueFeature {
/** The requirement(s) to increase this repeatable. */
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. */
amount: Persistent<DecimalSource>;
/** Whether or not this repeatable's amount is at it's limit. */
@ -59,27 +68,14 @@ export interface BaseRepeatable extends VueFeature {
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.
* @param optionsFunc Repeatable options.
*/
export function createRepeatable<T extends RepeatableOptions>(
optionsFunc: OptionsFunc<T, BaseRepeatable, Repeatable>
) {
export function createRepeatable<T extends RepeatableOptions>(optionsFunc: () => T) {
const amount = persistent<DecimalSource>(0);
return createLazyProxy(feature => {
const options = optionsFunc.call(feature, feature as Repeatable);
return createLazyProxy(() => {
const options = optionsFunc();
const {
requirements: _requirements,
display: _display,
@ -130,8 +126,7 @@ export function createRepeatable<T extends RepeatableOptions>(
const showAmount = processGetter(_display.showAmount);
const Title = title == null ? null : () => render(title, el => <h3>{el}</h3>);
const Description =
description == null ? null : () => render(description, el => <>{el}</>);
const Description = () => render(description, el => <>{el}</>);
const EffectDisplay =
effectDisplay == null ? null : () => render(effectDisplay, el => <>{el}</>);
@ -142,7 +137,7 @@ export function createRepeatable<T extends RepeatableOptions>(
<Title />
</div>
)}
{Description == null ? null : <Description />}
<Description />
{showAmount === false ? null : (
<div>
<br />
@ -181,6 +176,7 @@ export function createRepeatable<T extends RepeatableOptions>(
...vueFeature,
amount,
requirements,
initialAmount,
limit: processGetter(limit) ?? Decimal.dInf,
classes: computed(() => {
const currClasses = unref(vueFeature.classes) || {};

View file

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

View file

@ -1,4 +1,3 @@
import type { OptionsFunc, Replace } from "features/feature";
import type { Resource } from "features/resources/resource";
import Formula from "game/formulas/formulas";
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}.
*/
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;
/**
* 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.
* 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 createIndependentConversion}.
*/
export function createConversion<T extends ConversionOptions>(
optionsFunc: OptionsFunc<T, BaseConversion, Conversion>
) {
return createLazyProxy(feature => {
const options = optionsFunc.call(feature, feature as Conversion);
export function createConversion<T extends ConversionOptions>(optionsFunc: () => T) {
return createLazyProxy(() => {
const options = optionsFunc();
const {
baseResource,
gainResource,
@ -187,9 +221,7 @@ export function createConversion<T extends ConversionOptions>(
* This is equivalent to just calling createConversion directly.
* @param optionsFunc Conversion options.
*/
export function createCumulativeConversion<S extends ConversionOptions>(
optionsFunc: OptionsFunc<S, BaseConversion, Conversion>
) {
export function createCumulativeConversion<T extends ConversionOptions>(optionsFunc: () => T) {
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.
* @param optionsFunc Converison options.
*/
export function createIndependentConversion<S extends ConversionOptions>(
optionsFunc: OptionsFunc<S, BaseConversion, Conversion>
) {
return createConversion(feature => {
const conversion = optionsFunc.call(feature, feature);
export function createIndependentConversion<T extends ConversionOptions>(optionsFunc: () => T) {
const conversion = createConversion(() => {
const options = optionsFunc();
conversion.buyMax ??= false;
options.buyMax ??= false;
conversion.currentGain ??= computed(() => {
let gain = Decimal.floor(feature.formula.evaluate(conversion.baseResource.value)).max(
conversion.gainResource.value
options.currentGain ??= computed(() => {
let gain = Decimal.floor(conversion.formula.evaluate(options.baseResource.value)).max(
options.gainResource.value
);
if (unref(conversion.buyMax as MaybeRef<boolean>) === false) {
gain = gain.min(Decimal.add(conversion.gainResource.value, 1));
if (unref(options.buyMax as MaybeRef<boolean>) === false) {
gain = gain.min(Decimal.add(options.gainResource.value, 1));
}
return gain;
});
conversion.actualGain ??= computed(() => {
options.actualGain ??= computed(() => {
let gain = Decimal.sub(
feature.formula.evaluate(conversion.baseResource.value),
conversion.gainResource.value
conversion.formula.evaluate(options.baseResource.value),
options.gainResource.value
)
.floor()
.max(0);
if (unref(conversion.buyMax as MaybeRef<boolean>) === false) {
if (unref(options.buyMax as MaybeRef<boolean>) === false) {
gain = gain.min(1);
}
return gain;
});
conversion.convert ??= function () {
const amountGained = unref(feature.actualGain);
conversion.gainResource.value = unref(feature.currentGain);
feature.spend(amountGained);
feature.onConvert?.(amountGained);
options.convert ??= function () {
const amountGained = unref(conversion.actualGain);
options.gainResource.value = unref(conversion.currentGain);
conversion.spend(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 { 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;
/**
* 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 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 handleObject = (obj: Record<string, unknown>) => {
const handleObject = (obj: object) => {
Object.keys(obj).forEach(key => {
const value = obj[key];
const value: unknown = obj[key as keyof typeof obj];
if (value != null && typeof value === "object") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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 */
import type { OptionsFunc, Replace } from "features/feature";
import { Visibility } from "features/feature";
import Grid from "features/grids/Grid.vue";
import { getUniqueID, Visibility } from "features/feature";
import type { Persistent, State } from "game/persistence";
import { persistent } from "game/persistence";
import { isFunction } from "util/common";
import { ProcessedRefOrGetter, processGetter } from "util/computed";
import { processGetter } from "util/computed";
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 { computed, unref } from "vue";
import GridCell from "./GridCell.vue";
import { computed, isRef, unref } from "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. */
export const GridType = Symbol("Grid");
@ -38,8 +38,6 @@ export interface GridCell extends VueFeature {
startState: State;
/** The persistent state of this cell. */
state: State;
/** A header to appear at the top of the display. */
title?: MaybeRef<Renderable>;
/** The main text that appears in the display. */
display: MaybeRef<Renderable>;
/** A function that is called when the cell is clicked. */
@ -56,30 +54,49 @@ export interface GridOptions extends VueFeatureOptions {
rows: MaybeRefOrGetter<number>;
/** The number of columns in the grid. */
cols: MaybeRefOrGetter<number>;
/** A MaybeRefOrGetter to determine the visibility of a cell. */
/** A getter for the visibility of a cell. */
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>;
/** 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);
/** A MaybeRefOrGetter to get the CSS styles for a cell. */
/** A getter for the CSS styles for a cell. */
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>>;
/** A MaybeRefOrGetter to get the title component for a cell. */
getTitle?: CellMaybeRefOrGetter<MaybeRefOrGetter<Renderable>>;
/** A MaybeRefOrGetter to get the display component for a cell. */
getDisplay: CellMaybeRefOrGetter<MaybeRefOrGetter<Renderable>>;
/** A getter for the display component for a cell. */
getDisplay: CellMaybeRefOrGetter<Renderable> | {
getTitle?: CellMaybeRefOrGetter<Renderable>;
getDescription: CellMaybeRefOrGetter<Renderable>
};
/** A function that is called when a cell is clicked. */
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;
}
/**
* The properties that are added onto a processed {@link BoardOptions} to create a {@link Board}.
*/
export interface BaseGrid extends VueFeature {
/** An object that represents a feature that is a grid of cells that all behave according to the same rules. */
export interface Grid extends VueFeature {
/** A function that is called when a cell is clicked. */
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. */
getID: (row: number, col: number, state: State) => string;
/** Get the persistent state of the given cell. */
@ -94,39 +111,18 @@ export interface BaseGrid extends VueFeature {
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) {
return new Proxy({} as GridCell[], {
get(target, key) {
if (key === "isProxy") {
return true;
}
if (typeof key !== "string") {
return;
}
if (key === "length") {
return unref(grid.cols);
}
if (typeof key !== "number" && typeof key !== "string") {
return;
}
const keyNum = parseInt(key);
if (!Number.isFinite(keyNum) || keyNum >= unref(grid.cols)) {
const keyNum = typeof key === "number" ? key : parseInt(key);
if (Number.isFinite(keyNum) && keyNum < unref(grid.cols)) {
if (keyNum in target) {
return target[keyNum];
}
@ -144,20 +140,20 @@ function getCellRowHandler(grid: Grid, row: number) {
if (key === "length") {
return true;
}
if (typeof key !== "string") {
if (typeof key !== "number" && typeof key !== "string") {
return false;
}
const keyNum = parseInt(key);
const keyNum = typeof key === "number" ? key : parseInt(key);
if (!Number.isFinite(keyNum) || keyNum >= unref(grid.cols)) {
return false;
}
return true;
},
getOwnPropertyDescriptor(target, key) {
if (typeof key !== "string") {
if (typeof key !== "number" && typeof key !== "string") {
return;
}
const keyNum = parseInt(key);
const keyNum = typeof key === "number" ? key : parseInt(key);
if (key !== "length" && (!Number.isFinite(keyNum) || keyNum >= unref(grid.cols))) {
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
get(target, key, receiver) {
switch (key) {
case "isProxy":
return true;
case "wrappers":
return [];
case VueFeature:
@ -219,32 +213,28 @@ function getCellHandler(grid: Grid, row: number, col: number): GridCell {
case "state": {
return grid.getState(row, col);
}
case "id":
return target.id = target.id ?? getUniqueID("gridcell");
case "components":
return [
computed(() => (
<GridCell
<Clickable
onClick={receiver.onClick}
onHold={receiver.onHold}
display={receiver.display}
title={receiver.title}
canClick={receiver.canClick}
/>
))
];
}
let prop = (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;
if (typeof key === "symbol") {
return (grid as any)[key];
}
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 (!(key in cache)) {
cache[key] = computed(() =>
@ -263,14 +253,24 @@ function getCellHandler(grid: Grid, row: number, col: number): GridCell {
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];
},
set(target, key, value) {
console.log("!!?", key, value)
if (typeof key !== "string") {
return false;
}
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);
return true;
} 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>(
value: CellMaybeRefOrGetter<T>
): ProcessedCellRefOrGetter<T> {
@ -309,10 +315,10 @@ function convertCellMaybeRefOrGetter<T>(
* Lazily creates a grid with the given 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);
return createLazyProxy(feature => {
const options = optionsFunc.call(feature, feature as Grid);
return createLazyProxy(() => {
const options = optionsFunc();
const {
rows,
cols,
@ -321,40 +327,57 @@ export function createGrid<T extends GridOptions>(optionsFunc: OptionsFunc<T, Ba
getStartState,
getStyle,
getClasses,
getTitle,
getDisplay,
getDisplay: _getDisplay,
onClick,
onHold,
...props
} = 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 = {
type: GridType,
...(props as Omit<typeof props, keyof VueFeature | keyof GridOptions>),
...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,
cells: new Proxy({} as Record<number, GridCell[]>, {
cells: new Proxy({} as GridCell[][], {
get(target, key: PropertyKey) {
if (key === "isProxy") {
return true;
}
if (key === "length") {
return unref(grid.rows);
}
if (typeof key !== "string") {
if (typeof key !== "number" && typeof key !== "string") {
return;
}
const keyNum = parseInt(key);
if (!Number.isFinite(keyNum) || keyNum >= unref(grid.rows)) {
if (keyNum in target) {
return target[keyNum];
const keyNum = typeof key === "number" ? key : parseInt(key);
if (Number.isFinite(keyNum) && keyNum < unref(grid.rows)) {
if (!(keyNum in target)) {
target[keyNum] = getCellRowHandler(grid, keyNum);
}
return (target[keyNum] = getCellRowHandler(grid, keyNum));
return target[keyNum];
}
},
set(target, key, value) {
@ -368,20 +391,20 @@ export function createGrid<T extends GridOptions>(optionsFunc: OptionsFunc<T, Ba
if (key === "length") {
return true;
}
if (typeof key !== "string") {
if (typeof key !== "number" && typeof key !== "string") {
return false;
}
const keyNum = parseInt(key);
const keyNum = typeof key === "number" ? key : parseInt(key);
if (!Number.isFinite(keyNum) || keyNum >= unref(grid.rows)) {
return false;
}
return true;
},
getOwnPropertyDescriptor(target, key) {
if (typeof key !== "string") {
if (typeof key !== "number" && typeof key !== "string") {
return;
}
const keyNum = parseInt(key);
const keyNum = typeof key === "number" ? key : parseInt(key);
if (
key !== "length" &&
(!Number.isFinite(keyNum) || keyNum >= unref(grid.rows))
@ -399,15 +422,16 @@ export function createGrid<T extends GridOptions>(optionsFunc: OptionsFunc<T, Ba
cols: processGetter(cols),
getVisibility: convertCellMaybeRefOrGetter(getVisibility ?? true),
getCanClick: convertCellMaybeRefOrGetter(getCanClick ?? true),
getStartState: processGetter(getStartState),
getStartState: typeof getStartState === "function" && getStartState.length > 0 ?
getStartState : processGetter(getStartState),
getStyle: convertCellMaybeRefOrGetter(getStyle),
getClasses: convertCellMaybeRefOrGetter(getClasses),
getTitle: convertCellMaybeRefOrGetter(getTitle),
getDisplay: convertCellMaybeRefOrGetter(getDisplay),
getDisplay,
getID: function (row: number, col: number): string {
return grid.id + "-" + row + "-" + col;
},
getState: function (row: number, col: number): State {
cellState.value[row] ??= {};
if (cellState.value[row][col] != null) {
return cellState.value[row][col];
}
@ -421,7 +445,7 @@ export function createGrid<T extends GridOptions>(optionsFunc: OptionsFunc<T, Ba
onClick == null
? undefined
: 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);
}
},
@ -429,7 +453,7 @@ export function createGrid<T extends GridOptions>(optionsFunc: OptionsFunc<T, Ba
onHold == null
? undefined
: function (row, col, state) {
if (grid.cells[row][col].canClick) {
if (grid.cells[row][col].canClick !== false) {
onHold.call(grid, row, col, state);
}
}

View file

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

View file

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

View file

@ -1,11 +1,10 @@
import type { OptionsFunc, Replace } from "features/feature";
import Infobox from "features/infoboxes/Infobox.vue";
import type { 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 { 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. */
export const InfoboxType = Symbol("Infobox");
@ -14,7 +13,7 @@ export const InfoboxType = Symbol("Infobox");
* An object that configures an {@link Infobox}.
*/
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>;
/** CSS to apply to the title of the infobox. */
titleStyle?: MaybeRefOrGetter<CSSProperties>;
@ -26,38 +25,32 @@ export interface InfoboxOptions extends VueFeatureOptions {
display: MaybeRefOrGetter<Renderable>;
}
/**
* The properties that are added onto a processed {@link InfoboxOptions} to create an {@link Infobox}.
*/
export interface BaseInfobox extends VueFeature {
/** An object that represents a feature that displays information in a collapsible way. */
export interface Infobox extends VueFeature {
/** The background color of the Infobox. */
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. */
collapsed: Persistent<boolean>;
/** A symbol that helps identify features of the same type. */
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.
* @param optionsFunc Infobox options.
*/
export function createInfobox<T extends InfoboxOptions>(
optionsFunc: OptionsFunc<T, BaseInfobox, Infobox>
) {
export function createInfobox<T extends InfoboxOptions>(optionsFunc: () => T) {
const collapsed = persistent<boolean>(false, false);
return createLazyProxy(feature => {
const options = optionsFunc.call(feature, feature as Infobox);
return createLazyProxy(() => {
const options = optionsFunc();
const { color, titleStyle, bodyStyle, title, display, ...props } = options;
const infobox = {
@ -74,7 +67,7 @@ export function createInfobox<T extends InfoboxOptions>(
/>
)),
collapsed,
color: processGetter(color),
color: processGetter(color) ?? "--layer-color",
titleStyle: processGetter(titleStyle),
bodyStyle: processGetter(bodyStyle),
title: processGetter(title),

View file

@ -15,22 +15,30 @@
<script setup lang="ts">
import type { FeatureNode } 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 { Links } from "./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 outerBoundingRect = inject(BoundsInjectionKey, ref<DOMRect | undefined>(undefined));
const boundingRect = ref<DOMRect | undefined>(resizeListener.value?.getBoundingClientRect());
watch(
outerBoundingRect,
() => (boundingRect.value = resizeListener.value?.getBoundingClientRect())
);
onMounted(() => (boundingRect.value = resizeListener.value?.getBoundingClientRect()));
watch(outerBoundingRect, updateBounds);
onMounted(() => {
const resListener = resizeListener.value;
if (resListener != null) {
resizeObserver.observe(resListener);
}
updateBounds();
});
const validLinks = computed(() => {
const n = nodes.value;
@ -42,23 +50,14 @@ const validLinks = computed(() => {
</script>
<style scoped>
.resize-listener {
position: absolute;
top: 0px;
left: 0;
right: -4px;
bottom: 5px;
z-index: -10;
pointer-events: none;
}
svg {
.resize-listener, svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -10;
pointer-events: none;
margin: 0;
width: 100%;
height: 100%;
}
</style>

View file

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

View file

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

View file

@ -1,8 +1,6 @@
import { Application } from "@pixi/app";
import type { EmitterConfigV3 } 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 { VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/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. */
app: Ref<null | Application>;
/**
@ -37,27 +40,13 @@ export interface BaseParticles extends VueFeature {
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.
* @param optionsFunc Particles options.
*/
export function createParticles<T extends ParticlesOptions>(
optionsFunc?: OptionsFunc<T, BaseParticles, Particles>
) {
return createLazyProxy(feature => {
const options = optionsFunc?.call(feature, feature as Particles) ?? ({} as T);
export function createParticles<T extends ParticlesOptions>(optionsFunc?: () => T) {
return createLazyProxy(() => {
const options = optionsFunc?.() ?? ({} as T);
const { onContainerResized, onHotReload, ...props } = options;
let emittersToAdd: {

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +1,7 @@
import type { OptionsFunc, Replace } from "features/feature";
import { ProcessedRefOrGetter, processGetter } from "util/computed";
import { processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies";
import { render, Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
import { MaybeRefOrGetter } from "vue";
import { MaybeRef, MaybeRefOrGetter } from "vue";
import { JSX } from "vue/jsx-runtime";
/** 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. */
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.
* @param optionsFunc Tab options.
*/
export function createTab<T extends TabOptions>(optionsFunc: OptionsFunc<T, BaseTab, Tab>) {
return createLazyProxy(feature => {
const options = optionsFunc?.call(feature, feature as Tab) ?? ({} as T);
export function createTab<T extends TabOptions>(optionsFunc: () => T) {
return createLazyProxy(() => {
const options = optionsFunc?.() ?? ({} as T);
const { display, ...props } = options;
const tab = {

View file

@ -1,11 +1,10 @@
import type { OptionsFunc, Replace } from "features/feature";
import { isVisible } from "features/feature";
import { Tab } from "features/tabs/tab";
import TabButton from "features/tabs/TabButton.vue";
import TabFamily from "features/tabs/TabFamily.vue";
import type { 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 { Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
import type { CSSProperties, MaybeRef, MaybeRefOrGetter, Ref } from "vue";
@ -28,26 +27,20 @@ export interface TabButtonOptions extends VueFeatureOptions {
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.
* @see {@link TabFamily}
*/
export type TabButton = Replace<
Replace<TabButtonOptions, BaseTabButton>,
{
tab: Tab | MaybeRef<Renderable>;
display: ProcessedRefOrGetter<TabButtonOptions["display"]>;
glowColor: ProcessedRefOrGetter<TabButtonOptions["glowColor"]>;
}
>;
export interface TabButton extends VueFeature {
/** The tab to display when this button is clicked. */
tab: Tab | MaybeRef<Renderable>;
/** The label on this button. */
display: MaybeRef<Renderable>;
/** 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}.
@ -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. */
tabs: Record<string, TabButtonOptions>;
tabs: Record<string, TabButton>;
/** The currently active tab, if any. */
activeTab: Ref<Tab | MaybeRef<Renderable> | null>;
/** The name of the tab that is currently active. */
@ -73,32 +71,21 @@ export interface BaseTabFamily extends VueFeature {
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.
* @param optionsFunc Tab family options.
*/
export function createTabFamily<T extends TabFamilyOptions>(
tabs: Record<string, () => TabButtonOptions>,
optionsFunc?: OptionsFunc<T, BaseTabFamily, TabFamily>
optionsFunc?: () => T
) {
if (Object.keys(tabs).length === 0) {
console.error("Cannot create tab family with 0 tabs");
}
const selected = persistent(Object.keys(tabs)[0], false);
return createLazyProxy(feature => {
const options = optionsFunc?.call(feature, feature as TabFamily) ?? ({} as T);
return createLazyProxy(() => {
const options = optionsFunc?.() ?? ({} as T);
const { buttonContainerClasses, buttonContainerStyle, ...props } = options;
const tabFamily = {
@ -107,7 +94,6 @@ export function createTabFamily<T extends TabFamilyOptions>(
...vueFeatureMixin("tabFamily", options, () => (
<TabFamily
activeTab={tabFamily.activeTab}
selected={tabFamily.selected}
tabs={tabFamily.tabs}
buttonContainerClasses={tabFamily.buttonContainerClasses}
buttonContainerStyle={tabFamily.buttonContainerStyle}
@ -120,7 +106,13 @@ export function createTabFamily<T extends TabFamilyOptions>(
const tabButton = {
type: TabButtonType,
...(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),
glowColor: processGetter(glowColor),
display: processGetter(display)

View file

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

View file

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

View file

@ -1,13 +1,13 @@
import type { OptionsFunc, Replace } from "features/feature";
import { Link } from "features/links/links";
import type { Reset } from "features/reset";
import type { Resource } from "features/resources/resource";
import { displayResource } from "features/resources/resource";
import Tree from "features/trees/Tree.vue";
import TreeNode from "features/trees/TreeNode.vue";
import { noPersist } from "game/persistence";
import type { DecimalSource } 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 { Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/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}.
*/
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. */
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.
* @param optionsFunc Tree Node options.
*/
export function createTreeNode<T extends TreeNodeOptions>(
optionsFunc?: OptionsFunc<T, BaseTreeNode, TreeNode>
) {
return createLazyProxy(feature => {
const options = optionsFunc?.call(feature, feature as TreeNode) ?? ({} as T);
export function createTreeNode<T extends TreeNodeOptions>(optionsFunc?: () => T) {
return createLazyProxy(() => {
const options = optionsFunc?.() ?? ({} as T);
const { canClick, color, display, glowColor, onClick, onHold, ...props } = options;
const treeNode = {
@ -131,9 +132,21 @@ export interface TreeOptions extends VueFeatureOptions {
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. */
links: Ref<Link[]>;
links: MaybeRef<Link[]>;
/** Cause a reset on this node and propagate it through the tree according to {@link TreeOptions.resetPropagation}. */
reset: (node: TreeNode) => void;
/** A flag that is true while the reset is still propagating through the tree. */
@ -144,35 +157,29 @@ export interface BaseTree extends VueFeature {
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.
* @param optionsFunc Tree options.
*/
export function createTree<T extends TreeOptions>(optionsFunc: OptionsFunc<T, BaseTree, Tree>) {
return createLazyProxy(feature => {
const options = optionsFunc.call(feature, feature as Tree);
export function createTree<T extends TreeOptions>(optionsFunc: () => T) {
return createLazyProxy(() => {
const options = optionsFunc();
const {
branches,
branches: _branches,
nodes,
leftSideNodes,
rightSideNodes,
reset,
resetPropagation,
onReset,
style: _style,
...props
} = options;
const style = processGetter(_style);
options.style = () => ({ position: "static", ...(unref(style) ?? {}) });
const branches = _branches == null ? undefined : processGetter(_branches);
const tree = {
type: TreeType,
...(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: processGetter(branches),
branches,
isResetting: ref(false),
resettingNode: shallowRef<TreeNode | null>(null),
nodes: processGetter(nodes),
leftSideNodes: processGetter(leftSideNodes),
rightSideNodes: processGetter(rightSideNodes),
links: processGetter(branches) ?? [],
links: branches == null ? [] : noPersist(branches),
resetPropagation,
onReset,
reset:
reset ??
function (node: TreeNode) {
tree.isResetting.value = true;
tree.resettingNode.value = node;
tree.resetPropagation?.(tree, node);
tree.onReset?.(node);
tree.isResetting.value = false;
tree.resettingNode.value = null;
}
reset: function (node: TreeNode) {
tree.isResetting.value = true;
tree.resettingNode.value = node;
tree.resetPropagation?.(tree, node);
tree.onReset?.(node);
tree.isResetting.value = false;
tree.resettingNode.value = null;
}
} satisfies Tree;
return tree;

View file

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

View file

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

View file

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

View file

@ -1,14 +1,15 @@
import Board from "./Board.vue";
import Draggable from "./Draggable.vue";
import { globalBus } from "game/events";
import { Persistent, persistent } from "game/persistence";
import { DefaultValue, Persistent, persistent } from "game/persistence";
import type { PanZoom } from "panzoom";
import { Direction, isFunction } from "util/common";
import { processGetter } from "util/computed";
import { createLazyProxy, runAfterEvaluation } from "util/proxies";
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 panZoom from "vue-panzoom";
import Board from "./Board.vue";
import Draggable from "./Draggable.vue";
// Register panzoom so it can be used in Board.vue
globalBus.on("setupVue", app => panZoom.install(app));
@ -254,46 +255,85 @@ export interface MakeDraggableOptions<T> {
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.
* @param element The vue feature to make draggable.
* @param options The options to configure the dragging behavior.
*/
export function makeDraggable<T>(
export function makeDraggable<T, S extends MakeDraggableOptions<T>>(
element: VueFeature,
options: MakeDraggableOptions<T>
): asserts element is VueFeature & { position: Persistent<NodePosition> } {
const position = persistent(options.initialPosition ?? { x: 0, y: 0 });
(element as VueFeature & { position: Persistent<NodePosition> }).position = position;
const computedPosition = computed(() => {
if (options.nodeBeingDragged.value === options.id) {
return {
x: position.value.x + options.dragDelta.value.x,
y: position.value.y + options.dragDelta.value.y
};
}
return position.value;
optionsFunc: () => S
): asserts element is VueFeature & { draggable: Draggable<T> } {
const position = persistent<NodePosition>({ x: 0, y: 0 });
const draggable = createLazyProxy(() => {
const options = optionsFunc();
const { id, nodeBeingDragged, hasDragged, dragDelta, startDrag, endDrag, onMouseDown, onMouseUp, initialPosition, ...props } = options;
position[DefaultValue] = initialPosition ?? position[DefaultValue];
const draggable = {
...(props as Omit<typeof props, keyof VueFeature | keyof MakeDraggableOptions<S>>),
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) {
if (options.onMouseDown?.(e) === false) {
return;
}
if (options.nodeBeingDragged.value == null) {
options.startDrag(e, options.id);
}
}
function handleMouseUp(e: MouseEvent | TouchEvent) {
options.onMouseUp?.(e);
}
element.wrappers.push(el => (
<Draggable mouseDown={handleMouseDown} mouseUp={handleMouseUp} position={computedPosition}>
{el}
</Draggable>
));
runAfterEvaluation(element, el => {
draggable.id; // Ensure draggable gets evaluated
(el as VueFeature & { draggable: Draggable<T> }).draggable = draggable;
element.wrappers.push(el => (
<Draggable
mouseDown={draggable.onMouseDown}
mouseUp={draggable.onMouseUp}
position={draggable.computedPosition}
>
{el}
</Draggable>
));
});
}
/** 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 { NonPersistent } from "game/persistence";
import Decimal, { DecimalSource, format } from "util/bignum";
import { MaybeRefOrGetter, MaybeRef, processGetter } from "util/computed";
import { Ref, computed, ref, unref } from "vue";
import { processGetter } from "util/computed";
import { MaybeRef, MaybeRefOrGetter, Ref, computed, ref, unref } from "vue";
import * as ops from "./operations";
import type {
EvaluateFunction,

View file

@ -1,14 +1,13 @@
import Modal from "components/modals/Modal.vue";
import type { OptionsFunc, Replace } from "features/feature";
import { globalBus } from "game/events";
import type { Persistent } from "game/persistence";
import { persistent } from "game/persistence";
import player from "game/player";
import type { Emitter } from "nanoevents";
import { createNanoEvents } from "nanoevents";
import { ProcessedRefOrGetter, processGetter } from "util/computed";
import { processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies";
import { Renderable } from "util/vue";
import { render, Renderable } from "util/vue";
import {
computed,
type CSSProperties,
@ -163,20 +162,45 @@ export interface BaseLayer {
}
/** An unit of game content. Displayed to the user as a tab or modal. */
export type Layer = Replace<
Replace<LayerOptions, BaseLayer>,
{
color?: ProcessedRefOrGetter<LayerOptions["color"]>;
display: ProcessedRefOrGetter<LayerOptions["display"]>;
classes?: ProcessedRefOrGetter<LayerOptions["classes"]>;
style?: ProcessedRefOrGetter<LayerOptions["style"]>;
name: MaybeRef<string>;
minWidth: MaybeRef<string | number>;
minimizable: MaybeRef<boolean>;
minimizedDisplay?: ProcessedRefOrGetter<LayerOptions["minimizedDisplay"]>;
forceHideGoBack?: ProcessedRefOrGetter<LayerOptions["forceHideGoBack"]>;
}
>;
export interface Layer extends BaseLayer {
/** The color of the layer, used to theme the entire layer's display. */
color?: MaybeRef<string>;
/**
* The layout of this layer's features.
* When the layer is open in {@link game/player.PlayerData.tabs}, this is the content that is displayed.
*/
display: MaybeRef<Renderable>;
/** An object of classes that should be applied to the display. */
classes?: MaybeRef<Record<string, boolean>>;
/** Styles that should be applied to the display. */
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.
@ -193,7 +217,7 @@ export const addingLayers: string[] = [];
*/
export function createLayer<T extends LayerOptions>(
id: string,
optionsFunc: OptionsFunc<T, BaseLayer>
optionsFunc: (layer: BaseLayer) => T & ThisType<Layer & Omit<T, keyof Layer>>
) {
return createLazyProxy(() => {
const emitter = createNanoEvents<LayerEvents>();
@ -208,7 +232,7 @@ export function createLayer<T extends LayerOptions>(
minimized: persistent(false, false)
} satisfies BaseLayer;
const options = optionsFunc.call(baseLayer, baseLayer);
const options = optionsFunc(baseLayer);
const {
color,
display,
@ -357,7 +381,7 @@ export function setupLayerModal(layer: Layer): {
onUpdate:modelValue={value => (showModal.value = value)}
v-slots={{
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 type { OptionsFunc } from "features/feature";
import settings from "game/settings";
import type { DecimalSource } from "util/bignum";
import Decimal, { formatSmall } from "util/bignum";
@ -59,13 +58,10 @@ export interface AdditiveModifierOptions {
* @param optionsFunc Additive modifier options.
*/
export function createAdditiveModifier<T extends AdditiveModifierOptions, S = OperationModifier<T>>(
optionsFunc: OptionsFunc<T>
optionsFunc: () => T
) {
return createLazyProxy(feature => {
const { addend, description, enabled, smallerIsBetter } = optionsFunc.call(
feature,
feature
);
return createLazyProxy(() => {
const { addend, description, enabled, smallerIsBetter } = optionsFunc();
const processedAddend = processGetter(addend);
const processedDescription = processGetter(description);
@ -123,12 +119,9 @@ export interface MultiplicativeModifierOptions {
export function createMultiplicativeModifier<
T extends MultiplicativeModifierOptions,
S = OperationModifier<T>
>(optionsFunc: OptionsFunc<T>) {
return createLazyProxy(feature => {
const { multiplier, description, enabled, smallerIsBetter } = optionsFunc.call(
feature,
feature
);
>(optionsFunc: () => T) {
return createLazyProxy(() => {
const { multiplier, description, enabled, smallerIsBetter } = optionsFunc();
const processedMultiplier = processGetter(multiplier);
const processedDescription = processGetter(description);
@ -187,10 +180,10 @@ export interface ExponentialModifierOptions {
export function createExponentialModifier<
T extends ExponentialModifierOptions,
S = OperationModifier<T>
>(optionsFunc: OptionsFunc<T>) {
return createLazyProxy(feature => {
>(optionsFunc: () => T) {
return createLazyProxy(() => {
const { exponent, description, enabled, supportLowNumbers, smallerIsBetter } =
optionsFunc.call(feature, feature);
optionsFunc();
const processedExponent = processGetter(exponent);
const processedDescription = processGetter(description);

View file

@ -258,7 +258,7 @@ globalBus.on("addLayer", (layer: Layer, saveData: Record<string, unknown>) => {
Object.keys(obj).forEach(key => {
let value = obj[key];
if (value != null && typeof value === "object") {
if ((value as Record<PropertyKey, unknown>)[SkipPersistence] === true) {
if (SkipPersistence in value && value[SkipPersistence] === true) {
return;
}
if (ProxyState in value) {
@ -364,7 +364,7 @@ globalBus.on("addLayer", (layer: Layer, saveData: Record<string, unknown>) => {
return;
}
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" +
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 Decimal, { DecimalSource } from "lib/break_eternity";
import { processGetter } from "util/computed";
@ -65,7 +65,7 @@ export interface CostRequirementOptions {
*/
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>;
/**
@ -88,26 +88,42 @@ export interface CostRequirementOptions {
pay?: (amount?: DecimalSource) => void;
}
export type CostRequirement = Replace<
Requirement & CostRequirementOptions,
{
cost: MaybeRef<DecimalSource> | GenericFormula;
visibility: MaybeRef<Visibility.Visible | Visibility.None | boolean>;
requiresPay: MaybeRef<boolean>;
cumulativeCost: MaybeRef<boolean>;
canMaximize: MaybeRef<boolean>;
}
>;
export interface CostRequirement extends Requirement {
/**
* The resource that will be checked for meeting the {@link cost}.
*/
resource: Resource;
/**
* The amount of {@link resource} that must be met for this requirement. You can pass a formula, in which case maximizing will work out of the box (assuming its invertible and, for more accurate calculations, its integral is invertible). If you don't pass a formula then you can still support maximizing by passing a custom {@link pay} function.
*/
cost: 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.
* @param optionsFunc Cost requirement options.
*/
export function createCostRequirement<T extends CostRequirementOptions>(
optionsFunc: OptionsFunc<T>
) {
export function createCostRequirement<T extends CostRequirementOptions>(optionsFunc: () => T) {
return createLazyProxy(feature => {
const options = optionsFunc.call(feature, feature);
const options = optionsFunc.call(feature);
const {
visibility,
cost,

View file

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

View file

@ -1,13 +1,7 @@
import { isFunction } from "util/common";
import type { ComputedRef, MaybeRef, Ref, UnwrapRef } from "vue";
import type { ComputedRef } 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 {
if (isFunction(obj)) {
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 Decimal from "util/bignum";
export const ProxyState = Symbol("ProxyState");
export const ProxyPath = Symbol("ProxyPath");
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;
export const AfterEvaluation = Symbol("AfterEvaluation");
// Takes a function that returns an object and pretends to be that object
// 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;
let calculated = false;
let calculating = false;
const toBeEvaluated: ((proxy: S & T) => void)[] = [];
function calculateObj(): T {
if (!calculated) {
if (calculating) {
console.error("Cyclical dependency detected. Cannot evaluate lazy proxy.");
throw new Error("Cyclical dependency detected. Cannot evaluate lazy proxy.");
}
calculating = true;
Object.assign(obj, objectFunc.call(obj, obj));
calculated = true;
toBeEvaluated.forEach(cb => cb(obj));
}
return obj as S & T;
}
function runAfterEvaluation(cb: (proxy: S & T) => void) {
if (calculated) {
cb(obj);
} else {
toBeEvaluated.push(cb);
}
}
return new Proxy(obj, {
get(target, key) {
if (key === ProxyState) {
return calculateObj();
}
if (key === AfterEvaluation) {
return runAfterEvaluation;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const val = (calculateObj() as any)[key];
if (val != null && typeof val === "object" && NonPersistent in val) {
@ -70,7 +56,7 @@ export function createLazyProxy<T extends object, S extends T>(
return true;
},
has(target, key) {
if (key === ProxyState) {
if (key === ProxyState || key === AfterEvaluation) {
return true;
}
return Reflect.has(calculateObj(), key);
@ -87,3 +73,11 @@ export function createLazyProxy<T extends object, S extends 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 { processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies";
import { createLazyProxy, runAfterEvaluation } from "util/proxies";
import type { VueFeature } from "util/vue";
import { MaybeRef, MaybeRefOrGetter, unref } from "vue";
import MarkNode from "./MarkNode.vue";
@ -25,10 +25,10 @@ export interface Mark {
*/
export function addMark<T extends MarkOptions>(
element: VueFeature,
optionsFunc: OptionsFunc<T, Mark, Mark>
) {
const mark = createLazyProxy(feature => {
const options = optionsFunc.call(feature, feature as Mark);
optionsFunc: OptionsFunc<T, Mark>
): asserts element is VueFeature & { mark: Mark } {
const mark = createLazyProxy(() => {
const options = optionsFunc();
const { mark, ...props } = options;
return {
@ -37,9 +37,11 @@ export function addMark<T extends MarkOptions>(
} satisfies Mark;
});
element.wrappers.push(el =>
Boolean(unref(mark.mark)) ? <MarkNode mark={mark.mark}>{el}</MarkNode> : <>{el}</>
);
return mark;
runAfterEvaluation(element, el => {
mark.mark; // Ensure mark gets evaluated
(element as VueFeature & { mark: Mark }).mark = mark;
el.wrappers.push(el =>
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 { Direction } from "util/common";
import { ProcessedRefOrGetter, processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies";
import { processGetter } from "util/computed";
import { createLazyProxy, runAfterEvaluation } from "util/proxies";
import { Renderable, vueFeatureMixin, type VueFeature, type VueFeatureOptions } from "util/vue";
import { MaybeRef, MaybeRefOrGetter, type Ref } from "vue";
import Tooltip from "wrappers/tooltips/Tooltip.vue";
@ -30,26 +30,22 @@ export interface TooltipOptions extends VueFeatureOptions {
yoffset?: MaybeRefOrGetter<string>;
}
/**
* The properties that are added onto a processed {@link TooltipOptions} to create an {@link Tooltip}.
*/
export interface BaseTooltip extends VueFeature {
/** An object that represents a tooltip that appears when hovering over an element. */
export interface Tooltip extends VueFeature {
/** Whether or not this tooltip can be pinned, meaning it'll stay visible even when not hovered. */
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>;
}
/** 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.
* @param element The renderable feature to display the tooltip on.
@ -57,11 +53,11 @@ export type Tooltip = Replace<
*/
export function addTooltip<T extends TooltipOptions>(
element: VueFeature,
optionsFunc: OptionsFunc<T, BaseTooltip, Tooltip>
) {
optionsFunc: OptionsFunc<T, Tooltip>
): asserts element is VueFeature & { tooltip: Tooltip } {
const pinned = persistent<boolean>(false, false);
const tooltip = createLazyProxy(feature => {
const options = optionsFunc.call(feature, feature as Tooltip);
const tooltip = createLazyProxy(() => {
const options = optionsFunc();
const { pinnable, display, direction, xoffset, yoffset, ...props } = options;
if (pinnable === false) {
@ -82,23 +78,25 @@ export function addTooltip<T extends TooltipOptions>(
return tooltip;
});
element.wrappers.push(el =>
isVisible(tooltip.visibility ?? true) ? (
<Tooltip
pinned={tooltip.pinned}
display={tooltip.display}
classes={tooltip.classes}
style={tooltip.style}
direction={tooltip.direction}
xoffset={tooltip.xoffset}
yoffset={tooltip.yoffset}
>
{el}
</Tooltip>
) : (
<>{el}</>
)
);
return tooltip;
runAfterEvaluation(element, el => {
tooltip.id; // Ensure tooltip gets evaluated
(el as VueFeature & { tooltip: Tooltip }).tooltip = tooltip;
el.wrappers.push(el =>
isVisible(tooltip.visibility ?? true) ? (
<Tooltip
pinned={tooltip.pinned}
display={tooltip.display}
classes={tooltip.classes}
style={tooltip.style}
direction={tooltip.direction}
xoffset={tooltip.xoffset}
yoffset={tooltip.yoffset}
>
{el}
</Tooltip>
) : (
<>{el}</>
)
);
});
}

File diff suppressed because it is too large Load diff

View file

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