Merge branch 'feat/update-deps' into feat/board-rewrite
Some checks failed
Run Tests / test (pull_request) Failing after 1m43s

This commit is contained in:
thepaperpilot 2024-11-29 07:52:52 -06:00
commit 99511288c9
90 changed files with 5829 additions and 5075 deletions

1
.eslintignore Normal file
View file

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

View file

@ -5,15 +5,22 @@ module.exports = {
env: { env: {
node: true node: true
}, },
extends: [ parser: '@typescript-eslint/parser',
"plugin:vue/vue3-essential", plugins: ["@typescript-eslint"],
"@vue/eslint-config-typescript/recommended", overrides: [
"@vue/eslint-config-prettier" {
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"], ignorePatterns: ["src/lib"],
rules: { rules: {
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off", "no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
@ -27,6 +34,13 @@ module.exports = {
allowNullableObject: true, allowNullableObject: true,
allowNullableBoolean: true allowNullableBoolean: true
} }
],
"eqeqeq": [
"error",
"always",
{
"null": "never"
}
] ]
}, },
globals: { globals: {

View file

@ -8,6 +8,8 @@ jobs:
build-and-deploy: build-and-deploy:
if: github.repository != 'profectus-engine/Profectus' # Don't build placeholder mod on main repo if: github.repository != 'profectus-engine/Profectus' # Don't build placeholder mod on main repo
runs-on: docker runs-on: docker
container:
image: node:21-bullseye
steps: steps:
- name: Setup RSync - name: Setup RSync
run: | run: |

View file

@ -7,15 +7,14 @@ on:
jobs: jobs:
test: test:
runs-on: docker runs-on: docker
container:
image: node:21-bullseye
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
with: with:
submodules: recursive submodules: recursive
- name: Use Node.js 16.x
uses: actions/setup-node@v3
with:
node-version: 16.x
- run: npm ci - run: npm ci
- run: npm run build --if-present - run: npm run build --if-present
- run: npm test - run: npm test
- run: npm run lint

View file

@ -12,10 +12,11 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
with: with:
submodules: recursive submodules: recursive
- name: Use Node.js 16.x - name: Use Node.js 21.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 16.x node-version: 21.x
- run: npm ci - run: npm ci
- run: npm run build --if-present - run: npm run build --if-present
- run: npm test - run: npm test
- run: npm run lint

View file

@ -1,7 +1,7 @@
{ {
"vitest.commandLine": "npx vitest", "vitest.commandLine": "npx vitest",
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": true "source.fixAll.eslint": "explicit"
}, },
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"git.ignoreLimitWarning": true, "git.ignoreLimitWarning": true,

View file

@ -6,6 +6,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [0.6.1] - 2023-05-17
### Added ### Added
- Error boundaries around each layer, and errors now display on the page when in development - Error boundaries around each layer, and errors now display on the page when in development

31
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,31 @@
# Contributing to Profectus
Thank you for considering contributing to Profectus! We appreciate your interest in improving our project. Please take a moment to review the following guidelines to streamline the contribution process.
## Getting Started
For detailed instructions on setting up local development environment, please refer to the [Setup Guide](https://moddingtree.com/guide/getting-started/setup).
## Issue Reporting
If you encounter a bug or have a suggestion for improvement, please open an issue on Incremental Social. Provide as much detail as possible, including an example repo or steps to reproduce the issue if applicable.
## Contributing
Make sure to open your PR on [Incremental Social](https://code.incremental.social/profectus/Profectus) - the GitHub repo is just a mirror!
### Code Review
All PRs must be reviewed and approved by at least one of the project maintainers before merging. Please be patient during the review process and be open to feedback.
### Testing
Ensure that your changes pass all existing tests and, if applicable, add new tests to cover the changes you've made. Run `npm run test` to run all the tests.
### Code Style
We use ESLint and Prettier to enforce consistent code style throughout the project. Before submitting a PR, run `npm run lint:fix` to automatically fix any linting issues.
## License
By contributing to Profectus, you agree that your contributions will be licensed under the project's [LICENSE](./LICENSE).

View file

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

6738
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -6,8 +6,10 @@
<Nav v-if="useHeader" /> <Nav v-if="useHeader" />
<Game /> <Game />
<TPS v-if="unref(showTPS)" /> <TPS v-if="unref(showTPS)" />
<AddictionWarning />
<GameOverScreen /> <GameOverScreen />
<NaNScreen /> <NaNScreen />
<CloudSaveResolver />
<component :is="gameComponent" /> <component :is="gameComponent" />
</div> </div>
</template> </template>
@ -16,14 +18,16 @@
<script setup lang="tsx"> <script setup lang="tsx">
import "@fontsource/roboto-mono"; import "@fontsource/roboto-mono";
import Error from "components/Error.vue"; import Error from "components/Error.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 { jsx } from "features/feature";
import state from "game/state"; import state from "game/state";
import { coerceComponent, render } from "util/vue"; import { coerceComponent, render } from "util/vue";
import { CSSProperties, watch } from "vue"; import type { CSSProperties } from "vue";
import { computed, toRef, unref } from "vue"; import { computed, toRef, unref } from "vue";
import Game from "./components/Game.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 Nav from "./components/Nav.vue";
import TPS from "./components/TPS.vue"; import TPS from "./components/TPS.vue";
import projInfo from "./data/projInfo.json"; import projInfo from "./data/projInfo.json";

View file

@ -29,14 +29,23 @@ import player from "game/player";
import { computed, toRef, unref } from "vue"; import { computed, toRef, unref } from "vue";
import Layer from "./Layer.vue"; import Layer from "./Layer.vue";
import Nav from "./Nav.vue"; import Nav from "./Nav.vue";
import { deepUnref } from "util/vue";
const tabs = toRef(player, "tabs"); const tabs = toRef(player, "tabs");
const layerKeys = computed(() => Object.keys(layers)); const layerKeys = computed(() => Object.keys(layers));
const useHeader = projInfo.useHeader; const useHeader = projInfo.useHeader;
function gatherLayerProps(layer: GenericLayer) { function gatherLayerProps(layer: GenericLayer) {
const { display, minimized, name, color, minimizable, nodes, minimizedDisplay } = layer; const { display, name, color, minimizable, minimizedDisplay } = deepUnref(layer);
return { display, minimized, name, color, minimizable, nodes, minimizedDisplay }; return {
display,
name,
color,
minimizable,
minimizedDisplay,
minimized: layer.minimized,
nodes: layer.nodes
};
} }
</script> </script>

View file

@ -23,80 +23,48 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import projInfo from "data/projInfo.json"; import projInfo from "data/projInfo.json";
import type { CoercableComponent } from "features/feature"; import type { CoercableComponent } from "features/feature";
import type { FeatureNode } from "game/layers"; import type { FeatureNode } from "game/layers";
import player from "game/player"; import player from "game/player";
import { computeComponent, computeOptionalComponent, processedPropType, unwrapRef } from "util/vue"; import { computeComponent, computeOptionalComponent } from "util/vue";
import { PropType, Ref, computed, defineComponent, onErrorCaptured, ref, toRefs, unref } from "vue"; import { Ref, computed, onErrorCaptured, ref, toRef, unref } from "vue";
import Context from "./Context.vue"; import Context from "./Context.vue";
import ErrorVue from "./Error.vue"; import ErrorVue from "./Error.vue";
export default defineComponent({ const props = defineProps<{
components: { Context, ErrorVue }, index: number;
props: { display: CoercableComponent;
index: { minimizedDisplay?: CoercableComponent;
type: Number, minimized: Ref<boolean>;
required: true name: string;
}, color?: string;
display: { minimizable?: boolean;
type: processedPropType<CoercableComponent>(Object, String, Function), nodes: Ref<Record<string, FeatureNode | undefined>>;
required: true }>();
},
minimizedDisplay: processedPropType<CoercableComponent>(Object, String, Function),
minimized: {
type: Object as PropType<Ref<boolean>>,
required: true
},
name: {
type: processedPropType<string>(String),
required: true
},
color: processedPropType<string>(String),
minimizable: processedPropType<boolean>(Boolean),
nodes: {
type: Object as PropType<Ref<Record<string, FeatureNode | undefined>>>,
required: true
}
},
emits: ["setMinimized"],
setup(props) {
const { display, index, minimized, minimizedDisplay } = toRefs(props);
const component = computeComponent(display); const component = computeComponent(toRef(props, "display"));
const minimizedComponent = computeOptionalComponent(minimizedDisplay); const minimizedComponent = computeOptionalComponent(toRef(props, "minimizedDisplay"));
const showGoBack = computed( const showGoBack = computed(
() => projInfo.allowGoBack && index.value > 0 && !unwrapRef(minimized) () => projInfo.allowGoBack && props.index > 0 && !unref(props.minimized)
); );
function goBack() { function goBack() {
player.tabs.splice(unref(props.index), Infinity); player.tabs.splice(unref(props.index), Infinity);
} }
function updateNodes(nodes: Record<string, FeatureNode | undefined>) { function updateNodes(nodes: Record<string, FeatureNode | undefined>) {
props.nodes.value = nodes; props.nodes.value = nodes;
} }
const errors = ref<Error[]>([]); const errors = ref<Error[]>([]);
onErrorCaptured((err, instance, info) => { onErrorCaptured((err, instance, info) => {
console.warn(`Error caught in "${props.name}" layer`, err, instance, info); console.warn(`Error caught in "${props.name}" layer`, err, instance, info);
errors.value.push( errors.value.push(
err instanceof Error ? (err as Error) : new Error(JSON.stringify(err)) err instanceof Error ? (err as Error) : new Error(JSON.stringify(err))
); );
return false; return false;
});
return {
component,
minimizedComponent,
showGoBack,
updateNodes,
unref,
goBack,
errors
};
}
}); });
</script> </script>

View file

@ -36,7 +36,7 @@
</div> </div>
<div @click="savesManager?.open()"> <div @click="savesManager?.open()">
<Tooltip display="Saves" :direction="Direction.Down" xoffset="-20px"> <Tooltip display="Saves" :direction="Direction.Down" xoffset="-20px">
<span class="material-icons">library_books</span> <span class="material-icons" :class="{ needsSync }">library_books</span>
</Tooltip> </Tooltip>
</div> </div>
<div @click="options?.open()"> <div @click="options?.open()">
@ -53,7 +53,7 @@
</div> </div>
<div @click="savesManager?.open()"> <div @click="savesManager?.open()">
<Tooltip display="Saves" :direction="Direction.Right"> <Tooltip display="Saves" :direction="Direction.Right">
<span class="material-icons">library_books</span> <span class="material-icons" :class="{ needsSync }">library_books</span>
</Tooltip> </Tooltip>
</div> </div>
<div @click="options?.open()"> <div @click="options?.open()">
@ -98,12 +98,14 @@
import Changelog from "data/Changelog.vue"; import Changelog from "data/Changelog.vue";
import projInfo from "data/projInfo.json"; import projInfo from "data/projInfo.json";
import Tooltip from "features/tooltips/Tooltip.vue"; import Tooltip from "features/tooltips/Tooltip.vue";
import settings from "game/settings";
import { Direction } from "util/common"; import { Direction } from "util/common";
import { galaxy, syncedSaves } from "util/galaxy";
import type { ComponentPublicInstance } from "vue"; import type { ComponentPublicInstance } from "vue";
import { ref } from "vue"; import { computed, ref } from "vue";
import Info from "./Info.vue"; import Info from "./modals/Info.vue";
import Options from "./Options.vue"; import Options from "./modals/Options.vue";
import SavesManager from "./SavesManager.vue"; import SavesManager from "./modals/SavesManager.vue";
const info = ref<ComponentPublicInstance<typeof Info> | null>(null); const info = ref<ComponentPublicInstance<typeof Info> | null>(null);
const savesManager = ref<ComponentPublicInstance<typeof SavesManager> | null>(null); const savesManager = ref<ComponentPublicInstance<typeof SavesManager> | null>(null);
@ -117,6 +119,10 @@ const { useHeader, banner, title, discordName, discordLink, versionNumber } = pr
function openDiscord() { function openDiscord() {
window.open(discordLink, "mywindow"); window.open(discordLink, "mywindow");
} }
const needsSync = computed(
() => galaxy.value?.loggedIn === true && !syncedSaves.value.includes(settings.active)
);
</script> </script>
<style scoped> <style scoped>
@ -264,4 +270,32 @@ function openDiscord() {
color: var(--foreground); color: var(--foreground);
text-shadow: none; text-shadow: none;
} }
.needsSync {
color: var(--danger);
animation: 4s wiggle ease infinite;
}
@keyframes wiggle {
0% {
transform: rotate(-3deg);
box-shadow: 0 2px 2px #0003;
}
5% {
transform: rotate(20deg);
}
10% {
transform: rotate(-15deg);
}
15% {
transform: rotate(5deg);
}
20% {
transform: rotate(-1deg);
}
25% {
transform: rotate(0);
box-shadow: 0 2px 2px #0003;
}
}
</style> </style>

View file

@ -4,10 +4,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { RegisterNodeInjectionKey, UnregisterNodeInjectionKey } from "game/layers"; import { RegisterNodeInjectionKey, UnregisterNodeInjectionKey } from "game/layers";
import { computed, inject, onUnmounted, shallowRef, toRefs, unref, watch } from "vue"; import { computed, inject, onUnmounted, shallowRef, toRef, unref, watch } from "vue";
const _props = defineProps<{ id: string }>(); const props = defineProps<{ id: string }>();
const props = toRefs(_props);
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
const register = inject(RegisterNodeInjectionKey, () => {}); const register = inject(RegisterNodeInjectionKey, () => {});
@ -17,7 +16,7 @@ const unregister = inject(UnregisterNodeInjectionKey, () => {});
const node = shallowRef<HTMLElement | null>(null); const node = shallowRef<HTMLElement | null>(null);
const parentNode = computed(() => node.value && node.value.parentElement); const parentNode = computed(() => node.value && node.value.parentElement);
watch([parentNode, props.id], ([newNode, newID], [prevNode, prevID]) => { watch([parentNode, toRef(props, "id")], ([newNode, newID], [prevNode, prevID]) => {
if (prevNode) { if (prevNode) {
unregister(unref(prevID)); unregister(unref(prevID));
} }
@ -26,7 +25,7 @@ watch([parentNode, props.id], ([newNode, newID], [prevNode, prevID]) => {
} }
}); });
onUnmounted(() => unregister(unref(props.id))); onUnmounted(() => unregister(props.id));
</script> </script>
<style scoped> <style scoped>

View file

@ -10,13 +10,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, toRefs, unref, watch } from "vue"; import { ref, watch } from "vue";
const _props = defineProps<{ const props = defineProps<{
disabled?: boolean; disabled?: boolean;
skipConfirm?: boolean; skipConfirm?: boolean;
}>(); }>();
const props = toRefs(_props);
const emit = defineEmits<{ const emit = defineEmits<{
(e: "click"): void; (e: "click"): void;
(e: "confirmingChanged", value: boolean): void; (e: "confirmingChanged", value: boolean): void;
@ -29,7 +29,7 @@ watch(isConfirming, isConfirming => {
}); });
function click() { function click() {
if (unref(props.skipConfirm)) { if (props.skipConfirm) {
emit("click"); emit("click");
return; return;
} }

View file

@ -15,13 +15,13 @@ const emit = defineEmits<{
}>(); }>();
const activated = ref(false); const activated = ref(false);
const activatedTimeout = ref<NodeJS.Timer | null>(null); const activatedTimeout = ref<NodeJS.Timeout | null>(null);
function click() { function click() {
emit("click"); emit("click");
// Give feedback to user // Give feedback to user
if (activatedTimeout.value) { if (activatedTimeout.value != null) {
clearTimeout(activatedTimeout.value); clearTimeout(activatedTimeout.value);
} }
activated.value = false; activated.value = false;

View file

@ -16,8 +16,8 @@
<script setup lang="ts"> <script setup lang="ts">
import "components/common/fields.css"; import "components/common/fields.css";
import type { CoercableComponent } from "features/feature"; import type { CoercableComponent } from "features/feature";
import { computeOptionalComponent, unwrapRef } from "util/vue"; import { computeOptionalComponent } from "util/vue";
import { ref, toRef, watch } from "vue"; import { ref, toRef, unref, watch } from "vue";
import VueNextSelect from "vue-next-select"; import VueNextSelect from "vue-next-select";
import "vue-next-select/dist/index.css"; import "vue-next-select/dist/index.css";
@ -40,7 +40,7 @@ const value = ref<SelectOption | null>(
props.options.find(option => option.value === props.modelValue) ?? null props.options.find(option => option.value === props.modelValue) ?? null
); );
watch(toRef(props, "modelValue"), modelValue => { watch(toRef(props, "modelValue"), modelValue => {
if (unwrapRef(value) !== modelValue) { if (unref(value) !== modelValue) {
value.value = props.options.find(option => option.value === modelValue) ?? null; value.value = props.options.find(option => option.value === modelValue) ?? null;
} }
}); });

View file

@ -11,22 +11,22 @@
import "components/common/fields.css"; import "components/common/fields.css";
import Tooltip from "features/tooltips/Tooltip.vue"; import Tooltip from "features/tooltips/Tooltip.vue";
import { Direction } from "util/common"; import { Direction } from "util/common";
import { computed, toRefs, unref } from "vue"; import { computed } from "vue";
const _props = defineProps<{ const props = defineProps<{
title?: string; title?: string;
modelValue?: number; modelValue?: number;
min?: number; min?: number;
max?: number; max?: number;
}>(); }>();
const props = toRefs(_props);
const emit = defineEmits<{ const emit = defineEmits<{
(e: "update:modelValue", value: number): void; (e: "update:modelValue", value: number): void;
}>(); }>();
const value = computed({ const value = computed({
get() { get() {
return String(unref(props.modelValue) ?? 0); return String(props.modelValue ?? 0);
}, },
set(value: string) { set(value: string) {
emit("update:modelValue", Number(value)); emit("update:modelValue", Number(value));

View file

@ -0,0 +1,84 @@
<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";
import SavesManager from "./SavesManager.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

@ -0,0 +1,228 @@
<template>
<Modal v-model="isOpen" width="960px" ref="modal" :prevent-closing="true">
<template v-slot:header>
<div class="cloud-saves-modal-header">
<h2>Cloud {{ pluralizedSave }} loaded!</h2>
</div>
</template>
<template v-slot:body>
<div>
Upon loading, your cloud {{ pluralizedSave }}
{{ conflictingSaves.length > 1 ? "appear" : "appears" }} to be out of sync with your
local {{ pluralizedSave }}. Which
{{ pluralizedSave }}
do you want to keep?
</div>
<br />
<div
v-for="(conflict, i) in unref(conflictingSaves)"
:key="conflict.id"
class="conflict-container"
>
<div @click="selectCloud(i)" :class="{ selected: selectedSaves[i] === 'cloud' }">
<h2>
Cloud
<span
v-if="(conflict.cloud.time ?? 0) > (conflict.local.time ?? 0)"
class="note"
>(more recent)</span
>
<span
v-if="
(conflict.cloud.timePlayed ?? 0) > (conflict.local.timePlayed ?? 0)
"
class="note"
>(more playtime)</span
>
</h2>
<Save :save="conflict.cloud" :readonly="true" />
</div>
<div @click="selectLocal(i)" :class="{ selected: selectedSaves[i] === 'local' }">
<h2>
Local
<span
v-if="(conflict.cloud.time ?? 0) <= (conflict.local.time ?? 0)"
class="note"
>(more recent)</span
>
<span
v-if="
(conflict.cloud.timePlayed ?? 0) <= (conflict.local.timePlayed ?? 0)
"
class="note"
>(more playtime)</span
>
</h2>
<Save :save="conflict.local" :readonly="true" />
</div>
<div
@click="selectBoth(i)"
:class="{ selected: selectedSaves[i] === 'both' }"
style="flex-basis: 30%"
>
<h2>Both</h2>
<div class="save">Keep Both</div>
</div>
</div>
</template>
<template v-slot:footer>
<div class="cloud-saves-footer">
<button @click="close" class="button">Confirm</button>
</div>
</template>
</Modal>
</template>
<script setup lang="ts">
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);
// True means replacing local save with cloud save
const selectedSaves = ref<("cloud" | "local" | "both")[]>([]);
const pluralizedSave = computed(() => (conflictingSaves.value.length > 1 ? "saves" : "save"));
const modal = ref<ComponentPublicInstance<typeof Modal> | null>(null);
watch(
() => conflictingSaves.value.length > 0,
shouldOpen => {
if (shouldOpen) {
selectedSaves.value = conflictingSaves.value.map(({ local, cloud }) => {
return (local.time ?? 0) < (cloud.time ?? 0) ? "cloud" : "local";
});
isOpen.value = true;
}
},
{ immediate: true }
);
watch(
() => modal.value?.isOpen,
open => {
if (open === false) {
conflictingSaves.value = [];
}
}
);
function selectLocal(index: number) {
selectedSaves.value[index] = "local";
}
function selectCloud(index: number) {
selectedSaves.value[index] = "cloud";
}
function selectBoth(index: number) {
selectedSaves.value[index] = "both";
}
function close() {
for (let i = 0; i < selectedSaves.value.length; i++) {
const { slot, local, cloud } = conflictingSaves.value[i];
switch (selectedSaves.value[i]) {
case "local":
// Replace cloud save with local
galaxy.value
?.save(
slot,
LZString.compressToUTF16(stringifySave(setupInitialStore(local))),
cloud.name
)
.catch(console.error);
break;
case "cloud":
// Replace local save with cloud
save(setupInitialStore(cloud));
break;
case "both":
// Get a new save ID for the cloud save, and sync the local one to the cloud
const id = getUniqueID();
save({ ...setupInitialStore(cloud), id });
settings.saves.push(id);
galaxy.value
?.save(
slot,
LZString.compressToUTF16(stringifySave(setupInitialStore(local))),
cloud.name
)
.catch(console.error);
break;
}
}
isOpen.value = false;
}
</script>
<style scoped>
.cloud-saves-modal-header {
padding: 10px 0;
margin-left: 10px;
}
.cloud-saves-footer {
display: flex;
justify-content: flex-end;
}
.cloud-saves-footer button {
margin: 0 10px;
}
.conflict-container {
display: flex;
}
.conflict-container > * {
flex-basis: 50%;
display: flex;
flex-flow: column;
margin: 0;
}
.conflict-container + .conflict-container {
margin-top: 1em;
}
.conflict-container h2 {
display: flex;
flex-flow: column wrap;
height: 1.5em;
margin: 0;
}
.note {
font-size: x-small;
opacity: 0.7;
margin-right: 1em;
}
.save {
border: solid 4px var(--outline);
padding: 4px;
background: var(--raised-background);
margin: var(--feature-margin);
display: flex;
align-items: center;
min-height: 30px;
height: 100%;
}
</style>
<style>
.conflict-container .save {
cursor: pointer;
}
.conflict-container .selected .save {
border-color: var(--bought);
}
</style>

View file

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

View file

@ -60,7 +60,6 @@
</template> </template>
<script setup lang="tsx"> <script setup lang="tsx">
import Modal from "components/Modal.vue";
import type Changelog from "data/Changelog.vue"; import type Changelog from "data/Changelog.vue";
import projInfo from "data/projInfo.json"; import projInfo from "data/projInfo.json";
import { jsx } from "features/feature"; import { jsx } from "features/feature";
@ -68,12 +67,12 @@ import player from "game/player";
import { infoComponents } from "game/settings"; import { infoComponents } from "game/settings";
import { formatTime } from "util/bignum"; import { formatTime } from "util/bignum";
import { coerceComponent, render } from "util/vue"; import { coerceComponent, render } from "util/vue";
import { computed, ref, toRefs, unref } from "vue"; import { computed, ref } from "vue";
import Modal from "./Modal.vue";
const { title, logo, author, discordName, discordLink, versionNumber, versionTitle } = projInfo; const { title, logo, author, discordName, discordLink, versionNumber, versionTitle } = projInfo;
const _props = defineProps<{ changelog: typeof Changelog | null }>(); const props = defineProps<{ changelog: typeof Changelog | null }>();
const props = toRefs(_props);
const isOpen = ref(false); const isOpen = ref(false);
@ -90,7 +89,7 @@ defineExpose({
}); });
function openChangelog() { function openChangelog() {
unref(props.changelog)?.open(); props.changelog?.open();
} }
</script> </script>

View file

@ -4,6 +4,7 @@
name="modal" name="modal"
@before-enter="isAnimating = true" @before-enter="isAnimating = true"
@after-leave="isAnimating = false" @after-leave="isAnimating = false"
appear
> >
<div <div
class="modal-mask" class="modal-mask"
@ -12,7 +13,7 @@
v-bind="$attrs" v-bind="$attrs"
> >
<div class="modal-wrapper"> <div class="modal-wrapper">
<div class="modal-container"> <div class="modal-container" :width="width">
<div class="modal-header"> <div class="modal-header">
<slot name="header" :shown="isOpen"> default header </slot> <slot name="header" :shown="isOpen"> default header </slot>
</div> </div>
@ -40,20 +41,24 @@
<script setup lang="ts"> <script setup lang="ts">
import type { FeatureNode } from "game/layers"; import type { FeatureNode } from "game/layers";
import { computed, ref, toRefs, unref } from "vue"; import { computed, ref } from "vue";
import Context from "./Context.vue"; import Context from "../Context.vue";
const _props = defineProps<{ const props = defineProps<{
modelValue: boolean; modelValue: boolean;
preventClosing?: boolean;
width?: string;
}>(); }>();
const props = toRefs(_props);
const emit = defineEmits<{ const emit = defineEmits<{
(e: "update:modelValue", value: boolean): void; (e: "update:modelValue", value: boolean): void;
}>(); }>();
const isOpen = computed(() => unref(props.modelValue) || isAnimating.value); const isOpen = computed(() => props.modelValue || isAnimating.value);
function close() { function close() {
emit("update:modelValue", false); if (props.preventClosing !== true) {
emit("update:modelValue", false);
}
} }
const isAnimating = ref(false); const isAnimating = ref(false);

View file

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

View file

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

View file

@ -1,7 +1,7 @@
<template> <template>
<div class="save" :class="{ active: isActive }"> <div class="save" :class="{ active: isActive, readonly }">
<div class="handle material-icons">drag_handle</div> <div class="handle material-icons" v-if="readonly !== true">drag_handle</div>
<div class="actions" v-if="!isEditing"> <div class="actions" v-if="!isEditing && readonly !== true">
<FeedbackButton <FeedbackButton
@click="emit('export')" @click="emit('export')"
class="button" class="button"
@ -40,7 +40,7 @@
</Tooltip> </Tooltip>
</DangerButton> </DangerButton>
</div> </div>
<div class="actions" v-else> <div class="actions" v-else-if="readonly !== true">
<button @click="changeName" class="button"> <button @click="changeName" class="button">
<Tooltip display="Save" :direction="Direction.Left" class="info"> <Tooltip display="Save" :direction="Direction.Left" class="info">
<span class="material-icons">check</span> <span class="material-icons">check</span>
@ -53,12 +53,17 @@
</button> </button>
</div> </div>
<div class="details" v-if="save.error == undefined && !isEditing"> <div class="details" v-if="save.error == undefined && !isEditing">
<button class="button open" @click="emit('open')"> <Tooltip display="Synced!" :direction="Direction.Right" v-if="synced"
><span class="material-icons synced">cloud</span></Tooltip
>
<button class="button open" @click="emit('open')" :disabled="readonly">
<h3>{{ save.name }}</h3> <h3>{{ save.name }}</h3>
</button> </button>
<span class="save-version">v{{ save.modVersion }}</span <span class="save-version">v{{ save.modVersion }}</span
><br /> ><br />
<div v-if="currentTime">Last played {{ dateFormat.format(currentTime) }}</div> <div v-if="currentTime" class="time">
Last played {{ dateFormat.format(currentTime) }}
</div>
</div> </div>
<div class="details" v-else-if="save.error == undefined && isEditing"> <div class="details" v-else-if="save.error == undefined && isEditing">
<Text v-model="newName" class="editname" @submit="changeName" /> <Text v-model="newName" class="editname" @submit="changeName" />
@ -73,16 +78,18 @@
import Tooltip from "features/tooltips/Tooltip.vue"; import Tooltip from "features/tooltips/Tooltip.vue";
import player from "game/player"; import player from "game/player";
import { Direction } from "util/common"; import { Direction } from "util/common";
import { computed, ref, toRefs, watch } from "vue"; import { galaxy, syncedSaves } from "util/galaxy";
import DangerButton from "./fields/DangerButton.vue"; import { computed, ref, watch } from "vue";
import FeedbackButton from "./fields/FeedbackButton.vue"; import DangerButton from "../fields/DangerButton.vue";
import Text from "./fields/Text.vue"; import FeedbackButton from "../fields/FeedbackButton.vue";
import Text from "../fields/Text.vue";
import type { LoadablePlayerData } from "./SavesManager.vue"; import type { LoadablePlayerData } from "./SavesManager.vue";
const _props = defineProps<{ const props = defineProps<{
save: LoadablePlayerData; save: LoadablePlayerData;
readonly?: boolean;
}>(); }>();
const { save } = toRefs(_props);
const emit = defineEmits<{ const emit = defineEmits<{
(e: "export"): void; (e: "export"): void;
(e: "open"): void; (e: "open"): void;
@ -104,11 +111,19 @@ const isEditing = ref(false);
const isConfirming = ref(false); const isConfirming = ref(false);
const newName = ref(""); const newName = ref("");
watch(isEditing, () => (newName.value = save.value.name ?? "")); watch(isEditing, () => (newName.value = props.save.name ?? ""));
const isActive = computed(() => save.value != null && save.value.id === player.id); const isActive = computed(
() => props.save != null && props.save.id === player.id && !props.readonly
);
const currentTime = computed(() => const currentTime = computed(() =>
isActive.value ? player.time : (save.value != null && save.value.time) ?? 0 isActive.value ? player.time : (props.save != null && props.save.time) ?? 0
);
const synced = computed(
() =>
!props.readonly &&
galaxy.value?.loggedIn === true &&
syncedSaves.value.includes(props.save.id)
); );
function changeName() { function changeName() {
@ -139,6 +154,13 @@ function changeName() {
padding-left: 0; padding-left: 0;
} }
.open:disabled {
cursor: inherit;
color: var(--foreground);
opacity: 1;
pointer-events: none;
}
.handle { .handle {
flex-grow: 0; flex-grow: 0;
margin-right: 8px; margin-right: 8px;
@ -152,6 +174,10 @@ function changeName() {
margin-right: 80px; margin-right: 80px;
} }
.save.readonly .details {
margin-right: 0;
}
.error { .error {
font-size: 0.8em; font-size: 0.8em;
color: var(--danger); color: var(--danger);
@ -176,6 +202,17 @@ function changeName() {
.editname { .editname {
margin: 0; margin: 0;
} }
.time {
font-size: small;
}
.synced {
font-size: 100%;
margin-right: 0.5em;
vertical-align: middle;
cursor: default;
}
</style> </style>
<style> <style>
@ -201,4 +238,8 @@ function changeName() {
.save .field { .save .field {
margin: 0; margin: 0;
} }
.details > .tooltip-container {
display: inline;
}
</style> </style>

View file

@ -4,6 +4,9 @@
<h2>Saves Manager</h2> <h2>Saves Manager</h2>
</template> </template>
<template #body="{ shown }"> <template #body="{ shown }">
<div v-if="showNotSyncedWarning" style="color: var(--danger)">
Not all saves are synced! You may need to delete stale saves.
</div>
<Draggable <Draggable
:list="settings.saves" :list="settings.saves"
handle=".handle" handle=".handle"
@ -57,18 +60,28 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import Modal from "components/Modal.vue";
import projInfo from "data/projInfo.json"; import projInfo from "data/projInfo.json";
import type { Player } from "game/player"; import type { Player } from "game/player";
import player, { stringifySave } from "game/player"; import player, { stringifySave } from "game/player";
import settings from "game/settings"; import settings from "game/settings";
import LZString from "lz-string"; import LZString from "lz-string";
import { getUniqueID, loadSave, newSave, save } from "util/save"; import { galaxy, syncedSaves } from "util/galaxy";
import {
clearCachedSave,
clearCachedSaves,
decodeSave,
getCachedSave,
getUniqueID,
loadSave,
newSave,
save
} from "util/save";
import type { ComponentPublicInstance } from "vue"; import type { ComponentPublicInstance } from "vue";
import { computed, nextTick, ref, shallowReactive, watch } from "vue"; import { computed, nextTick, ref, watch } from "vue";
import Draggable from "vuedraggable"; import Draggable from "vuedraggable";
import Select from "./fields/Select.vue"; import Select from "../fields/Select.vue";
import Text from "./fields/Text.vue"; import Text from "../fields/Text.vue";
import Modal from "./Modal.vue";
import Save from "./Save.vue"; import Save from "./Save.vue";
export type LoadablePlayerData = Omit<Partial<Player>, "id"> & { id: string; error?: unknown }; export type LoadablePlayerData = Omit<Partial<Player>, "id"> & { id: string; error?: unknown };
@ -90,16 +103,8 @@ watch(saveToImport, importedSave => {
if (importedSave) { if (importedSave) {
nextTick(() => { nextTick(() => {
try { try {
if (importedSave[0] === "{") { importedSave = decodeSave(importedSave) ?? "";
// plaintext. No processing needed if (importedSave === "") {
} else if (importedSave[0] === "e") {
// Assumed to be base64, which starts with e
importedSave = decodeURIComponent(escape(atob(importedSave)));
} else if (importedSave[0] === "ᯡ") {
// Assumed to be lz, which starts with
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
importedSave = LZString.decompressFromUTF16(importedSave)!;
} else {
console.warn("Unable to determine preset encoding", importedSave); console.warn("Unable to determine preset encoding", importedSave);
importingFailed.value = true; importingFailed.value = true;
return; return;
@ -125,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( let bank = ref(
Object.keys(bankContext).reduce((acc: Array<{ label: string; value: string }>, curr) => { Object.keys(bankContext).reduce((acc: Array<{ label: string; value: string }>, curr) => {
acc.push({ acc.push({
@ -139,48 +144,10 @@ let bank = ref(
}, []) }, [])
); );
const cachedSaves = shallowReactive<Record<string, LoadablePlayerData | undefined>>({});
function getCachedSave(id: string) {
if (cachedSaves[id] == null) {
let save = localStorage.getItem(id);
if (save == null) {
cachedSaves[id] = { error: `Save doesn't exist in localStorage`, id };
} else if (save === "dW5kZWZpbmVk") {
cachedSaves[id] = { error: `Save is undefined`, id };
} else {
try {
if (save[0] === "{") {
// plaintext. No processing needed
} else if (save[0] === "e") {
// Assumed to be base64, which starts with e
save = decodeURIComponent(escape(atob(save)));
} else if (save[0] === "ᯡ") {
// Assumed to be lz, which starts with
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
save = LZString.decompressFromUTF16(save)!;
} else {
console.warn("Unable to determine preset encoding", save);
importingFailed.value = true;
cachedSaves[id] = { error: "Unable to determine preset encoding", id };
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return cachedSaves[id]!;
}
cachedSaves[id] = { ...JSON.parse(save), id };
} catch (error) {
cachedSaves[id] = { error, id };
console.warn(
`SavesManager: Failed to load info about save with id ${id}:\n${error}\n${save}`
);
}
}
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return cachedSaves[id]!;
}
// Wipe cache whenever the modal is opened // Wipe cache whenever the modal is opened
watch(isOpen, isOpen => { watch(isOpen, isOpen => {
if (isOpen) { if (isOpen) {
Object.keys(cachedSaves).forEach(key => delete cachedSaves[key]); clearCachedSaves();
} }
}); });
@ -191,6 +158,10 @@ const saves = computed(() =>
}, {}) }, {})
); );
const showNotSyncedWarning = computed(
() => galaxy.value?.loggedIn === true && settings.saves.length < syncedSaves.value.length
);
function exportSave(id: string) { function exportSave(id: string) {
let saveToExport; let saveToExport;
if (player.id === id) { if (player.id === id) {
@ -233,20 +204,37 @@ function duplicateSave(id: string) {
} }
function deleteSave(id: string) { function deleteSave(id: string) {
if (galaxy.value?.loggedIn === true) {
galaxy.value.getSaveList().then(list => {
const slot = Object.keys(list).find(slot => {
const content = list[slot as unknown as number].content;
try {
if (JSON.parse(content).id === id) {
return true;
}
} catch (e) {
return false;
}
});
if (slot != null) {
galaxy.value?.save(parseInt(slot), "", "").catch(console.error);
}
});
}
settings.saves = settings.saves.filter((save: string) => save !== id); settings.saves = settings.saves.filter((save: string) => save !== id);
localStorage.removeItem(id); localStorage.removeItem(id);
cachedSaves[id] = undefined; clearCachedSave(id);
} }
function openSave(id: string) { function openSave(id: string) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
saves.value[player.id]!.time = player.time; saves.value[player.id]!.time = player.time;
save(); save();
cachedSaves[player.id] = undefined; clearCachedSave(player.id);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
loadSave(saves.value[id]!); loadSave(saves.value[id]!);
// Delete cached version in case of opening it again // Delete cached version in case of opening it again
cachedSaves[id] = undefined; clearCachedSave(id);
} }
function newFromPreset(preset: string) { function newFromPreset(preset: string) {
@ -256,16 +244,8 @@ function newFromPreset(preset: string) {
selectedPreset.value = null; selectedPreset.value = null;
}); });
if (preset[0] === "{") { preset = decodeSave(preset) ?? "";
// plaintext. No processing needed if (preset === "") {
} else if (preset[0] === "e") {
// Assumed to be base64, which starts with e
preset = decodeURIComponent(escape(atob(preset)));
} else if (preset[0] === "ᯡ") {
// Assumed to be lz, which starts with
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
preset = LZString.decompressFromUTF16(preset)!;
} else {
console.warn("Unable to determine preset encoding", preset); console.warn("Unable to determine preset encoding", preset);
return; return;
} }
@ -287,7 +267,7 @@ function editSave(id: string, newName: string) {
save(); save();
} else { } else {
save(currSave as Player); save(currSave as Player);
cachedSaves[id] = undefined; clearCachedSave(id);
} }
} }
} }

View file

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

View file

@ -128,7 +128,7 @@ export function createResetButton<T extends ClickableOptions & ResetButtonOption
)} )}
</b>{" "} </b>{" "}
{resetButton.conversion.gainResource.displayName} {resetButton.conversion.gainResource.displayName}
{unref(resetButton.showNextAt) != null ? ( {unref(resetButton.showNextAt as ProcessedComputable<boolean>) != null ? (
<div> <div>
<br /> <br />
{unref(resetButton.conversion.buyMax) ? "Next:" : "Req:"}{" "} {unref(resetButton.conversion.buyMax) ? "Next:" : "Req:"}{" "}

View file

@ -88,6 +88,10 @@
"type": "string", "type": "string",
"enum": ["base64", "lz", "plain"], "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." "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, "maxTickLength": 3600,
"offlineLimit": 1, "offlineLimit": 1,
"enablePausing": true, "enablePausing": true,
"exportEncoding": "base64" "exportEncoding": "base64",
"disableHealthWarning": false
} }

View file

@ -23,89 +23,62 @@
</div> </div>
</template> </template>
<script lang="tsx"> <script setup lang="tsx">
import "components/common/features.css"; import "components/common/features.css";
import MarkNode from "components/MarkNode.vue";
import Node from "components/Node.vue";
import { isHidden, isVisible, jsx, Visibility } from "features/feature"; import { isHidden, isVisible, jsx, Visibility } from "features/feature";
import { displayRequirements, Requirements } from "game/requirements"; import { displayRequirements, Requirements } from "game/requirements";
import { coerceComponent, isCoercableComponent, processedPropType, unwrapRef } from "util/vue"; import { coerceComponent, isCoercableComponent } from "util/vue";
import { Component, defineComponent, shallowRef, StyleValue, toRefs, unref, UnwrapRef, watchEffect } from "vue"; import { Component, shallowRef, StyleValue, unref, UnwrapRef, watchEffect } from "vue";
import { GenericAchievement } from "./achievement"; import { GenericAchievement } from "./achievement";
export default defineComponent({ const props = defineProps<{
props: { visibility: Visibility | boolean;
visibility: { display?: UnwrapRef<GenericAchievement["display"]>;
type: processedPropType<Visibility | boolean>(Number, Boolean), earned: boolean;
required: true requirements?: Requirements;
}, image?: string;
display: processedPropType<UnwrapRef<GenericAchievement["display"]>>(Object, String, Function), style?: StyleValue;
earned: { classes?: Record<string, boolean>;
type: processedPropType<boolean>(Boolean), mark?: boolean | string;
required: true small?: boolean;
}, id: string;
requirements: processedPropType<Requirements>(Object, Array), }>();
image: processedPropType<string>(String),
style: processedPropType<StyleValue>(String, Object, Array),
classes: processedPropType<Record<string, boolean>>(Object),
mark: processedPropType<boolean | string>(Boolean, String),
small: processedPropType<boolean>(Boolean),
id: {
type: String,
required: true
}
},
components: {
Node,
MarkNode
},
setup(props) {
const { display, requirements, earned } = toRefs(props);
const comp = shallowRef<Component | string>(""); const comp = shallowRef<Component | string>("");
watchEffect(() => { watchEffect(() => {
const currDisplay = unwrapRef(display); const currDisplay = props.display;
if (currDisplay == null) { if (currDisplay == null) {
comp.value = ""; comp.value = "";
return; return;
}
if (isCoercableComponent(currDisplay)) {
comp.value = coerceComponent(currDisplay);
return;
}
const Requirement = coerceComponent(currDisplay.requirement ? currDisplay.requirement : jsx(() => displayRequirements(unwrapRef(requirements) ?? [])), "h3");
const EffectDisplay = coerceComponent(currDisplay.effectDisplay || "", "b");
const OptionsDisplay = unwrapRef(earned) ?
coerceComponent(currDisplay.optionsDisplay || "", "span") :
"";
comp.value = coerceComponent(
jsx(() => (
<span>
<Requirement />
{currDisplay.effectDisplay != null ? (
<div>
<EffectDisplay />
</div>
) : null}
{currDisplay.optionsDisplay != null ? (
<div class="equal-spaced">
<OptionsDisplay />
</div>
) : null}
</span>
))
);
});
return {
comp,
unref,
Visibility,
isVisible,
isHidden
};
} }
if (isCoercableComponent(currDisplay)) {
comp.value = coerceComponent(currDisplay);
return;
}
const Requirement = coerceComponent(currDisplay.requirement ? currDisplay.requirement :
jsx(() => displayRequirements(props.requirements ?? [])), "h3");
const EffectDisplay = coerceComponent(currDisplay.effectDisplay || "", "b");
const OptionsDisplay = props.earned ?
coerceComponent(currDisplay.optionsDisplay || "", "span") :
"";
comp.value = coerceComponent(
jsx(() => (
<span>
<Requirement />
{currDisplay.effectDisplay != null ? (
<div>
<EffectDisplay />
</div>
) : null}
{currDisplay.optionsDisplay != null ? (
<div class="equal-spaced">
<OptionsDisplay />
</div>
) : null}
</span>
))
);
}); });
</script> </script>

View file

@ -1,5 +1,4 @@
import { computed } from "@vue/reactivity"; import { computed } from "vue";
import { isArray } from "@vue/shared";
import Select from "components/fields/Select.vue"; import Select from "components/fields/Select.vue";
import AchievementComponent from "features/achievements/Achievement.vue"; import AchievementComponent from "features/achievements/Achievement.vue";
import { GenericDecorator } from "features/decorators/common"; import { GenericDecorator } from "features/decorators/common";
@ -208,7 +207,7 @@ export function createAchievement<T extends AchievementOptions>(
unref(achievement.earned) && unref(achievement.earned) &&
!( !(
display != null && display != null &&
typeof display == "object" && typeof display === "object" &&
"optionsDisplay" in (display as Record<string, unknown>) "optionsDisplay" in (display as Record<string, unknown>)
) )
) { ) {
@ -275,7 +274,7 @@ export function createAchievement<T extends AchievementOptions>(
const requirements = [ const requirements = [
createVisibilityRequirement(genericAchievement), createVisibilityRequirement(genericAchievement),
createBooleanRequirement(() => !genericAchievement.earned.value), createBooleanRequirement(() => !genericAchievement.earned.value),
...(isArray(achievement.requirements) ...(Array.isArray(achievement.requirements)
? achievement.requirements ? achievement.requirements
: [achievement.requirements]) : [achievement.requirements])
]; ];
@ -306,18 +305,20 @@ const msDisplayOptions = Object.values(AchievementDisplay).map(option => ({
value: option value: option
})); }));
registerSettingField( globalBus.on("setupVue", () =>
jsx(() => ( registerSettingField(
<Select jsx(() => (
title={jsx(() => ( <Select
<span class="option-title"> title={jsx(() => (
Show achievements <span class="option-title">
<desc>Select which achievements to display based on criterias.</desc> Show achievements
</span> <desc>Select which achievements to display based on criterias.</desc>
))} </span>
options={msDisplayOptions} ))}
onUpdate:modelValue={value => (settings.msDisplay = value as AchievementDisplay)} options={msDisplayOptions}
modelValue={settings.msDisplay} 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 ClickableComponent from "features/clickables/Clickable.vue";
import { import {
Component, Component,
@ -31,7 +30,7 @@ import { coerceComponent, isCoercableComponent, render } from "util/vue";
import { computed, Ref, ref, unref } from "vue"; import { computed, Ref, ref, unref } from "vue";
import { BarOptions, createBar, GenericBar } from "./bars/bar"; import { BarOptions, createBar, GenericBar } from "./bars/bar";
import { ClickableOptions } from "./clickables/clickable"; import { ClickableOptions } from "./clickables/clickable";
import { Decorator, GenericDecorator } from "./decorators/common"; import { GenericDecorator } from "./decorators/common";
/** A symbol used to identify {@link Action} features. */ /** A symbol used to identify {@link Action} features. */
export const ActionType = Symbol("Action"); export const ActionType = Symbol("Action");
@ -157,7 +156,7 @@ export function createAction<T extends ActionOptions>(
} }
]; ];
const originalStyle = unref(style); const originalStyle = unref(style);
if (isArray(originalStyle)) { if (Array.isArray(originalStyle)) {
currStyle.push(...originalStyle); currStyle.push(...originalStyle);
} else if (originalStyle != null) { } else if (originalStyle != null) {
currStyle.push(originalStyle); currStyle.push(originalStyle);
@ -219,7 +218,7 @@ export function createAction<T extends ActionOptions>(
const onClick = action.onClick.bind(action); const onClick = action.onClick.bind(action);
action.onClick = function () { action.onClick = function () {
if (unref(action.canClick) === false) { if (unref(action.canClick as ProcessedComputable<boolean>) === false) {
return; return;
} }
const amount = Decimal.div(progress.value, unref(genericAction.duration)); const amount = Decimal.div(progress.value, unref(genericAction.duration));

View file

@ -41,107 +41,68 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import MarkNode from "components/MarkNode.vue";
import Node from "components/Node.vue";
import { CoercableComponent, isHidden, isVisible, Visibility } from "features/feature"; import { CoercableComponent, isHidden, isVisible, Visibility } from "features/feature";
import type { DecimalSource } from "util/bignum"; import type { DecimalSource } from "util/bignum";
import Decimal from "util/bignum"; import Decimal from "util/bignum";
import { Direction } from "util/common"; import { Direction } from "util/common";
import { computeOptionalComponent, processedPropType, unwrapRef } from "util/vue"; import { computeOptionalComponent } from "util/vue";
import type { CSSProperties, StyleValue } from "vue"; import type { CSSProperties, StyleValue } from "vue";
import { computed, defineComponent, toRefs, unref } from "vue"; import { computed, toRef, unref } from "vue";
export default defineComponent({ const props = defineProps<{
props: { progress: DecimalSource;
progress: { width: number;
type: processedPropType<DecimalSource>(String, Object, Number), height: number;
required: true direction: Direction;
}, display?: CoercableComponent;
width: { visibility: Visibility | boolean;
type: processedPropType<number>(Number), style?: StyleValue;
required: true classes?: Record<string, boolean>;
}, borderStyle?: StyleValue;
height: { textStyle?: StyleValue;
type: processedPropType<number>(Number), baseStyle?: StyleValue;
required: true fillStyle?: StyleValue;
}, mark?: boolean | string;
direction: { id: string;
type: processedPropType<Direction>(String), }>();
required: true
},
display: processedPropType<CoercableComponent>(Object, String, Function),
visibility: {
type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true
},
style: processedPropType<StyleValue>(Object, String, Array),
classes: processedPropType<Record<string, boolean>>(Object),
borderStyle: processedPropType<StyleValue>(Object, String, Array),
textStyle: processedPropType<StyleValue>(Object, String, Array),
baseStyle: processedPropType<StyleValue>(Object, String, Array),
fillStyle: processedPropType<StyleValue>(Object, String, Array),
mark: processedPropType<boolean | string>(Boolean, String),
id: {
type: String,
required: true
}
},
components: {
MarkNode,
Node
},
setup(props) {
const { progress, width, height, direction, display } = toRefs(props);
const normalizedProgress = computed(() => { const normalizedProgress = computed(() => {
let progressNumber = let progressNumber =
progress.value instanceof Decimal props.progress instanceof Decimal
? progress.value.toNumber() ? props.progress.toNumber()
: Number(progress.value); : Number(props.progress);
return (1 - Math.min(Math.max(progressNumber, 0), 1)) * 100; return (1 - Math.min(Math.max(progressNumber, 0), 1)) * 100;
});
const barStyle = computed(() => {
const barStyle: Partial<CSSProperties> = {
width: unwrapRef(width) + 0.5 + "px",
height: unwrapRef(height) + 0.5 + "px"
};
switch (unref(direction)) {
case Direction.Up:
barStyle.clipPath = `inset(${normalizedProgress.value}% 0% 0% 0%)`;
barStyle.width = unwrapRef(width) + 1 + "px";
break;
case Direction.Down:
barStyle.clipPath = `inset(0% 0% ${normalizedProgress.value}% 0%)`;
barStyle.width = unwrapRef(width) + 1 + "px";
break;
case Direction.Right:
barStyle.clipPath = `inset(0% ${normalizedProgress.value}% 0% 0%)`;
break;
case Direction.Left:
barStyle.clipPath = `inset(0% 0% 0% ${normalizedProgress.value}%)`;
break;
case Direction.Default:
barStyle.clipPath = "inset(0% 50% 0% 0%)";
break;
}
return barStyle;
});
const component = computeOptionalComponent(display);
return {
normalizedProgress,
barStyle,
component,
unref,
Visibility,
isVisible,
isHidden
};
}
}); });
const barStyle = computed(() => {
const barStyle: Partial<CSSProperties> = {
width: props.width + 0.5 + "px",
height: props.height + 0.5 + "px"
};
switch (props.direction) {
case Direction.Up:
barStyle.clipPath = `inset(${normalizedProgress.value}% 0% 0% 0%)`;
barStyle.width = props.width + 1 + "px";
break;
case Direction.Down:
barStyle.clipPath = `inset(0% 0% ${normalizedProgress.value}% 0%)`;
barStyle.width = props.width + 1 + "px";
break;
case Direction.Right:
barStyle.clipPath = `inset(0% ${normalizedProgress.value}% 0% 0%)`;
break;
case Direction.Left:
barStyle.clipPath = `inset(0% 0% 0% ${normalizedProgress.value}%)`;
break;
case Direction.Default:
barStyle.clipPath = "inset(0% 50% 0% 0%)";
break;
}
return barStyle;
});
const component = computeOptionalComponent(toRef(props, "display"));
</script> </script>
<style scoped> <style scoped>

View file

@ -30,7 +30,7 @@
</div> </div>
</template> </template>
<script lang="tsx"> <script setup lang="tsx">
import "components/common/features.css"; import "components/common/features.css";
import MarkNode from "components/MarkNode.vue"; import MarkNode from "components/MarkNode.vue";
import Node from "components/Node.vue"; import Node from "components/Node.vue";
@ -39,139 +39,92 @@ import type { StyleValue } from "features/feature";
import { isHidden, isVisible, jsx, Visibility } from "features/feature"; import { isHidden, isVisible, jsx, Visibility } from "features/feature";
import { getHighNotifyStyle, getNotifyStyle } from "game/notifications"; import { getHighNotifyStyle, getNotifyStyle } from "game/notifications";
import { displayRequirements, Requirements } from "game/requirements"; import { displayRequirements, Requirements } from "game/requirements";
import { coerceComponent, isCoercableComponent, processedPropType, unwrapRef } from "util/vue"; import { coerceComponent, isCoercableComponent } from "util/vue";
import type { Component, PropType, UnwrapRef } from "vue"; import type { Component, UnwrapRef } from "vue";
import { computed, defineComponent, shallowRef, toRefs, unref, watchEffect } from "vue"; import { computed, shallowRef, unref, watchEffect } from "vue";
export default defineComponent({ const props = defineProps<{
props: { active: boolean;
active: { maxed: boolean;
type: processedPropType<boolean>(Boolean), canComplete: boolean;
required: true display?: UnwrapRef<GenericChallenge["display"]>;
}, requirements?: Requirements;
maxed: { visibility: Visibility | boolean;
type: processedPropType<boolean>(Boolean), style?: StyleValue;
required: true classes?: Record<string, boolean>;
}, completed: boolean;
canComplete: { canStart: boolean;
type: processedPropType<boolean>(Boolean), mark?: boolean | string;
required: true id: string;
}, toggle: VoidFunction;
display: processedPropType<UnwrapRef<GenericChallenge["display"]>>( }>();
String,
Object,
Function
),
requirements: processedPropType<Requirements>(Object, Array),
visibility: {
type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true
},
style: processedPropType<StyleValue>(String, Object, Array),
classes: processedPropType<Record<string, boolean>>(Object),
completed: {
type: processedPropType<boolean>(Boolean),
required: true
},
canStart: {
type: processedPropType<boolean>(Boolean),
required: true
},
mark: processedPropType<boolean | string>(Boolean, String),
id: {
type: String,
required: true
},
toggle: {
type: Function as PropType<VoidFunction>,
required: true
}
},
components: {
MarkNode,
Node
},
setup(props) {
const { active, maxed, canComplete, display, requirements } = toRefs(props);
const buttonText = computed(() => { const buttonText = computed(() => {
if (active.value) { if (props.active) {
return canComplete.value ? "Finish" : "Exit Early"; return props.canComplete ? "Finish" : "Exit Early";
}
if (maxed.value) {
return "Completed";
}
return "Start";
});
const comp = shallowRef<Component | string>("");
const notifyStyle = computed(() => {
const currActive = unwrapRef(active);
const currCanComplete = unwrapRef(canComplete);
if (currActive) {
if (currCanComplete) {
return getHighNotifyStyle();
}
return getNotifyStyle();
}
return {};
});
watchEffect(() => {
const currDisplay = unwrapRef(display);
if (currDisplay == null) {
comp.value = "";
return;
}
if (isCoercableComponent(currDisplay)) {
comp.value = coerceComponent(currDisplay);
return;
}
const Title = coerceComponent(currDisplay.title || "", "h3");
const Description = coerceComponent(currDisplay.description, "div");
const Goal = coerceComponent(currDisplay.goal != null ? currDisplay.goal : jsx(() => displayRequirements(unwrapRef(requirements) ?? [])), "h3");
const Reward = coerceComponent(currDisplay.reward || "");
const EffectDisplay = coerceComponent(currDisplay.effectDisplay || "");
comp.value = coerceComponent(
jsx(() => (
<span>
{currDisplay.title != null ? (
<div>
<Title />
</div>
) : null}
<Description />
<div>
<br />
Goal: <Goal />
</div>
{currDisplay.reward != null ? (
<div>
<br />
Reward: <Reward />
</div>
) : null}
{currDisplay.effectDisplay != null ? (
<div>
Currently: <EffectDisplay />
</div>
) : null}
</span>
))
);
});
return {
buttonText,
notifyStyle,
comp,
Visibility,
isVisible,
isHidden,
unref
};
} }
if (props.maxed) {
return "Completed";
}
return "Start";
});
const comp = shallowRef<Component | string>("");
const notifyStyle = computed(() => {
const currActive = props.active;
const currCanComplete = props.canComplete;
if (currActive) {
if (currCanComplete) {
return getHighNotifyStyle();
}
return getNotifyStyle();
}
return {};
});
watchEffect(() => {
const currDisplay = props.display;
if (currDisplay == null) {
comp.value = "";
return;
}
if (isCoercableComponent(currDisplay)) {
comp.value = coerceComponent(currDisplay);
return;
}
const Title = coerceComponent(currDisplay.title || "", "h3");
const Description = coerceComponent(currDisplay.description, "div");
const Goal = coerceComponent(currDisplay.goal != null ? currDisplay.goal : jsx(() => displayRequirements(props.requirements ?? [])), "h3");
const Reward = coerceComponent(currDisplay.reward || "");
const EffectDisplay = coerceComponent(currDisplay.effectDisplay || "");
comp.value = coerceComponent(
jsx(() => (
<span>
{currDisplay.title != null ? (
<div>
<Title />
</div>
) : null}
<Description />
<div>
<br />
Goal: <Goal />
</div>
{currDisplay.reward != null ? (
<div>
<br />
Reward: <Reward />
</div>
) : null}
{currDisplay.effectDisplay != null ? (
<div>
Currently: <EffectDisplay />
</div>
) : null}
</span>
))
);
}); });
</script> </script>

View file

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

View file

@ -27,7 +27,7 @@
</button> </button>
</template> </template>
<script lang="tsx"> <script setup lang="tsx">
import "components/common/features.css"; import "components/common/features.css";
import MarkNode from "components/MarkNode.vue"; import MarkNode from "components/MarkNode.vue";
import Node from "components/Node.vue"; import Node from "components/Node.vue";
@ -37,90 +37,53 @@ import { isHidden, isVisible, jsx, Visibility } from "features/feature";
import { import {
coerceComponent, coerceComponent,
isCoercableComponent, isCoercableComponent,
processedPropType, setupHoldToClick
setupHoldToClick,
unwrapRef
} from "util/vue"; } from "util/vue";
import type { Component, PropType, UnwrapRef } from "vue"; import type { Component, UnwrapRef } from "vue";
import { defineComponent, shallowRef, toRefs, unref, watchEffect } from "vue"; import { shallowRef, toRef, unref, watchEffect } from "vue";
export default defineComponent({ const props = defineProps<{
props: { display: UnwrapRef<GenericClickable["display"]>;
display: { visibility: Visibility | boolean;
type: processedPropType<UnwrapRef<GenericClickable["display"]>>( style?: StyleValue;
Object, classes?: Record<string, boolean>;
String, onClick?: (e?: MouseEvent | TouchEvent) => void;
Function onHold?: VoidFunction;
), canClick: boolean;
required: true small?: boolean;
}, mark?: boolean | string;
visibility: { id: string;
type: processedPropType<Visibility | boolean>(Number, Boolean), }>();
required: true
},
style: processedPropType<StyleValue>(Object, String, Array),
classes: processedPropType<Record<string, boolean>>(Object),
onClick: Function as PropType<(e?: MouseEvent | TouchEvent) => void>,
onHold: Function as PropType<VoidFunction>,
canClick: {
type: processedPropType<boolean>(Boolean),
required: true
},
small: Boolean,
mark: processedPropType<boolean | string>(Boolean, String),
id: {
type: String,
required: true
}
},
components: {
Node,
MarkNode
},
setup(props) {
const { display, onClick, onHold } = toRefs(props);
const comp = shallowRef<Component | string>(""); const comp = shallowRef<Component | string>("");
watchEffect(() => { watchEffect(() => {
const currDisplay = unwrapRef(display); const currDisplay = props.display;
if (currDisplay == null) { if (currDisplay == null) {
comp.value = ""; comp.value = "";
return; return;
}
if (isCoercableComponent(currDisplay)) {
comp.value = coerceComponent(currDisplay);
return;
}
const Title = coerceComponent(currDisplay.title ?? "", "h3");
const Description = coerceComponent(currDisplay.description, "div");
comp.value = coerceComponent(
jsx(() => (
<span>
{currDisplay.title != null ? (
<div>
<Title />
</div>
) : null}
<Description />
</span>
))
);
});
const { start, stop } = setupHoldToClick(onClick, onHold);
return {
start,
stop,
comp,
Visibility,
isVisible,
isHidden,
unref
};
} }
if (isCoercableComponent(currDisplay)) {
comp.value = coerceComponent(currDisplay);
return;
}
const Title = coerceComponent(currDisplay.title ?? "", "h3");
const Description = coerceComponent(currDisplay.description, "div");
comp.value = coerceComponent(
jsx(() => (
<span>
{currDisplay.title != null ? (
<div>
<Title />
</div>
) : null}
<Description />
</span>
))
);
}); });
const { start, stop } = setupHoldToClick(toRef(props, "onClick"), toRef(props, "onHold"));
</script> </script>
<style scoped> <style scoped>

View file

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

View file

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

View file

@ -2,6 +2,7 @@ import Decimal from "util/bignum";
import { DoNotCache, ProcessedComputable } from "util/computed"; import { DoNotCache, ProcessedComputable } from "util/computed";
import type { CSSProperties, DefineComponent } from "vue"; import type { CSSProperties, DefineComponent } from "vue";
import { isRef, unref } 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 * 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, key: K,
value: T[K] value: T[K]
): asserts object is Exclude<T, K> & Required<Pick<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; object[key] = value;
} }
} }
@ -135,7 +136,7 @@ export function excludeFeatures(obj: Record<string, unknown>, ...types: symbol[]
if (value != null && typeof value === "object") { if (value != null && typeof value === "object") {
if ( if (
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
!types.includes((value as Record<string, any>).type) !types.includes((value as Record<string, any>).type)
) { ) {

View file

@ -7,7 +7,7 @@
class="table-grid" class="table-grid"
> >
<div v-for="row in unref(rows)" class="row-grid" :class="{ mergeAdjacent }" :key="row"> <div v-for="row in unref(rows)" class="row-grid" :class="{ mergeAdjacent }" :key="row">
<GridCell <GridCellVue
v-for="col in unref(cols)" v-for="col in unref(cols)"
:key="col" :key="col"
v-bind="gatherCellProps(unref(cells)[row * 100 + col])" v-bind="gatherCellProps(unref(cells)[row * 100 + col])"
@ -16,45 +16,26 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import "components/common/table.css"; import "components/common/table.css";
import themes from "data/themes"; import themes from "data/themes";
import { isHidden, isVisible, Visibility } from "features/feature"; import { isHidden, isVisible, Visibility } from "features/feature";
import type { GridCell } from "features/grids/grid"; import type { GridCell } from "features/grids/grid";
import settings from "game/settings"; import settings from "game/settings";
import { processedPropType } from "util/vue"; import { computed, unref } from "vue";
import { computed, defineComponent, unref } from "vue";
import GridCellVue from "./GridCell.vue"; import GridCellVue from "./GridCell.vue";
export default defineComponent({ defineProps<{
props: { visibility: Visibility | boolean;
visibility: { rows: number;
type: processedPropType<Visibility | boolean>(Number, Boolean), cols: number;
required: true cells: Record<string, GridCell>;
}, }>();
rows: {
type: processedPropType<number>(Number),
required: true
},
cols: {
type: processedPropType<number>(Number),
required: true
},
cells: {
type: processedPropType<Record<string, GridCell>>(Object),
required: true
}
},
components: { GridCell: GridCellVue },
setup() {
const mergeAdjacent = computed(() => themes[settings.theme].mergeAdjacent);
function gatherCellProps(cell: GridCell) { const mergeAdjacent = computed(() => themes[settings.theme].mergeAdjacent);
const { visibility, onClick, onHold, display, title, style, canClick, id } = cell;
return { visibility, onClick, onHold, display, title, style, canClick, id };
}
return { unref, gatherCellProps, Visibility, mergeAdjacent, isVisible, isHidden }; function gatherCellProps(cell: GridCell) {
} const { visibility, onClick, onHold, display, title, style, canClick, id } = cell;
}); return { visibility, onClick, onHold, display, title, style, canClick, id };
}
</script> </script>

View file

@ -22,7 +22,7 @@
</button> </button>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import "components/common/features.css"; import "components/common/features.css";
import Node from "components/Node.vue"; import Node from "components/Node.vue";
import type { CoercableComponent, StyleValue } from "features/feature"; import type { CoercableComponent, StyleValue } from "features/feature";
@ -30,58 +30,26 @@ import { isHidden, isVisible, Visibility } from "features/feature";
import { import {
computeComponent, computeComponent,
computeOptionalComponent, computeOptionalComponent,
processedPropType,
setupHoldToClick setupHoldToClick
} from "util/vue"; } from "util/vue";
import type { PropType } from "vue"; import { toRef, unref } from "vue";
import { defineComponent, toRefs, unref } from "vue";
export default defineComponent({ const props = defineProps<{
props: { visibility: Visibility | boolean;
visibility: { onClick?: (e?: MouseEvent | TouchEvent) => void;
type: processedPropType<Visibility | boolean>(Number, Boolean), onHold?: VoidFunction;
required: true display: CoercableComponent;
}, title?: CoercableComponent;
onClick: Function as PropType<(e?: MouseEvent | TouchEvent) => void>, style?: StyleValue;
onHold: Function as PropType<VoidFunction>, canClick: boolean;
display: { id: string;
type: processedPropType<CoercableComponent>(Object, String, Function), }>();
required: true
},
title: processedPropType<CoercableComponent>(Object, String, Function),
style: processedPropType<StyleValue>(String, Object, Array),
canClick: {
type: processedPropType<boolean>(Boolean),
required: true
},
id: {
type: String,
required: true
}
},
components: {
Node
},
setup(props) {
const { onClick, onHold, title, display } = toRefs(props);
const { start, stop } = setupHoldToClick(onClick, onHold);
const titleComponent = computeOptionalComponent(title); const { start, stop } = setupHoldToClick(toRef(props, "onClick"), toRef(props, "onHold"));
const component = computeComponent(display);
return { const titleComponent = computeOptionalComponent(toRef(props, "title"));
start, const component = computeComponent(toRef(props, "display"));
stop,
titleComponent,
component,
Visibility,
unref,
isVisible,
isHidden
};
}
});
</script> </script>
<style scoped> <style scoped>

View file

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

View file

@ -99,40 +99,57 @@ document.onkeydown = function (e) {
if (hasWon.value && !player.keepGoing) { if (hasWon.value && !player.keepGoing) {
return; return;
} }
let key = e.key; const keysToCheck: string[] = [e.key];
if (uppercaseNumbers.includes(key)) { if (e.shiftKey && e.ctrlKey) {
key = "shift+" + uppercaseNumbers.indexOf(key); 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) { } 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) { const hotkey = hotkeys[keysToCheck.find(key => key in hotkeys) ?? ""];
key = "ctrl+" + key;
}
const hotkey = hotkeys[key] ?? hotkeys[key.toLowerCase()];
if (hotkey && unref(hotkey.enabled)) { if (hotkey && unref(hotkey.enabled)) {
e.preventDefault(); e.preventDefault();
hotkey.onPress(); hotkey.onPress();
} }
}; };
registerInfoComponent( globalBus.on("setupVue", () =>
jsx(() => { registerInfoComponent(
const keys = Object.values(hotkeys).filter(hotkey => unref(hotkey?.enabled)); jsx(() => {
if (keys.length === 0) { const keys = Object.values(hotkeys).filter(hotkey => unref(hotkey?.enabled));
return ""; if (keys.length === 0) {
} return "";
return ( }
<div> return (
<br /> <div>
<h4>Hotkeys</h4> <br />
<div style="column-count: 2"> <h4>Hotkeys</h4>
{keys.map(hotkey => ( <div style="column-count: 2">
<div> {keys.map(hotkey => (
<Hotkey hotkey={hotkey as GenericHotkey} /> {hotkey?.description} <div>
</div> <Hotkey hotkey={hotkey as GenericHotkey} />{" "}
))} {unref(hotkey?.description)}
</div>
))}
</div>
</div> </div>
</div> );
); })
}) )
); );

View file

@ -28,67 +28,33 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import CollapseTransition from "@ivanv/vue-collapse-transition/src/CollapseTransition.vue"; import CollapseTransition from "@ivanv/vue-collapse-transition/src/CollapseTransition.vue";
import Node from "components/Node.vue"; import Node from "components/Node.vue";
import themes from "data/themes"; import themes from "data/themes";
import type { CoercableComponent } from "features/feature"; import type { CoercableComponent } from "features/feature";
import { isHidden, isVisible, Visibility } from "features/feature"; import { isHidden, isVisible, Visibility } from "features/feature";
import settings from "game/settings"; import settings from "game/settings";
import { computeComponent, processedPropType } from "util/vue"; import { computeComponent } from "util/vue";
import type { PropType, Ref, StyleValue } from "vue"; import type { Ref, StyleValue } from "vue";
import { computed, defineComponent, toRefs, unref } from "vue"; import { computed, toRef, unref } from "vue";
export default defineComponent({ const props = defineProps<{
props: { visibility: Visibility | boolean;
visibility: { display: CoercableComponent;
type: processedPropType<Visibility | boolean>(Number, Boolean), title: CoercableComponent;
required: true color?: string;
}, collapsed: Ref<boolean>;
display: { style?: StyleValue;
type: processedPropType<CoercableComponent>(Object, String, Function), titleStyle?: StyleValue;
required: true bodyStyle?: StyleValue;
}, classes?: Record<string, boolean>;
title: { id: string;
type: processedPropType<CoercableComponent>(Object, String, Function), }>();
required: true
},
color: processedPropType<string>(String),
collapsed: {
type: Object as PropType<Ref<boolean>>,
required: true
},
style: processedPropType<StyleValue>(Object, String, Array),
titleStyle: processedPropType<StyleValue>(Object, String, Array),
bodyStyle: processedPropType<StyleValue>(Object, String, Array),
classes: processedPropType<Record<string, boolean>>(Object),
id: {
type: String,
required: true
}
},
components: {
Node,
CollapseTransition
},
setup(props) {
const { title, display } = toRefs(props);
const titleComponent = computeComponent(title); const titleComponent = computeComponent(toRef(props, "title"));
const bodyComponent = computeComponent(display); const bodyComponent = computeComponent(toRef(props, "display"));
const stacked = computed(() => themes[settings.theme].mergeAdjacent); const stacked = computed(() => themes[settings.theme].mergeAdjacent);
return {
titleComponent,
bodyComponent,
stacked,
unref,
Visibility,
isVisible,
isHidden
};
}
});
</script> </script>
<style scoped> <style scoped>

View file

@ -14,47 +14,46 @@
import type { Link } from "features/links/links"; import type { Link } from "features/links/links";
import type { FeatureNode } from "game/layers"; import type { FeatureNode } from "game/layers";
import { kebabifyObject } from "util/vue"; import { kebabifyObject } from "util/vue";
import { computed, toRefs } from "vue"; import { computed } from "vue";
const _props = defineProps<{ const props = defineProps<{
link: Link; link: Link;
startNode: FeatureNode; startNode: FeatureNode;
endNode: FeatureNode; endNode: FeatureNode;
boundingRect: DOMRect | undefined; boundingRect: DOMRect | undefined;
}>(); }>();
const props = toRefs(_props);
const startPosition = computed(() => { const startPosition = computed(() => {
const rect = props.startNode.value.rect; const rect = props.startNode.rect;
const boundingRect = props.boundingRect.value; const boundingRect = props.boundingRect;
const position = boundingRect const position = boundingRect
? { ? {
x: rect.x + rect.width / 2 - boundingRect.x, x: rect.x + rect.width / 2 - boundingRect.x,
y: rect.y + rect.height / 2 - boundingRect.y y: rect.y + rect.height / 2 - boundingRect.y
} }
: { x: 0, y: 0 }; : { x: 0, y: 0 };
if (props.link.value.offsetStart) { if (props.link.offsetStart) {
position.x += props.link.value.offsetStart.x; position.x += props.link.offsetStart.x;
position.y += props.link.value.offsetStart.y; position.y += props.link.offsetStart.y;
} }
return position; return position;
}); });
const endPosition = computed(() => { const endPosition = computed(() => {
const rect = props.endNode.value.rect; const rect = props.endNode.rect;
const boundingRect = props.boundingRect.value; const boundingRect = props.boundingRect;
const position = boundingRect const position = boundingRect
? { ? {
x: rect.x + rect.width / 2 - boundingRect.x, x: rect.x + rect.width / 2 - boundingRect.x,
y: rect.y + rect.height / 2 - boundingRect.y y: rect.y + rect.height / 2 - boundingRect.y
} }
: { x: 0, y: 0 }; : { x: 0, y: 0 };
if (props.link.value.offsetEnd) { if (props.link.offsetEnd) {
position.x += props.link.value.offsetEnd.x; position.x += props.link.offsetEnd.x;
position.y += props.link.value.offsetEnd.y; position.y += props.link.offsetEnd.y;
} }
return position; return position;
}); });
const linkProps = computed(() => kebabifyObject(_props.link as unknown as Record<string, unknown>)); const linkProps = computed(() => kebabifyObject(props.link as unknown as Record<string, unknown>));
</script> </script>

View file

@ -16,11 +16,10 @@
import type { Link } from "features/links/links"; import type { Link } from "features/links/links";
import type { FeatureNode } from "game/layers"; import type { FeatureNode } from "game/layers";
import { BoundsInjectionKey, NodesInjectionKey } from "game/layers"; import { BoundsInjectionKey, NodesInjectionKey } from "game/layers";
import { computed, inject, onMounted, ref, toRef, watch } from "vue"; import { computed, inject, onMounted, ref, watch } from "vue";
import LinkVue from "./Link.vue"; import LinkVue from "./Link.vue";
const _props = defineProps<{ links?: Link[] }>(); const props = defineProps<{ links?: Link[] }>();
const links = toRef(_props, "links");
const resizeListener = ref<Element | null>(null); const resizeListener = ref<Element | null>(null);
@ -36,7 +35,7 @@ onMounted(() => (boundingRect.value = resizeListener.value?.getBoundingClientRec
const validLinks = computed(() => { const validLinks = computed(() => {
const n = nodes.value; const n = nodes.value;
return ( return (
links.value?.filter(link => n[link.startNode.id]?.rect && n[link.startNode.id]?.rect) ?? [] props.links?.filter(link => n[link.startNode.id]?.rect && n[link.endNode.id]?.rect) ?? []
); );
}); });
</script> </script>

View file

@ -7,78 +7,61 @@
/> />
</template> </template>
<script lang="tsx"> <script setup lang="tsx">
import { Application } from "@pixi/app"; import { Application } from "@pixi/app";
import type { StyleValue } from "features/feature"; import type { StyleValue } from "features/feature";
import { globalBus } from "game/events"; import { globalBus } from "game/events";
import "lib/pixi"; import "lib/pixi";
import { processedPropType } from "util/vue"; import { nextTick, onBeforeUnmount, onMounted, shallowRef, unref } from "vue";
import type { PropType } from "vue";
import { defineComponent, nextTick, onBeforeUnmount, onMounted, shallowRef, unref } from "vue";
// TODO get typing support on the Particles component const props = defineProps<{
export default defineComponent({ style?: StyleValue;
props: { classes?: Record<string, boolean>;
style: processedPropType<StyleValue>(String, Object, Array), onInit: (app: Application) => void;
classes: processedPropType<Record<string, boolean>>(Object), id: string;
onInit: { onContainerResized?: (rect: DOMRect) => void;
type: Function as PropType<(app: Application) => void>, onHotReload?: VoidFunction;
required: true }>();
},
id: {
type: String,
required: true
},
onContainerResized: Function as PropType<(rect: DOMRect) => void>,
onHotReload: Function as PropType<VoidFunction>
},
setup(props) {
const app = shallowRef<null | Application>(null);
const resizeObserver = new ResizeObserver(updateBounds); const app = shallowRef<null | Application>(null);
const resizeListener = shallowRef<HTMLElement | null>(null);
onMounted(() => { const resizeObserver = new ResizeObserver(updateBounds);
// ResizeListener exists because ResizeObserver's don't work when told to observe an SVG element const resizeListener = shallowRef<HTMLElement | null>(null);
const resListener = resizeListener.value;
if (resListener != null) { onMounted(() => {
resizeObserver.observe(resListener); // ResizeListener exists because ResizeObserver's don't work when told to observe an SVG element
app.value = new Application({ const resListener = resizeListener.value;
resizeTo: resListener, if (resListener != null) {
backgroundAlpha: 0 resizeObserver.observe(resListener);
}); app.value = new Application({
resizeListener.value?.appendChild(app.value.view); resizeTo: resListener,
props.onInit?.(app.value as Application); backgroundAlpha: 0
}
updateBounds();
if (props.onHotReload) {
nextTick(props.onHotReload);
}
}); });
onBeforeUnmount(() => { resizeListener.value?.appendChild(app.value.view);
app.value?.destroy(); props.onInit?.(app.value as Application);
}); }
updateBounds();
let isDirty = true; if (props.onHotReload) {
function updateBounds() { nextTick(props.onHotReload);
if (isDirty) {
isDirty = false;
nextTick(() => {
if (resizeListener.value != null) {
props.onContainerResized?.(resizeListener.value.getBoundingClientRect());
}
isDirty = true;
});
}
}
globalBus.on("fontsLoaded", updateBounds);
return {
unref,
resizeListener
};
} }
}); });
onBeforeUnmount(() => {
app.value?.destroy();
});
let isDirty = true;
function updateBounds() {
if (isDirty) {
isDirty = false;
nextTick(() => {
if (resizeListener.value != null) {
props.onContainerResized?.(resizeListener.value.getBoundingClientRect());
}
isDirty = true;
});
}
}
globalBus.on("fontsLoaded", updateBounds);
</script> </script>
<style scoped> <style scoped>

View file

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

View file

@ -25,23 +25,19 @@ import type { Resource } from "features/resources/resource";
import ResourceVue from "features/resources/Resource.vue"; import ResourceVue from "features/resources/Resource.vue";
import Decimal from "util/bignum"; import Decimal from "util/bignum";
import { computeOptionalComponent } from "util/vue"; import { computeOptionalComponent } from "util/vue";
import { ComponentPublicInstance, ref, Ref, StyleValue } from "vue"; import { ComponentPublicInstance, computed, ref, StyleValue, toRef } from "vue";
import { computed, toRefs } from "vue";
const _props = defineProps<{ const props = defineProps<{
resource: Resource; resource: Resource;
color?: string; color?: string;
classes?: Record<string, boolean>; classes?: Record<string, boolean>;
style?: StyleValue; style?: StyleValue;
effectDisplay?: CoercableComponent; effectDisplay?: CoercableComponent;
}>(); }>();
const props = toRefs(_props);
const effectRef = ref<ComponentPublicInstance | null>(null); const effectRef = ref<ComponentPublicInstance | null>(null);
const effectComponent = computeOptionalComponent( const effectComponent = computeOptionalComponent(toRef(props, "effectDisplay"));
props.effectDisplay as Ref<CoercableComponent | undefined>
);
const showPrefix = computed(() => { const showPrefix = computed(() => {
return Decimal.lt(props.resource.value, "1e1000"); return Decimal.lt(props.resource.value, "1e1000");

View file

@ -5,9 +5,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { CoercableComponent } from "features/feature"; import type { CoercableComponent } from "features/feature";
import { computeComponent } from "util/vue"; import { computeComponent } from "util/vue";
import { toRefs } from "vue"; import { toRef } from "vue";
const _props = defineProps<{ display: CoercableComponent }>(); const props = defineProps<{ display: CoercableComponent }>();
const { display } = toRefs(_props); const component = computeComponent(toRef(props, "display"));
const component = computeComponent(display);
</script> </script>

View file

@ -19,61 +19,43 @@
</button> </button>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import type { CoercableComponent, StyleValue } from "features/feature"; import type { CoercableComponent, StyleValue } from "features/feature";
import { isHidden, isVisible, Visibility } from "features/feature"; import { isHidden, isVisible, Visibility } from "features/feature";
import { getNotifyStyle } from "game/notifications"; import { getNotifyStyle } from "game/notifications";
import { computeComponent, processedPropType, unwrapRef } from "util/vue"; import { computeComponent } from "util/vue";
import { computed, defineComponent, toRefs, unref } from "vue"; import { computed, toRef, unref } from "vue";
export default defineComponent({ const props = defineProps<{
props: { visibility: Visibility | boolean;
visibility: { display: CoercableComponent;
type: processedPropType<Visibility | boolean>(Number, Boolean), style?: StyleValue;
required: true classes?: Record<string, boolean>;
}, glowColor?: string;
display: { active?: boolean;
type: processedPropType<CoercableComponent>(Object, String, Function), floating?: boolean;
required: true }>();
},
style: processedPropType<StyleValue>(String, Object, Array),
classes: processedPropType<Record<string, boolean>>(Object),
glowColor: processedPropType<string>(String),
active: Boolean,
floating: Boolean
},
emits: ["selectTab"],
setup(props, { emit }) {
const { display, glowColor, floating } = toRefs(props);
const component = computeComponent(display); const emit = defineEmits<{
selectTab: [];
}>();
const glowColorStyle = computed(() => { const component = computeComponent(toRef(props, "display"));
const color = unwrapRef(glowColor);
if (color == null || color === "") {
return {};
}
if (unref(floating)) {
return getNotifyStyle(color);
}
return { boxShadow: `0px 9px 5px -6px ${color}` };
});
function selectTab() { const glowColorStyle = computed(() => {
emit("selectTab"); const color = props.glowColor;
} if (color == null || color === "") {
return {};
return {
selectTab,
component,
glowColorStyle,
unref,
Visibility,
isVisible,
isHidden
};
} }
if (props.floating) {
return getNotifyStyle(color);
}
return { boxShadow: `0px 9px 5px -6px ${color}` };
}); });
function selectTab() {
emit("selectTab");
}
</script> </script>
<style scoped> <style scoped>

View file

@ -33,7 +33,7 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import Sticky from "components/layout/Sticky.vue"; import Sticky from "components/layout/Sticky.vue";
import themes from "data/themes"; import themes from "data/themes";
import type { CoercableComponent, StyleValue } from "features/feature"; import type { CoercableComponent, StyleValue } from "features/feature";
@ -42,93 +42,60 @@ import type { GenericTab } from "features/tabs/tab";
import TabButton from "features/tabs/TabButton.vue"; import TabButton from "features/tabs/TabButton.vue";
import type { GenericTabButton } from "features/tabs/tabFamily"; import type { GenericTabButton } from "features/tabs/tabFamily";
import settings from "game/settings"; import settings from "game/settings";
import { coerceComponent, isCoercableComponent, processedPropType, unwrapRef } from "util/vue"; import { coerceComponent, deepUnref, isCoercableComponent } from "util/vue";
import type { Component, PropType, Ref } from "vue"; import type { Component, Ref } from "vue";
import { computed, defineComponent, shallowRef, toRefs, unref, watchEffect } from "vue"; import { computed, shallowRef, unref, watchEffect } from "vue";
export default defineComponent({ const props = defineProps<{
props: { visibility: Visibility | boolean;
visibility: { activeTab: GenericTab | CoercableComponent | null;
type: processedPropType<Visibility | boolean>(Number, Boolean), selected: Ref<string>;
required: true tabs: Record<string, GenericTabButton>;
}, style?: StyleValue;
activeTab: { classes?: Record<string, boolean>;
type: processedPropType<GenericTab | CoercableComponent | null>(Object), buttonContainerStyle?: StyleValue;
required: true buttonContainerClasses?: Record<string, boolean>;
}, }>();
selected: {
type: Object as PropType<Ref<string>>,
required: true
},
tabs: {
type: processedPropType<Record<string, GenericTabButton>>(Object),
required: true
},
style: processedPropType<StyleValue>(String, Object, Array),
classes: processedPropType<Record<string, boolean>>(Object),
buttonContainerStyle: processedPropType<StyleValue>(String, Object, Array),
buttonContainerClasses: processedPropType<Record<string, boolean>>(Object)
},
components: {
Sticky,
TabButton
},
setup(props) {
const { activeTab } = toRefs(props);
const floating = computed(() => { const floating = computed(() => {
return themes[settings.theme].floatingTabs; return themes[settings.theme].floatingTabs;
});
const component = shallowRef<Component | string>("");
watchEffect(() => {
const currActiveTab = unwrapRef(activeTab);
if (currActiveTab == null) {
component.value = "";
return;
}
if (isCoercableComponent(currActiveTab)) {
component.value = coerceComponent(currActiveTab);
return;
}
component.value = coerceComponent(unref(currActiveTab.display));
});
const tabClasses = computed(() => {
const currActiveTab = unwrapRef(activeTab);
const tabClasses =
isCoercableComponent(currActiveTab) || !currActiveTab
? undefined
: unref(currActiveTab.classes);
return tabClasses;
});
const tabStyle = computed(() => {
const currActiveTab = unwrapRef(activeTab);
return isCoercableComponent(currActiveTab) || !currActiveTab
? undefined
: unref(currActiveTab.style);
});
function gatherButtonProps(button: GenericTabButton) {
const { display, style, classes, glowColor, visibility } = button;
return { display, style: unref(style), classes, glowColor, visibility };
}
return {
floating,
tabClasses,
tabStyle,
Visibility,
component,
gatherButtonProps,
unref,
isVisible,
isHidden
};
}
}); });
const component = shallowRef<Component | string>("");
watchEffect(() => {
const currActiveTab = props.activeTab;
if (currActiveTab == null) {
component.value = "";
return;
}
if (isCoercableComponent(currActiveTab)) {
component.value = coerceComponent(currActiveTab);
return;
}
component.value = coerceComponent(unref(currActiveTab.display));
});
const tabClasses = computed(() => {
const currActiveTab = props.activeTab;
const tabClasses =
isCoercableComponent(currActiveTab) || !currActiveTab
? undefined
: unref(currActiveTab.classes);
return tabClasses;
});
const tabStyle = computed(() => {
const currActiveTab = props.activeTab;
return isCoercableComponent(currActiveTab) || !currActiveTab
? undefined
: unref(currActiveTab.style);
});
function gatherButtonProps(button: GenericTabButton) {
const { display, style, classes, glowColor, visibility } = deepUnref(button);
return { display, style, classes, glowColor, visibility };
}
</script> </script>
<style scoped> <style scoped>

View file

@ -34,7 +34,7 @@
</div> </div>
</template> </template>
<script lang="tsx"> <script setup lang="tsx">
import themes from "data/themes"; import themes from "data/themes";
import type { CoercableComponent } from "features/feature"; import type { CoercableComponent } from "features/feature";
import { jsx, StyleValue } from "features/feature"; import { jsx, StyleValue } from "features/feature";
@ -45,66 +45,45 @@ import type { VueFeature } from "util/vue";
import { import {
coerceComponent, coerceComponent,
computeOptionalComponent, computeOptionalComponent,
processedPropType, renderJSX
renderJSX,
unwrapRef
} from "util/vue"; } from "util/vue";
import type { Component, PropType } from "vue"; import type { Component } from "vue";
import { computed, defineComponent, ref, shallowRef, toRefs, unref } from "vue"; import { computed, ref, shallowRef, toRef, unref } from "vue";
export default defineComponent({ const props = defineProps<{
props: { element?: VueFeature;
element: Object as PropType<VueFeature>, display: CoercableComponent;
display: { style?: StyleValue;
type: processedPropType<CoercableComponent>(Object, String, Function), classes?: Record<string, boolean>;
required: true direction?: Direction;
}, xoffset?: string;
style: processedPropType<StyleValue>(Object, String, Array), yoffset?: string;
classes: processedPropType<Record<string, boolean>>(Object), pinned?: Persistent<boolean>;
direction: processedPropType<Direction>(String), }>();
xoffset: processedPropType<string>(String),
yoffset: processedPropType<string>(String),
pinned: Object as PropType<Persistent<boolean>>
},
setup(props) {
const { element, display, pinned } = toRefs(props);
const isHovered = ref(false); const isHovered = ref(false);
const isShown = computed(() => (unwrapRef(pinned) || isHovered.value) && comp.value); const isShown = computed(() => (props.pinned?.value === true || isHovered.value) && comp.value);
const comp = computeOptionalComponent(display); const comp = computeOptionalComponent(toRef(props, "display"));
const elementComp = shallowRef<Component | "" | null>( const elementComp = shallowRef<Component | "" | null>(
coerceComponent( coerceComponent(
jsx(() => { jsx(() => {
const currComponent = unwrapRef(element); const currComponent = props.element;
return currComponent == null ? "" : renderJSX(currComponent); return currComponent == null ? "" : renderJSX(currComponent);
}) })
) )
); );
function togglePinned(e: MouseEvent) { function togglePinned(e: MouseEvent) {
const isPinned = pinned as unknown as Persistent<boolean> | undefined; // Vue typing :/ const isPinned = props.pinned;
if (e.shiftKey && isPinned) { if (e.shiftKey && isPinned != null) {
isPinned.value = !isPinned.value; isPinned.value = !isPinned.value;
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
}
}
const showPin = computed(() => unwrapRef(pinned) && themes[settings.theme].showPin);
return {
Direction,
isHovered,
isShown,
comp,
elementComp,
unref,
togglePinned,
showPin
};
} }
}); }
const showPin = computed(() => props.pinned?.value === true && themes[settings.theme].showPin);
</script> </script>
<style scoped> <style scoped>

View file

@ -1,6 +1,6 @@
import type { CoercableComponent, GenericComponent, Replace, StyleValue } from "features/feature"; import type { CoercableComponent, GenericComponent, Replace, StyleValue } from "features/feature";
import { Component, GatherProps, setDefault } from "features/feature"; import { Component, GatherProps, setDefault } from "features/feature";
import { deletePersistent, Persistent, persistent } from "game/persistence"; import { persistent } from "game/persistence";
import { Direction } from "util/common"; import { Direction } from "util/common";
import type { import type {
Computable, Computable,

View file

@ -5,74 +5,58 @@
<Links v-if="branches" :links="unref(branches)" /> <Links v-if="branches" :links="unref(branches)" />
</template> </template>
<script lang="tsx"> <script setup lang="tsx">
import "components/common/table.css"; import "components/common/table.css";
import { jsx } from "features/feature"; import { jsx } from "features/feature";
import Links from "features/links/Links.vue"; import Links from "features/links/Links.vue";
import type { GenericTreeNode, TreeBranch } from "features/trees/tree"; import type { GenericTreeNode, TreeBranch } from "features/trees/tree";
import { coerceComponent, processedPropType, renderJSX, unwrapRef } from "util/vue"; import { coerceComponent, renderJSX } from "util/vue";
import type { Component } from "vue"; import type { Component } from "vue";
import { defineComponent, shallowRef, toRefs, unref, watchEffect } from "vue"; import { shallowRef, unref, watchEffect } from "vue";
export default defineComponent({ const props = defineProps<{
props: { nodes: GenericTreeNode[][];
nodes: { leftSideNodes?: GenericTreeNode[];
type: processedPropType<GenericTreeNode[][]>(Array), rightSideNodes?: GenericTreeNode[];
required: true branches?: TreeBranch[];
}, }>();
leftSideNodes: processedPropType<GenericTreeNode[]>(Array),
rightSideNodes: processedPropType<GenericTreeNode[]>(Array),
branches: processedPropType<TreeBranch[]>(Array)
},
components: { Links },
setup(props) {
const { nodes, leftSideNodes, rightSideNodes } = toRefs(props);
const nodesComp = shallowRef<Component | "">(); const nodesComp = shallowRef<Component | "">();
watchEffect(() => { watchEffect(() => {
const currNodes = unwrapRef(nodes); const currNodes = props.nodes;
nodesComp.value = coerceComponent( nodesComp.value = coerceComponent(
jsx(() => (
<>
{currNodes.map(row => (
<span class="row tree-row" style="margin: 50px auto;">
{row.map(renderJSX)}
</span>
))}
</>
))
);
});
const leftNodesComp = shallowRef<Component | "">();
watchEffect(() => {
const currNodes = props.leftSideNodes;
leftNodesComp.value = currNodes
? coerceComponent(
jsx(() => ( jsx(() => (
<> <span class="left-side-nodes small">{currNodes.map(renderJSX)}</span>
{currNodes.map(row => (
<span class="row tree-row" style="margin: 50px auto;">
{row.map(renderJSX)}
</span>
))}
</>
)) ))
); )
}); : "";
});
const leftNodesComp = shallowRef<Component | "">(); const rightNodesComp = shallowRef<Component | "">();
watchEffect(() => { watchEffect(() => {
const currNodes = unwrapRef(leftSideNodes); const currNodes = props.rightSideNodes;
leftNodesComp.value = currNodes rightNodesComp.value = currNodes
? coerceComponent( ? coerceComponent(
jsx(() => ( jsx(() => <span class="side-nodes small">{currNodes.map(renderJSX)}</span>)
<span class="left-side-nodes small">{currNodes.map(renderJSX)}</span> )
)) : "";
)
: "";
});
const rightNodesComp = shallowRef<Component | "">();
watchEffect(() => {
const currNodes = unwrapRef(rightSideNodes);
rightNodesComp.value = currNodes
? coerceComponent(
jsx(() => <span class="side-nodes small">{currNodes.map(renderJSX)}</span>)
)
: "";
});
return {
unref,
nodesComp,
leftNodesComp,
rightNodesComp
};
}
}); });
</script> </script>

View file

@ -33,66 +33,34 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="tsx">
import MarkNode from "components/MarkNode.vue"; import MarkNode from "components/MarkNode.vue";
import Node from "components/Node.vue"; import Node from "components/Node.vue";
import type { CoercableComponent, StyleValue } from "features/feature"; import type { CoercableComponent, StyleValue, Visibility } from "features/feature";
import { isHidden, isVisible, Visibility } from "features/feature"; import { isHidden, isVisible } from "features/feature";
import { import {
computeOptionalComponent, computeOptionalComponent,
isCoercableComponent,
processedPropType,
setupHoldToClick setupHoldToClick
} from "util/vue"; } from "util/vue";
import type { PropType } from "vue"; import { toRef, unref } from "vue";
import { defineComponent, toRefs, unref } from "vue";
export default defineComponent({ const props = defineProps<{
props: { visibility: Visibility | boolean;
display: processedPropType<CoercableComponent>(Object, String, Function), canClick: boolean;
visibility: { id: string;
type: processedPropType<Visibility | boolean>(Number, Boolean), display?: CoercableComponent;
required: true style?: StyleValue;
}, classes?: Record<string, boolean>;
style: processedPropType<StyleValue>(String, Object, Array), onClick?: (e?: MouseEvent | TouchEvent) => void;
classes: processedPropType<Record<string, boolean>>(Object), onHold?: VoidFunction;
onClick: Function as PropType<(e?: MouseEvent | TouchEvent) => void>, color?: string;
onHold: Function as PropType<VoidFunction>, glowColor?: string;
color: processedPropType<string>(String), mark?: boolean | string;
glowColor: processedPropType<string>(String), }>();
canClick: {
type: processedPropType<boolean>(Boolean),
required: true
},
mark: processedPropType<boolean | string>(Boolean, String),
id: {
type: String,
required: true
}
},
components: {
MarkNode,
Node
},
setup(props) {
const { onClick, onHold, display } = toRefs(props);
const comp = computeOptionalComponent(display); const comp = computeOptionalComponent(toRef(props, "display"));
const { start, stop } = setupHoldToClick(onClick, onHold); const { start, stop } = setupHoldToClick(toRef(props, "onClick"), toRef(props, "onHold"));
return {
start,
stop,
comp,
unref,
Visibility,
isCoercableComponent,
isVisible,
isHidden
};
}
});
</script> </script>
<style scoped> <style scoped>

View file

@ -1,4 +1,4 @@
import { Decorator, GenericDecorator } from "features/decorators/common"; import { GenericDecorator } from "features/decorators/common";
import type { import type {
CoercableComponent, CoercableComponent,
GenericComponent, GenericComponent,
@ -141,7 +141,9 @@ export function createTreeNode<T extends TreeNodeOptions>(
if (treeNode.onClick) { if (treeNode.onClick) {
const onClick = treeNode.onClick.bind(treeNode); const onClick = treeNode.onClick.bind(treeNode);
treeNode.onClick = function (e) { treeNode.onClick = function (e) {
if (unref(treeNode.canClick) !== false) { if (
unref(treeNode.canClick as ProcessedComputable<boolean | undefined>) !== false
) {
onClick(e); onClick(e);
} }
}; };
@ -149,7 +151,9 @@ export function createTreeNode<T extends TreeNodeOptions>(
if (treeNode.onHold) { if (treeNode.onHold) {
const onHold = treeNode.onHold.bind(treeNode); const onHold = treeNode.onHold.bind(treeNode);
treeNode.onHold = function () { treeNode.onHold = function () {
if (unref(treeNode.canClick) !== false) { if (
unref(treeNode.canClick as ProcessedComputable<boolean | undefined>) !== false
) {
onHold(); onHold();
} }
}; };
@ -342,15 +346,15 @@ export const branchedResetPropagation = function (
if (links == null) return; if (links == null) return;
const reset: GenericTreeNode[] = []; const reset: GenericTreeNode[] = [];
let current = [resettingNode]; let current = [resettingNode];
while (current.length != 0) { while (current.length !== 0) {
const next: GenericTreeNode[] = []; const next: GenericTreeNode[] = [];
for (const node of current) { for (const node of current) {
for (const link of links.filter(link => link.startNode === node)) { for (const link of links.filter(link => link.startNode === node)) {
if ([...reset, ...current].includes(link.endNode)) continue if ([...reset, ...current].includes(link.endNode)) continue;
next.push(link.endNode); next.push(link.endNode);
link.endNode.reset?.reset(); link.endNode.reset?.reset();
} }
}; }
reset.push(...current); reset.push(...current);
current = next; current = next;
} }

View file

@ -24,7 +24,7 @@
</button> </button>
</template> </template>
<script lang="tsx"> <script setup lang="tsx">
import "components/common/features.css"; import "components/common/features.css";
import MarkNode from "components/MarkNode.vue"; import MarkNode from "components/MarkNode.vue";
import Node from "components/Node.vue"; import Node from "components/Node.vue";
@ -32,94 +32,56 @@ import type { StyleValue } from "features/feature";
import { isHidden, isVisible, jsx, Visibility } from "features/feature"; import { isHidden, isVisible, jsx, Visibility } from "features/feature";
import type { GenericUpgrade } from "features/upgrades/upgrade"; import type { GenericUpgrade } from "features/upgrades/upgrade";
import { displayRequirements, Requirements } from "game/requirements"; import { displayRequirements, Requirements } from "game/requirements";
import { coerceComponent, isCoercableComponent, processedPropType, unwrapRef } from "util/vue"; import { coerceComponent, isCoercableComponent } from "util/vue";
import type { Component, PropType, UnwrapRef } from "vue"; import type { Component, UnwrapRef } from "vue";
import { defineComponent, shallowRef, toRefs, unref, watchEffect } from "vue"; import { shallowRef, unref, watchEffect } from "vue";
export default defineComponent({ const props = defineProps<{
props: { display: UnwrapRef<GenericUpgrade["display"]>;
display: { visibility: Visibility | boolean;
type: processedPropType<UnwrapRef<GenericUpgrade["display"]>>(String, Object, Function), style?: StyleValue;
required: true classes?: Record<string, boolean>;
}, requirements: Requirements;
visibility: { canPurchase: boolean;
type: processedPropType<Visibility | boolean>(Number, Boolean), bought: boolean;
required: true mark?: boolean | string;
}, id: string;
style: processedPropType<StyleValue>(String, Object, Array), purchase?: VoidFunction;
classes: processedPropType<Record<string, boolean>>(Object), }>();
requirements: {
type: Object as PropType<Requirements>,
required: true
},
canPurchase: {
type: processedPropType<boolean>(Boolean),
required: true
},
bought: {
type: processedPropType<boolean>(Boolean),
required: true
},
mark: processedPropType<boolean | string>(Boolean, String),
id: {
type: String,
required: true
},
purchase: {
type: Function as PropType<VoidFunction>,
required: true
}
},
components: {
Node,
MarkNode
},
setup(props) {
const { display, requirements, bought } = toRefs(props);
const component = shallowRef<Component | string>(""); const component = shallowRef<Component | string>("");
watchEffect(() => { watchEffect(() => {
const currDisplay = unwrapRef(display); const currDisplay = props.display;
if (currDisplay == null) { if (currDisplay == null) {
component.value = ""; component.value = "";
return; return;
}
if (isCoercableComponent(currDisplay)) {
component.value = coerceComponent(currDisplay);
return;
}
const Title = coerceComponent(currDisplay.title || "", "h3");
const Description = coerceComponent(currDisplay.description, "div");
const EffectDisplay = coerceComponent(currDisplay.effectDisplay || "");
component.value = coerceComponent(
jsx(() => (
<span>
{currDisplay.title != null ? (
<div>
<Title />
</div>
) : null}
<Description />
{currDisplay.effectDisplay != null ? (
<div>
Currently: <EffectDisplay />
</div>
) : null}
{bought.value ? null : <><br />{displayRequirements(requirements.value)}</>}
</span>
))
);
});
return {
component,
unref,
Visibility,
isVisible,
isHidden
};
} }
if (isCoercableComponent(currDisplay)) {
component.value = coerceComponent(currDisplay);
return;
}
const Title = coerceComponent(currDisplay.title || "", "h3");
const Description = coerceComponent(currDisplay.description, "div");
const EffectDisplay = coerceComponent(currDisplay.effectDisplay || "");
component.value = coerceComponent(
jsx(() => (
<span>
{currDisplay.title != null ? (
<div>
<Title />
</div>
) : null}
<Description />
{currDisplay.effectDisplay != null ? (
<div>
Currently: <EffectDisplay />
</div>
) : null}
{props.bought ? null : <><br />{displayRequirements(props.requirements)}</>}
</span>
))
);
}); });
</script> </script>

View file

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

View file

@ -2,6 +2,7 @@ import type { Settings } from "game/settings";
import { createNanoEvents } from "nanoevents"; import { createNanoEvents } from "nanoevents";
import type { App } from "vue"; import type { App } from "vue";
import type { GenericLayer } from "./layers"; import type { GenericLayer } from "./layers";
import state from "./state";
/** All types of events able to be sent or emitted from the global event bus. */ /** All types of events able to be sent or emitted from the global event bus. */
export interface GlobalEvents { 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 // JSDom doesn't add document.fonts, and Object.defineProperty doesn't seem to work on document
document.fonts.onloadingdone = () => globalBus.emit("fontsLoaded"); 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; invertIntegral?(value: DecimalSource): DecimalSource;
} }
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export abstract class InternalFormula<T extends [FormulaSource] | FormulaSource[]> { export abstract class InternalFormula<T extends [FormulaSource] | FormulaSource[]> {
readonly inputs: T; readonly inputs: T;
@ -56,6 +57,7 @@ export abstract class InternalFormula<T extends [FormulaSource] | FormulaSource[
protected readonly internalIntegrate: IntegrateFunction<T> | undefined; protected readonly internalIntegrate: IntegrateFunction<T> | undefined;
protected readonly internalIntegrateInner: IntegrateFunction<T> | undefined; protected readonly internalIntegrateInner: IntegrateFunction<T> | undefined;
protected readonly applySubstitution: SubstitutionFunction<T> | undefined; protected readonly applySubstitution: SubstitutionFunction<T> | undefined;
protected readonly description: string | undefined;
protected readonly internalVariables: number; protected readonly internalVariables: number;
public readonly innermostVariable: ProcessedComputable<DecimalSource> | undefined; public readonly innermostVariable: ProcessedComputable<DecimalSource> | undefined;
@ -85,6 +87,7 @@ export abstract class InternalFormula<T extends [FormulaSource] | FormulaSource[
this.internalIntegrate = readonlyProperties.internalIntegrate; this.internalIntegrate = readonlyProperties.internalIntegrate;
this.internalIntegrateInner = readonlyProperties.internalIntegrateInner; this.internalIntegrateInner = readonlyProperties.internalIntegrateInner;
this.applySubstitution = readonlyProperties.applySubstitution; this.applySubstitution = readonlyProperties.applySubstitution;
this.description = options.description;
} }
private setupVariable({ private setupVariable({
@ -216,6 +219,25 @@ export abstract class InternalFormula<T extends [FormulaSource] | FormulaSource[
return new Formula({ variable: value }); 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 // TODO add integration support to step-wise functions
/** /**
* Creates a step-wise formula. After {@link start} the formula will have an additional modifier. * 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({ return new Formula({
inputs: [value], inputs: [value],
evaluate: evalStep, 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({ return new Formula({
inputs: [value], inputs: [value],
evaluate: evalStep, 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( public static conditional(
@ -878,6 +904,10 @@ export abstract class InternalFormula<T extends [FormulaSource] | FormulaSource[
}); });
} }
public stringify() {
return Formula.stringify(this);
}
public step( public step(
start: Computable<DecimalSource>, start: Computable<DecimalSource>,
formulaModifier: (value: InvertibleIntegralFormula) => GenericFormula formulaModifier: (value: InvertibleIntegralFormula) => GenericFormula
@ -1402,28 +1432,6 @@ export function findNonInvertible(formula: GenericFormula): GenericFormula | nul
return null; 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. * 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 * @param formula The formula to use for calculating buy max from

View file

@ -552,7 +552,9 @@ export function tetrate(
export function invertTetrate( export function invertTetrate(
value: DecimalSource, value: DecimalSource,
base: FormulaSource, base: FormulaSource,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
height: FormulaSource, height: FormulaSource,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
payload: FormulaSource payload: FormulaSource
) { ) {
if (hasVariable(base)) { if (hasVariable(base)) {
@ -576,6 +578,7 @@ export function invertIteratedExp(
value: DecimalSource, value: DecimalSource,
lhs: FormulaSource, lhs: FormulaSource,
height: FormulaSource, height: FormulaSource,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
payload: FormulaSource payload: FormulaSource
) { ) {
if (hasVariable(lhs)) { if (hasVariable(lhs)) {
@ -626,6 +629,7 @@ export function invertLayeradd(
value: DecimalSource, value: DecimalSource,
lhs: FormulaSource, lhs: FormulaSource,
diff: FormulaSource, diff: FormulaSource,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
base: FormulaSource base: FormulaSource
) { ) {
if (hasVariable(lhs)) { if (hasVariable(lhs)) {

View file

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

View file

@ -1,18 +1,14 @@
import { hasWon } from "data/projEntry";
import projInfo from "data/projInfo.json"; import projInfo from "data/projInfo.json";
import { globalBus } from "game/events"; import { globalBus } from "game/events";
import settings from "game/settings"; import settings from "game/settings";
import Decimal from "util/bignum"; import Decimal from "util/bignum";
import { loadingSave } from "util/save"; import { loadingSave } from "util/save";
import type { Ref } from "vue";
import { watch } from "vue"; import { watch } from "vue";
import player from "./player"; import player from "./player";
import state from "./state"; import state from "./state";
let intervalID: NodeJS.Timer | null = null; let intervalID: NodeJS.Timeout | 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() { function update() {
const now = Date.now(); const now = Date.now();
@ -43,7 +39,7 @@ function update() {
loadingSave.value = false; loadingSave.value = false;
// Add offline time if any // Add offline time if any
if (player.offlineTime != undefined) { if (player.offlineTime != null) {
if (Decimal.gt(player.offlineTime, projInfo.offlineLimit * 3600)) { if (Decimal.gt(player.offlineTime, projInfo.offlineLimit * 3600)) {
player.offlineTime = projInfo.offlineLimit * 3600; player.offlineTime = projInfo.offlineLimit * 3600;
} }
@ -63,7 +59,7 @@ function update() {
diff = Math.min(diff, projInfo.maxTickLength); diff = Math.min(diff, projInfo.maxTickLength);
// Apply dev speed // Apply dev speed
if (player.devSpeed != undefined) { if (player.devSpeed != null) {
diff *= player.devSpeed; diff *= player.devSpeed;
} }
@ -95,15 +91,22 @@ function update() {
/** Starts the game loop for the project, which updates the game in ticks. */ /** Starts the game loop for the project, which updates the game in ticks. */
export async function startGameLoop() { export async function startGameLoop() {
hasWon = (await import("data/projEntry")).hasWon;
watch(hasWon, hasWon => {
if (hasWon) {
globalBus.emit("gameWon");
}
});
if (settings.unthrottled) { if (settings.unthrottled) {
requestAnimationFrame(update); requestAnimationFrame(update);
} else { } else {
intervalID = setInterval(update, 50); 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 { import type {
CoercableComponent, CoercableComponent,
JSXFunction, JSXFunction,

View file

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

View file

@ -36,12 +36,12 @@ export type LayerData<T> = {
[P in keyof T]?: T[P] extends (infer U)[] [P in keyof T]?: T[P] extends (infer U)[]
? Record<string, LayerData<U>> ? Record<string, LayerData<U>>
: T[P] extends Record<string, never> : T[P] extends Record<string, never>
? never ? never
: T[P] extends Ref<infer S> : T[P] extends Ref<infer S>
? S ? S
: T[P] extends object : T[P] extends object
? LayerData<T[P]> ? LayerData<T[P]>
: T[P]; : T[P];
}; };
const player = reactive<Player>({ 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. */ /** Convert a player save data object into a JSON string. Unwraps refs. */
export function stringifySave(player: Player): string { 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 { declare global {

View file

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

View file

@ -3,7 +3,7 @@ import { Themes } from "data/themes";
import type { CoercableComponent } from "features/feature"; import type { CoercableComponent } from "features/feature";
import { globalBus } from "game/events"; import { globalBus } from "game/events";
import LZString from "lz-string"; import LZString from "lz-string";
import { hardReset } from "util/save"; import { decodeSave, hardReset } from "util/save";
import { reactive, watch } from "vue"; import { reactive, watch } from "vue";
/** The player's settings object. */ /** The player's settings object. */
@ -20,6 +20,8 @@ export interface Settings {
unthrottled: boolean; unthrottled: boolean;
/** Whether to align modifiers to the unit. */ /** Whether to align modifiers to the unit. */
alignUnits: boolean; alignUnits: boolean;
/** Whether or not to show a video game health warning after playing excessively. */
showHealthWarning: boolean;
} }
const state = reactive<Partial<Settings>>({ const state = reactive<Partial<Settings>>({
@ -28,7 +30,8 @@ const state = reactive<Partial<Settings>>({
showTPS: true, showTPS: true,
theme: Themes.Nordic, theme: Themes.Nordic,
unthrottled: false, unthrottled: false,
alignUnits: false alignUnits: false,
showHealthWarning: true
}); });
watch( watch(
@ -56,12 +59,15 @@ declare global {
export default window.settings = state as Settings; export default window.settings = state as Settings;
/** A function that erases all player settings, including all saves. */ /** A function that erases all player settings, including all saves. */
export const hardResetSettings = (window.hardResetSettings = () => { 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: "", active: "",
saves: [], saves: [],
showTPS: true, showTPS: true,
theme: Themes.Nordic, theme: Themes.Nordic,
alignUnits: false unthrottled: false,
alignUnits: false,
showHealthWarning: true
}; };
globalBus.emit("loadSettings", settings); globalBus.emit("loadSettings", settings);
Object.assign(state, settings); Object.assign(state, settings);
@ -78,16 +84,8 @@ export function loadSettings(): void {
try { try {
let item: string | null = localStorage.getItem(projInfo.id); let item: string | null = localStorage.getItem(projInfo.id);
if (item != null && item !== "") { if (item != null && item !== "") {
if (item[0] === "{") { item = decodeSave(item);
// plaintext. No processing needed if (item == null) {
} else if (item[0] === "e") {
// Assumed to be base64, which starts with e
item = decodeURIComponent(escape(atob(item)));
} else if (item[0] === "ᯡ") {
// Assumed to be lz, which starts with ᯡ
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
item = LZString.decompressFromUTF16(item)!;
} else {
console.warn("Unable to determine settings encoding", item); console.warn("Unable to determine settings encoding", item);
return; return;
} }

View file

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

View file

@ -66,3 +66,21 @@ ul {
.Vue-Toastification__toast { .Vue-Toastification__toast {
margin: unset; margin: unset;
} }
: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,11 +3,14 @@ import App from "App.vue";
import projInfo from "data/projInfo.json"; import projInfo from "data/projInfo.json";
import "game/notifications"; import "game/notifications";
import state from "game/state"; import state from "game/state";
import "util/galaxy";
import { load } from "util/save"; import { load } from "util/save";
import { useRegisterSW } from "virtual:pwa-register/vue"; import { useRegisterSW } from "virtual:pwa-register/vue";
import type { App as VueApp } from "vue"; import type { App as VueApp } from "vue";
import { createApp, nextTick } from "vue"; import { createApp, nextTick } from "vue";
import { useToast } from "vue-toastification"; import { useToast } from "vue-toastification";
import { globalBus } from "./game/events";
import { startGameLoop } from "./game/gameLoop";
declare global { declare global {
/** /**
@ -17,11 +20,6 @@ declare global {
vue: VueApp; vue: VueApp;
projInfo: typeof projInfo; projInfo: typeof projInfo;
} }
/** Fix for typedoc treating import functions as taking AssertOptions instead of GlobOptions. */
interface AssertOptions {
as: string;
}
} }
const error = console.error; const error = console.error;
@ -60,8 +58,6 @@ requestAnimationFrame(async () => {
"padding: 4px;" "padding: 4px;"
); );
await load(); await load();
const { globalBus } = await import("./game/events");
const { startGameLoop } = await import("./game/gameLoop");
// Create Vue // Create Vue
const vue = (window.vue = createApp(App)); const vue = (window.vue = createApp(App));
@ -74,33 +70,13 @@ requestAnimationFrame(async () => {
// Setup PWA update prompt // Setup PWA update prompt
nextTick(() => { nextTick(() => {
const toast = useToast(); const toast = useToast();
const { updateServiceWorker } = useRegisterSW({ useRegisterSW({
onNeedRefresh() { immediate: true,
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();
}
});
},
onOfflineReady() { onOfflineReady() {
toast.info("App ready to work offline"); toast.info("App ready to work offline");
}, },
onRegisterError: console.warn, onRegisterError: console.warn,
onRegistered(r) { onRegistered: console.info
if (r) {
// https://stackoverflow.com/questions/65500916/typeerror-failed-to-execute-update-on-serviceworkerregistration-illegal-in
setInterval(() => r.update(), 60 * 60 * 1000);
}
}
}); });
}); });

View file

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

View file

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

View file

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

186
src/util/galaxy.ts Normal file
View file

@ -0,0 +1,186 @@
import { LoadablePlayerData } from "components/modals/SavesManager.vue";
import player, { Player, stringifySave } from "game/player";
import settings from "game/settings";
import LZString from "lz-string";
import { GalaxyApi, initGalaxy } from "unofficial-galaxy-sdk";
import { ref } from "vue";
import { decodeSave, loadSave, save, setupInitialStore } from "./save";
export const galaxy = ref<GalaxyApi>();
export const conflictingSaves = ref<
{ id: string; local: LoadablePlayerData; cloud: LoadablePlayerData; slot: number }[]
>([]);
export const syncedSaves = ref<string[]>([]);
export function sync() {
if (galaxy.value?.loggedIn !== true) {
return;
}
if (conflictingSaves.value.length > 0) {
// Pause syncing while resolving conflicted saves
return;
}
galaxy.value
.getSaveList()
.then(syncSaves)
.then(list => {
syncedSaves.value = list.map(s => s.content.id);
})
.catch(console.error);
}
// Setup Galaxy API
initGalaxy({
supportsSaving: true,
supportsSaveManager: true,
onLoggedInChanged
})
.then(g => {
galaxy.value = g;
onLoggedInChanged(g);
})
.catch(console.error);
function onLoggedInChanged(g: GalaxyApi) {
if (g.loggedIn !== true) {
return;
}
if (conflictingSaves.value.length > 0) {
// Pause syncing while resolving conflicted saves
return;
}
g.getSaveList()
.then(list => {
const saves = syncSaves(list);
syncedSaves.value = saves.map(s => s.content.id);
// If our current save has under 2 minutes of playtime, load the cloud save with the most recent time.
if (player.timePlayed < 120 * 1000 && saves.length > 0) {
const longestSave = saves.reduce((acc, curr) =>
acc.content.time < curr.content.time ? curr : acc
);
loadSave(longestSave.content);
}
})
.catch(console.error);
setInterval(sync, 60 * 1000);
}
function syncSaves(
list: Record<
number,
{
label: string;
content: string;
}
>
) {
const savesToUpload = new Set(settings.saves.slice());
const availableSlots = new Set(new Array(11).fill(0).map((_, i) => i));
const saves = (
Object.keys(list)
.map(slot => {
const { label, content } = list[slot as unknown as number];
try {
return {
slot: parseInt(slot),
label,
content: JSON.parse(decodeSave(content) ?? "")
};
} catch (e) {
return null;
}
})
.filter(
n =>
n != null &&
typeof n.content.id === "string" &&
typeof n.content.time === "number" &&
typeof n.content.timePlayed === "number"
) as {
slot: number;
label?: string;
content: Partial<Player> & { id: string; time: number; timePlayed: number };
}[]
).filter(cloudSave => {
if (cloudSave.label != null) {
cloudSave.content.name = cloudSave.label;
}
availableSlots.delete(cloudSave.slot);
const localSaveId = settings.saves.find(id => id === cloudSave.content.id);
if (localSaveId == null) {
settings.saves.push(cloudSave.content.id);
save(setupInitialStore(cloudSave.content));
} else {
savesToUpload.delete(localSaveId);
try {
const localSave = JSON.parse(
decodeSave(localStorage.getItem(localSaveId) ?? "") ?? ""
) as Partial<Player> | null;
if (localSave == null) {
return false;
}
localSave.id = localSaveId;
localSave.time = localSave.time ?? 0;
localSave.timePlayed = localSave.timePlayed ?? 0;
const timePlayedDiff = Math.abs(
localSave.timePlayed - cloudSave.content.timePlayed
);
const timeDiff = Math.abs(localSave.time - cloudSave.content.time);
// If their last played time and total time played are both within 2 minutes, just use the newer save (very unlikely to be coincidence)
// Otherwise, ask the player
if (timePlayedDiff < 120 * 1000 && timeDiff < 120 * 1000) {
if (localSave.time < cloudSave.content.time) {
save(setupInitialStore(cloudSave.content));
if (settings.active === localSaveId) {
loadSave(cloudSave.content);
}
} else {
galaxy.value
?.save(
cloudSave.slot,
LZString.compressToUTF16(
stringifySave(setupInitialStore(localSave))
),
localSave.name ?? cloudSave.label
)
.catch(console.error);
// Update cloud save content for the return value
cloudSave.content = localSave as Player;
}
} else {
conflictingSaves.value.push({
id: localSaveId,
cloud: cloudSave.content,
local: localSave as LoadablePlayerData,
slot: cloudSave.slot
});
}
} catch (e) {
return false;
}
}
return true;
});
savesToUpload.forEach(id => {
try {
if (availableSlots.size > 0) {
const localSave = localStorage.getItem(id) ?? "";
const parsedLocalSave = JSON.parse(decodeSave(localSave) ?? "");
const slot = availableSlots.values().next().value;
if (slot == null) return;
galaxy.value
?.save(slot, localSave, parsedLocalSave.name)
.then(() => syncedSaves.value.push(parsedLocalSave.id))
.catch(console.error);
availableSlots.delete(slot);
}
} catch (e) {}
});
return saves;
}

View file

@ -5,28 +5,30 @@ import Decimal from "util/bignum";
export const ProxyState = Symbol("ProxyState"); export const ProxyState = Symbol("ProxyState");
export const ProxyPath = Symbol("ProxyPath"); export const ProxyPath = Symbol("ProxyPath");
export type ProxiedWithState<T> = NonNullable<T> extends Record<PropertyKey, unknown> export type ProxiedWithState<T> =
? NonNullable<T> extends Decimal NonNullable<T> extends Record<PropertyKey, unknown>
? T ? NonNullable<T> extends Decimal
: { ? T
[K in keyof T]: ProxiedWithState<T[K]>; : {
} & { [K in keyof T]: ProxiedWithState<T[K]>;
[ProxyState]: T; } & {
[ProxyPath]: string[]; [ProxyState]: T;
} [ProxyPath]: string[];
: T; }
: T;
export type Proxied<T> = NonNullable<T> extends Record<PropertyKey, unknown> export type Proxied<T> =
? NonNullable<T> extends Persistent<infer S> NonNullable<T> extends Record<PropertyKey, unknown>
? NonPersistent<S> ? NonNullable<T> extends Persistent<infer S>
: NonNullable<T> extends Decimal ? NonPersistent<S>
? T : NonNullable<T> extends Decimal
: { ? T
[K in keyof T]: Proxied<T[K]>; : {
} & { [K in keyof T]: Proxied<T[K]>;
[ProxyState]: T; } & {
} [ProxyState]: T;
: T; }
: T;
// Takes a function that returns an object and pretends to be that object // Takes a function that returns an object and pretends to be that object
// Note that the object is lazily calculated // Note that the object is lazily calculated

View file

@ -1,10 +1,13 @@
import { LoadablePlayerData } from "components/modals/SavesManager.vue";
import { fixOldSave, getInitialLayers } from "data/projEntry";
import projInfo from "data/projInfo.json"; import projInfo from "data/projInfo.json";
import { globalBus } from "game/events"; import { globalBus } from "game/events";
import { addLayer, layers, removeLayer } from "game/layers";
import type { Player } from "game/player"; import type { Player } from "game/player";
import player, { stringifySave } from "game/player"; import player, { stringifySave } from "game/player";
import settings, { loadSettings } from "game/settings"; import settings, { loadSettings } from "game/settings";
import LZString from "lz-string"; import LZString from "lz-string";
import { ref } from "vue"; import { ref, shallowReactive } from "vue";
export function setupInitialStore(player: Partial<Player> = {}): Player { export function setupInitialStore(player: Partial<Player> = {}): Player {
return Object.assign( return Object.assign(
@ -42,17 +45,9 @@ export async function load(): Promise<void> {
await loadSave(newSave()); await loadSave(newSave());
return; return;
} }
if (save[0] === "{") { save = decodeSave(save);
// plaintext. No processing needed if (save == null) {
} else if (save[0] === "e") { throw "Unable to determine save encoding";
// Assumed to be base64, which starts with e
save = decodeURIComponent(escape(atob(save)));
} else if (save[0] === "ᯡ") {
// Assumed to be lz, which starts with ᯡ
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
save = LZString.decompressFromUTF16(save)!;
} else {
throw `Unable to determine save encoding`;
} }
const player = JSON.parse(save); const player = JSON.parse(save);
if (player.modID !== projInfo.id) { if (player.modID !== projInfo.id) {
@ -67,6 +62,23 @@ export async function load(): Promise<void> {
} }
} }
export function decodeSave(save: string) {
if (save[0] === "{") {
// plaintext. No processing needed
} else if (save[0] === "e") {
// Assumed to be base64, which starts with e
save = decodeURIComponent(escape(atob(save)));
} else if (save[0] === "ᯡ") {
// Assumed to be lz, which starts with ᯡ
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
save = LZString.decompressFromUTF16(save)!;
} else {
console.warn("Unable to determine preset encoding", save);
return null;
}
return save;
}
export function newSave(): Player { export function newSave(): Player {
const id = getUniqueID(); const id = getUniqueID();
const player = setupInitialStore({ id }); const player = setupInitialStore({ id });
@ -91,8 +103,6 @@ export const loadingSave = ref(false);
export async function loadSave(playerObj: Partial<Player>): Promise<void> { export async function loadSave(playerObj: Partial<Player>): Promise<void> {
console.info("Loading save", playerObj); console.info("Loading save", playerObj);
loadingSave.value = true; loadingSave.value = true;
const { layers, removeLayer, addLayer } = await import("game/layers");
const { fixOldSave, getInitialLayers } = await import("data/projEntry");
for (const layer in layers) { for (const layer in layers) {
const l = layers[layer]; const l = layers[layer];
@ -109,7 +119,7 @@ export async function loadSave(playerObj: Partial<Player>): Promise<void> {
playerObj.time && playerObj.time &&
playerObj.devSpeed !== 0 playerObj.devSpeed !== 0
) { ) {
if (playerObj.offlineTime == undefined) playerObj.offlineTime = 0; if (playerObj.offlineTime == null) playerObj.offlineTime = 0;
playerObj.offlineTime += Math.min( playerObj.offlineTime += Math.min(
playerObj.offlineTime + (Date.now() - playerObj.time) / 1000, playerObj.offlineTime + (Date.now() - playerObj.time) / 1000,
projInfo.offlineLimit * 3600 projInfo.offlineLimit * 3600
@ -127,6 +137,40 @@ export async function loadSave(playerObj: Partial<Player>): Promise<void> {
globalBus.emit("onLoad"); globalBus.emit("onLoad");
} }
const cachedSaves = shallowReactive<Record<string, LoadablePlayerData | undefined>>({});
export function getCachedSave(id: string) {
if (cachedSaves[id] == null) {
let save = localStorage.getItem(id);
if (save == null) {
cachedSaves[id] = { error: `Save doesn't exist in localStorage`, id };
} else if (save === "dW5kZWZpbmVk") {
cachedSaves[id] = { error: `Save is undefined`, id };
} else {
try {
save = decodeSave(save);
if (save == null) {
console.warn("Unable to determine preset encoding", save);
cachedSaves[id] = { error: "Unable to determine preset encoding", id };
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return cachedSaves[id]!;
}
cachedSaves[id] = { ...JSON.parse(save), id };
} catch (error) {
cachedSaves[id] = { error, id };
console.warn(`Failed to load info about save with id ${id}:\n${error}\n${save}`);
}
}
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return cachedSaves[id]!;
}
export function clearCachedSaves() {
Object.keys(cachedSaves).forEach(key => delete cachedSaves[key]);
}
export function clearCachedSave(id: string) {
cachedSaves[id] = undefined;
}
setInterval(() => { setInterval(() => {
if (player.autosave) { if (player.autosave) {
save(); save();

View file

@ -1,16 +1,20 @@
/* eslint-disable vue/multi-word-component-names */
// ^ I have no idea why that's necessary; the rule is disabled, and this file isn't a vue component?
// I'm _guessing_ it's related to us using DefineComponent, but I figured that eslint rule should
// only apply to SFCs
import Col from "components/layout/Column.vue"; import Col from "components/layout/Column.vue";
import Row from "components/layout/Row.vue"; import Row from "components/layout/Row.vue";
import type { CoercableComponent, GenericComponent, JSXFunction } from "features/feature"; import type { CoercableComponent, GenericComponent, JSXFunction } from "features/feature";
import { import {
Component as ComponentKey, Component as ComponentKey,
GatherProps, GatherProps,
Visibility,
isVisible, isVisible,
jsx, jsx
Visibility
} from "features/feature"; } from "features/feature";
import type { ProcessedComputable } from "util/computed"; import type { ProcessedComputable } from "util/computed";
import { DoNotCache } from "util/computed"; import { DoNotCache } from "util/computed";
import type { Component, ComputedRef, DefineComponent, PropType, Ref, ShallowRef } from "vue"; import type { Component, DefineComponent, Ref, ShallowRef, UnwrapRef } from "vue";
import { import {
computed, computed,
defineComponent, defineComponent,
@ -21,6 +25,7 @@ import {
unref, unref,
watchEffect watchEffect
} from "vue"; } from "vue";
import { JSX } from "vue/jsx-runtime";
import { camelToKebab } from "./common"; import { camelToKebab } from "./common";
export function coerceComponent( export function coerceComponent(
@ -125,17 +130,17 @@ export function setupHoldToClick(
stop: VoidFunction; stop: VoidFunction;
handleHolding: VoidFunction; handleHolding: VoidFunction;
} { } {
const interval = ref<NodeJS.Timer | null>(null); const interval = ref<NodeJS.Timeout | null>(null);
const event = ref<MouseEvent | TouchEvent | undefined>(undefined); const event = ref<MouseEvent | TouchEvent | undefined>(undefined);
function start(e: MouseEvent | TouchEvent) { function start(e: MouseEvent | TouchEvent) {
if (!interval.value) { if (interval.value == null) {
interval.value = setInterval(handleHolding, 250); interval.value = setInterval(handleHolding, 250);
} }
event.value = e; event.value = e;
} }
function stop() { function stop() {
if (interval.value) { if (interval.value != null) {
clearInterval(interval.value); clearInterval(interval.value);
interval.value = null; interval.value = null;
} }
@ -174,36 +179,38 @@ export function getFirstFeature<
} }
export function computeComponent( export function computeComponent(
component: Ref<ProcessedComputable<CoercableComponent>>, component: Ref<CoercableComponent>,
defaultWrapper = "div" defaultWrapper = "div"
): ShallowRef<Component | ""> { ): ShallowRef<Component | ""> {
const comp = shallowRef<Component | "">(); const comp = shallowRef<Component | "">();
watchEffect(() => { watchEffect(() => {
comp.value = coerceComponent(unwrapRef(component), defaultWrapper); comp.value = coerceComponent(unref(component), defaultWrapper);
}); });
return comp as ShallowRef<Component | "">; return comp as ShallowRef<Component | "">;
} }
export function computeOptionalComponent( export function computeOptionalComponent(
component: Ref<ProcessedComputable<CoercableComponent | undefined> | undefined>, component: Ref<CoercableComponent | undefined>,
defaultWrapper = "div" defaultWrapper = "div"
): ShallowRef<Component | "" | null> { ): ShallowRef<Component | "" | null> {
const comp = shallowRef<Component | "" | null>(null); const comp = shallowRef<Component | "" | null>(null);
watchEffect(() => { watchEffect(() => {
const currComponent = unwrapRef(component); const currComponent = unref(component);
comp.value = comp.value =
currComponent == "" || currComponent == null currComponent === "" || currComponent == null
? null ? null
: coerceComponent(currComponent, defaultWrapper); : coerceComponent(currComponent, defaultWrapper);
}); });
return comp; return comp;
} }
export function wrapRef<T>(ref: Ref<ProcessedComputable<T>>): ComputedRef<T> { export function deepUnref<T extends object>(refObject: T): { [K in keyof T]: UnwrapRef<T[K]> } {
return computed(() => unwrapRef(ref)); return (Object.keys(refObject) as (keyof T)[]).reduce(
} (acc, curr) => {
acc[curr] = unref(refObject[curr]) as UnwrapRef<T[keyof T]>;
export function unwrapRef<T>(ref: Ref<ProcessedComputable<T>>): T { return acc;
return unref<T>(unref(ref)); },
{} as { [K in keyof T]: UnwrapRef<T[K]> }
);
} }
export function setRefValue<T>(ref: Ref<T | Ref<T>>, value: T) { export function setRefValue<T>(ref: Ref<T | Ref<T>>, value: T) {
@ -221,14 +228,6 @@ export type PropTypes =
| typeof Function | typeof Function
| typeof Object | typeof Object
| typeof Array; | typeof Array;
// TODO Unfortunately, the typescript engine gives up on typing completely when you use this method,
// Even though it has the same typing as when doing it manually
export function processedPropType<T>(...types: PropTypes[]): PropType<ProcessedComputable<T>> {
if (!types.includes(Object)) {
types.push(Object);
}
return types as PropType<ProcessedComputable<T>>;
}
export function trackHover(element: VueFeature): Ref<boolean> { export function trackHover(element: VueFeature): Ref<boolean> {
const isHovered = ref(false); const isHovered = ref(false);
@ -244,8 +243,11 @@ export function trackHover(element: VueFeature): Ref<boolean> {
} }
export function kebabifyObject(object: Record<string, unknown>) { export function kebabifyObject(object: Record<string, unknown>) {
return Object.keys(object).reduce((acc, curr) => { return Object.keys(object).reduce(
acc[camelToKebab(curr)] = object[curr]; (acc, curr) => {
return acc; acc[camelToKebab(curr)] = object[curr];
}, {} as Record<string, unknown>); return acc;
},
{} as Record<string, unknown>
);
} }

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", () => { describe("Formula aliases", () => {
function testAliases<T extends FormulaFunctions>( function testAliases<T extends FormulaFunctions>(
aliases: T[], aliases: T[],
args: Parameters<typeof Formula[T]> args: Parameters<(typeof Formula)[T]>
) { ) {
describe(aliases[0], () => { describe(aliases[0], () => {
let formula: GenericFormula; let formula: GenericFormula;
@ -250,7 +250,7 @@ describe("Creating Formulas", () => {
function checkFormula<T extends FormulaFunctions>( function checkFormula<T extends FormulaFunctions>(
functionName: T, functionName: T,
args: Readonly<Parameters<typeof Formula[T]>> args: Readonly<Parameters<(typeof Formula)[T]>>
) { ) {
let formula: GenericFormula; let formula: GenericFormula;
beforeAll(() => { beforeAll(() => {
@ -274,7 +274,7 @@ describe("Creating Formulas", () => {
// It's a lot of tests, but I'd rather be exhaustive // It's a lot of tests, but I'd rather be exhaustive
function testFormulaCall<T extends FormulaFunctions>( function testFormulaCall<T extends FormulaFunctions>(
functionName: T, functionName: T,
args: Readonly<Parameters<typeof Formula[T]>> args: Readonly<Parameters<(typeof Formula)[T]>>
) { ) {
if ((functionName === "slog" || functionName === "layeradd") && args[0] === -1) { if ((functionName === "slog" || functionName === "layeradd") && args[0] === -1) {
// These cases in particular take a long time, so skip them // 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 { CoercableComponent, JSXFunction } from "features/feature";
import Formula, { printFormula } from "game/formulas/formulas"; import Formula from "game/formulas/formulas";
import { import {
createAdditiveModifier, createAdditiveModifier,
createExponentialModifier, createExponentialModifier,
@ -52,7 +52,7 @@ function testModifiers<
expect(modifier.invert(operation(10, 5))).compare_tolerance(10)); expect(modifier.invert(operation(10, 5))).compare_tolerance(10));
test("getFormula returns the right formula", () => { test("getFormula returns the right formula", () => {
const value = ref(10); 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)` `${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)); expect(modifier.invert(Decimal.add(10, 5).times(5).pow(5))).compare_tolerance(10));
test("getFormula returns the right formula", () => { test("getFormula returns the right formula", () => {
const value = ref(10); 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)` `pow(mul(add(x, 5.00), 5.00), 5.00)`
); );
}); });

View file

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

View file

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

View file

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