diff --git a/.eslintrc.js b/.eslintrc.js
index 2a1d0bc..a881f38 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -11,7 +11,8 @@ module.exports = {
"@vue/eslint-config-prettier"
],
parserOptions: {
- ecmaVersion: 2020
+ ecmaVersion: 2020,
+ project: "tsconfig.json"
},
ignorePatterns: ["src/lib"],
rules: {
@@ -19,7 +20,14 @@ module.exports = {
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
"vue/script-setup-uses-vars": "warn",
"vue/no-mutating-props": "off",
- "vue/multi-word-component-names": "off"
+ "vue/multi-word-component-names": "off",
+ "@typescript-eslint/strict-boolean-expressions": [
+ "error",
+ {
+ allowNullableObject: true,
+ allowNullableBoolean: true
+ }
+ ]
},
globals: {
defineProps: "readonly",
diff --git a/.forgejo/workflows/deploy.yaml b/.forgejo/workflows/deploy.yaml
new file mode 100644
index 0000000..bc7210a
--- /dev/null
+++ b/.forgejo/workflows/deploy.yaml
@@ -0,0 +1,31 @@
+name: Build and Deploy
+on:
+ push:
+ branches:
+ - 'main'
+ workflow_dispatch:
+jobs:
+ build-and-deploy:
+ if: github.repository != 'profectus-engine/Profectus' # Don't build placeholder mod on main repo
+ runs-on: docker
+ steps:
+ - name: Setup RSync
+ run: |
+ apt-get update
+ apt-get install -y rsync
+
+ - name: Checkout 🛎️
+ uses: actions/checkout@v2
+ with:
+ submodules: recursive
+
+ - name: Install and Build 🔧 # This example project is built using npm and outputs the result to the 'build' folder. Replace with the commands required to build your project, or remove this step entirely if your site is pre-built.
+ run: |
+ npm ci
+ npm run build
+
+ - name: Deploy 🚀
+ uses: https://github.com/JamesIves/github-pages-deploy-action@v4.2.5
+ with:
+ branch: pages # The branch the action should deploy to.
+ folder: dist # The folder the action should deploy.
diff --git a/.forgejo/workflows/test.yaml b/.forgejo/workflows/test.yaml
new file mode 100644
index 0000000..33df8d8
--- /dev/null
+++ b/.forgejo/workflows/test.yaml
@@ -0,0 +1,21 @@
+name: Run Tests
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+jobs:
+ test:
+ runs-on: docker
+
+ steps:
+ - uses: actions/checkout@v3
+ with:
+ submodules: recursive
+ - name: Use Node.js 16.x
+ uses: actions/setup-node@v3
+ with:
+ node-version: 16.x
+ - run: npm ci
+ - run: npm run build --if-present
+ - run: npm test
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index f4210ed..8293b9e 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -3,6 +3,7 @@ on:
push:
branches:
- 'main'
+ workflow_dispatch:
jobs:
build-and-deploy:
if: github.repository != 'profectus-engine/Profectus' # Don't build placeholder mod on main repo
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index c6b970d..c41d085 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1,11 +1,11 @@
-name: Build and Deploy
+name: Run Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
- build:
+ test:
runs-on: ubuntu-latest
steps:
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 77377bd..d46602a 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,3 +1,12 @@
{
- "vitest.commandLine": "npx vitest"
-}
\ No newline at end of file
+ "vitest.commandLine": "npx vitest",
+ "editor.codeActionsOnSave": {
+ "source.fixAll.eslint": true
+ },
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "git.ignoreLimitWarning": true,
+ "[typescriptreact]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "typescript.tsdk": "node_modules/typescript/lib"
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b982041..78e51ce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,115 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.6.1] - 2023-05-17
+### Added
+- Error boundaries around each layer, and errors now display on the page when in development
+- Utility for creating requirement based on whether a conversion has met a requirement
+### Changed
+- **BREAKING** Formulas/requirements refactor
+ - spendResources renamed to cumulativeCost
+ - summedPurchases renamed to directSum
+ - calculateMaxAffordable now takes optional 'maxBulkAmount' parameter
+ - cost requirements now pass cumulativeCost, maxBulkAmount, and directSum to calculateMaxAffordable
+ - Non-integrable and non-invertible formulas will now work in more situations
+ - Repeatable.maximize is removed
+ - Challenge.maximize is removed
+- Formulas have better typing information now
+- Integrate functions now log errors if the variable input is not integrable
+- Cyclical proxies now throw errors
+- createFormulaPreview is now a JSX function
+- Tree nodes are not automatically capitalized anymore
+- upgrade.canPurchase now returns false if the upgrade is already bought
+- TPS display is simplified and more performant now
+### Fixed
+- Actions could not be constructed
+- Progress bar on actions was misaligned
+- Many different issues the Board features (and many changes/improvements)
+- Calculating max affordable could sometimes infinite loop
+- Non-integrable formulas could cause errors in cost requirements
+- estimateTime would not show "never" when production is 0
+- isInvertible and isIntegrable now properly handle nested formulas
+- Repeatables' amount display would show the literal text "joinJSX"
+- Repeatables would not buy max properly
+- Reset buttons were showing wrong "currentAt" vs "nextAt"
+- Step-wise formulas not updating their value correctly
+- Bonus amount decorator now checks for `amount` property in the post construct callback
+### Documentation
+- Various typos fixed and a few sections made more thorough
+
+## [0.6.0] - 2023-04-20
+### Added
+- **BREAKING** New requirements system
+ - Replaces many features' existing requirements with new generic form
+- **BREAKING** Formulas, which can be used to calculate buy max for you
+ - Requirements can use them so repeatables and challenges can be "buy max" without any extra effort
+ - Conversions now use formulas instead of the old scaling functions system, allowing for arbitrary functions that are much easier to follow
+ - Modifiers have a new getFormula property
+- Feature decorators, which simplify the process of adding extra values to features
+- Action feature, which is a clickable with a cooldown
+- ETA util (calculates time until a specific amount of a resource, based on its current gain rate)
+- createCollapsibleAchievements util
+- deleteLowerSaves util
+- Minimized layers can now display a component
+- submitOnBlur property to Text fields
+- showPopups property to achievements
+- Mouse/touch events to more onClick listeners
+- Example hotkey to starting layer
+- Schema for projInfo.json
+### Changes
+- **BREAKING** Buyables renamed to Repeatables
+ - Renamed purchaseLimit to limit
+ - Renamed buyMax to maximize
+ - Added initialAmount property
+- **BREAKING** Persistent refs no longer have redundancies in save object
+ - Requires referencing persistent refs either through a proxy or by wrapping in `noPersist()`
+- **BREAKING** Visibility properties can now take booleans
+ - Removed showIf util
+- **BREAKING** Lazy proxies and options functions now pass the base object in as `this` as well as the first parameter.
+- Tweaked settings display
+- setupPassiveGeneration will no longer lower the resource
+- displayResource now floors resource amounts
+- Tweaked modifier displays, incl showing negative modifiers in red
+- Hotkeys now appear on key graphic
+- Mofifier sections now accept computable strings for title and subtitle
+- Every VueFeature's `[Component]` property is now typed as GenericComponent
+- Make errors throw objects instead of strings
+- Updated b_e
+### Fixed
+- NaN detection stopped working
+ - Now specifically only checks persistent refs
+- trackTotal would increase the total when loading the save
+- PWAs wouldn't show updates
+- Board feature no longer working at all
+- Some discord links didn't open in new tab
+- Adjacent grid cells wouldn't merge
+- When fixing old saves, the modVersion would not be updated
+- Default layer would display `Dev Speed: 0x` when paused
+- Fixed hotkeys not working with shift + numbers
+- Fixed console errors about deleted persistent refs not being included in the layer object
+- Modifiers wouldn't display small numbers
+- Conversions' addSoftcap wouldn't affect currentAt or nextAt
+- MainDisplay not respecting style and classes props
+- Tabs could sometimes not update correctly
+- offlineTime not capping properly
+- Tooltips being user-selectable
+- Pinnable tooltips causing stack overflow
+- Workflows not working with submodules
+- Various minor typing issues
+### Removed
+- **BREAKING** Removed milestones (achievements now have small and large displays)
+### Documented
+- every single feature
+- formulas
+- requirements
+### Tests
+- conversions
+- formulas
+- modifiers
+- requirements
+
+Contributors: thepaperpilot, escapee, adsaf, ducdat
+
## [0.5.2] - 2022-08-22
### Added
- onLoad event
diff --git a/README.md b/README.md
index e535eb2..5f8c741 100644
--- a/README.md
+++ b/README.md
@@ -26,11 +26,6 @@ npm run build
npm run preview
```
-### Lints and fixes files
-```
-npm run lint
-```
-
### Runs the tests using vite-jest
```
npm run test
diff --git a/package.json b/package.json
index 6bb5fc4..7b14824 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,5 @@
{
"name": "profectus",
- "version": "0.5.2",
"private": true,
"scripts": {
"start": "vite",
@@ -51,9 +50,9 @@
"eslint": "^8.6.0",
"jsdom": "^20.0.0",
"prettier": "^2.5.1",
- "typescript": "^4.7.4",
+ "typescript": "^5.0.2",
"unplugin-json-dts": "^1.2.0",
- "vitest": "^0.17.1",
+ "vitest": "^0.29.3",
"vue-tsc": "^0.38.1"
},
"engines": {
diff --git a/src/App.vue b/src/App.vue
index 05fa7fd..6a365ef 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,18 +1,25 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
@@ -49,5 +55,17 @@ const gameComponent = computed(() => {
position: absolute;
min-height: 100%;
height: 100%;
+ color: var(--foreground);
+}
+
+.error-container {
+ background: var(--background);
+ overflow: auto;
+ width: 100%;
+ height: 100%;
+}
+
+.error-container > .error {
+ position: static;
}
diff --git a/src/components/Error.vue b/src/components/Error.vue
new file mode 100644
index 0000000..16c9b6b
--- /dev/null
+++ b/src/components/Error.vue
@@ -0,0 +1,135 @@
+
+
+
{{ firstError.name }}: {{ firstError.message }}
+
+
+ Check the console for more details, and consider sharing it with the developers on
+
discord !
+
content_paste Copy Save
+
Other errors
+
+
+ {{ error.name }}: {{ error.message }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/Game.vue b/src/components/Game.vue
index 6f9bb66..1975696 100644
--- a/src/components/Game.vue
+++ b/src/components/Game.vue
@@ -4,7 +4,6 @@
v-for="(tab, index) in tabs"
:key="index"
class="tab"
- :ref="`tab-${index}`"
:style="unref(layers[tab]?.style)"
:class="unref(layers[tab]?.classes)"
>
@@ -14,7 +13,7 @@
v-if="layerKeys.includes(tab)"
v-bind="gatherLayerProps(layers[tab]!)"
:index="index"
- :tab="() => (($refs[`tab-${index}`] as HTMLElement[] | undefined)?.[0])"
+ @set-minimized="(value: boolean) => (layers[tab]!.minimized.value = value)"
/>
@@ -36,8 +35,8 @@ const layerKeys = computed(() => Object.keys(layers));
const useHeader = projInfo.useHeader;
function gatherLayerProps(layer: GenericLayer) {
- const { display, minimized, minWidth, name, color, minimizable, nodes } = layer;
- return { display, minimized, minWidth, name, color, minimizable, nodes };
+ const { display, minimized, name, color, minimizable, nodes, minimizedDisplay } = layer;
+ return { display, minimized, name, color, minimizable, nodes, minimizedDisplay };
}
diff --git a/src/components/Hotkey.vue b/src/components/Hotkey.vue
new file mode 100644
index 0000000..7e9c876
--- /dev/null
+++ b/src/components/Hotkey.vue
@@ -0,0 +1,70 @@
+
+
+
+ Ctrl
+Shift
+
+ {{ key }}
+
+
+
+
+
diff --git a/src/components/Info.vue b/src/components/Info.vue
index 69a3545..15395bf 100644
--- a/src/components/Info.vue
+++ b/src/components/Info.vue
@@ -21,19 +21,32 @@
Changelog
-
+
discord
The Modding Tree
@@ -67,7 +80,7 @@ const isOpen = ref(false);
const timePlayed = computed(() => formatTime(player.timePlayed));
const infoComponent = computed(() => {
- return coerceComponent(jsx(() => <>{infoComponents.map(render)}>));
+ return coerceComponent(jsx(() => (<>{infoComponents.map(render)}>)));
});
defineExpose({
diff --git a/src/components/Layer.vue b/src/components/Layer.vue
index a64a255..1857535 100644
--- a/src/components/Layer.vue
+++ b/src/components/Layer.vue
@@ -1,15 +1,23 @@
-
-
←
-
- {{ unref(name) }}
+
+
+
❌
+
+
+
+ {{ unref(name) }}
-
+
+
▼
@@ -19,34 +27,26 @@
import projInfo from "data/projInfo.json";
import type { CoercableComponent } from "features/feature";
import type { FeatureNode } from "game/layers";
-import type { Persistent } from "game/persistence";
import player from "game/player";
-import { computeComponent, processedPropType, wrapRef } from "util/vue";
-import type { PropType, Ref } from "vue";
-import { computed, defineComponent, nextTick, toRefs, unref, watch } from "vue";
+import { computeComponent, computeOptionalComponent, processedPropType, unwrapRef } from "util/vue";
+import { PropType, Ref, computed, defineComponent, onErrorCaptured, ref, toRefs, unref } from "vue";
import Context from "./Context.vue";
+import ErrorVue from "./Error.vue";
export default defineComponent({
- components: { Context },
+ components: { Context, ErrorVue },
props: {
index: {
type: Number,
required: true
},
- tab: {
- type: Function as PropType<() => HTMLElement | undefined>,
- required: true
- },
display: {
type: processedPropType(Object, String, Function),
required: true
},
+ minimizedDisplay: processedPropType(Object, String, Function),
minimized: {
- type: Object as PropType>,
- required: true
- },
- minWidth: {
- type: processedPropType(Number, String),
+ type: Object as PropType[>,
required: true
},
name: {
@@ -60,56 +60,41 @@ export default defineComponent({
required: true
}
},
+ emits: ["setMinimized"],
setup(props) {
- const { display, index, minimized, minWidth, tab } = toRefs(props);
+ const { display, index, minimized, minimizedDisplay } = toRefs(props);
const component = computeComponent(display);
+ const minimizedComponent = computeOptionalComponent(minimizedDisplay);
const showGoBack = computed(
- () => projInfo.allowGoBack && index.value > 0 && !minimized.value
+ () => projInfo.allowGoBack && index.value > 0 && !unwrapRef(minimized)
);
function goBack() {
player.tabs.splice(unref(props.index), Infinity);
}
- nextTick(() => updateTab(minimized.value, unref(minWidth.value)));
- watch([minimized, wrapRef(minWidth)], ([minimized, minWidth]) =>
- updateTab(minimized, minWidth)
- );
-
function updateNodes(nodes: Record]) {
props.nodes.value = nodes;
}
- function updateTab(minimized: boolean, minWidth: number | string) {
- const width =
- typeof minWidth === "number" || Number.isNaN(parseInt(minWidth))
- ? minWidth + "px"
- : minWidth;
- const tabValue = tab.value();
- if (tabValue != undefined) {
- if (minimized) {
- tabValue.style.flexGrow = "0";
- tabValue.style.flexShrink = "0";
- tabValue.style.width = "60px";
- tabValue.style.minWidth = tabValue.style.flexBasis = "";
- tabValue.style.margin = "0";
- } else {
- tabValue.style.flexGrow = "";
- tabValue.style.flexShrink = "";
- tabValue.style.width = "";
- tabValue.style.minWidth = tabValue.style.flexBasis = width;
- tabValue.style.margin = "";
- }
- }
- }
+ const errors = ref([]);
+ onErrorCaptured((err, instance, info) => {
+ console.warn(`Error caught in "${props.name}" layer`, err, instance, info);
+ errors.value.push(
+ err instanceof Error ? (err as Error) : new Error(JSON.stringify(err))
+ );
+ return false;
+ });
return {
component,
+ minimizedComponent,
showGoBack,
updateNodes,
unref,
- goBack
+ goBack,
+ errors
};
}
});
@@ -155,9 +140,10 @@ export default defineComponent({
background-color: transparent;
}
-.layer-tab.minimized div {
+.layer-tab.minimized > * {
margin: 0;
writing-mode: vertical-rl;
+ text-align: left;
padding-left: 10px;
width: 50px;
}
@@ -201,8 +187,8 @@ export default defineComponent({
.goBack {
position: sticky;
- top: 6px;
- left: 20px;
+ top: 10px;
+ left: 10px;
line-height: 30px;
margin-top: -50px;
margin-left: -35px;
@@ -211,7 +197,7 @@ export default defineComponent({
box-shadow: var(--background) 0 2px 3px 5px;
border-radius: 50%;
color: var(--foreground);
- font-size: 40px;
+ font-size: 30px;
cursor: pointer;
z-index: 7;
}
@@ -221,3 +207,10 @@ export default defineComponent({
text-shadow: 0 0 7px var(--foreground);
}
+
+
diff --git a/src/components/Modal.vue b/src/components/Modal.vue
index 7a3ee19..9fd5f06 100644
--- a/src/components/Modal.vue
+++ b/src/components/Modal.vue
@@ -40,7 +40,7 @@
-
diff --git a/src/components/Save.vue b/src/components/Save.vue
index c151666..77d2988 100644
--- a/src/components/Save.vue
+++ b/src/components/Save.vue
@@ -33,7 +33,7 @@
(isConfirming = value)"
+ @confirmingChanged="(value: boolean) => (isConfirming = value)"
>
delete
@@ -104,11 +104,11 @@ const isEditing = ref(false);
const isConfirming = ref(false);
const newName = ref("");
-watch(isEditing, () => (newName.value = save.value.name || ""));
+watch(isEditing, () => (newName.value = save.value.name ?? ""));
-const isActive = computed(() => save.value && save.value.id === player.id);
+const isActive = computed(() => save.value != null && save.value.id === player.id);
const currentTime = computed(() =>
- isActive.value ? player.time : (save.value && save.value.time) || 0
+ isActive.value ? player.time : (save.value != null && save.value.time) ?? 0
);
function changeName() {
diff --git a/src/components/SavesManager.vue b/src/components/SavesManager.vue
index bdf14c8..fe9b95e 100644
--- a/src/components/SavesManager.vue
+++ b/src/components/SavesManager.vue
@@ -15,7 +15,7 @@
:save="saves[element]"
@open="openSave(element)"
@export="exportSave(element)"
- @editName="name => editSave(element, name)"
+ @editName="(name: string) => editSave(element, name)"
@duplicate="duplicateSave(element)"
@delete="deleteSave(element)"
/>
@@ -40,7 +40,7 @@
v-if="Object.keys(bank).length > 0"
:options="bank"
:modelValue="selectedPreset"
- @update:modelValue="preset => newFromPreset(preset as string)"
+ @update:modelValue="(preset: unknown) => newFromPreset(preset as string)"
closeOnSelect
placeholder="Select preset"
class="presets"
@@ -62,11 +62,10 @@
import Modal from "components/Modal.vue";
import projInfo from "data/projInfo.json";
import { isHosting, room } from "data/socket";
-import type { PlayerData } from "game/player";
+import type { Player } from "game/player";
import player, { stringifySave } from "game/player";
import settings from "game/settings";
import LZString from "lz-string";
-import { ProxyState } from "util/proxies";
import { getUniqueID, loadSave, newSave, save } from "util/save";
import type { ComponentPublicInstance } from "vue";
import { computed, nextTick, ref, shallowReactive, watch } from "vue";
@@ -75,7 +74,7 @@ import Select from "./fields/Select.vue";
import Text from "./fields/Text.vue";
import Save from "./Save.vue";
-export type LoadablePlayerData = Omit, "id"> & { id: string; error?: unknown };
+export type LoadablePlayerData = Omit, "id"> & { id: string; error?: unknown };
const isOpen = ref(false);
const modal = ref | null>(null);
@@ -198,7 +197,7 @@ const saves = computed(() =>
function exportSave(id: string) {
let saveToExport;
if (player.id === id) {
- saveToExport = stringifySave(player[ProxyState]);
+ saveToExport = stringifySave(player);
} else {
saveToExport = JSON.stringify(saves.value[id]);
}
@@ -231,7 +230,7 @@ function duplicateSave(id: string) {
}
const playerData = { ...saves.value[id], id: getUniqueID() };
- save(playerData as PlayerData);
+ save(playerData as Player);
settings.saves.push(playerData.id);
}
@@ -275,7 +274,7 @@ function newFromPreset(preset: string) {
}
const playerData = JSON.parse(preset);
playerData.id = getUniqueID();
- save(playerData as PlayerData);
+ save(playerData as Player);
settings.saves.push(playerData.id);
@@ -284,13 +283,13 @@ function newFromPreset(preset: string) {
function editSave(id: string, newName: string) {
const currSave = saves.value[id];
- if (currSave) {
+ if (currSave != null) {
currSave.name = newName;
if (player.id === id) {
player.name = newName;
save();
} else {
- save(currSave as PlayerData);
+ save(currSave as Player);
cachedSaves[id] = undefined;
}
}
diff --git a/src/components/TPS.vue b/src/components/TPS.vue
index a460a09..db73da6 100644
--- a/src/components/TPS.vue
+++ b/src/components/TPS.vue
@@ -1,17 +1,11 @@
-
- TPS: {{ formatWhole(tps) }}
- {{ formatWhole(low) }}
-
+ TPS: {{ formatWhole(tps) }}
diff --git a/src/features/achievements/achievement.tsx b/src/features/achievements/achievement.tsx
index 8bebb55..9bf454f 100644
--- a/src/features/achievements/achievement.tsx
+++ b/src/features/achievements/achievement.tsx
@@ -1,20 +1,35 @@
+import { computed } from "@vue/reactivity";
+import { isArray } from "@vue/shared";
+import Select from "components/fields/Select.vue";
import AchievementComponent from "features/achievements/Achievement.vue";
+import { GenericDecorator } from "features/decorators/common";
import {
CoercableComponent,
Component,
GatherProps,
- getUniqueID,
+ GenericComponent,
OptionsFunc,
Replace,
- setDefault,
StyleValue,
- Visibility
+ Visibility,
+ getUniqueID,
+ jsx,
+ setDefault
} from "features/feature";
+import { globalBus } from "game/events";
import "game/notifications";
import type { Persistent } from "game/persistence";
import { persistent } from "game/persistence";
import player from "game/player";
-import settings from "game/settings";
+import {
+ Requirements,
+ createBooleanRequirement,
+ createVisibilityRequirement,
+ displayRequirements,
+ requirementsMet
+} from "game/requirements";
+import settings, { registerSettingField } from "game/settings";
+import { camelToTitle } from "util/common";
import type {
Computable,
GetComputableType,
@@ -23,34 +38,79 @@ import type {
} from "util/computed";
import { processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies";
-import { coerceComponent } from "util/vue";
+import { coerceComponent, isCoercableComponent } from "util/vue";
import { unref, watchEffect } from "vue";
import { useToast } from "vue-toastification";
const toast = useToast();
+/** A symbol used to identify {@link Achievement} features. */
export const AchievementType = Symbol("Achievement");
+/** Modes for only displaying some achievements. */
+export enum AchievementDisplay {
+ All = "all",
+ //Last = "last",
+ Configurable = "configurable",
+ Incomplete = "incomplete",
+ None = "none"
+}
+
+/**
+ * An object that configures an {@link Achievement}.
+ */
export interface AchievementOptions {
- visibility?: Computable;
- shouldEarn?: () => boolean;
- display?: Computable;
+ /** Whether this achievement should be visible. */
+ visibility?: Computable;
+ /** The requirement(s) to earn this achievement. Can be left null if using {@link BaseAchievement.complete}. */
+ requirements?: Requirements;
+ /** The display to use for this achievement. */
+ display?: Computable<
+ | CoercableComponent
+ | {
+ /** Description of the requirement(s) for this achievement. If unspecified then the requirements will be displayed automatically based on {@link requirements}. */
+ requirement?: CoercableComponent;
+ /** Description of what will change (if anything) for achieving this. */
+ effectDisplay?: CoercableComponent;
+ /** Any additional things to display on this achievement, such as a toggle for it's effect. */
+ optionsDisplay?: CoercableComponent;
+ }
+ >;
+ /** Shows a marker on the corner of the feature. */
mark?: Computable;
+ /** Toggles a smaller design for the feature. */
+ small?: Computable;
+ /** An image to display as the background for this achievement. */
image?: Computable;
+ /** CSS to apply to this feature. */
style?: Computable;
+ /** Dictionary of CSS classes to apply to this feature. */
classes?: Computable>;
+ /** Whether or not to display a notification popup when this achievement is earned. */
+ showPopups?: Computable;
+ /** A function that is called when the achievement is completed. */
onComplete?: VoidFunction;
}
+/**
+ * The properties that are added onto a processed {@link AchievementOptions} to create an {@link Achievement}.
+ */
export interface BaseAchievement {
+ /** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string;
+ /** Whether or not this achievement has been earned. */
earned: Persistent;
+ /** A function to complete this achievement. */
complete: VoidFunction;
+ /** A symbol that helps identify features of the same type. */
type: typeof AchievementType;
- [Component]: typeof AchievementComponent;
+ /** The Vue component used to render this feature. */
+ [Component]: GenericComponent;
+ /** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record;
}
+/** An object that represents a feature with requirements that is passively earned upon meeting certain requirements. */
export type Achievement = Replace<
T & BaseAchievement,
{
@@ -60,68 +120,169 @@ export type Achievement = Replace<
image: GetComputableType;
style: GetComputableType;
classes: GetComputableType;
+ showPopups: GetComputableTypeWithDefault;
}
>;
+/** A type that matches any valid {@link Achievement} object. */
export type GenericAchievement = Replace<
Achievement,
{
- visibility: ProcessedComputable;
+ visibility: ProcessedComputable;
+ showPopups: ProcessedComputable;
}
>;
+/**
+ * Lazily creates an achievement with the given options.
+ * @param optionsFunc Achievement options.
+ */
export function createAchievement(
- optionsFunc?: OptionsFunc
+ optionsFunc?: OptionsFunc,
+ ...decorators: GenericDecorator[]
): Achievement {
- const earned = persistent(false);
- return createLazyProxy(() => {
- const achievement = optionsFunc?.() ?? ({} as ReturnType>);
+ const earned = persistent(false, false);
+ const decoratedData = decorators.reduce(
+ (current, next) => Object.assign(current, next.getPersistentData?.()),
+ {}
+ );
+ return createLazyProxy(feature => {
+ const achievement =
+ optionsFunc?.call(feature, feature) ??
+ ({} as ReturnType>);
achievement.id = getUniqueID("achievement-");
achievement.type = AchievementType;
- achievement[Component] = AchievementComponent;
+ achievement[Component] = AchievementComponent as GenericComponent;
+
+ for (const decorator of decorators) {
+ decorator.preConstruct?.(achievement);
+ }
achievement.earned = earned;
achievement.complete = function () {
+ if (earned.value) {
+ return;
+ }
earned.value = true;
+ const genericAchievement = achievement as GenericAchievement;
+ genericAchievement.onComplete?.();
+ if (
+ genericAchievement.display != null &&
+ unref(genericAchievement.showPopups) === true
+ ) {
+ const display = unref(genericAchievement.display);
+ let Display;
+ if (isCoercableComponent(display)) {
+ Display = coerceComponent(display);
+ } else if (display.requirement != null) {
+ Display = coerceComponent(display.requirement);
+ } else {
+ Display = displayRequirements(genericAchievement.requirements ?? []);
+ }
+ toast.info(
+
+
Achievement earned!
+
+ {/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
+ {/* @ts-ignore */}
+
+
+
+ );
+ }
};
+ Object.assign(achievement, decoratedData);
+
processComputable(achievement as T, "visibility");
setDefault(achievement, "visibility", Visibility.Visible);
+ const visibility = achievement.visibility as ProcessedComputable;
+ achievement.visibility = computed(() => {
+ const display = unref((achievement as GenericAchievement).display);
+ switch (settings.msDisplay) {
+ default:
+ case AchievementDisplay.All:
+ return unref(visibility);
+ case AchievementDisplay.Configurable:
+ if (
+ unref(achievement.earned) &&
+ !(
+ display != null &&
+ typeof display == "object" &&
+ "optionsDisplay" in (display as Record)
+ )
+ ) {
+ return Visibility.None;
+ }
+ return unref(visibility);
+ case AchievementDisplay.Incomplete:
+ if (unref(achievement.earned)) {
+ return Visibility.None;
+ }
+ return unref(visibility);
+ case AchievementDisplay.None:
+ return Visibility.None;
+ }
+ });
+
processComputable(achievement as T, "display");
processComputable(achievement as T, "mark");
+ processComputable(achievement as T, "small");
processComputable(achievement as T, "image");
processComputable(achievement as T, "style");
processComputable(achievement as T, "classes");
+ processComputable(achievement as T, "showPopups");
+ setDefault(achievement, "showPopups", true);
+ for (const decorator of decorators) {
+ decorator.postConstruct?.(achievement);
+ }
+
+ const decoratedProps = decorators.reduce(
+ (current, next) => Object.assign(current, next.getGatheredProps?.(achievement)),
+ {}
+ );
achievement[GatherProps] = function (this: GenericAchievement) {
- const { visibility, display, earned, image, style, classes, mark, id } = this;
- return { visibility, display, earned, image, style: unref(style), classes, mark, id };
+ const {
+ visibility,
+ display,
+ requirements,
+ earned,
+ image,
+ style,
+ classes,
+ mark,
+ small,
+ id
+ } = this;
+ return {
+ visibility,
+ display,
+ requirements,
+ earned,
+ image,
+ style: unref(style),
+ classes,
+ mark,
+ small,
+ id,
+ ...decoratedProps
+ };
};
- if (achievement.shouldEarn) {
+ if (achievement.requirements) {
const genericAchievement = achievement as GenericAchievement;
+ const requirements = [
+ createVisibilityRequirement(genericAchievement),
+ createBooleanRequirement(() => !genericAchievement.earned.value),
+ ...(isArray(achievement.requirements)
+ ? achievement.requirements
+ : [achievement.requirements])
+ ];
watchEffect(() => {
if (settings.active !== player.id) return;
- if (
- !genericAchievement.earned.value &&
- unref(genericAchievement.visibility) === Visibility.Visible &&
- genericAchievement.shouldEarn?.()
- ) {
- genericAchievement.earned.value = true;
- genericAchievement.onComplete?.();
- if (genericAchievement.display) {
- const Display = coerceComponent(unref(genericAchievement.display));
- toast.info(
-
-
Achievement earned!
-
- {/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
- {/* @ts-ignore */}
-
-
-
- );
- }
+ if (requirementsMet(requirements)) {
+ genericAchievement.complete();
}
});
}
@@ -129,3 +290,34 @@ export function createAchievement(
return achievement as unknown as Achievement;
});
}
+
+declare module "game/settings" {
+ interface Settings {
+ msDisplay: AchievementDisplay;
+ }
+}
+
+globalBus.on("loadSettings", settings => {
+ setDefault(settings, "msDisplay", AchievementDisplay.All);
+});
+
+const msDisplayOptions = Object.values(AchievementDisplay).map(option => ({
+ label: camelToTitle(option),
+ value: option
+}));
+
+registerSettingField(
+ jsx(() => (
+ (
+
+ Show achievements
+ Select which achievements to display based on criterias.
+
+ ))}
+ options={msDisplayOptions}
+ onUpdate:modelValue={value => (settings.msDisplay = value as AchievementDisplay)}
+ modelValue={settings.msDisplay}
+ />
+ ))
+);
diff --git a/src/features/action.tsx b/src/features/action.tsx
new file mode 100644
index 0000000..1fbb8d3
--- /dev/null
+++ b/src/features/action.tsx
@@ -0,0 +1,293 @@
+import { isArray } from "@vue/shared";
+import ClickableComponent from "features/clickables/Clickable.vue";
+import {
+ Component,
+ findFeatures,
+ GatherProps,
+ GenericComponent,
+ getUniqueID,
+ jsx,
+ JSXFunction,
+ OptionsFunc,
+ Replace,
+ setDefault,
+ StyleValue,
+ Visibility
+} 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 type {
+ Computable,
+ GetComputableType,
+ GetComputableTypeWithDefault,
+ ProcessedComputable
+} from "util/computed";
+import { processComputable } from "util/computed";
+import { createLazyProxy } from "util/proxies";
+import { coerceComponent, isCoercableComponent, render } from "util/vue";
+import { computed, Ref, ref, unref } from "vue";
+import { BarOptions, createBar, GenericBar } from "./bars/bar";
+import { ClickableOptions } from "./clickables/clickable";
+import { Decorator, GenericDecorator } from "./decorators/common";
+
+/** A symbol used to identify {@link Action} features. */
+export const ActionType = Symbol("Action");
+
+/**
+ * An object that configures an {@link Action}.
+ */
+export interface ActionOptions extends Omit {
+ /** The cooldown during which the action cannot be performed again, in seconds. */
+ duration: Computable;
+ /** Whether or not the action should perform automatically when the cooldown is finished. */
+ autoStart?: Computable;
+ /** A function that is called when the action is clicked. */
+ onClick: (amount: DecimalSource) => void;
+ /** A pass-through to the {@link Bar} used to display the cooldown progress for the action. */
+ barOptions?: Partial;
+}
+
+/**
+ * The properties that are added onto a processed {@link ActionOptions} to create an {@link Action}.
+ */
+export interface BaseAction {
+ /** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
+ id: string;
+ /** A symbol that helps identify features of the same type. */
+ type: typeof ActionType;
+ /** 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;
+ /** The current amount of progress through the cooldown. */
+ progress: Ref;
+ /** The bar used to display the current cooldown progress. */
+ progressBar: GenericBar;
+ /** Update the cooldown the specified number of seconds */
+ update: (diff: number) => void;
+ /** The Vue component used to render this feature. */
+ [Component]: GenericComponent;
+ /** A function to gather the props the vue component requires for this feature. */
+ [GatherProps]: () => Record;
+}
+
+/** An object that represents a feature that can be clicked upon, and then has a cooldown before it can be clicked again. */
+export type Action = Replace<
+ T & BaseAction,
+ {
+ duration: GetComputableType;
+ autoStart: GetComputableTypeWithDefault;
+ visibility: GetComputableTypeWithDefault;
+ canClick: GetComputableTypeWithDefault;
+ classes: GetComputableType;
+ style: GetComputableType;
+ mark: GetComputableType;
+ display: JSXFunction;
+ onClick: VoidFunction;
+ }
+>;
+
+/** A type that matches any valid {@link Action} object. */
+export type GenericAction = Replace<
+ Action,
+ {
+ autoStart: ProcessedComputable;
+ visibility: ProcessedComputable;
+ canClick: ProcessedComputable;
+ }
+>;
+
+/**
+ * Lazily creates an action with the given options.
+ * @param optionsFunc Action options.
+ */
+export function createAction(
+ optionsFunc?: OptionsFunc,
+ ...decorators: GenericDecorator[]
+): Action {
+ const progress = persistent(0);
+ const decoratedData = decorators.reduce(
+ (current, next) => Object.assign(current, next.getPersistentData?.()),
+ {}
+ );
+ return createLazyProxy(feature => {
+ const action =
+ optionsFunc?.call(feature, feature) ??
+ ({} as ReturnType>);
+ action.id = getUniqueID("action-");
+ action.type = ActionType;
+ action[Component] = ClickableComponent as GenericComponent;
+
+ // Required because of display changing types
+ const genericAction = action as unknown as GenericAction;
+
+ for (const decorator of decorators) {
+ decorator.preConstruct?.(action);
+ }
+
+ action.isHolding = ref(false);
+ action.progress = progress;
+ Object.assign(action, decoratedData);
+
+ processComputable(action as T, "visibility");
+ setDefault(action, "visibility", Visibility.Visible);
+ processComputable(action as T, "duration");
+ processComputable(action as T, "autoStart");
+ setDefault(action, "autoStart", false);
+ processComputable(action as T, "canClick");
+ setDefault(action, "canClick", true);
+ processComputable(action as T, "classes");
+ processComputable(action as T, "style");
+ processComputable(action as T, "mark");
+ processComputable(action as T, "display");
+
+ const style = action.style as ProcessedComputable;
+ action.style = computed(() => {
+ const currStyle: StyleValue[] = [
+ {
+ cursor: Decimal.gte(
+ progress.value,
+ unref(action.duration as ProcessedComputable)
+ )
+ ? "pointer"
+ : "progress",
+ display: "flex",
+ flexDirection: "column"
+ }
+ ];
+ const originalStyle = unref(style);
+ if (isArray(originalStyle)) {
+ currStyle.push(...originalStyle);
+ } else if (originalStyle != null) {
+ currStyle.push(originalStyle);
+ }
+ return currStyle as StyleValue;
+ });
+
+ action.progressBar = createBar(() => ({
+ direction: Direction.Right,
+ width: 100,
+ height: 10,
+ borderStyle: "border-color: black",
+ baseStyle: "margin-top: -1px",
+ progress: () => Decimal.div(progress.value, unref(genericAction.duration)),
+ ...action.barOptions
+ }));
+
+ const canClick = action.canClick as ProcessedComputable;
+ action.canClick = computed(
+ () =>
+ unref(canClick) &&
+ Decimal.gte(
+ progress.value,
+ unref(action.duration as ProcessedComputable)
+ )
+ );
+
+ const display = action.display as GetComputableType;
+ action.display = jsx(() => {
+ const currDisplay = unref(display);
+ let Comp: GenericComponent | undefined;
+ if (isCoercableComponent(currDisplay)) {
+ Comp = coerceComponent(currDisplay);
+ } else if (currDisplay != null) {
+ const Title = coerceComponent(currDisplay.title ?? "", "h3");
+ const Description = coerceComponent(currDisplay.description, "div");
+ Comp = coerceComponent(
+ jsx(() => (
+
+ {currDisplay.title != null ? (
+
+
+
+ ) : null}
+
+
+ ))
+ );
+ }
+ return (
+ <>
+
+ {Comp == null ? null : }
+
+ {render(genericAction.progressBar)}
+ >
+ );
+ });
+
+ const onClick = action.onClick.bind(action);
+ action.onClick = function () {
+ if (unref(action.canClick) === false) {
+ return;
+ }
+ const amount = Decimal.div(progress.value, unref(genericAction.duration));
+ onClick?.(amount);
+ progress.value = 0;
+ };
+
+ action.update = function (diff) {
+ const duration = unref(genericAction.duration);
+ if (Decimal.gte(progress.value, duration)) {
+ progress.value = duration;
+ } else {
+ progress.value = Decimal.add(progress.value, diff);
+ if (genericAction.isHolding.value || unref(genericAction.autoStart)) {
+ genericAction.onClick();
+ }
+ }
+ };
+
+ for (const decorator of decorators) {
+ decorator.postConstruct?.(action);
+ }
+
+ const decoratedProps = decorators.reduce(
+ (current, next) => Object.assign(current, next.getGatheredProps?.(action)),
+ {}
+ );
+ action[GatherProps] = function (this: GenericAction) {
+ const {
+ display,
+ visibility,
+ style,
+ classes,
+ onClick,
+ isHolding,
+ canClick,
+ small,
+ mark,
+ id
+ } = this;
+ return {
+ display,
+ visibility,
+ style: unref(style),
+ classes,
+ onClick,
+ isHolding,
+ canClick,
+ small,
+ mark,
+ id,
+ ...decoratedProps
+ };
+ };
+
+ return action as unknown as Action;
+ });
+}
+
+const listeners: Record = {};
+globalBus.on("addLayer", layer => {
+ const actions: GenericAction[] = findFeatures(layer, ActionType) as GenericAction[];
+ listeners[layer.id] = layer.on("postUpdate", diff => {
+ actions.forEach(action => action.update(diff));
+ });
+});
+globalBus.on("removeLayer", layer => {
+ // unsubscribe from postUpdate
+ listeners[layer.id]?.();
+ listeners[layer.id] = undefined;
+});
diff --git a/src/features/bars/Bar.vue b/src/features/bars/Bar.vue
index df140c0..4d7bb16 100644
--- a/src/features/bars/Bar.vue
+++ b/src/features/bars/Bar.vue
@@ -1,11 +1,11 @@
import MarkNode from "components/MarkNode.vue";
import Node from "components/Node.vue";
-import { CoercableComponent, Visibility } from "features/feature";
+import { CoercableComponent, isHidden, isVisible, Visibility } from "features/feature";
import type { DecimalSource } from "util/bignum";
import Decimal from "util/bignum";
import { Direction } from "util/common";
@@ -72,7 +72,7 @@ export default defineComponent({
},
display: processedPropType
(Object, String, Function),
visibility: {
- type: processedPropType(Number),
+ type: processedPropType(Number, Boolean),
required: true
},
style: processedPropType(Object, String, Array),
@@ -120,7 +120,7 @@ export default defineComponent({
barStyle.clipPath = `inset(0% ${normalizedProgress.value}% 0% 0%)`;
break;
case Direction.Left:
- barStyle.clipPath = `inset(0% 0% 0% ${normalizedProgress.value} + '%)`;
+ barStyle.clipPath = `inset(0% 0% 0% ${normalizedProgress.value}%)`;
break;
case Direction.Default:
barStyle.clipPath = "inset(0% 50% 0% 0%)";
@@ -136,7 +136,9 @@ export default defineComponent({
barStyle,
component,
unref,
- Visibility
+ Visibility,
+ isVisible,
+ isHidden
};
}
});
@@ -177,5 +179,6 @@ export default defineComponent({
margin-left: -0.5px;
transition-duration: 0.2s;
z-index: 2;
+ transition-duration: 0.05s;
}
diff --git a/src/features/bars/bar.ts b/src/features/bars/bar.ts
index 4d15f18..8179001 100644
--- a/src/features/bars/bar.ts
+++ b/src/features/bars/bar.ts
@@ -1,6 +1,13 @@
import BarComponent from "features/bars/Bar.vue";
-import type { CoercableComponent, OptionsFunc, Replace, StyleValue } from "features/feature";
-import { Component, GatherProps, getUniqueID, setDefault, Visibility } from "features/feature";
+import { GenericDecorator } from "features/decorators/common";
+import type {
+ CoercableComponent,
+ GenericComponent,
+ OptionsFunc,
+ Replace,
+ StyleValue
+} from "features/feature";
+import { Component, GatherProps, Visibility, getUniqueID, setDefault } from "features/feature";
import type { DecimalSource } from "util/bignum";
import { Direction } from "util/common";
import type {
@@ -13,31 +20,56 @@ import { processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies";
import { unref } from "vue";
+/** A symbol used to identify {@link Bar} features. */
export const BarType = Symbol("Bar");
+/**
+ * An object that configures a {@link Bar}.
+ */
export interface BarOptions {
- visibility?: Computable;
+ /** Whether this bar should be visible. */
+ visibility?: Computable;
+ /** The width of the bar. */
width: Computable;
+ /** The height of the bar. */
height: Computable;
+ /** The direction in which the bar progresses. */
direction: Computable;
+ /** CSS to apply to this feature. */
style?: Computable;
+ /** Dictionary of CSS classes to apply to this feature. */
classes?: Computable>;
+ /** CSS to apply to the bar's border. */
borderStyle?: Computable;
+ /** CSS to apply to the bar's base. */
baseStyle?: Computable;
+ /** CSS to apply to the bar's text. */
textStyle?: Computable;
+ /** CSS to apply to the bar's fill. */
fillStyle?: Computable;
+ /** The progress value of the bar, from 0 to 1. */
progress: Computable;
+ /** The display to use for this bar. */
display?: Computable;
+ /** Shows a marker on the corner of the feature. */
mark?: Computable;
}
+/**
+ * The properties that are added onto a processed {@link BarOptions} to create a {@link Bar}.
+ */
export interface BaseBar {
+ /** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string;
+ /** A symbol that helps identify features of the same type. */
type: typeof BarType;
- [Component]: typeof BarComponent;
+ /** The Vue component used to render this feature. */
+ [Component]: GenericComponent;
+ /** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record;
}
+/** An object that represents a feature that displays some sort of progress or completion or resource with a cap. */
export type Bar = Replace<
T & BaseBar,
{
@@ -57,21 +89,37 @@ export type Bar = Replace<
}
>;
+/** A type that matches any valid {@link Bar} object. */
export type GenericBar = Replace<
Bar,
{
- visibility: ProcessedComputable;
+ visibility: ProcessedComputable;
}
>;
+/**
+ * Lazily creates a bar with the given options.
+ * @param optionsFunc Bar options.
+ */
export function createBar(
- optionsFunc: OptionsFunc
+ optionsFunc: OptionsFunc,
+ ...decorators: GenericDecorator[]
): Bar {
- return createLazyProxy(() => {
- const bar = optionsFunc();
+ const decoratedData = decorators.reduce(
+ (current, next) => Object.assign(current, next.getPersistentData?.()),
+ {}
+ );
+ return createLazyProxy(feature => {
+ const bar = optionsFunc.call(feature, feature);
bar.id = getUniqueID("bar-");
bar.type = BarType;
- bar[Component] = BarComponent;
+ bar[Component] = BarComponent as GenericComponent;
+
+ for (const decorator of decorators) {
+ decorator.preConstruct?.(bar);
+ }
+
+ Object.assign(bar, decoratedData);
processComputable(bar as T, "visibility");
setDefault(bar, "visibility", Visibility.Visible);
@@ -88,6 +136,14 @@ export function createBar(
processComputable(bar as T, "display");
processComputable(bar as T, "mark");
+ for (const decorator of decorators) {
+ decorator.postConstruct?.(bar);
+ }
+
+ const decoratedProps = decorators.reduce(
+ (current, next) => Object.assign(current, next.getGatheredProps?.(bar)),
+ {}
+ );
bar[GatherProps] = function (this: GenericBar) {
const {
progress,
@@ -119,7 +175,8 @@ export function createBar(
baseStyle,
fillStyle,
mark,
- id
+ id,
+ ...decoratedProps
};
};
diff --git a/src/features/boards/Board.vue b/src/features/boards/Board.vue
index 7d683f9..bb4e619 100644
--- a/src/features/boards/Board.vue
+++ b/src/features/boards/Board.vue
@@ -1,7 +1,6 @@
mouseDown(e)"
@touchstart="(e: TouchEvent) => mouseDown(e)"
- @mouseup="() => endDragging(dragging)"
- @touchend.passive="() => endDragging(dragging)"
- @mouseleave="() => endDragging(dragging)"
+ @mouseup="() => endDragging(unref(draggingNode))"
+ @touchend.passive="() => endDragging(unref(draggingNode))"
+ @mouseleave="() => endDragging(unref(draggingNode), true)"
@zoom="zoom"
>
-
-
+
+
@@ -35,14 +46,17 @@
clickAction(node, actionId)"
/>
@@ -68,9 +82,9 @@ import type {
} from "features/boards/board";
import { getNodeProperty } from "features/boards/board";
import type { StyleValue } from "features/feature";
-import { Visibility } from "features/feature";
+import { Visibility, isVisible } from "features/feature";
import type { ProcessedComputable } from "util/computed";
-import { computed, ref, Ref, toRefs, unref } from "vue";
+import { Ref, computed, ref, toRefs, unref, watchEffect } from "vue";
import BoardLinkVue from "./BoardLink.vue";
import BoardNodeVue from "./BoardNode.vue";
@@ -78,7 +92,7 @@ const _props = defineProps<{
nodes: Ref;
types: Record;
state: Ref;
- visibility: ProcessedComputable;
+ visibility: ProcessedComputable;
width?: ProcessedComputable;
height?: ProcessedComputable;
style?: ProcessedComputable;
@@ -86,33 +100,36 @@ const _props = defineProps<{
links: Ref;
selectedAction: Ref;
selectedNode: Ref;
+ draggingNode: Ref;
+ receivingNode: Ref;
mousePosition: Ref<{ x: number; y: number } | null>;
+ setReceivingNode: (node: BoardNode | null) => void;
+ setDraggingNode: (node: BoardNode | null) => void;
}>();
const props = toRefs(_props);
const lastMousePosition = ref({ x: 0, y: 0 });
const dragged = ref({ x: 0, y: 0 });
-const dragging = ref(null);
const hasDragged = ref(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const stage = ref(null);
const currentZoom = ref(1);
-const draggingNode = computed(() =>
- dragging.value == null ? undefined : props.nodes.value.find(node => node.id === dragging.value)
-);
-
const sortedNodes = computed(() => {
const nodes = props.nodes.value.slice();
- if (draggingNode.value) {
- const node = nodes.splice(nodes.indexOf(draggingNode.value), 1)[0];
+ if (props.selectedNode.value) {
+ const node = nodes.splice(nodes.indexOf(props.selectedNode.value), 1)[0];
+ nodes.push(node);
+ }
+ if (props.draggingNode.value) {
+ const node = nodes.splice(nodes.indexOf(props.draggingNode.value), 1)[0];
nodes.push(node);
}
return nodes;
});
-const receivingNode = computed(() => {
- const node = draggingNode.value;
+watchEffect(() => {
+ const node = props.draggingNode.value;
if (node == null) {
return null;
}
@@ -122,26 +139,30 @@ const receivingNode = computed(() => {
y: node.position.y + dragged.value.y
};
let smallestDistance = Number.MAX_VALUE;
- return props.nodes.value.reduce((smallest: BoardNode | null, curr: BoardNode) => {
- if (curr.id === node.id) {
- return smallest;
- }
- const nodeType = props.types.value[curr.type];
- const canAccept = getNodeProperty(nodeType.canAccept, curr);
- if (!canAccept) {
- return smallest;
- }
- const distanceSquared =
- Math.pow(position.x - curr.position.x, 2) + Math.pow(position.y - curr.position.y, 2);
- let size = getNodeProperty(nodeType.size, curr);
- if (distanceSquared > smallestDistance || distanceSquared > size * size) {
- return smallest;
- }
+ props.setReceivingNode.value(
+ props.nodes.value.reduce((smallest: BoardNode | null, curr: BoardNode) => {
+ if (curr.id === node.id) {
+ return smallest;
+ }
+ const nodeType = props.types.value[curr.type];
+ const canAccept = getNodeProperty(nodeType.canAccept, curr, node);
+ if (!canAccept) {
+ return smallest;
+ }
- smallestDistance = distanceSquared;
- return curr;
- }, null);
+ const distanceSquared =
+ Math.pow(position.x - curr.position.x, 2) +
+ Math.pow(position.y - curr.position.y, 2);
+ let size = getNodeProperty(nodeType.size, curr);
+ if (distanceSquared > smallestDistance || distanceSquared > size * size) {
+ return smallest;
+ }
+
+ smallestDistance = distanceSquared;
+ return curr;
+ }, null)
+ );
});
const cursors = computed(() => Object.entries(cursorPositions.value).filter(([id]) => id in nicknames.value));
@@ -149,10 +170,11 @@ const cursors = computed(() => Object.entries(cursorPositions.value).filter(([id
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function onInit(panzoomInstance: any) {
panzoomInstance.setTransformOrigin(null);
+ panzoomInstance.moveTo(stage.value.$el.clientWidth / 2, stage.value.$el.clientHeight / 2);
}
-function mouseDown(e: MouseEvent | TouchEvent, nodeID: number | null = null, draggable = false) {
- if (dragging.value == null) {
+function mouseDown(e: MouseEvent | TouchEvent, node: BoardNode | null = null, draggable = false) {
+ if (props.draggingNode.value == null) {
e.preventDefault();
e.stopPropagation();
@@ -176,10 +198,10 @@ function mouseDown(e: MouseEvent | TouchEvent, nodeID: number | null = null, dra
hasDragged.value = false;
if (draggable) {
- dragging.value = nodeID;
+ props.setDraggingNode.value(node);
}
}
- if (nodeID != null) {
+ if (node != null) {
props.state.value.selectedNode = null;
props.state.value.selectedAction = null;
}
@@ -194,7 +216,7 @@ function drag(e: MouseEvent | TouchEvent) {
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
- endDragging(dragging.value);
+ endDragging(props.draggingNode.value);
props.mousePosition.value = null;
return;
}
@@ -204,8 +226,8 @@ function drag(e: MouseEvent | TouchEvent) {
}
props.mousePosition.value = {
- x: (clientX - x - 10),
- y: (clientY - y - 50)
+ x: (clientX - x) / scale,
+ y: (clientY - y) / scale
};
dragged.value = {
@@ -221,35 +243,45 @@ function drag(e: MouseEvent | TouchEvent) {
hasDragged.value = true;
}
- if (dragging.value) {
+ if (props.draggingNode.value != null) {
e.preventDefault();
e.stopPropagation();
}
}
-function endDragging(nodeID: number | null) {
- if (dragging.value != null && dragging.value === nodeID && draggingNode.value != null) {
- draggingNode.value.position.x += Math.round(dragged.value.x / 25) * 25;
- draggingNode.value.position.y += Math.round(dragged.value.y / 25) * 25;
+function endDragging(node: BoardNode | null, mouseLeave = false) {
+ if (props.draggingNode.value != null && props.draggingNode.value === node) {
+ if (props.receivingNode.value == null) {
+ props.draggingNode.value.position.x += Math.round(dragged.value.x / 25) * 25;
+ props.draggingNode.value.position.y += Math.round(dragged.value.y / 25) * 25;
+ }
const nodes = props.nodes.value;
- nodes.splice(nodes.indexOf(draggingNode.value), 1);
- nodes.push(draggingNode.value);
+ nodes.push(nodes.splice(nodes.indexOf(props.draggingNode.value), 1)[0]);
- if (receivingNode.value) {
- props.types.value[receivingNode.value.type].onDrop?.(
- receivingNode.value,
- draggingNode.value
+ if (props.receivingNode.value) {
+ props.types.value[props.receivingNode.value.type].onDrop?.(
+ props.receivingNode.value,
+ props.draggingNode.value
);
}
- dragging.value = null;
- } else if (!hasDragged.value) {
+ props.setDraggingNode.value(null);
+ } else if (!hasDragged.value && !mouseLeave) {
props.state.value.selectedNode = null;
props.state.value.selectedAction = null;
}
}
+function clickAction(node: BoardNode, actionId: string) {
+ if (props.state.value.selectedAction === actionId) {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ unref(props.selectedAction)!.onClick(unref(props.selectedNode)!);
+ } else {
+ props.state.value = { ...props.state.value, selectedAction: actionId };
+ }
+}
+
function zoom() {
const { x, y, scale } = stage.value.panZoomInstance.getTransform();
const { x: clientX, y: clientY } = lastMousePosition.value;
diff --git a/src/features/boards/BoardLink.vue b/src/features/boards/BoardLink.vue
index cb61914..5dacc66 100644
--- a/src/features/boards/BoardLink.vue
+++ b/src/features/boards/BoardLink.vue
@@ -1,7 +1,7 @@
+
+
diff --git a/src/features/boards/board.ts b/src/features/boards/board.ts
index 4763993..8a9026f 100644
--- a/src/features/boards/board.ts
+++ b/src/features/boards/board.ts
@@ -1,5 +1,5 @@
import BoardComponent from "features/boards/Board.vue";
-import type { OptionsFunc, Replace, StyleValue } from "features/feature";
+import type { GenericComponent, OptionsFunc, Replace, StyleValue } from "features/feature";
import {
Component,
findFeatures,
@@ -9,10 +9,10 @@ import {
Visibility
} from "features/feature";
import { globalBus } from "game/events";
-import type { Persistent, State } from "game/persistence";
+import { DefaultValue, deletePersistent, Persistent, State } from "game/persistence";
import { persistent } from "game/persistence";
import type { Unsubscribe } from "nanoevents";
-import { isFunction } from "util/common";
+import { Direction, isFunction } from "util/common";
import type {
Computable,
GetComputableType,
@@ -21,26 +21,35 @@ import type {
} from "util/computed";
import { processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies";
-import { computed, ref, Ref, unref } from "vue";
+import { computed, isRef, ref, Ref, unref } from "vue";
import panZoom from "vue-panzoom";
import type { Link } from "../links/links";
globalBus.on("setupVue", app => panZoom.install(app));
+/** A symbol used to identify {@link Board} features. */
export const BoardType = Symbol("Board");
-export type NodeComputable = Computable | ((node: BoardNode) => T);
+/**
+ * A type representing a computable value for a node on the board. Used for node types to return different values based on the given node and the state of the board.
+ */
+export type NodeComputable =
+ | Computable
+ | ((node: BoardNode, ...args: S) => T);
+/** Ways to display progress of an action with a duration. */
export enum ProgressDisplay {
Outline = "Outline",
Fill = "Fill"
}
+/** Node shapes. */
export enum Shape {
Circle = "Circle",
Diamond = "Triangle"
}
+/** An object representing a node on the board. */
export interface BoardNode {
id: number;
position: {
@@ -52,54 +61,90 @@ export interface BoardNode {
pinned?: boolean;
}
+/** An object representing a link between two nodes on the board. */
export interface BoardNodeLink extends Omit {
startNode: BoardNode;
endNode: BoardNode;
+ stroke: string;
+ strokeWidth: number;
pulsing?: boolean;
}
+/** An object representing a label for a node. */
export interface NodeLabel {
text: string;
color?: string;
pulsing?: boolean;
}
+/** The persistent data for a board. */
export type BoardData = {
nodes: BoardNode[];
selectedNode: number | null;
selectedAction: string | null;
};
+/**
+ * An object that configures a {@link NodeType}.
+ */
export interface NodeTypeOptions {
+ /** The title to display for the node. */
title: NodeComputable;
+ /** An optional label for the node. */
label?: NodeComputable;
+ /** The size of the node - diameter for circles, width and height for squares. */
size: NodeComputable;
+ /** CSS to apply to this node. */
+ style?: NodeComputable;
+ /** Dictionary of CSS classes to apply to this node. */
+ classes?: NodeComputable>;
+ /** Whether the node is draggable or not. */
draggable?: NodeComputable;
+ /** The shape of the node. */
shape: NodeComputable;
- canAccept?: boolean | Ref | ((node: BoardNode, otherNode: BoardNode) => boolean);
+ /** Whether the node can accept another node being dropped upon it. */
+ canAccept?: NodeComputable;
+ /** The progress value of the node, from 0 to 1. */
progress?: NodeComputable;
+ /** How the progress should be displayed on the node. */
progressDisplay?: NodeComputable;
+ /** The color of the progress indicator. */
progressColor?: NodeComputable;
+ /** The fill color of the node. */
fillColor?: NodeComputable;
+ /** The outline color of the node. */
outlineColor?: NodeComputable;
+ /** The color of the title text. */
titleColor?: NodeComputable;
+ /** The list of action options for the node. */
actions?: BoardNodeActionOptions[];
+ /** The arc between each action, in radians. */
actionDistance?: NodeComputable;
+ /** A function that is called when the node is clicked. */
onClick?: (node: BoardNode) => void;
+ /** A function that is called when a node is dropped onto this node. */
onDrop?: (node: BoardNode, otherNode: BoardNode) => void;
+ /** A function that is called for each node of this type every tick. */
update?: (node: BoardNode, diff: number) => void;
}
+/**
+ * The properties that are added onto a processed {@link NodeTypeOptions} to create a {@link NodeType}.
+ */
export interface BaseNodeType {
+ /** The nodes currently on the board of this type. */
nodes: Ref;
}
+/** An object that represents a type of node that can appear on a board. It will handle getting properties and callbacks for every node of that type. */
export type NodeType = Replace<
T & BaseNodeType,
{
title: GetComputableType;
label: GetComputableType;
size: GetComputableTypeWithDefault;
+ style: GetComputableType;
+ classes: GetComputableType;
draggable: GetComputableTypeWithDefault;
shape: GetComputableTypeWithDefault;
canAccept: GetComputableTypeWithDefault;
@@ -114,33 +159,50 @@ export type NodeType = Replace<
}
>;
+/** A type that matches any valid {@link NodeType} object. */
export type GenericNodeType = Replace<
NodeType,
{
size: NodeComputable;
draggable: NodeComputable;
shape: NodeComputable;
- canAccept: NodeComputable;
+ canAccept: NodeComputable;
progressDisplay: NodeComputable;
progressColor: NodeComputable;
actionDistance: NodeComputable;
}
>;
+/**
+ * An object that configures a {@link BoardNodeAction}.
+ */
export interface BoardNodeActionOptions {
+ /** A unique identifier for the action. */
id: string;
- visibility?: NodeComputable;
+ /** Whether this action should be visible. */
+ visibility?: NodeComputable;
+ /** The icon to display for the action. */
icon: NodeComputable;
+ /** The fill color of the action. */
fillColor?: NodeComputable;
- tooltip: NodeComputable;
+ /** The tooltip text to display for the action. */
+ tooltip: NodeComputable;
+ /** The confirmation label that appears under the action. */
+ confirmationLabel?: NodeComputable;
+ /** An array of board node links associated with the action. They appear when the action is focused. */
links?: NodeComputable;
- onClick: (node: BoardNode) => boolean | undefined;
+ /** A function that is called when the action is clicked. */
+ onClick: (node: BoardNode) => void;
}
+/**
+ * The properties that are added onto a processed {@link BoardNodeActionOptions} to create an {@link BoardNodeAction}.
+ */
export interface BaseBoardNodeAction {
links?: Ref;
}
+/** An object that represents an action that can be taken upon a node. */
export type BoardNodeAction = Replace<
T & BaseBoardNodeAction,
{
@@ -148,40 +210,73 @@ export type BoardNodeAction = Replace<
icon: GetComputableType;
fillColor: GetComputableType;
tooltip: GetComputableType;
+ confirmationLabel: GetComputableTypeWithDefault;
links: GetComputableType;
}
>;
+/** A type that matches any valid {@link BoardNodeAction} object. */
export type GenericBoardNodeAction = Replace<
BoardNodeAction,
{
- visibility: NodeComputable;
+ visibility: NodeComputable;
+ confirmationLabel: NodeComputable;
}
>;
+/**
+ * An object that configures a {@link Board}.
+ */
export interface BoardOptions {
- visibility?: Computable;
+ /** Whether this board should be visible. */
+ visibility?: Computable;
+ /** The height of the board. Defaults to 100% */
height?: Computable;
+ /** The width of the board. Defaults to 100% */
width?: Computable;
+ /** Dictionary of CSS classes to apply to this feature. */
classes?: Computable>;
+ /** CSS to apply to this feature. */
style?: Computable;
+ /** A function that returns an array of initial board nodes, without IDs. */
startNodes: () => Omit[];
+ /** A dictionary of node types that can appear on the board. */
types: Record;
+ /** The persistent state of the board. */
+ state?: Computable;
+ /** An array of board node links to display. */
+ links?: Computable;
}
+/**
+ * The properties that are added onto a processed {@link BoardOptions} to create a {@link Board}.
+ */
export interface BaseBoard {
+ /** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string;
- state: Persistent;
- links: Ref;
+ /** All the nodes currently on the board. */
nodes: Ref;
+ /** The currently selected node, if any. */
selectedNode: Ref;
+ /** The currently selected action, if any. */
selectedAction: Ref;
+ /** The currently being dragged node, if any. */
+ draggingNode: Ref;
+ /** If dragging a node, the node it's currently being hovered over, if any. */
+ receivingNode: Ref;
+ /** The current mouse position, if over the board. */
mousePosition: Ref<{ x: number; y: number } | null>;
+ /** Places a node in the nearest empty space in the given direction with the specified space around it. */
+ placeInAvailableSpace: (node: BoardNode, radius?: number, direction?: Direction) => void;
+ /** A symbol that helps identify features of the same type. */
type: typeof BoardType;
- [Component]: typeof BoardComponent;
+ /** The Vue component used to render this feature. */
+ [Component]: GenericComponent;
+ /** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record;
}
+/** An object that represents a feature that is a zoomable, pannable board with various nodes upon it. */
export type Board = Replace<
T & BaseBoard,
{
@@ -191,74 +286,133 @@ export type Board = Replace<
width: GetComputableType;
classes: GetComputableType;
style: GetComputableType;
+ state: GetComputableTypeWithDefault>;
+ links: GetComputableTypeWithDefault>;
}
>;
+/** A type that matches any valid {@link Board} object. */
export type GenericBoard = Replace<
Board,
{
- visibility: ProcessedComputable;
+ visibility: ProcessedComputable;
+ state: ProcessedComputable;
+ links: ProcessedComputable;
}
>;
+/**
+ * Lazily creates a board with the given options.
+ * @param optionsFunc Board options.
+ */
export function createBoard(
optionsFunc: OptionsFunc
): Board {
- return createLazyProxy(() => {
- const board = optionsFunc();
- board.id = getUniqueID("board-");
- board.type = BoardType;
- board[Component] = BoardComponent;
-
- board.state = persistent({
- nodes: board.startNodes().map((n, i) => {
- (n as BoardNode).id = i;
- return n as BoardNode;
- }),
+ const state = persistent(
+ {
+ nodes: [],
selectedNode: null,
selectedAction: null
+ },
+ false
+ );
+
+ return createLazyProxy(feature => {
+ const board = optionsFunc.call(feature, feature);
+ board.id = getUniqueID("board-");
+ board.type = BoardType;
+ board[Component] = BoardComponent as GenericComponent;
+
+ if (board.state) {
+ deletePersistent(state);
+ processComputable(board as T, "state");
+ } else {
+ state[DefaultValue] = {
+ nodes: board.startNodes().map((n, i) => {
+ (n as BoardNode).id = i;
+ return n as BoardNode;
+ }),
+ selectedNode: null,
+ selectedAction: null
+ };
+ board.state = state;
+ }
+
+ board.nodes = computed(() => unref(processedBoard.state).nodes);
+ board.selectedNode = computed({
+ get() {
+ return (
+ processedBoard.nodes.value.find(
+ node => node.id === unref(processedBoard.state).selectedNode
+ ) || null
+ );
+ },
+ set(node) {
+ if (isRef(processedBoard.state)) {
+ processedBoard.state.value = {
+ ...processedBoard.state.value,
+ selectedNode: node?.id ?? null
+ };
+ } else {
+ processedBoard.state.selectedNode = node?.id ?? null;
+ }
+ }
});
- board.nodes = computed(() => processedBoard.state.value.nodes);
- board.selectedNode = computed(
- () =>
- processedBoard.nodes.value.find(
- node => node.id === processedBoard.state.value.selectedNode
- ) || null
- );
- board.selectedAction = computed(() => {
- const selectedNode = processedBoard.selectedNode.value;
- if (selectedNode == null) {
- return null;
+ board.selectedAction = computed({
+ get() {
+ const selectedNode = processedBoard.selectedNode.value;
+ if (selectedNode == null) {
+ return null;
+ }
+ const type = processedBoard.types[selectedNode.type];
+ if (type.actions == null) {
+ return null;
+ }
+ return (
+ type.actions.find(
+ action => action.id === unref(processedBoard.state).selectedAction
+ ) || null
+ );
+ },
+ set(action) {
+ if (isRef(processedBoard.state)) {
+ processedBoard.state.value = {
+ ...processedBoard.state.value,
+ selectedAction: action?.id ?? null
+ };
+ } else {
+ processedBoard.state.selectedAction = action?.id ?? null;
+ }
}
- const type = processedBoard.types[selectedNode.type];
- if (type.actions == null) {
- return null;
- }
- return (
- type.actions.find(
- action => action.id === processedBoard.state.value.selectedAction
- ) || null
- );
});
board.mousePosition = ref(null);
- board.links = computed(() => {
- if (processedBoard.selectedAction.value == null) {
- return null;
- }
- if (processedBoard.selectedAction.value.links && processedBoard.selectedNode.value) {
- return getNodeProperty(
- processedBoard.selectedAction.value.links,
+ if (board.links) {
+ processComputable(board as T, "links");
+ } else {
+ board.links = computed(() => {
+ if (processedBoard.selectedAction.value == null) {
+ return null;
+ }
+ if (
+ processedBoard.selectedAction.value.links &&
processedBoard.selectedNode.value
- );
- }
- return null;
- });
+ ) {
+ return getNodeProperty(
+ processedBoard.selectedAction.value.links,
+ processedBoard.selectedNode.value
+ );
+ }
+ return null;
+ });
+ }
+ board.draggingNode = ref(null);
+ board.receivingNode = ref(null);
processComputable(board as T, "visibility");
setDefault(board, "visibility", Visibility.Visible);
processComputable(board as T, "width");
setDefault(board, "width", "100%");
processComputable(board as T, "height");
- setDefault(board, "height", "400px");
+ setDefault(board, "height", "100%");
processComputable(board as T, "classes");
processComputable(board as T, "style");
@@ -269,6 +423,8 @@ export function createBoard(
processComputable(nodeType as NodeTypeOptions, "label");
processComputable(nodeType as NodeTypeOptions, "size");
setDefault(nodeType, "size", 50);
+ processComputable(nodeType as NodeTypeOptions, "style");
+ processComputable(nodeType as NodeTypeOptions, "classes");
processComputable(nodeType as NodeTypeOptions, "draggable");
setDefault(nodeType, "draggable", false);
processComputable(nodeType as NodeTypeOptions, "shape");
@@ -286,10 +442,10 @@ export function createBoard(
processComputable(nodeType as NodeTypeOptions, "actionDistance");
setDefault(nodeType, "actionDistance", Math.PI / 6);
nodeType.nodes = computed(() =>
- processedBoard.state.value.nodes.filter(node => node.type === type)
+ unref(processedBoard.state).nodes.filter(node => node.type === type)
);
setDefault(nodeType, "onClick", function (node: BoardNode) {
- processedBoard.state.value.selectedNode = node.id;
+ unref(processedBoard.state).selectedNode = node.id;
});
if (nodeType.actions) {
@@ -299,11 +455,92 @@ export function createBoard(
processComputable(action, "icon");
processComputable(action, "fillColor");
processComputable(action, "tooltip");
+ processComputable(action, "confirmationLabel");
+ setDefault(action, "confirmationLabel", { text: "Tap again to confirm" });
processComputable(action, "links");
}
}
}
+ function setDraggingNode(node: BoardNode | null) {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ board.draggingNode!.value = node;
+ }
+ function setReceivingNode(node: BoardNode | null) {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ board.receivingNode!.value = node;
+ }
+
+ board.placeInAvailableSpace = function (
+ node: BoardNode,
+ radius = 100,
+ direction = Direction.Right
+ ) {
+ const nodes = processedBoard.nodes.value
+ .slice()
+ .filter(n => {
+ // Exclude self
+ if (n === node) {
+ return false;
+ }
+
+ // Exclude nodes that aren't within the corridor we'll be moving within
+ if (
+ (direction === Direction.Down || direction === Direction.Up) &&
+ Math.abs(n.position.x - node.position.x) > radius
+ ) {
+ return false;
+ }
+ if (
+ (direction === Direction.Left || direction === Direction.Right) &&
+ Math.abs(n.position.y - node.position.y) > radius
+ ) {
+ return false;
+ }
+
+ // Exclude nodes in the wrong direction
+ return !(
+ (direction === Direction.Right &&
+ n.position.x < node.position.x - radius) ||
+ (direction === Direction.Left && n.position.x > node.position.x + radius) ||
+ (direction === Direction.Up && n.position.y > node.position.y + radius) ||
+ (direction === Direction.Down && n.position.y < node.position.y - radius)
+ );
+ })
+ .sort(
+ direction === Direction.Right
+ ? (a, b) => a.position.x - b.position.x
+ : direction === Direction.Left
+ ? (a, b) => b.position.x - a.position.x
+ : direction === Direction.Up
+ ? (a, b) => b.position.y - a.position.y
+ : (a, b) => a.position.y - b.position.y
+ );
+ for (let i = 0; i < nodes.length; i++) {
+ const nodeToCheck = nodes[i];
+ const distance =
+ direction === Direction.Right || direction === Direction.Left
+ ? Math.abs(node.position.x - nodeToCheck.position.x)
+ : Math.abs(node.position.y - nodeToCheck.position.y);
+
+ // If we're too close to this node, move further
+ if (distance < radius) {
+ if (direction === Direction.Right) {
+ node.position.x = nodeToCheck.position.x + radius;
+ } else if (direction === Direction.Left) {
+ node.position.x = nodeToCheck.position.x - radius;
+ } else if (direction === Direction.Up) {
+ node.position.y = nodeToCheck.position.y - radius;
+ } else if (direction === Direction.Down) {
+ node.position.y = nodeToCheck.position.y + radius;
+ }
+ } else if (i > 0 && distance > radius) {
+ // If we're further from this node than the radius, then the nodes are past us and we can early exit
+ break;
+ }
+ }
+ };
+
board[GatherProps] = function (this: GenericBoard) {
const {
nodes,
@@ -317,7 +554,9 @@ export function createBoard(
links,
selectedAction,
selectedNode,
- mousePosition
+ mousePosition,
+ draggingNode,
+ receivingNode
} = this;
return {
nodes,
@@ -331,7 +570,11 @@ export function createBoard(
links,
selectedAction,
selectedNode,
- mousePosition
+ mousePosition,
+ draggingNode,
+ receivingNode,
+ setDraggingNode,
+ setReceivingNode
};
};
@@ -341,10 +584,25 @@ export function createBoard(
});
}
-export function getNodeProperty(property: NodeComputable, node: BoardNode): T {
- return isFunction>(property) ? property(node) : unref(property);
+/**
+ * Gets the value of a property for a specified node.
+ * @param property The property to find the value of
+ * @param node The node to get the property of
+ */
+export function getNodeProperty(
+ property: NodeComputable,
+ node: BoardNode,
+ ...args: S
+): T {
+ return isFunction>(property)
+ ? property(node, ...args)
+ : unref(property);
}
+/**
+ * Utility to get an ID for a node that is guaranteed unique.
+ * @param board The board feature to generate an ID for
+ */
export function getUniqueNodeID(board: GenericBoard): number {
let id = 0;
board.nodes.value.forEach(node => {
diff --git a/src/features/buyable.tsx b/src/features/buyable.tsx
deleted file mode 100644
index c2f9972..0000000
--- a/src/features/buyable.tsx
+++ /dev/null
@@ -1,243 +0,0 @@
-import ClickableComponent from "features/clickables/Clickable.vue";
-import type { CoercableComponent, OptionsFunc, Replace, StyleValue } from "features/feature";
-import { Component, GatherProps, getUniqueID, jsx, setDefault, Visibility } from "features/feature";
-import type { Resource } from "features/resources/resource";
-import type { Persistent } from "game/persistence";
-import { persistent } from "game/persistence";
-import type { DecimalSource } from "util/bignum";
-import Decimal, { format, formatWhole } from "util/bignum";
-import type {
- Computable,
- GetComputableType,
- GetComputableTypeWithDefault,
- ProcessedComputable
-} from "util/computed";
-import { processComputable } from "util/computed";
-import { createLazyProxy } from "util/proxies";
-import { coerceComponent, isCoercableComponent } from "util/vue";
-import type { Ref } from "vue";
-import { computed, unref } from "vue";
-
-export const BuyableType = Symbol("Buyable");
-
-export type BuyableDisplay =
- | CoercableComponent
- | {
- title?: CoercableComponent;
- description?: CoercableComponent;
- effectDisplay?: CoercableComponent;
- showAmount?: boolean;
- };
-
-export interface BuyableOptions {
- visibility?: Computable;
- cost?: Computable;
- resource?: Resource;
- canPurchase?: Computable;
- purchaseLimit?: Computable;
- classes?: Computable>;
- style?: Computable;
- mark?: Computable;
- small?: Computable;
- display?: Computable;
- onPurchase?: (cost: DecimalSource | undefined) => void;
-}
-
-export interface BaseBuyable {
- id: string;
- amount: Persistent;
- maxed: Ref;
- canAfford: Ref;
- canClick: ProcessedComputable