Compare commits

...

36 commits

Author SHA1 Message Date
bd165da264 Update deps some more 2024-10-18 09:11:58 -05:00
c93418bfec Fix merge 2024-10-18 08:50:05 -05:00
88abd53faf Update eslint and prettier 2024-10-18 07:04:33 -05:00
80d7a743bc Update TS 2024-10-18 07:04:29 -05:00
2dab35f7cf Update vue 2024-10-18 07:04:26 -05:00
dc8d0ecc95 Update pixi (while staying at v6 until particle-emitter supports v8) 2024-10-18 07:04:20 -05:00
707aacc383 Update vitest 2024-10-18 07:04:19 -05:00
1e13da1129 Revert "Add link to docs in setupDraggableNode docstring"
This reverts commit 1c7824b550.
2024-10-18 07:02:34 -05:00
643bfccada Add link to docs in setupDraggableNode docstring 2024-10-18 07:02:34 -05:00
f9c59f7636 Revert changes in /data. Check the board-example branch for them instead 2024-10-18 07:02:34 -05:00
99227a2cb2 Move common board node CSS to class 2024-10-18 07:02:34 -05:00
e0da9588d2 Move board to src/game 2024-10-18 07:02:34 -05:00
46d0a9aa2e Add some tests for boards 2024-10-18 07:02:33 -05:00
c6035f9077 Document boards 2024-10-18 07:02:33 -05:00
8745304631 Fix upgrade purchasing on drag 2024-10-18 07:02:33 -05:00
5b33a0fceb Perf optimization 2024-10-18 07:02:33 -05:00
6a17bbc29c Use z-index to avoid changing render order 2024-10-18 07:02:33 -05:00
a75c8d81f8 Add cnodes 2024-10-18 07:02:32 -05:00
c64ac82a25 Add support for rendering VueFeatures in boards 2024-10-18 07:02:32 -05:00
1cbe97251c WIP on rewriting board 2024-10-18 07:02:32 -05:00
6ba25f9abd Removed dynamic imports 2024-10-18 07:02:32 -05:00
673f7790c7 Switch from CJS to ESM 2024-10-18 07:00:56 -05:00
ae45f9bc2f Bump some minor dependencies 2024-10-18 07:00:55 -05:00
8a9e106157 Update vite dependencies 2024-10-18 07:00:46 -05:00
90300ce848 Update fontsource dependencies 2024-10-18 07:00:08 -05:00
2b861c3fcf Fix Links.vue checking startNode twice instead of both nodes
Thanks escapee for reporting the issue!
2024-10-17 16:50:55 +00:00
9debfe6fb4 Unref hotkey descriptions 2024-10-17 16:28:04 +00:00
9f25d7f58f Fix more modal paths 2024-10-17 16:17:32 +00:00
239ae7c94a Update saves bank path 2024-10-17 16:17:32 +00:00
2d28be84a9 Add modal to take a mental health break 2024-10-17 16:17:32 +00:00
c6389317d0 Version Bump 2024-03-29 00:49:58 -05:00
b98f6db1c4 Move printFormula to Formula.stringify and add tests for it
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
Run Tests / test (push) Successful in 2m2s
2024-03-29 00:24:31 -05:00
563eaa7539 Lint 2024-03-29 00:19:57 -05:00
b88fa68874 Fix extends syntax 2024-03-28 23:40:47 -05:00
90d0307cf0 Add hotkey tests, make them pass
Includes updating vitest and supporting hotkeys with both ctrl+shift
2024-03-28 23:40:46 -05:00
dfb14acc6e Allow null and undefined values in persistent refs 2024-03-29 04:39:56 +00:00
72 changed files with 4803 additions and 5321 deletions

1
.eslintignore Normal file
View file

@ -0,0 +1 @@
.eslintrc.cjs

View file

@ -5,15 +5,22 @@ module.exports = {
env: {
node: true
},
extends: [
"plugin:vue/vue3-essential",
"@vue/eslint-config-typescript/recommended",
"@vue/eslint-config-prettier"
parser: '@typescript-eslint/parser',
plugins: ["@typescript-eslint"],
overrides: [
{
files: ['*.ts', '*.tsx'],
extends: [
"plugin:vue/vue3-essential",
"@vue/eslint-config-typescript/recommended",
"@vue/eslint-config-prettier"
],
parserOptions: {
ecmaVersion: 2020,
project: "./tsconfig.json"
},
}
],
parserOptions: {
ecmaVersion: 2020,
project: "tsconfig.json"
},
ignorePatterns: ["src/lib"],
rules: {
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
@ -27,6 +34,13 @@ module.exports = {
allowNullableObject: true,
allowNullableBoolean: true
}
],
"eqeqeq": [
"error",
"always",
{
"null": "never"
}
]
},
globals: {

View file

@ -6,6 +6,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.6.2] - 2024-04-01
### Added
- Export save button in error boundaries
- isRendered utility function
- Automatic galaxy.click cloud saves support
- Support for null and undefined in persistent refs
### Changes
- round, floor, ceil, trunc, and add now invert as no-ops
- "The Paper Pilot Community" renamed to "Profectus & Friends"
- Updated CI etc. to work with Forgejo
- Improved modifier typing
- Rename `printFormula` to `Formula.stringify`
### Fixed
- Hotkeys not working correctly with most combinations of modifiers
- Reset button using `currentAt` when not gaining
- Formulas not using modifiers that are disabled initially
- branchedResetPropagation logic being incorrect
- Fixed default elementsd in the main layer not updating Context when being added or removed
- Board links props not working in camelCase
- Board links absorbing pointer events
- Thrown errors not appearing in console
- Disabled elements would eat mouse events
- Fixed cost requirement without formula counting as being able to afford infinite purchases rather than just one
- Pinnable tooltips causing innocuous console error
- Bars with direction as "Left" wouldn't appear correctly
### Documentation
- Clarified expected progress values for board nodes
- Added CONTRIBUTING.md and enforce eslint on all PRs
### Tests
- Update formula test cases
- Tree reset propagation
Contributors: thepaperpilot, escapee, nif
## [0.6.1] - 2023-05-17
### Added
- Error boundaries around each layer, and errors now display on the page when in development

View file

@ -8,6 +8,7 @@
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="alternate icon" type="image/png" sizes="48x48" href="/favicon.ico">
<link rel="mask-icon" href="/favicon.svg" color="#2E3440">
<meta name="theme-color" content="#2E3440">
<title>Profectus</title>

6708
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,8 @@
{
"name": "profectus",
"version": "0.6.1",
"version": "0.6.2",
"private": true,
"type": "module",
"scripts": {
"start": "vite",
"dev": "vite",
@ -14,47 +15,47 @@
"lint:fix": "eslint --fix --max-warnings 0 src"
},
"dependencies": {
"@fontsource/material-icons": "^4.5.4",
"@fontsource/roboto-mono": "^4.5.8",
"@pixi/app": "~6.3.2",
"@pixi/constants": "~6.3.2",
"@pixi/core": "~6.3.2",
"@pixi/display": "~6.3.2",
"@pixi/math": "~6.3.2",
"@fontsource/material-icons": "^5.1.0",
"@fontsource/roboto-mono": "^5.1.0",
"@pixi/app": "^6.5.10",
"@pixi/constants": "~6.5.10",
"@pixi/core": "^6.5.10",
"@pixi/display": "~6.5.10",
"@pixi/math": "~6.5.10",
"@pixi/particle-emitter": "^5.0.7",
"@pixi/sprite": "~6.3.2",
"@pixi/ticker": "~6.3.2",
"@vitejs/plugin-vue": "^2.3.3",
"@vitejs/plugin-vue-jsx": "^1.3.10",
"@pixi/sprite": "~6.5.10",
"@pixi/ticker": "~6.5.10",
"@vitejs/plugin-vue": "^5.1.4",
"@vitejs/plugin-vue-jsx": "^4.0.1",
"is-plain-object": "^5.0.0",
"lz-string": "^1.4.4",
"nanoevents": "^6.0.2",
"lz-string": "^1.5.0",
"nanoevents": "^9.0.0",
"unofficial-galaxy-sdk": "git+https://code.incremental.social/thepaperpilot/unofficial-galaxy-sdk.git#1.0.1",
"vite": "^2.9.12",
"vite-plugin-pwa": "^0.12.0",
"vite-tsconfig-paths": "^3.5.0",
"vue": "^3.2.26",
"vue-next-select": "^2.10.2",
"vite": "^5.1.8",
"vite-plugin-pwa": "^0.20.5",
"vite-tsconfig-paths": "^4.3.0",
"vue": "^3.5.12",
"vue-next-select": "^2.10.5",
"vue-panzoom": "https://github.com/thepaperpilot/vue-panzoom.git",
"vue-textarea-autosize": "^1.1.1",
"vue-toastification": "^2.0.0-rc.1",
"vue-transition-expand": "^0.1.0",
"vue-toastification": "^2.0.0-rc.5",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@ivanv/vue-collapse-transition": "^1.0.2",
"@rushstack/eslint-patch": "^1.1.0",
"@types/lz-string": "^1.3.34",
"@vue/eslint-config-prettier": "^7.0.0",
"@vue/eslint-config-typescript": "^10.0.0",
"eslint": "^8.6.0",
"jsdom": "^20.0.0",
"prettier": "^2.5.1",
"typescript": "^5.0.2",
"vitest": "^0.29.3",
"vue-tsc": "^0.38.1"
"@rushstack/eslint-patch": "^1.7.2",
"@types/lz-string": "^1.5.0",
"@typescript-eslint/parser": "^7.2.0",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^13.0.0",
"eslint": "^8.57.0",
"jsdom": "^24.0.0",
"prettier": "^3.2.5",
"typescript": "^5.4.2",
"vitest": "^1.4.0",
"vue-tsc": "^2.0.6"
},
"engines": {
"node": "16.x"
"node": "21.x"
}
}

View file

@ -6,6 +6,7 @@
<Nav v-if="useHeader" />
<Game />
<TPS v-if="unref(showTPS)" />
<AddictionWarning />
<GameOverScreen />
<NaNScreen />
<CloudSaveResolver />
@ -17,15 +18,16 @@
<script setup lang="tsx">
import "@fontsource/roboto-mono";
import Error from "components/Error.vue";
import CloudSaveResolver from "components/saves/CloudSaveResolver.vue";
import AddictionWarning from "components/modals/AddictionWarning.vue";
import CloudSaveResolver from "components/modals/CloudSaveResolver.vue";
import GameOverScreen from "components/modals/GameOverScreen.vue";
import NaNScreen from "components/modals/NaNScreen.vue";
import { jsx } from "features/feature";
import state from "game/state";
import { coerceComponent, render } from "util/vue";
import type { CSSProperties } from "vue";
import { computed, toRef, unref } from "vue";
import Game from "./components/Game.vue";
import GameOverScreen from "./components/GameOverScreen.vue";
import NaNScreen from "./components/NaNScreen.vue";
import Nav from "./components/Nav.vue";
import TPS from "./components/TPS.vue";
import projInfo from "./data/projInfo.json";

View file

@ -103,9 +103,9 @@ import { Direction } from "util/common";
import { galaxy, syncedSaves } from "util/galaxy";
import type { ComponentPublicInstance } from "vue";
import { computed, ref } from "vue";
import Info from "./Info.vue";
import Options from "./Options.vue";
import SavesManager from "./saves/SavesManager.vue";
import Info from "./modals/Info.vue";
import Options from "./modals/Options.vue";
import SavesManager from "./modals/SavesManager.vue";
const info = ref<ComponentPublicInstance<typeof Info> | null>(null);
const savesManager = ref<ComponentPublicInstance<typeof SavesManager> | null>(null);

View file

@ -0,0 +1,83 @@
<template>
<Modal v-model="isOpen" v-bind="$attrs">
<template v-slot:header>
<div class="vga-modal-header">
<h2>Kindly consider taking a break.</h2>
</div>
</template>
<template v-slot:body>
<p>
You've been actively enjoying this game for awhile recently - and it's great that
you've been having a good time! That said, there are dangers to games like these that you should be aware of:
</p>
<p>
While incremental games can be fun and even healthy in certain contexts, they can
exacerbate video game addiction even more than other genres. If you feel like
playing incremental games is taking priority over other things in your life, or
manipulating your sleep schedule, it may be prudent to seek help.
</p>
<h4>Resources:</h4>
<p>
<span>
<a style="display: inline" href="https://www.samhsa.gov/" target="_blank">
SAMHSA
</a>
(<a style="display: inline" href="tel:1-800-662-4357">1-800-662-HELP</a>)
</span>
<br />
<a href="https://www.reddit.com/r/StopGaming/">r/StopGaming</a>
</p>
</template>
<template v-slot:footer>
<div class="vga-footer">
<button @click="neverShow" class="button">Never show this again</button>
<button @click="isOpen = false" class="button">Close</button>
</div>
</template>
</Modal>
<SavesManager ref="savesManager" />
</template>
<script setup lang="ts">
import projInfo from "data/projInfo.json";
import settings from "game/settings";
import state from "game/state";
import { ref, watchEffect } from "vue";
import Modal from "./Modal.vue";
const isOpen = ref(false);
watchEffect(() => {
if (
projInfo.disableHealthWarning === false &&
settings.showHealthWarning &&
state.mouseActivity.filter(i => i).length > 6
) {
isOpen.value = true;
}
});
function neverShow() {
settings.showHealthWarning = false;
isOpen.value = false;
}
</script>
<style scoped>
.vga-modal-header {
padding-top: 10px;
margin-left: 10px;
}
.vga-footer {
display: flex;
justify-content: flex-end;
}
.vga-footer button {
margin: 0 10px;
}
p {
margin-bottom: 10px;
}
</style>

View file

@ -74,13 +74,13 @@
</template>
<script setup lang="ts">
import Modal from "components/Modal.vue";
import { stringifySave } from "game/player";
import settings from "game/settings";
import LZString from "lz-string";
import { conflictingSaves, galaxy } from "util/galaxy";
import { getUniqueID, save, setupInitialStore } from "util/save";
import { ComponentPublicInstance, computed, ref, unref, watch } from "vue";
import Modal from "./Modal.vue";
import Save from "./Save.vue";
const isOpen = ref(false);

View file

@ -37,14 +37,14 @@
</template>
<script setup lang="ts">
import Modal from "components/Modal.vue";
import { hasWon } from "data/projEntry";
import projInfo from "data/projInfo.json";
import player from "game/player";
import { formatTime } from "util/bignum";
import { loadSave, newSave } from "util/save";
import { computed, toRef } from "vue";
import Toggle from "./fields/Toggle.vue";
import Toggle from "../fields/Toggle.vue";
import Modal from "./Modal.vue";
const { title, logo, discordName, discordLink, versionNumber, versionTitle } = projInfo;

View file

@ -60,7 +60,6 @@
</template>
<script setup lang="tsx">
import Modal from "components/Modal.vue";
import type Changelog from "data/Changelog.vue";
import projInfo from "data/projInfo.json";
import { jsx } from "features/feature";
@ -69,6 +68,7 @@ import { infoComponents } from "game/settings";
import { formatTime } from "util/bignum";
import { coerceComponent, render } from "util/vue";
import { computed, ref, toRefs, unref } from "vue";
import Modal from "./Modal.vue";
const { title, logo, author, discordName, discordLink, versionNumber, versionTitle } = projInfo;

View file

@ -42,7 +42,7 @@
<script setup lang="ts">
import type { FeatureNode } from "game/layers";
import { computed, ref, toRefs, unref } from "vue";
import Context from "./Context.vue";
import Context from "../Context.vue";
const _props = defineProps<{
modelValue: boolean;

View file

@ -46,7 +46,6 @@
</template>
<script setup lang="ts">
import Modal from "components/Modal.vue";
import projInfo from "data/projInfo.json";
import player from "game/player";
import state from "game/state";
@ -54,8 +53,9 @@ import type { DecimalSource } from "util/bignum";
import Decimal, { format } from "util/bignum";
import type { ComponentPublicInstance } from "vue";
import { computed, ref, toRef, watch } from "vue";
import Toggle from "./fields/Toggle.vue";
import SavesManager from "./saves/SavesManager.vue";
import Toggle from "../fields/Toggle.vue";
import Modal from "./Modal.vue";
import SavesManager from "./SavesManager.vue";
const { discordName, discordLink } = projInfo;
const autosave = ref(true);

View file

@ -14,6 +14,7 @@
<Toggle :title="unthrottledTitle" v-model="unthrottled" />
<Toggle v-if="projInfo.enablePausing" :title="isPausedTitle" v-model="isPaused" />
<Toggle :title="offlineProdTitle" v-model="offlineProd" />
<Toggle :title="showHealthWarningTitle" v-model="showHealthWarning" v-if="!projInfo.disableHealthWarning" />
<Toggle :title="autosaveTitle" v-model="autosave" />
<FeedbackButton v-if="!autosave" class="button save-button" @click="save()">Manually save</FeedbackButton>
</div>
@ -28,20 +29,20 @@
</template>
<script setup lang="tsx">
import Modal from "components/Modal.vue";
import projInfo from "data/projInfo.json";
import { save } from "util/save";
import rawThemes from "data/themes";
import { jsx } from "features/feature";
import Tooltip from "features/tooltips/Tooltip.vue";
import player from "game/player";
import settings, { settingFields } from "game/settings";
import { camelToTitle, Direction } from "util/common";
import { save } from "util/save";
import { coerceComponent, render } from "util/vue";
import { computed, ref, toRefs } from "vue";
import Select from "./fields/Select.vue";
import Toggle from "./fields/Toggle.vue";
import FeedbackButton from "./fields/FeedbackButton.vue";
import FeedbackButton from "../fields/FeedbackButton.vue";
import Select from "../fields/Select.vue";
import Toggle from "../fields/Toggle.vue";
import Modal from "./Modal.vue";
const isOpen = ref(false);
const currentTab = ref("behaviour");
@ -72,7 +73,7 @@ const settingFieldsComponent = computed(() => {
return coerceComponent(jsx(() => (<>{settingFields.map(render)}</>)));
});
const { showTPS, theme, unthrottled, alignUnits } = toRefs(settings);
const { showTPS, theme, unthrottled, alignUnits, showHealthWarning } = toRefs(settings);
const { autosave, offlineProd } = toRefs(player);
const isPaused = computed({
get() {
@ -91,10 +92,16 @@ const unthrottledTitle = jsx(() => (
));
const offlineProdTitle = jsx(() => (
<span class="option-title">
Offline Production<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip>
Offline production<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip>
<desc>Simulate production that occurs while the game is closed.</desc>
</span>
));
const showHealthWarningTitle = jsx(() => (
<span class="option-title">
Show videogame addiction warning
<desc>Show a helpful warning after playing for a long time about video game addiction and encouraging you to take a break.</desc>
</span>
));
const autosaveTitle = jsx(() => (
<span class="option-title">
Autosave<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip>

View file

@ -60,12 +60,12 @@
</template>
<script setup lang="ts">
import Modal from "components/Modal.vue";
import projInfo from "data/projInfo.json";
import type { Player } from "game/player";
import player, { stringifySave } from "game/player";
import settings from "game/settings";
import LZString from "lz-string";
import { galaxy, syncedSaves } from "util/galaxy";
import {
clearCachedSave,
clearCachedSaves,
@ -81,8 +81,8 @@ import { computed, nextTick, ref, watch } from "vue";
import Draggable from "vuedraggable";
import Select from "../fields/Select.vue";
import Text from "../fields/Text.vue";
import Modal from "./Modal.vue";
import Save from "./Save.vue";
import { galaxy, syncedSaves } from "util/galaxy";
export type LoadablePlayerData = Omit<Partial<Player>, "id"> & { id: string; error?: unknown };
@ -130,7 +130,7 @@ watch(saveToImport, importedSave => {
}
});
let bankContext = import.meta.globEager("./../../saves/*.txt", { as: "raw" });
let bankContext = import.meta.glob("./../../../saves/*.txt", { query: "?raw", eager: true });
let bank = ref(
Object.keys(bankContext).reduce((acc: Array<{ label: string; value: string }>, curr) => {
acc.push({

View file

@ -19,7 +19,7 @@
</template>
<script setup lang="ts">
import Modal from "components/Modal.vue";
import Modal from "components/modals/Modal.vue";
import { ref } from "vue";
const isOpen = ref(false);

View file

@ -7,3 +7,12 @@
.modifier-toggle.collapsed {
transform: translate(-5px, -5px) rotate(-90deg);
}
.node-text {
text-anchor: middle;
dominant-baseline: middle;
font-family: monospace;
font-size: 200%;
pointer-events: none;
filter: drop-shadow(3px 3px 2px var(--tooltip-background));
}

View file

@ -27,7 +27,8 @@ import type {
import { convertComputable, processComputable } from "util/computed";
import { getFirstFeature, renderColJSX, renderJSX } from "util/vue";
import type { ComputedRef, Ref } from "vue";
import { computed, unref } from "vue";
import { computed, ref, unref } from "vue";
import { JSX } from "vue/jsx-runtime";
import "./common.css";
/** An object that configures a {@link ResetButton} */
@ -128,7 +129,7 @@ export function createResetButton<T extends ClickableOptions & ResetButtonOption
)}
</b>{" "}
{resetButton.conversion.gainResource.displayName}
{unref(resetButton.showNextAt) != null ? (
{unref(resetButton.showNextAt as ProcessedComputable<boolean>) != null ? (
<div>
<br />
{unref(resetButton.conversion.buyMax) ? "Next:" : "Req:"}{" "}
@ -505,3 +506,21 @@ export function isRendered(layer: BaseLayer, idOrFeature: string | { id: string
const id = typeof idOrFeature === "string" ? idOrFeature : idOrFeature.id;
return computed(() => id in layer.nodes.value);
}
/**
* Utility function for setting up a system where one of many things can be selected.
* It's recommended to use an ID or index rather than the object itself, so that you can wrap the ref in a persistent without breaking anything.
* @returns The ref containing the selection, as well as a select and deselect function
*/
export function setupSelectable<T>() {
const selected = ref<T>();
return {
select: function (node: T) {
selected.value = node;
},
deselect: function () {
selected.value = undefined;
},
selected
};
}

View file

@ -88,6 +88,10 @@
"type": "string",
"enum": ["base64", "lz", "plain"],
"description": "The encoding to use when exporting to the clipboard. Plain-text is fast to generate but is easiest for the player to manipulate and cheat with. Base 64 is slightly slower and the string will be longer but will offer a small barrier to people trying to cheat. LZ-String is the slowest method, but produces the smallest strings and still offers a small barrier to those trying to cheat. Some sharing platforms like pastebin may automatically delete base64 encoded text, and some sites might not support all the characters used in lz-string exports."
},
"disableHealthWarning": {
"type": "boolean",
"description": "Whether or not to disable the health warning that appears to the player after excessive playtime (activity during 6 of the last 8 hours). If left enabled, the player will still be able to individually turn off the health warning in settings or by clicking \"Do not show again\" in the warning itself."
}
}
}

View file

@ -22,5 +22,6 @@
"maxTickLength": 3600,
"offlineLimit": 1,
"enablePausing": true,
"exportEncoding": "base64"
"exportEncoding": "base64",
"disableHealthWarning": false
}

View file

@ -1,5 +1,4 @@
import { computed } from "@vue/reactivity";
import { isArray } from "@vue/shared";
import { computed } from "vue";
import Select from "components/fields/Select.vue";
import AchievementComponent from "features/achievements/Achievement.vue";
import { GenericDecorator } from "features/decorators/common";
@ -208,7 +207,7 @@ export function createAchievement<T extends AchievementOptions>(
unref(achievement.earned) &&
!(
display != null &&
typeof display == "object" &&
typeof display === "object" &&
"optionsDisplay" in (display as Record<string, unknown>)
)
) {
@ -275,7 +274,7 @@ export function createAchievement<T extends AchievementOptions>(
const requirements = [
createVisibilityRequirement(genericAchievement),
createBooleanRequirement(() => !genericAchievement.earned.value),
...(isArray(achievement.requirements)
...(Array.isArray(achievement.requirements)
? achievement.requirements
: [achievement.requirements])
];
@ -306,18 +305,20 @@ const msDisplayOptions = Object.values(AchievementDisplay).map(option => ({
value: option
}));
registerSettingField(
jsx(() => (
<Select
title={jsx(() => (
<span class="option-title">
Show achievements
<desc>Select which achievements to display based on criterias.</desc>
</span>
))}
options={msDisplayOptions}
onUpdate:modelValue={value => (settings.msDisplay = value as AchievementDisplay)}
modelValue={settings.msDisplay}
/>
))
globalBus.on("setupVue", () =>
registerSettingField(
jsx(() => (
<Select
title={jsx(() => (
<span class="option-title">
Show achievements
<desc>Select which achievements to display based on criterias.</desc>
</span>
))}
options={msDisplayOptions}
onUpdate:modelValue={value => (settings.msDisplay = value as AchievementDisplay)}
modelValue={settings.msDisplay}
/>
))
)
);

View file

@ -1,4 +1,3 @@
import { isArray } from "@vue/shared";
import ClickableComponent from "features/clickables/Clickable.vue";
import {
Component,
@ -157,7 +156,7 @@ export function createAction<T extends ActionOptions>(
}
];
const originalStyle = unref(style);
if (isArray(originalStyle)) {
if (Array.isArray(originalStyle)) {
currStyle.push(...originalStyle);
} else if (originalStyle != null) {
currStyle.push(originalStyle);
@ -219,7 +218,7 @@ export function createAction<T extends ActionOptions>(
const onClick = action.onClick.bind(action);
action.onClick = function () {
if (unref(action.canClick) === false) {
if (unref(action.canClick as ProcessedComputable<boolean>) === false) {
return;
}
const amount = Decimal.div(progress.value, unref(genericAction.duration));

View file

@ -1,294 +0,0 @@
<template>
<panZoom
v-if="isVisible(visibility)"
:style="[
{
width,
height
},
style
]"
:class="classes"
selector=".g1"
:options="{ initialZoom: 1, minZoom: 0.1, maxZoom: 10, zoomDoubleClickSpeed: 1 }"
ref="stage"
@init="onInit"
@mousemove="drag"
@touchmove="drag"
@mousedown="(e: MouseEvent) => mouseDown(e)"
@touchstart="(e: TouchEvent) => mouseDown(e)"
@mouseup="() => endDragging(unref(draggingNode))"
@touchend.passive="() => endDragging(unref(draggingNode))"
@mouseleave="() => endDragging(unref(draggingNode), true)"
>
<svg class="stage" width="100%" height="100%">
<g class="g1">
<transition-group name="link" appear>
<g
v-for="link in unref(links) || []"
:key="`${link.startNode.id}-${link.endNode.id}`"
>
<BoardLinkVue
:link="link"
:dragging="unref(draggingNode)"
:dragged="
link.startNode === unref(draggingNode) ||
link.endNode === unref(draggingNode)
? dragged
: undefined
"
/>
</g>
</transition-group>
<transition-group name="grow" :duration="500" appear>
<g v-for="node in sortedNodes" :key="node.id" style="transition-duration: 0s">
<BoardNodeVue
:node="node"
:nodeType="types[node.type]"
:dragging="unref(draggingNode)"
:dragged="unref(draggingNode) === node ? dragged : undefined"
:hasDragged="unref(draggingNode) == null ? false : hasDragged"
:receivingNode="unref(receivingNode) === node"
:isSelected="unref(selectedNode) === node"
:selectedAction="
unref(selectedNode) === node ? unref(selectedAction) : null
"
@mouseDown="mouseDown"
@endDragging="endDragging"
@clickAction="(actionId: string) => clickAction(node, actionId)"
/>
</g>
</transition-group>
</g>
</svg>
</panZoom>
</template>
<script setup lang="ts">
import type {
BoardData,
BoardNode,
BoardNodeLink,
GenericBoardNodeAction,
GenericNodeType
} from "features/boards/board";
import { getNodeProperty } from "features/boards/board";
import type { StyleValue } from "features/feature";
import { Visibility, isVisible } from "features/feature";
import type { ProcessedComputable } from "util/computed";
import { Ref, computed, ref, toRefs, unref, watchEffect } from "vue";
import BoardLinkVue from "./BoardLink.vue";
import BoardNodeVue from "./BoardNode.vue";
const _props = defineProps<{
nodes: Ref<BoardNode[]>;
types: Record<string, GenericNodeType>;
state: Ref<BoardData>;
visibility: ProcessedComputable<Visibility | boolean>;
width?: ProcessedComputable<string>;
height?: ProcessedComputable<string>;
style?: ProcessedComputable<StyleValue>;
classes?: ProcessedComputable<Record<string, boolean>>;
links: Ref<BoardNodeLink[] | null>;
selectedAction: Ref<GenericBoardNodeAction | null>;
selectedNode: Ref<BoardNode | null>;
draggingNode: Ref<BoardNode | null>;
receivingNode: Ref<BoardNode | null>;
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 hasDragged = ref(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const stage = ref<any>(null);
const sortedNodes = computed(() => {
const nodes = props.nodes.value.slice();
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;
});
watchEffect(() => {
const node = props.draggingNode.value;
if (node == null) {
return null;
}
const position = {
x: node.position.x + dragged.value.x,
y: node.position.y + dragged.value.y
};
let smallestDistance = Number.MAX_VALUE;
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;
}
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)
);
});
// 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, node: BoardNode | null = null, draggable = false) {
if (props.draggingNode.value == null) {
e.preventDefault();
e.stopPropagation();
let clientX, clientY;
if ("touches" in e) {
if (e.touches.length === 1) {
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
return;
}
} else {
clientX = e.clientX;
clientY = e.clientY;
}
lastMousePosition.value = {
x: clientX,
y: clientY
};
dragged.value = { x: 0, y: 0 };
hasDragged.value = false;
if (draggable) {
props.setDraggingNode.value(node);
}
}
if (node != null) {
props.state.value.selectedNode = null;
props.state.value.selectedAction = null;
}
}
function drag(e: MouseEvent | TouchEvent) {
const { x, y, scale } = stage.value.panZoomInstance.getTransform();
let clientX, clientY;
if ("touches" in e) {
if (e.touches.length === 1) {
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
endDragging(props.draggingNode.value);
props.mousePosition.value = null;
return;
}
} else {
clientX = e.clientX;
clientY = e.clientY;
}
props.mousePosition.value = {
x: (clientX - x) / scale,
y: (clientY - y) / scale
};
dragged.value = {
x: dragged.value.x + (clientX - lastMousePosition.value.x) / scale,
y: dragged.value.y + (clientY - lastMousePosition.value.y) / scale
};
lastMousePosition.value = {
x: clientX,
y: clientY
};
if (Math.abs(dragged.value.x) > 10 || Math.abs(dragged.value.y) > 10) {
hasDragged.value = true;
}
if (props.draggingNode.value != null) {
e.preventDefault();
e.stopPropagation();
}
}
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.push(nodes.splice(nodes.indexOf(props.draggingNode.value), 1)[0]);
if (props.receivingNode.value) {
props.types.value[props.receivingNode.value.type].onDrop?.(
props.receivingNode.value,
props.draggingNode.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 };
}
}
</script>
<style>
.vue-pan-zoom-scene {
width: 100%;
height: 100%;
cursor: grab;
}
.vue-pan-zoom-scene:active {
cursor: grabbing;
}
.g1 {
transition-duration: 0s;
}
.link-enter-from,
.link-leave-to {
opacity: 0;
}
</style>

View file

@ -1,80 +0,0 @@
<template>
<line
class="link"
v-bind="linkProps"
:class="{ pulsing: link.pulsing }"
:x1="startPosition.x"
:y1="startPosition.y"
:x2="endPosition.x"
:y2="endPosition.y"
/>
</template>
<script setup lang="ts">
import type { BoardNode, BoardNodeLink } from "features/boards/board";
import { kebabifyObject } from "util/vue";
import { computed, toRefs, unref } from "vue";
const _props = defineProps<{
link: BoardNodeLink;
dragging: BoardNode | null;
dragged?: {
x: number;
y: number;
};
}>();
const props = toRefs(_props);
const startPosition = computed(() => {
const position = { ...props.link.value.startNode.position };
if (props.link.value.offsetStart) {
position.x += unref(props.link.value.offsetStart).x;
position.y += unref(props.link.value.offsetStart).y;
}
if (props.dragging?.value === props.link.value.startNode) {
position.x += props.dragged?.value?.x ?? 0;
position.y += props.dragged?.value?.y ?? 0;
}
return position;
});
const endPosition = computed(() => {
const position = { ...props.link.value.endNode.position };
if (props.link.value.offsetEnd) {
position.x += unref(props.link.value.offsetEnd).x;
position.y += unref(props.link.value.offsetEnd).y;
}
if (props.dragging?.value === props.link.value.endNode) {
position.x += props.dragged?.value?.x ?? 0;
position.y += props.dragged?.value?.y ?? 0;
}
return position;
});
const linkProps = computed(() => kebabifyObject(_props.link as unknown as Record<string, unknown>));
</script>
<style scoped>
.link {
transition-duration: 0s;
pointer-events: none;
}
.link.pulsing {
animation: pulsing 2s ease-in infinite;
}
@keyframes pulsing {
0% {
opacity: 0.25;
}
50% {
opacity: 1;
}
100% {
opacity: 0.25;
}
}
</style>

View file

@ -1,339 +0,0 @@
<template>
<!-- Ugly casting to prevent TS compiler error about style because vue doesn't think it supports arrays when it does -->
<g
class="boardnode"
:class="{ [node.type]: true, isSelected, isDraggable, ...classes }"
:style="[{ opacity: dragging?.id === node.id && hasDragged ? 0.5 : 1 }, style ?? []] as unknown as (string | CSSProperties)"
:transform="`translate(${position.x},${position.y})${isSelected ? ' scale(1.2)' : ''}`"
>
<BoardNodeAction
:actions="actions ?? []"
:is-selected="isSelected"
:node="node"
:node-type="nodeType"
:selected-action="selectedAction"
@click-action="(actionId: string) => emit('clickAction', actionId)"
/>
<g
class="node-container"
@mousedown="mouseDown"
@touchstart.passive="mouseDown"
@mouseup="mouseUp"
@touchend.passive="mouseUp"
>
<g v-if="shape === Shape.Circle">
<circle
v-if="canAccept"
class="receiver"
:r="size + 8"
:fill="backgroundColor"
:stroke="receivingNode ? '#0F0' : '#0F03'"
:stroke-width="2"
/>
<circle
class="body"
:r="size"
:fill="fillColor"
:stroke="outlineColor"
:stroke-width="4"
/>
<circle
class="progress progressFill"
v-if="progressDisplay === ProgressDisplay.Fill"
:r="Math.max(size * progress - 2, 0)"
:fill="progressColor"
/>
<circle
v-else
:r="size + 4.5"
class="progress progressRing"
fill="transparent"
:stroke-dasharray="(size + 4.5) * 2 * Math.PI"
:stroke-width="5"
:stroke-dashoffset="
(size + 4.5) * 2 * Math.PI - progress * (size + 4.5) * 2 * Math.PI
"
:stroke="progressColor"
/>
</g>
<g v-else-if="shape === Shape.Diamond" transform="rotate(45, 0, 0)">
<rect
v-if="canAccept"
class="receiver"
:width="size * sqrtTwo + 16"
:height="size * sqrtTwo + 16"
:transform="`translate(${-(size * sqrtTwo + 16) / 2}, ${
-(size * sqrtTwo + 16) / 2
})`"
:fill="backgroundColor"
:stroke="receivingNode ? '#0F0' : '#0F03'"
:stroke-width="2"
/>
<rect
class="body"
:width="size * sqrtTwo"
:height="size * sqrtTwo"
:transform="`translate(${(-size * sqrtTwo) / 2}, ${(-size * sqrtTwo) / 2})`"
:fill="fillColor"
:stroke="outlineColor"
:stroke-width="4"
/>
<rect
v-if="progressDisplay === ProgressDisplay.Fill"
class="progress progressFill"
:width="Math.max(size * sqrtTwo * progress - 2, 0)"
:height="Math.max(size * sqrtTwo * progress - 2, 0)"
:transform="`translate(${-Math.max(size * sqrtTwo * progress - 2, 0) / 2}, ${
-Math.max(size * sqrtTwo * progress - 2, 0) / 2
})`"
:fill="progressColor"
/>
<rect
v-else
class="progress progressDiamond"
:width="size * sqrtTwo + 9"
:height="size * sqrtTwo + 9"
:transform="`translate(${-(size * sqrtTwo + 9) / 2}, ${
-(size * sqrtTwo + 9) / 2
})`"
fill="transparent"
:stroke-dasharray="(size * sqrtTwo + 9) * 4"
:stroke-width="5"
:stroke-dashoffset="
(size * sqrtTwo + 9) * 4 - progress * (size * sqrtTwo + 9) * 4
"
:stroke="progressColor"
/>
</g>
<text :fill="titleColor" class="node-title">{{ title }}</text>
</g>
<transition name="fade" appear>
<g v-if="label">
<text
:fill="label.color ?? titleColor"
class="node-title"
:class="{ pulsing: label.pulsing }"
:y="-size - 20"
>{{ label.text }}
</text>
</g>
</transition>
<transition name="fade" appear>
<text
v-if="isSelected && selectedAction"
:fill="confirmationLabel.color ?? titleColor"
class="node-title"
:class="{ pulsing: confirmationLabel.pulsing }"
:y="size + 75"
>{{ confirmationLabel.text }}</text
>
</transition>
</g>
</template>
<script setup lang="ts">
import themes from "data/themes";
import type { BoardNode, GenericBoardNodeAction, GenericNodeType } from "features/boards/board";
import { ProgressDisplay, Shape, getNodeProperty } from "features/boards/board";
import { isVisible } from "features/feature";
import settings from "game/settings";
import { CSSProperties, computed, toRefs, unref, watch } from "vue";
import BoardNodeAction from "./BoardNodeAction.vue";
const sqrtTwo = Math.sqrt(2);
const _props = defineProps<{
node: BoardNode;
nodeType: GenericNodeType;
dragging: BoardNode | null;
dragged?: {
x: number;
y: number;
};
hasDragged?: boolean;
receivingNode?: boolean;
isSelected: boolean;
selectedAction: GenericBoardNodeAction | null;
}>();
const props = toRefs(_props);
const emit = defineEmits<{
(e: "mouseDown", event: MouseEvent | TouchEvent, node: BoardNode, isDraggable: boolean): void;
(e: "endDragging", node: BoardNode): void;
(e: "clickAction", actionId: string): void;
}>();
const isDraggable = computed(() =>
getNodeProperty(props.nodeType.value.draggable, unref(props.node))
);
watch(isDraggable, value => {
const node = unref(props.node);
if (unref(props.dragging) === node && !value) {
emit("endDragging", node);
}
});
const actions = computed(() => {
const node = unref(props.node);
return getNodeProperty(props.nodeType.value.actions, node)?.filter(action =>
isVisible(getNodeProperty(action.visibility, node))
);
});
const position = computed(() => {
const node = unref(props.node);
if (
getNodeProperty(props.nodeType.value.draggable, node) &&
unref(props.dragging)?.id === node.id &&
unref(props.dragged) != null
) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const { x, y } = unref(props.dragged)!;
return {
x: node.position.x + Math.round(x / 25) * 25,
y: node.position.y + Math.round(y / 25) * 25
};
}
return node.position;
});
const shape = computed(() => getNodeProperty(props.nodeType.value.shape, unref(props.node)));
const title = computed(() => getNodeProperty(props.nodeType.value.title, unref(props.node)));
const label = computed(
() =>
(props.isSelected.value
? unref(props.selectedAction) &&
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
getNodeProperty(unref(props.selectedAction)!.tooltip, unref(props.node))
: null) ?? getNodeProperty(props.nodeType.value.label, unref(props.node))
);
const confirmationLabel = computed(() =>
getNodeProperty(
unref(props.selectedAction)?.confirmationLabel ?? {
text: "Tap again to confirm"
},
unref(props.node)
)
);
const size = computed(() => getNodeProperty(props.nodeType.value.size, unref(props.node)));
const progress = computed(
() => getNodeProperty(props.nodeType.value.progress, unref(props.node)) ?? 0
);
const backgroundColor = computed(() => themes[settings.theme].variables["--background"]);
const outlineColor = computed(
() =>
getNodeProperty(props.nodeType.value.outlineColor, unref(props.node)) ??
themes[settings.theme].variables["--outline"]
);
const fillColor = computed(
() =>
getNodeProperty(props.nodeType.value.fillColor, unref(props.node)) ??
themes[settings.theme].variables["--raised-background"]
);
const progressColor = computed(() =>
getNodeProperty(props.nodeType.value.progressColor, unref(props.node))
);
const titleColor = computed(
() =>
getNodeProperty(props.nodeType.value.titleColor, unref(props.node)) ??
themes[settings.theme].variables["--foreground"]
);
const progressDisplay = computed(() =>
getNodeProperty(props.nodeType.value.progressDisplay, unref(props.node))
);
const canAccept = computed(
() =>
props.dragging.value != null &&
unref(props.hasDragged) &&
getNodeProperty(props.nodeType.value.canAccept, unref(props.node), props.dragging.value)
);
const style = computed(() => getNodeProperty(props.nodeType.value.style, unref(props.node)));
const classes = computed(() => getNodeProperty(props.nodeType.value.classes, unref(props.node)));
function mouseDown(e: MouseEvent | TouchEvent) {
emit("mouseDown", e, props.node.value, isDraggable.value);
}
function mouseUp(e: MouseEvent | TouchEvent) {
if (!props.hasDragged?.value) {
emit("endDragging", props.node.value);
props.nodeType.value.onClick?.(props.node.value);
e.stopPropagation();
}
}
</script>
<style scoped>
.boardnode {
cursor: pointer;
transition-duration: 0s;
}
.boardnode:hover .body {
fill: var(--highlighted);
}
.boardnode.isSelected .body {
fill: var(--accent1) !important;
}
.boardnode:not(.isDraggable) .body {
fill: var(--locked);
}
.node-title {
text-anchor: middle;
dominant-baseline: middle;
font-family: monospace;
font-size: 200%;
pointer-events: none;
filter: drop-shadow(3px 3px 2px var(--tooltip-background));
}
.progress {
transition-duration: 0.05s;
}
.progressRing {
transform: rotate(-90deg);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.pulsing {
animation: pulsing 2s ease-in infinite;
}
@keyframes pulsing {
0% {
opacity: 0.25;
}
50% {
opacity: 1;
}
100% {
opacity: 0.25;
}
}
</style>
<style>
.grow-enter-from .node-container,
.grow-leave-to .node-container {
transform: scale(0);
}
</style>

View file

@ -1,109 +0,0 @@
<template>
<transition name="actions" appear>
<g v-if="isSelected && actions">
<g
v-for="(action, index) in actions"
:key="action.id"
class="action"
:class="{ selected: selectedAction?.id === action.id }"
:transform="`translate(
${
(-size - 30) *
Math.sin(((actions.length - 1) / 2 - index) * actionDistance)
},
${
(size + 30) *
Math.cos(((actions.length - 1) / 2 - index) * actionDistance)
}
)`"
@mousedown="e => performAction(e, action)"
@touchstart="e => performAction(e, action)"
@mouseup="e => actionMouseUp(e, action)"
@touchend.stop="e => actionMouseUp(e, action)"
>
<circle
:fill="getNodeProperty(action.fillColor, node)"
r="20"
:stroke-width="selectedAction?.id === action.id ? 4 : 0"
:stroke="outlineColor"
/>
<text :fill="titleColor" class="material-icons">{{
getNodeProperty(action.icon, node)
}}</text>
</g>
</g>
</transition>
</template>
<script setup lang="ts">
import themes from "data/themes";
import type { BoardNode, GenericBoardNodeAction, GenericNodeType } from "features/boards/board";
import { getNodeProperty } from "features/boards/board";
import settings from "game/settings";
import { computed, toRefs, unref } from "vue";
const _props = defineProps<{
node: BoardNode;
nodeType: GenericNodeType;
actions?: GenericBoardNodeAction[];
isSelected: boolean;
selectedAction: GenericBoardNodeAction | null;
}>();
const props = toRefs(_props);
const emit = defineEmits<{
(e: "clickAction", actionId: string): void;
}>();
const size = computed(() => getNodeProperty(props.nodeType.value.size, unref(props.node)));
const outlineColor = computed(
() =>
getNodeProperty(props.nodeType.value.outlineColor, unref(props.node)) ??
themes[settings.theme].variables["--outline"]
);
const titleColor = computed(
() =>
getNodeProperty(props.nodeType.value.titleColor, unref(props.node)) ??
themes[settings.theme].variables["--foreground"]
);
const actionDistance = computed(() =>
getNodeProperty(props.nodeType.value.actionDistance, unref(props.node))
);
function performAction(e: MouseEvent | TouchEvent, action: GenericBoardNodeAction) {
emit("clickAction", action.id);
e.preventDefault();
e.stopPropagation();
}
function actionMouseUp(e: MouseEvent | TouchEvent, action: GenericBoardNodeAction) {
if (unref(props.selectedAction)?.id === action.id) {
e.preventDefault();
e.stopPropagation();
}
}
</script>
<style scoped>
.action:not(.boardnode):hover circle,
.action:not(.boardnode).selected circle {
r: 25;
}
.action:not(.boardnode):hover text,
.action:not(.boardnode).selected text {
font-size: 187.5%; /* 150% * 1.25 */
}
.action:not(.boardnode) text {
text-anchor: middle;
dominant-baseline: central;
}
</style>
<style>
.actions-enter-from .action,
.actions-leave-to .action {
transform: translate(0, 0);
}
</style>

View file

@ -1,631 +0,0 @@
import BoardComponent from "features/boards/Board.vue";
import type { GenericComponent, OptionsFunc, Replace, StyleValue } from "features/feature";
import {
Component,
findFeatures,
GatherProps,
getUniqueID,
setDefault,
Visibility
} from "features/feature";
import { globalBus } from "game/events";
import { DefaultValue, deletePersistent, Persistent, State } from "game/persistence";
import { persistent } from "game/persistence";
import type { Unsubscribe } from "nanoevents";
import { Direction, isFunction } from "util/common";
import type {
Computable,
GetComputableType,
GetComputableTypeWithDefault,
ProcessedComputable
} from "util/computed";
import { processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies";
import { computed, isRef, ref, Ref, unref } from "vue";
import panZoom from "vue-panzoom";
import type { Link } from "../links/links";
globalBus.on("setupVue", app => panZoom.install(app));
/** A symbol used to identify {@link Board} features. */
export const BoardType = Symbol("Board");
/**
* A type representing a computable value for a node on the board. Used for node types to return different values based on the given node and the state of the board.
*/
export type NodeComputable<T, S extends unknown[] = []> =
| Computable<T>
| ((node: BoardNode, ...args: S) => T);
/** Ways to display progress of an action with a duration. */
export enum ProgressDisplay {
Outline = "Outline",
Fill = "Fill"
}
/** Node shapes. */
export enum Shape {
Circle = "Circle",
Diamond = "Triangle"
}
/** An object representing a node on the board. */
export interface BoardNode {
id: number;
position: {
x: number;
y: number;
};
type: string;
state?: State;
pinned?: boolean;
}
/** An object representing a link between two nodes on the board. */
export interface BoardNodeLink extends Omit<Link, "startNode" | "endNode"> {
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<string>;
/** An optional label for the node. */
label?: NodeComputable<NodeLabel | null>;
/** The size of the node - diameter for circles, width and height for squares. */
size: NodeComputable<number>;
/** CSS to apply to this node. */
style?: NodeComputable<StyleValue>;
/** Dictionary of CSS classes to apply to this node. */
classes?: NodeComputable<Record<string, boolean>>;
/** Whether the node is draggable or not. */
draggable?: NodeComputable<boolean>;
/** The shape of the node. */
shape: NodeComputable<Shape>;
/** Whether the node can accept another node being dropped upon it. */
canAccept?: NodeComputable<boolean, [BoardNode]>;
/** The progress value of the node, from 0 to 1. */
progress?: NodeComputable<number>;
/** How the progress should be displayed on the node. */
progressDisplay?: NodeComputable<ProgressDisplay>;
/** The color of the progress indicator. */
progressColor?: NodeComputable<string>;
/** The fill color of the node. */
fillColor?: NodeComputable<string>;
/** The outline color of the node. */
outlineColor?: NodeComputable<string>;
/** The color of the title text. */
titleColor?: NodeComputable<string>;
/** The list of action options for the node. */
actions?: BoardNodeActionOptions[];
/** The arc between each action, in radians. */
actionDistance?: NodeComputable<number>;
/** 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<BoardNode[]>;
}
/** 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<T extends NodeTypeOptions> = Replace<
T & BaseNodeType,
{
title: GetComputableType<T["title"]>;
label: GetComputableType<T["label"]>;
size: GetComputableTypeWithDefault<T["size"], 50>;
style: GetComputableType<T["style"]>;
classes: GetComputableType<T["classes"]>;
draggable: GetComputableTypeWithDefault<T["draggable"], false>;
shape: GetComputableTypeWithDefault<T["shape"], Shape.Circle>;
canAccept: GetComputableTypeWithDefault<T["canAccept"], false>;
progress: GetComputableType<T["progress"]>;
progressDisplay: GetComputableTypeWithDefault<T["progressDisplay"], ProgressDisplay.Fill>;
progressColor: GetComputableTypeWithDefault<T["progressColor"], "none">;
fillColor: GetComputableType<T["fillColor"]>;
outlineColor: GetComputableType<T["outlineColor"]>;
titleColor: GetComputableType<T["titleColor"]>;
actions?: GenericBoardNodeAction[];
actionDistance: GetComputableTypeWithDefault<T["actionDistance"], number>;
}
>;
/** A type that matches any valid {@link NodeType} object. */
export type GenericNodeType = Replace<
NodeType<NodeTypeOptions>,
{
size: NodeComputable<number>;
draggable: NodeComputable<boolean>;
shape: NodeComputable<Shape>;
canAccept: NodeComputable<boolean, [BoardNode]>;
progressDisplay: NodeComputable<ProgressDisplay>;
progressColor: NodeComputable<string>;
actionDistance: NodeComputable<number>;
}
>;
/**
* An object that configures a {@link BoardNodeAction}.
*/
export interface BoardNodeActionOptions {
/** A unique identifier for the action. */
id: string;
/** Whether this action should be visible. */
visibility?: NodeComputable<Visibility | boolean>;
/** The icon to display for the action. */
icon: NodeComputable<string>;
/** The fill color of the action. */
fillColor?: NodeComputable<string>;
/** The tooltip text to display for the action. */
tooltip: NodeComputable<NodeLabel>;
/** The confirmation label that appears under the action. */
confirmationLabel?: NodeComputable<NodeLabel>;
/** An array of board node links associated with the action. They appear when the action is focused. */
links?: NodeComputable<BoardNodeLink[]>;
/** 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<BoardNodeLink[]>;
}
/** An object that represents an action that can be taken upon a node. */
export type BoardNodeAction<T extends BoardNodeActionOptions> = Replace<
T & BaseBoardNodeAction,
{
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
icon: GetComputableType<T["icon"]>;
fillColor: GetComputableType<T["fillColor"]>;
tooltip: GetComputableType<T["tooltip"]>;
confirmationLabel: GetComputableTypeWithDefault<T["confirmationLabel"], NodeLabel>;
links: GetComputableType<T["links"]>;
}
>;
/** A type that matches any valid {@link BoardNodeAction} object. */
export type GenericBoardNodeAction = Replace<
BoardNodeAction<BoardNodeActionOptions>,
{
visibility: NodeComputable<Visibility | boolean>;
confirmationLabel: NodeComputable<NodeLabel>;
}
>;
/**
* An object that configures a {@link Board}.
*/
export interface BoardOptions {
/** Whether this board should be visible. */
visibility?: Computable<Visibility | boolean>;
/** The height of the board. Defaults to 100% */
height?: Computable<string>;
/** The width of the board. Defaults to 100% */
width?: Computable<string>;
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>;
/** A function that returns an array of initial board nodes, without IDs. */
startNodes: () => Omit<BoardNode, "id">[];
/** A dictionary of node types that can appear on the board. */
types: Record<string, NodeTypeOptions>;
/** The persistent state of the board. */
state?: Computable<BoardData>;
/** An array of board node links to display. */
links?: Computable<BoardNodeLink[] | null>;
}
/**
* The properties that are added onto a processed {@link BoardOptions} to create a {@link Board}.
*/
export interface BaseBoard {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string;
/** All the nodes currently on the board. */
nodes: Ref<BoardNode[]>;
/** The currently selected node, if any. */
selectedNode: Ref<BoardNode | null>;
/** The currently selected action, if any. */
selectedAction: Ref<GenericBoardNodeAction | null>;
/** The currently being dragged node, if any. */
draggingNode: Ref<BoardNode | null>;
/** If dragging a node, the node it's currently being hovered over, if any. */
receivingNode: Ref<BoardNode | null>;
/** The current mouse position, if over the board. */
mousePosition: Ref<{ x: number; y: number } | null>;
/** Places a node in the nearest empty space in the given direction with the specified space around it. */
placeInAvailableSpace: (node: BoardNode, radius?: number, direction?: Direction) => void;
/** A symbol that helps identify features of the same type. */
type: typeof BoardType;
/** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>;
}
/** An object that represents a feature that is a zoomable, pannable board with various nodes upon it. */
export type Board<T extends BoardOptions> = Replace<
T & BaseBoard,
{
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
types: Record<string, GenericNodeType>;
height: GetComputableType<T["height"]>;
width: GetComputableType<T["width"]>;
classes: GetComputableType<T["classes"]>;
style: GetComputableType<T["style"]>;
state: GetComputableTypeWithDefault<T["state"], Persistent<BoardData>>;
links: GetComputableTypeWithDefault<T["links"], Ref<BoardNodeLink[] | null>>;
}
>;
/** A type that matches any valid {@link Board} object. */
export type GenericBoard = Replace<
Board<BoardOptions>,
{
visibility: ProcessedComputable<Visibility | boolean>;
state: ProcessedComputable<BoardData>;
links: ProcessedComputable<BoardNodeLink[] | null>;
}
>;
/**
* Lazily creates a board with the given options.
* @param optionsFunc Board options.
*/
export function createBoard<T extends BoardOptions>(
optionsFunc: OptionsFunc<T, BaseBoard, GenericBoard>
): Board<T> {
const state = persistent<BoardData>(
{
nodes: [],
selectedNode: null,
selectedAction: null
},
false
);
return createLazyProxy(feature => {
const board = optionsFunc.call(feature, feature);
board.id = getUniqueID("board-");
board.type = BoardType;
board[Component] = BoardComponent as GenericComponent;
if (board.state) {
deletePersistent(state);
processComputable(board as T, "state");
} else {
state[DefaultValue] = {
nodes: board.startNodes().map((n, i) => {
(n as BoardNode).id = i;
return n as BoardNode;
}),
selectedNode: null,
selectedAction: null
};
board.state = state;
}
board.nodes = computed(() => unref(processedBoard.state).nodes);
board.selectedNode = computed({
get() {
return (
processedBoard.nodes.value.find(
node => node.id === unref(processedBoard.state).selectedNode
) || null
);
},
set(node) {
if (isRef(processedBoard.state)) {
processedBoard.state.value = {
...processedBoard.state.value,
selectedNode: node?.id ?? null
};
} else {
processedBoard.state.selectedNode = node?.id ?? null;
}
}
});
board.selectedAction = computed({
get() {
const selectedNode = processedBoard.selectedNode.value;
if (selectedNode == null) {
return null;
}
const type = processedBoard.types[selectedNode.type];
if (type.actions == null) {
return null;
}
return (
type.actions.find(
action => action.id === unref(processedBoard.state).selectedAction
) || null
);
},
set(action) {
if (isRef(processedBoard.state)) {
processedBoard.state.value = {
...processedBoard.state.value,
selectedAction: action?.id ?? null
};
} else {
processedBoard.state.selectedAction = action?.id ?? null;
}
}
});
board.mousePosition = ref(null);
if (board.links) {
processComputable(board as T, "links");
} else {
board.links = computed(() => {
if (processedBoard.selectedAction.value == null) {
return null;
}
if (
processedBoard.selectedAction.value.links &&
processedBoard.selectedNode.value
) {
return getNodeProperty(
processedBoard.selectedAction.value.links,
processedBoard.selectedNode.value
);
}
return null;
});
}
board.draggingNode = ref(null);
board.receivingNode = ref(null);
processComputable(board as T, "visibility");
setDefault(board, "visibility", Visibility.Visible);
processComputable(board as T, "width");
setDefault(board, "width", "100%");
processComputable(board as T, "height");
setDefault(board, "height", "100%");
processComputable(board as T, "classes");
processComputable(board as T, "style");
for (const type in board.types) {
const nodeType: NodeTypeOptions & Partial<BaseNodeType> = board.types[type];
processComputable(nodeType as NodeTypeOptions, "title");
processComputable(nodeType as NodeTypeOptions, "label");
processComputable(nodeType as NodeTypeOptions, "size");
setDefault(nodeType, "size", 50);
processComputable(nodeType as NodeTypeOptions, "style");
processComputable(nodeType as NodeTypeOptions, "classes");
processComputable(nodeType as NodeTypeOptions, "draggable");
setDefault(nodeType, "draggable", false);
processComputable(nodeType as NodeTypeOptions, "shape");
setDefault(nodeType, "shape", Shape.Circle);
processComputable(nodeType as NodeTypeOptions, "canAccept");
setDefault(nodeType, "canAccept", false);
processComputable(nodeType as NodeTypeOptions, "progress");
processComputable(nodeType as NodeTypeOptions, "progressDisplay");
setDefault(nodeType, "progressDisplay", ProgressDisplay.Fill);
processComputable(nodeType as NodeTypeOptions, "progressColor");
setDefault(nodeType, "progressColor", "none");
processComputable(nodeType as NodeTypeOptions, "fillColor");
processComputable(nodeType as NodeTypeOptions, "outlineColor");
processComputable(nodeType as NodeTypeOptions, "titleColor");
processComputable(nodeType as NodeTypeOptions, "actionDistance");
setDefault(nodeType, "actionDistance", Math.PI / 6);
nodeType.nodes = computed(() =>
unref(processedBoard.state).nodes.filter(node => node.type === type)
);
setDefault(nodeType, "onClick", function (node: BoardNode) {
unref(processedBoard.state).selectedNode = node.id;
});
if (nodeType.actions) {
for (const action of nodeType.actions) {
processComputable(action, "visibility");
setDefault(action, "visibility", Visibility.Visible);
processComputable(action, "icon");
processComputable(action, "fillColor");
processComputable(action, "tooltip");
processComputable(action, "confirmationLabel");
setDefault(action, "confirmationLabel", { text: "Tap again to confirm" });
processComputable(action, "links");
}
}
}
function setDraggingNode(node: BoardNode | null) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
board.draggingNode!.value = node;
}
function setReceivingNode(node: BoardNode | null) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
board.receivingNode!.value = node;
}
board.placeInAvailableSpace = function (
node: BoardNode,
radius = 100,
direction = Direction.Right
) {
const nodes = processedBoard.nodes.value
.slice()
.filter(n => {
// Exclude self
if (n === node) {
return false;
}
// Exclude nodes that aren't within the corridor we'll be moving within
if (
(direction === Direction.Down || direction === Direction.Up) &&
Math.abs(n.position.x - node.position.x) > radius
) {
return false;
}
if (
(direction === Direction.Left || direction === Direction.Right) &&
Math.abs(n.position.y - node.position.y) > radius
) {
return false;
}
// Exclude nodes in the wrong direction
return !(
(direction === Direction.Right &&
n.position.x < node.position.x - radius) ||
(direction === Direction.Left && n.position.x > node.position.x + radius) ||
(direction === Direction.Up && n.position.y > node.position.y + radius) ||
(direction === Direction.Down && n.position.y < node.position.y - radius)
);
})
.sort(
direction === Direction.Right
? (a, b) => a.position.x - b.position.x
: direction === Direction.Left
? (a, b) => b.position.x - a.position.x
: direction === Direction.Up
? (a, b) => b.position.y - a.position.y
: (a, b) => a.position.y - b.position.y
);
for (let i = 0; i < nodes.length; i++) {
const nodeToCheck = nodes[i];
const distance =
direction === Direction.Right || direction === Direction.Left
? Math.abs(node.position.x - nodeToCheck.position.x)
: Math.abs(node.position.y - nodeToCheck.position.y);
// If we're too close to this node, move further
if (distance < radius) {
if (direction === Direction.Right) {
node.position.x = nodeToCheck.position.x + radius;
} else if (direction === Direction.Left) {
node.position.x = nodeToCheck.position.x - radius;
} else if (direction === Direction.Up) {
node.position.y = nodeToCheck.position.y - radius;
} else if (direction === Direction.Down) {
node.position.y = nodeToCheck.position.y + radius;
}
} else if (i > 0 && distance > radius) {
// If we're further from this node than the radius, then the nodes are past us and we can early exit
break;
}
}
};
board[GatherProps] = function (this: GenericBoard) {
const {
nodes,
types,
state,
visibility,
width,
height,
style,
classes,
links,
selectedAction,
selectedNode,
mousePosition,
draggingNode,
receivingNode
} = this;
return {
nodes,
types,
state,
visibility,
width,
height,
style: unref(style),
classes,
links,
selectedAction,
selectedNode,
mousePosition,
draggingNode,
receivingNode,
setDraggingNode,
setReceivingNode
};
};
// This is necessary because board.types is different from T and Board
const processedBoard = board as unknown as Board<T>;
return processedBoard;
});
}
/**
* Gets the value of a property for a specified node.
* @param property The property to find the value of
* @param node The node to get the property of
*/
export function getNodeProperty<T, S extends unknown[]>(
property: NodeComputable<T, S>,
node: BoardNode,
...args: S
): T {
return isFunction<T, [BoardNode, ...S], Computable<T>>(property)
? property(node, ...args)
: unref(property);
}
/**
* Utility to get an ID for a node that is guaranteed unique.
* @param board The board feature to generate an ID for
*/
export function getUniqueNodeID(board: GenericBoard): number {
let id = 0;
board.nodes.value.forEach(node => {
if (node.id >= id) {
id = node.id + 1;
}
});
return id;
}
const listeners: Record<string, Unsubscribe | undefined> = {};
globalBus.on("addLayer", layer => {
const boards: GenericBoard[] = findFeatures(layer, BoardType) as GenericBoard[];
listeners[layer.id] = layer.on("postUpdate", diff => {
boards.forEach(board => {
Object.values(board.types).forEach(type =>
type.nodes.value.forEach(node => type.update?.(node, diff))
);
});
});
});
globalBus.on("removeLayer", layer => {
// unsubscribe from postUpdate
listeners[layer.id]?.();
listeners[layer.id] = undefined;
});

View file

@ -1,4 +1,3 @@
import { isArray } from "@vue/shared";
import Toggle from "components/fields/Toggle.vue";
import ChallengeComponent from "features/challenges/Challenge.vue";
import { GenericDecorator } from "features/decorators/common";
@ -348,7 +347,7 @@ export function createActiveChallenge(
export function isAnyChallengeActive(
challenges: GenericChallenge[] | Ref<GenericChallenge | null>
): Ref<boolean> {
if (isArray(challenges)) {
if (Array.isArray(challenges)) {
challenges = createActiveChallenge(challenges);
}
return computed(() => (challenges as Ref<GenericChallenge | null>).value != null);
@ -364,17 +363,19 @@ globalBus.on("loadSettings", settings => {
setDefault(settings, "hideChallenges", false);
});
registerSettingField(
jsx(() => (
<Toggle
title={jsx(() => (
<span class="option-title">
Hide maxed challenges
<desc>Hide challenges that have been fully completed.</desc>
</span>
))}
onUpdate:modelValue={value => (settings.hideChallenges = value)}
modelValue={settings.hideChallenges}
/>
))
globalBus.on("setupVue", () =>
registerSettingField(
jsx(() => (
<Toggle
title={jsx(() => (
<span class="option-title">
Hide maxed challenges
<desc>Hide challenges that have been fully completed.</desc>
</span>
))}
onUpdate:modelValue={value => (settings.hideChallenges = value)}
modelValue={settings.hideChallenges}
/>
))
)
);

View file

@ -129,7 +129,7 @@ export function createClickable<T extends ClickableOptions>(
if (clickable.onClick) {
const onClick = clickable.onClick.bind(clickable);
clickable.onClick = function (e) {
if (unref(clickable.canClick) !== false) {
if (unref(clickable.canClick as ProcessedComputable<boolean>) !== false) {
onClick(e);
}
};
@ -137,7 +137,7 @@ export function createClickable<T extends ClickableOptions>(
if (clickable.onHold) {
const onHold = clickable.onHold.bind(clickable);
clickable.onHold = function () {
if (unref(clickable.canClick) !== false) {
if (unref(clickable.canClick as ProcessedComputable<boolean>) !== false) {
onHold();
}
};

View file

@ -228,7 +228,7 @@ export function createIndependentConversion<S extends ConversionOptions>(
conversion.baseResource.value
)
).max(conversion.gainResource.value);
if (unref(conversion.buyMax) === false) {
if (unref(conversion.buyMax as ProcessedComputable<boolean>) === false) {
gain = gain.min(Decimal.add(conversion.gainResource.value, 1));
}
return gain;
@ -245,7 +245,7 @@ export function createIndependentConversion<S extends ConversionOptions>(
.floor()
.max(0);
if (unref(conversion.buyMax) === false) {
if (unref(conversion.buyMax as ProcessedComputable<boolean>) === false) {
gain = gain.min(1);
}
return gain;

View file

@ -2,6 +2,7 @@ import Decimal from "util/bignum";
import { DoNotCache, ProcessedComputable } from "util/computed";
import type { CSSProperties, DefineComponent } from "vue";
import { isRef, unref } from "vue";
import { JSX } from "vue/jsx-runtime";
/**
* A symbol to use as a key for a vue component a feature can be rendered with
@ -92,7 +93,7 @@ export function setDefault<T, K extends keyof T>(
key: K,
value: T[K]
): asserts object is Exclude<T, K> & Required<Pick<T, K>> {
if (object[key] === undefined && value != undefined) {
if (object[key] == null && value != null) {
object[key] = value;
}
}
@ -135,7 +136,7 @@ export function excludeFeatures(obj: Record<string, unknown>, ...types: symbol[]
if (value != null && typeof value === "object") {
if (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
typeof (value as Record<string, any>).type == "symbol" &&
typeof (value as Record<string, any>).type === "symbol" &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
!types.includes((value as Record<string, any>).type)
) {

View file

@ -128,7 +128,7 @@ function getCellHandler(id: string): ProxyHandler<GenericGrid> {
if (isFunction(prop)) {
return () => prop.call(receiver, id, target.getState(id));
}
if (prop != undefined || typeof key === "symbol") {
if (prop != null || typeof key === "symbol") {
return prop;
}
@ -145,7 +145,7 @@ function getCellHandler(id: string): ProxyHandler<GenericGrid> {
cache[key] = computed(() => prop.call(receiver, id, target.getState(id)));
}
return cache[key].value;
} else if (prop != undefined) {
} else if (prop != null) {
return unref(prop);
}
@ -153,7 +153,7 @@ function getCellHandler(id: string): ProxyHandler<GenericGrid> {
prop = (target as any)[`on${key}`];
if (isFunction(prop)) {
return () => prop.call(receiver, id, target.getState(id));
} else if (prop != undefined) {
} else if (prop != null) {
return prop;
}
@ -318,7 +318,7 @@ export function createGrid<T extends GridOptions>(
return grid.id + "-" + cell;
};
grid.getState = function (this: GenericGrid, cell: string | number) {
if (this.cellState.value[cell] != undefined) {
if (this.cellState.value[cell] != null) {
return cellState.value[cell];
}
return this.cells[cell].startState;

View file

@ -99,40 +99,57 @@ document.onkeydown = function (e) {
if (hasWon.value && !player.keepGoing) {
return;
}
let key = e.key;
if (uppercaseNumbers.includes(key)) {
key = "shift+" + uppercaseNumbers.indexOf(key);
const keysToCheck: string[] = [e.key];
if (e.shiftKey && e.ctrlKey) {
keysToCheck.splice(0, 1);
keysToCheck.push("ctrl+shift+" + e.key.toUpperCase());
keysToCheck.push("shift+ctrl+" + e.key.toUpperCase());
if (uppercaseNumbers.includes(e.key)) {
keysToCheck.push("ctrl+shift+" + uppercaseNumbers.indexOf(e.key));
keysToCheck.push("shift+ctrl+" + uppercaseNumbers.indexOf(e.key));
} else {
keysToCheck.push("ctrl+shift+" + e.key.toLowerCase());
keysToCheck.push("shift+ctrl+" + e.key.toLowerCase());
}
} else if (uppercaseNumbers.includes(e.key)) {
keysToCheck.push("shift+" + e.key);
keysToCheck.push("shift+" + uppercaseNumbers.indexOf(e.key));
} else if (e.shiftKey) {
key = "shift+" + key;
keysToCheck.push("shift+" + e.key.toUpperCase());
keysToCheck.push("shift+" + e.key.toLowerCase());
} else if (e.ctrlKey) {
// remove e.key since the key doesn't change based on ctrl being held or not
keysToCheck.splice(0, 1);
keysToCheck.push("ctrl+" + e.key);
}
if (e.ctrlKey) {
key = "ctrl+" + key;
}
const hotkey = hotkeys[key] ?? hotkeys[key.toLowerCase()];
const hotkey = hotkeys[keysToCheck.find(key => key in hotkeys) ?? ""];
if (hotkey && unref(hotkey.enabled)) {
e.preventDefault();
hotkey.onPress();
}
};
registerInfoComponent(
jsx(() => {
const keys = Object.values(hotkeys).filter(hotkey => unref(hotkey?.enabled));
if (keys.length === 0) {
return "";
}
return (
<div>
<br />
<h4>Hotkeys</h4>
<div style="column-count: 2">
{keys.map(hotkey => (
<div>
<Hotkey hotkey={hotkey as GenericHotkey} /> {hotkey?.description}
</div>
))}
globalBus.on("setupVue", () =>
registerInfoComponent(
jsx(() => {
const keys = Object.values(hotkeys).filter(hotkey => unref(hotkey?.enabled));
if (keys.length === 0) {
return "";
}
return (
<div>
<br />
<h4>Hotkeys</h4>
<div style="column-count: 2">
{keys.map(hotkey => (
<div>
<Hotkey hotkey={hotkey as GenericHotkey} />{" "}
{unref(hotkey?.description)}
</div>
))}
</div>
</div>
</div>
);
})
);
})
)
);

View file

@ -36,7 +36,7 @@ onMounted(() => (boundingRect.value = resizeListener.value?.getBoundingClientRec
const validLinks = computed(() => {
const n = nodes.value;
return (
links.value?.filter(link => n[link.startNode.id]?.rect && n[link.startNode.id]?.rect) ?? []
links.value?.filter(link => n[link.startNode.id]?.rect && n[link.endNode.id]?.rect) ?? []
);
});
</script>

View file

@ -1,4 +1,3 @@
import { isArray } from "@vue/shared";
import ClickableComponent from "features/clickables/Clickable.vue";
import type {
CoercableComponent,
@ -162,7 +161,7 @@ export function createRepeatable<T extends RepeatableOptions>(
canMaximize: true
} as const;
const visibilityRequirement = createVisibilityRequirement(repeatable as GenericRepeatable);
if (isArray(repeatable.requirements)) {
if (Array.isArray(repeatable.requirements)) {
repeatable.requirements.unshift(visibilityRequirement);
repeatable.requirements.push(limitRequirement);
} else {

View file

@ -141,7 +141,9 @@ export function createTreeNode<T extends TreeNodeOptions>(
if (treeNode.onClick) {
const onClick = treeNode.onClick.bind(treeNode);
treeNode.onClick = function (e) {
if (unref(treeNode.canClick) !== false) {
if (
unref(treeNode.canClick as ProcessedComputable<boolean | undefined>) !== false
) {
onClick(e);
}
};
@ -149,7 +151,9 @@ export function createTreeNode<T extends TreeNodeOptions>(
if (treeNode.onHold) {
const onHold = treeNode.onHold.bind(treeNode);
treeNode.onHold = function () {
if (unref(treeNode.canClick) !== false) {
if (
unref(treeNode.canClick as ProcessedComputable<boolean | undefined>) !== false
) {
onHold();
}
};
@ -342,7 +346,7 @@ export const branchedResetPropagation = function (
if (links == null) return;
const reset: GenericTreeNode[] = [];
let current = [resettingNode];
while (current.length != 0) {
while (current.length !== 0) {
const next: GenericTreeNode[] = [];
for (const node of current) {
for (const link of links.filter(link => link.startNode === node)) {

View file

@ -1,4 +1,3 @@
import { isArray } from "@vue/shared";
import { GenericDecorator } from "features/decorators/common";
import type {
CoercableComponent,
@ -151,7 +150,7 @@ export function createUpgrade<T extends UpgradeOptions>(
};
const visibilityRequirement = createVisibilityRequirement(upgrade as GenericUpgrade);
if (isArray(upgrade.requirements)) {
if (Array.isArray(upgrade.requirements)) {
upgrade.requirements.unshift(visibilityRequirement);
} else {
upgrade.requirements = [visibilityRequirement, upgrade.requirements];

101
src/game/boards/Board.vue Normal file
View file

@ -0,0 +1,101 @@
<template>
<panZoom
selector=".stage"
:options="{ initialZoom: 1, minZoom: 0.1, maxZoom: 10, zoomDoubleClickSpeed: 1 }"
ref="stage"
@init="onInit"
@mousemove="(e: MouseEvent) => emit('drag', e)"
@touchmove="(e: TouchEvent) => emit('drag', e)"
@mouseleave="(e: MouseEvent) => emit('mouseLeave', e)"
@mouseup="(e: MouseEvent) => emit('mouseUp', e)"
@touchend.passive="(e: TouchEvent) => emit('mouseUp', e)"
>
<div
class="event-listener"
@mousedown="(e: MouseEvent) => emit('mouseDown', e)"
@touchstart="(e: TouchEvent) => emit('mouseDown', e)"
/>
<div class="stage">
<slot />
</div>
</panZoom>
</template>
<script setup lang="ts">
import type { PanZoom } from "panzoom";
import type { ComponentPublicInstance } from "vue";
import { computed, ref } from "vue";
// Required to make sure panzoom component gets registered:
import "./board";
defineExpose({
panZoomInstance: computed(() => stage.value?.panZoomInstance)
});
const emit = defineEmits<{
(event: "drag", e: MouseEvent | TouchEvent): void;
(event: "mouseDown", e: MouseEvent | TouchEvent): void;
(event: "mouseUp", e: MouseEvent | TouchEvent): void;
(event: "mouseLeave", e: MouseEvent | TouchEvent): void;
}>();
const stage = ref<{ panZoomInstance: PanZoom } & ComponentPublicInstance<HTMLElement>>();
function onInit(panzoomInstance: PanZoom) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
panzoomInstance.setTransformOrigin(null);
panzoomInstance.moveTo(0, stage.value?.$el.clientHeight / 2);
}
</script>
<style scoped>
.event-listener {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.stage {
transition-duration: 0s;
width: 100%;
height: 100%;
pointer-events: none;
}
</style>
<style>
.vue-pan-zoom-item {
overflow: hidden;
}
.vue-pan-zoom-scene {
width: 100%;
height: 100%;
cursor: grab;
}
.vue-pan-zoom-scene:active {
cursor: grabbing;
}
.stage > * {
pointer-events: initial;
}
/* "Only" child (excluding resize listener) */
.layer-tab > .vue-pan-zoom-item:first-child:nth-last-child(2) {
width: calc(100% + 20px);
height: calc(100% + 100px);
margin: -50px -10px;
}
.board-node {
position: absolute;
top: 0;
left: 50%;
transition-duration: 0s;
}
</style>
game/boards/board

View file

@ -0,0 +1,29 @@
<template>
<circle
:r="r"
fill="transparent"
:stroke-dasharray="r * 2 * Math.PI"
:stroke-width="5"
:stroke-dashoffset="r * 2 * Math.PI - progress * r * 2 * Math.PI"
:stroke="stroke"
/>
</template>
<script setup lang="ts">
import type { SVGAttributes } from "vue";
interface CircleProgressProps extends SVGAttributes {
r: number;
progress: number;
stroke: string;
}
defineProps<CircleProgressProps>();
</script>
<style scoped>
circle {
transition-duration: 0.05s;
transform: rotate(-90deg);
}
</style>

View file

@ -0,0 +1,29 @@
<template>
<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)"
>
<component v-if="comp" :is="comp" />
</div>
</template>
<script setup lang="tsx">
import { jsx } from "features/feature";
import { VueFeature, coerceComponent, renderJSX } from "util/vue";
import { Ref, shallowRef, unref } from "vue";
import { NodePosition } from "./board";
unref;
const props = defineProps<{
element: VueFeature;
mouseDown: (e: MouseEvent | TouchEvent) => void;
mouseUp: (e: MouseEvent | TouchEvent) => void;
position: Ref<NodePosition>;
}>();
const comp = shallowRef(coerceComponent(jsx(() => renderJSX(props.element))));
</script>

View file

@ -0,0 +1,28 @@
<template>
<svg
@mousedown="e => emit('mouseDown', e)"
@touchstart.passive="e => emit('mouseDown', e)"
@mouseup="e => emit('mouseUp', e)"
@touchend.passive="e => emit('mouseUp', e)"
width="1"
height="1"
>
<slot />
</svg>
</template>
<script setup lang="ts">
const emit = defineEmits<{
(e: "mouseDown", event: MouseEvent | TouchEvent): void;
(e: "mouseUp", event: MouseEvent | TouchEvent): void;
}>();
</script>
<style scoped>
svg {
cursor: pointer;
transition-duration: 0s;
overflow: visible;
position: absolute;
}
</style>

View file

@ -0,0 +1,30 @@
<template>
<rect
:width="size"
:height="size"
:transform="`translate(${-size / 2}, ${-size / 2})`"
fill="transparent"
:stroke-dasharray="size * 4"
:stroke-width="5"
:stroke-dashoffset="size * 4 - progress * size * 4"
:stroke="stroke"
/>
</template>
<script setup lang="ts">
import type { SVGAttributes } from "vue";
interface SquareProgressProps extends SVGAttributes {
size: number;
progress: number;
stroke: string;
}
defineProps<SquareProgressProps>();
</script>
<style scoped>
rect {
transition-duration: 0.05s;
}
</style>

437
src/game/boards/board.tsx Normal file
View file

@ -0,0 +1,437 @@
import Board from "./Board.vue";
import Draggable from "./Draggable.vue";
import { Component, GatherProps, GenericComponent, jsx } from "features/feature";
import { globalBus } from "game/events";
import { Persistent, persistent } from "game/persistence";
import type { PanZoom } from "panzoom";
import { Direction, isFunction } from "util/common";
import type { Computable, ProcessedComputable } from "util/computed";
import { convertComputable } from "util/computed";
import { VueFeature } from "util/vue";
import type { ComponentPublicInstance, Ref } from "vue";
import { computed, nextTick, ref, unref, watchEffect } from "vue";
import panZoom from "vue-panzoom";
// Register panzoom so it can be used in Board.vue
globalBus.on("setupVue", app => panZoom.install(app));
/** A type representing the position of a node. */
export type NodePosition = { x: number; y: number };
/**
* A type representing a computable value for a node on the board. Used for node types to return different values based on the given node and the state of the board.
*/
export type NodeComputable<T, R, S extends unknown[] = []> =
| Computable<R>
| ((node: T, ...args: S) => R);
/**
* Gets the value of a property for a specified node.
* @param property The property to find the value of
* @param node The node to get the property of
*/
export function unwrapNodeRef<T, R, S extends unknown[]>(
property: NodeComputable<T, R, S>,
node: T,
...args: S
): R {
return isFunction<R, [T, ...S], ProcessedComputable<R>>(property)
? property(node, ...args)
: unref(property);
}
/**
* Create a computed ref that can assist in assigning new nodes an ID unique from all current nodes.
* @param nodes The list of current nodes with IDs as properties
* @returns A computed ref that will give the value of the next unique ID
*/
export function setupUniqueIds(nodes: Computable<{ id: number }[]>) {
const processedNodes = convertComputable(nodes);
return computed(() => Math.max(-1, ...unref(processedNodes).map(node => node.id)) + 1);
}
/** An object that configures a {@link DraggableNode}. */
export interface DraggableNodeOptions<T> {
/** A ref to the specific instance of the Board vue component the node will be draggable on. Obtained by passing a suitable ref as the "ref" attribute to the <Board> element. */
board: Ref<ComponentPublicInstance<typeof Board> | undefined>;
/** Getter function to go from the node (typically ID) to the position of said node. */
getPosition: (node: T) => NodePosition;
/** Setter function to update the position of a node. */
setPosition: (node: T, position: NodePosition) => void;
/** A list of nodes that the currently dragged node can be dropped upon. */
receivingNodes?: NodeComputable<T, T[]>;
/** The maximum distance (in pixels, before zoom) away a node can be and still drop onto a receiving node. */
dropAreaRadius?: NodeComputable<T, number>;
/** A callback for when a node gets dropped upon a receiving node. */
onDrop?: (acceptingNode: T, draggingNode: T) => void;
}
/** An object that represents a system for moving nodes on a board by dragging them. */
export interface DraggableNode<T> {
/** A ref to the node currently being moved. */
nodeBeingDragged: Ref<T | undefined>;
/** A ref to the node the node being dragged could be dropped upon if let go, if any. The node closest to the node being dragged if there are more than one within the drop area radius. */
receivingNode: Ref<T | undefined>;
/** A ref to whether or not the node being dragged has actually been dragged away from its starting position. */
hasDragged: Ref<boolean>;
/** The position of the node being dragged relative to where it started at the beginning of the drag. */
dragDelta: Ref<NodePosition>;
/** The nodes that can receive the node currently being dragged. */
receivingNodes: Ref<T[]>;
/** A function to call whenever a drag should start, that takes the mouse event that triggered it. Typically attached to each node's onMouseDown listener. */
startDrag: (e: MouseEvent | TouchEvent, node: T) => void;
/** A function to call whenever a drag should end, typically attached to the Board's onMouseUp and onMouseLeave listeners. */
endDrag: VoidFunction;
/** A function to call when the mouse moves during a drag, typically attached to the Board's onDrag listener. */
drag: (e: MouseEvent | TouchEvent) => void;
}
/**
* Sets up a system to allow nodes to be moved within a board by dragging and dropping.
* Also allows for dropping nodes on other nodes to trigger code.
* @param options Draggable node options.
* @returns A DraggableNode object.
*/
export function setupDraggableNode<T>(options: DraggableNodeOptions<T>): DraggableNode<T> {
const nodeBeingDragged = ref<T>();
const receivingNode = ref<T>();
const hasDragged = ref(false);
const dragDelta = ref({ x: 0, y: 0 });
const receivingNodes = computed(() =>
nodeBeingDragged.value == null
? []
: // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
unwrapNodeRef(options.receivingNodes ?? [], nodeBeingDragged.value!)
);
const dropAreaRadius = options.dropAreaRadius ?? 50;
const mousePosition = ref<NodePosition>();
const lastMousePosition = ref({ x: 0, y: 0 });
watchEffect(() => {
const node = nodeBeingDragged.value;
if (node == null) {
return null;
}
const originalPosition = options.getPosition(node);
const position = {
x: originalPosition.x + dragDelta.value.x,
y: originalPosition.y + dragDelta.value.y
};
let smallestDistance = Number.MAX_VALUE;
receivingNode.value = unref(receivingNodes).reduce((smallest: T | undefined, curr: T) => {
if ((curr as T) === node) {
return smallest;
}
const { x, y } = options.getPosition(curr);
const distanceSquared = Math.pow(position.x - x, 2) + Math.pow(position.y - y, 2);
const size = unwrapNodeRef(dropAreaRadius, curr);
if (distanceSquared > smallestDistance || distanceSquared > size * size) {
return smallest;
}
smallestDistance = distanceSquared;
return curr;
}, undefined);
});
const result = {
nodeBeingDragged,
receivingNode,
hasDragged,
dragDelta,
receivingNodes,
startDrag: function (e: MouseEvent | TouchEvent, node: T) {
e.preventDefault();
e.stopPropagation();
let clientX, clientY;
if ("touches" in e) {
if (e.touches.length === 1) {
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
return;
}
} else {
clientX = e.clientX;
clientY = e.clientY;
}
lastMousePosition.value = {
x: clientX,
y: clientY
};
dragDelta.value = { x: 0, y: 0 };
hasDragged.value = false;
nodeBeingDragged.value = node;
},
endDrag: function () {
if (nodeBeingDragged.value == null) {
return;
}
if (receivingNode.value == null) {
const { x, y } = options.getPosition(nodeBeingDragged.value);
const newX = x + Math.round(dragDelta.value.x / 25) * 25;
const newY = y + Math.round(dragDelta.value.y / 25) * 25;
options.setPosition(nodeBeingDragged.value, { x: newX, y: newY });
}
if (receivingNode.value != null) {
options.onDrop?.(receivingNode.value, nodeBeingDragged.value);
}
nodeBeingDragged.value = undefined;
},
drag: function (e: MouseEvent | TouchEvent) {
const panZoomInstance = options.board.value?.panZoomInstance as PanZoom | undefined;
if (panZoomInstance == null || nodeBeingDragged.value == null) {
return;
}
const { x, y, scale } = panZoomInstance.getTransform();
let clientX, clientY;
if ("touches" in e) {
if (e.touches.length === 1) {
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
result.endDrag();
mousePosition.value = undefined;
return;
}
} else {
clientX = e.clientX;
clientY = e.clientY;
}
mousePosition.value = {
x: (clientX - x) / scale,
y: (clientY - y) / scale
};
dragDelta.value = {
x: dragDelta.value.x + (clientX - lastMousePosition.value.x) / scale,
y: dragDelta.value.y + (clientY - lastMousePosition.value.y) / scale
};
lastMousePosition.value = {
x: clientX,
y: clientY
};
if (Math.abs(dragDelta.value.x) > 10 || Math.abs(dragDelta.value.y) > 10) {
hasDragged.value = true;
}
e.preventDefault();
e.stopPropagation();
}
};
return result;
}
/** An object that configures how to make a vue feature draggable using {@link makeDraggable}. */
export interface MakeDraggableOptions<T> {
/** The node ID to use for the vue feature. */
id: T;
/** A reference to the current node being dragged, typically from {@link setupDraggableNode}. */
nodeBeingDragged: Ref<T | undefined>;
/** A reference to whether or not the node being dragged has been moved away from its initial position. Typically from {@link setupDraggableNode}. */
hasDragged: Ref<boolean>;
/** A reference to how far the node being dragged is from its initial position. Typically from {@link setupDraggableNode}. */
dragDelta: Ref<NodePosition>;
/** A function to call when a drag is supposed to start. Typically from {@link setupDraggableNode}. */
startDrag: (e: MouseEvent | TouchEvent, id: T) => void;
/** A function to call when a drag is supposed to end. Typically from {@link setupDraggableNode}. */
endDrag: VoidFunction;
/** A callback that's called when the element is pressed down. Fires before drag starts, and returning `false` will prevent the drag from happening. */
onMouseDown?: (e: MouseEvent | TouchEvent) => boolean | void;
/** A callback that's called when the mouse is lifted off the element. */
onMouseUp?: (e: MouseEvent | TouchEvent) => boolean | void;
/** The initial position of the node on the board. Defaults to (0, 0). */
initialPosition?: 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 extends VueFeature, S>(
element: T,
options: MakeDraggableOptions<S>
): asserts element is T & { position: Persistent<NodePosition> } {
const position = persistent(options.initialPosition ?? { x: 0, y: 0 });
(element as T & { 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;
});
function handleMouseDown(e: MouseEvent | TouchEvent) {
if (options.onMouseDown?.(e) === false) {
return;
}
if (options.nodeBeingDragged.value == null) {
options.startDrag(e, options.id);
}
}
function handleMouseUp(e: MouseEvent | TouchEvent) {
options.onMouseUp?.(e);
}
nextTick(() => {
const elementComponent = element[Component];
const elementGatherProps = element[GatherProps].bind(element);
element[Component] = Draggable as GenericComponent;
element[GatherProps] = function gatherTooltipProps(this: typeof options) {
return {
element: {
[Component]: elementComponent,
[GatherProps]: elementGatherProps
},
mouseDown: handleMouseDown,
mouseUp: handleMouseUp,
position: computedPosition
};
}.bind(options);
});
}
/** An object that configures how to setup a list of actions using {@link setupActions}. */
export interface SetupActionsOptions<T extends NodePosition> {
/** The node to display actions upon, or undefined when the actions should be hidden. */
node: Computable<T | undefined>;
/** Whether or not to currently display the actions. */
shouldShowActions?: NodeComputable<T, boolean>;
/** The list of actions to display. Actions are arbitrary JSX elements. */
actions: NodeComputable<T, ((position: NodePosition) => JSX.Element)[]>;
/** The distance from the node to place the actions. */
distance: NodeComputable<T, number>;
/** The arc length to place between actions, in radians. */
arcLength?: NodeComputable<T, number>;
}
/**
* Sets up a system where a list of actions, which are arbitrary JSX elements, will get displayed around a node radially, under given conditions. The actions are radially centered around 3/2 PI (Down).
* @param options Setup actions options.
* @returns A JSX function to render the actions.
*/
export function setupActions<T extends NodePosition>(options: SetupActionsOptions<T>) {
const node = convertComputable(options.node);
return jsx(() => {
const currNode = unref(node);
if (currNode == null) {
return "";
}
const actions = unwrapNodeRef(options.actions, currNode);
const shouldShow = unwrapNodeRef(options.shouldShowActions, currNode) ?? true;
if (!shouldShow) {
return <>{actions.map(f => f(currNode))}</>;
}
const distance = unwrapNodeRef(options.distance, currNode);
const arcLength = unwrapNodeRef(options.arcLength, currNode) ?? Math.PI / 6;
const firstAngle = Math.PI / 2 - ((actions.length - 1) / 2) * arcLength;
return (
<>
{actions.map((f, index) =>
f({
x: currNode.x + Math.cos(firstAngle + index * arcLength) * distance,
y: currNode.y + Math.sin(firstAngle + index * arcLength) * distance
})
)}
</>
);
});
}
/**
* Moves a node so that it is sufficiently far away from any other nodes, to prevent overlapping.
* @param nodeToPlace The node to find a spot for, with it's current/preffered position.
* @param nodes The list of nodes to make sure nodeToPlace is far enough away from.
* @param radius How far away nodeToPlace must be from any other nodes.
* @param direction The direction to push the nodeToPlace until it finds an available spot.
*/
export function placeInAvailableSpace<T extends NodePosition>(
nodeToPlace: T,
nodes: T[],
radius = 100,
direction = Direction.Right
) {
nodes = nodes
.filter(n => {
// Exclude self
if (n === nodeToPlace) {
return false;
}
// Exclude nodes that aren't within the corridor we'll be moving within
if (
(direction === Direction.Down || direction === Direction.Up) &&
Math.abs(n.x - nodeToPlace.x) > radius
) {
return false;
}
if (
(direction === Direction.Left || direction === Direction.Right) &&
Math.abs(n.y - nodeToPlace.y) > radius
) {
return false;
}
// Exclude nodes in the wrong direction
return !(
(direction === Direction.Right && n.x < nodeToPlace.x - radius) ||
(direction === Direction.Left && n.x > nodeToPlace.x + radius) ||
(direction === Direction.Up && n.y > nodeToPlace.y + radius) ||
(direction === Direction.Down && n.y < nodeToPlace.y - radius)
);
})
.sort(
direction === Direction.Right
? (a, b) => a.x - b.x
: direction === Direction.Left
? (a, b) => b.x - a.x
: direction === Direction.Up
? (a, b) => b.y - a.y
: (a, b) => a.y - b.y
);
for (let i = 0; i < nodes.length; i++) {
const nodeToCheck = nodes[i];
const distance =
direction === Direction.Right || direction === Direction.Left
? Math.abs(nodeToPlace.x - nodeToCheck.x)
: Math.abs(nodeToPlace.y - nodeToCheck.y);
// If we're too close to this node, move further
// Keep in mind positions start at top right, so "down" means increasing Y
if (distance < radius) {
if (direction === Direction.Right) {
nodeToPlace.x = nodeToCheck.x + radius;
} else if (direction === Direction.Left) {
nodeToPlace.x = nodeToCheck.x - radius;
} else if (direction === Direction.Up) {
nodeToPlace.y = nodeToCheck.y - radius;
} else if (direction === Direction.Down) {
nodeToPlace.y = nodeToCheck.y + radius;
}
} else if (i > 0 && distance > radius) {
// If we're further from this node than the radius, then the nodes are past us and we can early exit
break;
}
}
}

View file

@ -2,6 +2,7 @@ import type { Settings } from "game/settings";
import { createNanoEvents } from "nanoevents";
import type { App } from "vue";
import type { GenericLayer } from "./layers";
import state from "./state";
/** All types of events able to be sent or emitted from the global event bus. */
export interface GlobalEvents {
@ -59,3 +60,7 @@ if ("fonts" in document) {
// JSDom doesn't add document.fonts, and Object.defineProperty doesn't seem to work on document
document.fonts.onloadingdone = () => globalBus.emit("fontsLoaded");
}
document.onmousemove = function () {
state.mouseActivity[state.mouseActivity.length - 1] = true;
};

View file

@ -48,6 +48,7 @@ export interface InternalFormula<T extends [FormulaSource] | FormulaSource[]> {
invertIntegral?(value: DecimalSource): DecimalSource;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export abstract class InternalFormula<T extends [FormulaSource] | FormulaSource[]> {
readonly inputs: T;
@ -56,6 +57,7 @@ export abstract class InternalFormula<T extends [FormulaSource] | FormulaSource[
protected readonly internalIntegrate: IntegrateFunction<T> | undefined;
protected readonly internalIntegrateInner: IntegrateFunction<T> | undefined;
protected readonly applySubstitution: SubstitutionFunction<T> | undefined;
protected readonly description: string | undefined;
protected readonly internalVariables: number;
public readonly innermostVariable: ProcessedComputable<DecimalSource> | undefined;
@ -85,6 +87,7 @@ export abstract class InternalFormula<T extends [FormulaSource] | FormulaSource[
this.internalIntegrate = readonlyProperties.internalIntegrate;
this.internalIntegrateInner = readonlyProperties.internalIntegrateInner;
this.applySubstitution = readonlyProperties.applySubstitution;
this.description = options.description;
}
private setupVariable({
@ -216,6 +219,25 @@ export abstract class InternalFormula<T extends [FormulaSource] | FormulaSource[
return new Formula({ variable: value });
}
/**
* Stringifies the formula so it's more easy to read in the console
* @param formula The formula source to print, used for mapping inputs
*/
public static stringify(formula: FormulaSource): string {
if (formula instanceof InternalFormula) {
if (formula.description != null) {
return formula.description;
}
if (formula.internalEvaluate == null) {
return formula.hasVariable() ? "x" : format(formula.inputs[0] ?? 0);
}
return `${formula.internalEvaluate.name}(${formula.inputs
.map(Formula.stringify)
.join(", ")})`;
}
return format(unref(formula));
}
// TODO add integration support to step-wise functions
/**
* Creates a step-wise formula. After {@link start} the formula will have an additional modifier.
@ -256,7 +278,9 @@ export abstract class InternalFormula<T extends [FormulaSource] | FormulaSource[
return new Formula({
inputs: [value],
evaluate: evalStep,
invert: formula.isInvertible() && formula.hasVariable() ? invertStep : undefined
invert: formula.isInvertible() && formula.hasVariable() ? invertStep : undefined,
// Can't do anything more descriptive, due to formula's input always being a variable
description: "indeterminate"
});
}
@ -309,7 +333,9 @@ export abstract class InternalFormula<T extends [FormulaSource] | FormulaSource[
return new Formula({
inputs: [value],
evaluate: evalStep,
invert: formula.isInvertible() && formula.hasVariable() ? invertStep : undefined
invert: formula.isInvertible() && formula.hasVariable() ? invertStep : undefined,
// Can't do anything more descriptive, due to formula's input always being a variable
description: "indeterminate"
});
}
public static conditional(
@ -878,6 +904,10 @@ export abstract class InternalFormula<T extends [FormulaSource] | FormulaSource[
});
}
public stringify() {
return Formula.stringify(this);
}
public step(
start: Computable<DecimalSource>,
formulaModifier: (value: InvertibleIntegralFormula) => GenericFormula
@ -1402,28 +1432,6 @@ export function findNonInvertible(formula: GenericFormula): GenericFormula | nul
return null;
}
/**
* Stringifies a formula so it's more easy to read in the console
* @param formula The formula to print
*/
export function printFormula(formula: FormulaSource): string {
if (formula instanceof InternalFormula) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return formula.internalEvaluate == null
? formula.hasVariable()
? "x"
: formula.inputs[0] ?? 0
: // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
formula.internalEvaluate.name +
"(" +
formula.inputs.map(printFormula).join(", ") +
")";
}
return format(unref(formula));
}
/**
* Utility for calculating the maximum amount of purchases possible with a given formula and resource. If {@link cumulativeCost} is changed to false, the calculation will be much faster with higher numbers.
* @param formula The formula to use for calculating buy max from

View file

@ -37,9 +37,13 @@ type SubstitutionFunction<T> = (
...inputs: T
) => GenericFormula;
type VariableFormulaOptions = { variable: ProcessedComputable<DecimalSource> };
type VariableFormulaOptions = {
variable: ProcessedComputable<DecimalSource>;
description?: string;
};
type ConstantFormulaOptions = {
inputs: [FormulaSource];
description?: string;
};
type GeneralFormulaOptions<T extends [FormulaSource] | FormulaSource[]> = {
inputs: T;
@ -48,6 +52,7 @@ type GeneralFormulaOptions<T extends [FormulaSource] | FormulaSource[]> = {
integrate?: IntegrateFunction<T>;
integrateInner?: IntegrateFunction<T>;
applySubstitution?: SubstitutionFunction<T>;
description?: string;
};
type FormulaOptions<T extends [FormulaSource] | FormulaSource[]> =
| VariableFormulaOptions
@ -63,6 +68,7 @@ type InternalFormulaProperties<T extends [FormulaSource] | FormulaSource[]> = {
internalIntegrateInner?: IntegrateFunction<T>;
applySubstitution?: SubstitutionFunction<T>;
innermostVariable?: ProcessedComputable<DecimalSource>;
description?: string;
};
type SubstitutionStack = ((value: GenericFormula) => GenericFormula)[] | undefined;

View file

@ -1,19 +1,15 @@
import { hasWon } from "data/projEntry";
import projInfo from "data/projInfo.json";
import { globalBus } from "game/events";
import settings from "game/settings";
import Decimal from "util/bignum";
import { loadingSave } from "util/save";
import type { Ref } from "vue";
import { watch } from "vue";
import player from "./player";
import state from "./state";
let intervalID: NodeJS.Timer | null = null;
// Not imported immediately due to dependency cycles
// This gets set during startGameLoop(), and will only be used in the update function
let hasWon: null | Ref<boolean> = null;
function update() {
const now = Date.now();
let diff = (now - player.time) / 1e3;
@ -43,7 +39,7 @@ function update() {
loadingSave.value = false;
// Add offline time if any
if (player.offlineTime != undefined) {
if (player.offlineTime != null) {
if (Decimal.gt(player.offlineTime, projInfo.offlineLimit * 3600)) {
player.offlineTime = projInfo.offlineLimit * 3600;
}
@ -63,7 +59,7 @@ function update() {
diff = Math.min(diff, projInfo.maxTickLength);
// Apply dev speed
if (player.devSpeed != undefined) {
if (player.devSpeed != null) {
diff *= player.devSpeed;
}
@ -95,15 +91,19 @@ function update() {
/** Starts the game loop for the project, which updates the game in ticks. */
export async function startGameLoop() {
hasWon = (await import("data/projEntry")).hasWon;
watch(hasWon, hasWon => {
if (hasWon) {
globalBus.emit("gameWon");
}
});
if (settings.unthrottled) {
requestAnimationFrame(update);
} else {
intervalID = setInterval(update, 50);
}
}
watch(hasWon, hasWon => {
if (hasWon) {
globalBus.emit("gameWon");
}
});
setInterval(() => {
state.mouseActivity = [...state.mouseActivity.slice(-7), false];
}, 1000 * 60 * 60);

View file

@ -1,4 +1,4 @@
import Modal from "components/Modal.vue";
import Modal from "components/modals/Modal.vue";
import type {
CoercableComponent,
JSXFunction,

View file

@ -1,4 +1,3 @@
import { isArray } from "@vue/shared";
import { globalBus } from "game/events";
import type { GenericLayer } from "game/layers";
import { addingLayers, persistentRefs } from "game/layers";
@ -62,6 +61,8 @@ export type State =
| number
| boolean
| DecimalSource
| null
| undefined
| { [key: string]: State }
| { [key: number]: State };
@ -227,7 +228,7 @@ export function noPersist<T extends Persistent<S>, S extends State>(persistent:
if (key === PersistentState) {
return false;
}
if (key == SkipPersistence) {
if (key === SkipPersistence) {
return true;
}
return Reflect.has(target, key);
@ -279,7 +280,7 @@ globalBus.on("addLayer", (layer: GenericLayer, saveData: Record<string, unknown>
// Handle SaveDataPath
const newPath = [layer.id, ...path, key];
if (
value[SaveDataPath] != undefined &&
value[SaveDataPath] != null &&
JSON.stringify(newPath) !== JSON.stringify(value[SaveDataPath])
) {
console.error(
@ -339,7 +340,7 @@ globalBus.on("addLayer", (layer: GenericLayer, saveData: Record<string, unknown>
// Show warning for persistent values inside arrays
// TODO handle arrays better
if (foundPersistentInChild) {
if (isArray(value) && !isArray(obj)) {
if (Array.isArray(value) && !Array.isArray(obj)) {
console.warn(
"Found array that contains persistent values when adding layer. Keep in mind changing the order of elements in the array will mess with existing player saves.",
ProxyState in obj

View file

@ -36,12 +36,12 @@ export type LayerData<T> = {
[P in keyof T]?: T[P] extends (infer U)[]
? Record<string, LayerData<U>>
: T[P] extends Record<string, never>
? never
: T[P] extends Ref<infer S>
? S
: T[P] extends object
? LayerData<T[P]>
: T[P];
? never
: T[P] extends Ref<infer S>
? S
: T[P] extends object
? LayerData<T[P]>
: T[P];
};
const player = reactive<Player>({
@ -64,7 +64,8 @@ export default window.player = player;
/** Convert a player save data object into a JSON string. Unwraps refs. */
export function stringifySave(player: Player): string {
return JSON.stringify(player, (key, value) => unref(value));
// Convert undefineds into nulls for proper parsing
return JSON.stringify(player, (key, value) => unref(value) ?? null);
}
declare global {

View file

@ -1,4 +1,3 @@
import { isArray } from "@vue/shared";
import {
CoercableComponent,
isVisible,
@ -19,6 +18,7 @@ import {
import { createLazyProxy } from "util/proxies";
import { joinJSX, renderJSX } from "util/vue";
import { computed, unref } from "vue";
import { JSX } from "vue/jsx-runtime";
import Formula, { calculateCost, calculateMaxAffordable } from "./formulas/formulas";
import type { GenericFormula } from "./formulas/types";
import { DefaultValue, Persistent } from "./persistence";
@ -179,7 +179,7 @@ export function createCostRequirement<T extends CostRequirementOptions>(
? calculateCost(
req.cost,
amount ?? 1,
unref(req.cumulativeCost) as boolean,
unref(req.cumulativeCost as ProcessedComputable<boolean>),
unref(req.directSum) as number
)
: unref(req.cost as ProcessedComputable<DecimalSource>);
@ -269,7 +269,7 @@ export function createBooleanRequirement(
* @param requirements The 1+ requirements to check
*/
export function requirementsMet(requirements: Requirements): boolean {
if (isArray(requirements)) {
if (Array.isArray(requirements)) {
return requirements.every(requirementsMet);
}
const reqsMet = unref(requirements.requirementMet);
@ -281,7 +281,7 @@ export function requirementsMet(requirements: Requirements): boolean {
* @param requirements The 1+ requirements to check
*/
export function maxRequirementsMet(requirements: Requirements): DecimalSource {
if (isArray(requirements)) {
if (Array.isArray(requirements)) {
return requirements.map(maxRequirementsMet).reduce(Decimal.min);
}
const reqsMet = unref(requirements.requirementMet);
@ -299,13 +299,13 @@ export function maxRequirementsMet(requirements: Requirements): DecimalSource {
* @param amount The amount of levels earned to be displayed
*/
export function displayRequirements(requirements: Requirements, amount: DecimalSource = 1) {
if (isArray(requirements)) {
if (Array.isArray(requirements)) {
requirements = requirements.filter(r => isVisible(r.visibility));
if (requirements.length === 1) {
requirements = requirements[0];
}
}
if (isArray(requirements)) {
if (Array.isArray(requirements)) {
requirements = requirements.filter(r => "partialDisplay" in r);
const withCosts = requirements.filter(r => unref(r.requiresPay));
const withoutCosts = requirements.filter(r => !unref(r.requiresPay));
@ -343,7 +343,7 @@ export function displayRequirements(requirements: Requirements, amount: DecimalS
* @param amount How many levels to pay for
*/
export function payRequirements(requirements: Requirements, amount: DecimalSource = 1) {
if (isArray(requirements)) {
if (Array.isArray(requirements)) {
requirements.filter(r => unref(r.requiresPay)).forEach(r => r.pay?.(amount));
} else if (unref(requirements.requiresPay)) {
requirements.pay?.(amount);

View file

@ -20,6 +20,8 @@ export interface Settings {
unthrottled: boolean;
/** Whether to align modifiers to the unit. */
alignUnits: boolean;
/** Whether or not to show a video game health warning after playing excessively. */
showHealthWarning: boolean;
}
const state = reactive<Partial<Settings>>({
@ -28,7 +30,8 @@ const state = reactive<Partial<Settings>>({
showTPS: true,
theme: Themes.Nordic,
unthrottled: false,
alignUnits: false
alignUnits: false,
showHealthWarning: true
});
watch(
@ -56,12 +59,15 @@ declare global {
export default window.settings = state as Settings;
/** A function that erases all player settings, including all saves. */
export const hardResetSettings = (window.hardResetSettings = () => {
const settings = {
// Only partial because of any properties that are only added during the loadSettings event.
const settings: Partial<Settings> = {
active: "",
saves: [],
showTPS: true,
theme: Themes.Nordic,
alignUnits: false
unthrottled: false,
alignUnits: false,
showHealthWarning: true
};
globalBus.emit("loadSettings", settings);
Object.assign(state, settings);

View file

@ -6,6 +6,8 @@ import type { Persistent } from "./persistence";
export interface Transient {
/** A list of the duration, in ms, of the last 10 game ticks. Used for calculating TPS. */
lastTenTicks: number[];
/** A list of bools represnting which of the last few hours had mouse activity. */
mouseActivity: boolean[];
/** Whether or not a NaN value has been detected and undealt with. */
hasNaN: boolean;
/** The location within the player save data object of the NaN value. */
@ -25,6 +27,7 @@ declare global {
/** The global transient state object. */
export default window.state = shallowReactive<Transient>({
lastTenTicks: [],
mouseActivity: [false],
hasNaN: false,
NaNPath: [],
errors: reactive([])

View file

@ -70,3 +70,17 @@ ul {
:disabled {
pointer-events: none;
}
.material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
font-size: 24px;
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
}

View file

@ -3,12 +3,14 @@ import App from "App.vue";
import projInfo from "data/projInfo.json";
import "game/notifications";
import state from "game/state";
import "util/galaxy";
import { load } from "util/save";
import { useRegisterSW } from "virtual:pwa-register/vue";
import type { App as VueApp } from "vue";
import { createApp, nextTick } from "vue";
import { useToast } from "vue-toastification";
import "util/galaxy";
import { globalBus } from "./game/events";
import { startGameLoop } from "./game/gameLoop";
declare global {
/**
@ -18,11 +20,6 @@ declare global {
vue: VueApp;
projInfo: typeof projInfo;
}
/** Fix for typedoc treating import functions as taking AssertOptions instead of GlobOptions. */
interface AssertOptions {
as: string;
}
}
const error = console.error;
@ -61,8 +58,6 @@ requestAnimationFrame(async () => {
"padding: 4px;"
);
await load();
const { globalBus } = await import("./game/events");
const { startGameLoop } = await import("./game/gameLoop");
// Create Vue
const vue = (window.vue = createApp(App));
@ -75,33 +70,13 @@ requestAnimationFrame(async () => {
// Setup PWA update prompt
nextTick(() => {
const toast = useToast();
const { updateServiceWorker } = useRegisterSW({
onNeedRefresh() {
toast.info("New content available, click here to update.", {
timeout: false,
closeOnClick: false,
draggable: false,
icon: {
iconClass: "material-icons",
iconChildren: "refresh",
iconTag: "i"
},
rtl: false,
onClick() {
updateServiceWorker();
}
});
},
useRegisterSW({
immediate: true,
onOfflineReady() {
toast.info("App ready to work offline");
},
onRegisterError: console.warn,
onRegistered(r) {
if (r) {
// https://stackoverflow.com/questions/65500916/typeerror-failed-to-execute-update-on-serviceworkerregistration-illegal-in
setInterval(() => r.update(), 60 * 60 * 1000);
}
}
onRegistered: console.info
});
});

View file

@ -16,8 +16,8 @@ export function exponentialFormat(num: DecimalSource, precision: number, mantiss
const eString = e.gte(1e9)
? format(e, Math.max(Math.max(precision, 3), projInfo.defaultDecimalsShown))
: e.gte(10000)
? commaFormat(e, 0)
: e.toStringWithDecimalPlaces(0);
? commaFormat(e, 0)
: e.toStringWithDecimalPlaces(0);
if (mantissa) {
return m.toStringWithDecimalPlaces(precision) + "e" + eString;
} else {
@ -26,7 +26,7 @@ export function exponentialFormat(num: DecimalSource, precision: number, mantiss
}
export function commaFormat(num: DecimalSource, precision: number): string {
if (num === null || num === undefined) {
if (num == null) {
return "NaN";
}
num = new Decimal(num);
@ -36,12 +36,12 @@ export function commaFormat(num: DecimalSource, precision: number): string {
const init = num.toStringWithDecimalPlaces(precision);
const portions = init.split(".");
portions[0] = portions[0].replace(/(\d)(?=(\d\d\d)+(?!\d))/g, "$1,");
if (portions.length == 1) return portions[0];
if (portions.length === 1) return portions[0];
return portions[0] + "." + portions[1];
}
export function regularFormat(num: DecimalSource, precision: number): string {
if (num === null || num === undefined) {
if (num == null) {
return "NaN";
}
num = new Decimal(num);

View file

@ -8,9 +8,8 @@ export type OptionalKeys<T> = {
export type OmitOptional<T> = Pick<T, RequiredKeys<T>>;
export type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
export type ArrayElements<T extends ReadonlyArray<unknown>> = T extends ReadonlyArray<infer S>
? S
: never;
export type ArrayElements<T extends ReadonlyArray<unknown>> =
T extends ReadonlyArray<infer S> ? S : never;
// Reference:
// https://stackoverflow.com/questions/7225407/convert-camelcasetext-to-sentence-case-text
@ -36,5 +35,6 @@ export enum Direction {
Down = "Down",
Left = "Left",
Right = "Right",
// eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values
Default = "Up"
}

View file

@ -10,10 +10,10 @@ export type ProcessedComputable<T> = T | Ref<T>;
export type GetComputableType<T> = T extends { [DoNotCache]: true }
? T
: T extends () => infer S
? Ref<S>
: undefined extends T
? undefined
: T;
? Ref<S>
: undefined extends T
? undefined
: T;
export type GetComputableTypeWithDefault<T, S> = undefined extends T
? S
: GetComputableType<NonNullable<T>>;

View file

@ -1,8 +1,8 @@
import { LoadablePlayerData } from "components/saves/SavesManager.vue";
import { LoadablePlayerData } from "components/modals/SavesManager.vue";
import player, { Player, stringifySave } from "game/player";
import settings from "game/settings";
import { GalaxyApi, initGalaxy } from "unofficial-galaxy-sdk";
import LZString from "lz-string";
import { GalaxyApi, initGalaxy } from "unofficial-galaxy-sdk";
import { ref } from "vue";
import { decodeSave, loadSave, save, setupInitialStore } from "./save";
@ -110,7 +110,7 @@ function syncSaves(
}
availableSlots.delete(cloudSave.slot);
const localSaveId = settings.saves.find(id => id === cloudSave.content.id);
if (localSaveId == undefined) {
if (localSaveId == null) {
settings.saves.push(cloudSave.content.id);
save(setupInitialStore(cloudSave.content));
} else {

View file

@ -5,28 +5,30 @@ 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 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 type Proxied<T> =
NonNullable<T> extends Record<PropertyKey, unknown>
? NonNullable<T> extends Persistent<infer S>
? NonPersistent<S>
: NonNullable<T> extends Decimal
? T
: {
[K in keyof T]: Proxied<T[K]>;
} & {
[ProxyState]: T;
}
: T;
// Takes a function that returns an object and pretends to be that object
// Note that the object is lazily calculated

View file

@ -1,6 +1,8 @@
import { LoadablePlayerData } from "components/saves/SavesManager.vue";
import { LoadablePlayerData } from "components/modals/SavesManager.vue";
import { fixOldSave, getInitialLayers } from "data/projEntry";
import projInfo from "data/projInfo.json";
import { globalBus } from "game/events";
import { addLayer, layers, removeLayer } from "game/layers";
import type { Player } from "game/player";
import player, { stringifySave } from "game/player";
import settings, { loadSettings } from "game/settings";
@ -101,8 +103,6 @@ export const loadingSave = ref(false);
export async function loadSave(playerObj: Partial<Player>): Promise<void> {
console.info("Loading save", playerObj);
loadingSave.value = true;
const { layers, removeLayer, addLayer } = await import("game/layers");
const { fixOldSave, getInitialLayers } = await import("data/projEntry");
for (const layer in layers) {
const l = layers[layer];
@ -119,7 +119,7 @@ export async function loadSave(playerObj: Partial<Player>): Promise<void> {
playerObj.time &&
playerObj.devSpeed !== 0
) {
if (playerObj.offlineTime == undefined) playerObj.offlineTime = 0;
if (playerObj.offlineTime == null) playerObj.offlineTime = 0;
playerObj.offlineTime += Math.min(
playerObj.offlineTime + (Date.now() - playerObj.time) / 1000,
projInfo.offlineLimit * 3600

View file

@ -4,9 +4,9 @@ import type { CoercableComponent, GenericComponent, JSXFunction } from "features
import {
Component as ComponentKey,
GatherProps,
Visibility,
isVisible,
jsx,
Visibility
jsx
} from "features/feature";
import type { ProcessedComputable } from "util/computed";
import { DoNotCache } from "util/computed";
@ -21,6 +21,7 @@ import {
unref,
watchEffect
} from "vue";
import { JSX } from "vue/jsx-runtime";
import { camelToKebab } from "./common";
export function coerceComponent(
@ -191,7 +192,7 @@ export function computeOptionalComponent(
watchEffect(() => {
const currComponent = unwrapRef(component);
comp.value =
currComponent == "" || currComponent == null
currComponent === "" || currComponent == null
? null
: coerceComponent(currComponent, defaultWrapper);
});
@ -244,8 +245,11 @@ export function trackHover(element: VueFeature): Ref<boolean> {
}
export function kebabifyObject(object: Record<string, unknown>) {
return Object.keys(object).reduce((acc, curr) => {
acc[camelToKebab(curr)] = object[curr];
return acc;
}, {} as Record<string, unknown>);
return Object.keys(object).reduce(
(acc, curr) => {
acc[camelToKebab(curr)] = object[curr];
return acc;
},
{} as Record<string, unknown>
);
}

View file

@ -0,0 +1,108 @@
import {
NodePosition,
placeInAvailableSpace,
setupUniqueIds,
unwrapNodeRef
} from "game/boards/board";
import { beforeEach, describe, expect, test } from "vitest";
import { Ref, ref } from "vue";
import "../utils";
import { Direction } from "util/common";
describe("Unwraps node refs", () => {
test("Static value", () => expect(unwrapNodeRef(100, {})).toBe(100));
test("Ref value", () => expect(unwrapNodeRef(ref(100), {})).toBe(100));
test("0 param function value", () => expect(unwrapNodeRef(() => 100, {})).toBe(100));
test("1 param function value", () => {
const actualNode = { foo: "bar" };
expect(
unwrapNodeRef(function (node) {
if (node === actualNode) {
return true;
}
return false;
}, actualNode)
).toBe(true);
});
});
describe("Set up unique IDs", () => {
let nodes: Ref<{ id: number }[]>, nextId: Ref<number>;
beforeEach(() => {
nodes = ref([]);
nextId = setupUniqueIds(nodes);
});
test("Starts at 0", () => expect(nextId?.value).toBe(0));
test("Calculates initial value properly", () => {
nodes.value = [{ id: 0 }, { id: 1 }, { id: 2 }];
expect(nextId.value).toBe(3);
});
test("Non consecutive IDs", () => {
nodes.value = [{ id: -5 }, { id: 0 }, { id: 200 }];
expect(nextId.value).toBe(201);
});
test("After modification", () => {
nodes.value = [{ id: 0 }, { id: 1 }, { id: 2 }];
nodes.value.push({ id: nextId.value });
expect(nextId.value).toBe(4);
});
});
describe("Place in available space", () => {
let nodes: Ref<NodePosition[]>, node: NodePosition;
beforeEach(() => {
nodes = ref([]);
node = { x: 10, y: 20 };
});
test("No nodes", () => {
placeInAvailableSpace(node, nodes.value);
expect(node).toMatchObject({ x: 10, y: 20 });
});
test("Moves node", () => {
nodes.value = [{ x: 10, y: 20 }];
placeInAvailableSpace(node, nodes.value);
expect(node).not.toMatchObject({ x: 10, y: 20 });
});
describe("Respects radius", () => {
test("Positions radius away", () => {
nodes.value = [{ x: 10, y: 20 }];
placeInAvailableSpace(node, nodes.value, 32);
expect(node).toMatchObject({ x: 42, y: 20 });
});
test("Ignores node already radius away", () => {
nodes.value = [{ x: 42, y: 20 }];
placeInAvailableSpace(node, nodes.value, 32);
expect(node).toMatchObject({ x: 10, y: 20 });
});
test("Doesn't ignore node just under radius away", () => {
nodes.value = [{ x: 41, y: 20 }];
placeInAvailableSpace(node, nodes.value, 32);
expect(node).not.toMatchObject({ x: 10, y: 20 });
});
});
describe("Respects direction", () => {
test("Goes left", () => {
nodes.value = [{ x: 10, y: 20 }];
placeInAvailableSpace(node, nodes.value, 10, Direction.Left);
expect(node).toMatchObject({ x: 0, y: 20 });
});
test("Goes up", () => {
nodes.value = [{ x: 10, y: 20 }];
placeInAvailableSpace(node, nodes.value, 10, Direction.Up);
expect(node).toMatchObject({ x: 10, y: 10 });
});
test("Goes down", () => {
nodes.value = [{ x: 10, y: 20 }];
placeInAvailableSpace(node, nodes.value, 10, Direction.Down);
expect(node).toMatchObject({ x: 10, y: 30 });
});
});
test("Finds hole", () => {
nodes.value = [
{ x: 10, y: 20 },
{ x: 30, y: 20 }
];
placeInAvailableSpace(node, nodes.value, 10);
expect(node).toMatchObject({ x: 20, y: 20 });
});
});

View file

@ -0,0 +1,100 @@
import { createHotkey, hotkeys } from "features/hotkey";
import { afterEach, describe, expect, onTestFailed, test } from "vitest";
import { Ref, ref } from "vue";
import "../utils";
function createSuccessHotkey(key: string, triggered: Ref<boolean>) {
hotkeys[key] = createHotkey(() => ({
description: "",
key: key,
onPress: () => (triggered.value = true)
}));
}
function createFailHotkey(key: string) {
hotkeys[key] = createHotkey(() => ({
description: "Fail test",
key,
onPress: () => expect(true).toBe(false)
}));
}
function mockKeypress(key: string, shiftKey = false, ctrlKey = false) {
const event = new KeyboardEvent("keydown", { key, shiftKey, ctrlKey });
expect(document.dispatchEvent(event)).toBe(true);
return event;
}
function testHotkey(pass: string, fail: string, key: string, shiftKey = false, ctrlKey = false) {
const triggered = ref(false);
createSuccessHotkey(pass, triggered);
createFailHotkey(fail);
mockKeypress(key, shiftKey, ctrlKey);
expect(triggered.value).toBe(true);
}
describe("Hotkeys fire correctly", () => {
afterEach(() => {
Object.keys(hotkeys).forEach(key => delete hotkeys[key]);
});
test("Lower case letters", () => testHotkey("a", "A", "a"));
test.each([["A"], ["shift+a"], ["shift+A"]])("Upper case letters using %s as key", key => {
testHotkey(key, "a", "A", true);
});
describe.each([
[0, ")"],
[1, "!"],
[2, "@"],
[3, "#"],
[4, "$"],
[5, "%"],
[6, "^"],
[7, "&"],
[8, "*"],
[9, "("]
])("Handle number %i and it's 'capital', %s", (number, symbol) => {
test("Triggering number", () =>
testHotkey(number.toString(), symbol, number.toString(), true));
test.each([symbol, `shift+${number}`, `shift+${symbol}`])(
"Triggering symbol using %s as key",
key => testHotkey(key, number.toString(), symbol, true)
);
});
test("Ctrl modifier", () => testHotkey("ctrl+a", "a", "a", false, true));
test.each(["shift+ctrl+a", "ctrl+shift+a", "shift+ctrl+A", "ctrl+shift+A"])(
"Shift and Ctrl modifiers using %s as key",
key => {
const triggered = ref(false);
createSuccessHotkey(key, triggered);
createFailHotkey("a");
createFailHotkey("A");
createFailHotkey("shift+A");
createFailHotkey("shift+a");
createFailHotkey("ctrl+a");
createFailHotkey("ctrl+A");
mockKeypress("a", true, true);
expect(triggered.value).toBe(true);
}
);
test.each(["shift+ctrl+1", "ctrl+shift+1", "shift+ctrl+!", "ctrl+shift+!"])(
"Shift and Ctrl modifiers using %s as key",
key => {
const triggered = ref(false);
createSuccessHotkey(key, triggered);
createFailHotkey("1");
createFailHotkey("!");
createFailHotkey("shift+1");
createFailHotkey("shift+!");
createFailHotkey("ctrl+1");
createFailHotkey("ctrl+!");
mockKeypress("!", true, true);
expect(triggered.value).toBe(true);
}
);
});

File diff suppressed because it is too large Load diff

View file

@ -155,7 +155,7 @@ describe("Formula Equality Checking", () => {
describe("Formula aliases", () => {
function testAliases<T extends FormulaFunctions>(
aliases: T[],
args: Parameters<typeof Formula[T]>
args: Parameters<(typeof Formula)[T]>
) {
describe(aliases[0], () => {
let formula: GenericFormula;
@ -250,7 +250,7 @@ describe("Creating Formulas", () => {
function checkFormula<T extends FormulaFunctions>(
functionName: T,
args: Readonly<Parameters<typeof Formula[T]>>
args: Readonly<Parameters<(typeof Formula)[T]>>
) {
let formula: GenericFormula;
beforeAll(() => {
@ -274,7 +274,7 @@ describe("Creating Formulas", () => {
// It's a lot of tests, but I'd rather be exhaustive
function testFormulaCall<T extends FormulaFunctions>(
functionName: T,
args: Readonly<Parameters<typeof Formula[T]>>
args: Readonly<Parameters<(typeof Formula)[T]>>
) {
if ((functionName === "slog" || functionName === "layeradd") && args[0] === -1) {
// These cases in particular take a long time, so skip them
@ -1275,3 +1275,16 @@ describe("Buy Max", () => {
});
});
});
describe("Stringifies", () => {
test("Nested formula", () => {
const variable = Formula.variable(ref(0));
expect(variable.add(5).pow(Formula.constant(10)).stringify()).toBe(
"pow(add(x, 5.00), 10.00)"
);
});
test("Indeterminate", () => {
expect(Formula.if(10, true, f => f.add(5)).stringify()).toBe("indeterminate");
expect(Formula.step(10, 5, f => f.add(5)).stringify()).toBe("indeterminate");
});
});

View file

@ -1,5 +1,5 @@
import { CoercableComponent, JSXFunction } from "features/feature";
import Formula, { printFormula } from "game/formulas/formulas";
import Formula from "game/formulas/formulas";
import {
createAdditiveModifier,
createExponentialModifier,
@ -52,7 +52,7 @@ function testModifiers<
expect(modifier.invert(operation(10, 5))).compare_tolerance(10));
test("getFormula returns the right formula", () => {
const value = ref(10);
expect(printFormula(modifier.getFormula(Formula.variable(value)))).toBe(
expect(modifier.getFormula(Formula.variable(value)).stringify()).toBe(
`${operation.name}(x, 5.00)`
);
});
@ -156,7 +156,7 @@ describe("Sequential Modifiers", () => {
expect(modifier.invert(Decimal.add(10, 5).times(5).pow(5))).compare_tolerance(10));
test("getFormula returns the right formula", () => {
const value = ref(10);
expect(printFormula(modifier.getFormula(Formula.variable(value)))).toBe(
expect(modifier.getFormula(Formula.variable(value)).stringify()).toBe(
`pow(mul(add(x, 5.00), 5.00), 5.00)`
);
});

View file

@ -6,14 +6,11 @@ interface CustomMatchers<R = unknown> {
toLogError(): R;
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Vi {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface Assertion extends CustomMatchers {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface AsymmetricMatchersContaining extends CustomMatchers {}
}
declare module "vitest" {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface Assertion extends CustomMatchers {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface AsymmetricMatchersContaining extends CustomMatchers {}
}
expect.extend({

View file

@ -6,6 +6,7 @@
"strict": true,
"checkJs": false,
"jsx": "preserve",
"jsxImportSource": "vue",
"importHelpers": true,
"moduleResolution": "node",
"resolveJsonModule": true,

View file

@ -31,7 +31,7 @@ export default defineConfig({
}),
tsconfigPaths(),
VitePWA({
includeAssets: ["Logo.svg", "favicon.ico", "robots.txt", "apple-touch-icon.png"],
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}']
},