Merge remote-tracking branch 'template/feature/feat-and-board-rewrite' into feat/board-feature-rewrite
This commit is contained in:
commit
ffd9529e9a
147 changed files with 13384 additions and 16765 deletions
1
.eslintignore
Normal file
1
.eslintignore
Normal file
|
@ -0,0 +1 @@
|
||||||
|
.eslintrc.cjs
|
|
@ -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: {
|
|
@ -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: |
|
||||||
|
|
|
@ -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
|
||||||
|
|
5
.github/workflows/test.yml
vendored
5
.github/workflows/test.yml
vendored
|
@ -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
|
||||||
|
|
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
@ -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,
|
||||||
|
|
34
CHANGELOG.md
34
CHANGELOG.md
|
@ -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
31
CONTRIBUTING.md
Normal 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).
|
|
@ -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
6738
package-lock.json
generated
File diff suppressed because it is too large
Load diff
74
package.json
74
package.json
|
@ -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.13",
|
||||||
|
"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.5.4",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
23
src/App.vue
23
src/App.vue
|
@ -1,14 +1,18 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-if="appErrors.length > 0" class="error-container" :style="theme"><Error :errors="appErrors" /></div>
|
<div v-if="appErrors.length > 0" class="error-container" :style="theme">
|
||||||
|
<Error :errors="appErrors" />
|
||||||
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div id="modal-root" :style="theme" />
|
<div id="modal-root" :style="theme" />
|
||||||
<div class="app" :style="theme" :class="{ useHeader }">
|
<div class="app" :style="theme" :class="{ useHeader }">
|
||||||
<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 />
|
||||||
<component :is="gameComponent" />
|
<CloudSaveResolver />
|
||||||
|
<GameComponent />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
@ -16,14 +20,15 @@
|
||||||
<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 { jsx } from "features/feature";
|
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 state from "game/state";
|
import state from "game/state";
|
||||||
import { coerceComponent, render } from "util/vue";
|
import { 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";
|
||||||
|
@ -36,9 +41,7 @@ const theme = computed(() => themes[settings.theme].variables as CSSProperties);
|
||||||
const showTPS = toRef(settings, "showTPS");
|
const showTPS = toRef(settings, "showTPS");
|
||||||
const appErrors = toRef(state, "errors");
|
const appErrors = toRef(state, "errors");
|
||||||
|
|
||||||
const gameComponent = computed(() => {
|
const GameComponent = () => gameComponents.map(c => render(c));
|
||||||
return coerceComponent(jsx(() => (<>{gameComponents.map(render)}</>)));
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
@ -9,9 +9,9 @@
|
||||||
>
|
>
|
||||||
<Nav v-if="index === 0 && !useHeader" />
|
<Nav v-if="index === 0 && !useHeader" />
|
||||||
<div class="inner-tab">
|
<div class="inner-tab">
|
||||||
<Layer
|
<LayerVue
|
||||||
v-if="layerKeys.includes(tab)"
|
v-if="layerKeys.includes(tab)"
|
||||||
v-bind="gatherLayerProps(layers[tab]!)"
|
v-bind="gatherLayerProps(layers[tab])"
|
||||||
:index="index"
|
:index="index"
|
||||||
@set-minimized="(value: boolean) => (layers[tab]!.minimized.value = value)"
|
@set-minimized="(value: boolean) => (layers[tab]!.minimized.value = value)"
|
||||||
/>
|
/>
|
||||||
|
@ -23,20 +23,37 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import projInfo from "data/projInfo.json";
|
import projInfo from "data/projInfo.json";
|
||||||
import type { GenericLayer } from "game/layers";
|
import { type Layer, layers } from "game/layers";
|
||||||
import { layers } from "game/layers";
|
|
||||||
import player from "game/player";
|
import player from "game/player";
|
||||||
import { computed, toRef, unref } from "vue";
|
import { computed, toRef, unref } from "vue";
|
||||||
import Layer from "./Layer.vue";
|
import LayerVue from "./Layer.vue";
|
||||||
import Nav from "./Nav.vue";
|
import Nav from "./Nav.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: Layer) {
|
||||||
const { display, minimized, name, color, minimizable, nodes, minimizedDisplay } = layer;
|
const {
|
||||||
return { display, minimized, name, color, minimizable, nodes, minimizedDisplay };
|
display,
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
minimizable,
|
||||||
|
minimizedDisplay,
|
||||||
|
minimized,
|
||||||
|
nodes,
|
||||||
|
forceHideGoBack
|
||||||
|
} = layer;
|
||||||
|
return {
|
||||||
|
display,
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
minimizable,
|
||||||
|
minimizedDisplay,
|
||||||
|
minimized,
|
||||||
|
nodes,
|
||||||
|
forceHideGoBack
|
||||||
|
};
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -12,11 +12,11 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { GenericHotkey } from "features/hotkey";
|
import { Hotkey } from "features/hotkey";
|
||||||
import { watchEffect } from "vue";
|
import { watchEffect } from "vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
hotkey: GenericHotkey;
|
hotkey: Hotkey;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
let key = "";
|
let key = "";
|
||||||
|
|
|
@ -8,12 +8,12 @@
|
||||||
v-if="unref(minimized)"
|
v-if="unref(minimized)"
|
||||||
@click="$emit('setMinimized', false)"
|
@click="$emit('setMinimized', false)"
|
||||||
>
|
>
|
||||||
<component v-if="minimizedComponent" :is="minimizedComponent" />
|
<MinimizedComponent v-if="minimizedDisplay" />
|
||||||
<div v-else>{{ unref(name) }}</div>
|
<div v-else>{{ unref(name) }}</div>
|
||||||
</button>
|
</button>
|
||||||
<div class="layer-tab" :class="{ showGoBack }" v-else>
|
<div class="layer-tab" :class="{ showGoBack }" v-else>
|
||||||
<Context @update-nodes="updateNodes">
|
<Context @update-nodes="updateNodes">
|
||||||
<component :is="component" />
|
<Component />
|
||||||
</Context>
|
</Context>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -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 { Layer, 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 { render } from "util/vue";
|
||||||
import { PropType, Ref, computed, defineComponent, onErrorCaptured, ref, toRefs, unref } from "vue";
|
import { computed, onErrorCaptured, ref, 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 },
|
display: Layer["display"];
|
||||||
props: {
|
minimizedDisplay: Layer["minimizedDisplay"];
|
||||||
index: {
|
minimized: Layer["minimized"];
|
||||||
type: Number,
|
name: Layer["name"];
|
||||||
required: true
|
color: Layer["color"];
|
||||||
},
|
minimizable: Layer["minimizable"];
|
||||||
display: {
|
nodes: Layer["nodes"];
|
||||||
type: processedPropType<CoercableComponent>(Object, String, Function),
|
forceHideGoBack: Layer["forceHideGoBack"];
|
||||||
required: true
|
index: number;
|
||||||
},
|
}>();
|
||||||
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 = () => render(props.display);
|
||||||
const minimizedComponent = computeOptionalComponent(minimizedDisplay);
|
const MinimizedComponent = () => props.minimizedDisplay == null ? undefined : render(props.minimizedDisplay);
|
||||||
const showGoBack = computed(
|
const showGoBack = computed(
|
||||||
() => projInfo.allowGoBack && index.value > 0 && !unwrapRef(minimized)
|
() => projInfo.allowGoBack && !unref(props.forceHideGoBack) && 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>
|
||||||
|
|
||||||
|
|
|
@ -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()">
|
||||||
|
@ -97,13 +97,15 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
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 "wrappers/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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
.feature:not(li),
|
.feature {
|
||||||
.feature:not(li) button {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.feature,
|
||||||
|
.feature button {
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
border: 2px solid rgba(0, 0, 0, 0.125);
|
border: 2px solid rgba(0, 0, 0, 0.125);
|
||||||
|
@ -11,13 +14,17 @@
|
||||||
transition: all 0.5s, z-index 0s 0.5s;
|
transition: all 0.5s, z-index 0s 0.5s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.can,
|
.feature button {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.can,
|
||||||
.can button {
|
.can button {
|
||||||
background-color: var(--layer-color);
|
background-color: var(--layer-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.can:hover,
|
button.can:hover,
|
||||||
.can:hover button {
|
.can:hover button {
|
||||||
transform: scale(1.15, 1.15);
|
transform: scale(1.15, 1.15);
|
||||||
box-shadow: 0 0 20px var(--points);
|
box-shadow: 0 0 20px var(--points);
|
||||||
|
@ -25,13 +32,13 @@
|
||||||
transition: all 0.5s, z-index 0s;
|
transition: all 0.5s, z-index 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.locked,
|
button.locked,
|
||||||
.locked button {
|
.locked button {
|
||||||
background-color: var(--locked);
|
background-color: var(--locked);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bought,
|
button.bought,
|
||||||
.bought button {
|
.bought button {
|
||||||
background-color: var(--bought);
|
background-color: var(--bought);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
|
|
@ -20,11 +20,6 @@
|
||||||
margin: 0 10px;
|
margin: 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row > :not(.feature) {
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col {
|
.col {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: column wrap;
|
flex-flow: column wrap;
|
||||||
|
@ -34,95 +29,148 @@
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row.mergeAdjacent > .feature:not(.dontMerge),
|
.row.mergeAdjacent *,
|
||||||
.row.mergeAdjacent > .tooltip-container > .feature:not(.dontMerge) {
|
.row.mergeAdjacent button.feature,
|
||||||
|
.row.mergeAdjacent .feature button {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.mergeAdjacent button.feature,
|
||||||
|
.row.mergeAdjacent .feature button {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row.mergeAdjacent > .feature:not(.dontMerge):first-child,
|
.row.mergeAdjacent > button.feature:first-child,
|
||||||
.row.mergeAdjacent > .tooltip-container:first-child > .feature:not(.dontMerge) {
|
.row.mergeAdjacent > .feature:first-child button,
|
||||||
|
.row.mergeAdjacent > :first-child button.feature,
|
||||||
|
.row.mergeAdjacent > :first-child .feature button {
|
||||||
border-radius: var(--border-radius) 0 0 var(--border-radius);
|
border-radius: var(--border-radius) 0 0 var(--border-radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
.row.mergeAdjacent > .feature:not(.dontMerge):last-child,
|
.row.mergeAdjacent > button.feature:last-child,
|
||||||
.row.mergeAdjacent > .tooltip-container:last-child > .feature:not(.dontMerge) {
|
.row.mergeAdjacent > .feature:last-child button,
|
||||||
|
.row.mergeAdjacent > :last-child button.feature,
|
||||||
|
.row.mergeAdjacent > :last-child .feature button {
|
||||||
border-radius: 0 var(--border-radius) var(--border-radius) 0;
|
border-radius: 0 var(--border-radius) var(--border-radius) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row.mergeAdjacent > .feature:not(.dontMerge):first-child:last-child,
|
.row.mergeAdjacent > button.feature:first-child:last-child,
|
||||||
.row.mergeAdjacent > .tooltip-container:first-child:last-child > .feature:not(.dontMerge) {
|
.row.mergeAdjacent > .feature:first-child:last-child button,
|
||||||
|
.row.mergeAdjacent > :first-child:last-child button.feature,
|
||||||
|
.row.mergeAdjacent > :first-child:last-child .feature button {
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-grid.mergeAdjacent > .feature:not(.dontMerge),
|
.col.mergeAdjacent *,
|
||||||
.row-grid.mergeAdjacent > .tooltip-container > .feature:not(.dontMerge) {
|
.col.mergeAdjacent button.feature,
|
||||||
margin-left: 0;
|
.col.mergeAdjacent .feature button {
|
||||||
margin-right: 0;
|
|
||||||
margin-bottom: 0;
|
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col.mergeAdjacent button.feature,
|
||||||
|
.col.mergeAdjacent .feature button {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-grid.mergeAdjacent > .feature:not(.dontMerge):last-child,
|
.col.mergeAdjacent > button.feature:first-child,
|
||||||
.row-grid.mergeAdjacent > .tooltip-container:last-child > .feature:not(.dontMerge) {
|
.col.mergeAdjacent > .feature:first-child button,
|
||||||
border-radius: 0 0 0 0;
|
.col.mergeAdjacent > :first-child button.feature,
|
||||||
}
|
.col.mergeAdjacent > :first-child .feature button {
|
||||||
|
|
||||||
|
|
||||||
.row-grid.mergeAdjacent > .feature:not(.dontMerge):first-child,
|
|
||||||
.row-grid.mergeAdjacent > .tooltip-container:first-child > .feature:not(.dontMerge) {
|
|
||||||
border-radius: 0 0 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-grid > .row-grid.mergeAdjacent:last-child > .feature:not(.dontMerge):first-child {
|
|
||||||
border-radius: 0 0 0 var(--border-radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-grid > .row-grid.mergeAdjacent:first-child > .feature:not(.dontMerge):last-child {
|
|
||||||
border-radius: 0 var(--border-radius) 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-grid > .row-grid.mergeAdjacent:first-child > .feature:not(.dontMerge):first-child {
|
|
||||||
border-radius: var(--border-radius) 0 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-grid > .row-grid.mergeAdjacent:last-child > .feature:not(.dontMerge):last-child {
|
|
||||||
border-radius: 0 0 var(--border-radius) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/*
|
|
||||||
TODO how to implement mergeAdjacent for grids?
|
|
||||||
.row.mergeAdjacent + .row.mergeAdjacent > .feature:not(.dontMerge) {
|
|
||||||
border-top-left-radius: 0;
|
|
||||||
border-top-right-radius: 0;
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
.col.mergeAdjacent .feature:not(.dontMerge) {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col.mergeAdjacent .feature:not(.dontMerge):first-child {
|
|
||||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.col.mergeAdjacent .feature:not(.dontMerge):last-child {
|
.col.mergeAdjacent > button.feature:last-child,
|
||||||
|
.col.mergeAdjacent > .feature:last-child button,
|
||||||
|
.col.mergeAdjacent > :last-child button.feature,
|
||||||
|
.col.mergeAdjacent > :last-child .feature button {
|
||||||
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
.col.mergeAdjacent .feature:not(.dontMerge):first-child:last-child {
|
.col.mergeAdjacent > button.feature:first-child:last-child,
|
||||||
|
.col.mergeAdjacent > .feature:first-child:last-child button,
|
||||||
|
.col.mergeAdjacent > :first-child:last-child button.feature,
|
||||||
|
.col.mergeAdjacent > :first-child:last-child .feature button {
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
.col.mergeAdjacent > .table > .row.mergeAdjacent:first-child > button.feature:not(:first-child):not(:last-child),
|
||||||
TODO how to implement mergeAdjacent for grids?
|
.col.mergeAdjacent > .table > .row.mergeAdjacent:first-child > .feature:not(:first-child):not(:last-child) button,
|
||||||
.col.mergeAdjacent + .col.mergeAdjacent > .feature:not(.dontMerge) {
|
.col.mergeAdjacent > .table > .row.mergeAdjacent:first-child > :not(:first-child):not(:last-child) button.feature,
|
||||||
border-top-left-radius: 0;
|
.col.mergeAdjacent > .table > .row.mergeAdjacent:first-child > :not(:first-child):not(:last-child) .feature button,
|
||||||
border-bottom-left-radius: 0;
|
|
||||||
|
.col.mergeAdjacent > .table > .row.mergeAdjacent:last-child > button.feature:not(:first-child):not(:last-child),
|
||||||
|
.col.mergeAdjacent > .table > .row.mergeAdjacent:last-child > .feature:not(:first-child):not(:last-child) button,
|
||||||
|
.col.mergeAdjacent > .table > .row.mergeAdjacent:last-child > :not(:first-child):not(:last-child) button.feature,
|
||||||
|
.col.mergeAdjacent > .table > .row.mergeAdjacent:last-child > :not(:first-child):not(:last-child) .feature button
|
||||||
|
|
||||||
|
.col.mergeAdjacent > .table:not(:first-child):not(:last-child) > .row.mergeAdjacent > button.feature,
|
||||||
|
.col.mergeAdjacent > .table:not(:first-child):not(:last-child) > .row.mergeAdjacent > .feature button,
|
||||||
|
.col.mergeAdjacent > .table:not(:first-child):not(:last-child) > .row.mergeAdjacent > * button.feature,
|
||||||
|
.col.mergeAdjacent > .table:not(:first-child):not(:last-child) > .row.mergeAdjacent > * .feature button
|
||||||
|
|
||||||
|
.row.mergeAdjacent > .table > .col.mergeAdjacent:first-child > button.feature:not(:first-child):not(:last-child),
|
||||||
|
.row.mergeAdjacent > .table > .col.mergeAdjacent:first-child > .feature:not(:first-child):not(:last-child) button,
|
||||||
|
.row.mergeAdjacent > .table > .col.mergeAdjacent:first-child > :not(:first-child):not(:last-child) button.feature,
|
||||||
|
.row.mergeAdjacent > .table > .col.mergeAdjacent:first-child > :not(:first-child):not(:last-child) .feature button,
|
||||||
|
|
||||||
|
.row.mergeAdjacent > .table > .col.mergeAdjacent:last-child > button.feature:not(:first-child):not(:last-child),
|
||||||
|
.row.mergeAdjacent > .table > .col.mergeAdjacent:last-child > .feature:not(:first-child):not(:last-child) button,
|
||||||
|
.row.mergeAdjacent > .table > .col.mergeAdjacent:last-child > :not(:first-child):not(:last-child) button.feature,
|
||||||
|
.row.mergeAdjacent > .table > .col.mergeAdjacent:last-child > :not(:first-child):not(:last-child) .feature button
|
||||||
|
|
||||||
|
.row.mergeAdjacent > .table:not(:first-child):not(:last-child) > .col.mergeAdjacent > button.feature,
|
||||||
|
.row.mergeAdjacent > .table:not(:first-child):not(:last-child) > .col.mergeAdjacent > .feature button,
|
||||||
|
.row.mergeAdjacent > .table:not(:first-child):not(:last-child) > .col.mergeAdjacent > * button.feature,
|
||||||
|
.row.mergeAdjacent > .table:not(:first-child):not(:last-child) > .col.mergeAdjacent > * .feature button {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col.mergeAdjacent > .table:first-child > .row.mergeAdjacent > button.feature:first-child,
|
||||||
|
.col.mergeAdjacent > .table:first-child > .row.mergeAdjacent > .feature:first-child button,
|
||||||
|
.col.mergeAdjacent > .table:first-child > .row.mergeAdjacent > :first-child button.feature,
|
||||||
|
.col.mergeAdjacent > .table:first-child > .row.mergeAdjacent > :first-child .feature button,
|
||||||
|
|
||||||
|
.row.mergeAdjacent > .table:first-child > .col.mergeAdjacent > button.feature:first-child,
|
||||||
|
.row.mergeAdjacent > .table:first-child > .col.mergeAdjacent > .feature:first-child button,
|
||||||
|
.row.mergeAdjacent > .table:first-child > .col.mergeAdjacent > :first-child button.feature,
|
||||||
|
.row.mergeAdjacent > .table:first-child > .col.mergeAdjacent > :first-child .feature button {
|
||||||
|
border-radius: var(--border-radius) 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col.mergeAdjacent > .table:first-child > .row.mergeAdjacent > button.feature:last-child,
|
||||||
|
.col.mergeAdjacent > .table:first-child > .row.mergeAdjacent > .feature:last-child button,
|
||||||
|
.col.mergeAdjacent > .table:first-child > .row.mergeAdjacent > :last-child button.feature,
|
||||||
|
.col.mergeAdjacent > .table:first-child > .row.mergeAdjacent > :last-child .feature button,
|
||||||
|
|
||||||
|
.row.mergeAdjacent > .table:first-child > .col.mergeAdjacent > button.feature:last-child,
|
||||||
|
.row.mergeAdjacent > .table:first-child > .col.mergeAdjacent > .feature:last-child button,
|
||||||
|
.row.mergeAdjacent > .table:first-child > .col.mergeAdjacent > :last-child button.feature,
|
||||||
|
.row.mergeAdjacent > .table:first-child > .col.mergeAdjacent > :last-child .feature button {
|
||||||
|
border-radius: 0 var(--border-radius) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col.mergeAdjacent > .table:last-child > .row.mergeAdjacent > button.feature:last-child,
|
||||||
|
.col.mergeAdjacent > .table:last-child > .row.mergeAdjacent > .feature:last-child button,
|
||||||
|
.col.mergeAdjacent > .table:last-child > .row.mergeAdjacent > :last-child button.feature,
|
||||||
|
.col.mergeAdjacent > .table:last-child > .row.mergeAdjacent > :last-child .feature button,
|
||||||
|
|
||||||
|
.row.mergeAdjacent > .table:last-child > .col.mergeAdjacent > button.feature:last-child,
|
||||||
|
.row.mergeAdjacent > .table:last-child > .col.mergeAdjacent > .feature:last-child button,
|
||||||
|
.row.mergeAdjacent > .table:last-child > .col.mergeAdjacent > :last-child button.feature,
|
||||||
|
.row.mergeAdjacent > .table:last-child > .col.mergeAdjacent > :last-child .feature button {
|
||||||
|
border-radius: 0 0 var(--border-radius) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col.mergeAdjacent > .table:last-child > .row.mergeAdjacent > button.feature:first-child,
|
||||||
|
.col.mergeAdjacent > .table:last-child > .row.mergeAdjacent > .feature:first-child button,
|
||||||
|
.col.mergeAdjacent > .table:last-child > .row.mergeAdjacent > :first-child button.feature,
|
||||||
|
.col.mergeAdjacent > .table:last-child > .row.mergeAdjacent > :first-child .feature button,
|
||||||
|
|
||||||
|
.row.mergeAdjacent > .table:last-child > .col.mergeAdjacent > button.feature:first-child,
|
||||||
|
.row.mergeAdjacent > .table:last-child > .col.mergeAdjacent > .feature:first-child button,
|
||||||
|
.row.mergeAdjacent > .table:last-child > .col.mergeAdjacent > :first-child button.feature,
|
||||||
|
.row.mergeAdjacent > .table:last-child > .col.mergeAdjacent > :first-child .feature button {
|
||||||
|
border-radius: 0 0 0 var(--border-radius);
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -1,30 +1,29 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="field-title" v-if="titleComponent"><component :is="titleComponent" /></span>
|
<span class="field-title" v-if="title"><Title /></span>
|
||||||
<VueNextSelect
|
<VueNextSelect
|
||||||
:options="options"
|
:options="options"
|
||||||
v-model="value"
|
v-model="value"
|
||||||
@update:model-value="onUpdate"
|
|
||||||
:min="1"
|
:min="1"
|
||||||
label-by="label"
|
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:close-on-select="closeOnSelect"
|
:close-on-select="closeOnSelect"
|
||||||
|
@update:model-value="onUpdate"
|
||||||
|
label-by="label"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="tsx">
|
||||||
import "components/common/fields.css";
|
import "components/common/fields.css";
|
||||||
import type { CoercableComponent } from "features/feature";
|
import { render, Renderable } from "util/vue";
|
||||||
import { computeOptionalComponent, unwrapRef } from "util/vue";
|
import { MaybeRef, ref, toRef, unref, watch } from "vue";
|
||||||
import { ref, toRef, 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";
|
||||||
|
|
||||||
export type SelectOption = { label: string; value: unknown };
|
export type SelectOption = { label: string; value: unknown };
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
title?: CoercableComponent;
|
title?: MaybeRef<Renderable>;
|
||||||
modelValue?: unknown;
|
modelValue?: unknown;
|
||||||
options: SelectOption[];
|
options: SelectOption[];
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
@ -34,13 +33,13 @@ const emit = defineEmits<{
|
||||||
(e: "update:modelValue", value: unknown): void;
|
(e: "update:modelValue", value: unknown): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const titleComponent = computeOptionalComponent(toRef(props, "title"), "span");
|
const Title = () => props.title ? render(props.title, el => <span>{el}</span>) : <></>;
|
||||||
|
|
||||||
const value = ref<SelectOption | null>(
|
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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -9,24 +9,24 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import "components/common/fields.css";
|
import "components/common/fields.css";
|
||||||
import Tooltip from "features/tooltips/Tooltip.vue";
|
import Tooltip from "wrappers/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));
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
<template>
|
<template>
|
||||||
<form @submit.prevent="submit">
|
<form @submit.prevent="submit">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="field-title" v-if="titleComponent"
|
<span class="field-title" v-if="title">
|
||||||
><component :is="titleComponent"
|
<Title />
|
||||||
/></span>
|
</span>
|
||||||
<VueTextareaAutosize
|
<VueTextareaAutosize
|
||||||
v-if="textArea"
|
v-if="textArea"
|
||||||
v-model="value"
|
v-model="value"
|
||||||
|
@ -25,15 +25,14 @@
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="tsx">
|
||||||
import "components/common/fields.css";
|
import "components/common/fields.css";
|
||||||
import type { CoercableComponent } from "features/feature";
|
import { render, Renderable } from "util/vue";
|
||||||
import { computeOptionalComponent } from "util/vue";
|
import { computed, MaybeRef, onMounted, shallowRef, unref } from "vue";
|
||||||
import { computed, onMounted, shallowRef, toRef, unref } from "vue";
|
|
||||||
import VueTextareaAutosize from "vue-textarea-autosize";
|
import VueTextareaAutosize from "vue-textarea-autosize";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
title?: CoercableComponent;
|
title?: MaybeRef<Renderable>;
|
||||||
modelValue?: string;
|
modelValue?: string;
|
||||||
textArea?: boolean;
|
textArea?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
@ -46,7 +45,7 @@ const emit = defineEmits<{
|
||||||
(e: "cancel"): void;
|
(e: "cancel"): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const titleComponent = computeOptionalComponent(toRef(props, "title"), "span");
|
const Title = () => props.title == null ? <></> : render(props.title, el => <span>{el}</span>);
|
||||||
|
|
||||||
const field = shallowRef<HTMLElement | null>(null);
|
const field = shallowRef<HTMLElement | null>(null);
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|
|
@ -1,25 +1,24 @@
|
||||||
<template>
|
<template>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<input type="checkbox" class="toggle" v-model="value" />
|
<input type="checkbox" class="toggle" v-model="value" />
|
||||||
<component :is="component" />
|
<Component />
|
||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="tsx">
|
||||||
import "components/common/fields.css";
|
import "components/common/fields.css";
|
||||||
import type { CoercableComponent } from "features/feature";
|
import { render, Renderable } from "util/vue";
|
||||||
import { coerceComponent } from "util/vue";
|
import { computed, MaybeRef } from "vue";
|
||||||
import { computed, unref } from "vue";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
title?: CoercableComponent;
|
title?: MaybeRef<Renderable>;
|
||||||
modelValue?: boolean;
|
modelValue?: boolean;
|
||||||
}>();
|
}>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "update:modelValue", value: boolean): void;
|
(e: "update:modelValue", value: boolean): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const component = computed(() => coerceComponent(unref(props.title) ?? "<span></span>", "span"));
|
const Component = () => render(props.title ?? "", el => <span>{el}</span>);
|
||||||
|
|
||||||
const value = computed({
|
const value = computed({
|
||||||
get() {
|
get() {
|
||||||
|
|
|
@ -1,27 +1,25 @@
|
||||||
<template>
|
<template>
|
||||||
<Col class="collapsible-container">
|
<Col class="collapsible-container">
|
||||||
<button @click="collapsed.value = !collapsed.value" class="feature collapsible-toggle">
|
<button @click="collapsed.value = !collapsed.value" class="feature collapsible-toggle">
|
||||||
<component :is="displayComponent" />
|
<Display />
|
||||||
</button>
|
</button>
|
||||||
<component v-if="!collapsed.value" :is="contentComponent" />
|
<Content v-if="!collapsed.value" />
|
||||||
</Col>
|
</Col>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { CoercableComponent } from "features/feature";
|
import { render, Renderable } from "util/vue";
|
||||||
import { computeComponent } from "util/vue";
|
import type { MaybeRef, Ref } from "vue";
|
||||||
import type { Ref } from "vue";
|
|
||||||
import { toRef } from "vue";
|
|
||||||
import Col from "./Column.vue";
|
import Col from "./Column.vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
collapsed: Ref<boolean>;
|
collapsed: Ref<boolean>;
|
||||||
display: CoercableComponent;
|
display: MaybeRef<Renderable>;
|
||||||
content: CoercableComponent;
|
content: MaybeRef<Renderable>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const displayComponent = computeComponent(toRef(props, "display"));
|
const Display = () => render(props.display);
|
||||||
const contentComponent = computeComponent(toRef(props, "content"));
|
const Content = () => render(props.content);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
@ -12,5 +12,10 @@ import themes from "data/themes";
|
||||||
import settings from "game/settings";
|
import settings from "game/settings";
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
|
||||||
const mergeAdjacent = computed(() => themes[settings.theme].mergeAdjacent);
|
const props = defineProps<{
|
||||||
|
dontMerge?: boolean
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const mergeAdjacent = computed(() =>
|
||||||
|
themes[settings.theme].mergeAdjacent && props.dontMerge !== true);
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -12,5 +12,10 @@ import themes from "data/themes";
|
||||||
import settings from "game/settings";
|
import settings from "game/settings";
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
|
||||||
const mergeAdjacent = computed(() => themes[settings.theme].mergeAdjacent);
|
const props = defineProps<{
|
||||||
|
dontMerge?: boolean
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const mergeAdjacent = computed(() =>
|
||||||
|
themes[settings.theme].mergeAdjacent && props.dontMerge !== true);
|
||||||
</script>
|
</script>
|
||||||
|
|
84
src/components/modals/AddictionWarning.vue
Normal file
84
src/components/modals/AddictionWarning.vue
Normal 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>
|
228
src/components/modals/CloudSaveResolver.vue
Normal file
228
src/components/modals/CloudSaveResolver.vue
Normal 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>
|
|
@ -18,12 +18,18 @@
|
||||||
updates!
|
updates!
|
||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
<div>
|
<div v-if="discordLink && discordName">
|
||||||
<a :href="discordLink" class="game-over-modal-discord-link">
|
<a :href="discordLink" class="game-over-modal-discord-link">
|
||||||
<span class="material-icons game-over-modal-discord">discord</span>
|
<span class="material-icons game-over-modal-discord">discord</span>
|
||||||
{{ discordName }}
|
{{ discordName }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<a href="https://discord.gg/yJ4fjnjU54" class="game-over-modal-discord-link">
|
||||||
|
<span class="material-icons game-over-modal-discord">discord</span>
|
||||||
|
Profectus & Friends
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<Toggle title="Autosave" v-model="autosave" />
|
<Toggle title="Autosave" v-model="autosave" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -37,14 +43,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;
|
||||||
|
|
|
@ -53,35 +53,31 @@
|
||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
<div>Time Played: {{ timePlayed }}</div>
|
<div>Time Played: {{ timePlayed }}</div>
|
||||||
<component :is="infoComponent" />
|
<Info />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
</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 player from "game/player";
|
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 { 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);
|
||||||
|
|
||||||
const timePlayed = computed(() => formatTime(player.timePlayed));
|
const timePlayed = computed(() => formatTime(player.timePlayed));
|
||||||
|
|
||||||
const infoComponent = computed(() => {
|
const Info = () => infoComponents.map(f => render(f));
|
||||||
return coerceComponent(jsx(() => (<>{infoComponents.map(render)}</>)));
|
|
||||||
});
|
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
open() {
|
open() {
|
||||||
|
@ -90,7 +86,7 @@ defineExpose({
|
||||||
});
|
});
|
||||||
|
|
||||||
function openChangelog() {
|
function openChangelog() {
|
||||||
unref(props.changelog)?.open();
|
props.changelog?.open();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -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);
|
|
@ -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;
|
|
@ -14,12 +14,13 @@
|
||||||
<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>
|
||||||
<div v-if="isTab('appearance')">
|
<div v-if="isTab('appearance')">
|
||||||
<Select :title="themeTitle" :options="themes" v-model="theme" />
|
<Select :title="themeTitle" :options="themes" v-model="theme" />
|
||||||
<component :is="settingFieldsComponent" />
|
<SettingFields />
|
||||||
<Toggle :title="showTPSTitle" v-model="showTPS" />
|
<Toggle :title="showTPSTitle" v-model="showTPS" />
|
||||||
<Toggle :title="alignModifierUnitsTitle" v-model="alignUnits" />
|
<Toggle :title="alignModifierUnitsTitle" v-model="alignUnits" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -28,20 +29,19 @@
|
||||||
</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 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 { coerceComponent, render } from "util/vue";
|
import { save } from "util/save";
|
||||||
|
import { render } from "util/vue";
|
||||||
import { computed, ref, toRefs } from "vue";
|
import { computed, ref, toRefs } from "vue";
|
||||||
import Select from "./fields/Select.vue";
|
import Tooltip from "wrappers/tooltips/Tooltip.vue";
|
||||||
import Toggle from "./fields/Toggle.vue";
|
import FeedbackButton from "../fields/FeedbackButton.vue";
|
||||||
import FeedbackButton from "./fields/FeedbackButton.vue";
|
import Select from "../fields/Select.vue";
|
||||||
|
import Toggle from "../fields/Toggle.vue";
|
||||||
|
import Modal from "./Modal.vue";
|
||||||
|
|
||||||
const isOpen = ref(false);
|
const isOpen = ref(false);
|
||||||
const currentTab = ref("behaviour");
|
const currentTab = ref("behaviour");
|
||||||
|
@ -68,11 +68,9 @@ const themes = Object.keys(rawThemes).map(theme => ({
|
||||||
value: theme
|
value: theme
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const settingFieldsComponent = computed(() => {
|
const SettingFields = () => settingFields.map(f => render(f));
|
||||||
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() {
|
||||||
|
@ -83,48 +81,38 @@ const isPaused = computed({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const unthrottledTitle = jsx(() => (
|
const unthrottledTitle = <span class="option-title">
|
||||||
<span class="option-title">
|
Unthrottled
|
||||||
Unthrottled
|
<desc>Allow the game to run as fast as possible. Not battery friendly.</desc>
|
||||||
<desc>Allow the game to run as fast as possible. Not battery friendly.</desc>
|
</span>;
|
||||||
</span>
|
const offlineProdTitle = <span class="option-title">
|
||||||
));
|
Offline production<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip>
|
||||||
const offlineProdTitle = jsx(() => (
|
<desc>Simulate production that occurs while the game is closed.</desc>
|
||||||
<span class="option-title">
|
</span>;
|
||||||
Offline Production<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip>
|
const showHealthWarningTitle = <span class="option-title">
|
||||||
<desc>Simulate production that occurs while the game is closed.</desc>
|
Show videogame addiction warning
|
||||||
</span>
|
<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 = <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>
|
<desc>Automatically save the game every second or when the game is closed.</desc>
|
||||||
<desc>Automatically save the game every second or when the game is closed.</desc>
|
</span>;
|
||||||
</span>
|
const isPausedTitle = <span class="option-title">
|
||||||
));
|
Pause game<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip>
|
||||||
const isPausedTitle = jsx(() => (
|
<desc>Stop everything from moving.</desc>
|
||||||
<span class="option-title">
|
</span>;
|
||||||
Pause game<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip>
|
const themeTitle = <span class="option-title">
|
||||||
<desc>Stop everything from moving.</desc>
|
Theme
|
||||||
</span>
|
<desc>How the game looks.</desc>
|
||||||
));
|
</span>;
|
||||||
const themeTitle = jsx(() => (
|
const showTPSTitle = <span class="option-title">
|
||||||
<span class="option-title">
|
Show TPS
|
||||||
Theme
|
<desc>Show TPS meter at the bottom-left corner of the page.</desc>
|
||||||
<desc>How the game looks.</desc>
|
</span>;
|
||||||
</span>
|
const alignModifierUnitsTitle = <span class="option-title">
|
||||||
));
|
Align modifier units
|
||||||
const showTPSTitle = jsx(() => (
|
<desc>Align numbers to the beginning of the unit in modifier view.</desc>
|
||||||
<span class="option-title">
|
</span>;
|
||||||
Show TPS
|
|
||||||
<desc>Show TPS meter at the bottom-left corner of the page.</desc>
|
|
||||||
</span>
|
|
||||||
));
|
|
||||||
const alignModifierUnitsTitle = jsx(() => (
|
|
||||||
<span class="option-title">
|
|
||||||
Align modifier units
|
|
||||||
<desc>Align numbers to the beginning of the unit in modifier view.</desc>
|
|
||||||
</span>
|
|
||||||
));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
|
@ -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" />
|
||||||
|
@ -70,19 +75,21 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Tooltip from "features/tooltips/Tooltip.vue";
|
import Tooltip from "wrappers/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>
|
|
@ -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,62 +130,22 @@ 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({
|
||||||
// .slice(2, -4) strips the leading ./ and the trailing .txt
|
// .slice(2, -4) strips the leading ./ and the trailing .txt
|
||||||
label: curr.split("/").slice(-1)[0].slice(0, -4),
|
label: curr.split("/").slice(-1)[0].slice(0, -4),
|
||||||
// Have to perform this unholy cast because globEager's typing doesn't appear to know
|
value: bankContext[curr] as string
|
||||||
// adding { as: "raw" } will make the object contain strings rather than modules
|
|
||||||
value: bankContext[curr] as unknown as string
|
|
||||||
});
|
});
|
||||||
return acc;
|
return acc;
|
||||||
}, [])
|
}, [])
|
||||||
);
|
);
|
||||||
|
|
||||||
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 +156,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 +202,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[parseInt(slot)].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 +242,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 +265,7 @@ function editSave(id: string, newName: string) {
|
||||||
save();
|
save();
|
||||||
} else {
|
} else {
|
||||||
save(currSave as Player);
|
save(currSave as Player);
|
||||||
cachedSaves[id] = undefined;
|
clearCachedSave(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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);
|
||||||
|
|
|
@ -7,3 +7,12 @@
|
||||||
.modifier-toggle.collapsed {
|
.modifier-toggle.collapsed {
|
||||||
transform: translate(-5px, -5px) rotate(-90deg);
|
transform: translate(-5px, -5px) rotate(-90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.node-text {
|
||||||
|
text-anchor: middle;
|
||||||
|
dominant-baseline: middle;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 200%;
|
||||||
|
pointer-events: none;
|
||||||
|
filter: drop-shadow(3px 3px 2px var(--tooltip-background));
|
||||||
|
}
|
||||||
|
|
|
@ -1,64 +1,58 @@
|
||||||
import Collapsible from "components/layout/Collapsible.vue";
|
import Collapsible from "components/layout/Collapsible.vue";
|
||||||
import { GenericAchievement } from "features/achievements/achievement";
|
import { Achievement } from "features/achievements/achievement";
|
||||||
import type { Clickable, ClickableOptions, GenericClickable } from "features/clickables/clickable";
|
import type { Clickable, ClickableOptions } from "features/clickables/clickable";
|
||||||
import { createClickable } from "features/clickables/clickable";
|
import { createClickable } from "features/clickables/clickable";
|
||||||
import type { GenericConversion } from "features/conversion";
|
import { Conversion } from "features/conversion";
|
||||||
import type { CoercableComponent, JSXFunction, OptionsFunc, Replace } from "features/feature";
|
import { getFirstFeature } from "features/feature";
|
||||||
import { jsx, setDefault } from "features/feature";
|
import { displayResource, Resource } from "features/resources/resource";
|
||||||
import { Resource, displayResource } from "features/resources/resource";
|
import type { Tree, TreeNode, TreeNodeOptions } from "features/trees/tree";
|
||||||
import type { GenericTree, GenericTreeNode, TreeNode, TreeNodeOptions } from "features/trees/tree";
|
|
||||||
import { createTreeNode } from "features/trees/tree";
|
import { createTreeNode } from "features/trees/tree";
|
||||||
import type { GenericFormula } from "game/formulas/types";
|
import type { GenericFormula } from "game/formulas/types";
|
||||||
import { BaseLayer } from "game/layers";
|
import { BaseLayer } from "game/layers";
|
||||||
import type { Modifier } from "game/modifiers";
|
import { Modifier } from "game/modifiers";
|
||||||
import type { Persistent } from "game/persistence";
|
import type { Persistent } from "game/persistence";
|
||||||
import { DefaultValue, persistent } from "game/persistence";
|
import { DefaultValue, persistent } from "game/persistence";
|
||||||
import player from "game/player";
|
import player from "game/player";
|
||||||
import settings from "game/settings";
|
import settings from "game/settings";
|
||||||
import type { DecimalSource } from "util/bignum";
|
import type { DecimalSource } from "util/bignum";
|
||||||
import Decimal, { format, formatSmall, formatTime } from "util/bignum";
|
import Decimal, { format, formatSmall, formatTime } from "util/bignum";
|
||||||
import { WithRequired, camelToTitle } from "util/common";
|
import { WithRequired } from "util/common";
|
||||||
import type {
|
import { processGetter } from "util/computed";
|
||||||
Computable,
|
import { render, Renderable, renderCol } from "util/vue";
|
||||||
GetComputableType,
|
import type { ComputedRef, MaybeRef, MaybeRefOrGetter } from "vue";
|
||||||
GetComputableTypeWithDefault,
|
import { computed, ref, unref } from "vue";
|
||||||
ProcessedComputable
|
import { JSX } from "vue/jsx-runtime";
|
||||||
} from "util/computed";
|
|
||||||
import { convertComputable, processComputable } from "util/computed";
|
|
||||||
import { getFirstFeature, renderColJSX, renderJSX } from "util/vue";
|
|
||||||
import type { ComputedRef, Ref } from "vue";
|
|
||||||
import { computed, unref } from "vue";
|
|
||||||
import "./common.css";
|
import "./common.css";
|
||||||
|
|
||||||
/** An object that configures a {@link ResetButton} */
|
/** An object that configures a {@link ResetButton} */
|
||||||
export interface ResetButtonOptions extends ClickableOptions {
|
export interface ResetButtonOptions extends ClickableOptions {
|
||||||
/** The conversion the button uses to calculate how much resources will be gained on click */
|
/** The conversion the button uses to calculate how much resources will be gained on click */
|
||||||
conversion: GenericConversion;
|
conversion: Conversion;
|
||||||
/** The tree this reset button is apart of */
|
/** The tree this reset button is apart of */
|
||||||
tree: GenericTree;
|
tree: Tree;
|
||||||
/** The specific tree node associated with this reset button */
|
/** The specific tree node associated with this reset button */
|
||||||
treeNode: GenericTreeNode;
|
treeNode: TreeNode;
|
||||||
/**
|
/**
|
||||||
* Text to display on low conversion amounts, describing what "resetting" is in this context.
|
* Text to display on low conversion amounts, describing what "resetting" is in this context.
|
||||||
* Defaults to "Reset for ".
|
* Defaults to "Reset for ".
|
||||||
*/
|
*/
|
||||||
resetDescription?: Computable<string>;
|
resetDescription?: MaybeRefOrGetter<string>;
|
||||||
/** Whether or not to show how much currency would be required to make the gain amount increase. */
|
/** Whether or not to show how much currency would be required to make the gain amount increase. */
|
||||||
showNextAt?: Computable<boolean>;
|
showNextAt?: MaybeRefOrGetter<boolean>;
|
||||||
/**
|
/**
|
||||||
* The content to display on the button.
|
* The content to display on the button.
|
||||||
* By default, this includes the reset description, and amount of currency to be gained.
|
* By default, this includes the reset description, and amount of currency to be gained.
|
||||||
*/
|
*/
|
||||||
display?: Computable<CoercableComponent>;
|
display?: MaybeRefOrGetter<Renderable>;
|
||||||
/**
|
/**
|
||||||
* Whether or not this button can currently be clicked.
|
* Whether or not this button can currently be clicked.
|
||||||
* Defaults to checking the current gain amount is greater than {@link minimumGain}
|
* Defaults to checking the current gain amount is greater than {@link minimumGain}
|
||||||
*/
|
*/
|
||||||
canClick?: Computable<boolean>;
|
canClick?: MaybeRefOrGetter<boolean>;
|
||||||
/**
|
/**
|
||||||
* When {@link canClick} is left to its default, minimumGain is used to only enable the reset button when a sufficient amount of currency to gain is available.
|
* When {@link canClick} is left to its default, minimumGain is used to only enable the reset button when a sufficient amount of currency to gain is available.
|
||||||
*/
|
*/
|
||||||
minimumGain?: Computable<DecimalSource>;
|
minimumGain?: MaybeRefOrGetter<DecimalSource>;
|
||||||
/** A persistent ref to track how much time has passed since the last time this tree node was reset. */
|
/** A persistent ref to track how much time has passed since the last time this tree node was reset. */
|
||||||
resetTime?: Persistent<DecimalSource>;
|
resetTime?: Persistent<DecimalSource>;
|
||||||
}
|
}
|
||||||
|
@ -68,108 +62,117 @@ export interface ResetButtonOptions extends ClickableOptions {
|
||||||
* It will show how much can be converted currently, and can show when that amount will go up, as well as handle only being clickable when a sufficient amount of currency can be gained.
|
* It will show how much can be converted currently, and can show when that amount will go up, as well as handle only being clickable when a sufficient amount of currency can be gained.
|
||||||
* Assumes this button is associated with a specific node on a tree, and triggers that tree's reset propagation.
|
* Assumes this button is associated with a specific node on a tree, and triggers that tree's reset propagation.
|
||||||
*/
|
*/
|
||||||
export type ResetButton<T extends ResetButtonOptions> = Replace<
|
export interface ResetButton extends Clickable {
|
||||||
Clickable<T>,
|
/** The conversion the button uses to calculate how much resources will be gained on click */
|
||||||
{
|
conversion: Conversion;
|
||||||
resetDescription: GetComputableTypeWithDefault<T["resetDescription"], Ref<string>>;
|
/** The tree this reset button is apart of */
|
||||||
showNextAt: GetComputableTypeWithDefault<T["showNextAt"], true>;
|
tree: Tree;
|
||||||
display: GetComputableTypeWithDefault<T["display"], Ref<JSX.Element>>;
|
/** The specific tree node associated with this reset button */
|
||||||
canClick: GetComputableTypeWithDefault<T["canClick"], Ref<boolean>>;
|
treeNode: TreeNode;
|
||||||
minimumGain: GetComputableTypeWithDefault<T["minimumGain"], 1>;
|
/**
|
||||||
onClick: (event?: MouseEvent | TouchEvent) => void;
|
* Text to display on low conversion amounts, describing what "resetting" is in this context.
|
||||||
}
|
* Defaults to "Reset for ".
|
||||||
>;
|
*/
|
||||||
|
resetDescription?: MaybeRef<string>;
|
||||||
/** A type that matches any valid {@link ResetButton} object. */
|
/** Whether or not to show how much currency would be required to make the gain amount increase. */
|
||||||
export type GenericResetButton = Replace<
|
showNextAt?: MaybeRef<boolean>;
|
||||||
GenericClickable & ResetButton<ResetButtonOptions>,
|
/**
|
||||||
{
|
* When {@link canClick} is left to its default, minimumGain is used to only enable the reset button when a sufficient amount of currency to gain is available.
|
||||||
resetDescription: ProcessedComputable<string>;
|
*/
|
||||||
showNextAt: ProcessedComputable<boolean>;
|
minimumGain?: MaybeRef<DecimalSource>;
|
||||||
display: ProcessedComputable<CoercableComponent>;
|
/** A persistent ref to track how much time has passed since the last time this tree node was reset. */
|
||||||
canClick: ProcessedComputable<boolean>;
|
resetTime?: Persistent<DecimalSource>;
|
||||||
minimumGain: ProcessedComputable<DecimalSource>;
|
}
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lazily creates a reset button with the given options.
|
* Lazily creates a reset button with the given options.
|
||||||
* @param optionsFunc A function that returns the options object for this reset button.
|
* @param optionsFunc A function that returns the options object for this reset button.
|
||||||
*/
|
*/
|
||||||
export function createResetButton<T extends ClickableOptions & ResetButtonOptions>(
|
export function createResetButton<T extends ClickableOptions & ResetButtonOptions>(
|
||||||
optionsFunc: OptionsFunc<T>
|
optionsFunc: () => T
|
||||||
): ResetButton<T> {
|
) {
|
||||||
return createClickable(feature => {
|
const resetButton = createClickable(() => {
|
||||||
const resetButton = optionsFunc.call(feature, feature);
|
const options = optionsFunc();
|
||||||
|
const {
|
||||||
|
conversion,
|
||||||
|
tree,
|
||||||
|
treeNode,
|
||||||
|
resetTime,
|
||||||
|
resetDescription,
|
||||||
|
showNextAt,
|
||||||
|
minimumGain,
|
||||||
|
display,
|
||||||
|
canClick,
|
||||||
|
onClick,
|
||||||
|
...props
|
||||||
|
} = options;
|
||||||
|
|
||||||
processComputable(resetButton as T, "showNextAt");
|
return {
|
||||||
setDefault(resetButton, "showNextAt", true);
|
...(props as Omit<typeof props, keyof ResetButtonOptions>),
|
||||||
setDefault(resetButton, "minimumGain", 1);
|
conversion,
|
||||||
|
tree,
|
||||||
if (resetButton.resetDescription == null) {
|
treeNode,
|
||||||
resetButton.resetDescription = computed(() =>
|
resetTime,
|
||||||
Decimal.lt(resetButton.conversion.gainResource.value, 1e3) ? "Reset for " : ""
|
resetDescription:
|
||||||
);
|
processGetter(resetDescription) ??
|
||||||
} else {
|
computed((): string =>
|
||||||
processComputable(resetButton as T, "resetDescription");
|
Decimal.lt(conversion.gainResource.value, 1e3) ? "Reset for " : ""
|
||||||
}
|
),
|
||||||
|
showNextAt: processGetter(showNextAt) ?? true,
|
||||||
if (resetButton.display == null) {
|
minimumGain: processGetter(minimumGain) ?? 1,
|
||||||
resetButton.display = jsx(() => (
|
canClick:
|
||||||
<span>
|
processGetter(canClick) ??
|
||||||
{unref(resetButton.resetDescription as ProcessedComputable<string>)}
|
computed((): boolean =>
|
||||||
<b>
|
Decimal.gte(unref(conversion.actualGain), unref(resetButton.minimumGain))
|
||||||
{displayResource(
|
),
|
||||||
resetButton.conversion.gainResource,
|
display:
|
||||||
Decimal.max(
|
processGetter(display) ??
|
||||||
unref(resetButton.conversion.actualGain),
|
computed(
|
||||||
unref(resetButton.minimumGain as ProcessedComputable<DecimalSource>)
|
(): JSX.Element => (
|
||||||
)
|
<span>
|
||||||
)}
|
{unref(resetButton.resetDescription)}
|
||||||
</b>{" "}
|
<b>
|
||||||
{resetButton.conversion.gainResource.displayName}
|
{displayResource(
|
||||||
{unref(resetButton.showNextAt) != null ? (
|
conversion.gainResource,
|
||||||
<div>
|
Decimal.max(
|
||||||
<br />
|
unref(conversion.actualGain),
|
||||||
{unref(resetButton.conversion.buyMax) ? "Next:" : "Req:"}{" "}
|
unref(resetButton.minimumGain)
|
||||||
{displayResource(
|
)
|
||||||
resetButton.conversion.baseResource,
|
)}
|
||||||
!unref(resetButton.conversion.buyMax) &&
|
</b>{" "}
|
||||||
Decimal.gte(unref(resetButton.conversion.actualGain), 1)
|
{conversion.gainResource.displayName}
|
||||||
? unref(resetButton.conversion.currentAt)
|
{unref(resetButton.showNextAt) != null ? (
|
||||||
: unref(resetButton.conversion.nextAt)
|
<div>
|
||||||
)}{" "}
|
<br />
|
||||||
{resetButton.conversion.baseResource.displayName}
|
{unref(conversion.buyMax) ? "Next:" : "Req:"}{" "}
|
||||||
</div>
|
{displayResource(
|
||||||
) : null}
|
conversion.baseResource,
|
||||||
</span>
|
!unref<boolean>(conversion.buyMax) &&
|
||||||
));
|
Decimal.gte(unref(conversion.actualGain), 1)
|
||||||
}
|
? unref(conversion.currentAt)
|
||||||
|
: unref(conversion.nextAt)
|
||||||
if (resetButton.canClick == null) {
|
)}{" "}
|
||||||
resetButton.canClick = computed(() =>
|
{conversion.baseResource.displayName}
|
||||||
Decimal.gte(
|
</div>
|
||||||
unref(resetButton.conversion.actualGain),
|
) : null}
|
||||||
unref(resetButton.minimumGain as ProcessedComputable<DecimalSource>)
|
</span>
|
||||||
)
|
)
|
||||||
);
|
),
|
||||||
}
|
onClick: function (e?: MouseEvent | TouchEvent) {
|
||||||
|
if (unref(resetButton.canClick) === false) {
|
||||||
const onClick = resetButton.onClick;
|
return;
|
||||||
resetButton.onClick = function (event?: MouseEvent | TouchEvent) {
|
}
|
||||||
if (unref(resetButton.canClick) === false) {
|
conversion.convert();
|
||||||
return;
|
tree.reset(treeNode);
|
||||||
|
if (resetTime) {
|
||||||
|
resetTime.value = resetTime[DefaultValue];
|
||||||
|
}
|
||||||
|
onClick?.call(resetButton, e);
|
||||||
}
|
}
|
||||||
resetButton.conversion.convert();
|
|
||||||
resetButton.tree.reset(resetButton.treeNode);
|
|
||||||
if (resetButton.resetTime) {
|
|
||||||
resetButton.resetTime.value = resetButton.resetTime[DefaultValue];
|
|
||||||
}
|
|
||||||
onClick?.(event);
|
|
||||||
};
|
};
|
||||||
|
}) satisfies ResetButton;
|
||||||
|
|
||||||
return resetButton;
|
return resetButton;
|
||||||
}) as unknown as ResetButton<T>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An object that configures a {@link LayerTreeNode} */
|
/** An object that configures a {@link LayerTreeNode} */
|
||||||
|
@ -177,75 +180,73 @@ export interface LayerTreeNodeOptions extends TreeNodeOptions {
|
||||||
/** The ID of the layer this tree node is associated with */
|
/** The ID of the layer this tree node is associated with */
|
||||||
layerID: string;
|
layerID: string;
|
||||||
/** The color to display this tree node as */
|
/** The color to display this tree node as */
|
||||||
color: Computable<string>; // marking as required
|
color: MaybeRefOrGetter<string>; // marking as required
|
||||||
/** Whether or not to append the layer to the tabs list.
|
/** Whether or not to append the layer to the tabs list.
|
||||||
* If set to false, then the tree node will instead always remove all tabs to its right and then add the layer tab.
|
* If set to false, then the tree node will instead always remove all tabs to its right and then add the layer tab.
|
||||||
* Defaults to true.
|
* Defaults to true.
|
||||||
*/
|
*/
|
||||||
append?: Computable<boolean>;
|
append?: MaybeRefOrGetter<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A tree node that is associated with a given layer, and which opens the layer when clicked. */
|
/** A tree node that is associated with a given layer, and which opens the layer when clicked. */
|
||||||
export type LayerTreeNode<T extends LayerTreeNodeOptions> = Replace<
|
export interface LayerTreeNode extends TreeNode {
|
||||||
TreeNode<T>,
|
/** The ID of the layer this tree node is associated with */
|
||||||
{
|
layerID: string;
|
||||||
display: GetComputableTypeWithDefault<T["display"], T["layerID"]>;
|
/** Whether or not to append the layer to the tabs list.
|
||||||
append: GetComputableType<T["append"]>;
|
* If set to false, then the tree node will instead always remove all tabs to its right and then add the layer tab.
|
||||||
}
|
* Defaults to true.
|
||||||
>;
|
*/
|
||||||
/** A type that matches any valid {@link LayerTreeNode} object. */
|
append?: MaybeRef<boolean>;
|
||||||
export type GenericLayerTreeNode = Replace<
|
}
|
||||||
LayerTreeNode<LayerTreeNodeOptions>,
|
|
||||||
{
|
|
||||||
display: ProcessedComputable<CoercableComponent>;
|
|
||||||
append?: ProcessedComputable<boolean>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lazily creates a tree node that's associated with a specific layer, with the given options.
|
* Lazily creates a tree node that's associated with a specific layer, with the given options.
|
||||||
* @param optionsFunc A function that returns the options object for this tree node.
|
* @param optionsFunc A function that returns the options object for this tree node.
|
||||||
*/
|
*/
|
||||||
export function createLayerTreeNode<T extends LayerTreeNodeOptions>(
|
export function createLayerTreeNode<T extends LayerTreeNodeOptions>(optionsFunc: () => T) {
|
||||||
optionsFunc: OptionsFunc<T>
|
const layerTreeNode = createTreeNode(() => {
|
||||||
): LayerTreeNode<T> {
|
const options = optionsFunc();
|
||||||
return createTreeNode(feature => {
|
const { display, append, layerID, ...props } = options;
|
||||||
const options = optionsFunc.call(feature, feature);
|
|
||||||
setDefault(options, "display", camelToTitle(options.layerID));
|
|
||||||
processComputable(options as T, "append");
|
|
||||||
return {
|
return {
|
||||||
...options,
|
...(props as Omit<typeof props, keyof LayerTreeNodeOptions>),
|
||||||
onClick: unref((options as unknown as GenericLayerTreeNode).append)
|
layerID,
|
||||||
? function () {
|
display: processGetter(display) ?? layerID,
|
||||||
if (player.tabs.includes(options.layerID)) {
|
append: processGetter(append) ?? true,
|
||||||
const index = player.tabs.lastIndexOf(options.layerID);
|
onClick() {
|
||||||
player.tabs.splice(index, 1);
|
if (unref<boolean>(layerTreeNode.append)) {
|
||||||
} else {
|
if (player.tabs.includes(layerID)) {
|
||||||
player.tabs.push(options.layerID);
|
const index = player.tabs.lastIndexOf(layerID);
|
||||||
}
|
player.tabs.splice(index, 1);
|
||||||
}
|
} else {
|
||||||
: function () {
|
player.tabs.push(layerID);
|
||||||
player.tabs.splice(1, 1, options.layerID);
|
}
|
||||||
}
|
} else {
|
||||||
|
player.tabs.splice(1, 1, layerID);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}) as unknown as LayerTreeNode<T>;
|
}) satisfies LayerTreeNode;
|
||||||
|
|
||||||
|
return layerTreeNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An option object for a modifier display as a single section. **/
|
/** An option object for a modifier display as a single section. **/
|
||||||
export interface Section {
|
export interface Section {
|
||||||
/** The header for this modifier. **/
|
/** The header for this modifier. **/
|
||||||
title: Computable<string>;
|
title: MaybeRefOrGetter<string>;
|
||||||
/** A subtitle for this modifier, e.g. to explain the context for the modifier. **/
|
/** A subtitle for this modifier, e.g. to explain the context for the modifier. **/
|
||||||
subtitle?: Computable<string>;
|
subtitle?: MaybeRefOrGetter<string>;
|
||||||
/** The modifier to be displaying in this section. **/
|
/** The modifier to be displaying in this section. **/
|
||||||
modifier: WithRequired<Modifier, "description">;
|
modifier: WithRequired<Modifier, "description">;
|
||||||
/** The base value being modified. **/
|
/** The base value being modified. **/
|
||||||
base?: Computable<DecimalSource>;
|
base?: MaybeRefOrGetter<DecimalSource>;
|
||||||
/** The unit of measurement for the base. **/
|
/** The unit of measurement for the base. **/
|
||||||
unit?: string;
|
unit?: string;
|
||||||
/** The label to call the base amount. Defaults to "Base". **/
|
/** The label to call the base amount. Defaults to "Base". **/
|
||||||
baseText?: Computable<CoercableComponent>;
|
baseText?: MaybeRefOrGetter<Renderable>;
|
||||||
/** Whether or not this section should be currently visible to the player. **/
|
/** Whether or not this section should be currently visible to the player. **/
|
||||||
visible?: Computable<boolean>;
|
visible?: MaybeRefOrGetter<boolean>;
|
||||||
/** Determines if numbers larger or smaller than the base should be displayed as red. */
|
/** Determines if numbers larger or smaller than the base should be displayed as red. */
|
||||||
smallerIsBetter?: boolean;
|
smallerIsBetter?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -257,33 +258,33 @@ export interface Section {
|
||||||
*/
|
*/
|
||||||
export function createCollapsibleModifierSections(
|
export function createCollapsibleModifierSections(
|
||||||
sectionsFunc: () => Section[]
|
sectionsFunc: () => Section[]
|
||||||
): [JSXFunction, Persistent<Record<number, boolean>>] {
|
): [MaybeRef<Renderable>, Persistent<Record<number, boolean>>] {
|
||||||
const sections: Section[] = [];
|
const sections: Section[] = [];
|
||||||
const processed:
|
const processed:
|
||||||
| {
|
| {
|
||||||
base: ProcessedComputable<DecimalSource | undefined>[];
|
base: MaybeRef<DecimalSource | undefined>[];
|
||||||
baseText: ProcessedComputable<CoercableComponent | undefined>[];
|
baseText: (MaybeRef<Renderable> | undefined)[];
|
||||||
visible: ProcessedComputable<boolean | undefined>[];
|
visible: MaybeRef<boolean | undefined>[];
|
||||||
title: ProcessedComputable<string | undefined>[];
|
title: MaybeRef<string | undefined>[];
|
||||||
subtitle: ProcessedComputable<string | undefined>[];
|
subtitle: MaybeRef<string | undefined>[];
|
||||||
}
|
}
|
||||||
| Record<string, never> = {};
|
| Record<string, never> = {};
|
||||||
let calculated = false;
|
let calculated = false;
|
||||||
function calculateSections() {
|
function calculateSections() {
|
||||||
if (!calculated) {
|
if (!calculated) {
|
||||||
sections.push(...sectionsFunc());
|
sections.push(...sectionsFunc());
|
||||||
processed.base = sections.map(s => convertComputable(s.base));
|
processed.base = sections.map(s => processGetter(s.base));
|
||||||
processed.baseText = sections.map(s => convertComputable(s.baseText));
|
processed.baseText = sections.map(s => processGetter(s.baseText));
|
||||||
processed.visible = sections.map(s => convertComputable(s.visible));
|
processed.visible = sections.map(s => processGetter(s.visible));
|
||||||
processed.title = sections.map(s => convertComputable(s.title));
|
processed.title = sections.map(s => processGetter(s.title));
|
||||||
processed.subtitle = sections.map(s => convertComputable(s.subtitle));
|
processed.subtitle = sections.map(s => processGetter(s.subtitle));
|
||||||
calculated = true;
|
calculated = true;
|
||||||
}
|
}
|
||||||
return sections;
|
return sections;
|
||||||
}
|
}
|
||||||
|
|
||||||
const collapsed = persistent<Record<number, boolean>>({}, false);
|
const collapsed = persistent<Record<number, boolean>>({}, false);
|
||||||
const jsxFunc = jsx(() => {
|
const jsxFunc = computed(() => {
|
||||||
const sections = calculateSections();
|
const sections = calculateSections();
|
||||||
|
|
||||||
let firstVisibleSection = true;
|
let firstVisibleSection = true;
|
||||||
|
@ -310,16 +311,14 @@ export function createCollapsibleModifierSections(
|
||||||
<>
|
<>
|
||||||
<div class="modifier-container">
|
<div class="modifier-container">
|
||||||
<span class="modifier-description">
|
<span class="modifier-description">
|
||||||
{renderJSX(unref(processed.baseText[i]) ?? "Base")}
|
{render(unref(processed.baseText[i]) ?? "Base")}
|
||||||
</span>
|
</span>
|
||||||
<span class="modifier-amount">
|
<span class="modifier-amount">
|
||||||
{format(unref(processed.base[i]) ?? 1)}
|
{format(unref(processed.base[i]) ?? 1)}
|
||||||
{s.unit}
|
{s.unit}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{s.modifier.description == null
|
{s.modifier.description == null ? null : render(unref(s.modifier.description))}
|
||||||
? null
|
|
||||||
: renderJSX(unref(s.modifier.description))}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -382,7 +381,7 @@ export function colorText(textToColor: string, color = "var(--accent2)"): JSX.El
|
||||||
* Creates a collapsible display of a list of achievements
|
* Creates a collapsible display of a list of achievements
|
||||||
* @param achievements A dictionary of the achievements to display, inserted in the order from easiest to hardest
|
* @param achievements A dictionary of the achievements to display, inserted in the order from easiest to hardest
|
||||||
*/
|
*/
|
||||||
export function createCollapsibleAchievements(achievements: Record<string, GenericAchievement>) {
|
export function createCollapsibleAchievements(achievements: Record<string, Achievement>) {
|
||||||
// Achievements are typically defined from easiest to hardest, and we want to show hardest first
|
// Achievements are typically defined from easiest to hardest, and we want to show hardest first
|
||||||
const orderedAchievements = Object.values(achievements).reverse();
|
const orderedAchievements = Object.values(achievements).reverse();
|
||||||
const collapseAchievements = persistent<boolean>(true, false);
|
const collapseAchievements = persistent<boolean>(true, false);
|
||||||
|
@ -393,25 +392,23 @@ export function createCollapsibleAchievements(achievements: Record<string, Gener
|
||||||
orderedAchievements,
|
orderedAchievements,
|
||||||
m => m.earned.value
|
m => m.earned.value
|
||||||
);
|
);
|
||||||
const display = jsx(() => {
|
const display = computed(() => {
|
||||||
const achievementsToDisplay = [...lockedAchievements.value];
|
const achievementsToDisplay = [...lockedAchievements.value];
|
||||||
if (firstFeature.value) {
|
if (firstFeature.value) {
|
||||||
achievementsToDisplay.push(firstFeature.value);
|
achievementsToDisplay.push(firstFeature.value);
|
||||||
}
|
}
|
||||||
return renderColJSX(
|
return renderCol(
|
||||||
...achievementsToDisplay,
|
...achievementsToDisplay,
|
||||||
jsx(() => (
|
<Collapsible
|
||||||
<Collapsible
|
collapsed={collapseAchievements}
|
||||||
collapsed={collapseAchievements}
|
content={collapsedContent}
|
||||||
content={collapsedContent}
|
display={
|
||||||
display={
|
collapseAchievements.value
|
||||||
collapseAchievements.value
|
? "Show other completed achievements"
|
||||||
? "Show other completed achievements"
|
: "Hide other completed achievements"
|
||||||
: "Hide other completed achievements"
|
}
|
||||||
}
|
v-show={unref(hasCollapsedContent)}
|
||||||
v-show={unref(hasCollapsedContent)}
|
/>
|
||||||
/>
|
|
||||||
))
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
@ -428,11 +425,11 @@ export function createCollapsibleAchievements(achievements: Record<string, Gener
|
||||||
*/
|
*/
|
||||||
export function estimateTime(
|
export function estimateTime(
|
||||||
resource: Resource,
|
resource: Resource,
|
||||||
rate: Computable<DecimalSource>,
|
rate: MaybeRefOrGetter<DecimalSource>,
|
||||||
target: Computable<DecimalSource>
|
target: MaybeRefOrGetter<DecimalSource>
|
||||||
) {
|
) {
|
||||||
const processedRate = convertComputable(rate);
|
const processedRate = processGetter(rate);
|
||||||
const processedTarget = convertComputable(target);
|
const processedTarget = processGetter(target);
|
||||||
return computed(() => {
|
return computed(() => {
|
||||||
const currRate = unref(processedRate);
|
const currRate = unref(processedRate);
|
||||||
const currTarget = unref(processedTarget);
|
const currTarget = unref(processedTarget);
|
||||||
|
@ -454,15 +451,15 @@ export function estimateTime(
|
||||||
*/
|
*/
|
||||||
export function createFormulaPreview(
|
export function createFormulaPreview(
|
||||||
formula: GenericFormula,
|
formula: GenericFormula,
|
||||||
showPreview: Computable<boolean>,
|
showPreview: MaybeRefOrGetter<boolean>,
|
||||||
previewAmount: Computable<DecimalSource> = 1
|
previewAmount: MaybeRefOrGetter<DecimalSource> = 1
|
||||||
) {
|
) {
|
||||||
const processedShowPreview = convertComputable(showPreview);
|
const processedShowPreview = processGetter(showPreview);
|
||||||
const processedPreviewAmount = convertComputable(previewAmount);
|
const processedPreviewAmount = processGetter(previewAmount);
|
||||||
if (!formula.hasVariable()) {
|
if (!formula.hasVariable()) {
|
||||||
console.error("Cannot create formula preview if the formula does not have a variable");
|
console.error("Cannot create formula preview if the formula does not have a variable");
|
||||||
}
|
}
|
||||||
return jsx(() => {
|
return computed(() => {
|
||||||
if (unref(processedShowPreview)) {
|
if (unref(processedShowPreview)) {
|
||||||
const curr = formatSmall(formula.evaluate());
|
const curr = formatSmall(formula.evaluate());
|
||||||
const preview = formatSmall(
|
const preview = formatSmall(
|
||||||
|
@ -505,3 +502,21 @@ export function isRendered(layer: BaseLayer, idOrFeature: string | { id: string
|
||||||
const id = typeof idOrFeature === "string" ? idOrFeature : idOrFeature.id;
|
const id = typeof idOrFeature === "string" ? idOrFeature : idOrFeature.id;
|
||||||
return computed(() => id in layer.nodes.value);
|
return computed(() => id in layer.nodes.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function for setting up a system where one of many things can be selected.
|
||||||
|
* It's recommended to use an ID or index rather than the object itself, so that you can wrap the ref in a persistent without breaking anything.
|
||||||
|
* @returns The ref containing the selection, as well as a select and deselect function
|
||||||
|
*/
|
||||||
|
export function setupSelectable<T>() {
|
||||||
|
const selected = ref<T>();
|
||||||
|
return {
|
||||||
|
select: function (node: T) {
|
||||||
|
selected.value = node;
|
||||||
|
},
|
||||||
|
deselect: function () {
|
||||||
|
selected.value = undefined;
|
||||||
|
},
|
||||||
|
selected
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -1,16 +1,17 @@
|
||||||
import { main } from "data/projEntry";
|
import { main } from "data/projEntry";
|
||||||
import { createAchievement } from "features/achievements/achievement";
|
import { createAchievement } from "features/achievements/achievement";
|
||||||
import { jsx } from "features/feature";
|
|
||||||
import { createGrid } from "features/grids/grid";
|
import { createGrid } from "features/grids/grid";
|
||||||
import { createResource } from "features/resources/resource";
|
import { createResource } from "features/resources/resource";
|
||||||
import Tooltip from "features/tooltips/Tooltip.vue";
|
|
||||||
import { addTooltip } from "features/tooltips/tooltip";
|
|
||||||
import { createTreeNode } from "features/trees/tree";
|
import { createTreeNode } from "features/trees/tree";
|
||||||
import { createLayer } from "game/layers";
|
import { createLayer } from "game/layers";
|
||||||
|
import { noPersist } from "game/persistence";
|
||||||
|
import { createCostRequirement } from "game/requirements";
|
||||||
import { DecimalSource } from "lib/break_eternity";
|
import { DecimalSource } from "lib/break_eternity";
|
||||||
import Decimal from "util/bignum";
|
import Decimal from "util/bignum";
|
||||||
import { Direction } from "util/common";
|
import { Direction } from "util/common";
|
||||||
import { renderRow } from "util/vue";
|
import { renderRow } from "util/vue";
|
||||||
|
import { addTooltip } from "wrappers/tooltips/tooltip";
|
||||||
|
import Tooltip from "wrappers/tooltips/Tooltip.vue";
|
||||||
|
|
||||||
const id = "a";
|
const id = "a";
|
||||||
const layer = createLayer(id, () => {
|
const layer = createLayer(id, () => {
|
||||||
|
@ -36,87 +37,93 @@ const layer = createLayer(id, () => {
|
||||||
requirements: [],
|
requirements: [],
|
||||||
small: true
|
small: true
|
||||||
}));
|
}));
|
||||||
addTooltip(ach1, {
|
addTooltip(ach1, () => ({
|
||||||
display() {
|
display() {
|
||||||
if (ach1.earned.value) {
|
if (ach1.earned.value === true) {
|
||||||
return "You did it!";
|
return "You did it!";
|
||||||
}
|
}
|
||||||
return "How did this happen?";
|
return "How did this happen?";
|
||||||
},
|
},
|
||||||
direction: Direction.Down
|
direction: Direction.Right
|
||||||
});
|
}));
|
||||||
const ach2 = createAchievement(() => ({
|
const ach2 = createAchievement(() => ({
|
||||||
display: "Impossible!",
|
display: "Impossible!",
|
||||||
style: { color: "#04e050" }
|
style: { color: "#04e050" },
|
||||||
|
small: true
|
||||||
}));
|
}));
|
||||||
addTooltip(ach2, {
|
addTooltip(ach2, () => ({
|
||||||
display() {
|
display() {
|
||||||
if (ach2.earned.value) {
|
if (ach2.earned.value === true) {
|
||||||
return "HOW????";
|
return "HOW????";
|
||||||
}
|
}
|
||||||
return "Mwahahaha!";
|
return "Mwahahaha!";
|
||||||
},
|
},
|
||||||
direction: Direction.Down
|
direction: Direction.Right
|
||||||
});
|
}));
|
||||||
const ach3 = createAchievement(() => ({
|
const ach3 = createAchievement(() => ({
|
||||||
display: "EIEIO",
|
display: "EIEIO",
|
||||||
requirements: [],
|
requirements: [
|
||||||
|
createCostRequirement(() => ({
|
||||||
|
cost: 1,
|
||||||
|
resource: noPersist(points),
|
||||||
|
requiresPay: false
|
||||||
|
}))
|
||||||
|
],
|
||||||
onComplete() {
|
onComplete() {
|
||||||
console.log("Bork bork bork!");
|
console.log("Bork bork bork!");
|
||||||
},
|
},
|
||||||
small: true
|
small: true
|
||||||
}));
|
}));
|
||||||
addTooltip(ach3, {
|
addTooltip(ach3, () => ({
|
||||||
display:
|
display:
|
||||||
"Get a farm point.\n\nReward: The dinosaur is now your friend (you can max Farm Points).",
|
"Get a farm point.\n\nReward: The dinosaur is now your friend (you can max Farm Points).",
|
||||||
direction: Direction.Down
|
direction: Direction.Right
|
||||||
});
|
}));
|
||||||
const achievements = [ach1, ach2, ach3];
|
const achievements = [ach1, ach2, ach3];
|
||||||
|
|
||||||
const grid = createGrid(() => ({
|
const grid = createGrid(() => ({
|
||||||
rows: 2,
|
rows: 2,
|
||||||
cols: 2,
|
cols: 2,
|
||||||
getStartState(id) {
|
getStartState(row, col) {
|
||||||
return id;
|
return row * 100 + col;
|
||||||
},
|
},
|
||||||
getStyle(id, state) {
|
getStyle(row, col, state) {
|
||||||
return { backgroundColor: `#${(Number(state) * 1234) % 999999}` };
|
return { "--layer-color": `#${(Number(state) * 1234) % 999999}` };
|
||||||
},
|
},
|
||||||
// TODO display should return an object
|
getDisplay: {
|
||||||
getTitle(id) {
|
getTitle(row, col) {
|
||||||
let direction = "";
|
const direction = [
|
||||||
if (id === "101") {
|
["top", "bottom"],
|
||||||
direction = "top";
|
["left", "right"]
|
||||||
} else if (id === "102") {
|
][row][col];
|
||||||
direction = "bottom";
|
return (
|
||||||
} else if (id === "201") {
|
<Tooltip
|
||||||
direction = "left";
|
display={JSON.stringify(grid.cells[row][col].style)}
|
||||||
} else if (id === "202") {
|
{...{ [direction]: true }}
|
||||||
direction = "right";
|
direction={Direction.Down}
|
||||||
|
>
|
||||||
|
<h3>Gridable #{`${row}0${col}`}</h3>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getDescription(row, col, state) {
|
||||||
|
return String(state);
|
||||||
}
|
}
|
||||||
return jsx(() => (
|
|
||||||
<Tooltip display={JSON.stringify(this.cells[id].style)} {...{ [direction]: true }}>
|
|
||||||
<h3>Gridable #{id}</h3>
|
|
||||||
</Tooltip>
|
|
||||||
));
|
|
||||||
},
|
|
||||||
getDisplay(id, state) {
|
|
||||||
return String(state);
|
|
||||||
},
|
},
|
||||||
getCanClick() {
|
getCanClick() {
|
||||||
return Decimal.eq(main.points.value, 10);
|
return Decimal.gte(main.points.value, 10);
|
||||||
},
|
},
|
||||||
onClick(id, state) {
|
onClick(row, col, state) {
|
||||||
this.cells[id].state = Number(state) + 1;
|
grid.cells[row][col].state = Number(state) + 1;
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const display = jsx(() => (
|
const display = () => (
|
||||||
<>
|
<>
|
||||||
{renderRow(...achievements)}
|
{renderRow(...achievements)}
|
||||||
{renderRow(grid)}
|
{renderRow(grid)}
|
||||||
</>
|
</>
|
||||||
));
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import Modal from "components/Modal.vue";
|
|
||||||
import Slider from "components/fields/Slider.vue";
|
import Slider from "components/fields/Slider.vue";
|
||||||
import Text from "components/fields/Text.vue";
|
import Text from "components/fields/Text.vue";
|
||||||
import Toggle from "components/fields/Toggle.vue";
|
import Toggle from "components/fields/Toggle.vue";
|
||||||
|
@ -7,33 +6,35 @@ import Row from "components/layout/Row.vue";
|
||||||
import Spacer from "components/layout/Spacer.vue";
|
import Spacer from "components/layout/Spacer.vue";
|
||||||
import Sticky from "components/layout/Sticky.vue";
|
import Sticky from "components/layout/Sticky.vue";
|
||||||
import VerticalRule from "components/layout/VerticalRule.vue";
|
import VerticalRule from "components/layout/VerticalRule.vue";
|
||||||
|
import Modal from "components/modals/Modal.vue";
|
||||||
import { createLayerTreeNode, createResetButton } from "data/common";
|
import { createLayerTreeNode, createResetButton } from "data/common";
|
||||||
import { main } from "data/projEntry";
|
import { main } from "data/projEntry";
|
||||||
import themes from "data/themes";
|
import themes from "data/themes";
|
||||||
|
import { createAchievement } from "features/achievements/achievement";
|
||||||
import { createBar } from "features/bars/bar";
|
import { createBar } from "features/bars/bar";
|
||||||
import { createChallenge } from "features/challenges/challenge";
|
import { createChallenge } from "features/challenges/challenge";
|
||||||
import { createClickable } from "features/clickables/clickable";
|
import { createClickable } from "features/clickables/clickable";
|
||||||
|
import { createRepeatable } from "features/clickables/repeatable";
|
||||||
|
import { createUpgrade } from "features/clickables/upgrade";
|
||||||
import { createCumulativeConversion } from "features/conversion";
|
import { createCumulativeConversion } from "features/conversion";
|
||||||
import { Visibility, jsx } from "features/feature";
|
import { Visibility } from "features/feature";
|
||||||
import { createHotkey } from "features/hotkey";
|
import { createHotkey } from "features/hotkey";
|
||||||
import { createInfobox } from "features/infoboxes/infobox";
|
import { createInfobox } from "features/infoboxes/infobox";
|
||||||
import { createLinks } from "features/links/links";
|
import { createLinks } from "features/links/links";
|
||||||
import { createRepeatable } from "features/repeatable";
|
|
||||||
import { createReset } from "features/reset";
|
import { createReset } from "features/reset";
|
||||||
import MainDisplay from "features/resources/MainDisplay.vue";
|
import MainDisplay from "features/resources/MainDisplay.vue";
|
||||||
import Resource from "features/resources/Resource.vue";
|
import Resource from "features/resources/Resource.vue";
|
||||||
import { createResource, displayResource, trackBest } from "features/resources/resource";
|
import { createResource, displayResource, trackBest } from "features/resources/resource";
|
||||||
import { createTab } from "features/tabs/tab";
|
import { createTab } from "features/tabs/tab";
|
||||||
import { GenericTabFamily, createTabFamily } from "features/tabs/tabFamily";
|
import { TabFamily, createTabFamily } from "features/tabs/tabFamily";
|
||||||
import { addTooltip } from "features/tooltips/tooltip";
|
|
||||||
import {
|
import {
|
||||||
GenericTreeNode,
|
Tree,
|
||||||
TreeBranch,
|
TreeBranch,
|
||||||
createResourceTooltip,
|
createResourceTooltip,
|
||||||
createTree,
|
createTree,
|
||||||
createTreeNode
|
createTreeNode
|
||||||
} from "features/trees/tree";
|
} from "features/trees/tree";
|
||||||
import { createUpgrade } from "features/upgrades/upgrade";
|
import { InvertibleFormula } from "game/formulas/types";
|
||||||
import { createLayer } from "game/layers";
|
import { createLayer } from "game/layers";
|
||||||
import {
|
import {
|
||||||
createAdditiveModifier,
|
createAdditiveModifier,
|
||||||
|
@ -47,12 +48,10 @@ import settings from "game/settings";
|
||||||
import { DecimalSource } from "lib/break_eternity";
|
import { DecimalSource } from "lib/break_eternity";
|
||||||
import Decimal, { format, formatWhole } from "util/bignum";
|
import Decimal, { format, formatWhole } from "util/bignum";
|
||||||
import { Direction } from "util/common";
|
import { Direction } from "util/common";
|
||||||
import { render, renderCol, renderRow } from "util/vue";
|
import { render, Renderable, renderCol, renderRow } from "util/vue";
|
||||||
import { ComputedRef, Ref, computed, ref, unref } from "vue";
|
import { ComputedRef, Ref, computed, ref, unref } from "vue";
|
||||||
|
import { addTooltip } from "wrappers/tooltips/tooltip";
|
||||||
import f from "./f";
|
import f from "./f";
|
||||||
import { ProcessedComputable } from "util/computed";
|
|
||||||
import { createAchievement } from "features/achievements/achievement";
|
|
||||||
import { InvertibleFormula } from "game/formulas/types";
|
|
||||||
|
|
||||||
const id = "c";
|
const id = "c";
|
||||||
const layer = createLayer(id, () => {
|
const layer = createLayer(id, () => {
|
||||||
|
@ -72,7 +71,7 @@ const layer = createLayer(id, () => {
|
||||||
title: "Lore",
|
title: "Lore",
|
||||||
titleStyle: { color: "#FE0000" },
|
titleStyle: { color: "#FE0000" },
|
||||||
display: "DEEP LORE!",
|
display: "DEEP LORE!",
|
||||||
bodyStyle: { backgroundColor: "#0000EE" },
|
bodyStyle: { "backgroundColor": "#0000EE" },
|
||||||
color: "rgb(75, 220, 19)"
|
color: "rgb(75, 220, 19)"
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
@ -89,7 +88,7 @@ const layer = createLayer(id, () => {
|
||||||
display: {
|
display: {
|
||||||
requirement: "4 Lollipops",
|
requirement: "4 Lollipops",
|
||||||
effectDisplay: "You can toggle beep and boop (which do nothing)",
|
effectDisplay: "You can toggle beep and boop (which do nothing)",
|
||||||
optionsDisplay: jsx(() => (
|
optionsDisplay: () => (
|
||||||
<>
|
<>
|
||||||
<Toggle
|
<Toggle
|
||||||
title="beep"
|
title="beep"
|
||||||
|
@ -102,11 +101,11 @@ const layer = createLayer(id, () => {
|
||||||
modelValue={f.boop.value}
|
modelValue={f.boop.value}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
))
|
)
|
||||||
},
|
},
|
||||||
style() {
|
style() {
|
||||||
if (unref(this.earned)) {
|
if (unref(lollipopMilestone4.earned.value) !== false) {
|
||||||
return { backgroundColor: "#1111DD" };
|
return { "--layer-color": "#1111DD" };
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
@ -121,15 +120,15 @@ const layer = createLayer(id, () => {
|
||||||
spendResources: false
|
spendResources: false
|
||||||
})),
|
})),
|
||||||
completionLimit: 3,
|
completionLimit: 3,
|
||||||
display() {
|
display: {
|
||||||
return {
|
description: (): Renderable => <>
|
||||||
description: `Makes the game 0% harder<br>${formatWhole(this.completions.value)}/${
|
Makes the game 0% harder<br/>{formatWhole(funChallenge.completions.value)}/{
|
||||||
this.completionLimit
|
funChallenge.completionLimit
|
||||||
} completions`,
|
} completions
|
||||||
goal: "Have 20 points I guess",
|
</>,
|
||||||
reward: "Says hi",
|
goal: "Have 20 points I guess",
|
||||||
effectDisplay: format(funEffect.value) + "x"
|
reward: "Says hi",
|
||||||
};
|
effectDisplay: format(funEffect.value) + "x"
|
||||||
},
|
},
|
||||||
visibility: () => Decimal.gt(best.value, 0),
|
visibility: () => Decimal.gt(best.value, 0),
|
||||||
onComplete() {
|
onComplete() {
|
||||||
|
@ -145,7 +144,7 @@ const layer = createLayer(id, () => {
|
||||||
console.log("Sweet freedom!");
|
console.log("Sweet freedom!");
|
||||||
},
|
},
|
||||||
style: {
|
style: {
|
||||||
height: "200px"
|
height: "400px"
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
const funEffect = computed(() => Decimal.add(points.value, 1).tetrate(0.02));
|
const funEffect = computed(() => Decimal.add(points.value, 1).tetrate(0.02));
|
||||||
|
@ -161,10 +160,10 @@ const layer = createLayer(id, () => {
|
||||||
}))
|
}))
|
||||||
}));
|
}));
|
||||||
const lollipopMultiplierUpgrade = createUpgrade(() => ({
|
const lollipopMultiplierUpgrade = createUpgrade(() => ({
|
||||||
display: () => ({
|
display: {
|
||||||
description: "Point generation is faster based on your unspent Lollipops",
|
description: "Point generation is faster based on your unspent Lollipops",
|
||||||
effectDisplay: `${format(lollipopMultiplierEffect.value)}x`
|
effectDisplay: () => `${format(lollipopMultiplierEffect.value)}x`
|
||||||
}),
|
},
|
||||||
requirements: createCostRequirement(() => ({
|
requirements: createCostRequirement(() => ({
|
||||||
cost: 1,
|
cost: 1,
|
||||||
resource: noPersist(points)
|
resource: noPersist(points)
|
||||||
|
@ -189,11 +188,11 @@ const layer = createLayer(id, () => {
|
||||||
display:
|
display:
|
||||||
"Only buyable with less than 7 points, and gives you 7 more. Unlocks a secret subtab.",
|
"Only buyable with less than 7 points, and gives you 7 more. Unlocks a secret subtab.",
|
||||||
style() {
|
style() {
|
||||||
if (unref(this.bought)) {
|
if (unref(unlockIlluminatiUpgrade.bought)) {
|
||||||
return { backgroundColor: "#1111dd" };
|
return { "--layer-color": "#1111dd" };
|
||||||
}
|
}
|
||||||
if (!unref(this.canPurchase)) {
|
if (!unref(unlockIlluminatiUpgrade.canPurchase)) {
|
||||||
return { backgroundColor: "#dd1111" };
|
return { "--layer-color": "#dd1111" };
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
@ -220,21 +219,20 @@ const layer = createLayer(id, () => {
|
||||||
const cost = Decimal.pow(2, x.pow(1.5));
|
const cost = Decimal.pow(2, x.pow(1.5));
|
||||||
return cost.floor();
|
return cost.floor();
|
||||||
},
|
},
|
||||||
pay(amount) {
|
pay() {
|
||||||
const cost = unref(this.cost as unknown as ProcessedComputable<DecimalSource>);
|
const cost = unref(exhancersCost.cost) as DecimalSource;
|
||||||
spentOnBuyables.value = Decimal.add(spentOnBuyables.value, cost ?? 0);
|
spentOnBuyables.value = Decimal.add(spentOnBuyables.value, cost);
|
||||||
this.resource.value = Decimal.sub(this.resource.value, cost).max(0);
|
exhancersCost.resource.value = Decimal.sub(exhancersCost.resource.value, cost).max(0);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
const exhancers = createRepeatable(() => ({
|
const exhancers = createRepeatable(() => ({
|
||||||
requirements: exhancersCost,
|
requirements: exhancersCost,
|
||||||
display() {
|
display: {
|
||||||
return {
|
title: "Exhancers",
|
||||||
title: "Exhancers",
|
description: () =>
|
||||||
description: `Adds ${format(
|
`Adds ${format(
|
||||||
thingEffect.value
|
thingEffect.value
|
||||||
)} things and multiplies stuff by ${format(stuffEffect.value)}.`
|
)} things and multiplies stuff by ${format(stuffEffect.value)}.`
|
||||||
};
|
|
||||||
},
|
},
|
||||||
style: { height: "222px" },
|
style: { height: "222px" },
|
||||||
purchaseLimit: 4
|
purchaseLimit: 4
|
||||||
|
@ -282,7 +280,7 @@ const layer = createLayer(id, () => {
|
||||||
spentOnBuyables.value = Decimal.sub(spentOnBuyables.value, cost);
|
spentOnBuyables.value = Decimal.sub(spentOnBuyables.value, cost);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
const buyablesDisplay = jsx(() => (
|
const buyablesDisplay = computed(() => (
|
||||||
<Column>
|
<Column>
|
||||||
<Row>
|
<Row>
|
||||||
<Toggle
|
<Toggle
|
||||||
|
@ -311,8 +309,8 @@ const layer = createLayer(id, () => {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="button modal-default-button danger"
|
class="button modal-default-button danger"
|
||||||
onClick={() => {
|
onClick={e => {
|
||||||
respecBuyables.onClick();
|
respecBuyables.onClick?.(e);
|
||||||
confirming.value = false;
|
confirming.value = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -396,8 +394,8 @@ const layer = createLayer(id, () => {
|
||||||
key: "c",
|
key: "c",
|
||||||
description: "reset for lollipops or whatever",
|
description: "reset for lollipops or whatever",
|
||||||
onPress() {
|
onPress() {
|
||||||
if (resetButton.canClick.value) {
|
if (unref(resetButton.canClick) !== false) {
|
||||||
resetButton.onClick();
|
resetButton.onClick?.(undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})),
|
})),
|
||||||
|
@ -405,7 +403,7 @@ const layer = createLayer(id, () => {
|
||||||
key: "ctrl+c",
|
key: "ctrl+c",
|
||||||
description: "respec things",
|
description: "respec things",
|
||||||
onPress() {
|
onPress() {
|
||||||
respecBuyables.onClick();
|
respecBuyables.onClick?.(undefined);
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
];
|
];
|
||||||
|
@ -429,10 +427,10 @@ const layer = createLayer(id, () => {
|
||||||
textDecoration: "underline"
|
textDecoration: "underline"
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
const treeNodeTooltip = addTooltip(treeNode, {
|
const treeNodeTooltip = addTooltip(treeNode, () => ({
|
||||||
display: createResourceTooltip(points),
|
display: createResourceTooltip(points),
|
||||||
pinnable: true
|
pinnable: true
|
||||||
});
|
}));
|
||||||
|
|
||||||
const resetButton = createResetButton(() => ({
|
const resetButton = createResetButton(() => ({
|
||||||
conversion,
|
conversion,
|
||||||
|
@ -443,8 +441,8 @@ const layer = createLayer(id, () => {
|
||||||
},
|
},
|
||||||
resetDescription: "Melt your points into "
|
resetDescription: "Melt your points into "
|
||||||
}));
|
}));
|
||||||
const resetButtonTooltip = addTooltip(resetButton, {
|
const resetButtonTooltip = addTooltip(resetButton, () => ({
|
||||||
display: jsx(() =>
|
display: computed(() =>
|
||||||
createModifierSection({
|
createModifierSection({
|
||||||
title: "Modifiers",
|
title: "Modifiers",
|
||||||
modifier: conversionModifier
|
modifier: conversionModifier
|
||||||
|
@ -452,8 +450,8 @@ const layer = createLayer(id, () => {
|
||||||
),
|
),
|
||||||
pinnable: true,
|
pinnable: true,
|
||||||
direction: Direction.Down,
|
direction: Direction.Down,
|
||||||
style: "width: 400px; text-align: left"
|
style: { width: "400px", textAlign: "left" }
|
||||||
});
|
}));
|
||||||
|
|
||||||
const g = createTreeNode(() => ({
|
const g = createTreeNode(() => ({
|
||||||
display: "TH",
|
display: "TH",
|
||||||
|
@ -493,7 +491,7 @@ const layer = createLayer(id, () => {
|
||||||
visibility: Visibility.Hidden
|
visibility: Visibility.Hidden
|
||||||
}));
|
}));
|
||||||
const tree = createTree(() => ({
|
const tree = createTree(() => ({
|
||||||
nodes(): GenericTreeNode[][] {
|
nodes() {
|
||||||
return [
|
return [
|
||||||
[f.treeNode, treeNode],
|
[f.treeNode, treeNode],
|
||||||
[g, spook, h]
|
[g, spook, h]
|
||||||
|
@ -514,7 +512,7 @@ const layer = createLayer(id, () => {
|
||||||
{ startNode: g, endNode: h }
|
{ startNode: g, endNode: h }
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}));
|
})) as Tree;
|
||||||
|
|
||||||
const links = createLinks(() => ({
|
const links = createLinks(() => ({
|
||||||
links: [
|
links: [
|
||||||
|
@ -523,7 +521,10 @@ const layer = createLayer(id, () => {
|
||||||
endNode: flatBoi,
|
endNode: flatBoi,
|
||||||
"stroke-width": "5px",
|
"stroke-width": "5px",
|
||||||
stroke: "red",
|
stroke: "red",
|
||||||
offsetEnd: { x: -50 + 100 * flatBoi.progress.value.toNumber(), y: 0 }
|
offsetEnd: {
|
||||||
|
x: -50 + 100 * Number(new Decimal(unref(flatBoi.progress).toString())),
|
||||||
|
y: 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}));
|
}));
|
||||||
|
@ -531,13 +532,13 @@ const layer = createLayer(id, () => {
|
||||||
const illuminatiTabs = createTabFamily(
|
const illuminatiTabs = createTabFamily(
|
||||||
{
|
{
|
||||||
first: () => ({
|
first: () => ({
|
||||||
tab: jsx(() => (
|
tab: () => (
|
||||||
<>
|
<>
|
||||||
{renderRow(...upgrades)}
|
{renderRow(...upgrades)}
|
||||||
{renderRow(quasiUpgrade)}
|
{renderRow(quasiUpgrade)}
|
||||||
<div>confirmed</div>
|
<div>confirmed</div>
|
||||||
</>
|
</>
|
||||||
)),
|
),
|
||||||
display: "first"
|
display: "first"
|
||||||
}),
|
}),
|
||||||
second: () => ({
|
second: () => ({
|
||||||
|
@ -555,21 +556,13 @@ const layer = createLayer(id, () => {
|
||||||
marginRight: "auto"
|
marginRight: "auto"
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
) as GenericTabFamily;
|
) as TabFamily;
|
||||||
|
|
||||||
const tabs = createTabFamily({
|
const tabs = createTabFamily({
|
||||||
mainTab: () => ({
|
mainTab: () => ({
|
||||||
tab: createTab(() => ({
|
tab: createTab(() => ({
|
||||||
display: jsx(() => (
|
display: () => (
|
||||||
<>
|
<>
|
||||||
<MainDisplay
|
|
||||||
resource={points}
|
|
||||||
color={color}
|
|
||||||
effectDisplay={`which are boosting waffles by ${format(
|
|
||||||
waffleBoost.value
|
|
||||||
)} and increasing the Ice Cream cap by ${format(icecreamCap.value)}`}
|
|
||||||
/>
|
|
||||||
<Sticky>{render(resetButton)}</Sticky>
|
|
||||||
<Resource resource={points} color={color} />
|
<Resource resource={points} color={color} />
|
||||||
<Spacer height="5px" />
|
<Spacer height="5px" />
|
||||||
<button onClick={() => console.log("yeet")}>'HI'</button>
|
<button onClick={() => console.log("yeet")}>'HI'</button>
|
||||||
|
@ -588,7 +581,7 @@ const layer = createLayer(id, () => {
|
||||||
{renderRow(quasiUpgrade)}
|
{renderRow(quasiUpgrade)}
|
||||||
{renderRow(funChallenge)}
|
{renderRow(funChallenge)}
|
||||||
</>
|
</>
|
||||||
))
|
)
|
||||||
})),
|
})),
|
||||||
display: "main tab",
|
display: "main tab",
|
||||||
glowColor() {
|
glowColor() {
|
||||||
|
@ -609,7 +602,7 @@ const layer = createLayer(id, () => {
|
||||||
style() {
|
style() {
|
||||||
return { backgroundColor: "#222222", "--background": "#222222" };
|
return { backgroundColor: "#222222", "--background": "#222222" };
|
||||||
},
|
},
|
||||||
display: jsx(() => (
|
display: () => (
|
||||||
<>
|
<>
|
||||||
{render(buyablesDisplay)}
|
{render(buyablesDisplay)}
|
||||||
<Spacer />
|
<Spacer />
|
||||||
|
@ -628,7 +621,7 @@ const layer = createLayer(id, () => {
|
||||||
<Spacer />
|
<Spacer />
|
||||||
<img src="https://unsoftcapped2.github.io/The-Modding-Tree-2/discord.png" />
|
<img src="https://unsoftcapped2.github.io/The-Modding-Tree-2/discord.png" />
|
||||||
</>
|
</>
|
||||||
))
|
)
|
||||||
})),
|
})),
|
||||||
glowColor: "white",
|
glowColor: "white",
|
||||||
display: "thingies",
|
display: "thingies",
|
||||||
|
@ -636,7 +629,7 @@ const layer = createLayer(id, () => {
|
||||||
}),
|
}),
|
||||||
jail: () => ({
|
jail: () => ({
|
||||||
tab: createTab(() => ({
|
tab: createTab(() => ({
|
||||||
display: jsx(() => (
|
display: () => (
|
||||||
<>
|
<>
|
||||||
{render(coolInfo)}
|
{render(coolInfo)}
|
||||||
{render(longBoi)}
|
{render(longBoi)}
|
||||||
|
@ -658,16 +651,14 @@ const layer = createLayer(id, () => {
|
||||||
<div>It's jail because "bars"! So funny! Ha ha!</div>
|
<div>It's jail because "bars"! So funny! Ha ha!</div>
|
||||||
{render(tree)}
|
{render(tree)}
|
||||||
</>
|
</>
|
||||||
))
|
)
|
||||||
})),
|
})),
|
||||||
display: "jail"
|
display: "jail"
|
||||||
}),
|
}),
|
||||||
illuminati: () => ({
|
illuminati: () => ({
|
||||||
tab: createTab(() => ({
|
tab: createTab(() => ({
|
||||||
display: jsx(() => (
|
display: () => (
|
||||||
// This should really just be <> and </>, however for some reason the
|
<>
|
||||||
// typescript interpreter can't figure out this layer and f.tsx otherwise
|
|
||||||
<div>
|
|
||||||
<h1> C O N F I R M E D </h1>
|
<h1> C O N F I R M E D </h1>
|
||||||
<Spacer />
|
<Spacer />
|
||||||
{render(illuminatiTabs)}
|
{render(illuminatiTabs)}
|
||||||
|
@ -678,8 +669,8 @@ const layer = createLayer(id, () => {
|
||||||
min={1}
|
min={1}
|
||||||
max={30}
|
max={30}
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
)),
|
),
|
||||||
style: {
|
style: {
|
||||||
backgroundColor: "#3325CC"
|
backgroundColor: "#3325CC"
|
||||||
}
|
}
|
||||||
|
@ -727,12 +718,20 @@ const layer = createLayer(id, () => {
|
||||||
confirmRespec,
|
confirmRespec,
|
||||||
minWidth: 800,
|
minWidth: 800,
|
||||||
tabs,
|
tabs,
|
||||||
display: jsx(() => (
|
display: () => (
|
||||||
<>
|
<>
|
||||||
|
<MainDisplay
|
||||||
|
resource={points}
|
||||||
|
color={color}
|
||||||
|
effectDisplay={`which are boosting waffles by ${format(
|
||||||
|
waffleBoost.value
|
||||||
|
)} and increasing the Ice Cream cap by ${format(icecreamCap.value)}`}
|
||||||
|
/>
|
||||||
|
{render(resetButton)}
|
||||||
{render(tabs)}
|
{render(tabs)}
|
||||||
{render(links)}
|
{render(links)}
|
||||||
</>
|
</>
|
||||||
)),
|
),
|
||||||
treeNodeTooltip,
|
treeNodeTooltip,
|
||||||
resetButtonTooltip
|
resetButtonTooltip
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,22 +3,20 @@ import { createLayerTreeNode, createResetButton } from "data/common";
|
||||||
import { main } from "data/projEntry";
|
import { main } from "data/projEntry";
|
||||||
import { createClickable } from "features/clickables/clickable";
|
import { createClickable } from "features/clickables/clickable";
|
||||||
import { createIndependentConversion } from "features/conversion";
|
import { createIndependentConversion } from "features/conversion";
|
||||||
import { jsx } from "features/feature";
|
|
||||||
import { createInfobox } from "features/infoboxes/infobox";
|
import { createInfobox } from "features/infoboxes/infobox";
|
||||||
import { createParticles } from "features/particles/particles";
|
import { createParticles } from "features/particles/particles";
|
||||||
import { createReset } from "features/reset";
|
import { createReset } from "features/reset";
|
||||||
import MainDisplay from "features/resources/MainDisplay.vue";
|
import MainDisplay from "features/resources/MainDisplay.vue";
|
||||||
import { createResource, displayResource } from "features/resources/resource";
|
import { createResource, displayResource } from "features/resources/resource";
|
||||||
import { addTooltip } from "features/tooltips/tooltip";
|
|
||||||
import { createResourceTooltip } from "features/trees/tree";
|
import { createResourceTooltip } from "features/trees/tree";
|
||||||
import Formula from "game/formulas/formulas";
|
|
||||||
import { createLayer } from "game/layers";
|
import { createLayer } from "game/layers";
|
||||||
import { noPersist, persistent } from "game/persistence";
|
import { noPersist, persistent } from "game/persistence";
|
||||||
import Decimal, { DecimalSource, formatWhole } from "util/bignum";
|
import Decimal, { DecimalSource, formatWhole } from "util/bignum";
|
||||||
import { render, renderRow } from "util/vue";
|
import { render, renderRow } from "util/vue";
|
||||||
import { ref } from "vue";
|
import { ref, unref } from "vue";
|
||||||
import c from "./c";
|
import c from "./c";
|
||||||
import confetti from "./confetti.json";
|
import confetti from "./confetti.json";
|
||||||
|
import { addTooltip } from "wrappers/tooltips/tooltip";
|
||||||
|
|
||||||
const id = "f";
|
const id = "f";
|
||||||
const layer = createLayer(id, () => {
|
const layer = createLayer(id, () => {
|
||||||
|
@ -34,13 +32,11 @@ const layer = createLayer(id, () => {
|
||||||
bodyStyle: { backgroundColor: "#0000EE" }
|
bodyStyle: { backgroundColor: "#0000EE" }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const clickableState = persistent<string>("Start");
|
const clickableState = persistent<string>("Start", false);
|
||||||
const clickable = createClickable(() => ({
|
const clickable = createClickable(() => ({
|
||||||
display() {
|
display: {
|
||||||
return {
|
title: "Clicky clicky!",
|
||||||
title: "Clicky clicky!",
|
description: () => <>Current state:<br/>{clickableState.value}</>
|
||||||
description: "Current state:<br>" + clickableState.value
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
initialState: "Start",
|
initialState: "Start",
|
||||||
canClick() {
|
canClick() {
|
||||||
|
@ -58,7 +54,7 @@ const layer = createLayer(id, () => {
|
||||||
clickableState.value = "Maybe that's a bit too far...";
|
clickableState.value = "Maybe that's a bit too far...";
|
||||||
break;
|
break;
|
||||||
case "Maybe that's a bit too far...":
|
case "Maybe that's a bit too far...":
|
||||||
const pos = e == undefined ? undefined : "touches" in e ? e.touches[0] : e;
|
const pos = e == null ? undefined : "touches" in e ? e.touches[0] : e;
|
||||||
const confettiParticles = Object.assign({}, confetti, {
|
const confettiParticles = Object.assign({}, confetti, {
|
||||||
pos: {
|
pos: {
|
||||||
x: (pos?.clientX ?? 0) - (particles.boundingRect?.value?.left ?? 0),
|
x: (pos?.clientX ?? 0) - (particles.boundingRect?.value?.left ?? 0),
|
||||||
|
@ -79,13 +75,13 @@ const layer = createLayer(id, () => {
|
||||||
style() {
|
style() {
|
||||||
switch (clickableState.value) {
|
switch (clickableState.value) {
|
||||||
case "Start":
|
case "Start":
|
||||||
return { "background-color": "green" };
|
return { "--layer-color": "green" };
|
||||||
case "A new state!":
|
case "A new state!":
|
||||||
return { "background-color": "yellow" };
|
return { "--layer-color": "yellow" };
|
||||||
case "Keep going!":
|
case "Keep going!":
|
||||||
return { "background-color": "orange" };
|
return { "--layer-color": "orange" };
|
||||||
case "Maybe that's a bit too far...":
|
case "Maybe that's a bit too far...":
|
||||||
return { "background-color": "red" };
|
return { "--layer-color": "red" };
|
||||||
default:
|
default:
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
@ -94,12 +90,12 @@ const layer = createLayer(id, () => {
|
||||||
|
|
||||||
const resetClickable = createClickable(() => ({
|
const resetClickable = createClickable(() => ({
|
||||||
onClick() {
|
onClick() {
|
||||||
if (clickableState.value == "Borkened...") {
|
if (clickableState.value === "Borkened...") {
|
||||||
clickableState.value = "Start";
|
clickableState.value = "Start";
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
display() {
|
display() {
|
||||||
return clickableState.value == "Borkened..." ? "Fix the clickable!" : "Does nothing";
|
return clickableState.value === "Borkened..." ? "Fix the clickable!" : "Does nothing";
|
||||||
},
|
},
|
||||||
small: true
|
small: true
|
||||||
}));
|
}));
|
||||||
|
@ -119,7 +115,7 @@ const layer = createLayer(id, () => {
|
||||||
color,
|
color,
|
||||||
reset,
|
reset,
|
||||||
tooltip() {
|
tooltip() {
|
||||||
if (treeNode.canClick.value) {
|
if (unref(treeNode.canClick) !== false) {
|
||||||
return `${displayResource(points)} ${points.displayName}`;
|
return `${displayResource(points)} ${points.displayName}`;
|
||||||
}
|
}
|
||||||
return `This weird farmer dinosaur will only see you if you have at least 10 points. You only have ${displayResource(
|
return `This weird farmer dinosaur will only see you if you have at least 10 points. You only have ${displayResource(
|
||||||
|
@ -130,23 +126,23 @@ const layer = createLayer(id, () => {
|
||||||
return Decimal.gte(main.points.value, 10);
|
return Decimal.gte(main.points.value, 10);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
const tooltip = addTooltip(treeNode, {
|
const tooltip = addTooltip(treeNode, () => ({
|
||||||
display: createResourceTooltip(points),
|
display: createResourceTooltip(points),
|
||||||
pinnable: true
|
pinnable: true
|
||||||
});
|
}));
|
||||||
|
|
||||||
const resetButton = createResetButton(() => ({
|
const resetButton = createResetButton(() => ({
|
||||||
conversion,
|
conversion,
|
||||||
tree: main.tree,
|
tree: main.tree,
|
||||||
treeNode,
|
treeNode,
|
||||||
display: jsx(() => {
|
display: () => {
|
||||||
if (resetButton.conversion.buyMax) {
|
if (unref(resetButton.conversion.buyMax) !== false) {
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
Hi! I'm a <u>weird dinosaur</u> and I'll give you{" "}
|
Hi! I'm a <u>weird dinosaur</u> and I'll give you{" "}
|
||||||
<b>{formatWhole(resetButton.conversion.currentGain.value)}</b> Farm Points
|
<b>{formatWhole(unref(resetButton.conversion.currentGain))}</b> Farm Points
|
||||||
in exchange for all of your points and lollipops! (You'll get another one at{" "}
|
in exchange for all of your points and lollipops! (You'll get another one at{" "}
|
||||||
{formatWhole(resetButton.conversion.nextAt.value)} points)
|
{formatWhole(unref(resetButton.conversion.nextAt))} points)
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
@ -154,39 +150,21 @@ const layer = createLayer(id, () => {
|
||||||
<span>
|
<span>
|
||||||
Hi! I'm a <u>weird dinosaur</u> and I'll give you a Farm Point in exchange
|
Hi! I'm a <u>weird dinosaur</u> and I'll give you a Farm Point in exchange
|
||||||
for all of your points and lollipops! (At least{" "}
|
for all of your points and lollipops! (At least{" "}
|
||||||
{formatWhole(resetButton.conversion.nextAt.value)} points)
|
{formatWhole(unref(resetButton.conversion.nextAt))} points)
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const particles = createParticles(() => ({
|
const particles = createParticles(() => ({
|
||||||
boundingRect: ref<null | DOMRect>(null),
|
boundingRect: ref<null | DOMRect>(null),
|
||||||
onContainerResized(boundingRect) {
|
onContainerResized(boundingRect) {
|
||||||
this.boundingRect.value = boundingRect;
|
particles.boundingRect.value = boundingRect;
|
||||||
},
|
},
|
||||||
style: "z-index: 2"
|
style: { zIndex: 2 }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const tab = jsx(() => (
|
|
||||||
<>
|
|
||||||
{render(coolInfo)}
|
|
||||||
<MainDisplay resource={points} color={color} />
|
|
||||||
{render(resetButton)}
|
|
||||||
<div>You have {formatWhole(conversion.baseResource.value)} points</div>
|
|
||||||
<div>
|
|
||||||
<br />
|
|
||||||
<img src="https://images.beano.com/store/24ab3094eb95e5373bca1ccd6f330d4406db8d1f517fc4170b32e146f80d?auto=compress%2Cformat&dpr=1&w=390" />
|
|
||||||
<div>Bork Bork!</div>
|
|
||||||
</div>
|
|
||||||
<Spacer />
|
|
||||||
{renderRow(resetClickable)}
|
|
||||||
{renderRow(clickable)}
|
|
||||||
{render(particles)}
|
|
||||||
</>
|
|
||||||
));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
color,
|
color,
|
||||||
|
@ -201,7 +179,23 @@ const layer = createLayer(id, () => {
|
||||||
treeNode,
|
treeNode,
|
||||||
resetButton,
|
resetButton,
|
||||||
minWidth: 650,
|
minWidth: 650,
|
||||||
display: tab,
|
display: () => (
|
||||||
|
<>
|
||||||
|
{render(coolInfo)}
|
||||||
|
<MainDisplay resource={points} color={color} />
|
||||||
|
{render(resetButton)}
|
||||||
|
<div>You have {formatWhole(conversion.baseResource.value)} points</div>
|
||||||
|
<div>
|
||||||
|
<br />
|
||||||
|
<img src="https://www.thepaperpilot.org/paperpilot.png" height="200px" />
|
||||||
|
<div>Bork Bork!</div>
|
||||||
|
</div>
|
||||||
|
<Spacer />
|
||||||
|
{renderRow(resetClickable)}
|
||||||
|
{renderRow(clickable)}
|
||||||
|
{render(particles)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
tooltip
|
tooltip
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
424
src/data/layers/board.tsx
Normal file
424
src/data/layers/board.tsx
Normal file
|
@ -0,0 +1,424 @@
|
||||||
|
import { createUpgrade } from "features/clickables/upgrade";
|
||||||
|
import { createResource } from "features/resources/resource";
|
||||||
|
import Board from "game/boards/Board.vue";
|
||||||
|
import CircleProgress from "game/boards/CircleProgress.vue";
|
||||||
|
import SVGNode from "game/boards/SVGNode.vue";
|
||||||
|
import SquareProgress from "game/boards/SquareProgress.vue";
|
||||||
|
import {
|
||||||
|
Draggable,
|
||||||
|
MakeDraggableOptions,
|
||||||
|
NodePosition,
|
||||||
|
makeDraggable,
|
||||||
|
placeInAvailableSpace,
|
||||||
|
setupActions,
|
||||||
|
setupDraggableNode,
|
||||||
|
setupUniqueIds
|
||||||
|
} from "game/boards/board";
|
||||||
|
import type { BaseLayer } from "game/layers";
|
||||||
|
import { createLayer } from "game/layers";
|
||||||
|
import { persistent } from "game/persistence";
|
||||||
|
import { createCostRequirement } from "game/requirements";
|
||||||
|
import { render } from "util/vue";
|
||||||
|
import { ComponentPublicInstance, computed, ref, watch } from "vue";
|
||||||
|
import { setupSelectable } from "../common";
|
||||||
|
|
||||||
|
const board = createLayer("board", function (this: BaseLayer) {
|
||||||
|
type ANode = NodePosition & { id: number; links: number[]; type: "anode"; z: number };
|
||||||
|
type BNode = NodePosition & { id: number; links: number[]; type: "bnode"; z: number };
|
||||||
|
type CNode = typeof cNode & { draggable: Draggable<number | "cNode"> };
|
||||||
|
type NodeTypes = ANode | BNode;
|
||||||
|
|
||||||
|
const board = ref<ComponentPublicInstance<typeof Board>>();
|
||||||
|
|
||||||
|
const { select, deselect, selected } = setupSelectable<number>();
|
||||||
|
const {
|
||||||
|
select: selectAction,
|
||||||
|
deselect: deselectAction,
|
||||||
|
selected: selectedAction
|
||||||
|
} = setupSelectable<number>();
|
||||||
|
|
||||||
|
watch(selected, selected => {
|
||||||
|
if (selected == null) {
|
||||||
|
deselectAction();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
startDrag,
|
||||||
|
endDrag,
|
||||||
|
drag,
|
||||||
|
nodeBeingDragged,
|
||||||
|
hasDragged,
|
||||||
|
receivingNodes,
|
||||||
|
receivingNode,
|
||||||
|
dragDelta
|
||||||
|
} = setupDraggableNode<number | "cnode">({
|
||||||
|
board,
|
||||||
|
getPosition(id) {
|
||||||
|
return nodesById.value[id] ?? (cNode as CNode).draggable.position.value;
|
||||||
|
},
|
||||||
|
setPosition(id, position) {
|
||||||
|
const node = nodesById.value[id] ?? (cNode as CNode).draggable.position.value;
|
||||||
|
node.x = position.x;
|
||||||
|
node.y = position.y;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// a nodes can be slotted into b nodes to draw a branch between them, with limited connections
|
||||||
|
// a nodes can be selected and have an action to spawn a b node, and vice versa
|
||||||
|
// Newly spawned nodes should find a safe spot to spawn, and display a link to their creator
|
||||||
|
// a nodes use all the stuff circles used to have, and b diamonds
|
||||||
|
// c node also exists but is a single Upgrade element that cannot be selected, but can be dragged
|
||||||
|
// d nodes are a performance test - 1000 simple nodes that have no interactions
|
||||||
|
// Make all nodes animate in (decorator? `fadeIn(feature)?)
|
||||||
|
const nodes = persistent<(ANode | BNode)[]>([
|
||||||
|
{ id: 0, x: 0, y: 0, z: 0, links: [], type: "anode" }
|
||||||
|
]);
|
||||||
|
const nodesById = computed<Record<string, NodeTypes>>(() =>
|
||||||
|
nodes.value.reduce((acc, curr) => ({ ...acc, [curr.id]: curr }), {})
|
||||||
|
);
|
||||||
|
function mouseDownNode(e: MouseEvent | TouchEvent, node: NodeTypes) {
|
||||||
|
const oldZ = node.z;
|
||||||
|
nodes.value.forEach(node => {
|
||||||
|
if (node.z > oldZ) {
|
||||||
|
node.z--;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
node.z = nextId.value;
|
||||||
|
if (nodeBeingDragged.value == null) {
|
||||||
|
startDrag(e, node.id);
|
||||||
|
}
|
||||||
|
deselect();
|
||||||
|
}
|
||||||
|
function mouseUpNode(e: MouseEvent | TouchEvent, node: NodeTypes) {
|
||||||
|
if (!hasDragged.value) {
|
||||||
|
endDrag();
|
||||||
|
if (typeof node.id === "number") {
|
||||||
|
select(node.id);
|
||||||
|
}
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function translate(node: NodePosition, isDragging: boolean) {
|
||||||
|
let x = node.x;
|
||||||
|
let y = node.y;
|
||||||
|
if (isDragging) {
|
||||||
|
x += dragDelta.value.x;
|
||||||
|
y += dragDelta.value.y;
|
||||||
|
}
|
||||||
|
return ` translate(${x}px,${y}px)`;
|
||||||
|
}
|
||||||
|
function rotate(rotation: number) {
|
||||||
|
return ` rotate(${rotation}deg) `;
|
||||||
|
}
|
||||||
|
function scale(nodeOrBool: NodeTypes | boolean) {
|
||||||
|
const isSelected =
|
||||||
|
typeof nodeOrBool === "boolean" ? nodeOrBool : selected.value === nodeOrBool.id;
|
||||||
|
return isSelected ? " scale(1.2)" : "";
|
||||||
|
}
|
||||||
|
function opacity(node: NodeTypes) {
|
||||||
|
const isDragging = selected.value !== node.id && nodeBeingDragged.value === node.id;
|
||||||
|
if (isDragging) {
|
||||||
|
return "; opacity: 0.5;";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
function zIndex(node: NodeTypes) {
|
||||||
|
if (selected.value === node.id || nodeBeingDragged.value === node.id) {
|
||||||
|
return "; z-index: 100000000";
|
||||||
|
}
|
||||||
|
return "; z-index: " + node.z;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderANode = function (node: ANode) {
|
||||||
|
return (
|
||||||
|
<SVGNode
|
||||||
|
style={`transform: ${translate(node, nodeBeingDragged.value === node.id)}${opacity(
|
||||||
|
node
|
||||||
|
)}${zIndex(node)}`}
|
||||||
|
onMouseDown={e => mouseDownNode(e, node)}
|
||||||
|
onMouseUp={e => mouseUpNode(e, node)}
|
||||||
|
>
|
||||||
|
<g style={`transform: ${scale(node)}`}>
|
||||||
|
{receivingNodes.value.includes(node.id) && (
|
||||||
|
<circle
|
||||||
|
r="58"
|
||||||
|
fill="var(--background)"
|
||||||
|
stroke={receivingNode.value === node.id ? "#0F0" : "#0F03"}
|
||||||
|
stroke-width="2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<CircleProgress r={54.5} progress={0.5} stroke="var(--accent2)" />
|
||||||
|
<circle
|
||||||
|
r="50"
|
||||||
|
fill="var(--raised-background)"
|
||||||
|
stroke="var(--outline)"
|
||||||
|
stroke-width="4"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
{selected.value === node.id && selectedAction.value === 0 && (
|
||||||
|
<text y="140" fill="var(--foreground)" class="node-text">
|
||||||
|
Spawn B Node
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
<text fill="var(--foreground)" class="node-text">
|
||||||
|
A
|
||||||
|
</text>
|
||||||
|
</SVGNode>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const aActions = setupActions({
|
||||||
|
node: () => nodesById.value[selected.value ?? ""],
|
||||||
|
shouldShowActions: node => node.type === "anode",
|
||||||
|
actions(node) {
|
||||||
|
return [
|
||||||
|
p => (
|
||||||
|
<g
|
||||||
|
style={`transform: ${translate(p, selectedAction.value === 0)}${scale(
|
||||||
|
selectedAction.value === 0
|
||||||
|
)}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedAction.value === 0) {
|
||||||
|
spawnBNode(node as ANode);
|
||||||
|
} else {
|
||||||
|
selectAction(0);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<circle fill="black" r="20"></circle>
|
||||||
|
<text fill="white" class="material-icons" x="-12" y="12">
|
||||||
|
add
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
];
|
||||||
|
},
|
||||||
|
distance: 100
|
||||||
|
});
|
||||||
|
const sqrtTwo = Math.sqrt(2);
|
||||||
|
const renderBNode = function (node: BNode) {
|
||||||
|
return (
|
||||||
|
<SVGNode
|
||||||
|
style={`transform: ${translate(node, nodeBeingDragged.value === node.id)}${opacity(
|
||||||
|
node
|
||||||
|
)}${zIndex(node)}`}
|
||||||
|
onMouseDown={e => mouseDownNode(e, node)}
|
||||||
|
onMouseUp={e => mouseUpNode(e, node)}
|
||||||
|
>
|
||||||
|
<g style={`transform: ${scale(node)}${rotate(45)}`}>
|
||||||
|
{receivingNodes.value.includes(node.id) && (
|
||||||
|
<rect
|
||||||
|
width={50 * sqrtTwo + 16}
|
||||||
|
height={50 * sqrtTwo + 16}
|
||||||
|
style={`translate(${(-50 * sqrtTwo + 16) / 2}, ${
|
||||||
|
(-50 * sqrtTwo + 16) / 2
|
||||||
|
})`}
|
||||||
|
fill="var(--background)"
|
||||||
|
stroke={receivingNode.value === node.id ? "#0F0" : "#0F03"}
|
||||||
|
stroke-width="2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<SquareProgress
|
||||||
|
size={50 * sqrtTwo + 9}
|
||||||
|
progress={0.5}
|
||||||
|
stroke="var(--accent2)"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
width={50 * sqrtTwo}
|
||||||
|
height={50 * sqrtTwo}
|
||||||
|
style={`transform: translate(${(-50 * sqrtTwo) / 2}px, ${
|
||||||
|
(-50 * sqrtTwo) / 2
|
||||||
|
}px)`}
|
||||||
|
fill="var(--raised-background)"
|
||||||
|
stroke="var(--outline)"
|
||||||
|
stroke-width="4"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
{selected.value === node.id && selectedAction.value === 0 && (
|
||||||
|
<text y="140" fill="var(--foreground)" class="node-text">
|
||||||
|
Spawn A Node
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
<text fill="var(--foreground)" class="node-text">
|
||||||
|
B
|
||||||
|
</text>
|
||||||
|
</SVGNode>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const bActions = setupActions({
|
||||||
|
node: () => nodesById.value[selected.value ?? ""],
|
||||||
|
shouldShowActions: node => node.type === "bnode",
|
||||||
|
actions(node) {
|
||||||
|
return [
|
||||||
|
p => (
|
||||||
|
<g
|
||||||
|
style={`transform: ${translate(p, selectedAction.value === 0)}${scale(
|
||||||
|
selectedAction.value === 0
|
||||||
|
)}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedAction.value === 0) {
|
||||||
|
spawnANode(node as BNode);
|
||||||
|
} else {
|
||||||
|
selectAction(0);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<circle fill="white" r="20"></circle>
|
||||||
|
<text fill="black" class="material-icons" x="-12" y="12">
|
||||||
|
add
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
];
|
||||||
|
},
|
||||||
|
distance: 100
|
||||||
|
});
|
||||||
|
function spawnANode(parent: ANode | BNode) {
|
||||||
|
const node: ANode = {
|
||||||
|
x: parent.x,
|
||||||
|
y: parent.y,
|
||||||
|
z: nextId.value,
|
||||||
|
type: "anode",
|
||||||
|
links: [parent.id],
|
||||||
|
id: nextId.value
|
||||||
|
};
|
||||||
|
placeInAvailableSpace(node, nodes.value);
|
||||||
|
nodes.value.push(node);
|
||||||
|
}
|
||||||
|
function spawnBNode(parent: ANode | BNode) {
|
||||||
|
const node: BNode = {
|
||||||
|
x: parent.x,
|
||||||
|
y: parent.y,
|
||||||
|
z: nextId.value,
|
||||||
|
type: "bnode",
|
||||||
|
links: [parent.id],
|
||||||
|
id: nextId.value
|
||||||
|
};
|
||||||
|
placeInAvailableSpace(node, nodes.value);
|
||||||
|
nodes.value.push(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
const points = createResource(10);
|
||||||
|
const cNode = createUpgrade(() => ({
|
||||||
|
display: <h1>C</h1>,
|
||||||
|
// Purposefully not using noPersist
|
||||||
|
requirements: createCostRequirement(() => ({ cost: 10, resource: points })),
|
||||||
|
style: {
|
||||||
|
x: "100px",
|
||||||
|
y: "100px",
|
||||||
|
"--layer-color": "var(--accent1)"
|
||||||
|
},
|
||||||
|
// no-op to prevent purchasing while dragging
|
||||||
|
onHold: () => {}
|
||||||
|
}));
|
||||||
|
makeDraggable<number | "cnode", MakeDraggableOptions<number | "cnode">>(cNode, () => ({
|
||||||
|
id: "cnode",
|
||||||
|
endDrag,
|
||||||
|
startDrag,
|
||||||
|
hasDragged,
|
||||||
|
nodeBeingDragged,
|
||||||
|
dragDelta,
|
||||||
|
onMouseUp: cNode.purchase
|
||||||
|
}));
|
||||||
|
|
||||||
|
const dNodesPerAxis = 50;
|
||||||
|
const dNodes = (
|
||||||
|
<>
|
||||||
|
{new Array(dNodesPerAxis * dNodesPerAxis).fill(0).map((_, i) => {
|
||||||
|
const x = (Math.floor(i / dNodesPerAxis) - dNodesPerAxis / 2) * 100;
|
||||||
|
const y = ((i % dNodesPerAxis) - dNodesPerAxis / 2) * 100;
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
fill="var(--bought)"
|
||||||
|
style={`transform: translate(${x}px, ${y}px) scale(0.05)`}
|
||||||
|
d="M62.43,122.88h-1.98c0-16.15-6.04-30.27-18.11-42.34C30.27,68.47,16.16,62.43,0,62.43v-1.98 c16.16,0,30.27-6.04,42.34-18.14C54.41,30.21,60.45,16.1,60.45,0h1.98c0,16.15,6.04,30.27,18.11,42.34 c12.07,12.07,26.18,18.11,42.34,18.11v1.98c-16.15,0-30.27,6.04-42.34,18.11C68.47,92.61,62.43,106.72,62.43,122.88L62.43,122.88z"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const links = computed(() => (
|
||||||
|
<>
|
||||||
|
{nodes.value
|
||||||
|
.reduce(
|
||||||
|
(acc, curr) => [
|
||||||
|
...acc,
|
||||||
|
...curr.links.map(l => ({ from: curr, to: nodesById.value[l] }))
|
||||||
|
],
|
||||||
|
[] as { from: NodeTypes; to: NodeTypes }[]
|
||||||
|
)
|
||||||
|
.map(link => (
|
||||||
|
<line
|
||||||
|
stroke="white"
|
||||||
|
stroke-width={4}
|
||||||
|
x1={
|
||||||
|
nodeBeingDragged.value === link.from.id
|
||||||
|
? dragDelta.value.x + link.from.x
|
||||||
|
: link.from.x
|
||||||
|
}
|
||||||
|
y1={
|
||||||
|
nodeBeingDragged.value === link.from.id
|
||||||
|
? dragDelta.value.y + link.from.y
|
||||||
|
: link.from.y
|
||||||
|
}
|
||||||
|
x2={
|
||||||
|
nodeBeingDragged.value === link.to.id
|
||||||
|
? dragDelta.value.x + link.to.x
|
||||||
|
: link.to.x
|
||||||
|
}
|
||||||
|
y2={
|
||||||
|
nodeBeingDragged.value === link.to.id
|
||||||
|
? dragDelta.value.y + link.to.y
|
||||||
|
: link.to.y
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
));
|
||||||
|
|
||||||
|
const nextId = setupUniqueIds(() => nodes.value);
|
||||||
|
|
||||||
|
function renderNode(node: NodeTypes | typeof cNode) {
|
||||||
|
if (node.type === "anode") {
|
||||||
|
return renderANode(node);
|
||||||
|
} else if (node.type === "bnode") {
|
||||||
|
return renderBNode(node);
|
||||||
|
} else {
|
||||||
|
return render(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "Board",
|
||||||
|
color: "var(--accent1)",
|
||||||
|
display: () => (
|
||||||
|
<>
|
||||||
|
<Board
|
||||||
|
onDrag={drag}
|
||||||
|
onMouseDown={deselect}
|
||||||
|
onMouseUp={endDrag}
|
||||||
|
onMouseLeave={endDrag}
|
||||||
|
ref={board}
|
||||||
|
style={{ height: "600px" }}
|
||||||
|
>
|
||||||
|
<SVGNode>
|
||||||
|
{dNodes}
|
||||||
|
{links.value}
|
||||||
|
</SVGNode>
|
||||||
|
{nodes.value.map(renderNode)}
|
||||||
|
{render(cNode)}
|
||||||
|
<SVGNode>
|
||||||
|
{aActions.value}
|
||||||
|
{bActions.value}
|
||||||
|
</SVGNode>
|
||||||
|
</Board>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
boardNodes: nodes,
|
||||||
|
cNode,
|
||||||
|
selected: persistent(selected)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export default board;
|
|
@ -1,12 +1,12 @@
|
||||||
|
import Node from "components/Node.vue";
|
||||||
import Profectus from "components/Profectus.vue";
|
import Profectus from "components/Profectus.vue";
|
||||||
import Spacer from "components/layout/Spacer.vue";
|
import Spacer from "components/layout/Spacer.vue";
|
||||||
import { jsx } from "features/feature";
|
|
||||||
import { createResource, trackBest, trackOOMPS, trackTotal } from "features/resources/resource";
|
import { createResource, trackBest, trackOOMPS, trackTotal } from "features/resources/resource";
|
||||||
|
import { branchedResetPropagation, createTree, Tree } from "features/trees/tree";
|
||||||
import { globalBus } from "game/events";
|
import { globalBus } from "game/events";
|
||||||
import type { BaseLayer, GenericLayer } from "game/layers";
|
import type { BaseLayer, Layer } from "game/layers";
|
||||||
import { setupLayerModal } from "game/layers";
|
import { createLayer, setupLayerModal } from "game/layers";
|
||||||
import { createLayer } from "game/layers";
|
import player, { Player } from "game/player";
|
||||||
import player from "game/player";
|
|
||||||
import type { DecimalSource } from "util/bignum";
|
import type { DecimalSource } from "util/bignum";
|
||||||
import Decimal, { format, formatTime } from "util/bignum";
|
import Decimal, { format, formatTime } from "util/bignum";
|
||||||
import { render } from "util/vue";
|
import { render } from "util/vue";
|
||||||
|
@ -14,8 +14,8 @@ import { computed, toRaw } from "vue";
|
||||||
import a from "./layers/aca/a";
|
import a from "./layers/aca/a";
|
||||||
import c from "./layers/aca/c";
|
import c from "./layers/aca/c";
|
||||||
import f from "./layers/aca/f";
|
import f from "./layers/aca/f";
|
||||||
import { Player } from "game/player";
|
import board from "./layers/board";
|
||||||
import { createTree, GenericTree, branchedResetPropagation } from "features/trees/tree";
|
import { noPersist } from "game/persistence";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @hidden
|
* @hidden
|
||||||
|
@ -39,11 +39,13 @@ export const main = createLayer("main", function (this: BaseLayer) {
|
||||||
|
|
||||||
const { openModal, modal } = setupLayerModal(a);
|
const { openModal, modal } = setupLayerModal(a);
|
||||||
|
|
||||||
|
const { openModal: openBoardModal, modal: boardModal } = setupLayerModal(board);
|
||||||
|
|
||||||
// Note: Casting as generic tree to avoid recursive type definitions
|
// Note: Casting as generic tree to avoid recursive type definitions
|
||||||
const tree = createTree(() => ({
|
const tree = createTree(() => ({
|
||||||
nodes: [[c.treeNode], [f.treeNode, c.spook]],
|
nodes: noPersist([[c.treeNode], [f.treeNode, c.spook]]),
|
||||||
leftSideNodes: [a.treeNode, c.h],
|
leftSideNodes: noPersist([a.treeNode, c.h]),
|
||||||
branches: [
|
branches: noPersist([
|
||||||
{
|
{
|
||||||
startNode: f.treeNode,
|
startNode: f.treeNode,
|
||||||
endNode: c.treeNode,
|
endNode: c.treeNode,
|
||||||
|
@ -53,42 +55,61 @@ export const main = createLayer("main", function (this: BaseLayer) {
|
||||||
filter: "blur(5px)"
|
filter: "blur(5px)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
]),
|
||||||
onReset() {
|
onReset() {
|
||||||
points.value = toRaw(this.resettingNode.value) === toRaw(c.treeNode) ? 0 : 10;
|
points.value = toRaw(tree.resettingNode.value) === toRaw(c.treeNode) ? 0 : 10;
|
||||||
best.value = points.value;
|
best.value = points.value;
|
||||||
total.value = points.value;
|
total.value = points.value;
|
||||||
},
|
},
|
||||||
resetPropagation: branchedResetPropagation
|
resetPropagation: branchedResetPropagation
|
||||||
})) as GenericTree;
|
})) as Tree;
|
||||||
|
|
||||||
// Note: layers don't _need_ a reference to everything,
|
// Note: layers don't _need_ a reference to everything,
|
||||||
// but I'd recommend it over trying to remember what does and doesn't need to be included.
|
// but I'd recommend it over trying to remember what does and doesn't need to be included.
|
||||||
// Officially all you need are anything with persistency or that you want to access elsewhere
|
// Officially all you need are anything with persistency or that you want to access elsewhere
|
||||||
return {
|
return {
|
||||||
name: "Tree",
|
name: "Tree",
|
||||||
display: jsx(() => (
|
links: tree.links,
|
||||||
|
display: () => (
|
||||||
<>
|
<>
|
||||||
{player.devSpeed === 0 ? <div>Game Paused</div> : null}
|
{player.devSpeed === 0 ? (
|
||||||
{player.devSpeed != null && player.devSpeed != 0 && player.devSpeed !== 1 ? (
|
<div>
|
||||||
<div>Dev Speed: {format(player.devSpeed)}x</div>
|
Game Paused
|
||||||
|
<Node id="paused" />
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{player.offlineTime != null && player.offlineTime != 0 ? (
|
{player.devSpeed != null && player.devSpeed !== 0 && player.devSpeed !== 1 ? (
|
||||||
<div>Offline Time: {formatTime(player.offlineTime)}</div>
|
<div>
|
||||||
|
Dev Speed: {format(player.devSpeed)}x
|
||||||
|
<Node id="devspeed" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{player.offlineTime != null && player.offlineTime !== 0 ? (
|
||||||
|
<div>
|
||||||
|
Offline Time: {formatTime(player.offlineTime)}
|
||||||
|
<Node id="offline" />
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div>
|
<div>
|
||||||
{Decimal.lt(points.value, "1e1000") ? <span>You have </span> : null}
|
{Decimal.lt(points.value, "1e1000") ? <span>You have </span> : null}
|
||||||
<h2>{format(points.value)}</h2>
|
<h2>{format(points.value)}</h2>
|
||||||
{Decimal.lt(points.value, "1e1e6") ? <span> points</span> : null}
|
{Decimal.lt(points.value, "1e1e6") ? <span> points</span> : null}
|
||||||
</div>
|
</div>
|
||||||
{Decimal.gt(pointGain.value, 0) ? <div>({oomps.value})</div> : null}
|
{Decimal.gt(pointGain.value, 0) ? (
|
||||||
|
<div>
|
||||||
|
({oomps.value})
|
||||||
|
<Node id="oomps" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<Spacer />
|
<Spacer />
|
||||||
<button onClick={openModal}>open achievements</button>
|
<button onClick={openModal}>open achievements</button>
|
||||||
|
<button onClick={openBoardModal}>open board</button>
|
||||||
{render(modal)}
|
{render(modal)}
|
||||||
|
{render(boardModal)}
|
||||||
{render(tree)}
|
{render(tree)}
|
||||||
<Profectus height="200px" style="margin: 10px auto; display: block" />
|
<Profectus style="margin: 10px auto; display: block; height: 200px" />
|
||||||
</>
|
</>
|
||||||
)),
|
),
|
||||||
points,
|
points,
|
||||||
best,
|
best,
|
||||||
total,
|
total,
|
||||||
|
@ -105,7 +126,7 @@ export const main = createLayer("main", function (this: BaseLayer) {
|
||||||
export const getInitialLayers = (
|
export const getInitialLayers = (
|
||||||
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
|
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
|
||||||
player: Partial<Player>
|
player: Partial<Player>
|
||||||
): Array<GenericLayer> => [main, f, c, a];
|
): Array<Layer> => [main, f, c, a, board];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A computed ref whose value is true whenever the game is over.
|
* A computed ref whose value is true whenever the game is over.
|
||||||
|
|
|
@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -22,5 +22,6 @@
|
||||||
"maxTickLength": 3600,
|
"maxTickLength": 3600,
|
||||||
"offlineLimit": 1,
|
"offlineLimit": 1,
|
||||||
"enablePausing": true,
|
"enablePausing": true,
|
||||||
"exportEncoding": "base64"
|
"exportEncoding": "base64",
|
||||||
|
"disableHealthWarning": false
|
||||||
}
|
}
|
||||||
|
|
40
src/features/VueFeature.vue
Normal file
40
src/features/VueFeature.vue
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="isVisible(unref(visibility))"
|
||||||
|
:style="[
|
||||||
|
{
|
||||||
|
visibility: isHidden(unref(visibility)) ? 'hidden' : undefined
|
||||||
|
},
|
||||||
|
unref(style)
|
||||||
|
]"
|
||||||
|
:class="{ feature: true, ...unref(classes) }"
|
||||||
|
>
|
||||||
|
<Components />
|
||||||
|
<Node :id="id" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="tsx">
|
||||||
|
import "components/common/features.css";
|
||||||
|
import Node from "components/Node.vue";
|
||||||
|
import type { Visibility } from "features/feature";
|
||||||
|
import { isHidden, isVisible } from "features/feature";
|
||||||
|
import { render, Renderable } from "util/vue";
|
||||||
|
import { MaybeRef, unref, type CSSProperties } from "vue";
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
id: string;
|
||||||
|
components: MaybeRef<Renderable>[];
|
||||||
|
wrappers: ((el: () => Renderable) => Renderable)[];
|
||||||
|
visibility?: MaybeRef<Visibility | boolean>;
|
||||||
|
style?: MaybeRef<CSSProperties>;
|
||||||
|
classes?: MaybeRef<Record<string, boolean>>;
|
||||||
|
}>(), {
|
||||||
|
visibility: true,
|
||||||
|
style: () => ({}),
|
||||||
|
classes: () => ({})
|
||||||
|
});
|
||||||
|
|
||||||
|
const Components = () => props.wrappers.reduce<() => Renderable>(
|
||||||
|
(acc, curr) => (() => curr(acc)),
|
||||||
|
() => <>{props.components.map(el => render(el))}</>)();
|
||||||
|
</script>
|
|
@ -1,112 +1,63 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<button
|
||||||
v-if="isVisible(visibility)"
|
:style="{
|
||||||
:style="[
|
backgroundImage: (unref(earned) && unref(image) && `url(${image})`) || ''
|
||||||
{
|
}"
|
||||||
visibility: isHidden(visibility) ? 'hidden' : undefined,
|
|
||||||
backgroundImage: (earned && image && `url(${image})`) || ''
|
|
||||||
},
|
|
||||||
unref(style) ?? []
|
|
||||||
]"
|
|
||||||
:class="{
|
:class="{
|
||||||
feature: true,
|
|
||||||
achievement: true,
|
achievement: true,
|
||||||
locked: !unref(earned),
|
locked: !unref(earned),
|
||||||
done: unref(earned),
|
done: unref(earned),
|
||||||
small: unref(small),
|
small: unref(small),
|
||||||
...unref(classes)
|
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<component v-if="comp" :is="comp" />
|
<Component />
|
||||||
<MarkNode :mark="unref(mark)" />
|
</button>
|
||||||
<Node :id="id" />
|
|
||||||
</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 { isJSXElement, render } from "util/vue";
|
||||||
import Node from "components/Node.vue";
|
import { Component, isRef, unref } from "vue";
|
||||||
import { isHidden, isVisible, jsx, Visibility } from "features/feature";
|
import { Achievement } from "./achievement";
|
||||||
import { displayRequirements, Requirements } from "game/requirements";
|
import { displayRequirements } from "game/requirements";
|
||||||
import { coerceComponent, isCoercableComponent, processedPropType, unwrapRef } from "util/vue";
|
|
||||||
import { Component, defineComponent, shallowRef, StyleValue, toRefs, unref, UnwrapRef, watchEffect } from "vue";
|
|
||||||
import { GenericAchievement } from "./achievement";
|
|
||||||
|
|
||||||
export default defineComponent({
|
const props = defineProps<{
|
||||||
props: {
|
display: Achievement["display"];
|
||||||
visibility: {
|
earned: Achievement["earned"];
|
||||||
type: processedPropType<Visibility | boolean>(Number, Boolean),
|
requirements: Achievement["requirements"];
|
||||||
required: true
|
image: Achievement["image"];
|
||||||
},
|
small: Achievement["small"];
|
||||||
display: processedPropType<UnwrapRef<GenericAchievement["display"]>>(Object, String, Function),
|
}>();
|
||||||
earned: {
|
|
||||||
type: processedPropType<boolean>(Boolean),
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
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 Component = () => {
|
||||||
|
if (props.display == null) {
|
||||||
watchEffect(() => {
|
return null;
|
||||||
const currDisplay = unwrapRef(display);
|
} else if (
|
||||||
if (currDisplay == null) {
|
isRef(props.display) ||
|
||||||
comp.value = "";
|
typeof props.display === "string" ||
|
||||||
return;
|
isJSXElement(props.display)
|
||||||
}
|
) {
|
||||||
if (isCoercableComponent(currDisplay)) {
|
return render(props.display);
|
||||||
comp.value = coerceComponent(currDisplay);
|
} else {
|
||||||
return;
|
const { requirement, effectDisplay, optionsDisplay } = props.display;
|
||||||
}
|
return (
|
||||||
const Requirement = coerceComponent(currDisplay.requirement ? currDisplay.requirement : jsx(() => displayRequirements(unwrapRef(requirements) ?? [])), "h3");
|
<span>
|
||||||
const EffectDisplay = coerceComponent(currDisplay.effectDisplay || "", "b");
|
{requirement ?
|
||||||
const OptionsDisplay = unwrapRef(earned) ?
|
render(requirement, el => <h3>{el}</h3>) :
|
||||||
coerceComponent(currDisplay.optionsDisplay || "", "span") :
|
displayRequirements(props.requirements ?? [])}
|
||||||
"";
|
{effectDisplay ? (
|
||||||
comp.value = coerceComponent(
|
<div>
|
||||||
jsx(() => (
|
{render(effectDisplay, el => <b>{el}</b>)}
|
||||||
<span>
|
</div>
|
||||||
<Requirement />
|
) : null}
|
||||||
{currDisplay.effectDisplay != null ? (
|
{optionsDisplay != null ? (
|
||||||
<div>
|
<div class="equal-spaced">
|
||||||
<EffectDisplay />
|
{render(optionsDisplay)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{currDisplay.optionsDisplay != null ? (
|
</span>);
|
||||||
<div class="equal-spaced">
|
|
||||||
<OptionsDisplay />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
comp,
|
|
||||||
unref,
|
|
||||||
Visibility,
|
|
||||||
isVisible,
|
|
||||||
isHidden
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
@ -1,46 +1,32 @@
|
||||||
import { computed } from "@vue/reactivity";
|
|
||||||
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 { Visibility } from "features/feature";
|
||||||
import { GenericDecorator } from "features/decorators/common";
|
|
||||||
import {
|
|
||||||
CoercableComponent,
|
|
||||||
Component,
|
|
||||||
GatherProps,
|
|
||||||
GenericComponent,
|
|
||||||
OptionsFunc,
|
|
||||||
Replace,
|
|
||||||
StyleValue,
|
|
||||||
Visibility,
|
|
||||||
getUniqueID,
|
|
||||||
jsx,
|
|
||||||
setDefault
|
|
||||||
} from "features/feature";
|
|
||||||
import { globalBus } from "game/events";
|
import { globalBus } from "game/events";
|
||||||
import "game/notifications";
|
import "game/notifications";
|
||||||
import type { Persistent } from "game/persistence";
|
import type { Persistent } from "game/persistence";
|
||||||
import { persistent } from "game/persistence";
|
import { persistent } from "game/persistence";
|
||||||
import player from "game/player";
|
import player from "game/player";
|
||||||
import {
|
import {
|
||||||
Requirements,
|
|
||||||
createBooleanRequirement,
|
createBooleanRequirement,
|
||||||
createVisibilityRequirement,
|
createVisibilityRequirement,
|
||||||
displayRequirements,
|
displayRequirements,
|
||||||
|
Requirements,
|
||||||
requirementsMet
|
requirementsMet
|
||||||
} from "game/requirements";
|
} from "game/requirements";
|
||||||
import settings, { registerSettingField } from "game/settings";
|
import settings, { registerSettingField } from "game/settings";
|
||||||
import { camelToTitle } from "util/common";
|
import { camelToTitle } from "util/common";
|
||||||
import type {
|
import { processGetter } from "util/computed";
|
||||||
Computable,
|
|
||||||
GetComputableType,
|
|
||||||
GetComputableTypeWithDefault,
|
|
||||||
ProcessedComputable
|
|
||||||
} from "util/computed";
|
|
||||||
import { processComputable } from "util/computed";
|
|
||||||
import { createLazyProxy } from "util/proxies";
|
import { createLazyProxy } from "util/proxies";
|
||||||
import { coerceComponent, isCoercableComponent } from "util/vue";
|
import {
|
||||||
import { unref, watchEffect } from "vue";
|
isJSXElement,
|
||||||
|
render,
|
||||||
|
Renderable,
|
||||||
|
VueFeature,
|
||||||
|
vueFeatureMixin,
|
||||||
|
VueFeatureOptions
|
||||||
|
} from "util/vue";
|
||||||
|
import { computed, isRef, MaybeRef, MaybeRefOrGetter, unref, watchEffect } from "vue";
|
||||||
import { useToast } from "vue-toastification";
|
import { useToast } from "vue-toastification";
|
||||||
|
import Achievement from "./Achievement.vue";
|
||||||
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
|
@ -59,235 +45,171 @@ export enum AchievementDisplay {
|
||||||
/**
|
/**
|
||||||
* An object that configures an {@link Achievement}.
|
* An object that configures an {@link Achievement}.
|
||||||
*/
|
*/
|
||||||
export interface AchievementOptions {
|
export interface AchievementOptions extends VueFeatureOptions {
|
||||||
/** Whether this achievement should be visible. */
|
/** The requirement(s) to earn this achievement. Can be left null if using {@link Achievement.complete}. */
|
||||||
visibility?: Computable<Visibility | boolean>;
|
|
||||||
/** The requirement(s) to earn this achievement. Can be left null if using {@link BaseAchievement.complete}. */
|
|
||||||
requirements?: Requirements;
|
requirements?: Requirements;
|
||||||
/** The display to use for this achievement. */
|
/** The display to use for this achievement. */
|
||||||
display?: Computable<
|
display?:
|
||||||
| CoercableComponent
|
| MaybeRefOrGetter<Renderable>
|
||||||
| {
|
| {
|
||||||
/** Description of the requirement(s) for this achievement. If unspecified then the requirements will be displayed automatically based on {@link requirements}. */
|
/** Description of the requirement(s) for this achievement. If unspecified then the requirements will be displayed automatically based on {@link requirements}. */
|
||||||
requirement?: CoercableComponent;
|
requirement?: MaybeRefOrGetter<Renderable>;
|
||||||
/** Description of what will change (if anything) for achieving this. */
|
/** Description of what will change (if anything) for achieving this. */
|
||||||
effectDisplay?: CoercableComponent;
|
effectDisplay?: MaybeRefOrGetter<Renderable>;
|
||||||
/** Any additional things to display on this achievement, such as a toggle for it's effect. */
|
/** Any additional things to display on this achievement, such as a toggle for it's effect. */
|
||||||
optionsDisplay?: CoercableComponent;
|
optionsDisplay?: MaybeRefOrGetter<Renderable>;
|
||||||
}
|
};
|
||||||
>;
|
|
||||||
/** Shows a marker on the corner of the feature. */
|
|
||||||
mark?: Computable<boolean | string>;
|
|
||||||
/** Toggles a smaller design for the feature. */
|
/** Toggles a smaller design for the feature. */
|
||||||
small?: Computable<boolean>;
|
small?: MaybeRefOrGetter<boolean>;
|
||||||
/** An image to display as the background for this achievement. */
|
/** An image to display as the background for this achievement. */
|
||||||
image?: Computable<string>;
|
image?: MaybeRefOrGetter<string>;
|
||||||
/** CSS to apply to this feature. */
|
|
||||||
style?: Computable<StyleValue>;
|
|
||||||
/** Dictionary of CSS classes to apply to this feature. */
|
|
||||||
classes?: Computable<Record<string, boolean>>;
|
|
||||||
/** Whether or not to display a notification popup when this achievement is earned. */
|
/** Whether or not to display a notification popup when this achievement is earned. */
|
||||||
showPopups?: Computable<boolean>;
|
showPopups?: MaybeRefOrGetter<boolean>;
|
||||||
/** A function that is called when the achievement is completed. */
|
/** A function that is called when the achievement is completed. */
|
||||||
onComplete?: VoidFunction;
|
onComplete?: VoidFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** An object that represents a feature with requirements that is passively earned upon meeting certain requirements. */
|
||||||
* The properties that are added onto a processed {@link AchievementOptions} to create an {@link Achievement}.
|
export interface Achievement extends VueFeature {
|
||||||
*/
|
/** The requirement(s) to earn this achievement. */
|
||||||
export interface BaseAchievement {
|
requirements?: Requirements;
|
||||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
/** A function that is called when the achievement is completed. */
|
||||||
id: string;
|
onComplete?: VoidFunction;
|
||||||
|
/** The display to use for this achievement. */
|
||||||
|
display?:
|
||||||
|
| MaybeRef<Renderable>
|
||||||
|
| {
|
||||||
|
requirement?: MaybeRef<Renderable>;
|
||||||
|
effectDisplay?: MaybeRef<Renderable>;
|
||||||
|
optionsDisplay?: MaybeRef<Renderable>;
|
||||||
|
};
|
||||||
|
/** Toggles a smaller design for the feature. */
|
||||||
|
small?: MaybeRef<boolean>;
|
||||||
|
/** An image to display as the background for this achievement. */
|
||||||
|
image?: MaybeRef<string>;
|
||||||
|
/** Whether or not to display a notification popup when this achievement is earned. */
|
||||||
|
showPopups: MaybeRef<boolean>;
|
||||||
/** Whether or not this achievement has been earned. */
|
/** Whether or not this achievement has been earned. */
|
||||||
earned: Persistent<boolean>;
|
earned: Persistent<boolean>;
|
||||||
/** A function to complete this achievement. */
|
/** A function to complete this achievement. */
|
||||||
complete: VoidFunction;
|
complete: VoidFunction;
|
||||||
/** A symbol that helps identify features of the same type. */
|
/** A symbol that helps identify features of the same type. */
|
||||||
type: typeof AchievementType;
|
type: typeof AchievementType;
|
||||||
/** The Vue component used to render this feature. */
|
|
||||||
[Component]: GenericComponent;
|
|
||||||
/** A function to gather the props the vue component requires for this feature. */
|
|
||||||
[GatherProps]: () => Record<string, unknown>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An object that represents a feature with requirements that is passively earned upon meeting certain requirements. */
|
|
||||||
export type Achievement<T extends AchievementOptions> = Replace<
|
|
||||||
T & BaseAchievement,
|
|
||||||
{
|
|
||||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
|
||||||
display: GetComputableType<T["display"]>;
|
|
||||||
mark: GetComputableType<T["mark"]>;
|
|
||||||
image: GetComputableType<T["image"]>;
|
|
||||||
style: GetComputableType<T["style"]>;
|
|
||||||
classes: GetComputableType<T["classes"]>;
|
|
||||||
showPopups: GetComputableTypeWithDefault<T["showPopups"], true>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** A type that matches any valid {@link Achievement} object. */
|
|
||||||
export type GenericAchievement = Replace<
|
|
||||||
Achievement<AchievementOptions>,
|
|
||||||
{
|
|
||||||
visibility: ProcessedComputable<Visibility | boolean>;
|
|
||||||
showPopups: ProcessedComputable<boolean>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lazily creates an achievement with the given options.
|
* Lazily creates an achievement with the given options.
|
||||||
* @param optionsFunc Achievement options.
|
* @param optionsFunc Achievement options.
|
||||||
*/
|
*/
|
||||||
export function createAchievement<T extends AchievementOptions>(
|
export function createAchievement<T extends AchievementOptions>(optionsFunc?: () => T) {
|
||||||
optionsFunc?: OptionsFunc<T, BaseAchievement, GenericAchievement>,
|
|
||||||
...decorators: GenericDecorator[]
|
|
||||||
): Achievement<T> {
|
|
||||||
const earned = persistent<boolean>(false, false);
|
const earned = persistent<boolean>(false, false);
|
||||||
const decoratedData = decorators.reduce(
|
return createLazyProxy(() => {
|
||||||
(current, next) => Object.assign(current, next.getPersistentData?.()),
|
const options = optionsFunc?.() ?? ({} as T);
|
||||||
{}
|
const { requirements, display, small, image, showPopups, onComplete, ...props } = options;
|
||||||
);
|
|
||||||
return createLazyProxy(feature => {
|
|
||||||
const achievement =
|
|
||||||
optionsFunc?.call(feature, feature) ??
|
|
||||||
({} as ReturnType<NonNullable<typeof optionsFunc>>);
|
|
||||||
achievement.id = getUniqueID("achievement-");
|
|
||||||
achievement.type = AchievementType;
|
|
||||||
achievement[Component] = AchievementComponent as GenericComponent;
|
|
||||||
|
|
||||||
for (const decorator of decorators) {
|
const vueFeature = vueFeatureMixin("achievement", options, () => (
|
||||||
decorator.preConstruct?.(achievement);
|
<Achievement
|
||||||
}
|
display={achievement.display}
|
||||||
|
earned={achievement.earned}
|
||||||
|
requirements={achievement.requirements}
|
||||||
|
image={achievement.image}
|
||||||
|
small={achievement.small}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
achievement.earned = earned;
|
const achievement = {
|
||||||
achievement.complete = function () {
|
type: AchievementType,
|
||||||
if (earned.value) {
|
...(props as Omit<typeof props, keyof VueFeature | keyof AchievementOptions>),
|
||||||
return;
|
...vueFeature,
|
||||||
}
|
visibility: computed(() => {
|
||||||
earned.value = true;
|
const display = unref((achievement as Achievement).display);
|
||||||
const genericAchievement = achievement as GenericAchievement;
|
switch (settings.msDisplay) {
|
||||||
genericAchievement.onComplete?.();
|
default:
|
||||||
if (
|
case AchievementDisplay.All:
|
||||||
genericAchievement.display != null &&
|
return unref(vueFeature.visibility) ?? true;
|
||||||
unref(genericAchievement.showPopups) === true
|
case AchievementDisplay.Configurable:
|
||||||
) {
|
if (
|
||||||
const display = unref(genericAchievement.display);
|
unref(earned) &&
|
||||||
let Display;
|
!(
|
||||||
if (isCoercableComponent(display)) {
|
display != null &&
|
||||||
Display = coerceComponent(display);
|
typeof display === "object" &&
|
||||||
} else if (display.requirement != null) {
|
"optionsDisplay" in display
|
||||||
Display = coerceComponent(display.requirement);
|
)
|
||||||
} else {
|
) {
|
||||||
Display = displayRequirements(genericAchievement.requirements ?? []);
|
return Visibility.None;
|
||||||
|
}
|
||||||
|
return unref(vueFeature.visibility) ?? true;
|
||||||
|
case AchievementDisplay.Incomplete:
|
||||||
|
if (unref(earned)) {
|
||||||
|
return Visibility.None;
|
||||||
|
}
|
||||||
|
return unref(vueFeature.visibility) ?? true;
|
||||||
|
case AchievementDisplay.None:
|
||||||
|
return Visibility.None;
|
||||||
}
|
}
|
||||||
toast.info(
|
}),
|
||||||
<div>
|
earned,
|
||||||
<h3>Achievement earned!</h3>
|
onComplete,
|
||||||
|
small: processGetter(small),
|
||||||
|
image: processGetter(image),
|
||||||
|
showPopups: processGetter(showPopups) ?? true,
|
||||||
|
display:
|
||||||
|
display == null
|
||||||
|
? undefined
|
||||||
|
: isRef(display) ||
|
||||||
|
typeof display === "string" ||
|
||||||
|
typeof display === "function" ||
|
||||||
|
isJSXElement(display)
|
||||||
|
? processGetter(display)
|
||||||
|
: {
|
||||||
|
requirement: processGetter(display.requirement),
|
||||||
|
effectDisplay: processGetter(display.effectDisplay),
|
||||||
|
optionsDisplay: processGetter(display.optionsDisplay)
|
||||||
|
},
|
||||||
|
requirements:
|
||||||
|
requirements == null
|
||||||
|
? undefined
|
||||||
|
: [
|
||||||
|
createVisibilityRequirement(vueFeature.visibility ?? true),
|
||||||
|
createBooleanRequirement(() => !earned.value),
|
||||||
|
...(Array.isArray(requirements) ? requirements : [requirements])
|
||||||
|
],
|
||||||
|
complete() {
|
||||||
|
if (earned.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
earned.value = true;
|
||||||
|
achievement.onComplete?.();
|
||||||
|
if (achievement.display != null && unref(achievement.showPopups) === true) {
|
||||||
|
const display = achievement.display;
|
||||||
|
let Display;
|
||||||
|
if (isRef(display) || typeof display === "string" || isJSXElement(display)) {
|
||||||
|
Display = () => render(display);
|
||||||
|
} else if (display.requirement != null) {
|
||||||
|
Display = () => render(display.requirement!);
|
||||||
|
} else {
|
||||||
|
Display = () => displayRequirements(achievement.requirements ?? []);
|
||||||
|
}
|
||||||
|
toast.info(
|
||||||
<div>
|
<div>
|
||||||
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
|
<h3>Achievement earned!</h3>
|
||||||
{/* @ts-ignore */}
|
<div>{Display()}</div>
|
||||||
<Display />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
};
|
} satisfies Achievement;
|
||||||
|
|
||||||
Object.assign(achievement, decoratedData);
|
if (achievement.requirements != null) {
|
||||||
|
|
||||||
processComputable(achievement as T, "visibility");
|
|
||||||
setDefault(achievement, "visibility", Visibility.Visible);
|
|
||||||
const visibility = achievement.visibility as ProcessedComputable<Visibility | boolean>;
|
|
||||||
achievement.visibility = computed(() => {
|
|
||||||
const display = unref((achievement as GenericAchievement).display);
|
|
||||||
switch (settings.msDisplay) {
|
|
||||||
default:
|
|
||||||
case AchievementDisplay.All:
|
|
||||||
return unref(visibility);
|
|
||||||
case AchievementDisplay.Configurable:
|
|
||||||
if (
|
|
||||||
unref(achievement.earned) &&
|
|
||||||
!(
|
|
||||||
display != null &&
|
|
||||||
typeof display == "object" &&
|
|
||||||
"optionsDisplay" in (display as Record<string, unknown>)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return Visibility.None;
|
|
||||||
}
|
|
||||||
return unref(visibility);
|
|
||||||
case AchievementDisplay.Incomplete:
|
|
||||||
if (unref(achievement.earned)) {
|
|
||||||
return Visibility.None;
|
|
||||||
}
|
|
||||||
return unref(visibility);
|
|
||||||
case AchievementDisplay.None:
|
|
||||||
return Visibility.None;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
processComputable(achievement as T, "display");
|
|
||||||
processComputable(achievement as T, "mark");
|
|
||||||
processComputable(achievement as T, "small");
|
|
||||||
processComputable(achievement as T, "image");
|
|
||||||
processComputable(achievement as T, "style");
|
|
||||||
processComputable(achievement as T, "classes");
|
|
||||||
processComputable(achievement as T, "showPopups");
|
|
||||||
setDefault(achievement, "showPopups", true);
|
|
||||||
|
|
||||||
for (const decorator of decorators) {
|
|
||||||
decorator.postConstruct?.(achievement);
|
|
||||||
}
|
|
||||||
|
|
||||||
const decoratedProps = decorators.reduce(
|
|
||||||
(current, next) => Object.assign(current, next.getGatheredProps?.(achievement)),
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
achievement[GatherProps] = function (this: GenericAchievement) {
|
|
||||||
const {
|
|
||||||
visibility,
|
|
||||||
display,
|
|
||||||
requirements,
|
|
||||||
earned,
|
|
||||||
image,
|
|
||||||
style,
|
|
||||||
classes,
|
|
||||||
mark,
|
|
||||||
small,
|
|
||||||
id
|
|
||||||
} = this;
|
|
||||||
return {
|
|
||||||
visibility,
|
|
||||||
display,
|
|
||||||
requirements,
|
|
||||||
earned,
|
|
||||||
image,
|
|
||||||
style: unref(style),
|
|
||||||
classes,
|
|
||||||
mark,
|
|
||||||
small,
|
|
||||||
id,
|
|
||||||
...decoratedProps
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
if (achievement.requirements) {
|
|
||||||
const genericAchievement = achievement as GenericAchievement;
|
|
||||||
const requirements = [
|
|
||||||
createVisibilityRequirement(genericAchievement),
|
|
||||||
createBooleanRequirement(() => !genericAchievement.earned.value),
|
|
||||||
...(isArray(achievement.requirements)
|
|
||||||
? achievement.requirements
|
|
||||||
: [achievement.requirements])
|
|
||||||
];
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (settings.active !== player.id) return;
|
if (settings.active !== player.id) return;
|
||||||
if (requirementsMet(requirements)) {
|
if (requirementsMet(achievement.requirements ?? [])) {
|
||||||
genericAchievement.complete();
|
achievement.complete();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return achievement as unknown as Achievement<T>;
|
return achievement;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -298,7 +220,7 @@ declare module "game/settings" {
|
||||||
}
|
}
|
||||||
|
|
||||||
globalBus.on("loadSettings", settings => {
|
globalBus.on("loadSettings", settings => {
|
||||||
setDefault(settings, "msDisplay", AchievementDisplay.All);
|
settings.msDisplay ??= AchievementDisplay.All;
|
||||||
});
|
});
|
||||||
|
|
||||||
const msDisplayOptions = Object.values(AchievementDisplay).map(option => ({
|
const msDisplayOptions = Object.values(AchievementDisplay).map(option => ({
|
||||||
|
@ -306,15 +228,15 @@ const msDisplayOptions = Object.values(AchievementDisplay).map(option => ({
|
||||||
value: option
|
value: option
|
||||||
}));
|
}));
|
||||||
|
|
||||||
registerSettingField(
|
globalBus.on("setupVue", () =>
|
||||||
jsx(() => (
|
registerSettingField(() => (
|
||||||
<Select
|
<Select
|
||||||
title={jsx(() => (
|
title={
|
||||||
<span class="option-title">
|
<span class="option-title">
|
||||||
Show achievements
|
Show achievements
|
||||||
<desc>Select which achievements to display based on criterias.</desc>
|
<desc>Select which achievements to display based on criterias.</desc>
|
||||||
</span>
|
</span>
|
||||||
))}
|
}
|
||||||
options={msDisplayOptions}
|
options={msDisplayOptions}
|
||||||
onUpdate:modelValue={value => (settings.msDisplay = value as AchievementDisplay)}
|
onUpdate:modelValue={value => (settings.msDisplay = value as AchievementDisplay)}
|
||||||
modelValue={settings.msDisplay}
|
modelValue={settings.msDisplay}
|
||||||
|
|
|
@ -1,293 +0,0 @@
|
||||||
import { isArray } from "@vue/shared";
|
|
||||||
import ClickableComponent from "features/clickables/Clickable.vue";
|
|
||||||
import {
|
|
||||||
Component,
|
|
||||||
findFeatures,
|
|
||||||
GatherProps,
|
|
||||||
GenericComponent,
|
|
||||||
getUniqueID,
|
|
||||||
jsx,
|
|
||||||
JSXFunction,
|
|
||||||
OptionsFunc,
|
|
||||||
Replace,
|
|
||||||
setDefault,
|
|
||||||
StyleValue,
|
|
||||||
Visibility
|
|
||||||
} from "features/feature";
|
|
||||||
import { globalBus } from "game/events";
|
|
||||||
import { persistent } from "game/persistence";
|
|
||||||
import Decimal, { DecimalSource } from "lib/break_eternity";
|
|
||||||
import { Unsubscribe } from "nanoevents";
|
|
||||||
import { Direction } from "util/common";
|
|
||||||
import type {
|
|
||||||
Computable,
|
|
||||||
GetComputableType,
|
|
||||||
GetComputableTypeWithDefault,
|
|
||||||
ProcessedComputable
|
|
||||||
} from "util/computed";
|
|
||||||
import { processComputable } from "util/computed";
|
|
||||||
import { createLazyProxy } from "util/proxies";
|
|
||||||
import { coerceComponent, isCoercableComponent, render } from "util/vue";
|
|
||||||
import { computed, Ref, ref, unref } from "vue";
|
|
||||||
import { BarOptions, createBar, GenericBar } from "./bars/bar";
|
|
||||||
import { ClickableOptions } from "./clickables/clickable";
|
|
||||||
import { Decorator, GenericDecorator } from "./decorators/common";
|
|
||||||
|
|
||||||
/** A symbol used to identify {@link Action} features. */
|
|
||||||
export const ActionType = Symbol("Action");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An object that configures an {@link Action}.
|
|
||||||
*/
|
|
||||||
export interface ActionOptions extends Omit<ClickableOptions, "onClick" | "onHold"> {
|
|
||||||
/** The cooldown during which the action cannot be performed again, in seconds. */
|
|
||||||
duration: Computable<DecimalSource>;
|
|
||||||
/** Whether or not the action should perform automatically when the cooldown is finished. */
|
|
||||||
autoStart?: Computable<boolean>;
|
|
||||||
/** A function that is called when the action is clicked. */
|
|
||||||
onClick: (amount: DecimalSource) => void;
|
|
||||||
/** A pass-through to the {@link Bar} used to display the cooldown progress for the action. */
|
|
||||||
barOptions?: Partial<BarOptions>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The properties that are added onto a processed {@link ActionOptions} to create an {@link Action}.
|
|
||||||
*/
|
|
||||||
export interface BaseAction {
|
|
||||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
|
||||||
id: string;
|
|
||||||
/** A symbol that helps identify features of the same type. */
|
|
||||||
type: typeof ActionType;
|
|
||||||
/** Whether or not the player is holding down the action. Actions will be considered clicked as soon as the cooldown completes when being held down. */
|
|
||||||
isHolding: Ref<boolean>;
|
|
||||||
/** The current amount of progress through the cooldown. */
|
|
||||||
progress: Ref<DecimalSource>;
|
|
||||||
/** The bar used to display the current cooldown progress. */
|
|
||||||
progressBar: GenericBar;
|
|
||||||
/** Update the cooldown the specified number of seconds */
|
|
||||||
update: (diff: number) => void;
|
|
||||||
/** The Vue component used to render this feature. */
|
|
||||||
[Component]: GenericComponent;
|
|
||||||
/** A function to gather the props the vue component requires for this feature. */
|
|
||||||
[GatherProps]: () => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An object that represents a feature that can be clicked upon, and then has a cooldown before it can be clicked again. */
|
|
||||||
export type Action<T extends ActionOptions> = Replace<
|
|
||||||
T & BaseAction,
|
|
||||||
{
|
|
||||||
duration: GetComputableType<T["duration"]>;
|
|
||||||
autoStart: GetComputableTypeWithDefault<T["autoStart"], false>;
|
|
||||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
|
||||||
canClick: GetComputableTypeWithDefault<T["canClick"], true>;
|
|
||||||
classes: GetComputableType<T["classes"]>;
|
|
||||||
style: GetComputableType<T["style"]>;
|
|
||||||
mark: GetComputableType<T["mark"]>;
|
|
||||||
display: JSXFunction;
|
|
||||||
onClick: VoidFunction;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** A type that matches any valid {@link Action} object. */
|
|
||||||
export type GenericAction = Replace<
|
|
||||||
Action<ActionOptions>,
|
|
||||||
{
|
|
||||||
autoStart: ProcessedComputable<boolean>;
|
|
||||||
visibility: ProcessedComputable<Visibility | boolean>;
|
|
||||||
canClick: ProcessedComputable<boolean>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lazily creates an action with the given options.
|
|
||||||
* @param optionsFunc Action options.
|
|
||||||
*/
|
|
||||||
export function createAction<T extends ActionOptions>(
|
|
||||||
optionsFunc?: OptionsFunc<T, BaseAction, GenericAction>,
|
|
||||||
...decorators: GenericDecorator[]
|
|
||||||
): Action<T> {
|
|
||||||
const progress = persistent<DecimalSource>(0);
|
|
||||||
const decoratedData = decorators.reduce(
|
|
||||||
(current, next) => Object.assign(current, next.getPersistentData?.()),
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
return createLazyProxy(feature => {
|
|
||||||
const action =
|
|
||||||
optionsFunc?.call(feature, feature) ??
|
|
||||||
({} as ReturnType<NonNullable<typeof optionsFunc>>);
|
|
||||||
action.id = getUniqueID("action-");
|
|
||||||
action.type = ActionType;
|
|
||||||
action[Component] = ClickableComponent as GenericComponent;
|
|
||||||
|
|
||||||
// Required because of display changing types
|
|
||||||
const genericAction = action as unknown as GenericAction;
|
|
||||||
|
|
||||||
for (const decorator of decorators) {
|
|
||||||
decorator.preConstruct?.(action);
|
|
||||||
}
|
|
||||||
|
|
||||||
action.isHolding = ref(false);
|
|
||||||
action.progress = progress;
|
|
||||||
Object.assign(action, decoratedData);
|
|
||||||
|
|
||||||
processComputable(action as T, "visibility");
|
|
||||||
setDefault(action, "visibility", Visibility.Visible);
|
|
||||||
processComputable(action as T, "duration");
|
|
||||||
processComputable(action as T, "autoStart");
|
|
||||||
setDefault(action, "autoStart", false);
|
|
||||||
processComputable(action as T, "canClick");
|
|
||||||
setDefault(action, "canClick", true);
|
|
||||||
processComputable(action as T, "classes");
|
|
||||||
processComputable(action as T, "style");
|
|
||||||
processComputable(action as T, "mark");
|
|
||||||
processComputable(action as T, "display");
|
|
||||||
|
|
||||||
const style = action.style as ProcessedComputable<StyleValue | undefined>;
|
|
||||||
action.style = computed(() => {
|
|
||||||
const currStyle: StyleValue[] = [
|
|
||||||
{
|
|
||||||
cursor: Decimal.gte(
|
|
||||||
progress.value,
|
|
||||||
unref(action.duration as ProcessedComputable<DecimalSource>)
|
|
||||||
)
|
|
||||||
? "pointer"
|
|
||||||
: "progress",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column"
|
|
||||||
}
|
|
||||||
];
|
|
||||||
const originalStyle = unref(style);
|
|
||||||
if (isArray(originalStyle)) {
|
|
||||||
currStyle.push(...originalStyle);
|
|
||||||
} else if (originalStyle != null) {
|
|
||||||
currStyle.push(originalStyle);
|
|
||||||
}
|
|
||||||
return currStyle as StyleValue;
|
|
||||||
});
|
|
||||||
|
|
||||||
action.progressBar = createBar(() => ({
|
|
||||||
direction: Direction.Right,
|
|
||||||
width: 100,
|
|
||||||
height: 10,
|
|
||||||
borderStyle: "border-color: black",
|
|
||||||
baseStyle: "margin-top: -1px",
|
|
||||||
progress: () => Decimal.div(progress.value, unref(genericAction.duration)),
|
|
||||||
...action.barOptions
|
|
||||||
}));
|
|
||||||
|
|
||||||
const canClick = action.canClick as ProcessedComputable<boolean>;
|
|
||||||
action.canClick = computed(
|
|
||||||
() =>
|
|
||||||
unref(canClick) &&
|
|
||||||
Decimal.gte(
|
|
||||||
progress.value,
|
|
||||||
unref(action.duration as ProcessedComputable<DecimalSource>)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const display = action.display as GetComputableType<ClickableOptions["display"]>;
|
|
||||||
action.display = jsx(() => {
|
|
||||||
const currDisplay = unref(display);
|
|
||||||
let Comp: GenericComponent | undefined;
|
|
||||||
if (isCoercableComponent(currDisplay)) {
|
|
||||||
Comp = coerceComponent(currDisplay);
|
|
||||||
} else if (currDisplay != null) {
|
|
||||||
const Title = coerceComponent(currDisplay.title ?? "", "h3");
|
|
||||||
const Description = coerceComponent(currDisplay.description, "div");
|
|
||||||
Comp = coerceComponent(
|
|
||||||
jsx(() => (
|
|
||||||
<span>
|
|
||||||
{currDisplay.title != null ? (
|
|
||||||
<div>
|
|
||||||
<Title />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<Description />
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div style="flex-grow: 1" />
|
|
||||||
{Comp == null ? null : <Comp />}
|
|
||||||
<div style="flex-grow: 1" />
|
|
||||||
{render(genericAction.progressBar)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const onClick = action.onClick.bind(action);
|
|
||||||
action.onClick = function () {
|
|
||||||
if (unref(action.canClick) === false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const amount = Decimal.div(progress.value, unref(genericAction.duration));
|
|
||||||
onClick?.(amount);
|
|
||||||
progress.value = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
action.update = function (diff) {
|
|
||||||
const duration = unref(genericAction.duration);
|
|
||||||
if (Decimal.gte(progress.value, duration)) {
|
|
||||||
progress.value = duration;
|
|
||||||
} else {
|
|
||||||
progress.value = Decimal.add(progress.value, diff);
|
|
||||||
if (genericAction.isHolding.value || unref(genericAction.autoStart)) {
|
|
||||||
genericAction.onClick();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const decorator of decorators) {
|
|
||||||
decorator.postConstruct?.(action);
|
|
||||||
}
|
|
||||||
|
|
||||||
const decoratedProps = decorators.reduce(
|
|
||||||
(current, next) => Object.assign(current, next.getGatheredProps?.(action)),
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
action[GatherProps] = function (this: GenericAction) {
|
|
||||||
const {
|
|
||||||
display,
|
|
||||||
visibility,
|
|
||||||
style,
|
|
||||||
classes,
|
|
||||||
onClick,
|
|
||||||
isHolding,
|
|
||||||
canClick,
|
|
||||||
small,
|
|
||||||
mark,
|
|
||||||
id
|
|
||||||
} = this;
|
|
||||||
return {
|
|
||||||
display,
|
|
||||||
visibility,
|
|
||||||
style: unref(style),
|
|
||||||
classes,
|
|
||||||
onClick,
|
|
||||||
isHolding,
|
|
||||||
canClick,
|
|
||||||
small,
|
|
||||||
mark,
|
|
||||||
id,
|
|
||||||
...decoratedProps
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return action as unknown as Action<T>;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const listeners: Record<string, Unsubscribe | undefined> = {};
|
|
||||||
globalBus.on("addLayer", layer => {
|
|
||||||
const actions: GenericAction[] = findFeatures(layer, ActionType) as GenericAction[];
|
|
||||||
listeners[layer.id] = layer.on("postUpdate", diff => {
|
|
||||||
actions.forEach(action => action.update(diff));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
globalBus.on("removeLayer", layer => {
|
|
||||||
// unsubscribe from postUpdate
|
|
||||||
listeners[layer.id]?.();
|
|
||||||
listeners[layer.id] = undefined;
|
|
||||||
});
|
|
|
@ -1,18 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="isVisible(visibility)"
|
:style="{
|
||||||
:style="[
|
width: unref(width) + 'px',
|
||||||
{
|
height: unref(height) + 'px',
|
||||||
width: unref(width) + 'px',
|
|
||||||
height: unref(height) + 'px',
|
|
||||||
visibility: isHidden(visibility) ? 'hidden' : undefined
|
|
||||||
},
|
|
||||||
unref(style) ?? {}
|
|
||||||
]"
|
|
||||||
:class="{
|
|
||||||
bar: true,
|
|
||||||
...unref(classes)
|
|
||||||
}"
|
}"
|
||||||
|
class="bar"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="overlayTextContainer border"
|
class="overlayTextContainer border"
|
||||||
|
@ -21,127 +13,79 @@
|
||||||
unref(borderStyle) ?? {}
|
unref(borderStyle) ?? {}
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<span v-if="component" class="overlayText" :style="unref(textStyle)">
|
<span v-if="display" class="overlayText" :style="unref(textStyle)">
|
||||||
<component :is="component" />
|
<Component />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="border"
|
class="border"
|
||||||
:style="[
|
:style="[
|
||||||
{ width: unref(width) + 'px', height: unref(height) + 'px' },
|
{ width: unref(width) + 'px', height: unref(height) + 'px' },
|
||||||
unref(style) ?? {},
|
|
||||||
unref(baseStyle) ?? {},
|
unref(baseStyle) ?? {},
|
||||||
unref(borderStyle) ?? {}
|
unref(borderStyle) ?? {}
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div class="fill" :style="[barStyle, unref(style) ?? {}, unref(fillStyle) ?? {}]" />
|
<div class="fill" :style="[barStyle, unref(fillStyle) ?? {}]" />
|
||||||
</div>
|
</div>
|
||||||
<MarkNode :mark="unref(mark)" />
|
|
||||||
<Node :id="id" />
|
|
||||||
</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 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 { render } from "util/vue";
|
||||||
import type { CSSProperties, StyleValue } from "vue";
|
import type { CSSProperties } from "vue";
|
||||||
import { computed, defineComponent, toRefs, unref } from "vue";
|
import { computed, unref } from "vue";
|
||||||
|
import { Bar } from "./bar";
|
||||||
|
|
||||||
export default defineComponent({
|
const props = defineProps<{
|
||||||
props: {
|
width: Bar["width"];
|
||||||
progress: {
|
height: Bar["height"];
|
||||||
type: processedPropType<DecimalSource>(String, Object, Number),
|
direction: Bar["direction"];
|
||||||
required: true
|
borderStyle: Bar["borderStyle"];
|
||||||
},
|
baseStyle: Bar["baseStyle"];
|
||||||
width: {
|
textStyle: Bar["textStyle"];
|
||||||
type: processedPropType<number>(Number),
|
fillStyle: Bar["fillStyle"];
|
||||||
required: true
|
progress: Bar["progress"];
|
||||||
},
|
display: Bar["display"];
|
||||||
height: {
|
}>();
|
||||||
type: processedPropType<number>(Number),
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
direction: {
|
|
||||||
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: unref(props.width) + 0.5 + "px",
|
||||||
|
height: unref(props.height) + 0.5 + "px"
|
||||||
|
};
|
||||||
|
switch (props.direction) {
|
||||||
|
case Direction.Up:
|
||||||
|
barStyle.clipPath = `inset(${normalizedProgress.value}% 0% 0% 0%)`;
|
||||||
|
barStyle.width = unref(props.width) + 1 + "px";
|
||||||
|
break;
|
||||||
|
case Direction.Down:
|
||||||
|
barStyle.clipPath = `inset(0% 0% ${normalizedProgress.value}% 0%)`;
|
||||||
|
barStyle.width = unref(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 = () => props.display ? render(props.display) : null;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
@ -1,185 +0,0 @@
|
||||||
import BarComponent from "features/bars/Bar.vue";
|
|
||||||
import { GenericDecorator } from "features/decorators/common";
|
|
||||||
import type {
|
|
||||||
CoercableComponent,
|
|
||||||
GenericComponent,
|
|
||||||
OptionsFunc,
|
|
||||||
Replace,
|
|
||||||
StyleValue
|
|
||||||
} from "features/feature";
|
|
||||||
import { Component, GatherProps, Visibility, getUniqueID, setDefault } from "features/feature";
|
|
||||||
import type { DecimalSource } from "util/bignum";
|
|
||||||
import { Direction } from "util/common";
|
|
||||||
import type {
|
|
||||||
Computable,
|
|
||||||
GetComputableType,
|
|
||||||
GetComputableTypeWithDefault,
|
|
||||||
ProcessedComputable
|
|
||||||
} from "util/computed";
|
|
||||||
import { processComputable } from "util/computed";
|
|
||||||
import { createLazyProxy } from "util/proxies";
|
|
||||||
import { unref } from "vue";
|
|
||||||
|
|
||||||
/** A symbol used to identify {@link Bar} features. */
|
|
||||||
export const BarType = Symbol("Bar");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An object that configures a {@link Bar}.
|
|
||||||
*/
|
|
||||||
export interface BarOptions {
|
|
||||||
/** Whether this bar should be visible. */
|
|
||||||
visibility?: Computable<Visibility | boolean>;
|
|
||||||
/** The width of the bar. */
|
|
||||||
width: Computable<number>;
|
|
||||||
/** The height of the bar. */
|
|
||||||
height: Computable<number>;
|
|
||||||
/** The direction in which the bar progresses. */
|
|
||||||
direction: Computable<Direction>;
|
|
||||||
/** CSS to apply to this feature. */
|
|
||||||
style?: Computable<StyleValue>;
|
|
||||||
/** Dictionary of CSS classes to apply to this feature. */
|
|
||||||
classes?: Computable<Record<string, boolean>>;
|
|
||||||
/** CSS to apply to the bar's border. */
|
|
||||||
borderStyle?: Computable<StyleValue>;
|
|
||||||
/** CSS to apply to the bar's base. */
|
|
||||||
baseStyle?: Computable<StyleValue>;
|
|
||||||
/** CSS to apply to the bar's text. */
|
|
||||||
textStyle?: Computable<StyleValue>;
|
|
||||||
/** CSS to apply to the bar's fill. */
|
|
||||||
fillStyle?: Computable<StyleValue>;
|
|
||||||
/** The progress value of the bar, from 0 to 1. */
|
|
||||||
progress: Computable<DecimalSource>;
|
|
||||||
/** The display to use for this bar. */
|
|
||||||
display?: Computable<CoercableComponent>;
|
|
||||||
/** Shows a marker on the corner of the feature. */
|
|
||||||
mark?: Computable<boolean | string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The properties that are added onto a processed {@link BarOptions} to create a {@link Bar}.
|
|
||||||
*/
|
|
||||||
export interface BaseBar {
|
|
||||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
|
||||||
id: string;
|
|
||||||
/** A symbol that helps identify features of the same type. */
|
|
||||||
type: typeof BarType;
|
|
||||||
/** The Vue component used to render this feature. */
|
|
||||||
[Component]: GenericComponent;
|
|
||||||
/** A function to gather the props the vue component requires for this feature. */
|
|
||||||
[GatherProps]: () => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An object that represents a feature that displays some sort of progress or completion or resource with a cap. */
|
|
||||||
export type Bar<T extends BarOptions> = Replace<
|
|
||||||
T & BaseBar,
|
|
||||||
{
|
|
||||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
|
||||||
width: GetComputableType<T["width"]>;
|
|
||||||
height: GetComputableType<T["height"]>;
|
|
||||||
direction: GetComputableType<T["direction"]>;
|
|
||||||
style: GetComputableType<T["style"]>;
|
|
||||||
classes: GetComputableType<T["classes"]>;
|
|
||||||
borderStyle: GetComputableType<T["borderStyle"]>;
|
|
||||||
baseStyle: GetComputableType<T["baseStyle"]>;
|
|
||||||
textStyle: GetComputableType<T["textStyle"]>;
|
|
||||||
fillStyle: GetComputableType<T["fillStyle"]>;
|
|
||||||
progress: GetComputableType<T["progress"]>;
|
|
||||||
display: GetComputableType<T["display"]>;
|
|
||||||
mark: GetComputableType<T["mark"]>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** A type that matches any valid {@link Bar} object. */
|
|
||||||
export type GenericBar = Replace<
|
|
||||||
Bar<BarOptions>,
|
|
||||||
{
|
|
||||||
visibility: ProcessedComputable<Visibility | boolean>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lazily creates a bar with the given options.
|
|
||||||
* @param optionsFunc Bar options.
|
|
||||||
*/
|
|
||||||
export function createBar<T extends BarOptions>(
|
|
||||||
optionsFunc: OptionsFunc<T, BaseBar, GenericBar>,
|
|
||||||
...decorators: GenericDecorator[]
|
|
||||||
): Bar<T> {
|
|
||||||
const decoratedData = decorators.reduce(
|
|
||||||
(current, next) => Object.assign(current, next.getPersistentData?.()),
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
return createLazyProxy(feature => {
|
|
||||||
const bar = optionsFunc.call(feature, feature);
|
|
||||||
bar.id = getUniqueID("bar-");
|
|
||||||
bar.type = BarType;
|
|
||||||
bar[Component] = BarComponent as GenericComponent;
|
|
||||||
|
|
||||||
for (const decorator of decorators) {
|
|
||||||
decorator.preConstruct?.(bar);
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.assign(bar, decoratedData);
|
|
||||||
|
|
||||||
processComputable(bar as T, "visibility");
|
|
||||||
setDefault(bar, "visibility", Visibility.Visible);
|
|
||||||
processComputable(bar as T, "width");
|
|
||||||
processComputable(bar as T, "height");
|
|
||||||
processComputable(bar as T, "direction");
|
|
||||||
processComputable(bar as T, "style");
|
|
||||||
processComputable(bar as T, "classes");
|
|
||||||
processComputable(bar as T, "borderStyle");
|
|
||||||
processComputable(bar as T, "baseStyle");
|
|
||||||
processComputable(bar as T, "textStyle");
|
|
||||||
processComputable(bar as T, "fillStyle");
|
|
||||||
processComputable(bar as T, "progress");
|
|
||||||
processComputable(bar as T, "display");
|
|
||||||
processComputable(bar as T, "mark");
|
|
||||||
|
|
||||||
for (const decorator of decorators) {
|
|
||||||
decorator.postConstruct?.(bar);
|
|
||||||
}
|
|
||||||
|
|
||||||
const decoratedProps = decorators.reduce(
|
|
||||||
(current, next) => Object.assign(current, next.getGatheredProps?.(bar)),
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
bar[GatherProps] = function (this: GenericBar) {
|
|
||||||
const {
|
|
||||||
progress,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
direction,
|
|
||||||
display,
|
|
||||||
visibility,
|
|
||||||
style,
|
|
||||||
classes,
|
|
||||||
borderStyle,
|
|
||||||
textStyle,
|
|
||||||
baseStyle,
|
|
||||||
fillStyle,
|
|
||||||
mark,
|
|
||||||
id
|
|
||||||
} = this;
|
|
||||||
return {
|
|
||||||
progress,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
direction,
|
|
||||||
display,
|
|
||||||
visibility,
|
|
||||||
style: unref(style),
|
|
||||||
classes,
|
|
||||||
borderStyle,
|
|
||||||
textStyle,
|
|
||||||
baseStyle,
|
|
||||||
fillStyle,
|
|
||||||
mark,
|
|
||||||
id,
|
|
||||||
...decoratedProps
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return bar as unknown as Bar<T>;
|
|
||||||
});
|
|
||||||
}
|
|
109
src/features/bars/bar.tsx
Normal file
109
src/features/bars/bar.tsx
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
import Bar from "features/bars/Bar.vue";
|
||||||
|
import type { DecimalSource } from "util/bignum";
|
||||||
|
import { Direction } from "util/common";
|
||||||
|
import { processGetter } from "util/computed";
|
||||||
|
import { createLazyProxy } from "util/proxies";
|
||||||
|
import { Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
|
||||||
|
import { CSSProperties, MaybeRef, MaybeRefOrGetter } from "vue";
|
||||||
|
|
||||||
|
/** A symbol used to identify {@link Bar} features. */
|
||||||
|
export const BarType = Symbol("Bar");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object that configures a {@link Bar}.
|
||||||
|
*/
|
||||||
|
export interface BarOptions extends VueFeatureOptions {
|
||||||
|
/** The width of the bar. */
|
||||||
|
width: MaybeRefOrGetter<number>;
|
||||||
|
/** The height of the bar. */
|
||||||
|
height: MaybeRefOrGetter<number>;
|
||||||
|
/** The direction in which the bar progresses. */
|
||||||
|
direction: MaybeRefOrGetter<Direction>;
|
||||||
|
/** CSS to apply to the bar's border. */
|
||||||
|
borderStyle?: MaybeRefOrGetter<CSSProperties>;
|
||||||
|
/** CSS to apply to the bar's base. */
|
||||||
|
baseStyle?: MaybeRefOrGetter<CSSProperties>;
|
||||||
|
/** CSS to apply to the bar's text. */
|
||||||
|
textStyle?: MaybeRefOrGetter<CSSProperties>;
|
||||||
|
/** CSS to apply to the bar's fill. */
|
||||||
|
fillStyle?: MaybeRefOrGetter<CSSProperties>;
|
||||||
|
/** The progress value of the bar, from 0 to 1. */
|
||||||
|
progress: MaybeRefOrGetter<DecimalSource>;
|
||||||
|
/** The display to use for this bar. */
|
||||||
|
display?: MaybeRefOrGetter<Renderable>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An object that represents a feature that displays some sort of progress or completion or resource with a cap. */
|
||||||
|
export interface Bar extends VueFeature {
|
||||||
|
/** The width of the bar. */
|
||||||
|
width: MaybeRef<number>;
|
||||||
|
/** The height of the bar. */
|
||||||
|
height: MaybeRef<number>;
|
||||||
|
/** The direction in which the bar progresses. */
|
||||||
|
direction: MaybeRef<Direction>;
|
||||||
|
/** CSS to apply to the bar's border. */
|
||||||
|
borderStyle?: MaybeRef<CSSProperties>;
|
||||||
|
/** CSS to apply to the bar's base. */
|
||||||
|
baseStyle?: MaybeRef<CSSProperties>;
|
||||||
|
/** CSS to apply to the bar's text. */
|
||||||
|
textStyle?: MaybeRef<CSSProperties>;
|
||||||
|
/** CSS to apply to the bar's fill. */
|
||||||
|
fillStyle?: MaybeRef<CSSProperties>;
|
||||||
|
/** The progress value of the bar, from 0 to 1. */
|
||||||
|
progress: MaybeRef<DecimalSource>;
|
||||||
|
/** The display to use for this bar. */
|
||||||
|
display?: MaybeRef<Renderable>;
|
||||||
|
/** A symbol that helps identify features of the same type. */
|
||||||
|
type: typeof BarType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily creates a bar with the given options.
|
||||||
|
* @param optionsFunc Bar options.
|
||||||
|
*/
|
||||||
|
export function createBar<T extends BarOptions>(optionsFunc: () => T) {
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const options = optionsFunc?.();
|
||||||
|
const {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
direction,
|
||||||
|
borderStyle,
|
||||||
|
baseStyle,
|
||||||
|
textStyle,
|
||||||
|
fillStyle,
|
||||||
|
progress,
|
||||||
|
display,
|
||||||
|
...props
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const bar = {
|
||||||
|
type: BarType,
|
||||||
|
...(props as Omit<typeof props, keyof VueFeature | keyof BarOptions>),
|
||||||
|
...vueFeatureMixin("bar", options, () => (
|
||||||
|
<Bar
|
||||||
|
width={bar.width}
|
||||||
|
height={bar.height}
|
||||||
|
direction={bar.direction}
|
||||||
|
borderStyle={bar.borderStyle}
|
||||||
|
baseStyle={bar.baseStyle}
|
||||||
|
textStyle={bar.textStyle}
|
||||||
|
fillStyle={bar.fillStyle}
|
||||||
|
progress={bar.progress}
|
||||||
|
display={bar.display}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
width: processGetter(width),
|
||||||
|
height: processGetter(height),
|
||||||
|
direction: processGetter(direction),
|
||||||
|
borderStyle: processGetter(borderStyle),
|
||||||
|
baseStyle: processGetter(baseStyle),
|
||||||
|
textStyle: processGetter(textStyle),
|
||||||
|
fillStyle: processGetter(fillStyle),
|
||||||
|
progress: processGetter(progress),
|
||||||
|
display: processGetter(display)
|
||||||
|
} satisfies Bar;
|
||||||
|
|
||||||
|
return bar;
|
||||||
|
});
|
||||||
|
}
|
|
@ -1,294 +0,0 @@
|
||||||
<template>
|
|
||||||
<panZoom
|
|
||||||
v-if="isVisible(visibility)"
|
|
||||||
:style="[
|
|
||||||
{
|
|
||||||
width,
|
|
||||||
height
|
|
||||||
},
|
|
||||||
style
|
|
||||||
]"
|
|
||||||
:class="classes"
|
|
||||||
selector=".g1"
|
|
||||||
:options="{ initialZoom: 1, minZoom: 0.1, maxZoom: 10, zoomDoubleClickSpeed: 1 }"
|
|
||||||
ref="stage"
|
|
||||||
@init="onInit"
|
|
||||||
@mousemove="drag"
|
|
||||||
@touchmove="drag"
|
|
||||||
@mousedown="(e: MouseEvent) => mouseDown(e)"
|
|
||||||
@touchstart="(e: TouchEvent) => mouseDown(e)"
|
|
||||||
@mouseup="() => endDragging(unref(draggingNode))"
|
|
||||||
@touchend.passive="() => endDragging(unref(draggingNode))"
|
|
||||||
@mouseleave="() => endDragging(unref(draggingNode), true)"
|
|
||||||
>
|
|
||||||
<svg class="stage" width="100%" height="100%">
|
|
||||||
<g class="g1">
|
|
||||||
<transition-group name="link" appear>
|
|
||||||
<g
|
|
||||||
v-for="link in unref(links) || []"
|
|
||||||
:key="`${link.startNode.id}-${link.endNode.id}`"
|
|
||||||
>
|
|
||||||
<BoardLinkVue
|
|
||||||
:link="link"
|
|
||||||
:dragging="unref(draggingNode)"
|
|
||||||
:dragged="
|
|
||||||
link.startNode === unref(draggingNode) ||
|
|
||||||
link.endNode === unref(draggingNode)
|
|
||||||
? dragged
|
|
||||||
: undefined
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
</transition-group>
|
|
||||||
<transition-group name="grow" :duration="500" appear>
|
|
||||||
<g v-for="node in sortedNodes" :key="node.id" style="transition-duration: 0s">
|
|
||||||
<BoardNodeVue
|
|
||||||
:node="node"
|
|
||||||
:nodeType="types[node.type]"
|
|
||||||
:dragging="unref(draggingNode)"
|
|
||||||
:dragged="unref(draggingNode) === node ? dragged : undefined"
|
|
||||||
:hasDragged="unref(draggingNode) == null ? false : hasDragged"
|
|
||||||
:receivingNode="unref(receivingNode) === node"
|
|
||||||
:isSelected="unref(selectedNode) === node"
|
|
||||||
:selectedAction="
|
|
||||||
unref(selectedNode) === node ? unref(selectedAction) : null
|
|
||||||
"
|
|
||||||
@mouseDown="mouseDown"
|
|
||||||
@endDragging="endDragging"
|
|
||||||
@clickAction="(actionId: string) => clickAction(node, actionId)"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
</transition-group>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</panZoom>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type {
|
|
||||||
BoardData,
|
|
||||||
BoardNode,
|
|
||||||
BoardNodeLink,
|
|
||||||
GenericBoardNodeAction,
|
|
||||||
GenericNodeType
|
|
||||||
} from "features/boards/board";
|
|
||||||
import { getNodeProperty } from "features/boards/board";
|
|
||||||
import type { StyleValue } from "features/feature";
|
|
||||||
import { Visibility, isVisible } from "features/feature";
|
|
||||||
import type { ProcessedComputable } from "util/computed";
|
|
||||||
import { Ref, computed, ref, toRefs, unref, watchEffect } from "vue";
|
|
||||||
import BoardLinkVue from "./BoardLink.vue";
|
|
||||||
import BoardNodeVue from "./BoardNode.vue";
|
|
||||||
|
|
||||||
const _props = defineProps<{
|
|
||||||
nodes: Ref<BoardNode[]>;
|
|
||||||
types: Record<string, GenericNodeType>;
|
|
||||||
state: Ref<BoardData>;
|
|
||||||
visibility: ProcessedComputable<Visibility | boolean>;
|
|
||||||
width?: ProcessedComputable<string>;
|
|
||||||
height?: ProcessedComputable<string>;
|
|
||||||
style?: ProcessedComputable<StyleValue>;
|
|
||||||
classes?: ProcessedComputable<Record<string, boolean>>;
|
|
||||||
links: Ref<BoardNodeLink[] | null>;
|
|
||||||
selectedAction: Ref<GenericBoardNodeAction | null>;
|
|
||||||
selectedNode: Ref<BoardNode | null>;
|
|
||||||
draggingNode: Ref<BoardNode | null>;
|
|
||||||
receivingNode: Ref<BoardNode | null>;
|
|
||||||
mousePosition: Ref<{ x: number; y: number } | null>;
|
|
||||||
setReceivingNode: (node: BoardNode | null) => void;
|
|
||||||
setDraggingNode: (node: BoardNode | null) => void;
|
|
||||||
}>();
|
|
||||||
const props = toRefs(_props);
|
|
||||||
|
|
||||||
const lastMousePosition = ref({ x: 0, y: 0 });
|
|
||||||
const dragged = ref({ x: 0, y: 0 });
|
|
||||||
const hasDragged = ref(false);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const stage = ref<any>(null);
|
|
||||||
|
|
||||||
const sortedNodes = computed(() => {
|
|
||||||
const nodes = props.nodes.value.slice();
|
|
||||||
if (props.selectedNode.value) {
|
|
||||||
const node = nodes.splice(nodes.indexOf(props.selectedNode.value), 1)[0];
|
|
||||||
nodes.push(node);
|
|
||||||
}
|
|
||||||
if (props.draggingNode.value) {
|
|
||||||
const node = nodes.splice(nodes.indexOf(props.draggingNode.value), 1)[0];
|
|
||||||
nodes.push(node);
|
|
||||||
}
|
|
||||||
return nodes;
|
|
||||||
});
|
|
||||||
|
|
||||||
watchEffect(() => {
|
|
||||||
const node = props.draggingNode.value;
|
|
||||||
if (node == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const position = {
|
|
||||||
x: node.position.x + dragged.value.x,
|
|
||||||
y: node.position.y + dragged.value.y
|
|
||||||
};
|
|
||||||
let smallestDistance = Number.MAX_VALUE;
|
|
||||||
|
|
||||||
props.setReceivingNode.value(
|
|
||||||
props.nodes.value.reduce((smallest: BoardNode | null, curr: BoardNode) => {
|
|
||||||
if (curr.id === node.id) {
|
|
||||||
return smallest;
|
|
||||||
}
|
|
||||||
const nodeType = props.types.value[curr.type];
|
|
||||||
const canAccept = getNodeProperty(nodeType.canAccept, curr, node);
|
|
||||||
if (!canAccept) {
|
|
||||||
return smallest;
|
|
||||||
}
|
|
||||||
|
|
||||||
const distanceSquared =
|
|
||||||
Math.pow(position.x - curr.position.x, 2) +
|
|
||||||
Math.pow(position.y - curr.position.y, 2);
|
|
||||||
let size = getNodeProperty(nodeType.size, curr);
|
|
||||||
if (distanceSquared > smallestDistance || distanceSquared > size * size) {
|
|
||||||
return smallest;
|
|
||||||
}
|
|
||||||
|
|
||||||
smallestDistance = distanceSquared;
|
|
||||||
return curr;
|
|
||||||
}, null)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
function onInit(panzoomInstance: any) {
|
|
||||||
panzoomInstance.setTransformOrigin(null);
|
|
||||||
panzoomInstance.moveTo(stage.value.$el.clientWidth / 2, stage.value.$el.clientHeight / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mouseDown(e: MouseEvent | TouchEvent, node: BoardNode | null = null, draggable = false) {
|
|
||||||
if (props.draggingNode.value == null) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
let clientX, clientY;
|
|
||||||
if ("touches" in e) {
|
|
||||||
if (e.touches.length === 1) {
|
|
||||||
clientX = e.touches[0].clientX;
|
|
||||||
clientY = e.touches[0].clientY;
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
clientX = e.clientX;
|
|
||||||
clientY = e.clientY;
|
|
||||||
}
|
|
||||||
lastMousePosition.value = {
|
|
||||||
x: clientX,
|
|
||||||
y: clientY
|
|
||||||
};
|
|
||||||
dragged.value = { x: 0, y: 0 };
|
|
||||||
hasDragged.value = false;
|
|
||||||
|
|
||||||
if (draggable) {
|
|
||||||
props.setDraggingNode.value(node);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (node != null) {
|
|
||||||
props.state.value.selectedNode = null;
|
|
||||||
props.state.value.selectedAction = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function drag(e: MouseEvent | TouchEvent) {
|
|
||||||
const { x, y, scale } = stage.value.panZoomInstance.getTransform();
|
|
||||||
|
|
||||||
let clientX, clientY;
|
|
||||||
if ("touches" in e) {
|
|
||||||
if (e.touches.length === 1) {
|
|
||||||
clientX = e.touches[0].clientX;
|
|
||||||
clientY = e.touches[0].clientY;
|
|
||||||
} else {
|
|
||||||
endDragging(props.draggingNode.value);
|
|
||||||
props.mousePosition.value = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
clientX = e.clientX;
|
|
||||||
clientY = e.clientY;
|
|
||||||
}
|
|
||||||
|
|
||||||
props.mousePosition.value = {
|
|
||||||
x: (clientX - x) / scale,
|
|
||||||
y: (clientY - y) / scale
|
|
||||||
};
|
|
||||||
|
|
||||||
dragged.value = {
|
|
||||||
x: dragged.value.x + (clientX - lastMousePosition.value.x) / scale,
|
|
||||||
y: dragged.value.y + (clientY - lastMousePosition.value.y) / scale
|
|
||||||
};
|
|
||||||
lastMousePosition.value = {
|
|
||||||
x: clientX,
|
|
||||||
y: clientY
|
|
||||||
};
|
|
||||||
|
|
||||||
if (Math.abs(dragged.value.x) > 10 || Math.abs(dragged.value.y) > 10) {
|
|
||||||
hasDragged.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.draggingNode.value != null) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function endDragging(node: BoardNode | null, mouseLeave = false) {
|
|
||||||
if (props.draggingNode.value != null && props.draggingNode.value === node) {
|
|
||||||
if (props.receivingNode.value == null) {
|
|
||||||
props.draggingNode.value.position.x += Math.round(dragged.value.x / 25) * 25;
|
|
||||||
props.draggingNode.value.position.y += Math.round(dragged.value.y / 25) * 25;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodes = props.nodes.value;
|
|
||||||
nodes.push(nodes.splice(nodes.indexOf(props.draggingNode.value), 1)[0]);
|
|
||||||
|
|
||||||
if (props.receivingNode.value) {
|
|
||||||
props.types.value[props.receivingNode.value.type].onDrop?.(
|
|
||||||
props.receivingNode.value,
|
|
||||||
props.draggingNode.value
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
props.setDraggingNode.value(null);
|
|
||||||
} else if (!hasDragged.value && !mouseLeave) {
|
|
||||||
props.state.value.selectedNode = null;
|
|
||||||
props.state.value.selectedAction = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clickAction(node: BoardNode, actionId: string) {
|
|
||||||
if (props.state.value.selectedAction === actionId) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
unref(props.selectedAction)!.onClick(unref(props.selectedNode)!);
|
|
||||||
} else {
|
|
||||||
props.state.value = { ...props.state.value, selectedAction: actionId };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.vue-pan-zoom-scene {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
cursor: grab;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vue-pan-zoom-scene:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
.g1 {
|
|
||||||
transition-duration: 0s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-enter-from,
|
|
||||||
.link-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,80 +0,0 @@
|
||||||
<template>
|
|
||||||
<line
|
|
||||||
class="link"
|
|
||||||
v-bind="linkProps"
|
|
||||||
:class="{ pulsing: link.pulsing }"
|
|
||||||
:x1="startPosition.x"
|
|
||||||
:y1="startPosition.y"
|
|
||||||
:x2="endPosition.x"
|
|
||||||
:y2="endPosition.y"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { BoardNode, BoardNodeLink } from "features/boards/board";
|
|
||||||
import { kebabifyObject } from "util/vue";
|
|
||||||
import { computed, toRefs, unref } from "vue";
|
|
||||||
|
|
||||||
const _props = defineProps<{
|
|
||||||
link: BoardNodeLink;
|
|
||||||
dragging: BoardNode | null;
|
|
||||||
dragged?: {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
};
|
|
||||||
}>();
|
|
||||||
const props = toRefs(_props);
|
|
||||||
|
|
||||||
const startPosition = computed(() => {
|
|
||||||
const position = { ...props.link.value.startNode.position };
|
|
||||||
if (props.link.value.offsetStart) {
|
|
||||||
position.x += unref(props.link.value.offsetStart).x;
|
|
||||||
position.y += unref(props.link.value.offsetStart).y;
|
|
||||||
}
|
|
||||||
if (props.dragging?.value === props.link.value.startNode) {
|
|
||||||
position.x += props.dragged?.value?.x ?? 0;
|
|
||||||
position.y += props.dragged?.value?.y ?? 0;
|
|
||||||
}
|
|
||||||
return position;
|
|
||||||
});
|
|
||||||
|
|
||||||
const endPosition = computed(() => {
|
|
||||||
const position = { ...props.link.value.endNode.position };
|
|
||||||
if (props.link.value.offsetEnd) {
|
|
||||||
position.x += unref(props.link.value.offsetEnd).x;
|
|
||||||
position.y += unref(props.link.value.offsetEnd).y;
|
|
||||||
}
|
|
||||||
if (props.dragging?.value === props.link.value.endNode) {
|
|
||||||
position.x += props.dragged?.value?.x ?? 0;
|
|
||||||
position.y += props.dragged?.value?.y ?? 0;
|
|
||||||
}
|
|
||||||
return position;
|
|
||||||
});
|
|
||||||
|
|
||||||
const linkProps = computed(() => kebabifyObject(_props.link as unknown as Record<string, unknown>));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.link {
|
|
||||||
transition-duration: 0s;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link.pulsing {
|
|
||||||
animation: pulsing 2s ease-in infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulsing {
|
|
||||||
0% {
|
|
||||||
opacity: 0.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
opacity: 0.25;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,339 +0,0 @@
|
||||||
<template>
|
|
||||||
<!-- Ugly casting to prevent TS compiler error about style because vue doesn't think it supports arrays when it does -->
|
|
||||||
<g
|
|
||||||
class="boardnode"
|
|
||||||
:class="{ [node.type]: true, isSelected, isDraggable, ...classes }"
|
|
||||||
:style="[{ opacity: dragging?.id === node.id && hasDragged ? 0.5 : 1 }, style ?? []] as unknown as (string | CSSProperties)"
|
|
||||||
:transform="`translate(${position.x},${position.y})${isSelected ? ' scale(1.2)' : ''}`"
|
|
||||||
>
|
|
||||||
<BoardNodeAction
|
|
||||||
:actions="actions ?? []"
|
|
||||||
:is-selected="isSelected"
|
|
||||||
:node="node"
|
|
||||||
:node-type="nodeType"
|
|
||||||
:selected-action="selectedAction"
|
|
||||||
@click-action="(actionId: string) => emit('clickAction', actionId)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<g
|
|
||||||
class="node-container"
|
|
||||||
@mousedown="mouseDown"
|
|
||||||
@touchstart.passive="mouseDown"
|
|
||||||
@mouseup="mouseUp"
|
|
||||||
@touchend.passive="mouseUp"
|
|
||||||
>
|
|
||||||
<g v-if="shape === Shape.Circle">
|
|
||||||
<circle
|
|
||||||
v-if="canAccept"
|
|
||||||
class="receiver"
|
|
||||||
:r="size + 8"
|
|
||||||
:fill="backgroundColor"
|
|
||||||
:stroke="receivingNode ? '#0F0' : '#0F03'"
|
|
||||||
:stroke-width="2"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<circle
|
|
||||||
class="body"
|
|
||||||
:r="size"
|
|
||||||
:fill="fillColor"
|
|
||||||
:stroke="outlineColor"
|
|
||||||
:stroke-width="4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<circle
|
|
||||||
class="progress progressFill"
|
|
||||||
v-if="progressDisplay === ProgressDisplay.Fill"
|
|
||||||
:r="Math.max(size * progress - 2, 0)"
|
|
||||||
:fill="progressColor"
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
v-else
|
|
||||||
:r="size + 4.5"
|
|
||||||
class="progress progressRing"
|
|
||||||
fill="transparent"
|
|
||||||
:stroke-dasharray="(size + 4.5) * 2 * Math.PI"
|
|
||||||
:stroke-width="5"
|
|
||||||
:stroke-dashoffset="
|
|
||||||
(size + 4.5) * 2 * Math.PI - progress * (size + 4.5) * 2 * Math.PI
|
|
||||||
"
|
|
||||||
:stroke="progressColor"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
<g v-else-if="shape === Shape.Diamond" transform="rotate(45, 0, 0)">
|
|
||||||
<rect
|
|
||||||
v-if="canAccept"
|
|
||||||
class="receiver"
|
|
||||||
:width="size * sqrtTwo + 16"
|
|
||||||
:height="size * sqrtTwo + 16"
|
|
||||||
:transform="`translate(${-(size * sqrtTwo + 16) / 2}, ${
|
|
||||||
-(size * sqrtTwo + 16) / 2
|
|
||||||
})`"
|
|
||||||
:fill="backgroundColor"
|
|
||||||
:stroke="receivingNode ? '#0F0' : '#0F03'"
|
|
||||||
:stroke-width="2"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<rect
|
|
||||||
class="body"
|
|
||||||
:width="size * sqrtTwo"
|
|
||||||
:height="size * sqrtTwo"
|
|
||||||
:transform="`translate(${(-size * sqrtTwo) / 2}, ${(-size * sqrtTwo) / 2})`"
|
|
||||||
:fill="fillColor"
|
|
||||||
:stroke="outlineColor"
|
|
||||||
:stroke-width="4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<rect
|
|
||||||
v-if="progressDisplay === ProgressDisplay.Fill"
|
|
||||||
class="progress progressFill"
|
|
||||||
:width="Math.max(size * sqrtTwo * progress - 2, 0)"
|
|
||||||
:height="Math.max(size * sqrtTwo * progress - 2, 0)"
|
|
||||||
:transform="`translate(${-Math.max(size * sqrtTwo * progress - 2, 0) / 2}, ${
|
|
||||||
-Math.max(size * sqrtTwo * progress - 2, 0) / 2
|
|
||||||
})`"
|
|
||||||
:fill="progressColor"
|
|
||||||
/>
|
|
||||||
<rect
|
|
||||||
v-else
|
|
||||||
class="progress progressDiamond"
|
|
||||||
:width="size * sqrtTwo + 9"
|
|
||||||
:height="size * sqrtTwo + 9"
|
|
||||||
:transform="`translate(${-(size * sqrtTwo + 9) / 2}, ${
|
|
||||||
-(size * sqrtTwo + 9) / 2
|
|
||||||
})`"
|
|
||||||
fill="transparent"
|
|
||||||
:stroke-dasharray="(size * sqrtTwo + 9) * 4"
|
|
||||||
:stroke-width="5"
|
|
||||||
:stroke-dashoffset="
|
|
||||||
(size * sqrtTwo + 9) * 4 - progress * (size * sqrtTwo + 9) * 4
|
|
||||||
"
|
|
||||||
:stroke="progressColor"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<text :fill="titleColor" class="node-title">{{ title }}</text>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<transition name="fade" appear>
|
|
||||||
<g v-if="label">
|
|
||||||
<text
|
|
||||||
:fill="label.color ?? titleColor"
|
|
||||||
class="node-title"
|
|
||||||
:class="{ pulsing: label.pulsing }"
|
|
||||||
:y="-size - 20"
|
|
||||||
>{{ label.text }}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
</transition>
|
|
||||||
|
|
||||||
<transition name="fade" appear>
|
|
||||||
<text
|
|
||||||
v-if="isSelected && selectedAction"
|
|
||||||
:fill="confirmationLabel.color ?? titleColor"
|
|
||||||
class="node-title"
|
|
||||||
:class="{ pulsing: confirmationLabel.pulsing }"
|
|
||||||
:y="size + 75"
|
|
||||||
>{{ confirmationLabel.text }}</text
|
|
||||||
>
|
|
||||||
</transition>
|
|
||||||
</g>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import themes from "data/themes";
|
|
||||||
import type { BoardNode, GenericBoardNodeAction, GenericNodeType } from "features/boards/board";
|
|
||||||
import { ProgressDisplay, Shape, getNodeProperty } from "features/boards/board";
|
|
||||||
import { isVisible } from "features/feature";
|
|
||||||
import settings from "game/settings";
|
|
||||||
import { CSSProperties, computed, toRefs, unref, watch } from "vue";
|
|
||||||
import BoardNodeAction from "./BoardNodeAction.vue";
|
|
||||||
|
|
||||||
const sqrtTwo = Math.sqrt(2);
|
|
||||||
|
|
||||||
const _props = defineProps<{
|
|
||||||
node: BoardNode;
|
|
||||||
nodeType: GenericNodeType;
|
|
||||||
dragging: BoardNode | null;
|
|
||||||
dragged?: {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
};
|
|
||||||
hasDragged?: boolean;
|
|
||||||
receivingNode?: boolean;
|
|
||||||
isSelected: boolean;
|
|
||||||
selectedAction: GenericBoardNodeAction | null;
|
|
||||||
}>();
|
|
||||||
const props = toRefs(_props);
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: "mouseDown", event: MouseEvent | TouchEvent, node: BoardNode, isDraggable: boolean): void;
|
|
||||||
(e: "endDragging", node: BoardNode): void;
|
|
||||||
(e: "clickAction", actionId: string): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const isDraggable = computed(() =>
|
|
||||||
getNodeProperty(props.nodeType.value.draggable, unref(props.node))
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(isDraggable, value => {
|
|
||||||
const node = unref(props.node);
|
|
||||||
if (unref(props.dragging) === node && !value) {
|
|
||||||
emit("endDragging", node);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const actions = computed(() => {
|
|
||||||
const node = unref(props.node);
|
|
||||||
return getNodeProperty(props.nodeType.value.actions, node)?.filter(action =>
|
|
||||||
isVisible(getNodeProperty(action.visibility, node))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const position = computed(() => {
|
|
||||||
const node = unref(props.node);
|
|
||||||
|
|
||||||
if (
|
|
||||||
getNodeProperty(props.nodeType.value.draggable, node) &&
|
|
||||||
unref(props.dragging)?.id === node.id &&
|
|
||||||
unref(props.dragged) != null
|
|
||||||
) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
const { x, y } = unref(props.dragged)!;
|
|
||||||
return {
|
|
||||||
x: node.position.x + Math.round(x / 25) * 25,
|
|
||||||
y: node.position.y + Math.round(y / 25) * 25
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return node.position;
|
|
||||||
});
|
|
||||||
|
|
||||||
const shape = computed(() => getNodeProperty(props.nodeType.value.shape, unref(props.node)));
|
|
||||||
const title = computed(() => getNodeProperty(props.nodeType.value.title, unref(props.node)));
|
|
||||||
const label = computed(
|
|
||||||
() =>
|
|
||||||
(props.isSelected.value
|
|
||||||
? unref(props.selectedAction) &&
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
getNodeProperty(unref(props.selectedAction)!.tooltip, unref(props.node))
|
|
||||||
: null) ?? getNodeProperty(props.nodeType.value.label, unref(props.node))
|
|
||||||
);
|
|
||||||
const confirmationLabel = computed(() =>
|
|
||||||
getNodeProperty(
|
|
||||||
unref(props.selectedAction)?.confirmationLabel ?? {
|
|
||||||
text: "Tap again to confirm"
|
|
||||||
},
|
|
||||||
unref(props.node)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const size = computed(() => getNodeProperty(props.nodeType.value.size, unref(props.node)));
|
|
||||||
const progress = computed(
|
|
||||||
() => getNodeProperty(props.nodeType.value.progress, unref(props.node)) ?? 0
|
|
||||||
);
|
|
||||||
const backgroundColor = computed(() => themes[settings.theme].variables["--background"]);
|
|
||||||
const outlineColor = computed(
|
|
||||||
() =>
|
|
||||||
getNodeProperty(props.nodeType.value.outlineColor, unref(props.node)) ??
|
|
||||||
themes[settings.theme].variables["--outline"]
|
|
||||||
);
|
|
||||||
const fillColor = computed(
|
|
||||||
() =>
|
|
||||||
getNodeProperty(props.nodeType.value.fillColor, unref(props.node)) ??
|
|
||||||
themes[settings.theme].variables["--raised-background"]
|
|
||||||
);
|
|
||||||
const progressColor = computed(() =>
|
|
||||||
getNodeProperty(props.nodeType.value.progressColor, unref(props.node))
|
|
||||||
);
|
|
||||||
const titleColor = computed(
|
|
||||||
() =>
|
|
||||||
getNodeProperty(props.nodeType.value.titleColor, unref(props.node)) ??
|
|
||||||
themes[settings.theme].variables["--foreground"]
|
|
||||||
);
|
|
||||||
const progressDisplay = computed(() =>
|
|
||||||
getNodeProperty(props.nodeType.value.progressDisplay, unref(props.node))
|
|
||||||
);
|
|
||||||
const canAccept = computed(
|
|
||||||
() =>
|
|
||||||
props.dragging.value != null &&
|
|
||||||
unref(props.hasDragged) &&
|
|
||||||
getNodeProperty(props.nodeType.value.canAccept, unref(props.node), props.dragging.value)
|
|
||||||
);
|
|
||||||
const style = computed(() => getNodeProperty(props.nodeType.value.style, unref(props.node)));
|
|
||||||
const classes = computed(() => getNodeProperty(props.nodeType.value.classes, unref(props.node)));
|
|
||||||
|
|
||||||
function mouseDown(e: MouseEvent | TouchEvent) {
|
|
||||||
emit("mouseDown", e, props.node.value, isDraggable.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mouseUp(e: MouseEvent | TouchEvent) {
|
|
||||||
if (!props.hasDragged?.value) {
|
|
||||||
emit("endDragging", props.node.value);
|
|
||||||
props.nodeType.value.onClick?.(props.node.value);
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.boardnode {
|
|
||||||
cursor: pointer;
|
|
||||||
transition-duration: 0s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.boardnode:hover .body {
|
|
||||||
fill: var(--highlighted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.boardnode.isSelected .body {
|
|
||||||
fill: var(--accent1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.boardnode:not(.isDraggable) .body {
|
|
||||||
fill: var(--locked);
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-title {
|
|
||||||
text-anchor: middle;
|
|
||||||
dominant-baseline: middle;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 200%;
|
|
||||||
pointer-events: none;
|
|
||||||
filter: drop-shadow(3px 3px 2px var(--tooltip-background));
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress {
|
|
||||||
transition-duration: 0.05s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progressRing {
|
|
||||||
transform: rotate(-90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-enter-from,
|
|
||||||
.fade-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pulsing {
|
|
||||||
animation: pulsing 2s ease-in infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulsing {
|
|
||||||
0% {
|
|
||||||
opacity: 0.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
opacity: 0.25;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.grow-enter-from .node-container,
|
|
||||||
.grow-leave-to .node-container {
|
|
||||||
transform: scale(0);
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,109 +0,0 @@
|
||||||
<template>
|
|
||||||
<transition name="actions" appear>
|
|
||||||
<g v-if="isSelected && actions">
|
|
||||||
<g
|
|
||||||
v-for="(action, index) in actions"
|
|
||||||
:key="action.id"
|
|
||||||
class="action"
|
|
||||||
:class="{ selected: selectedAction?.id === action.id }"
|
|
||||||
:transform="`translate(
|
|
||||||
${
|
|
||||||
(-size - 30) *
|
|
||||||
Math.sin(((actions.length - 1) / 2 - index) * actionDistance)
|
|
||||||
},
|
|
||||||
${
|
|
||||||
(size + 30) *
|
|
||||||
Math.cos(((actions.length - 1) / 2 - index) * actionDistance)
|
|
||||||
}
|
|
||||||
)`"
|
|
||||||
@mousedown="e => performAction(e, action)"
|
|
||||||
@touchstart="e => performAction(e, action)"
|
|
||||||
@mouseup="e => actionMouseUp(e, action)"
|
|
||||||
@touchend.stop="e => actionMouseUp(e, action)"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
:fill="getNodeProperty(action.fillColor, node)"
|
|
||||||
r="20"
|
|
||||||
:stroke-width="selectedAction?.id === action.id ? 4 : 0"
|
|
||||||
:stroke="outlineColor"
|
|
||||||
/>
|
|
||||||
<text :fill="titleColor" class="material-icons">{{
|
|
||||||
getNodeProperty(action.icon, node)
|
|
||||||
}}</text>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</transition>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import themes from "data/themes";
|
|
||||||
import type { BoardNode, GenericBoardNodeAction, GenericNodeType } from "features/boards/board";
|
|
||||||
import { getNodeProperty } from "features/boards/board";
|
|
||||||
import settings from "game/settings";
|
|
||||||
import { computed, toRefs, unref } from "vue";
|
|
||||||
|
|
||||||
const _props = defineProps<{
|
|
||||||
node: BoardNode;
|
|
||||||
nodeType: GenericNodeType;
|
|
||||||
actions?: GenericBoardNodeAction[];
|
|
||||||
isSelected: boolean;
|
|
||||||
selectedAction: GenericBoardNodeAction | null;
|
|
||||||
}>();
|
|
||||||
const props = toRefs(_props);
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: "clickAction", actionId: string): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const size = computed(() => getNodeProperty(props.nodeType.value.size, unref(props.node)));
|
|
||||||
const outlineColor = computed(
|
|
||||||
() =>
|
|
||||||
getNodeProperty(props.nodeType.value.outlineColor, unref(props.node)) ??
|
|
||||||
themes[settings.theme].variables["--outline"]
|
|
||||||
);
|
|
||||||
const titleColor = computed(
|
|
||||||
() =>
|
|
||||||
getNodeProperty(props.nodeType.value.titleColor, unref(props.node)) ??
|
|
||||||
themes[settings.theme].variables["--foreground"]
|
|
||||||
);
|
|
||||||
const actionDistance = computed(() =>
|
|
||||||
getNodeProperty(props.nodeType.value.actionDistance, unref(props.node))
|
|
||||||
);
|
|
||||||
|
|
||||||
function performAction(e: MouseEvent | TouchEvent, action: GenericBoardNodeAction) {
|
|
||||||
emit("clickAction", action.id);
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
|
|
||||||
function actionMouseUp(e: MouseEvent | TouchEvent, action: GenericBoardNodeAction) {
|
|
||||||
if (unref(props.selectedAction)?.id === action.id) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.action:not(.boardnode):hover circle,
|
|
||||||
.action:not(.boardnode).selected circle {
|
|
||||||
r: 25;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action:not(.boardnode):hover text,
|
|
||||||
.action:not(.boardnode).selected text {
|
|
||||||
font-size: 187.5%; /* 150% * 1.25 */
|
|
||||||
}
|
|
||||||
|
|
||||||
.action:not(.boardnode) text {
|
|
||||||
text-anchor: middle;
|
|
||||||
dominant-baseline: central;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.actions-enter-from .action,
|
|
||||||
.actions-leave-to .action {
|
|
||||||
transform: translate(0, 0);
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,631 +0,0 @@
|
||||||
import BoardComponent from "features/boards/Board.vue";
|
|
||||||
import type { GenericComponent, OptionsFunc, Replace, StyleValue } from "features/feature";
|
|
||||||
import {
|
|
||||||
Component,
|
|
||||||
findFeatures,
|
|
||||||
GatherProps,
|
|
||||||
getUniqueID,
|
|
||||||
setDefault,
|
|
||||||
Visibility
|
|
||||||
} from "features/feature";
|
|
||||||
import { globalBus } from "game/events";
|
|
||||||
import { DefaultValue, deletePersistent, Persistent, State } from "game/persistence";
|
|
||||||
import { persistent } from "game/persistence";
|
|
||||||
import type { Unsubscribe } from "nanoevents";
|
|
||||||
import { Direction, isFunction } from "util/common";
|
|
||||||
import type {
|
|
||||||
Computable,
|
|
||||||
GetComputableType,
|
|
||||||
GetComputableTypeWithDefault,
|
|
||||||
ProcessedComputable
|
|
||||||
} from "util/computed";
|
|
||||||
import { processComputable } from "util/computed";
|
|
||||||
import { createLazyProxy } from "util/proxies";
|
|
||||||
import { computed, isRef, ref, Ref, unref } from "vue";
|
|
||||||
import panZoom from "vue-panzoom";
|
|
||||||
import type { Link } from "../links/links";
|
|
||||||
|
|
||||||
globalBus.on("setupVue", app => panZoom.install(app));
|
|
||||||
|
|
||||||
/** A symbol used to identify {@link Board} features. */
|
|
||||||
export const BoardType = Symbol("Board");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A type representing a computable value for a node on the board. Used for node types to return different values based on the given node and the state of the board.
|
|
||||||
*/
|
|
||||||
export type NodeComputable<T, S extends unknown[] = []> =
|
|
||||||
| Computable<T>
|
|
||||||
| ((node: BoardNode, ...args: S) => T);
|
|
||||||
|
|
||||||
/** Ways to display progress of an action with a duration. */
|
|
||||||
export enum ProgressDisplay {
|
|
||||||
Outline = "Outline",
|
|
||||||
Fill = "Fill"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Node shapes. */
|
|
||||||
export enum Shape {
|
|
||||||
Circle = "Circle",
|
|
||||||
Diamond = "Triangle"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An object representing a node on the board. */
|
|
||||||
export interface BoardNode {
|
|
||||||
id: number;
|
|
||||||
position: {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
};
|
|
||||||
type: string;
|
|
||||||
state?: State;
|
|
||||||
pinned?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An object representing a link between two nodes on the board. */
|
|
||||||
export interface BoardNodeLink extends Omit<Link, "startNode" | "endNode"> {
|
|
||||||
startNode: BoardNode;
|
|
||||||
endNode: BoardNode;
|
|
||||||
stroke: string;
|
|
||||||
strokeWidth: number;
|
|
||||||
pulsing?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An object representing a label for a node. */
|
|
||||||
export interface NodeLabel {
|
|
||||||
text: string;
|
|
||||||
color?: string;
|
|
||||||
pulsing?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The persistent data for a board. */
|
|
||||||
export type BoardData = {
|
|
||||||
nodes: BoardNode[];
|
|
||||||
selectedNode: number | null;
|
|
||||||
selectedAction: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An object that configures a {@link NodeType}.
|
|
||||||
*/
|
|
||||||
export interface NodeTypeOptions {
|
|
||||||
/** The title to display for the node. */
|
|
||||||
title: NodeComputable<string>;
|
|
||||||
/** An optional label for the node. */
|
|
||||||
label?: NodeComputable<NodeLabel | null>;
|
|
||||||
/** The size of the node - diameter for circles, width and height for squares. */
|
|
||||||
size: NodeComputable<number>;
|
|
||||||
/** CSS to apply to this node. */
|
|
||||||
style?: NodeComputable<StyleValue>;
|
|
||||||
/** Dictionary of CSS classes to apply to this node. */
|
|
||||||
classes?: NodeComputable<Record<string, boolean>>;
|
|
||||||
/** Whether the node is draggable or not. */
|
|
||||||
draggable?: NodeComputable<boolean>;
|
|
||||||
/** The shape of the node. */
|
|
||||||
shape: NodeComputable<Shape>;
|
|
||||||
/** Whether the node can accept another node being dropped upon it. */
|
|
||||||
canAccept?: NodeComputable<boolean, [BoardNode]>;
|
|
||||||
/** The progress value of the node, from 0 to 1. */
|
|
||||||
progress?: NodeComputable<number>;
|
|
||||||
/** How the progress should be displayed on the node. */
|
|
||||||
progressDisplay?: NodeComputable<ProgressDisplay>;
|
|
||||||
/** The color of the progress indicator. */
|
|
||||||
progressColor?: NodeComputable<string>;
|
|
||||||
/** The fill color of the node. */
|
|
||||||
fillColor?: NodeComputable<string>;
|
|
||||||
/** The outline color of the node. */
|
|
||||||
outlineColor?: NodeComputable<string>;
|
|
||||||
/** The color of the title text. */
|
|
||||||
titleColor?: NodeComputable<string>;
|
|
||||||
/** The list of action options for the node. */
|
|
||||||
actions?: BoardNodeActionOptions[];
|
|
||||||
/** The arc between each action, in radians. */
|
|
||||||
actionDistance?: NodeComputable<number>;
|
|
||||||
/** A function that is called when the node is clicked. */
|
|
||||||
onClick?: (node: BoardNode) => void;
|
|
||||||
/** A function that is called when a node is dropped onto this node. */
|
|
||||||
onDrop?: (node: BoardNode, otherNode: BoardNode) => void;
|
|
||||||
/** A function that is called for each node of this type every tick. */
|
|
||||||
update?: (node: BoardNode, diff: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The properties that are added onto a processed {@link NodeTypeOptions} to create a {@link NodeType}.
|
|
||||||
*/
|
|
||||||
export interface BaseNodeType {
|
|
||||||
/** The nodes currently on the board of this type. */
|
|
||||||
nodes: Ref<BoardNode[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An object that represents a type of node that can appear on a board. It will handle getting properties and callbacks for every node of that type. */
|
|
||||||
export type NodeType<T extends NodeTypeOptions> = Replace<
|
|
||||||
T & BaseNodeType,
|
|
||||||
{
|
|
||||||
title: GetComputableType<T["title"]>;
|
|
||||||
label: GetComputableType<T["label"]>;
|
|
||||||
size: GetComputableTypeWithDefault<T["size"], 50>;
|
|
||||||
style: GetComputableType<T["style"]>;
|
|
||||||
classes: GetComputableType<T["classes"]>;
|
|
||||||
draggable: GetComputableTypeWithDefault<T["draggable"], false>;
|
|
||||||
shape: GetComputableTypeWithDefault<T["shape"], Shape.Circle>;
|
|
||||||
canAccept: GetComputableTypeWithDefault<T["canAccept"], false>;
|
|
||||||
progress: GetComputableType<T["progress"]>;
|
|
||||||
progressDisplay: GetComputableTypeWithDefault<T["progressDisplay"], ProgressDisplay.Fill>;
|
|
||||||
progressColor: GetComputableTypeWithDefault<T["progressColor"], "none">;
|
|
||||||
fillColor: GetComputableType<T["fillColor"]>;
|
|
||||||
outlineColor: GetComputableType<T["outlineColor"]>;
|
|
||||||
titleColor: GetComputableType<T["titleColor"]>;
|
|
||||||
actions?: GenericBoardNodeAction[];
|
|
||||||
actionDistance: GetComputableTypeWithDefault<T["actionDistance"], number>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** A type that matches any valid {@link NodeType} object. */
|
|
||||||
export type GenericNodeType = Replace<
|
|
||||||
NodeType<NodeTypeOptions>,
|
|
||||||
{
|
|
||||||
size: NodeComputable<number>;
|
|
||||||
draggable: NodeComputable<boolean>;
|
|
||||||
shape: NodeComputable<Shape>;
|
|
||||||
canAccept: NodeComputable<boolean, [BoardNode]>;
|
|
||||||
progressDisplay: NodeComputable<ProgressDisplay>;
|
|
||||||
progressColor: NodeComputable<string>;
|
|
||||||
actionDistance: NodeComputable<number>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An object that configures a {@link BoardNodeAction}.
|
|
||||||
*/
|
|
||||||
export interface BoardNodeActionOptions {
|
|
||||||
/** A unique identifier for the action. */
|
|
||||||
id: string;
|
|
||||||
/** Whether this action should be visible. */
|
|
||||||
visibility?: NodeComputable<Visibility | boolean>;
|
|
||||||
/** The icon to display for the action. */
|
|
||||||
icon: NodeComputable<string>;
|
|
||||||
/** The fill color of the action. */
|
|
||||||
fillColor?: NodeComputable<string>;
|
|
||||||
/** The tooltip text to display for the action. */
|
|
||||||
tooltip: NodeComputable<NodeLabel>;
|
|
||||||
/** The confirmation label that appears under the action. */
|
|
||||||
confirmationLabel?: NodeComputable<NodeLabel>;
|
|
||||||
/** An array of board node links associated with the action. They appear when the action is focused. */
|
|
||||||
links?: NodeComputable<BoardNodeLink[]>;
|
|
||||||
/** A function that is called when the action is clicked. */
|
|
||||||
onClick: (node: BoardNode) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The properties that are added onto a processed {@link BoardNodeActionOptions} to create an {@link BoardNodeAction}.
|
|
||||||
*/
|
|
||||||
export interface BaseBoardNodeAction {
|
|
||||||
links?: Ref<BoardNodeLink[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An object that represents an action that can be taken upon a node. */
|
|
||||||
export type BoardNodeAction<T extends BoardNodeActionOptions> = Replace<
|
|
||||||
T & BaseBoardNodeAction,
|
|
||||||
{
|
|
||||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
|
||||||
icon: GetComputableType<T["icon"]>;
|
|
||||||
fillColor: GetComputableType<T["fillColor"]>;
|
|
||||||
tooltip: GetComputableType<T["tooltip"]>;
|
|
||||||
confirmationLabel: GetComputableTypeWithDefault<T["confirmationLabel"], NodeLabel>;
|
|
||||||
links: GetComputableType<T["links"]>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** A type that matches any valid {@link BoardNodeAction} object. */
|
|
||||||
export type GenericBoardNodeAction = Replace<
|
|
||||||
BoardNodeAction<BoardNodeActionOptions>,
|
|
||||||
{
|
|
||||||
visibility: NodeComputable<Visibility | boolean>;
|
|
||||||
confirmationLabel: NodeComputable<NodeLabel>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An object that configures a {@link Board}.
|
|
||||||
*/
|
|
||||||
export interface BoardOptions {
|
|
||||||
/** Whether this board should be visible. */
|
|
||||||
visibility?: Computable<Visibility | boolean>;
|
|
||||||
/** The height of the board. Defaults to 100% */
|
|
||||||
height?: Computable<string>;
|
|
||||||
/** The width of the board. Defaults to 100% */
|
|
||||||
width?: Computable<string>;
|
|
||||||
/** Dictionary of CSS classes to apply to this feature. */
|
|
||||||
classes?: Computable<Record<string, boolean>>;
|
|
||||||
/** CSS to apply to this feature. */
|
|
||||||
style?: Computable<StyleValue>;
|
|
||||||
/** A function that returns an array of initial board nodes, without IDs. */
|
|
||||||
startNodes: () => Omit<BoardNode, "id">[];
|
|
||||||
/** A dictionary of node types that can appear on the board. */
|
|
||||||
types: Record<string, NodeTypeOptions>;
|
|
||||||
/** The persistent state of the board. */
|
|
||||||
state?: Computable<BoardData>;
|
|
||||||
/** An array of board node links to display. */
|
|
||||||
links?: Computable<BoardNodeLink[] | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The properties that are added onto a processed {@link BoardOptions} to create a {@link Board}.
|
|
||||||
*/
|
|
||||||
export interface BaseBoard {
|
|
||||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
|
||||||
id: string;
|
|
||||||
/** All the nodes currently on the board. */
|
|
||||||
nodes: Ref<BoardNode[]>;
|
|
||||||
/** The currently selected node, if any. */
|
|
||||||
selectedNode: Ref<BoardNode | null>;
|
|
||||||
/** The currently selected action, if any. */
|
|
||||||
selectedAction: Ref<GenericBoardNodeAction | null>;
|
|
||||||
/** The currently being dragged node, if any. */
|
|
||||||
draggingNode: Ref<BoardNode | null>;
|
|
||||||
/** If dragging a node, the node it's currently being hovered over, if any. */
|
|
||||||
receivingNode: Ref<BoardNode | null>;
|
|
||||||
/** The current mouse position, if over the board. */
|
|
||||||
mousePosition: Ref<{ x: number; y: number } | null>;
|
|
||||||
/** Places a node in the nearest empty space in the given direction with the specified space around it. */
|
|
||||||
placeInAvailableSpace: (node: BoardNode, radius?: number, direction?: Direction) => void;
|
|
||||||
/** A symbol that helps identify features of the same type. */
|
|
||||||
type: typeof BoardType;
|
|
||||||
/** The Vue component used to render this feature. */
|
|
||||||
[Component]: GenericComponent;
|
|
||||||
/** A function to gather the props the vue component requires for this feature. */
|
|
||||||
[GatherProps]: () => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An object that represents a feature that is a zoomable, pannable board with various nodes upon it. */
|
|
||||||
export type Board<T extends BoardOptions> = Replace<
|
|
||||||
T & BaseBoard,
|
|
||||||
{
|
|
||||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
|
||||||
types: Record<string, GenericNodeType>;
|
|
||||||
height: GetComputableType<T["height"]>;
|
|
||||||
width: GetComputableType<T["width"]>;
|
|
||||||
classes: GetComputableType<T["classes"]>;
|
|
||||||
style: GetComputableType<T["style"]>;
|
|
||||||
state: GetComputableTypeWithDefault<T["state"], Persistent<BoardData>>;
|
|
||||||
links: GetComputableTypeWithDefault<T["links"], Ref<BoardNodeLink[] | null>>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** A type that matches any valid {@link Board} object. */
|
|
||||||
export type GenericBoard = Replace<
|
|
||||||
Board<BoardOptions>,
|
|
||||||
{
|
|
||||||
visibility: ProcessedComputable<Visibility | boolean>;
|
|
||||||
state: ProcessedComputable<BoardData>;
|
|
||||||
links: ProcessedComputable<BoardNodeLink[] | null>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lazily creates a board with the given options.
|
|
||||||
* @param optionsFunc Board options.
|
|
||||||
*/
|
|
||||||
export function createBoard<T extends BoardOptions>(
|
|
||||||
optionsFunc: OptionsFunc<T, BaseBoard, GenericBoard>
|
|
||||||
): Board<T> {
|
|
||||||
const state = persistent<BoardData>(
|
|
||||||
{
|
|
||||||
nodes: [],
|
|
||||||
selectedNode: null,
|
|
||||||
selectedAction: null
|
|
||||||
},
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
return createLazyProxy(feature => {
|
|
||||||
const board = optionsFunc.call(feature, feature);
|
|
||||||
board.id = getUniqueID("board-");
|
|
||||||
board.type = BoardType;
|
|
||||||
board[Component] = BoardComponent as GenericComponent;
|
|
||||||
|
|
||||||
if (board.state) {
|
|
||||||
deletePersistent(state);
|
|
||||||
processComputable(board as T, "state");
|
|
||||||
} else {
|
|
||||||
state[DefaultValue] = {
|
|
||||||
nodes: board.startNodes().map((n, i) => {
|
|
||||||
(n as BoardNode).id = i;
|
|
||||||
return n as BoardNode;
|
|
||||||
}),
|
|
||||||
selectedNode: null,
|
|
||||||
selectedAction: null
|
|
||||||
};
|
|
||||||
board.state = state;
|
|
||||||
}
|
|
||||||
|
|
||||||
board.nodes = computed(() => unref(processedBoard.state).nodes);
|
|
||||||
board.selectedNode = computed({
|
|
||||||
get() {
|
|
||||||
return (
|
|
||||||
processedBoard.nodes.value.find(
|
|
||||||
node => node.id === unref(processedBoard.state).selectedNode
|
|
||||||
) || null
|
|
||||||
);
|
|
||||||
},
|
|
||||||
set(node) {
|
|
||||||
if (isRef(processedBoard.state)) {
|
|
||||||
processedBoard.state.value = {
|
|
||||||
...processedBoard.state.value,
|
|
||||||
selectedNode: node?.id ?? null
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
processedBoard.state.selectedNode = node?.id ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
board.selectedAction = computed({
|
|
||||||
get() {
|
|
||||||
const selectedNode = processedBoard.selectedNode.value;
|
|
||||||
if (selectedNode == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const type = processedBoard.types[selectedNode.type];
|
|
||||||
if (type.actions == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
type.actions.find(
|
|
||||||
action => action.id === unref(processedBoard.state).selectedAction
|
|
||||||
) || null
|
|
||||||
);
|
|
||||||
},
|
|
||||||
set(action) {
|
|
||||||
if (isRef(processedBoard.state)) {
|
|
||||||
processedBoard.state.value = {
|
|
||||||
...processedBoard.state.value,
|
|
||||||
selectedAction: action?.id ?? null
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
processedBoard.state.selectedAction = action?.id ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
board.mousePosition = ref(null);
|
|
||||||
if (board.links) {
|
|
||||||
processComputable(board as T, "links");
|
|
||||||
} else {
|
|
||||||
board.links = computed(() => {
|
|
||||||
if (processedBoard.selectedAction.value == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
processedBoard.selectedAction.value.links &&
|
|
||||||
processedBoard.selectedNode.value
|
|
||||||
) {
|
|
||||||
return getNodeProperty(
|
|
||||||
processedBoard.selectedAction.value.links,
|
|
||||||
processedBoard.selectedNode.value
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
board.draggingNode = ref(null);
|
|
||||||
board.receivingNode = ref(null);
|
|
||||||
processComputable(board as T, "visibility");
|
|
||||||
setDefault(board, "visibility", Visibility.Visible);
|
|
||||||
processComputable(board as T, "width");
|
|
||||||
setDefault(board, "width", "100%");
|
|
||||||
processComputable(board as T, "height");
|
|
||||||
setDefault(board, "height", "100%");
|
|
||||||
processComputable(board as T, "classes");
|
|
||||||
processComputable(board as T, "style");
|
|
||||||
|
|
||||||
for (const type in board.types) {
|
|
||||||
const nodeType: NodeTypeOptions & Partial<BaseNodeType> = board.types[type];
|
|
||||||
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "title");
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "label");
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "size");
|
|
||||||
setDefault(nodeType, "size", 50);
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "style");
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "classes");
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "draggable");
|
|
||||||
setDefault(nodeType, "draggable", false);
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "shape");
|
|
||||||
setDefault(nodeType, "shape", Shape.Circle);
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "canAccept");
|
|
||||||
setDefault(nodeType, "canAccept", false);
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "progress");
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "progressDisplay");
|
|
||||||
setDefault(nodeType, "progressDisplay", ProgressDisplay.Fill);
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "progressColor");
|
|
||||||
setDefault(nodeType, "progressColor", "none");
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "fillColor");
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "outlineColor");
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "titleColor");
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "actionDistance");
|
|
||||||
setDefault(nodeType, "actionDistance", Math.PI / 6);
|
|
||||||
nodeType.nodes = computed(() =>
|
|
||||||
unref(processedBoard.state).nodes.filter(node => node.type === type)
|
|
||||||
);
|
|
||||||
setDefault(nodeType, "onClick", function (node: BoardNode) {
|
|
||||||
unref(processedBoard.state).selectedNode = node.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (nodeType.actions) {
|
|
||||||
for (const action of nodeType.actions) {
|
|
||||||
processComputable(action, "visibility");
|
|
||||||
setDefault(action, "visibility", Visibility.Visible);
|
|
||||||
processComputable(action, "icon");
|
|
||||||
processComputable(action, "fillColor");
|
|
||||||
processComputable(action, "tooltip");
|
|
||||||
processComputable(action, "confirmationLabel");
|
|
||||||
setDefault(action, "confirmationLabel", { text: "Tap again to confirm" });
|
|
||||||
processComputable(action, "links");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setDraggingNode(node: BoardNode | null) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
board.draggingNode!.value = node;
|
|
||||||
}
|
|
||||||
function setReceivingNode(node: BoardNode | null) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
board.receivingNode!.value = node;
|
|
||||||
}
|
|
||||||
|
|
||||||
board.placeInAvailableSpace = function (
|
|
||||||
node: BoardNode,
|
|
||||||
radius = 100,
|
|
||||||
direction = Direction.Right
|
|
||||||
) {
|
|
||||||
const nodes = processedBoard.nodes.value
|
|
||||||
.slice()
|
|
||||||
.filter(n => {
|
|
||||||
// Exclude self
|
|
||||||
if (n === node) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exclude nodes that aren't within the corridor we'll be moving within
|
|
||||||
if (
|
|
||||||
(direction === Direction.Down || direction === Direction.Up) &&
|
|
||||||
Math.abs(n.position.x - node.position.x) > radius
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
(direction === Direction.Left || direction === Direction.Right) &&
|
|
||||||
Math.abs(n.position.y - node.position.y) > radius
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exclude nodes in the wrong direction
|
|
||||||
return !(
|
|
||||||
(direction === Direction.Right &&
|
|
||||||
n.position.x < node.position.x - radius) ||
|
|
||||||
(direction === Direction.Left && n.position.x > node.position.x + radius) ||
|
|
||||||
(direction === Direction.Up && n.position.y > node.position.y + radius) ||
|
|
||||||
(direction === Direction.Down && n.position.y < node.position.y - radius)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.sort(
|
|
||||||
direction === Direction.Right
|
|
||||||
? (a, b) => a.position.x - b.position.x
|
|
||||||
: direction === Direction.Left
|
|
||||||
? (a, b) => b.position.x - a.position.x
|
|
||||||
: direction === Direction.Up
|
|
||||||
? (a, b) => b.position.y - a.position.y
|
|
||||||
: (a, b) => a.position.y - b.position.y
|
|
||||||
);
|
|
||||||
for (let i = 0; i < nodes.length; i++) {
|
|
||||||
const nodeToCheck = nodes[i];
|
|
||||||
const distance =
|
|
||||||
direction === Direction.Right || direction === Direction.Left
|
|
||||||
? Math.abs(node.position.x - nodeToCheck.position.x)
|
|
||||||
: Math.abs(node.position.y - nodeToCheck.position.y);
|
|
||||||
|
|
||||||
// If we're too close to this node, move further
|
|
||||||
if (distance < radius) {
|
|
||||||
if (direction === Direction.Right) {
|
|
||||||
node.position.x = nodeToCheck.position.x + radius;
|
|
||||||
} else if (direction === Direction.Left) {
|
|
||||||
node.position.x = nodeToCheck.position.x - radius;
|
|
||||||
} else if (direction === Direction.Up) {
|
|
||||||
node.position.y = nodeToCheck.position.y - radius;
|
|
||||||
} else if (direction === Direction.Down) {
|
|
||||||
node.position.y = nodeToCheck.position.y + radius;
|
|
||||||
}
|
|
||||||
} else if (i > 0 && distance > radius) {
|
|
||||||
// If we're further from this node than the radius, then the nodes are past us and we can early exit
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
board[GatherProps] = function (this: GenericBoard) {
|
|
||||||
const {
|
|
||||||
nodes,
|
|
||||||
types,
|
|
||||||
state,
|
|
||||||
visibility,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
style,
|
|
||||||
classes,
|
|
||||||
links,
|
|
||||||
selectedAction,
|
|
||||||
selectedNode,
|
|
||||||
mousePosition,
|
|
||||||
draggingNode,
|
|
||||||
receivingNode
|
|
||||||
} = this;
|
|
||||||
return {
|
|
||||||
nodes,
|
|
||||||
types,
|
|
||||||
state,
|
|
||||||
visibility,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
style: unref(style),
|
|
||||||
classes,
|
|
||||||
links,
|
|
||||||
selectedAction,
|
|
||||||
selectedNode,
|
|
||||||
mousePosition,
|
|
||||||
draggingNode,
|
|
||||||
receivingNode,
|
|
||||||
setDraggingNode,
|
|
||||||
setReceivingNode
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// This is necessary because board.types is different from T and Board
|
|
||||||
const processedBoard = board as unknown as Board<T>;
|
|
||||||
return processedBoard;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the value of a property for a specified node.
|
|
||||||
* @param property The property to find the value of
|
|
||||||
* @param node The node to get the property of
|
|
||||||
*/
|
|
||||||
export function getNodeProperty<T, S extends unknown[]>(
|
|
||||||
property: NodeComputable<T, S>,
|
|
||||||
node: BoardNode,
|
|
||||||
...args: S
|
|
||||||
): T {
|
|
||||||
return isFunction<T, [BoardNode, ...S], Computable<T>>(property)
|
|
||||||
? property(node, ...args)
|
|
||||||
: unref(property);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility to get an ID for a node that is guaranteed unique.
|
|
||||||
* @param board The board feature to generate an ID for
|
|
||||||
*/
|
|
||||||
export function getUniqueNodeID(board: GenericBoard): number {
|
|
||||||
let id = 0;
|
|
||||||
board.nodes.value.forEach(node => {
|
|
||||||
if (node.id >= id) {
|
|
||||||
id = node.id + 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const listeners: Record<string, Unsubscribe | undefined> = {};
|
|
||||||
globalBus.on("addLayer", layer => {
|
|
||||||
const boards: GenericBoard[] = findFeatures(layer, BoardType) as GenericBoard[];
|
|
||||||
listeners[layer.id] = layer.on("postUpdate", diff => {
|
|
||||||
boards.forEach(board => {
|
|
||||||
Object.values(board.types).forEach(type =>
|
|
||||||
type.nodes.value.forEach(node => type.update?.(node, diff))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
globalBus.on("removeLayer", layer => {
|
|
||||||
// unsubscribe from postUpdate
|
|
||||||
listeners[layer.id]?.();
|
|
||||||
listeners[layer.id] = undefined;
|
|
||||||
});
|
|
|
@ -1,20 +1,11 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="isVisible(visibility)"
|
:style="notifyStyle"
|
||||||
:style="[
|
|
||||||
{
|
|
||||||
visibility: isHidden(visibility) ? 'hidden' : undefined
|
|
||||||
},
|
|
||||||
notifyStyle,
|
|
||||||
unref(style) ?? {}
|
|
||||||
]"
|
|
||||||
:class="{
|
:class="{
|
||||||
feature: true,
|
|
||||||
challenge: true,
|
challenge: true,
|
||||||
done: unref(completed),
|
done: unref(completed),
|
||||||
canStart: unref(canStart) && !unref(maxed),
|
canStart: unref(canStart) && !unref(maxed),
|
||||||
maxed: unref(maxed),
|
maxed: unref(maxed)
|
||||||
...unref(classes)
|
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
@ -24,155 +15,80 @@
|
||||||
>
|
>
|
||||||
{{ buttonText }}
|
{{ buttonText }}
|
||||||
</button>
|
</button>
|
||||||
<component v-if="unref(comp)" :is="unref(comp)" />
|
<Component v-if="props.display" />
|
||||||
<MarkNode :mark="unref(mark)" />
|
|
||||||
<Node :id="id" />
|
|
||||||
</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 type { GenericChallenge } from "features/challenges/challenge";
|
|
||||||
import type { StyleValue } 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 } from "game/requirements";
|
||||||
import { coerceComponent, isCoercableComponent, processedPropType, unwrapRef } from "util/vue";
|
import { render } from "util/vue";
|
||||||
import type { Component, PropType, UnwrapRef } from "vue";
|
import type { Component } from "vue";
|
||||||
import { computed, defineComponent, shallowRef, toRefs, unref, watchEffect } from "vue";
|
import { computed, unref } from "vue";
|
||||||
|
import { Challenge } from "./challenge";
|
||||||
|
|
||||||
export default defineComponent({
|
const props = defineProps<{
|
||||||
props: {
|
active: Challenge["active"];
|
||||||
active: {
|
maxed: Challenge["maxed"];
|
||||||
type: processedPropType<boolean>(Boolean),
|
canComplete: Challenge["canComplete"];
|
||||||
required: true
|
display: Challenge["display"];
|
||||||
},
|
requirements: Challenge["requirements"];
|
||||||
maxed: {
|
completed: Challenge["completed"];
|
||||||
type: processedPropType<boolean>(Boolean),
|
canStart: Challenge["canStart"];
|
||||||
required: true
|
toggle: Challenge["toggle"];
|
||||||
},
|
}>();
|
||||||
canComplete: {
|
|
||||||
type: processedPropType<boolean>(Boolean),
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
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 (unref(props.active)) {
|
||||||
return canComplete.value ? "Finish" : "Exit Early";
|
return unref(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 (unref(props.maxed)) {
|
||||||
|
return "Completed";
|
||||||
|
}
|
||||||
|
return "Start";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const notifyStyle = computed(() => {
|
||||||
|
const currActive = unref(props.active);
|
||||||
|
const currCanComplete = unref(props.canComplete);
|
||||||
|
if (currActive) {
|
||||||
|
if (currCanComplete) {
|
||||||
|
return getHighNotifyStyle();
|
||||||
|
}
|
||||||
|
return getNotifyStyle();
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const Component = () => {
|
||||||
|
if (props.display == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof props.display === "object" && "description" in props.display) {
|
||||||
|
const { title, description, goal, reward, effectDisplay } = props.display;
|
||||||
|
return <span>
|
||||||
|
{title != null ? (<div>{render(title, el => <h3>{el}</h3>)}</div>) : null}
|
||||||
|
{render(description, el => <div>{el}</div>)}
|
||||||
|
<div>
|
||||||
|
<br />
|
||||||
|
Goal: {goal == null ? displayRequirements(props.requirements) : render(goal, el => <h3>{el}</h3>)}
|
||||||
|
</div>
|
||||||
|
{reward != null ? (
|
||||||
|
<div>
|
||||||
|
<br />
|
||||||
|
Reward: {render(reward)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{effectDisplay != null ? (
|
||||||
|
<div>
|
||||||
|
Currently: {render(effectDisplay)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</span>;
|
||||||
|
}
|
||||||
|
return render(props.display);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
@ -1,24 +1,6 @@
|
||||||
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 { isVisible } from "features/feature";
|
||||||
import { GenericDecorator } from "features/decorators/common";
|
import type { Reset } from "features/reset";
|
||||||
import type {
|
|
||||||
CoercableComponent,
|
|
||||||
GenericComponent,
|
|
||||||
OptionsFunc,
|
|
||||||
Replace,
|
|
||||||
StyleValue
|
|
||||||
} from "features/feature";
|
|
||||||
import {
|
|
||||||
Component,
|
|
||||||
GatherProps,
|
|
||||||
Visibility,
|
|
||||||
getUniqueID,
|
|
||||||
isVisible,
|
|
||||||
jsx,
|
|
||||||
setDefault
|
|
||||||
} from "features/feature";
|
|
||||||
import type { GenericReset } from "features/reset";
|
|
||||||
import { globalBus } from "game/events";
|
import { globalBus } from "game/events";
|
||||||
import type { Persistent } from "game/persistence";
|
import type { Persistent } from "game/persistence";
|
||||||
import { persistent } from "game/persistence";
|
import { persistent } from "game/persistence";
|
||||||
|
@ -26,16 +8,12 @@ import { Requirements, maxRequirementsMet } from "game/requirements";
|
||||||
import settings, { registerSettingField } from "game/settings";
|
import settings, { registerSettingField } from "game/settings";
|
||||||
import type { DecimalSource } from "util/bignum";
|
import type { DecimalSource } from "util/bignum";
|
||||||
import Decimal from "util/bignum";
|
import Decimal from "util/bignum";
|
||||||
import type {
|
import { processGetter } from "util/computed";
|
||||||
Computable,
|
|
||||||
GetComputableType,
|
|
||||||
GetComputableTypeWithDefault,
|
|
||||||
ProcessedComputable
|
|
||||||
} from "util/computed";
|
|
||||||
import { processComputable } from "util/computed";
|
|
||||||
import { createLazyProxy } from "util/proxies";
|
import { createLazyProxy } from "util/proxies";
|
||||||
import type { Ref, WatchStopHandle } from "vue";
|
import { Renderable, VueFeature, VueFeatureOptions, vueFeatureMixin } from "util/vue";
|
||||||
|
import type { MaybeRef, MaybeRefOrGetter, Ref, WatchStopHandle } from "vue";
|
||||||
import { computed, unref, watch } from "vue";
|
import { computed, unref, watch } from "vue";
|
||||||
|
import Challenge from "./Challenge.vue";
|
||||||
|
|
||||||
/** A symbol used to identify {@link Challenge} features. */
|
/** A symbol used to identify {@link Challenge} features. */
|
||||||
export const ChallengeType = Symbol("Challenge");
|
export const ChallengeType = Symbol("Challenge");
|
||||||
|
@ -43,39 +21,30 @@ export const ChallengeType = Symbol("Challenge");
|
||||||
/**
|
/**
|
||||||
* An object that configures a {@link Challenge}.
|
* An object that configures a {@link Challenge}.
|
||||||
*/
|
*/
|
||||||
export interface ChallengeOptions {
|
export interface ChallengeOptions extends VueFeatureOptions {
|
||||||
/** Whether this challenge should be visible. */
|
|
||||||
visibility?: Computable<Visibility | boolean>;
|
|
||||||
/** Whether this challenge can be started. */
|
/** Whether this challenge can be started. */
|
||||||
canStart?: Computable<boolean>;
|
canStart?: MaybeRefOrGetter<boolean>;
|
||||||
/** The reset function for this challenge. */
|
/** The reset function for this challenge. */
|
||||||
reset?: GenericReset;
|
reset?: Reset;
|
||||||
/** The requirement(s) to complete this challenge. */
|
/** The requirement(s) to complete this challenge. */
|
||||||
requirements: Requirements;
|
requirements: Requirements;
|
||||||
/** The maximum number of times the challenge can be completed. */
|
/** The maximum number of times the challenge can be completed. */
|
||||||
completionLimit?: Computable<DecimalSource>;
|
completionLimit?: MaybeRefOrGetter<DecimalSource>;
|
||||||
/** Shows a marker on the corner of the feature. */
|
|
||||||
mark?: Computable<boolean | string>;
|
|
||||||
/** Dictionary of CSS classes to apply to this feature. */
|
|
||||||
classes?: Computable<Record<string, boolean>>;
|
|
||||||
/** CSS to apply to this feature. */
|
|
||||||
style?: Computable<StyleValue>;
|
|
||||||
/** The display to use for this challenge. */
|
/** The display to use for this challenge. */
|
||||||
display?: Computable<
|
display?:
|
||||||
| CoercableComponent
|
| MaybeRefOrGetter<Renderable>
|
||||||
| {
|
| {
|
||||||
/** A header to appear at the top of the display. */
|
/** A header to appear at the top of the display. */
|
||||||
title?: CoercableComponent;
|
title?: MaybeRefOrGetter<Renderable>;
|
||||||
/** The main text that appears in the display. */
|
/** The main text that appears in the display. */
|
||||||
description: CoercableComponent;
|
description: MaybeRefOrGetter<Renderable>;
|
||||||
/** A description of the current goal for this challenge. If unspecified then the requirements will be displayed automatically based on {@link requirements}. */
|
/** A description of the current goal for this challenge. If unspecified then the requirements will be displayed automatically based on {@link requirements}. */
|
||||||
goal?: CoercableComponent;
|
goal?: MaybeRefOrGetter<Renderable>;
|
||||||
/** A description of what will change upon completing this challenge. */
|
/** A description of what will change upon completing this challenge. */
|
||||||
reward?: CoercableComponent;
|
reward?: MaybeRefOrGetter<Renderable>;
|
||||||
/** A description of the current effect of this challenge. */
|
/** A description of the current effect of this challenge. */
|
||||||
effectDisplay?: CoercableComponent;
|
effectDisplay?: MaybeRefOrGetter<Renderable>;
|
||||||
}
|
};
|
||||||
>;
|
|
||||||
/** A function that is called when the challenge is completed. */
|
/** A function that is called when the challenge is completed. */
|
||||||
onComplete?: VoidFunction;
|
onComplete?: VoidFunction;
|
||||||
/** A function that is called when the challenge is exited. */
|
/** A function that is called when the challenge is exited. */
|
||||||
|
@ -84,12 +53,37 @@ export interface ChallengeOptions {
|
||||||
onEnter?: VoidFunction;
|
onEnter?: VoidFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** An object that represents a feature that can be entered and exited, and have one or more completions with scaling requirements. */
|
||||||
* The properties that are added onto a processed {@link ChallengeOptions} to create a {@link Challenge}.
|
export interface Challenge extends VueFeature {
|
||||||
*/
|
/** The reset function for this challenge. */
|
||||||
export interface BaseChallenge {
|
reset?: Reset;
|
||||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
/** The requirement(s) to complete this challenge. */
|
||||||
id: string;
|
requirements: Requirements;
|
||||||
|
/** A function that is called when the challenge is completed. */
|
||||||
|
onComplete?: VoidFunction;
|
||||||
|
/** A function that is called when the challenge is exited. */
|
||||||
|
onExit?: VoidFunction;
|
||||||
|
/** A function that is called when the challenge is entered. */
|
||||||
|
onEnter?: VoidFunction;
|
||||||
|
/** Whether this challenge can be started. */
|
||||||
|
canStart?: MaybeRef<boolean>;
|
||||||
|
/** The maximum number of times the challenge can be completed. */
|
||||||
|
completionLimit?: MaybeRef<DecimalSource>;
|
||||||
|
/** The display to use for this challenge. */
|
||||||
|
display?:
|
||||||
|
| MaybeRef<Renderable>
|
||||||
|
| {
|
||||||
|
/** A header to appear at the top of the display. */
|
||||||
|
title?: MaybeRef<Renderable>;
|
||||||
|
/** The main text that appears in the display. */
|
||||||
|
description: MaybeRef<Renderable>;
|
||||||
|
/** A description of the current goal for this challenge. If unspecified then the requirements will be displayed automatically based on {@link requirements}. */
|
||||||
|
goal?: MaybeRef<Renderable>;
|
||||||
|
/** A description of what will change upon completing this challenge. */
|
||||||
|
reward?: MaybeRef<Renderable>;
|
||||||
|
/** A description of the current effect of this challenge. */
|
||||||
|
effectDisplay?: MaybeRef<Renderable>;
|
||||||
|
};
|
||||||
/** The current amount of times this challenge can be completed. */
|
/** The current amount of times this challenge can be completed. */
|
||||||
canComplete: Ref<DecimalSource>;
|
canComplete: Ref<DecimalSource>;
|
||||||
/** The current number of times this challenge has been completed. */
|
/** The current number of times this challenge has been completed. */
|
||||||
|
@ -109,203 +103,128 @@ export interface BaseChallenge {
|
||||||
complete: (remainInChallenge?: boolean) => void;
|
complete: (remainInChallenge?: boolean) => void;
|
||||||
/** A symbol that helps identify features of the same type. */
|
/** A symbol that helps identify features of the same type. */
|
||||||
type: typeof ChallengeType;
|
type: typeof ChallengeType;
|
||||||
/** The Vue component used to render this feature. */
|
|
||||||
[Component]: GenericComponent;
|
|
||||||
/** A function to gather the props the vue component requires for this feature. */
|
|
||||||
[GatherProps]: () => Record<string, unknown>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An object that represents a feature that can be entered and exited, and have one or more completions with scaling requirements. */
|
|
||||||
export type Challenge<T extends ChallengeOptions> = Replace<
|
|
||||||
T & BaseChallenge,
|
|
||||||
{
|
|
||||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
|
||||||
canStart: GetComputableTypeWithDefault<T["canStart"], true>;
|
|
||||||
requirements: GetComputableType<T["requirements"]>;
|
|
||||||
completionLimit: GetComputableTypeWithDefault<T["completionLimit"], 1>;
|
|
||||||
mark: GetComputableTypeWithDefault<T["mark"], Ref<boolean>>;
|
|
||||||
classes: GetComputableType<T["classes"]>;
|
|
||||||
style: GetComputableType<T["style"]>;
|
|
||||||
display: GetComputableType<T["display"]>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** A type that matches any valid {@link Challenge} object. */
|
|
||||||
export type GenericChallenge = Replace<
|
|
||||||
Challenge<ChallengeOptions>,
|
|
||||||
{
|
|
||||||
visibility: ProcessedComputable<Visibility | boolean>;
|
|
||||||
canStart: ProcessedComputable<boolean>;
|
|
||||||
completionLimit: ProcessedComputable<DecimalSource>;
|
|
||||||
mark: ProcessedComputable<boolean>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lazily creates a challenge with the given options.
|
* Lazily creates a challenge with the given options.
|
||||||
* @param optionsFunc Challenge options.
|
* @param optionsFunc Challenge options.
|
||||||
*/
|
*/
|
||||||
export function createChallenge<T extends ChallengeOptions>(
|
export function createChallenge<T extends ChallengeOptions>(optionsFunc: () => T) {
|
||||||
optionsFunc: OptionsFunc<T, BaseChallenge, GenericChallenge>,
|
const completions = persistent<DecimalSource>(0);
|
||||||
...decorators: GenericDecorator[]
|
const active = persistent<boolean>(false, false);
|
||||||
): Challenge<T> {
|
return createLazyProxy(() => {
|
||||||
const completions = persistent(0);
|
const options = optionsFunc();
|
||||||
const active = persistent(false, false);
|
const {
|
||||||
const decoratedData = decorators.reduce(
|
requirements,
|
||||||
(current, next) => Object.assign(current, next.getPersistentData?.()),
|
canStart,
|
||||||
{}
|
completionLimit,
|
||||||
);
|
display,
|
||||||
return createLazyProxy(feature => {
|
reset,
|
||||||
const challenge = optionsFunc.call(feature, feature);
|
onComplete,
|
||||||
|
onEnter,
|
||||||
|
onExit,
|
||||||
|
...props
|
||||||
|
} = options;
|
||||||
|
|
||||||
challenge.id = getUniqueID("challenge-");
|
const vueFeature = vueFeatureMixin("challenge", options, () => (
|
||||||
challenge.type = ChallengeType;
|
<Challenge
|
||||||
challenge[Component] = ChallengeComponent as GenericComponent;
|
active={challenge.active}
|
||||||
|
maxed={challenge.maxed}
|
||||||
|
canComplete={challenge.canComplete}
|
||||||
|
display={challenge.display}
|
||||||
|
requirements={challenge.requirements}
|
||||||
|
completed={challenge.completed}
|
||||||
|
canStart={challenge.canStart}
|
||||||
|
toggle={challenge.toggle}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
for (const decorator of decorators) {
|
const challenge = {
|
||||||
decorator.preConstruct?.(challenge);
|
type: ChallengeType,
|
||||||
}
|
...(props as Omit<typeof props, keyof VueFeature | keyof ChallengeOptions>),
|
||||||
|
...vueFeature,
|
||||||
challenge.completions = completions;
|
completions,
|
||||||
challenge.active = active;
|
active,
|
||||||
Object.assign(challenge, decoratedData);
|
completed: computed(() => Decimal.gt(completions.value, 0)),
|
||||||
|
canComplete: computed(() => maxRequirementsMet(requirements)),
|
||||||
challenge.completed = computed(() =>
|
maxed: computed((): boolean =>
|
||||||
Decimal.gt((challenge as GenericChallenge).completions.value, 0)
|
Decimal.gte(completions.value, unref(challenge.completionLimit))
|
||||||
);
|
),
|
||||||
challenge.maxed = computed(() =>
|
canStart: processGetter(canStart) ?? true,
|
||||||
Decimal.gte(
|
completionLimit: processGetter(completionLimit) ?? 1,
|
||||||
(challenge as GenericChallenge).completions.value,
|
requirements,
|
||||||
unref((challenge as GenericChallenge).completionLimit)
|
reset,
|
||||||
)
|
onComplete,
|
||||||
);
|
onEnter,
|
||||||
challenge.toggle = function () {
|
onExit,
|
||||||
const genericChallenge = challenge as GenericChallenge;
|
display:
|
||||||
if (genericChallenge.active.value) {
|
display == null
|
||||||
if (
|
? undefined
|
||||||
Decimal.gt(unref(genericChallenge.canComplete), 0) &&
|
: typeof display === "object" && "description" in display
|
||||||
!genericChallenge.maxed.value
|
? {
|
||||||
|
title: processGetter(display.title),
|
||||||
|
description: processGetter(display.description),
|
||||||
|
goal: processGetter(display.goal),
|
||||||
|
reward: processGetter(display.reward),
|
||||||
|
effectDisplay: processGetter(display.effectDisplay)
|
||||||
|
}
|
||||||
|
: processGetter(display),
|
||||||
|
toggle: function () {
|
||||||
|
if (active.value) {
|
||||||
|
if (
|
||||||
|
Decimal.gt(unref(challenge.canComplete), 0) &&
|
||||||
|
!unref<boolean>(challenge.maxed)
|
||||||
|
) {
|
||||||
|
const newCompletions = unref(challenge.canComplete);
|
||||||
|
completions.value = Decimal.min(
|
||||||
|
Decimal.add(challenge.completions.value, newCompletions),
|
||||||
|
unref(challenge.completionLimit)
|
||||||
|
);
|
||||||
|
onComplete?.();
|
||||||
|
}
|
||||||
|
active.value = false;
|
||||||
|
onExit?.();
|
||||||
|
reset?.reset();
|
||||||
|
} else if (
|
||||||
|
unref<boolean>(challenge.canStart) &&
|
||||||
|
isVisible(unref(challenge.visibility) ?? true) &&
|
||||||
|
!unref<boolean>(challenge.maxed)
|
||||||
) {
|
) {
|
||||||
const completions = unref(genericChallenge.canComplete);
|
challenge.reset?.reset();
|
||||||
genericChallenge.completions.value = Decimal.min(
|
active.value = true;
|
||||||
Decimal.add(genericChallenge.completions.value, completions),
|
onEnter?.();
|
||||||
unref(genericChallenge.completionLimit)
|
}
|
||||||
|
},
|
||||||
|
complete: function (remainInChallenge?: boolean) {
|
||||||
|
const newCompletions = unref(challenge.canComplete);
|
||||||
|
if (
|
||||||
|
active.value &&
|
||||||
|
Decimal.gt(newCompletions, 0) &&
|
||||||
|
!unref<boolean>(challenge.maxed)
|
||||||
|
) {
|
||||||
|
completions.value = Decimal.min(
|
||||||
|
Decimal.add(challenge.completions.value, newCompletions),
|
||||||
|
unref(challenge.completionLimit)
|
||||||
);
|
);
|
||||||
genericChallenge.onComplete?.();
|
onComplete?.();
|
||||||
}
|
if (remainInChallenge !== true) {
|
||||||
genericChallenge.active.value = false;
|
active.value = false;
|
||||||
genericChallenge.onExit?.();
|
onExit?.();
|
||||||
genericChallenge.reset?.reset();
|
reset?.reset();
|
||||||
} else if (
|
}
|
||||||
unref(genericChallenge.canStart) &&
|
|
||||||
isVisible(genericChallenge.visibility) &&
|
|
||||||
!genericChallenge.maxed.value
|
|
||||||
) {
|
|
||||||
genericChallenge.reset?.reset();
|
|
||||||
genericChallenge.active.value = true;
|
|
||||||
genericChallenge.onEnter?.();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
challenge.canComplete = computed(() =>
|
|
||||||
maxRequirementsMet((challenge as GenericChallenge).requirements)
|
|
||||||
);
|
|
||||||
challenge.complete = function (remainInChallenge?: boolean) {
|
|
||||||
const genericChallenge = challenge as GenericChallenge;
|
|
||||||
const completions = unref(genericChallenge.canComplete);
|
|
||||||
if (
|
|
||||||
genericChallenge.active.value &&
|
|
||||||
Decimal.gt(completions, 0) &&
|
|
||||||
!genericChallenge.maxed.value
|
|
||||||
) {
|
|
||||||
genericChallenge.completions.value = Decimal.min(
|
|
||||||
Decimal.add(genericChallenge.completions.value, completions),
|
|
||||||
unref(genericChallenge.completionLimit)
|
|
||||||
);
|
|
||||||
genericChallenge.onComplete?.();
|
|
||||||
if (remainInChallenge !== true) {
|
|
||||||
genericChallenge.active.value = false;
|
|
||||||
genericChallenge.onExit?.();
|
|
||||||
genericChallenge.reset?.reset();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
} satisfies Challenge;
|
||||||
processComputable(challenge as T, "visibility");
|
|
||||||
setDefault(challenge, "visibility", Visibility.Visible);
|
|
||||||
const visibility = challenge.visibility as ProcessedComputable<Visibility | boolean>;
|
|
||||||
challenge.visibility = computed(() => {
|
|
||||||
if (settings.hideChallenges === true && unref(challenge.maxed)) {
|
|
||||||
return Visibility.None;
|
|
||||||
}
|
|
||||||
return unref(visibility);
|
|
||||||
});
|
|
||||||
if (challenge.mark == null) {
|
|
||||||
challenge.mark = computed(
|
|
||||||
() =>
|
|
||||||
Decimal.gt(unref((challenge as GenericChallenge).completionLimit), 1) &&
|
|
||||||
!!unref(challenge.maxed)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
processComputable(challenge as T, "canStart");
|
|
||||||
setDefault(challenge, "canStart", true);
|
|
||||||
processComputable(challenge as T, "completionLimit");
|
|
||||||
setDefault(challenge, "completionLimit", 1);
|
|
||||||
processComputable(challenge as T, "mark");
|
|
||||||
processComputable(challenge as T, "classes");
|
|
||||||
processComputable(challenge as T, "style");
|
|
||||||
processComputable(challenge as T, "display");
|
|
||||||
|
|
||||||
if (challenge.reset != null) {
|
if (challenge.reset != null) {
|
||||||
globalBus.on("reset", currentReset => {
|
globalBus.on("reset", currentReset => {
|
||||||
if (currentReset === challenge.reset && (challenge.active as Ref<boolean>).value) {
|
if (currentReset === challenge.reset && active.value) {
|
||||||
(challenge.toggle as VoidFunction)();
|
challenge.toggle();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const decorator of decorators) {
|
return challenge;
|
||||||
decorator.postConstruct?.(challenge);
|
|
||||||
}
|
|
||||||
|
|
||||||
const decoratedProps = decorators.reduce(
|
|
||||||
(current, next) => Object.assign(current, next.getGatheredProps?.(challenge)),
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
challenge[GatherProps] = function (this: GenericChallenge) {
|
|
||||||
const {
|
|
||||||
active,
|
|
||||||
maxed,
|
|
||||||
canComplete,
|
|
||||||
display,
|
|
||||||
visibility,
|
|
||||||
style,
|
|
||||||
classes,
|
|
||||||
completed,
|
|
||||||
canStart,
|
|
||||||
mark,
|
|
||||||
id,
|
|
||||||
toggle,
|
|
||||||
requirements
|
|
||||||
} = this;
|
|
||||||
return {
|
|
||||||
active,
|
|
||||||
maxed,
|
|
||||||
canComplete,
|
|
||||||
display,
|
|
||||||
visibility,
|
|
||||||
style: unref(style),
|
|
||||||
classes,
|
|
||||||
completed,
|
|
||||||
canStart,
|
|
||||||
mark,
|
|
||||||
id,
|
|
||||||
toggle,
|
|
||||||
requirements,
|
|
||||||
...decoratedProps
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return challenge as unknown as Challenge<T>;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -316,8 +235,8 @@ export function createChallenge<T extends ChallengeOptions>(
|
||||||
* @param exitOnComplete Whether or not to exit the challenge after auto-completion
|
* @param exitOnComplete Whether or not to exit the challenge after auto-completion
|
||||||
*/
|
*/
|
||||||
export function setupAutoComplete(
|
export function setupAutoComplete(
|
||||||
challenge: GenericChallenge,
|
challenge: Challenge,
|
||||||
autoActive: Computable<boolean> = true,
|
autoActive: MaybeRefOrGetter<boolean> = true,
|
||||||
exitOnComplete = true
|
exitOnComplete = true
|
||||||
): WatchStopHandle {
|
): WatchStopHandle {
|
||||||
const isActive = typeof autoActive === "function" ? computed(autoActive) : autoActive;
|
const isActive = typeof autoActive === "function" ? computed(autoActive) : autoActive;
|
||||||
|
@ -335,9 +254,7 @@ export function setupAutoComplete(
|
||||||
* Utility for taking an array of challenges where only one may be active at a time, and giving a ref to the one currently active (or null if none are active)
|
* Utility for taking an array of challenges where only one may be active at a time, and giving a ref to the one currently active (or null if none are active)
|
||||||
* @param challenges The list of challenges that are mutually exclusive
|
* @param challenges The list of challenges that are mutually exclusive
|
||||||
*/
|
*/
|
||||||
export function createActiveChallenge(
|
export function createActiveChallenge(challenges: Challenge[]): Ref<Challenge | null> {
|
||||||
challenges: GenericChallenge[]
|
|
||||||
): Ref<GenericChallenge | null> {
|
|
||||||
return computed(() => challenges.find(challenge => challenge.active.value) ?? null);
|
return computed(() => challenges.find(challenge => challenge.active.value) ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -346,12 +263,12 @@ export function createActiveChallenge(
|
||||||
* @param challenges List of challenges that are mutually exclusive
|
* @param challenges List of challenges that are mutually exclusive
|
||||||
*/
|
*/
|
||||||
export function isAnyChallengeActive(
|
export function isAnyChallengeActive(
|
||||||
challenges: GenericChallenge[] | Ref<GenericChallenge | null>
|
challenges: Challenge[] | Ref<Challenge | 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<Challenge | null>).value != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "game/settings" {
|
declare module "game/settings" {
|
||||||
|
@ -361,18 +278,18 @@ declare module "game/settings" {
|
||||||
}
|
}
|
||||||
|
|
||||||
globalBus.on("loadSettings", settings => {
|
globalBus.on("loadSettings", settings => {
|
||||||
setDefault(settings, "hideChallenges", false);
|
settings.hideChallenges ??= false;
|
||||||
});
|
});
|
||||||
|
|
||||||
registerSettingField(
|
globalBus.on("setupVue", () =>
|
||||||
jsx(() => (
|
registerSettingField(() => (
|
||||||
<Toggle
|
<Toggle
|
||||||
title={jsx(() => (
|
title={
|
||||||
<span class="option-title">
|
<span class="option-title">
|
||||||
Hide maxed challenges
|
Hide maxed challenges
|
||||||
<desc>Hide challenges that have been fully completed.</desc>
|
<desc>Hide challenges that have been fully completed.</desc>
|
||||||
</span>
|
</span>
|
||||||
))}
|
}
|
||||||
onUpdate:modelValue={value => (settings.hideChallenges = value)}
|
onUpdate:modelValue={value => (settings.hideChallenges = value)}
|
||||||
modelValue={settings.hideChallenges}
|
modelValue={settings.hideChallenges}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,10 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
v-if="isVisible(visibility)"
|
|
||||||
:style="[
|
|
||||||
{ visibility: isHidden(visibility) ? 'hidden' : undefined },
|
|
||||||
unref(style) ?? []
|
|
||||||
]"
|
|
||||||
@click="onClick"
|
@click="onClick"
|
||||||
@mousedown="start"
|
@mousedown="start"
|
||||||
@mouseleave="stop"
|
@mouseleave="stop"
|
||||||
|
@ -13,114 +8,36 @@
|
||||||
@touchend.passive="stop"
|
@touchend.passive="stop"
|
||||||
@touchcancel.passive="stop"
|
@touchcancel.passive="stop"
|
||||||
:class="{
|
:class="{
|
||||||
feature: true,
|
|
||||||
clickable: true,
|
clickable: true,
|
||||||
can: unref(canClick),
|
can: unref(canClick),
|
||||||
locked: !unref(canClick),
|
locked: !unref(canClick)
|
||||||
small,
|
|
||||||
...unref(classes)
|
|
||||||
}"
|
}"
|
||||||
|
:disabled="!unref(canClick)"
|
||||||
>
|
>
|
||||||
<component v-if="unref(comp)" :is="unref(comp)" />
|
<Component />
|
||||||
<MarkNode :mark="unref(mark)" />
|
|
||||||
<Node :id="id" />
|
|
||||||
</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 type { Clickable } from "features/clickables/clickable";
|
||||||
import Node from "components/Node.vue";
|
|
||||||
import type { GenericClickable } from "features/clickables/clickable";
|
|
||||||
import type { StyleValue } from "features/feature";
|
|
||||||
import { isHidden, isVisible, jsx, Visibility } from "features/feature";
|
|
||||||
import {
|
import {
|
||||||
coerceComponent,
|
render,
|
||||||
isCoercableComponent,
|
setupHoldToClick
|
||||||
processedPropType,
|
|
||||||
setupHoldToClick,
|
|
||||||
unwrapRef
|
|
||||||
} from "util/vue";
|
} from "util/vue";
|
||||||
import type { Component, PropType, UnwrapRef } from "vue";
|
import type { Component } from "vue";
|
||||||
import { defineComponent, shallowRef, toRefs, unref, watchEffect } from "vue";
|
import { toRef, unref } from "vue";
|
||||||
|
|
||||||
export default defineComponent({
|
const props = defineProps<{
|
||||||
props: {
|
canClick: Clickable["canClick"];
|
||||||
display: {
|
onClick: Clickable["onClick"];
|
||||||
type: processedPropType<UnwrapRef<GenericClickable["display"]>>(
|
onHold?: Clickable["onHold"];
|
||||||
Object,
|
display: Clickable["display"];
|
||||||
String,
|
}>();
|
||||||
Function
|
|
||||||
),
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
visibility: {
|
|
||||||
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 Component = () => props.display == null ? <></> : render(props.display);
|
||||||
|
|
||||||
watchEffect(() => {
|
const { start, stop } = setupHoldToClick(toRef(props, "onClick"), toRef(props, "onHold"));
|
||||||
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");
|
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
@ -130,10 +47,6 @@ export default defineComponent({
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clickable.small {
|
|
||||||
min-height: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clickable > * {
|
.clickable > * {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
183
src/features/clickables/action.tsx
Normal file
183
src/features/clickables/action.tsx
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
import ClickableVue from "features/clickables/Clickable.vue";
|
||||||
|
import { findFeatures } from "features/feature";
|
||||||
|
import { globalBus } from "game/events";
|
||||||
|
import { persistent } from "game/persistence";
|
||||||
|
import Decimal, { DecimalSource } from "lib/break_eternity";
|
||||||
|
import { Unsubscribe } from "nanoevents";
|
||||||
|
import { Direction } from "util/common";
|
||||||
|
import { processGetter } from "util/computed";
|
||||||
|
import { createLazyProxy } from "util/proxies";
|
||||||
|
import { render, Renderable, VueFeature, vueFeatureMixin } from "util/vue";
|
||||||
|
import { computed, MaybeRef, MaybeRefOrGetter, Ref, ref, unref } from "vue";
|
||||||
|
import { JSX } from "vue/jsx-runtime";
|
||||||
|
import { Bar, BarOptions, createBar } from "../bars/bar";
|
||||||
|
import { type Clickable, ClickableOptions } from "./clickable";
|
||||||
|
|
||||||
|
/** A symbol used to identify {@link Action} features. */
|
||||||
|
export const ActionType = Symbol("Action");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object that configures an {@link Action}.
|
||||||
|
*/
|
||||||
|
export interface ActionOptions extends Omit<ClickableOptions, "onClick" | "onHold"> {
|
||||||
|
/** The cooldown during which the action cannot be performed again, in seconds. */
|
||||||
|
duration: MaybeRefOrGetter<DecimalSource>;
|
||||||
|
/** Whether or not the action should perform automatically when the cooldown is finished. */
|
||||||
|
autoStart?: MaybeRefOrGetter<boolean>;
|
||||||
|
/** A function that is called when the action is clicked. */
|
||||||
|
onClick: (amount: DecimalSource) => void;
|
||||||
|
/** A pass-through to the {@link Bar} used to display the cooldown progress for the action. */
|
||||||
|
barOptions?: Partial<BarOptions>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An object that represents a feature that can be clicked upon, and then has a cooldown before it can be clicked again. */
|
||||||
|
export interface Action extends VueFeature {
|
||||||
|
/** The cooldown during which the action cannot be performed again, in seconds. */
|
||||||
|
duration: MaybeRef<DecimalSource>;
|
||||||
|
/** Whether or not the action should perform automatically when the cooldown is finished. */
|
||||||
|
autoStart: MaybeRef<boolean>;
|
||||||
|
/** Whether or not the action may be performed. */
|
||||||
|
canClick: MaybeRef<boolean>;
|
||||||
|
/** The display to use for this action. */
|
||||||
|
display?: MaybeRef<Renderable>;
|
||||||
|
/** A function that is called when the action is clicked. */
|
||||||
|
onClick: (amount: DecimalSource) => void;
|
||||||
|
/** Whether or not the player is holding down the action. Actions will be considered clicked as soon as the cooldown completes when being held down. */
|
||||||
|
isHolding: Ref<boolean>;
|
||||||
|
/** The current amount of progress through the cooldown. */
|
||||||
|
progress: Ref<DecimalSource>;
|
||||||
|
/** The bar used to display the current cooldown progress. */
|
||||||
|
progressBar: Bar;
|
||||||
|
/** Update the cooldown the specified number of seconds */
|
||||||
|
update: (diff: number) => void;
|
||||||
|
/** A symbol that helps identify features of the same type. */
|
||||||
|
type: typeof ActionType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily creates an action with the given options.
|
||||||
|
* @param optionsFunc Action options.
|
||||||
|
*/
|
||||||
|
export function createAction<T extends ActionOptions>(optionsFunc?: () => T) {
|
||||||
|
const progress = persistent<DecimalSource>(0);
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const options = optionsFunc?.() ?? ({} as T);
|
||||||
|
const { style, duration, canClick, autoStart, display, barOptions, onClick, ...props } =
|
||||||
|
options;
|
||||||
|
|
||||||
|
const processedCanClick = processGetter(canClick) ?? true;
|
||||||
|
const processedStyle = processGetter(style);
|
||||||
|
|
||||||
|
const progressBar = createBar(() => ({
|
||||||
|
direction: Direction.Right,
|
||||||
|
width: 100,
|
||||||
|
height: 10,
|
||||||
|
borderStyle: { borderColor: "black" },
|
||||||
|
baseStyle: { marginTop: "-1px" },
|
||||||
|
progress: (): DecimalSource => Decimal.div(progress.value, unref(action.duration)),
|
||||||
|
...(barOptions as Omit<typeof barOptions, keyof VueFeature>)
|
||||||
|
}));
|
||||||
|
|
||||||
|
let Component: () => JSX.Element;
|
||||||
|
if (typeof display === "object" && "description" in display) {
|
||||||
|
const title = processGetter(display.title);
|
||||||
|
const description = processGetter(display.description);
|
||||||
|
|
||||||
|
const Title = () => (title == null ? <></> : render(title, el => <h3>{el}</h3>));
|
||||||
|
const Description = () => render(description, el => <div>{el}</div>);
|
||||||
|
|
||||||
|
Component = () => {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{title != null ? (
|
||||||
|
<div>
|
||||||
|
<Title />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Description />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
} else if (display != null) {
|
||||||
|
const processedDisplay = processGetter(display);
|
||||||
|
Component = () => render(processedDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = {
|
||||||
|
type: ActionType,
|
||||||
|
...(props as Omit<typeof props, keyof VueFeature | keyof ActionOptions>),
|
||||||
|
...vueFeatureMixin(
|
||||||
|
"action",
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
style: () => ({
|
||||||
|
cursor: Decimal.gte(progress.value, unref(action.duration))
|
||||||
|
? "pointer"
|
||||||
|
: "progress",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
...unref(processedStyle)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
() => (
|
||||||
|
<ClickableVue
|
||||||
|
canClick={action.canClick}
|
||||||
|
onClick={action.onClick}
|
||||||
|
display={action.display}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
),
|
||||||
|
progress,
|
||||||
|
isHolding: ref(false),
|
||||||
|
duration: processGetter(duration),
|
||||||
|
canClick: computed(
|
||||||
|
(): boolean =>
|
||||||
|
unref(processedCanClick) && Decimal.gte(progress.value, unref(action.duration))
|
||||||
|
),
|
||||||
|
autoStart: processGetter(autoStart) ?? false,
|
||||||
|
display: computed(() => (
|
||||||
|
<>
|
||||||
|
<div style="flex-grow: 1" />
|
||||||
|
{display == null ? null : <Component />}
|
||||||
|
<div style="flex-grow: 1" />
|
||||||
|
{render(progressBar)}
|
||||||
|
</>
|
||||||
|
)),
|
||||||
|
progressBar,
|
||||||
|
onClick: function () {
|
||||||
|
if (unref(action.canClick) === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const amount = Decimal.div(progress.value, unref(action.duration));
|
||||||
|
onClick?.call(action, amount);
|
||||||
|
progress.value = 0;
|
||||||
|
},
|
||||||
|
update: function (diff) {
|
||||||
|
const duration = unref(action.duration);
|
||||||
|
if (Decimal.gte(progress.value, duration)) {
|
||||||
|
progress.value = duration;
|
||||||
|
} else {
|
||||||
|
progress.value = Decimal.add(progress.value, diff);
|
||||||
|
if (action.isHolding.value || unref<boolean>(action.autoStart)) {
|
||||||
|
action.onClick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} satisfies Action satisfies Omit<Clickable, "type"> & { type: typeof ActionType };
|
||||||
|
|
||||||
|
return action;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners: Record<string, Unsubscribe | undefined> = {};
|
||||||
|
globalBus.on("addLayer", layer => {
|
||||||
|
const actions: Action[] = findFeatures(layer, ActionType) as Action[];
|
||||||
|
listeners[layer.id] = layer.on("postUpdate", (diff: number) => {
|
||||||
|
actions.forEach(action => action.update(diff));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
globalBus.on("removeLayer", layer => {
|
||||||
|
// unsubscribe from postUpdate
|
||||||
|
listeners[layer.id]?.();
|
||||||
|
listeners[layer.id] = undefined;
|
||||||
|
});
|
|
@ -1,204 +0,0 @@
|
||||||
import ClickableComponent from "features/clickables/Clickable.vue";
|
|
||||||
import { GenericDecorator } from "features/decorators/common";
|
|
||||||
import type {
|
|
||||||
CoercableComponent,
|
|
||||||
GenericComponent,
|
|
||||||
OptionsFunc,
|
|
||||||
Replace,
|
|
||||||
StyleValue
|
|
||||||
} from "features/feature";
|
|
||||||
import { Component, GatherProps, Visibility, getUniqueID, setDefault } from "features/feature";
|
|
||||||
import type { BaseLayer } from "game/layers";
|
|
||||||
import type { Unsubscribe } from "nanoevents";
|
|
||||||
import type {
|
|
||||||
Computable,
|
|
||||||
GetComputableType,
|
|
||||||
GetComputableTypeWithDefault,
|
|
||||||
ProcessedComputable
|
|
||||||
} from "util/computed";
|
|
||||||
import { processComputable } from "util/computed";
|
|
||||||
import { createLazyProxy } from "util/proxies";
|
|
||||||
import { computed, unref } from "vue";
|
|
||||||
|
|
||||||
/** A symbol used to identify {@link Clickable} features. */
|
|
||||||
export const ClickableType = Symbol("Clickable");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An object that configures a {@link Clickable}.
|
|
||||||
*/
|
|
||||||
export interface ClickableOptions {
|
|
||||||
/** Whether this clickable should be visible. */
|
|
||||||
visibility?: Computable<Visibility | boolean>;
|
|
||||||
/** Whether or not the clickable may be clicked. */
|
|
||||||
canClick?: Computable<boolean>;
|
|
||||||
/** Dictionary of CSS classes to apply to this feature. */
|
|
||||||
classes?: Computable<Record<string, boolean>>;
|
|
||||||
/** CSS to apply to this feature. */
|
|
||||||
style?: Computable<StyleValue>;
|
|
||||||
/** Shows a marker on the corner of the feature. */
|
|
||||||
mark?: Computable<boolean | string>;
|
|
||||||
/** The display to use for this clickable. */
|
|
||||||
display?: Computable<
|
|
||||||
| CoercableComponent
|
|
||||||
| {
|
|
||||||
/** A header to appear at the top of the display. */
|
|
||||||
title?: CoercableComponent;
|
|
||||||
/** The main text that appears in the display. */
|
|
||||||
description: CoercableComponent;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
/** Toggles a smaller design for the feature. */
|
|
||||||
small?: boolean;
|
|
||||||
/** A function that is called when the clickable is clicked. */
|
|
||||||
onClick?: (e?: MouseEvent | TouchEvent) => void;
|
|
||||||
/** A function that is called when the clickable is held down. */
|
|
||||||
onHold?: VoidFunction;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The properties that are added onto a processed {@link ClickableOptions} to create an {@link Clickable}.
|
|
||||||
*/
|
|
||||||
export interface BaseClickable {
|
|
||||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
|
||||||
id: string;
|
|
||||||
/** A symbol that helps identify features of the same type. */
|
|
||||||
type: typeof ClickableType;
|
|
||||||
/** The Vue component used to render this feature. */
|
|
||||||
[Component]: GenericComponent;
|
|
||||||
/** A function to gather the props the vue component requires for this feature. */
|
|
||||||
[GatherProps]: () => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An object that represents a feature that can be clicked or held down. */
|
|
||||||
export type Clickable<T extends ClickableOptions> = Replace<
|
|
||||||
T & BaseClickable,
|
|
||||||
{
|
|
||||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
|
||||||
canClick: GetComputableTypeWithDefault<T["canClick"], true>;
|
|
||||||
classes: GetComputableType<T["classes"]>;
|
|
||||||
style: GetComputableType<T["style"]>;
|
|
||||||
mark: GetComputableType<T["mark"]>;
|
|
||||||
display: GetComputableType<T["display"]>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** A type that matches any valid {@link Clickable} object. */
|
|
||||||
export type GenericClickable = Replace<
|
|
||||||
Clickable<ClickableOptions>,
|
|
||||||
{
|
|
||||||
visibility: ProcessedComputable<Visibility | boolean>;
|
|
||||||
canClick: ProcessedComputable<boolean>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lazily creates a clickable with the given options.
|
|
||||||
* @param optionsFunc Clickable options.
|
|
||||||
*/
|
|
||||||
export function createClickable<T extends ClickableOptions>(
|
|
||||||
optionsFunc?: OptionsFunc<T, BaseClickable, GenericClickable>,
|
|
||||||
...decorators: GenericDecorator[]
|
|
||||||
): Clickable<T> {
|
|
||||||
const decoratedData = decorators.reduce(
|
|
||||||
(current, next) => Object.assign(current, next.getPersistentData?.()),
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
return createLazyProxy(feature => {
|
|
||||||
const clickable =
|
|
||||||
optionsFunc?.call(feature, feature) ??
|
|
||||||
({} as ReturnType<NonNullable<typeof optionsFunc>>);
|
|
||||||
clickable.id = getUniqueID("clickable-");
|
|
||||||
clickable.type = ClickableType;
|
|
||||||
clickable[Component] = ClickableComponent as GenericComponent;
|
|
||||||
|
|
||||||
for (const decorator of decorators) {
|
|
||||||
decorator.preConstruct?.(clickable);
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.assign(clickable, decoratedData);
|
|
||||||
|
|
||||||
processComputable(clickable as T, "visibility");
|
|
||||||
setDefault(clickable, "visibility", Visibility.Visible);
|
|
||||||
processComputable(clickable as T, "canClick");
|
|
||||||
setDefault(clickable, "canClick", true);
|
|
||||||
processComputable(clickable as T, "classes");
|
|
||||||
processComputable(clickable as T, "style");
|
|
||||||
processComputable(clickable as T, "mark");
|
|
||||||
processComputable(clickable as T, "display");
|
|
||||||
|
|
||||||
if (clickable.onClick) {
|
|
||||||
const onClick = clickable.onClick.bind(clickable);
|
|
||||||
clickable.onClick = function (e) {
|
|
||||||
if (unref(clickable.canClick) !== false) {
|
|
||||||
onClick(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (clickable.onHold) {
|
|
||||||
const onHold = clickable.onHold.bind(clickable);
|
|
||||||
clickable.onHold = function () {
|
|
||||||
if (unref(clickable.canClick) !== false) {
|
|
||||||
onHold();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const decorator of decorators) {
|
|
||||||
decorator.postConstruct?.(clickable);
|
|
||||||
}
|
|
||||||
|
|
||||||
const decoratedProps = decorators.reduce(
|
|
||||||
(current, next) => Object.assign(current, next.getGatheredProps?.(clickable)),
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
clickable[GatherProps] = function (this: GenericClickable) {
|
|
||||||
const {
|
|
||||||
display,
|
|
||||||
visibility,
|
|
||||||
style,
|
|
||||||
classes,
|
|
||||||
onClick,
|
|
||||||
onHold,
|
|
||||||
canClick,
|
|
||||||
small,
|
|
||||||
mark,
|
|
||||||
id
|
|
||||||
} = this;
|
|
||||||
return {
|
|
||||||
display,
|
|
||||||
visibility,
|
|
||||||
style: unref(style),
|
|
||||||
classes,
|
|
||||||
onClick,
|
|
||||||
onHold,
|
|
||||||
canClick,
|
|
||||||
small,
|
|
||||||
mark,
|
|
||||||
id,
|
|
||||||
...decoratedProps
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return clickable as unknown as Clickable<T>;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility to auto click a clickable whenever it can be.
|
|
||||||
* @param layer The layer the clickable is apart of
|
|
||||||
* @param clickable The clicker to click automatically
|
|
||||||
* @param autoActive Whether or not the clickable should currently be auto-clicking
|
|
||||||
*/
|
|
||||||
export function setupAutoClick(
|
|
||||||
layer: BaseLayer,
|
|
||||||
clickable: GenericClickable,
|
|
||||||
autoActive: Computable<boolean> = true
|
|
||||||
): Unsubscribe {
|
|
||||||
const isActive: ProcessedComputable<boolean> =
|
|
||||||
typeof autoActive === "function" ? computed(autoActive) : autoActive;
|
|
||||||
return layer.on("update", () => {
|
|
||||||
if (unref(isActive) && unref(clickable.canClick)) {
|
|
||||||
clickable.onClick?.();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
130
src/features/clickables/clickable.tsx
Normal file
130
src/features/clickables/clickable.tsx
Normal file
|
@ -0,0 +1,130 @@
|
||||||
|
import Clickable from "features/clickables/Clickable.vue";
|
||||||
|
import type { BaseLayer } from "game/layers";
|
||||||
|
import type { Unsubscribe } from "nanoevents";
|
||||||
|
import { processGetter } from "util/computed";
|
||||||
|
import { createLazyProxy } from "util/proxies";
|
||||||
|
import { render, Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
|
||||||
|
import { computed, MaybeRef, MaybeRefOrGetter, unref } from "vue";
|
||||||
|
|
||||||
|
/** A symbol used to identify {@link Clickable} features. */
|
||||||
|
export const ClickableType = Symbol("Clickable");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object that configures a {@link Clickable}.
|
||||||
|
*/
|
||||||
|
export interface ClickableOptions extends VueFeatureOptions {
|
||||||
|
/** Whether or not the clickable may be clicked. */
|
||||||
|
canClick?: MaybeRefOrGetter<boolean>;
|
||||||
|
/** The display to use for this clickable. */
|
||||||
|
display?:
|
||||||
|
| MaybeRefOrGetter<Renderable>
|
||||||
|
| {
|
||||||
|
/** A header to appear at the top of the display. */
|
||||||
|
title?: MaybeRefOrGetter<Renderable>;
|
||||||
|
/** The main text that appears in the display. */
|
||||||
|
description: MaybeRefOrGetter<Renderable>;
|
||||||
|
};
|
||||||
|
/** A function that is called when the clickable is clicked. */
|
||||||
|
onClick?: (e?: MouseEvent | TouchEvent) => void;
|
||||||
|
/** A function that is called when the clickable is held down. */
|
||||||
|
onHold?: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An object that represents a feature that can be clicked or held down. */
|
||||||
|
export interface Clickable extends VueFeature {
|
||||||
|
/** A function that is called when the clickable is clicked. */
|
||||||
|
onClick?: (e?: MouseEvent | TouchEvent) => void;
|
||||||
|
/** A function that is called when the clickable is held down. */
|
||||||
|
onHold?: VoidFunction;
|
||||||
|
/** Whether or not the clickable may be clicked. */
|
||||||
|
canClick: MaybeRef<boolean>;
|
||||||
|
/** The display to use for this clickable. */
|
||||||
|
display?: MaybeRef<Renderable>;
|
||||||
|
/** A symbol that helps identify features of the same type. */
|
||||||
|
type: typeof ClickableType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily creates a clickable with the given options.
|
||||||
|
* @param optionsFunc Clickable options.
|
||||||
|
*/
|
||||||
|
export function createClickable<T extends ClickableOptions>(optionsFunc?: () => T) {
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const options = optionsFunc?.() ?? ({} as T);
|
||||||
|
const { canClick, display: _display, onClick: onClick, onHold: onHold, ...props } = options;
|
||||||
|
|
||||||
|
let display: MaybeRef<Renderable> | undefined = undefined;
|
||||||
|
if (typeof _display === "object" && "description" in _display) {
|
||||||
|
const title = processGetter(_display.title);
|
||||||
|
const description = processGetter(_display.description);
|
||||||
|
|
||||||
|
const Title = () => (title == null ? <></> : render(title, el => <h3>{el}</h3>));
|
||||||
|
const Description = () => render(description, el => <div>{el}</div>);
|
||||||
|
|
||||||
|
display = computed(() => (
|
||||||
|
<span>
|
||||||
|
{title != null ? (
|
||||||
|
<div>
|
||||||
|
<Title />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Description />
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
} else if (_display != null) {
|
||||||
|
display = processGetter(_display);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clickable = {
|
||||||
|
type: ClickableType,
|
||||||
|
...(props as Omit<typeof props, keyof VueFeature | keyof ClickableOptions>),
|
||||||
|
...vueFeatureMixin("clickable", options, () => (
|
||||||
|
<Clickable
|
||||||
|
canClick={clickable.canClick}
|
||||||
|
onClick={clickable.onClick}
|
||||||
|
display={clickable.display}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
canClick: processGetter(canClick) ?? true,
|
||||||
|
display,
|
||||||
|
onClick:
|
||||||
|
onClick == null
|
||||||
|
? undefined
|
||||||
|
: function (e) {
|
||||||
|
if (unref(clickable.canClick) !== false) {
|
||||||
|
onClick.call(clickable, e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onHold:
|
||||||
|
onHold == null
|
||||||
|
? undefined
|
||||||
|
: function () {
|
||||||
|
if (unref(clickable.canClick) !== false) {
|
||||||
|
onHold.call(clickable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} satisfies Clickable & { onClick: T["onClick"] };
|
||||||
|
|
||||||
|
return clickable;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility to auto click a clickable whenever it can be.
|
||||||
|
* @param layer The layer the clickable is apart of
|
||||||
|
* @param clickable The clicker to click automatically
|
||||||
|
* @param autoActive Whether or not the clickable should currently be auto-clicking
|
||||||
|
*/
|
||||||
|
export function setupAutoClick(
|
||||||
|
layer: BaseLayer,
|
||||||
|
clickable: Clickable,
|
||||||
|
autoActive: MaybeRefOrGetter<boolean> = true
|
||||||
|
): Unsubscribe {
|
||||||
|
const isActive: MaybeRef<boolean> =
|
||||||
|
typeof autoActive === "function" ? computed(autoActive) : autoActive;
|
||||||
|
return layer.on("update", () => {
|
||||||
|
if (unref(isActive) && unref<boolean>(clickable.canClick)) {
|
||||||
|
clickable.onClick?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
205
src/features/clickables/repeatable.tsx
Normal file
205
src/features/clickables/repeatable.tsx
Normal file
|
@ -0,0 +1,205 @@
|
||||||
|
import Clickable from "features/clickables/Clickable.vue";
|
||||||
|
import { Visibility } from "features/feature";
|
||||||
|
import { DefaultValue, Persistent, persistent } from "game/persistence";
|
||||||
|
import {
|
||||||
|
createVisibilityRequirement,
|
||||||
|
displayRequirements,
|
||||||
|
maxRequirementsMet,
|
||||||
|
payRequirements,
|
||||||
|
Requirements,
|
||||||
|
requirementsMet
|
||||||
|
} from "game/requirements";
|
||||||
|
import type { DecimalSource } from "util/bignum";
|
||||||
|
import Decimal, { formatWhole } from "util/bignum";
|
||||||
|
import { processGetter } from "util/computed";
|
||||||
|
import { createLazyProxy } from "util/proxies";
|
||||||
|
import { isJSXElement, render, Renderable, VueFeature, vueFeatureMixin } from "util/vue";
|
||||||
|
import type { MaybeRef, MaybeRefOrGetter, Ref } from "vue";
|
||||||
|
import { computed, isRef, unref } from "vue";
|
||||||
|
import { ClickableOptions } from "./clickable";
|
||||||
|
|
||||||
|
/** A symbol used to identify {@link Repeatable} features. */
|
||||||
|
export const RepeatableType = Symbol("Repeatable");
|
||||||
|
|
||||||
|
/** An object that configures a {@link Repeatable}. */
|
||||||
|
export interface RepeatableOptions extends ClickableOptions {
|
||||||
|
/** The requirement(s) to increase this repeatable. */
|
||||||
|
requirements: Requirements;
|
||||||
|
/** The maximum amount obtainable for this repeatable. */
|
||||||
|
limit?: MaybeRefOrGetter<DecimalSource>;
|
||||||
|
/** The initial amount this repeatable has on a new save / after reset. */
|
||||||
|
initialAmount?: DecimalSource;
|
||||||
|
/** The display to use for this repeatable. */
|
||||||
|
display?:
|
||||||
|
| MaybeRefOrGetter<Renderable>
|
||||||
|
| {
|
||||||
|
/** A header to appear at the top of the display. */
|
||||||
|
title?: MaybeRefOrGetter<Renderable>;
|
||||||
|
/** The main text that appears in the display. */
|
||||||
|
description: MaybeRefOrGetter<Renderable>;
|
||||||
|
/** A description of the current effect of this repeatable, based off its amount. */
|
||||||
|
effectDisplay?: MaybeRefOrGetter<Renderable>;
|
||||||
|
/** Whether or not to show the current amount of this repeatable at the bottom of the display. */
|
||||||
|
showAmount?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An object that represents a feature with multiple "levels" with scaling requirements. */
|
||||||
|
export interface Repeatable extends VueFeature {
|
||||||
|
/** The requirement(s) to increase this repeatable. */
|
||||||
|
requirements: Requirements;
|
||||||
|
/** The maximum amount obtainable for this repeatable. */
|
||||||
|
limit: MaybeRef<DecimalSource>;
|
||||||
|
/** The initial amount this repeatable has on a new save / after reset. */
|
||||||
|
initialAmount?: DecimalSource;
|
||||||
|
/** The display to use for this repeatable. */
|
||||||
|
display?: MaybeRef<Renderable>;
|
||||||
|
/** Whether or not the repeatable may be clicked. */
|
||||||
|
canClick: Ref<boolean>;
|
||||||
|
/** A function that is called when the repeatable is clicked. */
|
||||||
|
onClick: (event?: MouseEvent | TouchEvent) => void;
|
||||||
|
/** The current amount this repeatable has. */
|
||||||
|
amount: Persistent<DecimalSource>;
|
||||||
|
/** Whether or not this repeatable's amount is at it's limit. */
|
||||||
|
maxed: Ref<boolean>;
|
||||||
|
/** How much amount can be increased by, or 1 if unclickable. **/
|
||||||
|
amountToIncrease: Ref<DecimalSource>;
|
||||||
|
/** A symbol that helps identify features of the same type. */
|
||||||
|
type: typeof RepeatableType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily creates a repeatable with the given options.
|
||||||
|
* @param optionsFunc Repeatable options.
|
||||||
|
*/
|
||||||
|
export function createRepeatable<T extends RepeatableOptions>(optionsFunc: () => T) {
|
||||||
|
const amount = persistent<DecimalSource>(0);
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const options = optionsFunc();
|
||||||
|
const {
|
||||||
|
requirements: _requirements,
|
||||||
|
display: _display,
|
||||||
|
limit,
|
||||||
|
onClick,
|
||||||
|
initialAmount,
|
||||||
|
...props
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (options.classes == null) {
|
||||||
|
options.classes = computed(() => ({ bought: unref(repeatable.maxed) }));
|
||||||
|
} else {
|
||||||
|
const classes = processGetter(options.classes);
|
||||||
|
options.classes = computed(() => ({
|
||||||
|
...unref(classes),
|
||||||
|
bought: unref(repeatable.maxed)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const vueFeature = vueFeatureMixin("repeatable", options, () => (
|
||||||
|
<Clickable
|
||||||
|
canClick={repeatable.canClick}
|
||||||
|
onClick={repeatable.onClick}
|
||||||
|
display={repeatable.display}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
const limitRequirement = {
|
||||||
|
requirementMet: computed(
|
||||||
|
(): DecimalSource => Decimal.sub(unref(repeatable.limit), unref(amount))
|
||||||
|
),
|
||||||
|
requiresPay: false,
|
||||||
|
visibility: Visibility.None,
|
||||||
|
canMaximize: true
|
||||||
|
} as const;
|
||||||
|
const requirements: Requirements = [
|
||||||
|
...(Array.isArray(_requirements) ? _requirements : [_requirements]),
|
||||||
|
limitRequirement
|
||||||
|
];
|
||||||
|
if (vueFeature.visibility != null) {
|
||||||
|
requirements.push(createVisibilityRequirement(vueFeature.visibility));
|
||||||
|
}
|
||||||
|
|
||||||
|
let display;
|
||||||
|
if (typeof _display === "object" && !isRef(_display) && !isJSXElement(_display)) {
|
||||||
|
const title = processGetter(_display.title);
|
||||||
|
const description = processGetter(_display.description);
|
||||||
|
const effectDisplay = processGetter(_display.effectDisplay);
|
||||||
|
const showAmount = processGetter(_display.showAmount);
|
||||||
|
|
||||||
|
const Title = title == null ? null : () => render(title, el => <h3>{el}</h3>);
|
||||||
|
const Description = () => render(description, el => <>{el}</>);
|
||||||
|
const EffectDisplay =
|
||||||
|
effectDisplay == null ? null : () => render(effectDisplay, el => <>{el}</>);
|
||||||
|
|
||||||
|
display = computed(() => (
|
||||||
|
<span>
|
||||||
|
{Title == null ? null : (
|
||||||
|
<div>
|
||||||
|
<Title />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Description />
|
||||||
|
{showAmount === false ? null : (
|
||||||
|
<div>
|
||||||
|
<br />
|
||||||
|
<>Amount: {formatWhole(unref(amount))}</>
|
||||||
|
{Decimal.isFinite(unref(repeatable.limit)) ? (
|
||||||
|
<> / {formatWhole(unref(repeatable.limit))}</>
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{EffectDisplay == null ? null : (
|
||||||
|
<div>
|
||||||
|
<br />
|
||||||
|
Currently: <EffectDisplay />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{unref(repeatable.maxed) ? null : (
|
||||||
|
<div>
|
||||||
|
<br />
|
||||||
|
{displayRequirements(requirements, unref(repeatable.amountToIncrease))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
} else if (_display != null) {
|
||||||
|
const processedDisplay = processGetter(_display);
|
||||||
|
display = computed(() => render(processedDisplay));
|
||||||
|
} else {
|
||||||
|
display = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
amount[DefaultValue] = initialAmount ?? 0;
|
||||||
|
|
||||||
|
const repeatable = {
|
||||||
|
type: RepeatableType,
|
||||||
|
...(props as Omit<typeof props, keyof VueFeature | keyof RepeatableOptions>),
|
||||||
|
...vueFeature,
|
||||||
|
amount,
|
||||||
|
requirements,
|
||||||
|
initialAmount,
|
||||||
|
limit: processGetter(limit) ?? Decimal.dInf,
|
||||||
|
classes: computed(() => {
|
||||||
|
const currClasses = unref(vueFeature.classes) || {};
|
||||||
|
if (unref(repeatable.maxed)) {
|
||||||
|
currClasses.bought = true;
|
||||||
|
}
|
||||||
|
return currClasses;
|
||||||
|
}),
|
||||||
|
maxed: computed((): boolean => Decimal.gte(unref(amount), unref(repeatable.limit))),
|
||||||
|
canClick: computed(() => requirementsMet(requirements)),
|
||||||
|
amountToIncrease: computed(() => Decimal.clampMin(maxRequirementsMet(requirements), 1)),
|
||||||
|
onClick(event?: MouseEvent | TouchEvent) {
|
||||||
|
if (!unref(repeatable.canClick)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const purchaseAmount = unref(repeatable.amountToIncrease) ?? 1;
|
||||||
|
payRequirements(requirements, purchaseAmount);
|
||||||
|
amount.value = Decimal.add(unref(amount), purchaseAmount);
|
||||||
|
onClick?.(event);
|
||||||
|
},
|
||||||
|
display
|
||||||
|
} satisfies Repeatable;
|
||||||
|
|
||||||
|
return repeatable;
|
||||||
|
});
|
||||||
|
}
|
174
src/features/clickables/upgrade.tsx
Normal file
174
src/features/clickables/upgrade.tsx
Normal file
|
@ -0,0 +1,174 @@
|
||||||
|
import { findFeatures } from "features/feature";
|
||||||
|
import { Layer } from "game/layers";
|
||||||
|
import type { Persistent } from "game/persistence";
|
||||||
|
import { persistent } from "game/persistence";
|
||||||
|
import {
|
||||||
|
Requirements,
|
||||||
|
createVisibilityRequirement,
|
||||||
|
displayRequirements,
|
||||||
|
payRequirements,
|
||||||
|
requirementsMet
|
||||||
|
} from "game/requirements";
|
||||||
|
import { isFunction } from "util/common";
|
||||||
|
import { processGetter } from "util/computed";
|
||||||
|
import { createLazyProxy } from "util/proxies";
|
||||||
|
import { Renderable, VueFeature, VueFeatureOptions, render, vueFeatureMixin } from "util/vue";
|
||||||
|
import type { MaybeRef, MaybeRefOrGetter, Ref } from "vue";
|
||||||
|
import { computed, unref } from "vue";
|
||||||
|
import Clickable from "./Clickable.vue";
|
||||||
|
import { ClickableOptions } from "./clickable";
|
||||||
|
|
||||||
|
/** A symbol used to identify {@link Upgrade} features. */
|
||||||
|
export const UpgradeType = Symbol("Upgrade");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object that configures a {@link Upgrade}.
|
||||||
|
*/
|
||||||
|
export interface UpgradeOptions extends VueFeatureOptions, ClickableOptions {
|
||||||
|
/** The display to use for this upgrade. */
|
||||||
|
display?:
|
||||||
|
| MaybeRefOrGetter<Renderable>
|
||||||
|
| {
|
||||||
|
/** A header to appear at the top of the display. */
|
||||||
|
title?: MaybeRefOrGetter<Renderable>;
|
||||||
|
/** The main text that appears in the display. */
|
||||||
|
description: MaybeRefOrGetter<Renderable>;
|
||||||
|
/** A description of the current effect of the achievement. Useful when the effect changes dynamically. */
|
||||||
|
effectDisplay?: MaybeRefOrGetter<Renderable>;
|
||||||
|
};
|
||||||
|
/** The requirements to purchase this upgrade. */
|
||||||
|
requirements: Requirements;
|
||||||
|
/** A function that is called when the upgrade is purchased. */
|
||||||
|
onPurchase?: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An object that represents a feature that can be purchased a single time. */
|
||||||
|
export interface Upgrade extends VueFeature {
|
||||||
|
/** The requirements to purchase this upgrade. */
|
||||||
|
requirements: Requirements;
|
||||||
|
/** The display to use for this upgrade. */
|
||||||
|
display?: MaybeRef<Renderable>;
|
||||||
|
/** Whether or not this upgrade has been purchased. */
|
||||||
|
bought: Persistent<boolean>;
|
||||||
|
/** Whether or not the upgrade can currently be purchased. */
|
||||||
|
canPurchase: Ref<boolean>;
|
||||||
|
/** A function that is called when the upgrade is purchased. */
|
||||||
|
onPurchase?: VoidFunction;
|
||||||
|
/** Purchase the upgrade */
|
||||||
|
purchase: VoidFunction;
|
||||||
|
/** A symbol that helps identify features of the same type. */
|
||||||
|
type: typeof UpgradeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily creates an upgrade with the given options.
|
||||||
|
* @param optionsFunc Upgrade options.
|
||||||
|
*/
|
||||||
|
export function createUpgrade<T extends UpgradeOptions>(optionsFunc: () => T) {
|
||||||
|
const bought = persistent<boolean>(false, false);
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const options = optionsFunc();
|
||||||
|
const { requirements: _requirements, display: _display, onHold, ...props } = options;
|
||||||
|
|
||||||
|
if (options.classes == null) {
|
||||||
|
options.classes = computed(() => ({ bought: unref(upgrade.bought) }));
|
||||||
|
} else {
|
||||||
|
const classes = processGetter(options.classes);
|
||||||
|
options.classes = computed(() => ({
|
||||||
|
...unref(classes),
|
||||||
|
bought: unref(upgrade.bought)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const vueFeature = vueFeatureMixin("upgrade", options, () => (
|
||||||
|
<Clickable
|
||||||
|
onClick={upgrade.purchase}
|
||||||
|
onHold={upgrade.onHold}
|
||||||
|
canClick={upgrade.canPurchase}
|
||||||
|
display={upgrade.display}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
const requirements = Array.isArray(_requirements) ? _requirements : [_requirements];
|
||||||
|
if (vueFeature.visibility != null) {
|
||||||
|
requirements.push(createVisibilityRequirement(vueFeature.visibility));
|
||||||
|
}
|
||||||
|
|
||||||
|
let display: MaybeRef<Renderable> | undefined = undefined;
|
||||||
|
if (typeof _display === "object" && "description" in _display) {
|
||||||
|
const title = processGetter(_display.title);
|
||||||
|
const description = processGetter(_display.description);
|
||||||
|
const effectDisplay = processGetter(_display.effectDisplay);
|
||||||
|
|
||||||
|
const Title = () => (title == null ? <></> : render(title, el => <h3>{el}</h3>));
|
||||||
|
const Description = () => render(description, el => <div>{el}</div>);
|
||||||
|
const EffectDisplay = () =>
|
||||||
|
effectDisplay == null ? <></> : render(effectDisplay, el => <>{el}</>);
|
||||||
|
|
||||||
|
display = computed(() => (
|
||||||
|
<span>
|
||||||
|
{title != null ? (
|
||||||
|
<div>
|
||||||
|
<Title />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Description />
|
||||||
|
{effectDisplay != null ? (
|
||||||
|
<div>
|
||||||
|
Currently: <EffectDisplay />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{bought.value ? null : (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
{displayRequirements(requirements)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
} else if (_display != null) {
|
||||||
|
display = processGetter(_display);
|
||||||
|
}
|
||||||
|
|
||||||
|
const upgrade = {
|
||||||
|
type: UpgradeType,
|
||||||
|
...(props as Omit<typeof props, keyof VueFeature | keyof UpgradeOptions>),
|
||||||
|
...vueFeature,
|
||||||
|
bought,
|
||||||
|
canPurchase: computed(() => !bought.value && requirementsMet(requirements)),
|
||||||
|
requirements,
|
||||||
|
display,
|
||||||
|
onHold,
|
||||||
|
purchase() {
|
||||||
|
if (!unref(upgrade.canPurchase)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
payRequirements(requirements);
|
||||||
|
bought.value = true;
|
||||||
|
options.onPurchase?.();
|
||||||
|
}
|
||||||
|
} satisfies Upgrade;
|
||||||
|
|
||||||
|
return upgrade;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility to auto purchase a list of upgrades whenever they're affordable.
|
||||||
|
* @param layer The layer the upgrades are apart of
|
||||||
|
* @param autoActive Whether or not the upgrades should currently be auto-purchasing
|
||||||
|
* @param upgrades The specific upgrades to upgrade. If unspecified, uses all upgrades on the layer.
|
||||||
|
*/
|
||||||
|
export function setupAutoPurchase(
|
||||||
|
layer: Layer,
|
||||||
|
autoActive: MaybeRefOrGetter<boolean>,
|
||||||
|
upgrades: Upgrade[] = []
|
||||||
|
): void {
|
||||||
|
upgrades = upgrades.length === 0 ? (findFeatures(layer, UpgradeType) as Upgrade[]) : upgrades;
|
||||||
|
const isAutoActive: MaybeRef<boolean> = isFunction(autoActive)
|
||||||
|
? computed(autoActive)
|
||||||
|
: autoActive;
|
||||||
|
layer.on("update", () => {
|
||||||
|
if (unref(isAutoActive)) {
|
||||||
|
upgrades.forEach(upgrade => upgrade.purchase());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
|
@ -1,18 +1,17 @@
|
||||||
import type { CoercableComponent, OptionsFunc, Replace } from "features/feature";
|
|
||||||
import { setDefault } from "features/feature";
|
|
||||||
import type { Resource } from "features/resources/resource";
|
import type { Resource } from "features/resources/resource";
|
||||||
import Formula from "game/formulas/formulas";
|
import Formula from "game/formulas/formulas";
|
||||||
import { InvertibleFormula, InvertibleIntegralFormula } from "game/formulas/types";
|
import { InvertibleFormula, InvertibleIntegralFormula } from "game/formulas/types";
|
||||||
import type { BaseLayer } from "game/layers";
|
import type { BaseLayer } from "game/layers";
|
||||||
|
import { createBooleanRequirement } from "game/requirements";
|
||||||
import type { DecimalSource } from "util/bignum";
|
import type { DecimalSource } from "util/bignum";
|
||||||
import Decimal from "util/bignum";
|
import Decimal from "util/bignum";
|
||||||
import type { Computable, GetComputableTypeWithDefault, ProcessedComputable } from "util/computed";
|
import { processGetter } from "util/computed";
|
||||||
import { convertComputable, processComputable } from "util/computed";
|
|
||||||
import { createLazyProxy } from "util/proxies";
|
import { createLazyProxy } from "util/proxies";
|
||||||
import type { Ref } from "vue";
|
import { Renderable } from "util/vue";
|
||||||
import { computed, unref } from "vue";
|
import { computed, MaybeRef, MaybeRefOrGetter, unref } from "vue";
|
||||||
import { GenericDecorator } from "./decorators/common";
|
|
||||||
import { createBooleanRequirement } from "game/requirements";
|
/** A symbol used to identify {@link Conversion} features. */
|
||||||
|
export const ConversionType = Symbol("Conversion");
|
||||||
|
|
||||||
/** An object that configures a {@link Conversion}. */
|
/** An object that configures a {@link Conversion}. */
|
||||||
export interface ConversionOptions {
|
export interface ConversionOptions {
|
||||||
|
@ -25,24 +24,24 @@ export interface ConversionOptions {
|
||||||
* How much of the output resource the conversion can currently convert for.
|
* How much of the output resource the conversion can currently convert for.
|
||||||
* Typically this will be set for you in a conversion constructor.
|
* Typically this will be set for you in a conversion constructor.
|
||||||
*/
|
*/
|
||||||
currentGain?: Computable<DecimalSource>;
|
currentGain?: MaybeRefOrGetter<DecimalSource>;
|
||||||
/**
|
/**
|
||||||
* The absolute amount the output resource will be changed by.
|
* The absolute amount the output resource will be changed by.
|
||||||
* Typically this will be set for you in a conversion constructor.
|
* Typically this will be set for you in a conversion constructor.
|
||||||
* This will differ from {@link currentGain} in the cases where the conversion isn't just adding the converted amount to the output resource.
|
* This will differ from {@link currentGain} in the cases where the conversion isn't just adding the converted amount to the output resource.
|
||||||
*/
|
*/
|
||||||
actualGain?: Computable<DecimalSource>;
|
actualGain?: MaybeRefOrGetter<DecimalSource>;
|
||||||
/**
|
/**
|
||||||
* The amount of the input resource currently being required in order to produce the {@link currentGain}.
|
* The amount of the input resource currently being required in order to produce the {@link currentGain}.
|
||||||
* That is, if it went below this value then {@link currentGain} would decrease.
|
* That is, if it went below this value then {@link currentGain} would decrease.
|
||||||
* Typically this will be set for you in a conversion constructor.
|
* Typically this will be set for you in a conversion constructor.
|
||||||
*/
|
*/
|
||||||
currentAt?: Computable<DecimalSource>;
|
currentAt?: MaybeRefOrGetter<DecimalSource>;
|
||||||
/**
|
/**
|
||||||
* The amount of the input resource required to make {@link currentGain} increase.
|
* The amount of the input resource required to make {@link currentGain} increase.
|
||||||
* Typically this will be set for you in a conversion constructor.
|
* Typically this will be set for you in a conversion constructor.
|
||||||
*/
|
*/
|
||||||
nextAt?: Computable<DecimalSource>;
|
nextAt?: MaybeRefOrGetter<DecimalSource>;
|
||||||
/**
|
/**
|
||||||
* The input {@link features/resources/resource.Resource} for this conversion.
|
* The input {@link features/resources/resource.Resource} for this conversion.
|
||||||
*/
|
*/
|
||||||
|
@ -55,7 +54,7 @@ export interface ConversionOptions {
|
||||||
* Whether or not to cap the amount of the output resource gained by converting at 1.
|
* Whether or not to cap the amount of the output resource gained by converting at 1.
|
||||||
* Defaults to true.
|
* Defaults to true.
|
||||||
*/
|
*/
|
||||||
buyMax?: Computable<boolean>;
|
buyMax?: MaybeRefOrGetter<boolean>;
|
||||||
/**
|
/**
|
||||||
* The function that performs the actual conversion from {@link baseResource} to {@link gainResource}.
|
* The function that performs the actual conversion from {@link baseResource} to {@link gainResource}.
|
||||||
* Typically this will be set for you in a conversion constructor.
|
* Typically this will be set for you in a conversion constructor.
|
||||||
|
@ -77,40 +76,64 @@ export interface ConversionOptions {
|
||||||
/**
|
/**
|
||||||
* The properties that are added onto a processed {@link ConversionOptions} to create a {@link Conversion}.
|
* The properties that are added onto a processed {@link ConversionOptions} to create a {@link Conversion}.
|
||||||
*/
|
*/
|
||||||
export interface BaseConversion {
|
export interface Conversion {
|
||||||
/**
|
/**
|
||||||
* The function that performs the actual conversion.
|
* The formula used to determine how much {@link gainResource} should be earned by this converting.
|
||||||
|
*/
|
||||||
|
formula: InvertibleFormula;
|
||||||
|
/**
|
||||||
|
* How much of the output resource the conversion can currently convert for.
|
||||||
|
* Typically this will be set for you in a conversion constructor.
|
||||||
|
*/
|
||||||
|
currentGain: MaybeRef<DecimalSource>;
|
||||||
|
/**
|
||||||
|
* The absolute amount the output resource will be changed by.
|
||||||
|
* Typically this will be set for you in a conversion constructor.
|
||||||
|
* This will differ from {@link currentGain} in the cases where the conversion isn't just adding the converted amount to the output resource.
|
||||||
|
*/
|
||||||
|
actualGain: MaybeRef<DecimalSource>;
|
||||||
|
/**
|
||||||
|
* The amount of the input resource currently being required in order to produce the {@link currentGain}.
|
||||||
|
* That is, if it went below this value then {@link currentGain} would decrease.
|
||||||
|
* Typically this will be set for you in a conversion constructor.
|
||||||
|
*/
|
||||||
|
currentAt: MaybeRef<DecimalSource>;
|
||||||
|
/**
|
||||||
|
* The amount of the input resource required to make {@link currentGain} increase.
|
||||||
|
* Typically this will be set for you in a conversion constructor.
|
||||||
|
*/
|
||||||
|
nextAt: MaybeRef<DecimalSource>;
|
||||||
|
/**
|
||||||
|
* The input {@link features/resources/resource.Resource} for this conversion.
|
||||||
|
*/
|
||||||
|
baseResource: Resource;
|
||||||
|
/**
|
||||||
|
* The output {@link features/resources/resource.Resource} for this conversion. i.e. the resource being generated.
|
||||||
|
*/
|
||||||
|
gainResource: Resource;
|
||||||
|
/**
|
||||||
|
* Whether or not to cap the amount of the output resource gained by converting at 1.
|
||||||
|
* Defaults to true.
|
||||||
|
*/
|
||||||
|
buyMax: MaybeRef<boolean>;
|
||||||
|
/**
|
||||||
|
* The function that performs the actual conversion from {@link baseResource} to {@link gainResource}.
|
||||||
|
* Typically this will be set for you in a conversion constructor.
|
||||||
*/
|
*/
|
||||||
convert: VoidFunction;
|
convert: VoidFunction;
|
||||||
|
/**
|
||||||
|
* The function that spends the {@link baseResource} as part of the conversion.
|
||||||
|
* Defaults to setting the {@link baseResource} amount to 0.
|
||||||
|
*/
|
||||||
|
spend: (amountGained: DecimalSource) => void;
|
||||||
|
/**
|
||||||
|
* A callback that happens after a conversion has been completed.
|
||||||
|
* Receives the amount gained via conversion.
|
||||||
|
* This will not be called whenever using currentGain without calling convert (e.g. passive generation)
|
||||||
|
*/
|
||||||
|
onConvert?: (amountGained: DecimalSource) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An object that converts one {@link features/resources/resource.Resource} into another at a given rate. */
|
|
||||||
export type Conversion<T extends ConversionOptions> = Replace<
|
|
||||||
T & BaseConversion,
|
|
||||||
{
|
|
||||||
formula: InvertibleFormula;
|
|
||||||
currentGain: GetComputableTypeWithDefault<T["currentGain"], Ref<DecimalSource>>;
|
|
||||||
actualGain: GetComputableTypeWithDefault<T["actualGain"], Ref<DecimalSource>>;
|
|
||||||
currentAt: GetComputableTypeWithDefault<T["currentAt"], Ref<DecimalSource>>;
|
|
||||||
nextAt: GetComputableTypeWithDefault<T["nextAt"], Ref<DecimalSource>>;
|
|
||||||
buyMax: GetComputableTypeWithDefault<T["buyMax"], true>;
|
|
||||||
spend: undefined extends T["spend"] ? (amountGained: DecimalSource) => void : T["spend"];
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** A type that matches any valid {@link Conversion} object. */
|
|
||||||
export type GenericConversion = Replace<
|
|
||||||
Conversion<ConversionOptions>,
|
|
||||||
{
|
|
||||||
currentGain: ProcessedComputable<DecimalSource>;
|
|
||||||
actualGain: ProcessedComputable<DecimalSource>;
|
|
||||||
currentAt: ProcessedComputable<DecimalSource>;
|
|
||||||
nextAt: ProcessedComputable<DecimalSource>;
|
|
||||||
buyMax: ProcessedComputable<boolean>;
|
|
||||||
spend: (amountGained: DecimalSource) => void;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lazily creates a conversion with the given options.
|
* Lazily creates a conversion with the given options.
|
||||||
* You typically shouldn't use this function directly. Instead use one of the other conversion constructors, which will then call this.
|
* You typically shouldn't use this function directly. Instead use one of the other conversion constructors, which will then call this.
|
||||||
|
@ -118,81 +141,77 @@ export type GenericConversion = Replace<
|
||||||
* @see {@link createCumulativeConversion}.
|
* @see {@link createCumulativeConversion}.
|
||||||
* @see {@link createIndependentConversion}.
|
* @see {@link createIndependentConversion}.
|
||||||
*/
|
*/
|
||||||
export function createConversion<T extends ConversionOptions>(
|
export function createConversion<T extends ConversionOptions>(optionsFunc: () => T) {
|
||||||
optionsFunc: OptionsFunc<T, BaseConversion, GenericConversion>,
|
return createLazyProxy(() => {
|
||||||
...decorators: GenericDecorator[]
|
const options = optionsFunc();
|
||||||
): Conversion<T> {
|
const {
|
||||||
return createLazyProxy(feature => {
|
baseResource,
|
||||||
const conversion = optionsFunc.call(feature, feature);
|
gainResource,
|
||||||
|
formula,
|
||||||
|
currentGain: _currentGain,
|
||||||
|
actualGain,
|
||||||
|
currentAt,
|
||||||
|
nextAt,
|
||||||
|
convert,
|
||||||
|
spend,
|
||||||
|
buyMax,
|
||||||
|
onConvert,
|
||||||
|
...props
|
||||||
|
} = options;
|
||||||
|
|
||||||
for (const decorator of decorators) {
|
const currentGain =
|
||||||
decorator.preConstruct?.(conversion);
|
_currentGain == null
|
||||||
}
|
? computed((): Decimal => {
|
||||||
|
let gain = Decimal.floor(conversion.formula.evaluate(baseResource.value)).max(
|
||||||
|
0
|
||||||
|
);
|
||||||
|
if (unref(conversion.buyMax) === false) {
|
||||||
|
gain = gain.min(1);
|
||||||
|
}
|
||||||
|
return gain;
|
||||||
|
})
|
||||||
|
: processGetter(_currentGain);
|
||||||
|
|
||||||
(conversion as GenericConversion).formula = conversion.formula(
|
const conversion = {
|
||||||
Formula.variable(conversion.baseResource)
|
type: ConversionType,
|
||||||
);
|
...(props as Omit<typeof props, keyof ConversionOptions>),
|
||||||
if (conversion.currentGain == null) {
|
baseResource,
|
||||||
conversion.currentGain = computed(() => {
|
gainResource,
|
||||||
let gain = Decimal.floor(
|
formula: formula(Formula.variable(baseResource)),
|
||||||
(conversion as GenericConversion).formula.evaluate(
|
currentGain,
|
||||||
conversion.baseResource.value
|
actualGain: actualGain == null ? currentGain : processGetter(actualGain),
|
||||||
)
|
currentAt:
|
||||||
).max(0);
|
currentAt == null
|
||||||
if (unref(conversion.buyMax) === false) {
|
? computed(
|
||||||
gain = gain.min(1);
|
(): DecimalSource =>
|
||||||
}
|
conversion.formula.invert(
|
||||||
return gain;
|
Decimal.floor(unref(conversion.currentGain))
|
||||||
});
|
)
|
||||||
}
|
)
|
||||||
if (conversion.actualGain == null) {
|
: processGetter(currentAt),
|
||||||
conversion.actualGain = conversion.currentGain;
|
nextAt:
|
||||||
}
|
nextAt == null
|
||||||
if (conversion.currentAt == null) {
|
? computed(
|
||||||
conversion.currentAt = computed(() => {
|
(): DecimalSource =>
|
||||||
return (conversion as GenericConversion).formula.invert(
|
conversion.formula.invert(
|
||||||
Decimal.floor(unref((conversion as GenericConversion).currentGain))
|
Decimal.floor(unref(conversion.currentGain)).add(1)
|
||||||
);
|
)
|
||||||
});
|
)
|
||||||
}
|
: processGetter(nextAt),
|
||||||
if (conversion.nextAt == null) {
|
convert:
|
||||||
conversion.nextAt = computed(() => {
|
convert ??
|
||||||
return (conversion as GenericConversion).formula.invert(
|
function () {
|
||||||
Decimal.floor(unref((conversion as GenericConversion).currentGain)).add(1)
|
const amountGained = unref(conversion.currentGain);
|
||||||
);
|
gainResource.value = Decimal.add(gainResource.value, amountGained);
|
||||||
});
|
conversion.spend(amountGained);
|
||||||
}
|
onConvert?.(amountGained);
|
||||||
|
},
|
||||||
|
spend: spend ?? (() => (baseResource.value = 0)),
|
||||||
|
buyMax: processGetter(buyMax) ?? true,
|
||||||
|
onConvert
|
||||||
|
} satisfies Conversion;
|
||||||
|
|
||||||
if (conversion.convert == null) {
|
return conversion;
|
||||||
conversion.convert = function () {
|
|
||||||
const amountGained = unref((conversion as GenericConversion).currentGain);
|
|
||||||
conversion.gainResource.value = Decimal.add(
|
|
||||||
conversion.gainResource.value,
|
|
||||||
amountGained
|
|
||||||
);
|
|
||||||
(conversion as GenericConversion).spend(amountGained);
|
|
||||||
conversion.onConvert?.(amountGained);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (conversion.spend == null) {
|
|
||||||
conversion.spend = function () {
|
|
||||||
conversion.baseResource.value = 0;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
processComputable(conversion as T, "currentGain");
|
|
||||||
processComputable(conversion as T, "actualGain");
|
|
||||||
processComputable(conversion as T, "currentAt");
|
|
||||||
processComputable(conversion as T, "nextAt");
|
|
||||||
processComputable(conversion as T, "buyMax");
|
|
||||||
setDefault(conversion, "buyMax", true);
|
|
||||||
|
|
||||||
for (const decorator of decorators) {
|
|
||||||
decorator.postConstruct?.(conversion);
|
|
||||||
}
|
|
||||||
|
|
||||||
return conversion as unknown as Conversion<T>;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -202,9 +221,7 @@ export function createConversion<T extends ConversionOptions>(
|
||||||
* This is equivalent to just calling createConversion directly.
|
* This is equivalent to just calling createConversion directly.
|
||||||
* @param optionsFunc Conversion options.
|
* @param optionsFunc Conversion options.
|
||||||
*/
|
*/
|
||||||
export function createCumulativeConversion<S extends ConversionOptions>(
|
export function createCumulativeConversion<T extends ConversionOptions>(optionsFunc: () => T) {
|
||||||
optionsFunc: OptionsFunc<S, BaseConversion, GenericConversion>
|
|
||||||
): Conversion<S> {
|
|
||||||
return createConversion(optionsFunc);
|
return createConversion(optionsFunc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -213,55 +230,46 @@ export function createCumulativeConversion<S extends ConversionOptions>(
|
||||||
* This is similar to the behavior of "static" layers in The Modding Tree.
|
* This is similar to the behavior of "static" layers in The Modding Tree.
|
||||||
* @param optionsFunc Converison options.
|
* @param optionsFunc Converison options.
|
||||||
*/
|
*/
|
||||||
export function createIndependentConversion<S extends ConversionOptions>(
|
export function createIndependentConversion<T extends ConversionOptions>(optionsFunc: () => T) {
|
||||||
optionsFunc: OptionsFunc<S, BaseConversion, GenericConversion>
|
const conversion = createConversion(() => {
|
||||||
): Conversion<S> {
|
const options = optionsFunc();
|
||||||
return createConversion(feature => {
|
|
||||||
const conversion: S = optionsFunc.call(feature, feature);
|
|
||||||
|
|
||||||
setDefault(conversion, "buyMax", false);
|
options.buyMax ??= false;
|
||||||
|
|
||||||
if (conversion.currentGain == null) {
|
options.currentGain ??= computed(() => {
|
||||||
conversion.currentGain = computed(() => {
|
let gain = Decimal.floor(conversion.formula.evaluate(options.baseResource.value)).max(
|
||||||
let gain = Decimal.floor(
|
options.gainResource.value
|
||||||
(conversion as unknown as GenericConversion).formula.evaluate(
|
|
||||||
conversion.baseResource.value
|
|
||||||
)
|
|
||||||
).max(conversion.gainResource.value);
|
|
||||||
if (unref(conversion.buyMax) === false) {
|
|
||||||
gain = gain.min(Decimal.add(conversion.gainResource.value, 1));
|
|
||||||
}
|
|
||||||
return gain;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (conversion.actualGain == null) {
|
|
||||||
conversion.actualGain = computed(() => {
|
|
||||||
let gain = Decimal.sub(
|
|
||||||
(conversion as unknown as GenericConversion).formula.evaluate(
|
|
||||||
conversion.baseResource.value
|
|
||||||
),
|
|
||||||
conversion.gainResource.value
|
|
||||||
)
|
|
||||||
.floor()
|
|
||||||
.max(0);
|
|
||||||
|
|
||||||
if (unref(conversion.buyMax) === false) {
|
|
||||||
gain = gain.min(1);
|
|
||||||
}
|
|
||||||
return gain;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setDefault(conversion, "convert", function () {
|
|
||||||
const amountGained = unref((conversion as unknown as GenericConversion).actualGain);
|
|
||||||
conversion.gainResource.value = unref(
|
|
||||||
(conversion as unknown as GenericConversion).currentGain
|
|
||||||
);
|
);
|
||||||
(conversion as unknown as GenericConversion).spend(amountGained);
|
if (unref(options.buyMax as MaybeRef<boolean>) === false) {
|
||||||
conversion.onConvert?.(amountGained);
|
gain = gain.min(Decimal.add(options.gainResource.value, 1));
|
||||||
|
}
|
||||||
|
return gain;
|
||||||
});
|
});
|
||||||
|
|
||||||
return conversion;
|
options.actualGain ??= computed(() => {
|
||||||
}) as Conversion<S>;
|
let gain = Decimal.sub(
|
||||||
|
conversion.formula.evaluate(options.baseResource.value),
|
||||||
|
options.gainResource.value
|
||||||
|
)
|
||||||
|
.floor()
|
||||||
|
.max(0);
|
||||||
|
|
||||||
|
if (unref(options.buyMax as MaybeRef<boolean>) === false) {
|
||||||
|
gain = gain.min(1);
|
||||||
|
}
|
||||||
|
return gain;
|
||||||
|
});
|
||||||
|
|
||||||
|
options.convert ??= function () {
|
||||||
|
const amountGained = unref(conversion.actualGain);
|
||||||
|
options.gainResource.value = unref(conversion.currentGain);
|
||||||
|
conversion.spend(amountGained);
|
||||||
|
conversion.onConvert?.(amountGained);
|
||||||
|
};
|
||||||
|
|
||||||
|
return options;
|
||||||
|
});
|
||||||
|
return conversion;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -275,12 +283,12 @@ export function createIndependentConversion<S extends ConversionOptions>(
|
||||||
*/
|
*/
|
||||||
export function setupPassiveGeneration(
|
export function setupPassiveGeneration(
|
||||||
layer: BaseLayer,
|
layer: BaseLayer,
|
||||||
conversion: GenericConversion,
|
conversion: Conversion,
|
||||||
rate: Computable<DecimalSource> = 1,
|
rate: MaybeRefOrGetter<DecimalSource> = 1,
|
||||||
cap: Computable<DecimalSource> = Decimal.dInf
|
cap: MaybeRefOrGetter<DecimalSource> = Decimal.dInf
|
||||||
): void {
|
): void {
|
||||||
const processedRate = convertComputable(rate);
|
const processedRate = processGetter(rate);
|
||||||
const processedCap = convertComputable(cap);
|
const processedCap = processGetter(cap);
|
||||||
layer.on("preUpdate", diff => {
|
layer.on("preUpdate", diff => {
|
||||||
const currRate = unref(processedRate);
|
const currRate = unref(processedRate);
|
||||||
if (Decimal.neq(currRate, 0)) {
|
if (Decimal.neq(currRate, 0)) {
|
||||||
|
@ -300,11 +308,11 @@ export function setupPassiveGeneration(
|
||||||
* @param minGainAmount The minimum gain amount that must be met for the requirement to be met
|
* @param minGainAmount The minimum gain amount that must be met for the requirement to be met
|
||||||
*/
|
*/
|
||||||
export function createCanConvertRequirement(
|
export function createCanConvertRequirement(
|
||||||
conversion: GenericConversion,
|
conversion: Conversion,
|
||||||
minGainAmount: Computable<DecimalSource> = 1,
|
minGainAmount: MaybeRefOrGetter<DecimalSource> = 1,
|
||||||
display?: CoercableComponent
|
display?: MaybeRefOrGetter<Renderable>
|
||||||
) {
|
) {
|
||||||
const computedMinGainAmount = convertComputable(minGainAmount);
|
const computedMinGainAmount = processGetter(minGainAmount);
|
||||||
return createBooleanRequirement(
|
return createBooleanRequirement(
|
||||||
() => Decimal.gte(unref(conversion.actualGain), unref(computedMinGainAmount)),
|
() => Decimal.gte(unref(conversion.actualGain), unref(computedMinGainAmount)),
|
||||||
display
|
display
|
||||||
|
|
|
@ -1,117 +0,0 @@
|
||||||
import { Replace } from "features/feature";
|
|
||||||
import Decimal, { DecimalSource } from "util/bignum";
|
|
||||||
import {
|
|
||||||
Computable,
|
|
||||||
GetComputableType,
|
|
||||||
ProcessedComputable,
|
|
||||||
processComputable
|
|
||||||
} from "util/computed";
|
|
||||||
import { Ref, computed, unref } from "vue";
|
|
||||||
import { Decorator } from "./common";
|
|
||||||
|
|
||||||
export interface BonusAmountFeatureOptions {
|
|
||||||
bonusAmount: Computable<DecimalSource>;
|
|
||||||
totalAmount?: Computable<DecimalSource>;
|
|
||||||
}
|
|
||||||
export interface BonusCompletionsFeatureOptions {
|
|
||||||
bonusCompletions: Computable<DecimalSource>;
|
|
||||||
totalCompletions?: Computable<DecimalSource>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BaseBonusAmountFeature {
|
|
||||||
amount: Ref<DecimalSource>;
|
|
||||||
bonusAmount: ProcessedComputable<DecimalSource>;
|
|
||||||
totalAmount?: Ref<DecimalSource>;
|
|
||||||
}
|
|
||||||
export interface BaseBonusCompletionsFeature {
|
|
||||||
completions: Ref<DecimalSource>;
|
|
||||||
bonusCompletions: ProcessedComputable<DecimalSource>;
|
|
||||||
totalCompletions?: Ref<DecimalSource>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BonusAmountFeature<T extends BonusAmountFeatureOptions> = Replace<
|
|
||||||
T,
|
|
||||||
{ bonusAmount: GetComputableType<T["bonusAmount"]> }
|
|
||||||
>;
|
|
||||||
export type BonusCompletionsFeature<T extends BonusCompletionsFeatureOptions> = Replace<
|
|
||||||
T,
|
|
||||||
{ bonusAmount: GetComputableType<T["bonusCompletions"]> }
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type GenericBonusAmountFeature = Replace<
|
|
||||||
BonusAmountFeature<BonusAmountFeatureOptions>,
|
|
||||||
{
|
|
||||||
bonusAmount: ProcessedComputable<DecimalSource>;
|
|
||||||
totalAmount: ProcessedComputable<DecimalSource>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
export type GenericBonusCompletionsFeature = Replace<
|
|
||||||
BonusCompletionsFeature<BonusCompletionsFeatureOptions>,
|
|
||||||
{
|
|
||||||
bonusCompletions: ProcessedComputable<DecimalSource>;
|
|
||||||
totalCompletions: ProcessedComputable<DecimalSource>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Allows the addition of "bonus levels" to the decorated feature, with an accompanying "total amount".
|
|
||||||
* To function properly, the `createFeature()` function must have its generic type extended by {@linkcode BonusAmountFeatureOptions}.
|
|
||||||
* Additionally, the base feature must have an `amount` property.
|
|
||||||
* To allow access to the decorated values outside the `createFeature()` function, the output type must be extended by {@linkcode GenericBonusAmountFeature}.
|
|
||||||
* @example ```ts
|
|
||||||
* createRepeatable<RepeatableOptions & BonusAmountFeatureOptions>(() => ({
|
|
||||||
* bonusAmount: noPersist(otherRepeatable.amount),
|
|
||||||
* ...
|
|
||||||
* }), bonusAmountDecorator) as GenericRepeatable & GenericBonusAmountFeature
|
|
||||||
*/
|
|
||||||
export const bonusAmountDecorator: Decorator<
|
|
||||||
BonusAmountFeatureOptions,
|
|
||||||
BaseBonusAmountFeature,
|
|
||||||
GenericBonusAmountFeature
|
|
||||||
> = {
|
|
||||||
postConstruct(feature) {
|
|
||||||
if (feature.amount === undefined) {
|
|
||||||
console.error(
|
|
||||||
`Decorated feature ${feature.id} does not contain the required 'amount' property"`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
processComputable(feature, "bonusAmount");
|
|
||||||
if (feature.totalAmount === undefined) {
|
|
||||||
feature.totalAmount = computed(() =>
|
|
||||||
Decimal.add(
|
|
||||||
unref(feature.amount ?? 0),
|
|
||||||
unref(feature.bonusAmount as ProcessedComputable<DecimalSource>)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Allows the addition of "bonus levels" to the decorated feature, with an accompanying "total amount".
|
|
||||||
* To function properly, the `createFeature()` function must have its generic type extended by {@linkcode BonusCompletionFeatureOptions}.
|
|
||||||
* To allow access to the decorated values outside the `createFeature()` function, the output type must be extended by {@linkcode GenericBonusCompletionFeature}.
|
|
||||||
* @example ```ts
|
|
||||||
* createChallenge<ChallengeOptions & BonusCompletionFeatureOptions>(() => ({
|
|
||||||
* bonusCompletions: noPersist(otherChallenge.completions),
|
|
||||||
* ...
|
|
||||||
* }), bonusCompletionDecorator) as GenericChallenge & GenericBonusCompletionFeature
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export const bonusCompletionsDecorator: Decorator<
|
|
||||||
BonusCompletionsFeatureOptions,
|
|
||||||
BaseBonusCompletionsFeature,
|
|
||||||
GenericBonusCompletionsFeature
|
|
||||||
> = {
|
|
||||||
postConstruct(feature) {
|
|
||||||
processComputable(feature, "bonusCompletions");
|
|
||||||
if (feature.totalCompletions === undefined) {
|
|
||||||
feature.totalCompletions = computed(() =>
|
|
||||||
Decimal.add(
|
|
||||||
unref(feature.completions ?? 0),
|
|
||||||
unref(feature.bonusCompletions as ProcessedComputable<DecimalSource>)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
|
@ -1,59 +0,0 @@
|
||||||
import { Replace, OptionsObject } from "../feature";
|
|
||||||
import {
|
|
||||||
Computable,
|
|
||||||
GetComputableType,
|
|
||||||
processComputable,
|
|
||||||
ProcessedComputable
|
|
||||||
} from "util/computed";
|
|
||||||
import { Persistent, State } from "game/persistence";
|
|
||||||
|
|
||||||
export type Decorator<
|
|
||||||
FeatureOptions,
|
|
||||||
BaseFeature = object,
|
|
||||||
GenericFeature = BaseFeature,
|
|
||||||
S extends State = State
|
|
||||||
> = {
|
|
||||||
getPersistentData?(): Record<string, Persistent<S>>;
|
|
||||||
preConstruct?(
|
|
||||||
feature: OptionsObject<FeatureOptions, BaseFeature & { id: string }, GenericFeature>
|
|
||||||
): void;
|
|
||||||
postConstruct?(
|
|
||||||
feature: OptionsObject<FeatureOptions, BaseFeature & { id: string }, GenericFeature>
|
|
||||||
): void;
|
|
||||||
getGatheredProps?(
|
|
||||||
feature: OptionsObject<FeatureOptions, BaseFeature & { id: string }, GenericFeature>
|
|
||||||
): Partial<OptionsObject<FeatureOptions, BaseFeature & { id: string }, GenericFeature>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GenericDecorator = Decorator<unknown>;
|
|
||||||
|
|
||||||
export interface EffectFeatureOptions<T = unknown> {
|
|
||||||
effect: Computable<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EffectFeature<T extends EffectFeatureOptions> = Replace<
|
|
||||||
T,
|
|
||||||
{ effect: GetComputableType<T["effect"]> }
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type GenericEffectFeature<T = unknown> = Replace<
|
|
||||||
EffectFeature<EffectFeatureOptions>,
|
|
||||||
{ effect: ProcessedComputable<T> }
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Allows the usage of an `effect` field in the decorated feature.
|
|
||||||
* To function properly, the `createFeature()` function must have its generic type extended by {@linkcode EffectFeatureOptions}.
|
|
||||||
* To allow access to the decorated values outside the `createFeature()` function, the output type must be extended by {@linkcode GenericEffectFeature}.
|
|
||||||
* @example ```ts
|
|
||||||
* createRepeatable<RepeatableOptions & EffectFeatureOptions>(() => ({
|
|
||||||
* effect() { return Decimal.pow(2, this.amount); },
|
|
||||||
* ...
|
|
||||||
* }), effectDecorator) as GenericUpgrade & GenericEffectFeature;
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export const effectDecorator: Decorator<EffectFeatureOptions, unknown, GenericEffectFeature> = {
|
|
||||||
postConstruct(feature) {
|
|
||||||
processComputable(feature, "effect");
|
|
||||||
}
|
|
||||||
};
|
|
|
@ -1,50 +1,6 @@
|
||||||
import Decimal from "util/bignum";
|
import Decimal from "util/bignum";
|
||||||
import { DoNotCache, ProcessedComputable } from "util/computed";
|
import { Renderable, renderCol, VueFeature } from "util/vue";
|
||||||
import type { CSSProperties, DefineComponent } from "vue";
|
import { computed, isRef, MaybeRef, Ref, unref } from "vue";
|
||||||
import { isRef, unref } from "vue";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A symbol to use as a key for a vue component a feature can be rendered with
|
|
||||||
* @see {@link util/vue.VueFeature}
|
|
||||||
*/
|
|
||||||
export const Component = Symbol("Component");
|
|
||||||
/**
|
|
||||||
* A symbol to use as a key for a prop gathering function that a feature can use to send to its component
|
|
||||||
* @see {@link util/vue.VueFeature}
|
|
||||||
*/
|
|
||||||
export const GatherProps = Symbol("GatherProps");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A type referring to a function that returns JSX and is marked that it shouldn't be wrapped in a ComputedRef
|
|
||||||
* @see {@link jsx}
|
|
||||||
*/
|
|
||||||
export type JSXFunction = (() => JSX.Element) & { [DoNotCache]: true };
|
|
||||||
/**
|
|
||||||
* Any value that can be coerced into (or is) a vue component
|
|
||||||
*/
|
|
||||||
export type CoercableComponent = string | DefineComponent | JSXFunction;
|
|
||||||
/**
|
|
||||||
* Any value that can be passed into an HTML element's style attribute.
|
|
||||||
* Note that Profectus uses its own StyleValue and CSSProperties that are extended,
|
|
||||||
* in order to have additional properties added to them, such as variable CSS variables.
|
|
||||||
*/
|
|
||||||
export type StyleValue = string | CSSProperties | Array<string | CSSProperties>;
|
|
||||||
|
|
||||||
/** A type that refers to any vue component */
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export type GenericComponent = DefineComponent<any, any, any>;
|
|
||||||
|
|
||||||
/** Utility type that is S, with any properties from T that aren't already present in S */
|
|
||||||
export type Replace<T, S> = S & Omit<T, keyof S>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility function for a function that returns an object of a given type,
|
|
||||||
* with "this" bound to what the type will eventually be processed into.
|
|
||||||
* Intended for making lazily evaluated objects.
|
|
||||||
*/
|
|
||||||
export type OptionsFunc<T, R = unknown, S = R> = (obj: R) => OptionsObject<T, R, S>;
|
|
||||||
|
|
||||||
export type OptionsObject<T, R = unknown, S = R> = T & Partial<R> & ThisType<T & S>;
|
|
||||||
|
|
||||||
let id = 0;
|
let id = 0;
|
||||||
/**
|
/**
|
||||||
|
@ -67,34 +23,18 @@ export enum Visibility {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isVisible(visibility: ProcessedComputable<Visibility | boolean>) {
|
export function isVisible(visibility: MaybeRef<Visibility | boolean>) {
|
||||||
const currVisibility = unref(visibility);
|
const currVisibility = unref(visibility);
|
||||||
return currVisibility !== Visibility.None && currVisibility !== false;
|
return currVisibility !== Visibility.None && currVisibility !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isHidden(visibility: ProcessedComputable<Visibility | boolean>) {
|
export function isHidden(visibility: MaybeRef<Visibility | boolean>) {
|
||||||
const currVisibility = unref(visibility);
|
const currVisibility = unref(visibility);
|
||||||
return currVisibility === Visibility.Hidden;
|
return currVisibility === Visibility.Hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function isType<T extends symbol>(object: unknown, type: T): object is { type: T } {
|
||||||
* Takes a function and marks it as JSX so it won't get auto-wrapped into a ComputedRef.
|
return object != null && typeof object === "object" && "type" in object && object.type === type;
|
||||||
* The function may also return empty string as empty JSX tags cause issues.
|
|
||||||
*/
|
|
||||||
export function jsx(func: () => JSX.Element | ""): JSXFunction {
|
|
||||||
(func as Partial<JSXFunction>)[DoNotCache] = true;
|
|
||||||
return func as JSXFunction;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Utility function to set a property on an object if and only if it doesn't already exist */
|
|
||||||
export function setDefault<T, K extends keyof T>(
|
|
||||||
object: T,
|
|
||||||
key: K,
|
|
||||||
value: T[K]
|
|
||||||
): asserts object is Exclude<T, K> & Required<Pick<T, K>> {
|
|
||||||
if (object[key] === undefined && value != undefined) {
|
|
||||||
object[key] = value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -102,11 +42,11 @@ export function setDefault<T, K extends keyof T>(
|
||||||
* @param obj The object to traverse
|
* @param obj The object to traverse
|
||||||
* @param types The feature types that will be searched for
|
* @param types The feature types that will be searched for
|
||||||
*/
|
*/
|
||||||
export function findFeatures(obj: Record<string, unknown>, ...types: symbol[]): unknown[] {
|
export function findFeatures(obj: object, ...types: symbol[]): unknown[] {
|
||||||
const objects: unknown[] = [];
|
const objects: unknown[] = [];
|
||||||
const handleObject = (obj: Record<string, unknown>) => {
|
const handleObject = (obj: object) => {
|
||||||
Object.keys(obj).forEach(key => {
|
Object.keys(obj).forEach(key => {
|
||||||
const value = obj[key];
|
const value: unknown = obj[key as keyof typeof obj];
|
||||||
if (value != null && typeof value === "object") {
|
if (value != null && typeof value === "object") {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
if (types.includes((value as Record<string, any>).type)) {
|
if (types.includes((value as Record<string, any>).type)) {
|
||||||
|
@ -121,6 +61,24 @@ export function findFeatures(obj: Record<string, unknown>, ...types: symbol[]):
|
||||||
return objects;
|
return objects;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getFirstFeature<T extends VueFeature>(
|
||||||
|
features: T[],
|
||||||
|
filter: (feature: T) => boolean
|
||||||
|
): {
|
||||||
|
firstFeature: Ref<T | undefined>;
|
||||||
|
collapsedContent: MaybeRef<Renderable>;
|
||||||
|
hasCollapsedContent: Ref<boolean>;
|
||||||
|
} {
|
||||||
|
const filteredFeatures = computed(() =>
|
||||||
|
features.filter(feature => isVisible(feature.visibility ?? true) && filter(feature))
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
firstFeature: computed(() => filteredFeatures.value[0]),
|
||||||
|
collapsedContent: computed(() => renderCol(...filteredFeatures.value.slice(1))),
|
||||||
|
hasCollapsedContent: computed(() => filteredFeatures.value.length > 1)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Traverses an object and returns all features that are _not_ any of the given types.
|
* Traverses an object and returns all features that are _not_ any of the given types.
|
||||||
* Features are any object with a "type" property that has a symbol value.
|
* Features are any object with a "type" property that has a symbol value.
|
||||||
|
@ -135,7 +93,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)
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -1,60 +0,0 @@
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
v-if="isVisible(visibility)"
|
|
||||||
:style="{
|
|
||||||
visibility: isHidden(visibility) ? 'hidden' : undefined
|
|
||||||
}"
|
|
||||||
class="table-grid"
|
|
||||||
>
|
|
||||||
<div v-for="row in unref(rows)" class="row-grid" :class="{ mergeAdjacent }" :key="row">
|
|
||||||
<GridCell
|
|
||||||
v-for="col in unref(cols)"
|
|
||||||
:key="col"
|
|
||||||
v-bind="gatherCellProps(unref(cells)[row * 100 + col])"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import "components/common/table.css";
|
|
||||||
import themes from "data/themes";
|
|
||||||
import { isHidden, isVisible, Visibility } from "features/feature";
|
|
||||||
import type { GridCell } from "features/grids/grid";
|
|
||||||
import settings from "game/settings";
|
|
||||||
import { processedPropType } from "util/vue";
|
|
||||||
import { computed, defineComponent, unref } from "vue";
|
|
||||||
import GridCellVue from "./GridCell.vue";
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
props: {
|
|
||||||
visibility: {
|
|
||||||
type: processedPropType<Visibility | boolean>(Number, Boolean),
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
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 { 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 };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
|
@ -1,98 +0,0 @@
|
||||||
<template>
|
|
||||||
<button
|
|
||||||
v-if="isVisible(visibility)"
|
|
||||||
:class="{ feature: true, tile: true, can: unref(canClick), locked: !unref(canClick) }"
|
|
||||||
:style="[
|
|
||||||
{
|
|
||||||
visibility: isHidden(visibility) ? 'hidden' : undefined
|
|
||||||
},
|
|
||||||
unref(style) ?? {}
|
|
||||||
]"
|
|
||||||
@click="onClick"
|
|
||||||
@mousedown="start"
|
|
||||||
@mouseleave="stop"
|
|
||||||
@mouseup="stop"
|
|
||||||
@touchstart.passive="start"
|
|
||||||
@touchend.passive="stop"
|
|
||||||
@touchcancel.passive="stop"
|
|
||||||
>
|
|
||||||
<div v-if="title"><component :is="titleComponent" /></div>
|
|
||||||
<component :is="component" style="white-space: pre-line" />
|
|
||||||
<Node :id="id" />
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import "components/common/features.css";
|
|
||||||
import Node from "components/Node.vue";
|
|
||||||
import type { CoercableComponent, StyleValue } from "features/feature";
|
|
||||||
import { isHidden, isVisible, Visibility } from "features/feature";
|
|
||||||
import {
|
|
||||||
computeComponent,
|
|
||||||
computeOptionalComponent,
|
|
||||||
processedPropType,
|
|
||||||
setupHoldToClick
|
|
||||||
} from "util/vue";
|
|
||||||
import type { PropType } from "vue";
|
|
||||||
import { defineComponent, toRefs, unref } from "vue";
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
props: {
|
|
||||||
visibility: {
|
|
||||||
type: processedPropType<Visibility | boolean>(Number, Boolean),
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
onClick: Function as PropType<(e?: MouseEvent | TouchEvent) => void>,
|
|
||||||
onHold: Function as PropType<VoidFunction>,
|
|
||||||
display: {
|
|
||||||
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 component = computeComponent(display);
|
|
||||||
|
|
||||||
return {
|
|
||||||
start,
|
|
||||||
stop,
|
|
||||||
titleComponent,
|
|
||||||
component,
|
|
||||||
Visibility,
|
|
||||||
unref,
|
|
||||||
isVisible,
|
|
||||||
isHidden
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.tile {
|
|
||||||
min-height: 80px;
|
|
||||||
width: 80px;
|
|
||||||
font-size: 10px;
|
|
||||||
background-color: var(--layer-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile > * {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,370 +0,0 @@
|
||||||
import type {
|
|
||||||
CoercableComponent,
|
|
||||||
GenericComponent,
|
|
||||||
OptionsFunc,
|
|
||||||
Replace,
|
|
||||||
StyleValue
|
|
||||||
} from "features/feature";
|
|
||||||
import { Component, GatherProps, getUniqueID, setDefault, Visibility } from "features/feature";
|
|
||||||
import GridComponent from "features/grids/Grid.vue";
|
|
||||||
import type { Persistent, State } from "game/persistence";
|
|
||||||
import { persistent } from "game/persistence";
|
|
||||||
import { isFunction } from "util/common";
|
|
||||||
import type {
|
|
||||||
Computable,
|
|
||||||
GetComputableType,
|
|
||||||
GetComputableTypeWithDefault,
|
|
||||||
ProcessedComputable
|
|
||||||
} from "util/computed";
|
|
||||||
import { processComputable } from "util/computed";
|
|
||||||
import { createLazyProxy } from "util/proxies";
|
|
||||||
import type { Ref } from "vue";
|
|
||||||
import { computed, unref } from "vue";
|
|
||||||
|
|
||||||
/** A symbol used to identify {@link Grid} features. */
|
|
||||||
export const GridType = Symbol("Grid");
|
|
||||||
|
|
||||||
/** A type representing a computable value for a cell in the grid. */
|
|
||||||
export type CellComputable<T> = Computable<T> | ((id: string | number, state: State) => T);
|
|
||||||
|
|
||||||
/** Create proxy to more easily get the properties of cells on a grid. */
|
|
||||||
function createGridProxy(grid: GenericGrid): Record<string | number, GridCell> {
|
|
||||||
return new Proxy({}, getGridHandler(grid)) as Record<string | number, GridCell>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns traps for a proxy that will give cell proxies when accessing any numerical key.
|
|
||||||
* @param grid The grid to get the cells from.
|
|
||||||
* @see {@link createGridProxy}
|
|
||||||
*/
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
function getGridHandler(grid: GenericGrid): ProxyHandler<Record<string | number, GridCell>> {
|
|
||||||
const keys = computed(() => {
|
|
||||||
const keys = [];
|
|
||||||
for (let row = 1; row <= unref(grid.rows); row++) {
|
|
||||||
for (let col = 1; col <= unref(grid.cols); col++) {
|
|
||||||
keys.push((row * 100 + col).toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return keys;
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
get(target: Record<string | number, GridCell>, key: PropertyKey) {
|
|
||||||
if (key === "isProxy") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof key === "symbol") {
|
|
||||||
return (grid as never)[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!keys.value.includes(key.toString())) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (target[key] == null) {
|
|
||||||
target[key] = new Proxy(
|
|
||||||
grid,
|
|
||||||
getCellHandler(key.toString())
|
|
||||||
) as unknown as GridCell;
|
|
||||||
}
|
|
||||||
|
|
||||||
return target[key];
|
|
||||||
},
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
set(target: Record<string | number, GridCell>, key: PropertyKey, value: any) {
|
|
||||||
console.warn("Cannot set grid cells", target, key, value);
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
ownKeys() {
|
|
||||||
return keys.value;
|
|
||||||
},
|
|
||||||
has(target: Record<string | number, GridCell>, key: PropertyKey) {
|
|
||||||
return keys.value.includes(key.toString());
|
|
||||||
},
|
|
||||||
getOwnPropertyDescriptor(target: Record<string | number, GridCell>, key: PropertyKey) {
|
|
||||||
if (keys.value.includes(key.toString())) {
|
|
||||||
return {
|
|
||||||
configurable: true,
|
|
||||||
enumerable: true,
|
|
||||||
writable: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns traps for a proxy that will get the properties for the specified cell
|
|
||||||
* @param id The grid cell ID to get properties from.
|
|
||||||
* @see {@link getGridHandler}
|
|
||||||
* @see {@link createGridProxy}
|
|
||||||
*/
|
|
||||||
function getCellHandler(id: string): ProxyHandler<GenericGrid> {
|
|
||||||
const keys = [
|
|
||||||
"id",
|
|
||||||
"visibility",
|
|
||||||
"canClick",
|
|
||||||
"startState",
|
|
||||||
"state",
|
|
||||||
"style",
|
|
||||||
"classes",
|
|
||||||
"title",
|
|
||||||
"display",
|
|
||||||
"onClick",
|
|
||||||
"onHold"
|
|
||||||
];
|
|
||||||
const cache: Record<string, Ref<unknown>> = {};
|
|
||||||
return {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
get(target, key, receiver): any {
|
|
||||||
if (key === "isProxy") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
let prop = (target as any)[key];
|
|
||||||
|
|
||||||
if (isFunction(prop)) {
|
|
||||||
return () => prop.call(receiver, id, target.getState(id));
|
|
||||||
}
|
|
||||||
if (prop != undefined || typeof key === "symbol") {
|
|
||||||
return prop;
|
|
||||||
}
|
|
||||||
|
|
||||||
key = key.slice(0, 1).toUpperCase() + key.slice(1);
|
|
||||||
|
|
||||||
if (key === "startState") {
|
|
||||||
return prop.call(receiver, id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
prop = (target as any)[`get${key}`];
|
|
||||||
if (isFunction(prop)) {
|
|
||||||
if (!(key in cache)) {
|
|
||||||
cache[key] = computed(() => prop.call(receiver, id, target.getState(id)));
|
|
||||||
}
|
|
||||||
return cache[key].value;
|
|
||||||
} else if (prop != undefined) {
|
|
||||||
return unref(prop);
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
prop = (target as any)[`on${key}`];
|
|
||||||
if (isFunction(prop)) {
|
|
||||||
return () => prop.call(receiver, id, target.getState(id));
|
|
||||||
} else if (prop != undefined) {
|
|
||||||
return prop;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
return (target as any)[key];
|
|
||||||
},
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
set(target: Record<string, any>, key: string, value: any, receiver: typeof Proxy): boolean {
|
|
||||||
key = `set${key.slice(0, 1).toUpperCase() + key.slice(1)}`;
|
|
||||||
if (key in target && isFunction(target[key]) && target[key].length < 3) {
|
|
||||||
target[key].call(receiver, id, value);
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
console.warn(`No setter for "${key}".`, target);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
ownKeys() {
|
|
||||||
return keys;
|
|
||||||
},
|
|
||||||
has(target, key) {
|
|
||||||
return keys.includes(key.toString());
|
|
||||||
},
|
|
||||||
getOwnPropertyDescriptor(target, key) {
|
|
||||||
if (keys.includes(key.toString())) {
|
|
||||||
return {
|
|
||||||
configurable: true,
|
|
||||||
enumerable: true,
|
|
||||||
writable: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a cell within a grid. These properties will typically be accessed via a cell proxy that calls functions on the grid to get the properties for a specific cell.
|
|
||||||
* @see {@link createGridProxy}
|
|
||||||
*/
|
|
||||||
export interface GridCell {
|
|
||||||
/** A unique identifier for the grid cell. */
|
|
||||||
id: string;
|
|
||||||
/** Whether this cell should be visible. */
|
|
||||||
visibility: Visibility | boolean;
|
|
||||||
/** Whether this cell can be clicked. */
|
|
||||||
canClick: boolean;
|
|
||||||
/** The initial persistent state of this cell. */
|
|
||||||
startState: State;
|
|
||||||
/** The persistent state of this cell. */
|
|
||||||
state: State;
|
|
||||||
/** CSS to apply to this feature. */
|
|
||||||
style?: StyleValue;
|
|
||||||
/** Dictionary of CSS classes to apply to this feature. */
|
|
||||||
classes?: Record<string, boolean>;
|
|
||||||
/** A header to appear at the top of the display. */
|
|
||||||
title?: CoercableComponent;
|
|
||||||
/** The main text that appears in the display. */
|
|
||||||
display: CoercableComponent;
|
|
||||||
/** A function that is called when the cell is clicked. */
|
|
||||||
onClick?: (e?: MouseEvent | TouchEvent) => void;
|
|
||||||
/** A function that is called when the cell is held down. */
|
|
||||||
onHold?: VoidFunction;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An object that configures a {@link Grid}.
|
|
||||||
*/
|
|
||||||
export interface GridOptions {
|
|
||||||
/** Whether this grid should be visible. */
|
|
||||||
visibility?: Computable<Visibility | boolean>;
|
|
||||||
/** The number of rows in the grid. */
|
|
||||||
rows: Computable<number>;
|
|
||||||
/** The number of columns in the grid. */
|
|
||||||
cols: Computable<number>;
|
|
||||||
/** A computable to determine the visibility of a cell. */
|
|
||||||
getVisibility?: CellComputable<Visibility | boolean>;
|
|
||||||
/** A computable to determine if a cell can be clicked. */
|
|
||||||
getCanClick?: CellComputable<boolean>;
|
|
||||||
/** A computable to get the initial persistent state of a cell. */
|
|
||||||
getStartState: Computable<State> | ((id: string | number) => State);
|
|
||||||
/** A computable to get the CSS styles for a cell. */
|
|
||||||
getStyle?: CellComputable<StyleValue>;
|
|
||||||
/** A computable to get the CSS classes for a cell. */
|
|
||||||
getClasses?: CellComputable<Record<string, boolean>>;
|
|
||||||
/** A computable to get the title component for a cell. */
|
|
||||||
getTitle?: CellComputable<CoercableComponent>;
|
|
||||||
/** A computable to get the display component for a cell. */
|
|
||||||
getDisplay: CellComputable<CoercableComponent>;
|
|
||||||
/** A function that is called when a cell is clicked. */
|
|
||||||
onClick?: (id: string | number, state: State, e?: MouseEvent | TouchEvent) => void;
|
|
||||||
/** A function that is called when a cell is held down. */
|
|
||||||
onHold?: (id: string | number, state: State) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The properties that are added onto a processed {@link BoardOptions} to create a {@link Board}.
|
|
||||||
*/
|
|
||||||
export interface BaseGrid {
|
|
||||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
|
||||||
id: string;
|
|
||||||
/** Get the auto-generated ID for identifying a specific cell of this grid that appears in the DOM. Will not persist between refreshes or updates. */
|
|
||||||
getID: (id: string | number, state: State) => string;
|
|
||||||
/** Get the persistent state of the given cell. */
|
|
||||||
getState: (id: string | number) => State;
|
|
||||||
/** Set the persistent state of the given cell. */
|
|
||||||
setState: (id: string | number, state: State) => void;
|
|
||||||
/** A dictionary of cells within this grid. */
|
|
||||||
cells: Record<string | number, GridCell>;
|
|
||||||
/** The persistent state of this grid, which is a dictionary of cell states. */
|
|
||||||
cellState: Persistent<Record<string | number, State>>;
|
|
||||||
/** A symbol that helps identify features of the same type. */
|
|
||||||
type: typeof GridType;
|
|
||||||
/** The Vue component used to render this feature. */
|
|
||||||
[Component]: GenericComponent;
|
|
||||||
/** A function to gather the props the vue component requires for this feature. */
|
|
||||||
[GatherProps]: () => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An object that represents a feature that is a grid of cells that all behave according to the same rules. */
|
|
||||||
export type Grid<T extends GridOptions> = Replace<
|
|
||||||
T & BaseGrid,
|
|
||||||
{
|
|
||||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
|
||||||
rows: GetComputableType<T["rows"]>;
|
|
||||||
cols: GetComputableType<T["cols"]>;
|
|
||||||
getVisibility: GetComputableTypeWithDefault<T["getVisibility"], Visibility.Visible>;
|
|
||||||
getCanClick: GetComputableTypeWithDefault<T["getCanClick"], true>;
|
|
||||||
getStartState: GetComputableType<T["getStartState"]>;
|
|
||||||
getStyle: GetComputableType<T["getStyle"]>;
|
|
||||||
getClasses: GetComputableType<T["getClasses"]>;
|
|
||||||
getTitle: GetComputableType<T["getTitle"]>;
|
|
||||||
getDisplay: GetComputableType<T["getDisplay"]>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** A type that matches any valid {@link Grid} object. */
|
|
||||||
export type GenericGrid = Replace<
|
|
||||||
Grid<GridOptions>,
|
|
||||||
{
|
|
||||||
visibility: ProcessedComputable<Visibility | boolean>;
|
|
||||||
getVisibility: ProcessedComputable<Visibility | boolean>;
|
|
||||||
getCanClick: ProcessedComputable<boolean>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lazily creates a grid with the given options.
|
|
||||||
* @param optionsFunc Grid options.
|
|
||||||
*/
|
|
||||||
export function createGrid<T extends GridOptions>(
|
|
||||||
optionsFunc: OptionsFunc<T, BaseGrid, GenericGrid>
|
|
||||||
): Grid<T> {
|
|
||||||
const cellState = persistent<Record<string | number, State>>({}, false);
|
|
||||||
return createLazyProxy(feature => {
|
|
||||||
const grid = optionsFunc.call(feature, feature);
|
|
||||||
grid.id = getUniqueID("grid-");
|
|
||||||
grid[Component] = GridComponent as GenericComponent;
|
|
||||||
|
|
||||||
grid.cellState = cellState;
|
|
||||||
|
|
||||||
grid.getID = function (this: GenericGrid, cell: string | number) {
|
|
||||||
return grid.id + "-" + cell;
|
|
||||||
};
|
|
||||||
grid.getState = function (this: GenericGrid, cell: string | number) {
|
|
||||||
if (this.cellState.value[cell] != undefined) {
|
|
||||||
return cellState.value[cell];
|
|
||||||
}
|
|
||||||
return this.cells[cell].startState;
|
|
||||||
};
|
|
||||||
grid.setState = function (this: GenericGrid, cell: string | number, state: State) {
|
|
||||||
cellState.value[cell] = state;
|
|
||||||
};
|
|
||||||
|
|
||||||
grid.cells = createGridProxy(grid as GenericGrid);
|
|
||||||
|
|
||||||
processComputable(grid as T, "visibility");
|
|
||||||
setDefault(grid, "visibility", Visibility.Visible);
|
|
||||||
processComputable(grid as T, "rows");
|
|
||||||
processComputable(grid as T, "cols");
|
|
||||||
processComputable(grid as T, "getVisibility");
|
|
||||||
setDefault(grid, "getVisibility", Visibility.Visible);
|
|
||||||
processComputable(grid as T, "getCanClick");
|
|
||||||
setDefault(grid, "getCanClick", true);
|
|
||||||
processComputable(grid as T, "getStartState");
|
|
||||||
processComputable(grid as T, "getStyle");
|
|
||||||
processComputable(grid as T, "getClasses");
|
|
||||||
processComputable(grid as T, "getTitle");
|
|
||||||
processComputable(grid as T, "getDisplay");
|
|
||||||
|
|
||||||
if (grid.onClick) {
|
|
||||||
const onClick = grid.onClick.bind(grid);
|
|
||||||
grid.onClick = function (id, state, e) {
|
|
||||||
if (unref((grid as GenericGrid).cells[id].canClick)) {
|
|
||||||
onClick(id, state, e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (grid.onHold) {
|
|
||||||
const onHold = grid.onHold.bind(grid);
|
|
||||||
grid.onHold = function (id, state) {
|
|
||||||
if (unref((grid as GenericGrid).cells[id].canClick)) {
|
|
||||||
onHold(id, state);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
grid[GatherProps] = function (this: GenericGrid) {
|
|
||||||
const { visibility, rows, cols, cells, id } = this;
|
|
||||||
return { visibility, rows, cols, cells, id };
|
|
||||||
};
|
|
||||||
|
|
||||||
return grid as unknown as Grid<T>;
|
|
||||||
});
|
|
||||||
}
|
|
464
src/features/grids/grid.tsx
Normal file
464
src/features/grids/grid.tsx
Normal file
|
@ -0,0 +1,464 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { getUniqueID, Visibility } from "features/feature";
|
||||||
|
import type { Persistent, State } from "game/persistence";
|
||||||
|
import { persistent } from "game/persistence";
|
||||||
|
import { isFunction } from "util/common";
|
||||||
|
import { processGetter } from "util/computed";
|
||||||
|
import { createLazyProxy } from "util/proxies";
|
||||||
|
import { isJSXElement, render, Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
|
||||||
|
import type { CSSProperties, MaybeRef, MaybeRefOrGetter, Ref } from "vue";
|
||||||
|
import { computed, isRef, unref } from "vue";
|
||||||
|
import Column from "components/layout/Column.vue";
|
||||||
|
import Row from "components/layout/Row.vue";
|
||||||
|
import Clickable from "features/clickables/Clickable.vue";
|
||||||
|
|
||||||
|
/** A symbol used to identify {@link Grid} features. */
|
||||||
|
export const GridType = Symbol("Grid");
|
||||||
|
|
||||||
|
/** A type representing a MaybeRefOrGetter value for a cell in the grid. */
|
||||||
|
export type CellMaybeRefOrGetter<T> =
|
||||||
|
| MaybeRefOrGetter<T>
|
||||||
|
| ((row: number, col: number, state: State) => T);
|
||||||
|
export type ProcessedCellRefOrGetter<T> =
|
||||||
|
| MaybeRef<T>
|
||||||
|
| ((row: number, col: number, state: State) => T);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a cell within a grid. These properties will typically be accessed via a cell proxy that calls functions on the grid to get the properties for a specific cell.
|
||||||
|
* @see {@link createGridProxy}
|
||||||
|
*/
|
||||||
|
export interface GridCell extends VueFeature {
|
||||||
|
/** Which roe in the grid this cell is from. */
|
||||||
|
row: number;
|
||||||
|
/** Which col in the grid this cell is from. */
|
||||||
|
col: number;
|
||||||
|
/** Whether this cell can be clicked. */
|
||||||
|
canClick: boolean;
|
||||||
|
/** The initial persistent state of this cell. */
|
||||||
|
startState: State;
|
||||||
|
/** The persistent state of this cell. */
|
||||||
|
state: State;
|
||||||
|
/** The main text that appears in the display. */
|
||||||
|
display: MaybeRef<Renderable>;
|
||||||
|
/** A function that is called when the cell is clicked. */
|
||||||
|
onClick?: (e?: MouseEvent | TouchEvent) => void;
|
||||||
|
/** A function that is called when the cell is held down. */
|
||||||
|
onHold?: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object that configures a {@link Grid}.
|
||||||
|
*/
|
||||||
|
export interface GridOptions extends VueFeatureOptions {
|
||||||
|
/** The number of rows in the grid. */
|
||||||
|
rows: MaybeRefOrGetter<number>;
|
||||||
|
/** The number of columns in the grid. */
|
||||||
|
cols: MaybeRefOrGetter<number>;
|
||||||
|
/** A getter for the visibility of a cell. */
|
||||||
|
getVisibility?: CellMaybeRefOrGetter<Visibility | boolean>;
|
||||||
|
/** A getter for if a cell can be clicked. */
|
||||||
|
getCanClick?: CellMaybeRefOrGetter<boolean>;
|
||||||
|
/** A getter for the initial persistent state of a cell. */
|
||||||
|
getStartState: MaybeRefOrGetter<State> | ((row: number, col: number) => State);
|
||||||
|
/** A getter for the CSS styles for a cell. */
|
||||||
|
getStyle?: CellMaybeRefOrGetter<CSSProperties>;
|
||||||
|
/** A getter for the CSS classes for a cell. */
|
||||||
|
getClasses?: CellMaybeRefOrGetter<Record<string, boolean>>;
|
||||||
|
/** A getter for the display component for a cell. */
|
||||||
|
getDisplay: CellMaybeRefOrGetter<Renderable> | {
|
||||||
|
getTitle?: CellMaybeRefOrGetter<Renderable>;
|
||||||
|
getDescription: CellMaybeRefOrGetter<Renderable>
|
||||||
|
};
|
||||||
|
/** A function that is called when a cell is clicked. */
|
||||||
|
onClick?: (row: number, col: number, state: State, e?: MouseEvent | TouchEvent) => void;
|
||||||
|
/** A function that is called when a cell is held down. */
|
||||||
|
onHold?: (row: number, col: number, state: State) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An object that represents a feature that is a grid of cells that all behave according to the same rules. */
|
||||||
|
export interface Grid extends VueFeature {
|
||||||
|
/** A function that is called when a cell is clicked. */
|
||||||
|
onClick?: (row: number, col: number, state: State, e?: MouseEvent | TouchEvent) => void;
|
||||||
|
/** A function that is called when a cell is held down. */
|
||||||
|
onHold?: (row: number, col: number, state: State) => void;
|
||||||
|
/** A getter for determine the visibility of a cell. */
|
||||||
|
getVisibility?: ProcessedCellRefOrGetter<Visibility | boolean>;
|
||||||
|
/** A getter for determine if a cell can be clicked. */
|
||||||
|
getCanClick?: ProcessedCellRefOrGetter<boolean>;
|
||||||
|
/** The number of rows in the grid. */
|
||||||
|
rows: MaybeRef<number>;
|
||||||
|
/** The number of columns in the grid. */
|
||||||
|
cols: MaybeRef<number>;
|
||||||
|
/** A getter for the initial persistent state of a cell. */
|
||||||
|
getStartState: MaybeRef<State> | ((row: number, col: number) => State);
|
||||||
|
/** A getter for the CSS styles for a cell. */
|
||||||
|
getStyle?: ProcessedCellRefOrGetter<CSSProperties>;
|
||||||
|
/** A getter for the CSS classes for a cell. */
|
||||||
|
getClasses?: ProcessedCellRefOrGetter<Record<string, boolean>>;
|
||||||
|
/** A getter for the display component for a cell. */
|
||||||
|
getDisplay: ProcessedCellRefOrGetter<Renderable>;
|
||||||
|
/** Get the auto-generated ID for identifying a specific cell of this grid that appears in the DOM. Will not persist between refreshes or updates. */
|
||||||
|
getID: (row: number, col: number, state: State) => string;
|
||||||
|
/** Get the persistent state of the given cell. */
|
||||||
|
getState: (row: number, col: number) => State;
|
||||||
|
/** Set the persistent state of the given cell. */
|
||||||
|
setState: (row: number, col: number, state: State) => void;
|
||||||
|
/** A dictionary of cells within this grid. */
|
||||||
|
cells: GridCell[][];
|
||||||
|
/** The persistent state of this grid, which is a dictionary of cell states. */
|
||||||
|
cellState: Persistent<Record<number, Record<number, State>>>;
|
||||||
|
/** A symbol that helps identify features of the same type. */
|
||||||
|
type: typeof GridType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCellRowHandler(grid: Grid, row: number) {
|
||||||
|
return new Proxy({} as GridCell[], {
|
||||||
|
get(target, key) {
|
||||||
|
if (key === "length") {
|
||||||
|
return unref(grid.cols);
|
||||||
|
}
|
||||||
|
if (typeof key !== "number" && typeof key !== "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyNum = typeof key === "number" ? key : parseInt(key);
|
||||||
|
if (Number.isFinite(keyNum) && keyNum < unref(grid.cols)) {
|
||||||
|
if (keyNum in target) {
|
||||||
|
return target[keyNum];
|
||||||
|
}
|
||||||
|
return (target[keyNum] = getCellHandler(grid, row, keyNum));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set(target, key, value) {
|
||||||
|
console.warn("Cannot set grid cells", target, key, value);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
ownKeys() {
|
||||||
|
return [...new Array(unref(grid.cols)).fill(0).map((_, i) => "" + i), "length"];
|
||||||
|
},
|
||||||
|
has(target, key) {
|
||||||
|
if (key === "length") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof key !== "number" && typeof key !== "string") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const keyNum = typeof key === "number" ? key : parseInt(key);
|
||||||
|
if (!Number.isFinite(keyNum) || keyNum >= unref(grid.cols)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
getOwnPropertyDescriptor(target, key) {
|
||||||
|
if (typeof key !== "number" && typeof key !== "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const keyNum = typeof key === "number" ? key : parseInt(key);
|
||||||
|
if (key !== "length" && (!Number.isFinite(keyNum) || keyNum >= unref(grid.cols))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
writable: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns traps for a proxy that will get the properties for the specified cell
|
||||||
|
* @param id The grid cell ID to get properties from.
|
||||||
|
* @see {@link getGridHandler}
|
||||||
|
* @see {@link createGridProxy}
|
||||||
|
*/
|
||||||
|
function getCellHandler(grid: Grid, row: number, col: number): GridCell {
|
||||||
|
const keys = [
|
||||||
|
"id",
|
||||||
|
"visibility",
|
||||||
|
"classes",
|
||||||
|
"style",
|
||||||
|
"components",
|
||||||
|
"wrappers",
|
||||||
|
VueFeature,
|
||||||
|
"row",
|
||||||
|
"col",
|
||||||
|
"canClick",
|
||||||
|
"startState",
|
||||||
|
"state",
|
||||||
|
"title",
|
||||||
|
"display",
|
||||||
|
"onClick",
|
||||||
|
"onHold"
|
||||||
|
] as const;
|
||||||
|
const cache: Record<string, Ref<unknown>> = {};
|
||||||
|
return new Proxy({} as GridCell, {
|
||||||
|
// The typing in this function is absolutely atrocious in order to support custom properties
|
||||||
|
get(target, key, receiver) {
|
||||||
|
switch (key) {
|
||||||
|
case "wrappers":
|
||||||
|
return [];
|
||||||
|
case VueFeature:
|
||||||
|
return true;
|
||||||
|
case "row":
|
||||||
|
return row;
|
||||||
|
case "col":
|
||||||
|
return col;
|
||||||
|
case "startState": {
|
||||||
|
if (typeof grid.getStartState === "function") {
|
||||||
|
return grid.getStartState(row, col);
|
||||||
|
}
|
||||||
|
return unref(grid.getStartState);
|
||||||
|
}
|
||||||
|
case "state": {
|
||||||
|
return grid.getState(row, col);
|
||||||
|
}
|
||||||
|
case "id":
|
||||||
|
return target.id = target.id ?? getUniqueID("gridcell");
|
||||||
|
case "components":
|
||||||
|
return [
|
||||||
|
computed(() => (
|
||||||
|
<Clickable
|
||||||
|
onClick={receiver.onClick}
|
||||||
|
onHold={receiver.onHold}
|
||||||
|
display={receiver.display}
|
||||||
|
canClick={receiver.canClick}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof key === "symbol") {
|
||||||
|
return (grid as any)[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
key = key.slice(0, 1).toUpperCase() + key.slice(1);
|
||||||
|
|
||||||
|
let prop = (grid as any)[`get${key}`];
|
||||||
|
if (isFunction(prop)) {
|
||||||
|
if (!(key in cache)) {
|
||||||
|
cache[key] = computed(() =>
|
||||||
|
prop.call(receiver, row, col, grid.getState(row, col))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return cache[key].value;
|
||||||
|
} else if (prop != null) {
|
||||||
|
return unref(prop);
|
||||||
|
}
|
||||||
|
|
||||||
|
prop = (grid as any)[`on${key}`];
|
||||||
|
if (isFunction(prop)) {
|
||||||
|
return () => prop.call(receiver, row, col, grid.getState(row, col));
|
||||||
|
} else if (prop != null) {
|
||||||
|
return prop;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revert key change
|
||||||
|
key = key.slice(0, 1).toLowerCase() + key.slice(1);
|
||||||
|
prop = (grid as any)[key];
|
||||||
|
|
||||||
|
if (isFunction(prop)) {
|
||||||
|
return () => prop.call(receiver, row, col, grid.getState(row, col));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (grid as any)[key];
|
||||||
|
},
|
||||||
|
set(target, key, value) {
|
||||||
|
console.log("!!?", key, value)
|
||||||
|
if (typeof key !== "string") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
key = `set${key.slice(0, 1).toUpperCase() + key.slice(1)}`;
|
||||||
|
console.log(key, grid[key])
|
||||||
|
if (key in grid && isFunction((grid as any)[key]) && (grid as any)[key].length <= 3) {
|
||||||
|
(grid as any)[key].call(grid, row, col, value);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.warn(`No setter for "${key}".`, target);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ownKeys() {
|
||||||
|
return keys;
|
||||||
|
},
|
||||||
|
has(target, key) {
|
||||||
|
return (keys as readonly (string | symbol)[]).includes(key);
|
||||||
|
},
|
||||||
|
getOwnPropertyDescriptor(target, key) {
|
||||||
|
if ((keys as readonly (string | symbol)[]).includes(key)) {
|
||||||
|
return {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
writable: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertCellMaybeRefOrGetter<T>(
|
||||||
|
value: NonNullable<CellMaybeRefOrGetter<T>>
|
||||||
|
): ProcessedCellRefOrGetter<T>;
|
||||||
|
function convertCellMaybeRefOrGetter<T>(
|
||||||
|
value: CellMaybeRefOrGetter<T> | undefined
|
||||||
|
): ProcessedCellRefOrGetter<T> | undefined;
|
||||||
|
function convertCellMaybeRefOrGetter<T>(
|
||||||
|
value: CellMaybeRefOrGetter<T>
|
||||||
|
): ProcessedCellRefOrGetter<T> {
|
||||||
|
if (typeof value === "function" && value.length > 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return processGetter(value) as MaybeRef<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily creates a grid with the given options.
|
||||||
|
* @param optionsFunc Grid options.
|
||||||
|
*/
|
||||||
|
export function createGrid<T extends GridOptions>(optionsFunc: () => T) {
|
||||||
|
const cellState = persistent<Record<number, Record<number, State>>>({}, false);
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const options = optionsFunc();
|
||||||
|
const {
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
getVisibility,
|
||||||
|
getCanClick,
|
||||||
|
getStartState,
|
||||||
|
getStyle,
|
||||||
|
getClasses,
|
||||||
|
getDisplay: _getDisplay,
|
||||||
|
onClick,
|
||||||
|
onHold,
|
||||||
|
...props
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let getDisplay;
|
||||||
|
if (typeof _getDisplay === "object" && !isRef(_getDisplay) && !isJSXElement(_getDisplay)) {
|
||||||
|
const { getTitle, getDescription } = _getDisplay;
|
||||||
|
const getProcessedTitle = convertCellMaybeRefOrGetter(getTitle);
|
||||||
|
const getProcessedDescription = convertCellMaybeRefOrGetter(getDescription);
|
||||||
|
getDisplay = function(row: number, col: number, state: State) {
|
||||||
|
const title = typeof getProcessedTitle === "function" ? getProcessedTitle(row, col, state) : unref(getProcessedTitle);
|
||||||
|
const description = typeof getProcessedDescription === "function" ? getProcessedDescription(row, col, state) : unref(getProcessedDescription);
|
||||||
|
return <>
|
||||||
|
{title}
|
||||||
|
{description}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
getDisplay = convertCellMaybeRefOrGetter(_getDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
const grid = {
|
||||||
|
type: GridType,
|
||||||
|
...(props as Omit<typeof props, keyof VueFeature | keyof GridOptions>),
|
||||||
|
...vueFeatureMixin("grid", options, () => (
|
||||||
|
<Column>
|
||||||
|
{new Array(unref(grid.rows)).fill(0).map((_, row) => (
|
||||||
|
<Row>
|
||||||
|
{new Array(unref(grid.cols)).fill(0).map((_, col) =>
|
||||||
|
render(grid.cells[row][col]))}
|
||||||
|
</Row>))}
|
||||||
|
</Column>)),
|
||||||
|
cellState,
|
||||||
|
cells: new Proxy({} as GridCell[][], {
|
||||||
|
get(target, key: PropertyKey) {
|
||||||
|
if (key === "length") {
|
||||||
|
return unref(grid.rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof key !== "number" && typeof key !== "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyNum = typeof key === "number" ? key : parseInt(key);
|
||||||
|
if (Number.isFinite(keyNum) && keyNum < unref(grid.rows)) {
|
||||||
|
if (!(keyNum in target)) {
|
||||||
|
target[keyNum] = getCellRowHandler(grid, keyNum);
|
||||||
|
}
|
||||||
|
return target[keyNum];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set(target, key, value) {
|
||||||
|
console.warn("Cannot set grid cells", target, key, value);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
ownKeys(): string[] {
|
||||||
|
return [...new Array(unref(grid.rows)).fill(0).map((_, i) => "" + i), "length"];
|
||||||
|
},
|
||||||
|
has(target, key) {
|
||||||
|
if (key === "length") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof key !== "number" && typeof key !== "string") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const keyNum = typeof key === "number" ? key : parseInt(key);
|
||||||
|
if (!Number.isFinite(keyNum) || keyNum >= unref(grid.rows)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
getOwnPropertyDescriptor(target, key) {
|
||||||
|
if (typeof key !== "number" && typeof key !== "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const keyNum = typeof key === "number" ? key : parseInt(key);
|
||||||
|
if (
|
||||||
|
key !== "length" &&
|
||||||
|
(!Number.isFinite(keyNum) || keyNum >= unref(grid.rows))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
writable: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
rows: processGetter(rows),
|
||||||
|
cols: processGetter(cols),
|
||||||
|
getVisibility: convertCellMaybeRefOrGetter(getVisibility ?? true),
|
||||||
|
getCanClick: convertCellMaybeRefOrGetter(getCanClick ?? true),
|
||||||
|
getStartState: typeof getStartState === "function" && getStartState.length > 0 ?
|
||||||
|
getStartState : processGetter(getStartState),
|
||||||
|
getStyle: convertCellMaybeRefOrGetter(getStyle),
|
||||||
|
getClasses: convertCellMaybeRefOrGetter(getClasses),
|
||||||
|
getDisplay,
|
||||||
|
getID: function (row: number, col: number): string {
|
||||||
|
return grid.id + "-" + row + "-" + col;
|
||||||
|
},
|
||||||
|
getState: function (row: number, col: number): State {
|
||||||
|
cellState.value[row] ??= {};
|
||||||
|
if (cellState.value[row][col] != null) {
|
||||||
|
return cellState.value[row][col];
|
||||||
|
}
|
||||||
|
return grid.cells[row][col].startState;
|
||||||
|
},
|
||||||
|
setState: function (row: number, col: number, state: State) {
|
||||||
|
cellState.value[row] ??= {};
|
||||||
|
cellState.value[row][col] = state;
|
||||||
|
},
|
||||||
|
onClick:
|
||||||
|
onClick == null
|
||||||
|
? undefined
|
||||||
|
: function (row, col, state, e) {
|
||||||
|
if (grid.cells[row][col].canClick !== false) {
|
||||||
|
onClick.call(grid, row, col, state, e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onHold:
|
||||||
|
onHold == null
|
||||||
|
? undefined
|
||||||
|
: function (row, col, state) {
|
||||||
|
if (grid.cells[row][col].canClick !== false) {
|
||||||
|
onHold.call(grid, row, col, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} satisfies Grid;
|
||||||
|
|
||||||
|
return grid;
|
||||||
|
});
|
||||||
|
}
|
|
@ -1,22 +1,15 @@
|
||||||
|
import Hotkey from "components/Hotkey.vue";
|
||||||
import { hasWon } from "data/projEntry";
|
import { hasWon } from "data/projEntry";
|
||||||
import type { OptionsFunc, Replace } from "features/feature";
|
import { findFeatures } from "features/feature";
|
||||||
import { findFeatures, jsx, setDefault } from "features/feature";
|
|
||||||
import { globalBus } from "game/events";
|
import { globalBus } from "game/events";
|
||||||
import player from "game/player";
|
import player from "game/player";
|
||||||
import { registerInfoComponent } from "game/settings";
|
import { registerInfoComponent } from "game/settings";
|
||||||
import type {
|
import { processGetter } from "util/computed";
|
||||||
Computable,
|
|
||||||
GetComputableType,
|
|
||||||
GetComputableTypeWithDefault,
|
|
||||||
ProcessedComputable
|
|
||||||
} from "util/computed";
|
|
||||||
import { processComputable } from "util/computed";
|
|
||||||
import { createLazyProxy } from "util/proxies";
|
import { createLazyProxy } from "util/proxies";
|
||||||
import { shallowReactive, unref } from "vue";
|
import { MaybeRef, MaybeRefOrGetter, shallowReactive, unref } from "vue";
|
||||||
import Hotkey from "components/Hotkey.vue";
|
|
||||||
|
|
||||||
/** A dictionary of all hotkeys. */
|
/** A dictionary of all hotkeys. */
|
||||||
export const hotkeys: Record<string, GenericHotkey | undefined> = shallowReactive({});
|
export const hotkeys: Record<string, Hotkey | undefined> = shallowReactive({});
|
||||||
/** A symbol used to identify {@link Hotkey} features. */
|
/** A symbol used to identify {@link Hotkey} features. */
|
||||||
export const HotkeyType = Symbol("Hotkey");
|
export const HotkeyType = Symbol("Hotkey");
|
||||||
|
|
||||||
|
@ -25,39 +18,28 @@ export const HotkeyType = Symbol("Hotkey");
|
||||||
*/
|
*/
|
||||||
export interface HotkeyOptions {
|
export interface HotkeyOptions {
|
||||||
/** Whether or not this hotkey is currently enabled. */
|
/** Whether or not this hotkey is currently enabled. */
|
||||||
enabled?: Computable<boolean>;
|
enabled?: MaybeRefOrGetter<boolean>;
|
||||||
/** The key tied to this hotkey */
|
/** The key tied to this hotkey */
|
||||||
key: string;
|
key: string;
|
||||||
/** The description of this hotkey, to display in the settings. */
|
/** The description of this hotkey, to display in the settings. */
|
||||||
description: Computable<string>;
|
description: MaybeRefOrGetter<string>;
|
||||||
/** What to do upon pressing the key. */
|
/** What to do upon pressing the key. */
|
||||||
onPress: VoidFunction;
|
onPress: (e?: MouseEvent | TouchEvent) => void;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The properties that are added onto a processed {@link HotkeyOptions} to create an {@link Hotkey}.
|
|
||||||
*/
|
|
||||||
export interface BaseHotkey {
|
|
||||||
/** A symbol that helps identify features of the same type. */
|
|
||||||
type: typeof HotkeyType;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An object that represents a hotkey shortcut that performs an action upon a key sequence being pressed. */
|
/** An object that represents a hotkey shortcut that performs an action upon a key sequence being pressed. */
|
||||||
export type Hotkey<T extends HotkeyOptions> = Replace<
|
export interface Hotkey {
|
||||||
T & BaseHotkey,
|
/** Whether or not this hotkey is currently enabled. */
|
||||||
{
|
enabled: MaybeRef<boolean>;
|
||||||
enabled: GetComputableTypeWithDefault<T["enabled"], true>;
|
/** The key tied to this hotkey */
|
||||||
description: GetComputableType<T["description"]>;
|
key: string;
|
||||||
}
|
/** The description of this hotkey, to display in the settings. */
|
||||||
>;
|
description: MaybeRef<string>;
|
||||||
|
/** What to do upon pressing the key. */
|
||||||
/** A type that matches any valid {@link Hotkey} object. */
|
onPress: (e?: MouseEvent | TouchEvent) => void;
|
||||||
export type GenericHotkey = Replace<
|
/** A symbol that helps identify features of the same type. */
|
||||||
Hotkey<HotkeyOptions>,
|
type: typeof HotkeyType;
|
||||||
{
|
}
|
||||||
enabled: ProcessedComputable<boolean>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
const uppercaseNumbers = [")", "!", "@", "#", "$", "%", "^", "&", "*", "("];
|
const uppercaseNumbers = [")", "!", "@", "#", "$", "%", "^", "&", "*", "("];
|
||||||
|
|
||||||
|
@ -65,29 +47,32 @@ const uppercaseNumbers = [")", "!", "@", "#", "$", "%", "^", "&", "*", "("];
|
||||||
* Lazily creates a hotkey with the given options.
|
* Lazily creates a hotkey with the given options.
|
||||||
* @param optionsFunc Hotkey options.
|
* @param optionsFunc Hotkey options.
|
||||||
*/
|
*/
|
||||||
export function createHotkey<T extends HotkeyOptions>(
|
export function createHotkey<T extends HotkeyOptions>(optionsFunc: () => T) {
|
||||||
optionsFunc: OptionsFunc<T, BaseHotkey, GenericHotkey>
|
return createLazyProxy(() => {
|
||||||
): Hotkey<T> {
|
const options = optionsFunc();
|
||||||
return createLazyProxy(feature => {
|
const { enabled, description, key, onPress, ...props } = options;
|
||||||
const hotkey = optionsFunc.call(feature, feature);
|
|
||||||
hotkey.type = HotkeyType;
|
|
||||||
|
|
||||||
processComputable(hotkey as T, "enabled");
|
const hotkey = {
|
||||||
setDefault(hotkey, "enabled", true);
|
type: HotkeyType,
|
||||||
processComputable(hotkey as T, "description");
|
...(props as Omit<typeof props, keyof HotkeyOptions>),
|
||||||
|
enabled: processGetter(enabled) ?? true,
|
||||||
|
description: processGetter(description),
|
||||||
|
key,
|
||||||
|
onPress
|
||||||
|
} satisfies Hotkey;
|
||||||
|
|
||||||
return hotkey as unknown as Hotkey<T>;
|
return hotkey;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
globalBus.on("addLayer", layer => {
|
globalBus.on("addLayer", layer => {
|
||||||
(findFeatures(layer, HotkeyType) as GenericHotkey[]).forEach(hotkey => {
|
(findFeatures(layer, HotkeyType) as Hotkey[]).forEach(hotkey => {
|
||||||
hotkeys[hotkey.key] = hotkey;
|
hotkeys[hotkey.key] = hotkey;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
globalBus.on("removeLayer", layer => {
|
globalBus.on("removeLayer", layer => {
|
||||||
(findFeatures(layer, HotkeyType) as GenericHotkey[]).forEach(hotkey => {
|
(findFeatures(layer, HotkeyType) as Hotkey[]).forEach(hotkey => {
|
||||||
hotkeys[hotkey.key] = undefined;
|
hotkeys[hotkey.key] = undefined;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -99,24 +84,38 @@ 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;
|
if (hotkey != null && unref(hotkey.enabled) !== false) {
|
||||||
}
|
|
||||||
const hotkey = hotkeys[key];
|
|
||||||
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));
|
const keys = Object.values(hotkeys).filter(hotkey => unref(hotkey?.enabled));
|
||||||
if (keys.length === 0) {
|
if (keys.length === 0) {
|
||||||
return "";
|
return "";
|
||||||
|
@ -128,7 +127,7 @@ registerInfoComponent(
|
||||||
<div style="column-count: 2">
|
<div style="column-count: 2">
|
||||||
{keys.map(hotkey => (
|
{keys.map(hotkey => (
|
||||||
<div>
|
<div>
|
||||||
<Hotkey hotkey={hotkey as GenericHotkey} /> {hotkey?.description}
|
<Hotkey hotkey={hotkey as Hotkey} /> {unref(hotkey?.description)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,15 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="infobox"
|
class="infobox"
|
||||||
v-if="isVisible(visibility)"
|
:style="{
|
||||||
:style="[
|
|
||||||
{
|
|
||||||
borderColor: unref(color),
|
borderColor: unref(color),
|
||||||
visibility: isHidden(visibility) ? 'hidden' : undefined
|
}"
|
||||||
},
|
:class="{ collapsed: unref(collapsed), stacked }"
|
||||||
unref(style) ?? {}
|
|
||||||
]"
|
|
||||||
:class="{ collapsed: unref(collapsed), stacked, ...unref(classes) }"
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="title"
|
class="title"
|
||||||
|
@ -17,78 +12,37 @@
|
||||||
@click="collapsed.value = !unref(collapsed)"
|
@click="collapsed.value = !unref(collapsed)"
|
||||||
>
|
>
|
||||||
<span class="toggle">▼</span>
|
<span class="toggle">▼</span>
|
||||||
<component :is="titleComponent" />
|
<Title />
|
||||||
</button>
|
</button>
|
||||||
<CollapseTransition>
|
<CollapseTransition>
|
||||||
<div v-if="!unref(collapsed)" class="body" :style="{ backgroundColor: unref(color) }">
|
<div v-if="!unref(collapsed)" class="body" :style="unref(bodyStyle)">
|
||||||
<component :is="bodyComponent" :style="unref(bodyStyle)" />
|
<Body />
|
||||||
</div>
|
</div>
|
||||||
</CollapseTransition>
|
</CollapseTransition>
|
||||||
<Node :id="id" />
|
|
||||||
</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 themes from "data/themes";
|
import themes from "data/themes";
|
||||||
import type { CoercableComponent } 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 { render } from "util/vue";
|
||||||
import type { PropType, Ref, StyleValue } from "vue";
|
import { computed, unref } from "vue";
|
||||||
import { computed, defineComponent, toRefs, unref } from "vue";
|
import { Infobox } from "./infobox";
|
||||||
|
|
||||||
export default defineComponent({
|
const props = defineProps<{
|
||||||
props: {
|
color: Infobox["color"];
|
||||||
visibility: {
|
titleStyle: Infobox["titleStyle"];
|
||||||
type: processedPropType<Visibility | boolean>(Number, Boolean),
|
bodyStyle: Infobox["bodyStyle"];
|
||||||
required: true
|
collapsed: Infobox["collapsed"];
|
||||||
},
|
display: Infobox["display"];
|
||||||
display: {
|
title: Infobox["title"];
|
||||||
type: processedPropType<CoercableComponent>(Object, String, Function),
|
}>();
|
||||||
required: true
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
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 Title = () => render(props.title);
|
||||||
const bodyComponent = computeComponent(display);
|
const Body = () => render(props.display);
|
||||||
const stacked = computed(() => themes[settings.theme].mergeAdjacent);
|
|
||||||
|
|
||||||
return {
|
const stacked = computed(() => themes[settings.theme].mergeAdjacent);
|
||||||
titleComponent,
|
|
||||||
bodyComponent,
|
|
||||||
stacked,
|
|
||||||
unref,
|
|
||||||
Visibility,
|
|
||||||
isVisible,
|
|
||||||
isHidden
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
@ -125,6 +79,8 @@ export default defineComponent({
|
||||||
width: auto;
|
width: auto;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding-left: 30px;
|
padding-left: 30px;
|
||||||
|
border-radius: 0;
|
||||||
|
margin: 00;
|
||||||
}
|
}
|
||||||
|
|
||||||
.infobox:not(.stacked) .title {
|
.infobox:not(.stacked) .title {
|
||||||
|
@ -163,21 +119,15 @@ export default defineComponent({
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
transition-duration: 0.5s;
|
transition-duration: 0.5s;
|
||||||
border-radius: 5px;
|
padding: 8px;
|
||||||
border-top-left-radius: 0;
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background-color: var(--background);
|
||||||
|
border-radius: 0 0 var(--feature-margin) var(--feature-margin);
|
||||||
}
|
}
|
||||||
|
|
||||||
.infobox:not(.stacked) .body {
|
.infobox:not(.stacked) .body {
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.body > * {
|
|
||||||
padding: 8px;
|
|
||||||
width: 100%;
|
|
||||||
display: block;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border-radius: 5px;
|
|
||||||
border-top-left-radius: 0;
|
|
||||||
background-color: var(--background);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,141 +0,0 @@
|
||||||
import type {
|
|
||||||
CoercableComponent,
|
|
||||||
GenericComponent,
|
|
||||||
OptionsFunc,
|
|
||||||
Replace,
|
|
||||||
StyleValue
|
|
||||||
} from "features/feature";
|
|
||||||
import { Component, GatherProps, getUniqueID, setDefault, Visibility } from "features/feature";
|
|
||||||
import InfoboxComponent from "features/infoboxes/Infobox.vue";
|
|
||||||
import type { Persistent } from "game/persistence";
|
|
||||||
import { persistent } from "game/persistence";
|
|
||||||
import type {
|
|
||||||
Computable,
|
|
||||||
GetComputableType,
|
|
||||||
GetComputableTypeWithDefault,
|
|
||||||
ProcessedComputable
|
|
||||||
} from "util/computed";
|
|
||||||
import { processComputable } from "util/computed";
|
|
||||||
import { createLazyProxy } from "util/proxies";
|
|
||||||
import { unref } from "vue";
|
|
||||||
|
|
||||||
/** A symbol used to identify {@link Infobox} features. */
|
|
||||||
export const InfoboxType = Symbol("Infobox");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An object that configures an {@link Infobox}.
|
|
||||||
*/
|
|
||||||
export interface InfoboxOptions {
|
|
||||||
/** Whether this clickable should be visible. */
|
|
||||||
visibility?: Computable<Visibility | boolean>;
|
|
||||||
/** The background color of the Infobox. */
|
|
||||||
color?: Computable<string>;
|
|
||||||
/** CSS to apply to this feature. */
|
|
||||||
style?: Computable<StyleValue>;
|
|
||||||
/** CSS to apply to the title of the infobox. */
|
|
||||||
titleStyle?: Computable<StyleValue>;
|
|
||||||
/** CSS to apply to the body of the infobox. */
|
|
||||||
bodyStyle?: Computable<StyleValue>;
|
|
||||||
/** Dictionary of CSS classes to apply to this feature. */
|
|
||||||
classes?: Computable<Record<string, boolean>>;
|
|
||||||
/** A header to appear at the top of the display. */
|
|
||||||
title: Computable<CoercableComponent>;
|
|
||||||
/** The main text that appears in the display. */
|
|
||||||
display: Computable<CoercableComponent>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The properties that are added onto a processed {@link InfoboxOptions} to create an {@link Infobox}.
|
|
||||||
*/
|
|
||||||
export interface BaseInfobox {
|
|
||||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
|
||||||
id: string;
|
|
||||||
/** Whether or not this infobox is collapsed. */
|
|
||||||
collapsed: Persistent<boolean>;
|
|
||||||
/** A symbol that helps identify features of the same type. */
|
|
||||||
type: typeof InfoboxType;
|
|
||||||
/** The Vue component used to render this feature. */
|
|
||||||
[Component]: GenericComponent;
|
|
||||||
/** A function to gather the props the vue component requires for this feature. */
|
|
||||||
[GatherProps]: () => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An object that represents a feature that displays information in a collapsible way. */
|
|
||||||
export type Infobox<T extends InfoboxOptions> = Replace<
|
|
||||||
T & BaseInfobox,
|
|
||||||
{
|
|
||||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
|
||||||
color: GetComputableType<T["color"]>;
|
|
||||||
style: GetComputableType<T["style"]>;
|
|
||||||
titleStyle: GetComputableType<T["titleStyle"]>;
|
|
||||||
bodyStyle: GetComputableType<T["bodyStyle"]>;
|
|
||||||
classes: GetComputableType<T["classes"]>;
|
|
||||||
title: GetComputableType<T["title"]>;
|
|
||||||
display: GetComputableType<T["display"]>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** A type that matches any valid {@link Infobox} object. */
|
|
||||||
export type GenericInfobox = Replace<
|
|
||||||
Infobox<InfoboxOptions>,
|
|
||||||
{
|
|
||||||
visibility: ProcessedComputable<Visibility | boolean>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lazily creates an infobox with the given options.
|
|
||||||
* @param optionsFunc Infobox options.
|
|
||||||
*/
|
|
||||||
export function createInfobox<T extends InfoboxOptions>(
|
|
||||||
optionsFunc: OptionsFunc<T, BaseInfobox, GenericInfobox>
|
|
||||||
): Infobox<T> {
|
|
||||||
const collapsed = persistent<boolean>(false, false);
|
|
||||||
return createLazyProxy(feature => {
|
|
||||||
const infobox = optionsFunc.call(feature, feature);
|
|
||||||
infobox.id = getUniqueID("infobox-");
|
|
||||||
infobox.type = InfoboxType;
|
|
||||||
infobox[Component] = InfoboxComponent as GenericComponent;
|
|
||||||
|
|
||||||
infobox.collapsed = collapsed;
|
|
||||||
|
|
||||||
processComputable(infobox as T, "visibility");
|
|
||||||
setDefault(infobox, "visibility", Visibility.Visible);
|
|
||||||
processComputable(infobox as T, "color");
|
|
||||||
processComputable(infobox as T, "style");
|
|
||||||
processComputable(infobox as T, "titleStyle");
|
|
||||||
processComputable(infobox as T, "bodyStyle");
|
|
||||||
processComputable(infobox as T, "classes");
|
|
||||||
processComputable(infobox as T, "title");
|
|
||||||
processComputable(infobox as T, "display");
|
|
||||||
|
|
||||||
infobox[GatherProps] = function (this: GenericInfobox) {
|
|
||||||
const {
|
|
||||||
visibility,
|
|
||||||
display,
|
|
||||||
title,
|
|
||||||
color,
|
|
||||||
collapsed,
|
|
||||||
style,
|
|
||||||
titleStyle,
|
|
||||||
bodyStyle,
|
|
||||||
classes,
|
|
||||||
id
|
|
||||||
} = this;
|
|
||||||
return {
|
|
||||||
visibility,
|
|
||||||
display,
|
|
||||||
title,
|
|
||||||
color,
|
|
||||||
collapsed,
|
|
||||||
style: unref(style),
|
|
||||||
titleStyle,
|
|
||||||
bodyStyle,
|
|
||||||
classes,
|
|
||||||
id
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return infobox as unknown as Infobox<T>;
|
|
||||||
});
|
|
||||||
}
|
|
79
src/features/infoboxes/infobox.tsx
Normal file
79
src/features/infoboxes/infobox.tsx
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
import Infobox from "features/infoboxes/Infobox.vue";
|
||||||
|
import type { Persistent } from "game/persistence";
|
||||||
|
import { persistent } from "game/persistence";
|
||||||
|
import { processGetter } from "util/computed";
|
||||||
|
import { createLazyProxy } from "util/proxies";
|
||||||
|
import { Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
|
||||||
|
import { CSSProperties, MaybeRef, MaybeRefOrGetter } from "vue";
|
||||||
|
|
||||||
|
/** A symbol used to identify {@link Infobox} features. */
|
||||||
|
export const InfoboxType = Symbol("Infobox");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object that configures an {@link Infobox}.
|
||||||
|
*/
|
||||||
|
export interface InfoboxOptions extends VueFeatureOptions {
|
||||||
|
/** The background color of the Infobox. Defaults to the layer color. */
|
||||||
|
color?: MaybeRefOrGetter<string>;
|
||||||
|
/** CSS to apply to the title of the infobox. */
|
||||||
|
titleStyle?: MaybeRefOrGetter<CSSProperties>;
|
||||||
|
/** CSS to apply to the body of the infobox. */
|
||||||
|
bodyStyle?: MaybeRefOrGetter<CSSProperties>;
|
||||||
|
/** A header to appear at the top of the display. */
|
||||||
|
title: MaybeRefOrGetter<Renderable>;
|
||||||
|
/** The main text that appears in the display. */
|
||||||
|
display: MaybeRefOrGetter<Renderable>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An object that represents a feature that displays information in a collapsible way. */
|
||||||
|
export interface Infobox extends VueFeature {
|
||||||
|
/** The background color of the Infobox. */
|
||||||
|
color?: MaybeRef<string>;
|
||||||
|
/** CSS to apply to the title of the infobox. */
|
||||||
|
titleStyle?: MaybeRef<CSSProperties>;
|
||||||
|
/** CSS to apply to the body of the infobox. */
|
||||||
|
bodyStyle?: MaybeRef<CSSProperties>;
|
||||||
|
/** A header to appear at the top of the display. */
|
||||||
|
title: MaybeRef<Renderable>;
|
||||||
|
/** The main text that appears in the display. */
|
||||||
|
display: MaybeRef<Renderable>;
|
||||||
|
/** Whether or not this infobox is collapsed. */
|
||||||
|
collapsed: Persistent<boolean>;
|
||||||
|
/** A symbol that helps identify features of the same type. */
|
||||||
|
type: typeof InfoboxType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily creates an infobox with the given options.
|
||||||
|
* @param optionsFunc Infobox options.
|
||||||
|
*/
|
||||||
|
export function createInfobox<T extends InfoboxOptions>(optionsFunc: () => T) {
|
||||||
|
const collapsed = persistent<boolean>(false, false);
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const options = optionsFunc();
|
||||||
|
const { color, titleStyle, bodyStyle, title, display, ...props } = options;
|
||||||
|
|
||||||
|
const infobox = {
|
||||||
|
type: InfoboxType,
|
||||||
|
...(props as Omit<typeof props, keyof VueFeature | keyof InfoboxOptions>),
|
||||||
|
...vueFeatureMixin("infobox", options, () => (
|
||||||
|
<Infobox
|
||||||
|
color={infobox.color}
|
||||||
|
titleStyle={infobox.titleStyle}
|
||||||
|
bodyStyle={infobox.bodyStyle}
|
||||||
|
collapsed={infobox.collapsed}
|
||||||
|
title={infobox.title}
|
||||||
|
display={infobox.display}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
collapsed,
|
||||||
|
color: processGetter(color) ?? "--layer-color",
|
||||||
|
titleStyle: processGetter(titleStyle),
|
||||||
|
bodyStyle: processGetter(bodyStyle),
|
||||||
|
title: processGetter(title),
|
||||||
|
display: processGetter(display)
|
||||||
|
} satisfies Infobox;
|
||||||
|
|
||||||
|
return infobox;
|
||||||
|
});
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
|
@ -13,52 +13,51 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
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, shallowRef, unref, watch } from "vue";
|
||||||
import LinkVue from "./Link.vue";
|
import LinkVue from "./Link.vue";
|
||||||
|
import { Links } from "./links";
|
||||||
|
|
||||||
const _props = defineProps<{ links?: Link[] }>();
|
const props = defineProps<{ links: Links["links"] }>();
|
||||||
const links = toRef(_props, "links");
|
|
||||||
|
|
||||||
const resizeListener = ref<Element | null>(null);
|
function updateBounds() {
|
||||||
|
boundingRect.value = resizeListener.value?.getBoundingClientRect();
|
||||||
|
}
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(updateBounds);
|
||||||
|
const resizeListener = shallowRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
const nodes = inject(NodesInjectionKey, ref<Record<string, FeatureNode | undefined>>({}));
|
const nodes = inject(NodesInjectionKey, ref<Record<string, FeatureNode | undefined>>({}));
|
||||||
const outerBoundingRect = inject(BoundsInjectionKey, ref<DOMRect | undefined>(undefined));
|
const outerBoundingRect = inject(BoundsInjectionKey, ref<DOMRect | undefined>(undefined));
|
||||||
const boundingRect = ref<DOMRect | undefined>(resizeListener.value?.getBoundingClientRect());
|
const boundingRect = ref<DOMRect | undefined>(resizeListener.value?.getBoundingClientRect());
|
||||||
watch(
|
watch(outerBoundingRect, updateBounds);
|
||||||
outerBoundingRect,
|
onMounted(() => {
|
||||||
() => (boundingRect.value = resizeListener.value?.getBoundingClientRect())
|
const resListener = resizeListener.value;
|
||||||
);
|
if (resListener != null) {
|
||||||
onMounted(() => (boundingRect.value = resizeListener.value?.getBoundingClientRect()));
|
resizeObserver.observe(resListener);
|
||||||
|
}
|
||||||
|
updateBounds();
|
||||||
|
});
|
||||||
|
|
||||||
const validLinks = computed(() => {
|
const validLinks = computed(() => {
|
||||||
const n = nodes.value;
|
const n = nodes.value;
|
||||||
return (
|
return (
|
||||||
links.value?.filter(link => n[link.startNode.id]?.rect && n[link.startNode.id]?.rect) ?? []
|
unref(props.links)?.filter(link =>
|
||||||
|
n[link.startNode.id]?.rect && n[link.endNode.id]?.rect) ?? []
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.resize-listener {
|
.resize-listener, svg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0px;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: -4px;
|
|
||||||
bottom: 5px;
|
|
||||||
z-index: -10;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
position: absolute;
|
|
||||||
top: 5px;
|
|
||||||
left: 5px;
|
|
||||||
right: 5px;
|
|
||||||
bottom: 5px;
|
|
||||||
z-index: -10;
|
z-index: -10;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,78 +0,0 @@
|
||||||
import type { GenericComponent, OptionsFunc, Replace } from "features/feature";
|
|
||||||
import { GatherProps, Component } from "features/feature";
|
|
||||||
import type { Position } from "game/layers";
|
|
||||||
import type { Computable, GetComputableType, ProcessedComputable } from "util/computed";
|
|
||||||
import { processComputable } from "util/computed";
|
|
||||||
import { createLazyProxy } from "util/proxies";
|
|
||||||
import type { SVGAttributes } from "vue";
|
|
||||||
import LinksComponent from "./Links.vue";
|
|
||||||
|
|
||||||
/** A symbol used to identify {@link Links} features. */
|
|
||||||
export const LinksType = Symbol("Links");
|
|
||||||
|
|
||||||
/** Represents a link between two nodes. It will be displayed as an SVG line, and can take any appropriate properties for an SVG line element. */
|
|
||||||
export interface Link extends SVGAttributes {
|
|
||||||
startNode: { id: string };
|
|
||||||
endNode: { id: string };
|
|
||||||
offsetStart?: Position;
|
|
||||||
offsetEnd?: Position;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An object that configures a {@link Links}. */
|
|
||||||
export interface LinksOptions {
|
|
||||||
/** The list of links to display. */
|
|
||||||
links: Computable<Link[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The properties that are added onto a processed {@link LinksOptions} to create an {@link Links}.
|
|
||||||
*/
|
|
||||||
export interface BaseLinks {
|
|
||||||
/** A symbol that helps identify features of the same type. */
|
|
||||||
type: typeof LinksType;
|
|
||||||
/** The Vue component used to render this feature. */
|
|
||||||
[Component]: GenericComponent;
|
|
||||||
/** A function to gather the props the vue component requires for this feature. */
|
|
||||||
[GatherProps]: () => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An object that represents a list of links between nodes, which are the elements in the DOM for any renderable feature. */
|
|
||||||
export type Links<T extends LinksOptions> = Replace<
|
|
||||||
T & BaseLinks,
|
|
||||||
{
|
|
||||||
links: GetComputableType<T["links"]>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** A type that matches any valid {@link Links} object. */
|
|
||||||
export type GenericLinks = Replace<
|
|
||||||
Links<LinksOptions>,
|
|
||||||
{
|
|
||||||
links: ProcessedComputable<Link[]>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lazily creates links with the given options.
|
|
||||||
* @param optionsFunc Links options.
|
|
||||||
*/
|
|
||||||
export function createLinks<T extends LinksOptions>(
|
|
||||||
optionsFunc: OptionsFunc<T, BaseLinks, GenericLinks>
|
|
||||||
): Links<T> {
|
|
||||||
return createLazyProxy(feature => {
|
|
||||||
const links = optionsFunc.call(feature, feature);
|
|
||||||
links.type = LinksType;
|
|
||||||
links[Component] = LinksComponent as GenericComponent;
|
|
||||||
|
|
||||||
processComputable(links as T, "links");
|
|
||||||
|
|
||||||
links[GatherProps] = function (this: GenericLinks) {
|
|
||||||
const { links } = this;
|
|
||||||
return {
|
|
||||||
links
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return links as unknown as Links<T>;
|
|
||||||
});
|
|
||||||
}
|
|
54
src/features/links/links.tsx
Normal file
54
src/features/links/links.tsx
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
import type { Position } from "game/layers";
|
||||||
|
import { processGetter } from "util/computed";
|
||||||
|
import { createLazyProxy } from "util/proxies";
|
||||||
|
import { VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
|
||||||
|
import { unref, type MaybeRef, type MaybeRefOrGetter, type SVGAttributes } from "vue";
|
||||||
|
import Links from "./Links.vue";
|
||||||
|
|
||||||
|
/** A symbol used to identify {@link Links} features. */
|
||||||
|
export const LinksType = Symbol("Links");
|
||||||
|
|
||||||
|
/** Represents a link between two nodes. It will be displayed as an SVG line, and can take any appropriate properties for an SVG line element. */
|
||||||
|
export interface Link extends /* @vue-ignore */ SVGAttributes {
|
||||||
|
startNode: { id: string };
|
||||||
|
endNode: { id: string };
|
||||||
|
offsetStart?: Position;
|
||||||
|
offsetEnd?: Position;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An object that configures a {@link Links}. */
|
||||||
|
export interface LinksOptions extends VueFeatureOptions {
|
||||||
|
/** The list of links to display. */
|
||||||
|
links: MaybeRefOrGetter<Link[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An object that represents a list of links between nodes, which are the elements in the DOM for any renderable feature. */
|
||||||
|
export interface Links extends VueFeature {
|
||||||
|
/** The list of links to display. */
|
||||||
|
links: MaybeRef<Link[]>;
|
||||||
|
/** A symbol that helps identify features of the same type. */
|
||||||
|
type: typeof LinksType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily creates links with the given options.
|
||||||
|
* @param optionsFunc Links options.
|
||||||
|
*/
|
||||||
|
export function createLinks<T extends LinksOptions>(optionsFunc: () => T) {
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const options = optionsFunc?.();
|
||||||
|
const { links, style: _style, ...props } = options;
|
||||||
|
|
||||||
|
const style = processGetter(_style);
|
||||||
|
options.style = () => ({ position: "static", ...(unref(style) ?? {}) });
|
||||||
|
|
||||||
|
const retLinks = {
|
||||||
|
type: LinksType,
|
||||||
|
...(props as Omit<typeof props, keyof VueFeature | keyof LinksOptions>),
|
||||||
|
...vueFeatureMixin("links", options, () => <Links links={retLinks.links} />),
|
||||||
|
links: processGetter(links)
|
||||||
|
} satisfies Links;
|
||||||
|
|
||||||
|
return retLinks;
|
||||||
|
});
|
||||||
|
}
|
|
@ -2,83 +2,60 @@
|
||||||
<div
|
<div
|
||||||
ref="resizeListener"
|
ref="resizeListener"
|
||||||
class="resize-listener"
|
class="resize-listener"
|
||||||
:style="unref(style)"
|
|
||||||
:class="unref(classes)"
|
|
||||||
/>
|
/>
|
||||||
</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 { 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 type { Particles } from "./particles";
|
||||||
import { defineComponent, nextTick, onBeforeUnmount, onMounted, shallowRef, unref } from "vue";
|
|
||||||
|
|
||||||
// TODO get typing support on the Particles component
|
const props = defineProps<{
|
||||||
export default defineComponent({
|
onContainerResized: Particles["onContainerResized"];
|
||||||
props: {
|
onHotReload: Particles["onHotReload"];
|
||||||
style: processedPropType<StyleValue>(String, Object, Array),
|
onInit: (app: Application) => void;
|
||||||
classes: processedPropType<Record<string, boolean>>(Object),
|
}>();
|
||||||
onInit: {
|
|
||||||
type: Function as PropType<(app: Application) => void>,
|
|
||||||
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);
|
const resListener = resizeListener.value;
|
||||||
app.value = new Application({
|
if (resListener != null) {
|
||||||
resizeTo: resListener,
|
resizeObserver.observe(resListener);
|
||||||
backgroundAlpha: 0
|
app.value = new Application({
|
||||||
});
|
resizeTo: resListener,
|
||||||
resizeListener.value?.appendChild(app.value.view);
|
backgroundAlpha: 0
|
||||||
props.onInit?.(app.value as Application);
|
|
||||||
}
|
|
||||||
updateBounds();
|
|
||||||
if (props.onHotReload) {
|
|
||||||
nextTick(props.onHotReload);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
onBeforeUnmount(() => {
|
resizeListener.value?.appendChild(app.value.view);
|
||||||
app.value?.destroy();
|
props.onInit(app.value);
|
||||||
});
|
}
|
||||||
|
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>
|
||||||
|
|
|
@ -1,12 +1,10 @@
|
||||||
import { Application } from "@pixi/app";
|
import { Application } from "@pixi/app";
|
||||||
import type { EmitterConfigV3 } from "@pixi/particle-emitter";
|
import type { EmitterConfigV3 } from "@pixi/particle-emitter";
|
||||||
import { Emitter, upgradeConfig } from "@pixi/particle-emitter";
|
import { Emitter, upgradeConfig } from "@pixi/particle-emitter";
|
||||||
import type { GenericComponent, OptionsFunc, Replace, StyleValue } from "features/feature";
|
|
||||||
import { Component, GatherProps, getUniqueID } from "features/feature";
|
|
||||||
import ParticlesComponent from "features/particles/Particles.vue";
|
|
||||||
import type { Computable, GetComputableType } from "util/computed";
|
|
||||||
import { createLazyProxy } from "util/proxies";
|
import { createLazyProxy } from "util/proxies";
|
||||||
import { Ref, shallowRef, unref } from "vue";
|
import { VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
|
||||||
|
import { Ref, shallowRef } from "vue";
|
||||||
|
import Particles from "./Particles.vue";
|
||||||
|
|
||||||
/** A symbol used to identify {@link Particles} features. */
|
/** A symbol used to identify {@link Particles} features. */
|
||||||
export const ParticlesType = Symbol("Particles");
|
export const ParticlesType = Symbol("Particles");
|
||||||
|
@ -14,11 +12,7 @@ export const ParticlesType = Symbol("Particles");
|
||||||
/**
|
/**
|
||||||
* An object that configures {@link Particles}.
|
* An object that configures {@link Particles}.
|
||||||
*/
|
*/
|
||||||
export interface ParticlesOptions {
|
export interface ParticlesOptions extends VueFeatureOptions {
|
||||||
/** Dictionary of CSS classes to apply to this feature. */
|
|
||||||
classes?: Computable<Record<string, boolean>>;
|
|
||||||
/** CSS to apply to this feature. */
|
|
||||||
style?: Computable<StyleValue>;
|
|
||||||
/** A function that is called when the particles canvas is resized. */
|
/** A function that is called when the particles canvas is resized. */
|
||||||
onContainerResized?: (boundingRect: DOMRect) => void;
|
onContainerResized?: (boundingRect: DOMRect) => void;
|
||||||
/** A function that is called whenever the particles element is reloaded during development. For restarting particle effects. */
|
/** A function that is called whenever the particles element is reloaded during development. For restarting particle effects. */
|
||||||
|
@ -26,11 +20,14 @@ export interface ParticlesOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The properties that are added onto a processed {@link ParticlesOptions} to create an {@link Particles}.
|
* An object that represents a feature that display particle effects on the screen.
|
||||||
|
* The config should typically be gotten by designing the effect using the [online particle effect editor](https://pixijs.io/pixi-particles-editor/) and passing it into the {@link upgradeConfig} from @pixi/particle-emitter.
|
||||||
*/
|
*/
|
||||||
export interface BaseParticles {
|
export interface Particles extends VueFeature {
|
||||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
/** A function that is called when the particles canvas is resized. */
|
||||||
id: string;
|
onContainerResized?: (boundingRect: DOMRect) => void;
|
||||||
|
/** A function that is called whenever the particles element is reloaded during development. For restarting particle effects. */
|
||||||
|
onHotReload?: VoidFunction;
|
||||||
/** The Pixi.JS Application powering this particles canvas. */
|
/** The Pixi.JS Application powering this particles canvas. */
|
||||||
app: Ref<null | Application>;
|
app: Ref<null | Application>;
|
||||||
/**
|
/**
|
||||||
|
@ -41,52 +38,16 @@ export interface BaseParticles {
|
||||||
addEmitter: (config: EmitterConfigV3) => Promise<Emitter>;
|
addEmitter: (config: EmitterConfigV3) => Promise<Emitter>;
|
||||||
/** A symbol that helps identify features of the same type. */
|
/** A symbol that helps identify features of the same type. */
|
||||||
type: typeof ParticlesType;
|
type: typeof ParticlesType;
|
||||||
/** The Vue component used to render this feature. */
|
|
||||||
[Component]: GenericComponent;
|
|
||||||
/** A function to gather the props the vue component requires for this feature. */
|
|
||||||
[GatherProps]: () => Record<string, unknown>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* An object that represents a feature that display particle effects on the screen.
|
|
||||||
* The config should typically be gotten by designing the effect using the [online particle effect editor](https://pixijs.io/pixi-particles-editor/) and passing it into the {@link upgradeConfig} from @pixi/particle-emitter.
|
|
||||||
*/
|
|
||||||
export type Particles<T extends ParticlesOptions> = Replace<
|
|
||||||
T & BaseParticles,
|
|
||||||
{
|
|
||||||
classes: GetComputableType<T["classes"]>;
|
|
||||||
style: GetComputableType<T["style"]>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** A type that matches any valid {@link Particles} object. */
|
|
||||||
export type GenericParticles = Particles<ParticlesOptions>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lazily creates particles with the given options.
|
* Lazily creates particles with the given options.
|
||||||
* @param optionsFunc Particles options.
|
* @param optionsFunc Particles options.
|
||||||
*/
|
*/
|
||||||
export function createParticles<T extends ParticlesOptions>(
|
export function createParticles<T extends ParticlesOptions>(optionsFunc?: () => T) {
|
||||||
optionsFunc?: OptionsFunc<T, BaseParticles, GenericParticles>
|
return createLazyProxy(() => {
|
||||||
): Particles<T> {
|
const options = optionsFunc?.() ?? ({} as T);
|
||||||
return createLazyProxy(feature => {
|
const { onContainerResized, onHotReload, ...props } = options;
|
||||||
const particles =
|
|
||||||
optionsFunc?.call(feature, feature) ??
|
|
||||||
({} as ReturnType<NonNullable<typeof optionsFunc>>);
|
|
||||||
particles.id = getUniqueID("particles-");
|
|
||||||
particles.type = ParticlesType;
|
|
||||||
particles[Component] = ParticlesComponent as GenericComponent;
|
|
||||||
|
|
||||||
particles.app = shallowRef(null);
|
|
||||||
particles.addEmitter = (config: EmitterConfigV3): Promise<Emitter> => {
|
|
||||||
const genericParticles = particles as GenericParticles;
|
|
||||||
if (genericParticles.app.value) {
|
|
||||||
return Promise.resolve(new Emitter(genericParticles.app.value.stage, config));
|
|
||||||
}
|
|
||||||
return new Promise<Emitter>(resolve => {
|
|
||||||
emittersToAdd.push({ resolve, config });
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
let emittersToAdd: {
|
let emittersToAdd: {
|
||||||
resolve: (value: Emitter | PromiseLike<Emitter>) => void;
|
resolve: (value: Emitter | PromiseLike<Emitter>) => void;
|
||||||
|
@ -94,27 +55,34 @@ export function createParticles<T extends ParticlesOptions>(
|
||||||
}[] = [];
|
}[] = [];
|
||||||
|
|
||||||
function onInit(app: Application) {
|
function onInit(app: Application) {
|
||||||
const genericParticles = particles as GenericParticles;
|
|
||||||
genericParticles.app.value = app;
|
|
||||||
emittersToAdd.forEach(({ resolve, config }) => resolve(new Emitter(app.stage, config)));
|
emittersToAdd.forEach(({ resolve, config }) => resolve(new Emitter(app.stage, config)));
|
||||||
emittersToAdd = [];
|
emittersToAdd = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
particles.onContainerResized = particles.onContainerResized?.bind(particles);
|
const particles = {
|
||||||
|
type: ParticlesType,
|
||||||
|
...(props as Omit<typeof props, keyof VueFeature | keyof ParticlesOptions>),
|
||||||
|
...vueFeatureMixin("particles", options, () => (
|
||||||
|
<Particles
|
||||||
|
onInit={onInit}
|
||||||
|
onContainerResized={particles.onContainerResized}
|
||||||
|
onHotReload={particles.onHotReload}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
app: shallowRef<null | Application>(null),
|
||||||
|
onContainerResized,
|
||||||
|
onHotReload,
|
||||||
|
addEmitter: (config: EmitterConfigV3): Promise<Emitter> => {
|
||||||
|
if (particles.app.value != null) {
|
||||||
|
return Promise.resolve(new Emitter(particles.app.value.stage, config));
|
||||||
|
}
|
||||||
|
return new Promise<Emitter>(resolve => {
|
||||||
|
emittersToAdd.push({ resolve, config });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} satisfies Particles;
|
||||||
|
|
||||||
particles[GatherProps] = function (this: GenericParticles) {
|
return particles;
|
||||||
const { id, style, classes, onContainerResized, onHotReload } = this;
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
style: unref(style),
|
|
||||||
classes,
|
|
||||||
onContainerResized,
|
|
||||||
onHotReload,
|
|
||||||
onInit
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return particles as unknown as Particles<T>;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,299 +0,0 @@
|
||||||
import { isArray } from "@vue/shared";
|
|
||||||
import ClickableComponent from "features/clickables/Clickable.vue";
|
|
||||||
import type {
|
|
||||||
CoercableComponent,
|
|
||||||
GenericComponent,
|
|
||||||
OptionsFunc,
|
|
||||||
Replace,
|
|
||||||
StyleValue
|
|
||||||
} from "features/feature";
|
|
||||||
import { Component, GatherProps, Visibility, getUniqueID, jsx, setDefault } from "features/feature";
|
|
||||||
import { DefaultValue, Persistent, persistent } from "game/persistence";
|
|
||||||
import {
|
|
||||||
Requirements,
|
|
||||||
createVisibilityRequirement,
|
|
||||||
displayRequirements,
|
|
||||||
maxRequirementsMet,
|
|
||||||
payRequirements,
|
|
||||||
requirementsMet
|
|
||||||
} from "game/requirements";
|
|
||||||
import type { DecimalSource } from "util/bignum";
|
|
||||||
import Decimal, { formatWhole } from "util/bignum";
|
|
||||||
import type {
|
|
||||||
Computable,
|
|
||||||
GetComputableType,
|
|
||||||
GetComputableTypeWithDefault,
|
|
||||||
ProcessedComputable
|
|
||||||
} from "util/computed";
|
|
||||||
import { processComputable } from "util/computed";
|
|
||||||
import { createLazyProxy } from "util/proxies";
|
|
||||||
import { coerceComponent, isCoercableComponent } from "util/vue";
|
|
||||||
import type { Ref } from "vue";
|
|
||||||
import { computed, unref } from "vue";
|
|
||||||
import { GenericDecorator } from "./decorators/common";
|
|
||||||
|
|
||||||
/** A symbol used to identify {@link Repeatable} features. */
|
|
||||||
export const RepeatableType = Symbol("Repeatable");
|
|
||||||
|
|
||||||
/** A type that can be used to customize the {@link Repeatable} display. */
|
|
||||||
export type RepeatableDisplay =
|
|
||||||
| CoercableComponent
|
|
||||||
| {
|
|
||||||
/** A header to appear at the top of the display. */
|
|
||||||
title?: CoercableComponent;
|
|
||||||
/** The main text that appears in the display. */
|
|
||||||
description?: CoercableComponent;
|
|
||||||
/** A description of the current effect of this repeatable, based off its amount. */
|
|
||||||
effectDisplay?: CoercableComponent;
|
|
||||||
/** Whether or not to show the current amount of this repeatable at the bottom of the display. */
|
|
||||||
showAmount?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** An object that configures a {@link Repeatable}. */
|
|
||||||
export interface RepeatableOptions {
|
|
||||||
/** Whether this repeatable should be visible. */
|
|
||||||
visibility?: Computable<Visibility | boolean>;
|
|
||||||
/** The requirement(s) to increase this repeatable. */
|
|
||||||
requirements: Requirements;
|
|
||||||
/** The maximum amount obtainable for this repeatable. */
|
|
||||||
limit?: Computable<DecimalSource>;
|
|
||||||
/** The initial amount this repeatable has on a new save / after reset. */
|
|
||||||
initialAmount?: DecimalSource;
|
|
||||||
/** Dictionary of CSS classes to apply to this feature. */
|
|
||||||
classes?: Computable<Record<string, boolean>>;
|
|
||||||
/** CSS to apply to this feature. */
|
|
||||||
style?: Computable<StyleValue>;
|
|
||||||
/** Shows a marker on the corner of the feature. */
|
|
||||||
mark?: Computable<boolean | string>;
|
|
||||||
/** Toggles a smaller design for the feature. */
|
|
||||||
small?: Computable<boolean>;
|
|
||||||
/** The display to use for this repeatable. */
|
|
||||||
display?: Computable<RepeatableDisplay>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The properties that are added onto a processed {@link RepeatableOptions} to create a {@link Repeatable}.
|
|
||||||
*/
|
|
||||||
export interface BaseRepeatable {
|
|
||||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
|
||||||
id: string;
|
|
||||||
/** The current amount this repeatable has. */
|
|
||||||
amount: Persistent<DecimalSource>;
|
|
||||||
/** Whether or not this repeatable's amount is at it's limit. */
|
|
||||||
maxed: Ref<boolean>;
|
|
||||||
/** Whether or not this repeatable can be clicked. */
|
|
||||||
canClick: ProcessedComputable<boolean>;
|
|
||||||
/**
|
|
||||||
* How much amount can be increased by, or 1 if unclickable.
|
|
||||||
**/
|
|
||||||
amountToIncrease: Ref<DecimalSource>;
|
|
||||||
/** A function that gets called when this repeatable is clicked. */
|
|
||||||
onClick: (event?: MouseEvent | TouchEvent) => void;
|
|
||||||
/** A symbol that helps identify features of the same type. */
|
|
||||||
type: typeof RepeatableType;
|
|
||||||
/** The Vue component used to render this feature. */
|
|
||||||
[Component]: GenericComponent;
|
|
||||||
/** A function to gather the props the vue component requires for this feature. */
|
|
||||||
[GatherProps]: () => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An object that represents a feature with multiple "levels" with scaling requirements. */
|
|
||||||
export type Repeatable<T extends RepeatableOptions> = Replace<
|
|
||||||
T & BaseRepeatable,
|
|
||||||
{
|
|
||||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
|
||||||
requirements: GetComputableType<T["requirements"]>;
|
|
||||||
limit: GetComputableTypeWithDefault<T["limit"], Decimal>;
|
|
||||||
classes: GetComputableType<T["classes"]>;
|
|
||||||
style: GetComputableType<T["style"]>;
|
|
||||||
mark: GetComputableType<T["mark"]>;
|
|
||||||
small: GetComputableType<T["small"]>;
|
|
||||||
display: Ref<CoercableComponent>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** A type that matches any valid {@link Repeatable} object. */
|
|
||||||
export type GenericRepeatable = Replace<
|
|
||||||
Repeatable<RepeatableOptions>,
|
|
||||||
{
|
|
||||||
visibility: ProcessedComputable<Visibility | boolean>;
|
|
||||||
limit: ProcessedComputable<DecimalSource>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lazily creates a repeatable with the given options.
|
|
||||||
* @param optionsFunc Repeatable options.
|
|
||||||
*/
|
|
||||||
export function createRepeatable<T extends RepeatableOptions>(
|
|
||||||
optionsFunc: OptionsFunc<T, BaseRepeatable, GenericRepeatable>,
|
|
||||||
...decorators: GenericDecorator[]
|
|
||||||
): Repeatable<T> {
|
|
||||||
const amount = persistent<DecimalSource>(0);
|
|
||||||
const decoratedData = decorators.reduce(
|
|
||||||
(current, next) => Object.assign(current, next.getPersistentData?.()),
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
return createLazyProxy<Repeatable<T>, Repeatable<T>>(feature => {
|
|
||||||
const repeatable = optionsFunc.call(feature, feature);
|
|
||||||
|
|
||||||
repeatable.id = getUniqueID("repeatable-");
|
|
||||||
repeatable.type = RepeatableType;
|
|
||||||
repeatable[Component] = ClickableComponent as GenericComponent;
|
|
||||||
|
|
||||||
for (const decorator of decorators) {
|
|
||||||
decorator.preConstruct?.(repeatable);
|
|
||||||
}
|
|
||||||
|
|
||||||
repeatable.amount = amount;
|
|
||||||
repeatable.amount[DefaultValue] = repeatable.initialAmount ?? 0;
|
|
||||||
|
|
||||||
Object.assign(repeatable, decoratedData);
|
|
||||||
|
|
||||||
const limitRequirement = {
|
|
||||||
requirementMet: computed(() =>
|
|
||||||
Decimal.sub(
|
|
||||||
unref((repeatable as GenericRepeatable).limit),
|
|
||||||
(repeatable as GenericRepeatable).amount.value
|
|
||||||
)
|
|
||||||
),
|
|
||||||
requiresPay: false,
|
|
||||||
visibility: Visibility.None,
|
|
||||||
canMaximize: true
|
|
||||||
} as const;
|
|
||||||
const visibilityRequirement = createVisibilityRequirement(repeatable as GenericRepeatable);
|
|
||||||
if (isArray(repeatable.requirements)) {
|
|
||||||
repeatable.requirements.unshift(visibilityRequirement);
|
|
||||||
repeatable.requirements.push(limitRequirement);
|
|
||||||
} else {
|
|
||||||
repeatable.requirements = [
|
|
||||||
visibilityRequirement,
|
|
||||||
repeatable.requirements,
|
|
||||||
limitRequirement
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
repeatable.maxed = computed(() =>
|
|
||||||
Decimal.gte(
|
|
||||||
(repeatable as GenericRepeatable).amount.value,
|
|
||||||
unref((repeatable as GenericRepeatable).limit)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
processComputable(repeatable as T, "classes");
|
|
||||||
const classes = repeatable.classes as
|
|
||||||
| ProcessedComputable<Record<string, boolean>>
|
|
||||||
| undefined;
|
|
||||||
repeatable.classes = computed(() => {
|
|
||||||
const currClasses = unref(classes) || {};
|
|
||||||
if ((repeatable as GenericRepeatable).maxed.value) {
|
|
||||||
currClasses.bought = true;
|
|
||||||
}
|
|
||||||
return currClasses;
|
|
||||||
});
|
|
||||||
repeatable.amountToIncrease = computed(() =>
|
|
||||||
Decimal.clampMin(maxRequirementsMet(repeatable.requirements), 1)
|
|
||||||
);
|
|
||||||
repeatable.canClick = computed(() => requirementsMet(repeatable.requirements));
|
|
||||||
const onClick = repeatable.onClick;
|
|
||||||
repeatable.onClick = function (this: GenericRepeatable, event?: MouseEvent | TouchEvent) {
|
|
||||||
const genericRepeatable = repeatable as GenericRepeatable;
|
|
||||||
if (!unref(genericRepeatable.canClick)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const amountToIncrease = unref(repeatable.amountToIncrease) ?? 1;
|
|
||||||
payRequirements(repeatable.requirements, amountToIncrease);
|
|
||||||
genericRepeatable.amount.value = Decimal.add(
|
|
||||||
genericRepeatable.amount.value,
|
|
||||||
amountToIncrease
|
|
||||||
);
|
|
||||||
onClick?.(event);
|
|
||||||
};
|
|
||||||
processComputable(repeatable as T, "display");
|
|
||||||
const display = repeatable.display;
|
|
||||||
repeatable.display = jsx(() => {
|
|
||||||
// TODO once processComputable types correctly, remove this "as X"
|
|
||||||
const currDisplay = unref(display) as RepeatableDisplay;
|
|
||||||
if (isCoercableComponent(currDisplay)) {
|
|
||||||
const CurrDisplay = coerceComponent(currDisplay);
|
|
||||||
return <CurrDisplay />;
|
|
||||||
}
|
|
||||||
if (currDisplay != null) {
|
|
||||||
const genericRepeatable = repeatable as GenericRepeatable;
|
|
||||||
const Title = coerceComponent(currDisplay.title ?? "", "h3");
|
|
||||||
const Description = coerceComponent(currDisplay.description ?? "");
|
|
||||||
const EffectDisplay = coerceComponent(currDisplay.effectDisplay ?? "");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span>
|
|
||||||
{currDisplay.title == null ? null : (
|
|
||||||
<div>
|
|
||||||
<Title />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{currDisplay.description == null ? null : <Description />}
|
|
||||||
{currDisplay.showAmount === false ? null : (
|
|
||||||
<div>
|
|
||||||
<br />
|
|
||||||
<>Amount: {formatWhole(genericRepeatable.amount.value)}</>
|
|
||||||
{Decimal.isFinite(unref(genericRepeatable.limit)) ? (
|
|
||||||
<> / {formatWhole(unref(genericRepeatable.limit))}</>
|
|
||||||
) : undefined}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{currDisplay.effectDisplay == null ? null : (
|
|
||||||
<div>
|
|
||||||
<br />
|
|
||||||
Currently: <EffectDisplay />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{genericRepeatable.maxed.value ? null : (
|
|
||||||
<div>
|
|
||||||
<br />
|
|
||||||
{displayRequirements(
|
|
||||||
genericRepeatable.requirements,
|
|
||||||
unref(repeatable.amountToIncrease)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
});
|
|
||||||
|
|
||||||
processComputable(repeatable as T, "visibility");
|
|
||||||
setDefault(repeatable, "visibility", Visibility.Visible);
|
|
||||||
processComputable(repeatable as T, "limit");
|
|
||||||
setDefault(repeatable, "limit", Decimal.dInf);
|
|
||||||
processComputable(repeatable as T, "style");
|
|
||||||
processComputable(repeatable as T, "mark");
|
|
||||||
processComputable(repeatable as T, "small");
|
|
||||||
|
|
||||||
for (const decorator of decorators) {
|
|
||||||
decorator.postConstruct?.(repeatable);
|
|
||||||
}
|
|
||||||
|
|
||||||
const decoratedProps = decorators.reduce(
|
|
||||||
(current, next) => Object.assign(current, next.getGatheredProps?.(repeatable)),
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
repeatable[GatherProps] = function (this: GenericRepeatable) {
|
|
||||||
const { display, visibility, style, classes, onClick, canClick, small, mark, id } =
|
|
||||||
this;
|
|
||||||
return {
|
|
||||||
display,
|
|
||||||
visibility,
|
|
||||||
style: unref(style),
|
|
||||||
classes,
|
|
||||||
onClick,
|
|
||||||
canClick,
|
|
||||||
small,
|
|
||||||
mark,
|
|
||||||
id,
|
|
||||||
...decoratedProps
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return repeatable as unknown as Repeatable<T>;
|
|
||||||
});
|
|
||||||
}
|
|
|
@ -1,16 +1,18 @@
|
||||||
import type { OptionsFunc, Replace } from "features/feature";
|
|
||||||
import { getUniqueID } from "features/feature";
|
|
||||||
import { globalBus } from "game/events";
|
import { globalBus } from "game/events";
|
||||||
import Formula from "game/formulas/formulas";
|
import Formula from "game/formulas/formulas";
|
||||||
import type { BaseLayer } from "game/layers";
|
import type { BaseLayer } from "game/layers";
|
||||||
import { NonPersistent, Persistent, SkipPersistence } from "game/persistence";
|
import {
|
||||||
import { DefaultValue, persistent } from "game/persistence";
|
DefaultValue,
|
||||||
|
NonPersistent,
|
||||||
|
Persistent,
|
||||||
|
persistent,
|
||||||
|
SkipPersistence
|
||||||
|
} from "game/persistence";
|
||||||
import type { Unsubscribe } from "nanoevents";
|
import type { Unsubscribe } from "nanoevents";
|
||||||
import Decimal from "util/bignum";
|
import Decimal from "util/bignum";
|
||||||
import type { Computable, GetComputableType } from "util/computed";
|
import { processGetter } from "util/computed";
|
||||||
import { processComputable } from "util/computed";
|
|
||||||
import { createLazyProxy } from "util/proxies";
|
import { createLazyProxy } from "util/proxies";
|
||||||
import { isRef, unref } from "vue";
|
import { isRef, MaybeRef, MaybeRefOrGetter, unref } from "vue";
|
||||||
|
|
||||||
/** A symbol used to identify {@link Reset} features. */
|
/** A symbol used to identify {@link Reset} features. */
|
||||||
export const ResetType = Symbol("Reset");
|
export const ResetType = Symbol("Reset");
|
||||||
|
@ -20,75 +22,65 @@ export const ResetType = Symbol("Reset");
|
||||||
*/
|
*/
|
||||||
export interface ResetOptions {
|
export interface ResetOptions {
|
||||||
/** List of things to reset. Can include objects which will be recursed over for persistent values. */
|
/** List of things to reset. Can include objects which will be recursed over for persistent values. */
|
||||||
thingsToReset: Computable<unknown[]>;
|
thingsToReset: MaybeRefOrGetter<unknown[]>;
|
||||||
/** A function that is called when the reset is performed. */
|
/** A function that is called when the reset is performed. */
|
||||||
onReset?: VoidFunction;
|
onReset?: VoidFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** An object that represents a reset mechanic, which resets progress back to its initial state. */
|
||||||
* The properties that are added onto a processed {@link ResetOptions} to create an {@link Reset}.
|
export interface Reset {
|
||||||
*/
|
/** List of things to reset. Can include objects which will be recursed over for persistent values. */
|
||||||
export interface BaseReset {
|
thingsToReset: MaybeRef<unknown[]>;
|
||||||
/** An auto-generated ID for identifying which reset is being performed. Will not persist between refreshes or updates. */
|
/** A function that is called when the reset is performed. */
|
||||||
id: string;
|
onReset?: VoidFunction;
|
||||||
/** Trigger the reset. */
|
/** Trigger the reset. */
|
||||||
reset: VoidFunction;
|
reset: VoidFunction;
|
||||||
/** A symbol that helps identify features of the same type. */
|
/** A symbol that helps identify features of the same type. */
|
||||||
type: typeof ResetType;
|
type: typeof ResetType;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An object that represents a reset mechanic, which resets progress back to its initial state. */
|
|
||||||
export type Reset<T extends ResetOptions> = Replace<
|
|
||||||
T & BaseReset,
|
|
||||||
{
|
|
||||||
thingsToReset: GetComputableType<T["thingsToReset"]>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** A type that matches any valid {@link Reset} object. */
|
|
||||||
export type GenericReset = Reset<ResetOptions>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lazily creates a reset with the given options.
|
* Lazily creates a reset with the given options.
|
||||||
* @param optionsFunc Reset options.
|
* @param optionsFunc Reset options.
|
||||||
*/
|
*/
|
||||||
export function createReset<T extends ResetOptions>(
|
export function createReset<T extends ResetOptions>(optionsFunc: () => T) {
|
||||||
optionsFunc: OptionsFunc<T, BaseReset, GenericReset>
|
return createLazyProxy(() => {
|
||||||
): Reset<T> {
|
const options = optionsFunc();
|
||||||
return createLazyProxy(feature => {
|
const { thingsToReset, onReset, ...props } = options;
|
||||||
const reset = optionsFunc.call(feature, feature);
|
|
||||||
reset.id = getUniqueID("reset-");
|
|
||||||
reset.type = ResetType;
|
|
||||||
|
|
||||||
reset.reset = function () {
|
const reset = {
|
||||||
const handleObject = (obj: unknown) => {
|
type: ResetType,
|
||||||
if (
|
...(props as Omit<typeof props, keyof ResetOptions>),
|
||||||
obj != null &&
|
onReset,
|
||||||
typeof obj === "object" &&
|
thingsToReset: processGetter(thingsToReset),
|
||||||
!(obj instanceof Decimal) &&
|
reset: function () {
|
||||||
!(obj instanceof Formula)
|
const handleObject = (obj: unknown) => {
|
||||||
) {
|
if (
|
||||||
if (SkipPersistence in obj && obj[SkipPersistence] === true) {
|
obj != null &&
|
||||||
return;
|
typeof obj === "object" &&
|
||||||
|
!(obj instanceof Decimal) &&
|
||||||
|
!(obj instanceof Formula)
|
||||||
|
) {
|
||||||
|
if (SkipPersistence in obj && obj[SkipPersistence] === true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (DefaultValue in obj) {
|
||||||
|
const persistent = obj as NonPersistent;
|
||||||
|
persistent.value = persistent[DefaultValue];
|
||||||
|
} else if (!(obj instanceof Decimal) && !isRef(obj)) {
|
||||||
|
Object.values(obj).forEach(obj =>
|
||||||
|
handleObject(obj as Record<string, unknown>)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (DefaultValue in obj) {
|
};
|
||||||
const persistent = obj as NonPersistent;
|
unref(reset.thingsToReset).forEach(handleObject);
|
||||||
persistent.value = persistent[DefaultValue];
|
globalBus.emit("reset", reset);
|
||||||
} else if (!(obj instanceof Decimal) && !isRef(obj)) {
|
onReset?.();
|
||||||
Object.values(obj).forEach(obj =>
|
}
|
||||||
handleObject(obj as Record<string, unknown>)
|
} satisfies Reset;
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
unref((reset as GenericReset).thingsToReset).forEach(handleObject);
|
|
||||||
globalBus.emit("reset", reset as GenericReset);
|
|
||||||
reset.onReset?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
processComputable(reset as T, "thingsToReset");
|
return reset;
|
||||||
|
|
||||||
return reset as unknown as Reset<T>;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -98,7 +90,7 @@ const listeners: Record<string, Unsubscribe | undefined> = {};
|
||||||
* @param layer The layer the reset is attached to
|
* @param layer The layer the reset is attached to
|
||||||
* @param reset The reset mechanic to track the time since
|
* @param reset The reset mechanic to track the time since
|
||||||
*/
|
*/
|
||||||
export function trackResetTime(layer: BaseLayer, reset: GenericReset): Persistent<Decimal> {
|
export function trackResetTime(layer: BaseLayer, reset: Reset): Persistent<Decimal> {
|
||||||
const resetTime = persistent<Decimal>(new Decimal(0));
|
const resetTime = persistent<Decimal>(new Decimal(0));
|
||||||
globalBus.on("addLayer", layerBeingAdded => {
|
globalBus.on("addLayer", layerBeingAdded => {
|
||||||
if (layer.id === layerBeingAdded.id) {
|
if (layer.id === layerBeingAdded.id) {
|
||||||
|
@ -123,6 +115,6 @@ globalBus.on("removeLayer", layer => {
|
||||||
|
|
||||||
declare module "game/events" {
|
declare module "game/events" {
|
||||||
interface GlobalEvents {
|
interface GlobalEvents {
|
||||||
reset: (reset: GenericReset) => void;
|
reset: (reset: Reset) => void;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,16 +3,12 @@
|
||||||
<div
|
<div
|
||||||
class="main-display-container"
|
class="main-display-container"
|
||||||
:class="classes ?? {}"
|
:class="classes ?? {}"
|
||||||
:style="[{ height: `${(effectRef?.$el.clientHeight ?? 0) + 50}px` }, style ?? {}]"
|
:style="[{ height: `${(displayRef?.clientHeight ?? 0) + 20}px` }, style ?? {}]">
|
||||||
>
|
<div class="main-display" ref="displayRef">
|
||||||
<div class="main-display">
|
|
||||||
<span v-if="showPrefix">You have </span>
|
<span v-if="showPrefix">You have </span>
|
||||||
<ResourceVue :resource="resource" :color="color || 'white'" />
|
<ResourceVue :resource="resource" :color="color || 'white'" />
|
||||||
{{ resource.displayName
|
{{ resource.displayName }}<!-- remove whitespace -->
|
||||||
}}<!-- remove whitespace -->
|
<span v-if="effectDisplay">, <Effect /></span>
|
||||||
<span v-if="effectComponent"
|
|
||||||
>, <component :is="effectComponent" ref="effectRef"
|
|
||||||
/></span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Sticky>
|
</Sticky>
|
||||||
|
@ -20,28 +16,23 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Sticky from "components/layout/Sticky.vue";
|
import Sticky from "components/layout/Sticky.vue";
|
||||||
import type { CoercableComponent } from "features/feature";
|
|
||||||
import type { Resource } from "features/resources/resource";
|
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 { Renderable } from "util/vue";
|
||||||
import { ComponentPublicInstance, ref, Ref, StyleValue } from "vue";
|
import { computed, MaybeRefOrGetter, ref, StyleValue, toValue } 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?: MaybeRefOrGetter<Renderable>;
|
||||||
}>();
|
}>();
|
||||||
const props = toRefs(_props);
|
|
||||||
|
|
||||||
const effectRef = ref<ComponentPublicInstance | null>(null);
|
const displayRef = ref<Element | null>(null);
|
||||||
|
|
||||||
const effectComponent = computeOptionalComponent(
|
const Effect = () => toValue(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");
|
||||||
|
|
|
@ -3,9 +3,8 @@ import type { Persistent, State } from "game/persistence";
|
||||||
import { NonPersistent, persistent } from "game/persistence";
|
import { NonPersistent, persistent } from "game/persistence";
|
||||||
import type { DecimalSource } from "util/bignum";
|
import type { DecimalSource } from "util/bignum";
|
||||||
import Decimal, { format, formatWhole } from "util/bignum";
|
import Decimal, { format, formatWhole } from "util/bignum";
|
||||||
import type { ProcessedComputable } from "util/computed";
|
|
||||||
import { loadingSave } from "util/save";
|
import { loadingSave } from "util/save";
|
||||||
import type { ComputedRef, Ref } from "vue";
|
import type { ComputedRef, MaybeRef, Ref } from "vue";
|
||||||
import { computed, isRef, ref, unref, watch } from "vue";
|
import { computed, isRef, ref, unref, watch } from "vue";
|
||||||
|
|
||||||
/** An object that represents a named and quantifiable resource in the game. */
|
/** An object that represents a named and quantifiable resource in the game. */
|
||||||
|
@ -159,7 +158,7 @@ export function displayResource(resource: Resource, overrideAmount?: DecimalSour
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Utility for unwrapping a resource that may or may not be inside a ref. */
|
/** Utility for unwrapping a resource that may or may not be inside a ref. */
|
||||||
export function unwrapResource(resource: ProcessedComputable<Resource>): Resource {
|
export function unwrapResource(resource: MaybeRef<Resource>): Resource {
|
||||||
if ("displayName" in resource) {
|
if ("displayName" in resource) {
|
||||||
return resource;
|
return resource;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +0,0 @@
|
||||||
<template>
|
|
||||||
<component :is="component" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { CoercableComponent } from "features/feature";
|
|
||||||
import { computeComponent } from "util/vue";
|
|
||||||
import { toRefs } from "vue";
|
|
||||||
|
|
||||||
const _props = defineProps<{ display: CoercableComponent }>();
|
|
||||||
const { display } = toRefs(_props);
|
|
||||||
const component = computeComponent(display);
|
|
||||||
</script>
|
|
|
@ -1,79 +1,47 @@
|
||||||
<template>
|
<template>
|
||||||
<button
|
<button @click="selectTab" class="tabButton" :style="glowColorStyle" :class="{ active }">
|
||||||
v-if="isVisible(visibility)"
|
<Component />
|
||||||
@click="selectTab"
|
|
||||||
class="tabButton"
|
|
||||||
:style="[
|
|
||||||
{
|
|
||||||
visibility: isHidden(visibility) ? 'hidden' : undefined
|
|
||||||
},
|
|
||||||
glowColorStyle,
|
|
||||||
unref(style) ?? {}
|
|
||||||
]"
|
|
||||||
:class="{
|
|
||||||
active,
|
|
||||||
...unref(classes)
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<component :is="component" />
|
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import type { CoercableComponent, StyleValue } 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 { render } from "util/vue";
|
||||||
import { computed, defineComponent, toRefs, unref } from "vue";
|
import { computed, unref } from "vue";
|
||||||
|
import { TabButton } from "./tabFamily";
|
||||||
|
import themes from "data/themes";
|
||||||
|
import settings from "game/settings";
|
||||||
|
|
||||||
export default defineComponent({
|
const props = defineProps<{
|
||||||
props: {
|
display: TabButton["display"];
|
||||||
visibility: {
|
glowColor: TabButton["glowColor"];
|
||||||
type: processedPropType<Visibility | boolean>(Number, Boolean),
|
active?: boolean;
|
||||||
required: true
|
}>();
|
||||||
},
|
|
||||||
display: {
|
|
||||||
type: processedPropType<CoercableComponent>(Object, String, Function),
|
|
||||||
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 = () => render(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 = unref(props.glowColor);
|
||||||
}
|
if (color == null || color === "") {
|
||||||
|
return {};
|
||||||
return {
|
|
||||||
selectTab,
|
|
||||||
component,
|
|
||||||
glowColorStyle,
|
|
||||||
unref,
|
|
||||||
Visibility,
|
|
||||||
isVisible,
|
|
||||||
isHidden
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
if (floating.value) {
|
||||||
|
return getNotifyStyle(color);
|
||||||
|
}
|
||||||
|
return { boxShadow: `0px 9px 5px -6px ${color}` };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const floating = computed(() => {
|
||||||
|
return themes[settings.theme].floatingTabs;
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectTab() {
|
||||||
|
emit("selectTab");
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
@ -1,132 +1,61 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="tab-family-container" :class="tabClasses" :style="tabStyle">
|
||||||
v-if="isVisible(visibility)"
|
|
||||||
class="tab-family-container"
|
|
||||||
:class="{ ...unref(classes), ...tabClasses }"
|
|
||||||
:style="[
|
|
||||||
{
|
|
||||||
visibility: isHidden(visibility) ? 'hidden' : undefined
|
|
||||||
},
|
|
||||||
unref(style) ?? [],
|
|
||||||
tabStyle ?? []
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<Sticky
|
<Sticky
|
||||||
class="tab-buttons-container"
|
class="tab-buttons-container"
|
||||||
:class="unref(buttonContainerClasses)"
|
:class="unref(buttonContainerClasses)"
|
||||||
:style="unref(buttonContainerStyle)"
|
:style="unref(buttonContainerStyle)"
|
||||||
>
|
>
|
||||||
<div class="tab-buttons" :class="{ floating }">
|
<div class="tab-buttons" :class="{ floating }">
|
||||||
<TabButton
|
<TabButtons />
|
||||||
v-for="(button, id) in unref(tabs)"
|
|
||||||
@selectTab="selected.value = id"
|
|
||||||
:floating="floating"
|
|
||||||
:key="id"
|
|
||||||
:active="unref(button.tab) === unref(activeTab)"
|
|
||||||
v-bind="gatherButtonProps(button)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</Sticky>
|
</Sticky>
|
||||||
<template v-if="unref(activeTab)">
|
<Component v-if="unref(activeTab) != null" />
|
||||||
<component :is="unref(component)" />
|
|
||||||
</template>
|
|
||||||
</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 { isType } from "features/feature";
|
||||||
|
import { render } from "util/vue";
|
||||||
|
import type { Component } from "vue";
|
||||||
|
import { computed, unref } from "vue";
|
||||||
|
import { TabType } from "./tab";
|
||||||
|
import { TabFamily } from "./tabFamily";
|
||||||
import themes from "data/themes";
|
import themes from "data/themes";
|
||||||
import type { CoercableComponent, StyleValue } from "features/feature";
|
|
||||||
import { isHidden, isVisible, Visibility } from "features/feature";
|
|
||||||
import type { GenericTab } from "features/tabs/tab";
|
|
||||||
import TabButton from "features/tabs/TabButton.vue";
|
|
||||||
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 type { Component, PropType, Ref } from "vue";
|
|
||||||
import { computed, defineComponent, shallowRef, toRefs, unref, watchEffect } from "vue";
|
|
||||||
|
|
||||||
export default defineComponent({
|
const props = defineProps<{
|
||||||
props: {
|
activeTab: TabFamily["activeTab"];
|
||||||
visibility: {
|
tabs: TabFamily["tabs"];
|
||||||
type: processedPropType<Visibility | boolean>(Number, Boolean),
|
buttonContainerClasses: TabFamily["buttonContainerClasses"];
|
||||||
required: true
|
buttonContainerStyle: TabFamily["buttonContainerStyle"];
|
||||||
},
|
}>();
|
||||||
activeTab: {
|
|
||||||
type: processedPropType<GenericTab | CoercableComponent | null>(Object),
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
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 Component = () => {
|
||||||
return themes[settings.theme].floatingTabs;
|
const activeTab = unref(props.activeTab);
|
||||||
});
|
if (activeTab == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return render(activeTab);
|
||||||
|
};
|
||||||
|
|
||||||
const component = shallowRef<Component | string>("");
|
const floating = computed(() => {
|
||||||
|
return themes[settings.theme].floatingTabs;
|
||||||
|
});
|
||||||
|
|
||||||
watchEffect(() => {
|
const TabButtons = () => Object.values(props.tabs).map(tab => render(tab));
|
||||||
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 tabClasses = computed(() => {
|
||||||
const currActiveTab = unwrapRef(activeTab);
|
const activeTab = unref(props.activeTab);
|
||||||
const tabClasses =
|
if (isType(activeTab, TabType)) {
|
||||||
isCoercableComponent(currActiveTab) || !currActiveTab
|
return unref(activeTab.classes);
|
||||||
? undefined
|
}
|
||||||
: unref(currActiveTab.classes);
|
});
|
||||||
return tabClasses;
|
|
||||||
});
|
|
||||||
|
|
||||||
const tabStyle = computed(() => {
|
const tabStyle = computed(() => {
|
||||||
const currActiveTab = unwrapRef(activeTab);
|
const activeTab = unref(props.activeTab);
|
||||||
return isCoercableComponent(currActiveTab) || !currActiveTab
|
if (isType(activeTab, TabType)) {
|
||||||
? undefined
|
return unref(activeTab.style);
|
||||||
: 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
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
@ -199,6 +128,10 @@ export default defineComponent({
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tab-buttons > * {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.layer-tab
|
.layer-tab
|
||||||
> .tab-family-container:first-child:nth-last-child(3)
|
> .tab-family-container:first-child:nth-last-child(3)
|
||||||
> .tab-buttons-container
|
> .tab-buttons-container
|
||||||
|
|
|
@ -1,14 +1,8 @@
|
||||||
import type {
|
import { processGetter } from "util/computed";
|
||||||
CoercableComponent,
|
|
||||||
GenericComponent,
|
|
||||||
OptionsFunc,
|
|
||||||
Replace,
|
|
||||||
StyleValue
|
|
||||||
} from "features/feature";
|
|
||||||
import { Component, GatherProps, getUniqueID } from "features/feature";
|
|
||||||
import TabComponent from "features/tabs/Tab.vue";
|
|
||||||
import type { Computable, GetComputableType } from "util/computed";
|
|
||||||
import { createLazyProxy } from "util/proxies";
|
import { createLazyProxy } from "util/proxies";
|
||||||
|
import { render, Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
|
||||||
|
import { MaybeRef, MaybeRefOrGetter } from "vue";
|
||||||
|
import { JSX } from "vue/jsx-runtime";
|
||||||
|
|
||||||
/** A symbol used to identify {@link Tab} features. */
|
/** A symbol used to identify {@link Tab} features. */
|
||||||
export const TabType = Symbol("Tab");
|
export const TabType = Symbol("Tab");
|
||||||
|
@ -16,63 +10,38 @@ export const TabType = Symbol("Tab");
|
||||||
/**
|
/**
|
||||||
* An object that configures a {@link Tab}.
|
* An object that configures a {@link Tab}.
|
||||||
*/
|
*/
|
||||||
export interface TabOptions {
|
export interface TabOptions extends VueFeatureOptions {
|
||||||
/** Dictionary of CSS classes to apply to this feature. */
|
|
||||||
classes?: Computable<Record<string, boolean>>;
|
|
||||||
/** CSS to apply to this feature. */
|
|
||||||
style?: Computable<StyleValue>;
|
|
||||||
/** The display to use for this tab. */
|
/** The display to use for this tab. */
|
||||||
display: Computable<CoercableComponent>;
|
display: MaybeRefOrGetter<Renderable>;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The properties that are added onto a processed {@link TabOptions} to create an {@link Tab}.
|
|
||||||
*/
|
|
||||||
export interface BaseTab {
|
|
||||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
|
||||||
id: string;
|
|
||||||
/** A symbol that helps identify features of the same type. */
|
|
||||||
type: typeof TabType;
|
|
||||||
/** The Vue component used to render this feature. */
|
|
||||||
[Component]: GenericComponent;
|
|
||||||
/** A function to gather the props the vue component requires for this feature. */
|
|
||||||
[GatherProps]: () => Record<string, unknown>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An object representing a tab of content in a tabbed interface.
|
* An object representing a tab of content in a tabbed interface.
|
||||||
* @see {@link TabFamily}
|
* @see {@link TabFamily}
|
||||||
*/
|
*/
|
||||||
export type Tab<T extends TabOptions> = Replace<
|
export interface Tab extends VueFeature {
|
||||||
T & BaseTab,
|
/** The display to use for this tab. */
|
||||||
{
|
display: MaybeRef<Renderable>;
|
||||||
classes: GetComputableType<T["classes"]>;
|
/** A symbol that helps identify features of the same type. */
|
||||||
style: GetComputableType<T["style"]>;
|
type: typeof TabType;
|
||||||
display: GetComputableType<T["display"]>;
|
}
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** A type that matches any valid {@link Tab} object. */
|
|
||||||
export type GenericTab = Tab<TabOptions>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lazily creates a tab with the given options.
|
* Lazily creates a tab with the given options.
|
||||||
* @param optionsFunc Tab options.
|
* @param optionsFunc Tab options.
|
||||||
*/
|
*/
|
||||||
export function createTab<T extends TabOptions>(
|
export function createTab<T extends TabOptions>(optionsFunc: () => T) {
|
||||||
optionsFunc: OptionsFunc<T, BaseTab, GenericTab>
|
return createLazyProxy(() => {
|
||||||
): Tab<T> {
|
const options = optionsFunc?.() ?? ({} as T);
|
||||||
return createLazyProxy(feature => {
|
const { display, ...props } = options;
|
||||||
const tab = optionsFunc.call(feature, feature);
|
|
||||||
tab.id = getUniqueID("tab-");
|
|
||||||
tab.type = TabType;
|
|
||||||
tab[Component] = TabComponent as GenericComponent;
|
|
||||||
|
|
||||||
tab[GatherProps] = function (this: GenericTab) {
|
const tab = {
|
||||||
const { display } = this;
|
type: TabType,
|
||||||
return { display };
|
...(props as Omit<typeof props, keyof VueFeature | keyof TabOptions>),
|
||||||
};
|
...vueFeatureMixin("tab", options, (): JSX.Element => render(tab.display)),
|
||||||
|
display: processGetter(display)
|
||||||
|
} satisfies Tab;
|
||||||
|
|
||||||
return tab as unknown as Tab<T>;
|
return tab;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,232 +0,0 @@
|
||||||
import type {
|
|
||||||
CoercableComponent,
|
|
||||||
GenericComponent,
|
|
||||||
OptionsFunc,
|
|
||||||
Replace,
|
|
||||||
StyleValue
|
|
||||||
} from "features/feature";
|
|
||||||
import {
|
|
||||||
Component,
|
|
||||||
GatherProps,
|
|
||||||
getUniqueID,
|
|
||||||
isVisible,
|
|
||||||
setDefault,
|
|
||||||
Visibility
|
|
||||||
} from "features/feature";
|
|
||||||
import TabButtonComponent from "features/tabs/TabButton.vue";
|
|
||||||
import TabFamilyComponent from "features/tabs/TabFamily.vue";
|
|
||||||
import type { Persistent } from "game/persistence";
|
|
||||||
import { persistent } from "game/persistence";
|
|
||||||
import type {
|
|
||||||
Computable,
|
|
||||||
GetComputableType,
|
|
||||||
GetComputableTypeWithDefault,
|
|
||||||
ProcessedComputable
|
|
||||||
} from "util/computed";
|
|
||||||
import { processComputable } from "util/computed";
|
|
||||||
import { createLazyProxy } from "util/proxies";
|
|
||||||
import type { Ref } from "vue";
|
|
||||||
import { computed, unref } from "vue";
|
|
||||||
import type { GenericTab } from "./tab";
|
|
||||||
|
|
||||||
/** A symbol used to identify {@link TabButton} features. */
|
|
||||||
export const TabButtonType = Symbol("TabButton");
|
|
||||||
/** A symbol used to identify {@link TabFamily} features. */
|
|
||||||
export const TabFamilyType = Symbol("TabFamily");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An object that configures a {@link TabButton}.
|
|
||||||
*/
|
|
||||||
export interface TabButtonOptions {
|
|
||||||
/** Whether this tab button should be visible. */
|
|
||||||
visibility?: Computable<Visibility | boolean>;
|
|
||||||
/** The tab to display when this button is clicked. */
|
|
||||||
tab: Computable<GenericTab | CoercableComponent>;
|
|
||||||
/** The label on this button. */
|
|
||||||
display: Computable<CoercableComponent>;
|
|
||||||
/** Dictionary of CSS classes to apply to this feature. */
|
|
||||||
classes?: Computable<Record<string, boolean>>;
|
|
||||||
/** CSS to apply to this feature. */
|
|
||||||
style?: Computable<StyleValue>;
|
|
||||||
/** The color of the glow effect to display when this button is active. */
|
|
||||||
glowColor?: Computable<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The properties that are added onto a processed {@link TabButtonOptions} to create an {@link TabButton}.
|
|
||||||
*/
|
|
||||||
export interface BaseTabButton {
|
|
||||||
/** A symbol that helps identify features of the same type. */
|
|
||||||
type: typeof TabButtonType;
|
|
||||||
/** The Vue component used to render this feature. */
|
|
||||||
[Component]: GenericComponent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An object that represents a button that can be clicked to change tabs in a tabbed interface.
|
|
||||||
* @see {@link TabFamily}
|
|
||||||
*/
|
|
||||||
export type TabButton<T extends TabButtonOptions> = Replace<
|
|
||||||
T & BaseTabButton,
|
|
||||||
{
|
|
||||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
|
||||||
tab: GetComputableType<T["tab"]>;
|
|
||||||
display: GetComputableType<T["display"]>;
|
|
||||||
classes: GetComputableType<T["classes"]>;
|
|
||||||
style: GetComputableType<T["style"]>;
|
|
||||||
glowColor: GetComputableType<T["glowColor"]>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** A type that matches any valid {@link TabButton} object. */
|
|
||||||
export type GenericTabButton = Replace<
|
|
||||||
TabButton<TabButtonOptions>,
|
|
||||||
{
|
|
||||||
visibility: ProcessedComputable<Visibility | boolean>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An object that configures a {@link TabFamily}.
|
|
||||||
*/
|
|
||||||
export interface TabFamilyOptions {
|
|
||||||
/** Whether this tab button should be visible. */
|
|
||||||
visibility?: Computable<Visibility | boolean>;
|
|
||||||
/** Dictionary of CSS classes to apply to this feature. */
|
|
||||||
classes?: Computable<Record<string, boolean>>;
|
|
||||||
/** CSS to apply to this feature. */
|
|
||||||
style?: Computable<StyleValue>;
|
|
||||||
/** A dictionary of CSS classes to apply to the list of buttons for changing tabs. */
|
|
||||||
buttonContainerClasses?: Computable<Record<string, boolean>>;
|
|
||||||
/** CSS to apply to the list of buttons for changing tabs. */
|
|
||||||
buttonContainerStyle?: Computable<StyleValue>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The properties that are added onto a processed {@link TabFamilyOptions} to create an {@link TabFamily}.
|
|
||||||
*/
|
|
||||||
export interface BaseTabFamily {
|
|
||||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
|
||||||
id: string;
|
|
||||||
/** All the tabs within this family. */
|
|
||||||
tabs: Record<string, TabButtonOptions>;
|
|
||||||
/** The currently active tab, if any. */
|
|
||||||
activeTab: Ref<GenericTab | CoercableComponent | null>;
|
|
||||||
/** The name of the tab that is currently active. */
|
|
||||||
selected: Persistent<string>;
|
|
||||||
/** A symbol that helps identify features of the same type. */
|
|
||||||
type: typeof TabFamilyType;
|
|
||||||
/** The Vue component used to render this feature. */
|
|
||||||
[Component]: GenericComponent;
|
|
||||||
/** A function to gather the props the vue component requires for this feature. */
|
|
||||||
[GatherProps]: () => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An object that represents a tabbed interface.
|
|
||||||
* @see {@link TabFamily}
|
|
||||||
*/
|
|
||||||
export type TabFamily<T extends TabFamilyOptions> = Replace<
|
|
||||||
T & BaseTabFamily,
|
|
||||||
{
|
|
||||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
|
||||||
tabs: Record<string, GenericTabButton>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** A type that matches any valid {@link TabFamily} object. */
|
|
||||||
export type GenericTabFamily = Replace<
|
|
||||||
TabFamily<TabFamilyOptions>,
|
|
||||||
{
|
|
||||||
visibility: ProcessedComputable<Visibility | boolean>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lazily creates a tab family with the given options.
|
|
||||||
* @param optionsFunc Tab family options.
|
|
||||||
*/
|
|
||||||
export function createTabFamily<T extends TabFamilyOptions>(
|
|
||||||
tabs: Record<string, () => TabButtonOptions>,
|
|
||||||
optionsFunc?: OptionsFunc<T, BaseTabFamily, GenericTabFamily>
|
|
||||||
): TabFamily<T> {
|
|
||||||
if (Object.keys(tabs).length === 0) {
|
|
||||||
console.error("Cannot create tab family with 0 tabs");
|
|
||||||
}
|
|
||||||
|
|
||||||
const selected = persistent(Object.keys(tabs)[0], false);
|
|
||||||
return createLazyProxy(feature => {
|
|
||||||
const tabFamily =
|
|
||||||
optionsFunc?.call(feature, feature) ??
|
|
||||||
({} as ReturnType<NonNullable<typeof optionsFunc>>);
|
|
||||||
|
|
||||||
tabFamily.id = getUniqueID("tabFamily-");
|
|
||||||
tabFamily.type = TabFamilyType;
|
|
||||||
tabFamily[Component] = TabFamilyComponent as GenericComponent;
|
|
||||||
|
|
||||||
tabFamily.tabs = Object.keys(tabs).reduce<Record<string, GenericTabButton>>(
|
|
||||||
(parsedTabs, tab) => {
|
|
||||||
const tabButton: TabButtonOptions & Partial<BaseTabButton> = tabs[tab]();
|
|
||||||
tabButton.type = TabButtonType;
|
|
||||||
tabButton[Component] = TabButtonComponent as GenericComponent;
|
|
||||||
|
|
||||||
processComputable(tabButton as TabButtonOptions, "visibility");
|
|
||||||
setDefault(tabButton, "visibility", Visibility.Visible);
|
|
||||||
processComputable(tabButton as TabButtonOptions, "tab");
|
|
||||||
processComputable(tabButton as TabButtonOptions, "display");
|
|
||||||
processComputable(tabButton as TabButtonOptions, "classes");
|
|
||||||
processComputable(tabButton as TabButtonOptions, "style");
|
|
||||||
processComputable(tabButton as TabButtonOptions, "glowColor");
|
|
||||||
parsedTabs[tab] = tabButton as GenericTabButton;
|
|
||||||
return parsedTabs;
|
|
||||||
},
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
tabFamily.selected = selected;
|
|
||||||
tabFamily.activeTab = computed(() => {
|
|
||||||
const tabs = unref(processedTabFamily.tabs);
|
|
||||||
if (selected.value in tabs && isVisible(tabs[selected.value].visibility)) {
|
|
||||||
return unref(tabs[selected.value].tab);
|
|
||||||
}
|
|
||||||
const firstTab = Object.values(tabs).find(tab => isVisible(tab.visibility));
|
|
||||||
if (firstTab) {
|
|
||||||
return unref(firstTab.tab);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
processComputable(tabFamily as T, "visibility");
|
|
||||||
setDefault(tabFamily, "visibility", Visibility.Visible);
|
|
||||||
processComputable(tabFamily as T, "classes");
|
|
||||||
processComputable(tabFamily as T, "style");
|
|
||||||
processComputable(tabFamily as T, "buttonContainerClasses");
|
|
||||||
processComputable(tabFamily as T, "buttonContainerStyle");
|
|
||||||
|
|
||||||
tabFamily[GatherProps] = function (this: GenericTabFamily) {
|
|
||||||
const {
|
|
||||||
visibility,
|
|
||||||
activeTab,
|
|
||||||
selected,
|
|
||||||
tabs,
|
|
||||||
style,
|
|
||||||
classes,
|
|
||||||
buttonContainerClasses,
|
|
||||||
buttonContainerStyle
|
|
||||||
} = this;
|
|
||||||
return {
|
|
||||||
visibility,
|
|
||||||
activeTab,
|
|
||||||
selected,
|
|
||||||
tabs,
|
|
||||||
style: unref(style),
|
|
||||||
classes,
|
|
||||||
buttonContainerClasses,
|
|
||||||
buttonContainerStyle
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// This is necessary because board.types is different from T and TabFamily
|
|
||||||
const processedTabFamily = tabFamily as unknown as TabFamily<T>;
|
|
||||||
return processedTabFamily;
|
|
||||||
});
|
|
||||||
}
|
|
146
src/features/tabs/tabFamily.tsx
Normal file
146
src/features/tabs/tabFamily.tsx
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
import { isVisible } from "features/feature";
|
||||||
|
import { Tab } from "features/tabs/tab";
|
||||||
|
import TabButton from "features/tabs/TabButton.vue";
|
||||||
|
import TabFamily from "features/tabs/TabFamily.vue";
|
||||||
|
import type { Persistent } from "game/persistence";
|
||||||
|
import { persistent } from "game/persistence";
|
||||||
|
import { processGetter } from "util/computed";
|
||||||
|
import { createLazyProxy } from "util/proxies";
|
||||||
|
import { Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
|
||||||
|
import type { CSSProperties, MaybeRef, MaybeRefOrGetter, Ref } from "vue";
|
||||||
|
import { computed, unref } from "vue";
|
||||||
|
|
||||||
|
/** A symbol used to identify {@link TabButton} features. */
|
||||||
|
export const TabButtonType = Symbol("TabButton");
|
||||||
|
/** A symbol used to identify {@link TabFamily} features. */
|
||||||
|
export const TabFamilyType = Symbol("TabFamily");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object that configures a {@link TabButton}.
|
||||||
|
*/
|
||||||
|
export interface TabButtonOptions extends VueFeatureOptions {
|
||||||
|
/** The tab to display when this button is clicked. */
|
||||||
|
tab: Tab | MaybeRefOrGetter<Renderable>;
|
||||||
|
/** The label on this button. */
|
||||||
|
display: MaybeRefOrGetter<Renderable>;
|
||||||
|
/** The color of the glow effect to display when this button is active. */
|
||||||
|
glowColor?: MaybeRefOrGetter<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object that represents a button that can be clicked to change tabs in a tabbed interface.
|
||||||
|
* @see {@link TabFamily}
|
||||||
|
*/
|
||||||
|
export interface TabButton extends VueFeature {
|
||||||
|
/** The tab to display when this button is clicked. */
|
||||||
|
tab: Tab | MaybeRef<Renderable>;
|
||||||
|
/** The label on this button. */
|
||||||
|
display: MaybeRef<Renderable>;
|
||||||
|
/** The color of the glow effect to display when this button is active. */
|
||||||
|
glowColor?: MaybeRef<string>;
|
||||||
|
/** A symbol that helps identify features of the same type. */
|
||||||
|
type: typeof TabButtonType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object that configures a {@link TabFamily}.
|
||||||
|
*/
|
||||||
|
export interface TabFamilyOptions extends VueFeatureOptions {
|
||||||
|
/** A dictionary of CSS classes to apply to the list of buttons for changing tabs. */
|
||||||
|
buttonContainerClasses?: MaybeRefOrGetter<Record<string, boolean>>;
|
||||||
|
/** CSS to apply to the list of buttons for changing tabs. */
|
||||||
|
buttonContainerStyle?: MaybeRefOrGetter<CSSProperties>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object that represents a tabbed interface.
|
||||||
|
* @see {@link TabFamily}
|
||||||
|
*/
|
||||||
|
export interface TabFamily extends VueFeature {
|
||||||
|
/** A dictionary of CSS classes to apply to the list of buttons for changing tabs. */
|
||||||
|
buttonContainerClasses?: MaybeRef<Record<string, boolean>>;
|
||||||
|
/** CSS to apply to the list of buttons for changing tabs. */
|
||||||
|
buttonContainerStyle?: MaybeRef<CSSProperties>;
|
||||||
|
/** All the tabs within this family. */
|
||||||
|
tabs: Record<string, TabButton>;
|
||||||
|
/** The currently active tab, if any. */
|
||||||
|
activeTab: Ref<Tab | MaybeRef<Renderable> | null>;
|
||||||
|
/** The name of the tab that is currently active. */
|
||||||
|
selected: Persistent<string>;
|
||||||
|
/** A symbol that helps identify features of the same type. */
|
||||||
|
type: typeof TabFamilyType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily creates a tab family with the given options.
|
||||||
|
* @param optionsFunc Tab family options.
|
||||||
|
*/
|
||||||
|
export function createTabFamily<T extends TabFamilyOptions>(
|
||||||
|
tabs: Record<string, () => TabButtonOptions>,
|
||||||
|
optionsFunc?: () => T
|
||||||
|
) {
|
||||||
|
if (Object.keys(tabs).length === 0) {
|
||||||
|
console.error("Cannot create tab family with 0 tabs");
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = persistent(Object.keys(tabs)[0], false);
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const options = optionsFunc?.() ?? ({} as T);
|
||||||
|
const { buttonContainerClasses, buttonContainerStyle, ...props } = options;
|
||||||
|
|
||||||
|
const tabFamily = {
|
||||||
|
type: TabFamilyType,
|
||||||
|
...(props as Omit<typeof props, keyof VueFeature | keyof TabFamilyOptions>),
|
||||||
|
...vueFeatureMixin("tabFamily", options, () => (
|
||||||
|
<TabFamily
|
||||||
|
activeTab={tabFamily.activeTab}
|
||||||
|
tabs={tabFamily.tabs}
|
||||||
|
buttonContainerClasses={tabFamily.buttonContainerClasses}
|
||||||
|
buttonContainerStyle={tabFamily.buttonContainerStyle}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
tabs: Object.keys(tabs).reduce<Record<string, TabButton>>((parsedTabs, tab) => {
|
||||||
|
const options = tabs[tab]();
|
||||||
|
const { tab: buttonTab, glowColor, display, ...props } = options;
|
||||||
|
|
||||||
|
const tabButton = {
|
||||||
|
type: TabButtonType,
|
||||||
|
...(props as Omit<typeof props, keyof VueFeature | keyof TabButtonOptions>),
|
||||||
|
...vueFeatureMixin("tabButton", options, () =>
|
||||||
|
<TabButton
|
||||||
|
display={tabButton.display}
|
||||||
|
glowColor={tabButton.glowColor}
|
||||||
|
active={unref(tabButton.tab) === unref(tabFamily.activeTab)}
|
||||||
|
onSelectTab={() => tabFamily.selected.value = tab}
|
||||||
|
/>),
|
||||||
|
tab: processGetter(buttonTab),
|
||||||
|
glowColor: processGetter(glowColor),
|
||||||
|
display: processGetter(display)
|
||||||
|
} satisfies TabButton;
|
||||||
|
|
||||||
|
parsedTabs[tab] = tabButton;
|
||||||
|
return parsedTabs;
|
||||||
|
}, {}),
|
||||||
|
buttonContainerClasses: processGetter(buttonContainerClasses),
|
||||||
|
buttonContainerStyle: processGetter(buttonContainerStyle),
|
||||||
|
selected,
|
||||||
|
activeTab: computed((): Tab | MaybeRef<Renderable> | null => {
|
||||||
|
if (
|
||||||
|
selected.value in tabFamily.tabs &&
|
||||||
|
isVisible(tabFamily.tabs[selected.value].visibility ?? true)
|
||||||
|
) {
|
||||||
|
return unref(tabFamily.tabs[selected.value].tab);
|
||||||
|
}
|
||||||
|
const firstTab = Object.values(tabFamily.tabs).find(tab =>
|
||||||
|
isVisible(tab.visibility ?? true)
|
||||||
|
);
|
||||||
|
if (firstTab != null) {
|
||||||
|
return unref(firstTab.tab);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
} satisfies TabFamily;
|
||||||
|
|
||||||
|
return tabFamily;
|
||||||
|
});
|
||||||
|
}
|
|
@ -1,120 +0,0 @@
|
||||||
import type { CoercableComponent, GenericComponent, Replace, StyleValue } from "features/feature";
|
|
||||||
import { Component, GatherProps, setDefault } from "features/feature";
|
|
||||||
import { deletePersistent, Persistent, persistent } from "game/persistence";
|
|
||||||
import { Direction } from "util/common";
|
|
||||||
import type {
|
|
||||||
Computable,
|
|
||||||
GetComputableType,
|
|
||||||
GetComputableTypeWithDefault,
|
|
||||||
ProcessedComputable
|
|
||||||
} from "util/computed";
|
|
||||||
import { processComputable } from "util/computed";
|
|
||||||
import type { VueFeature } from "util/vue";
|
|
||||||
import type { Ref } from "vue";
|
|
||||||
import { nextTick, unref } from "vue";
|
|
||||||
import TooltipComponent from "./Tooltip.vue";
|
|
||||||
|
|
||||||
declare module "@vue/runtime-dom" {
|
|
||||||
interface CSSProperties {
|
|
||||||
"--xoffset"?: string;
|
|
||||||
"--yoffset"?: string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An object that configures a {@link Tooltip}.
|
|
||||||
*/
|
|
||||||
export interface TooltipOptions {
|
|
||||||
/** Whether or not this tooltip can be pinned, meaning it'll stay visible even when not hovered. */
|
|
||||||
pinnable?: boolean;
|
|
||||||
/** The text to display inside the tooltip. */
|
|
||||||
display: Computable<CoercableComponent>;
|
|
||||||
/** Dictionary of CSS classes to apply to this feature. */
|
|
||||||
classes?: Computable<Record<string, boolean>>;
|
|
||||||
/** CSS to apply to this feature. */
|
|
||||||
style?: Computable<StyleValue>;
|
|
||||||
/** The direction in which to display the tooltip */
|
|
||||||
direction?: Computable<Direction>;
|
|
||||||
/** The x offset of the tooltip, in px. */
|
|
||||||
xoffset?: Computable<string>;
|
|
||||||
/** The y offset of the tooltip, in px. */
|
|
||||||
yoffset?: Computable<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The properties that are added onto a processed {@link TooltipOptions} to create an {@link Tooltip}.
|
|
||||||
*/
|
|
||||||
export interface BaseTooltip {
|
|
||||||
pinned?: Ref<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An object that represents a tooltip that appears when hovering over an element. */
|
|
||||||
export type Tooltip<T extends TooltipOptions> = Replace<
|
|
||||||
T & BaseTooltip,
|
|
||||||
{
|
|
||||||
pinnable: undefined extends T["pinnable"] ? false : T["pinnable"];
|
|
||||||
pinned: T["pinnable"] extends true ? Ref<boolean> : undefined;
|
|
||||||
display: GetComputableType<T["display"]>;
|
|
||||||
classes: GetComputableType<T["classes"]>;
|
|
||||||
style: GetComputableType<T["style"]>;
|
|
||||||
direction: GetComputableTypeWithDefault<T["direction"], Direction.Up>;
|
|
||||||
xoffset: GetComputableType<T["xoffset"]>;
|
|
||||||
yoffset: GetComputableType<T["yoffset"]>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** A type that matches any valid {@link Tooltip} object. */
|
|
||||||
export type GenericTooltip = Replace<
|
|
||||||
Tooltip<TooltipOptions>,
|
|
||||||
{
|
|
||||||
pinnable: boolean;
|
|
||||||
pinned: Ref<boolean> | undefined;
|
|
||||||
direction: ProcessedComputable<Direction>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a tooltip on the given element with the given options.
|
|
||||||
* @param element The renderable feature to display the tooltip on.
|
|
||||||
* @param options Tooltip options.
|
|
||||||
*/
|
|
||||||
export function addTooltip<T extends TooltipOptions>(
|
|
||||||
element: VueFeature,
|
|
||||||
options: T & ThisType<Tooltip<T>> & Partial<BaseTooltip>
|
|
||||||
): Tooltip<T> {
|
|
||||||
processComputable(options as T, "display");
|
|
||||||
processComputable(options as T, "classes");
|
|
||||||
processComputable(options as T, "style");
|
|
||||||
processComputable(options as T, "direction");
|
|
||||||
setDefault(options, "direction", Direction.Up);
|
|
||||||
processComputable(options as T, "xoffset");
|
|
||||||
processComputable(options as T, "yoffset");
|
|
||||||
|
|
||||||
if (options.pinnable) {
|
|
||||||
options.pinned = persistent<boolean>(false, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
nextTick(() => {
|
|
||||||
const elementComponent = element[Component];
|
|
||||||
element[Component] = TooltipComponent as GenericComponent;
|
|
||||||
const elementGatherProps = element[GatherProps].bind(element);
|
|
||||||
element[GatherProps] = function gatherTooltipProps(this: GenericTooltip) {
|
|
||||||
const { display, classes, style, direction, xoffset, yoffset, pinned } = this;
|
|
||||||
return {
|
|
||||||
element: {
|
|
||||||
[Component]: elementComponent,
|
|
||||||
[GatherProps]: elementGatherProps
|
|
||||||
},
|
|
||||||
display,
|
|
||||||
classes,
|
|
||||||
style: unref(style),
|
|
||||||
direction,
|
|
||||||
xoffset,
|
|
||||||
yoffset,
|
|
||||||
pinned
|
|
||||||
};
|
|
||||||
}.bind(options as GenericTooltip);
|
|
||||||
});
|
|
||||||
|
|
||||||
return options as unknown as Tooltip<T>;
|
|
||||||
}
|
|
|
@ -1,79 +1,38 @@
|
||||||
<template>
|
<template>
|
||||||
<component :is="nodesComp" />
|
<Nodes />
|
||||||
<component v-if="leftNodesComp" :is="leftNodesComp" />
|
<LeftNodes v-if="leftSideNodes" />
|
||||||
<component v-if="rightNodesComp" :is="rightNodesComp" />
|
<RightNodes v-if="rightSideNodes" />
|
||||||
<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 Links from "features/links/Links.vue";
|
import Links from "features/links/Links.vue";
|
||||||
import type { GenericTreeNode, TreeBranch } from "features/trees/tree";
|
import type { Tree } from "features/trees/tree";
|
||||||
import { coerceComponent, processedPropType, renderJSX, unwrapRef } from "util/vue";
|
import { render } from "util/vue";
|
||||||
import type { Component } from "vue";
|
import { unref } from "vue";
|
||||||
import { defineComponent, shallowRef, toRefs, unref, watchEffect } from "vue";
|
|
||||||
|
|
||||||
export default defineComponent({
|
const props = defineProps<{
|
||||||
props: {
|
nodes: Tree["nodes"];
|
||||||
nodes: {
|
leftSideNodes: Tree["leftSideNodes"];
|
||||||
type: processedPropType<GenericTreeNode[][]>(Array),
|
rightSideNodes: Tree["rightSideNodes"];
|
||||||
required: true
|
branches: Tree["branches"];
|
||||||
},
|
}>();
|
||||||
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 Nodes = () => unref(props.nodes).map(nodes =>
|
||||||
watchEffect(() => {
|
<span class="row tree-row" style="margin: 50px auto;">
|
||||||
const currNodes = unwrapRef(nodes);
|
{nodes.map(node => render(node))}
|
||||||
nodesComp.value = coerceComponent(
|
</span>);
|
||||||
jsx(() => (
|
|
||||||
<>
|
|
||||||
{currNodes.map(row => (
|
|
||||||
<span class="row tree-row" style="margin: 50px auto;">
|
|
||||||
{row.map(renderJSX)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const leftNodesComp = shallowRef<Component | "">();
|
const LeftNodes = () => props.leftSideNodes == null ? <></> :
|
||||||
watchEffect(() => {
|
<span class="left-side-nodes small">
|
||||||
const currNodes = unwrapRef(leftSideNodes);
|
{unref(props.leftSideNodes).map(node => render(node))}
|
||||||
leftNodesComp.value = currNodes
|
</span>;
|
||||||
? coerceComponent(
|
|
||||||
jsx(() => (
|
|
||||||
<span class="left-side-nodes small">{currNodes.map(renderJSX)}</span>
|
|
||||||
))
|
|
||||||
)
|
|
||||||
: "";
|
|
||||||
});
|
|
||||||
|
|
||||||
const rightNodesComp = shallowRef<Component | "">();
|
const RightNodes = () => props.rightSideNodes == null ? <></> :
|
||||||
watchEffect(() => {
|
<span class="side-nodes small">
|
||||||
const currNodes = unwrapRef(rightSideNodes);
|
{unref(props.rightSideNodes).map(node => render(node))}
|
||||||
rightNodesComp.value = currNodes
|
</span>;
|
||||||
? coerceComponent(
|
|
||||||
jsx(() => <span class="side-nodes small">{currNodes.map(renderJSX)}</span>)
|
|
||||||
)
|
|
||||||
: "";
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
unref,
|
|
||||||
nodesComp,
|
|
||||||
leftNodesComp,
|
|
||||||
rightNodesComp
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<button
|
||||||
v-if="isVisible(visibility)"
|
:style="{
|
||||||
:style="{ visibility: isHidden(visibility) ? 'hidden' : undefined }"
|
backgroundColor: unref(color),
|
||||||
|
boxShadow: `-4px -4px 4px rgba(0, 0, 0, 0.25) inset, 0 0 20px ${unref(
|
||||||
|
glowColor
|
||||||
|
)}`
|
||||||
|
}"
|
||||||
:class="{
|
:class="{
|
||||||
treeNode: true,
|
treeNode: true,
|
||||||
can: unref(canClick),
|
can: unref(canClick)
|
||||||
...unref(classes)
|
|
||||||
}"
|
}"
|
||||||
@click="onClick"
|
@click="onClick"
|
||||||
@mousedown="start"
|
@mousedown="start"
|
||||||
|
@ -15,108 +18,45 @@
|
||||||
@touchend.passive="stop"
|
@touchend.passive="stop"
|
||||||
@touchcancel.passive="stop"
|
@touchcancel.passive="stop"
|
||||||
>
|
>
|
||||||
<div
|
<Component />
|
||||||
:style="[
|
</button>
|
||||||
{
|
|
||||||
backgroundColor: unref(color),
|
|
||||||
boxShadow: `-4px -4px 4px rgba(0, 0, 0, 0.25) inset, 0 0 20px ${unref(
|
|
||||||
glowColor
|
|
||||||
)}`
|
|
||||||
},
|
|
||||||
unref(style) ?? []
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<component :is="unref(comp)" />
|
|
||||||
</div>
|
|
||||||
<MarkNode :mark="unref(mark)" />
|
|
||||||
<Node :id="id" />
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="tsx">
|
||||||
import MarkNode from "components/MarkNode.vue";
|
import { render, setupHoldToClick } from "util/vue";
|
||||||
import Node from "components/Node.vue";
|
import { toRef, unref } from "vue";
|
||||||
import type { CoercableComponent, StyleValue } from "features/feature";
|
import { TreeNode } from "./tree";
|
||||||
import { isHidden, isVisible, Visibility } from "features/feature";
|
|
||||||
import {
|
|
||||||
computeOptionalComponent,
|
|
||||||
isCoercableComponent,
|
|
||||||
processedPropType,
|
|
||||||
setupHoldToClick
|
|
||||||
} from "util/vue";
|
|
||||||
import type { PropType } from "vue";
|
|
||||||
import { defineComponent, toRefs, unref } from "vue";
|
|
||||||
|
|
||||||
export default defineComponent({
|
const props = defineProps<{
|
||||||
props: {
|
canClick: TreeNode["canClick"];
|
||||||
display: processedPropType<CoercableComponent>(Object, String, Function),
|
display: TreeNode["display"];
|
||||||
visibility: {
|
onClick: TreeNode["onClick"];
|
||||||
type: processedPropType<Visibility | boolean>(Number, Boolean),
|
onHold: TreeNode["onHold"];
|
||||||
required: true
|
color: TreeNode["color"];
|
||||||
},
|
glowColor: TreeNode["glowColor"];
|
||||||
style: processedPropType<StyleValue>(String, Object, Array),
|
}>();
|
||||||
classes: processedPropType<Record<string, boolean>>(Object),
|
|
||||||
onClick: Function as PropType<(e?: MouseEvent | TouchEvent) => void>,
|
|
||||||
onHold: Function as PropType<VoidFunction>,
|
|
||||||
color: processedPropType<string>(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 Component = () => props.display == null ? <></> :
|
||||||
|
render(props.display, el => <div>{el}</div>);
|
||||||
|
|
||||||
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>
|
||||||
.treeNode {
|
.treeNode {
|
||||||
height: 100px;
|
height: 100px;
|
||||||
width: 100px;
|
width: 100px;
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.125);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0 10px 0 10px;
|
margin: 0 10px 0 10px;
|
||||||
}
|
|
||||||
|
|
||||||
.treeNode > *:first-child {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: 2px solid rgba(0, 0, 0, 0.125);
|
|
||||||
border-radius: inherit;
|
|
||||||
font-size: 40px;
|
font-size: 40px;
|
||||||
color: rgba(0, 0, 0, 0.5);
|
color: rgba(0, 0, 0, 0.5);
|
||||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.25);
|
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.25);
|
||||||
box-shadow: -4px -4px 4px rgba(0, 0, 0, 0.25) inset, 0px 0px 20px var(--background);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.treeNode > *:first-child > * {
|
.treeNode > * {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,383 +0,0 @@
|
||||||
import { Decorator, GenericDecorator } from "features/decorators/common";
|
|
||||||
import type {
|
|
||||||
CoercableComponent,
|
|
||||||
GenericComponent,
|
|
||||||
OptionsFunc,
|
|
||||||
Replace,
|
|
||||||
StyleValue
|
|
||||||
} from "features/feature";
|
|
||||||
import { Component, GatherProps, getUniqueID, setDefault, Visibility } from "features/feature";
|
|
||||||
import type { Link } from "features/links/links";
|
|
||||||
import type { GenericReset } from "features/reset";
|
|
||||||
import type { Resource } from "features/resources/resource";
|
|
||||||
import { displayResource } from "features/resources/resource";
|
|
||||||
import TreeComponent from "features/trees/Tree.vue";
|
|
||||||
import TreeNodeComponent from "features/trees/TreeNode.vue";
|
|
||||||
import type { DecimalSource } from "util/bignum";
|
|
||||||
import Decimal, { format, formatWhole } from "util/bignum";
|
|
||||||
import type {
|
|
||||||
Computable,
|
|
||||||
GetComputableType,
|
|
||||||
GetComputableTypeWithDefault,
|
|
||||||
ProcessedComputable
|
|
||||||
} from "util/computed";
|
|
||||||
import { convertComputable, processComputable } from "util/computed";
|
|
||||||
import { createLazyProxy } from "util/proxies";
|
|
||||||
import type { Ref } from "vue";
|
|
||||||
import { computed, ref, shallowRef, unref } from "vue";
|
|
||||||
|
|
||||||
/** A symbol used to identify {@link TreeNode} features. */
|
|
||||||
export const TreeNodeType = Symbol("TreeNode");
|
|
||||||
/** A symbol used to identify {@link Tree} features. */
|
|
||||||
export const TreeType = Symbol("Tree");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An object that configures a {@link TreeNode}.
|
|
||||||
*/
|
|
||||||
export interface TreeNodeOptions {
|
|
||||||
/** Whether this tree node should be visible. */
|
|
||||||
visibility?: Computable<Visibility | boolean>;
|
|
||||||
/** Whether or not this tree node can be clicked. */
|
|
||||||
canClick?: Computable<boolean>;
|
|
||||||
/** The background color for this node. */
|
|
||||||
color?: Computable<string>;
|
|
||||||
/** The label to display on this tree node. */
|
|
||||||
display?: Computable<CoercableComponent>;
|
|
||||||
/** The color of the glow effect shown to notify the user there's something to do with this node. */
|
|
||||||
glowColor?: Computable<string>;
|
|
||||||
/** Dictionary of CSS classes to apply to this feature. */
|
|
||||||
classes?: Computable<Record<string, boolean>>;
|
|
||||||
/** CSS to apply to this feature. */
|
|
||||||
style?: Computable<StyleValue>;
|
|
||||||
/** Shows a marker on the corner of the feature. */
|
|
||||||
mark?: Computable<boolean | string>;
|
|
||||||
/** A reset object attached to this node, used for propagating resets through the tree. */
|
|
||||||
reset?: GenericReset;
|
|
||||||
/** A function that is called when the tree node is clicked. */
|
|
||||||
onClick?: (e?: MouseEvent | TouchEvent) => void;
|
|
||||||
/** A function that is called when the tree node is held down. */
|
|
||||||
onHold?: VoidFunction;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The properties that are added onto a processed {@link TreeNodeOptions} to create an {@link TreeNode}.
|
|
||||||
*/
|
|
||||||
export interface BaseTreeNode {
|
|
||||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
|
||||||
id: string;
|
|
||||||
/** A symbol that helps identify features of the same type. */
|
|
||||||
type: typeof TreeNodeType;
|
|
||||||
/** The Vue component used to render this feature. */
|
|
||||||
[Component]: GenericComponent;
|
|
||||||
/** A function to gather the props the vue component requires for this feature. */
|
|
||||||
[GatherProps]: () => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An object that represents a node on a tree. */
|
|
||||||
export type TreeNode<T extends TreeNodeOptions> = Replace<
|
|
||||||
T & BaseTreeNode,
|
|
||||||
{
|
|
||||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
|
||||||
canClick: GetComputableTypeWithDefault<T["canClick"], true>;
|
|
||||||
color: GetComputableType<T["color"]>;
|
|
||||||
display: GetComputableType<T["display"]>;
|
|
||||||
glowColor: GetComputableType<T["glowColor"]>;
|
|
||||||
classes: GetComputableType<T["classes"]>;
|
|
||||||
style: GetComputableType<T["style"]>;
|
|
||||||
mark: GetComputableType<T["mark"]>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** A type that matches any valid {@link TreeNode} object. */
|
|
||||||
export type GenericTreeNode = Replace<
|
|
||||||
TreeNode<TreeNodeOptions>,
|
|
||||||
{
|
|
||||||
visibility: ProcessedComputable<Visibility | boolean>;
|
|
||||||
canClick: ProcessedComputable<boolean>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lazily creates a tree node with the given options.
|
|
||||||
* @param optionsFunc Tree Node options.
|
|
||||||
*/
|
|
||||||
export function createTreeNode<T extends TreeNodeOptions>(
|
|
||||||
optionsFunc?: OptionsFunc<T, BaseTreeNode, GenericTreeNode>,
|
|
||||||
...decorators: GenericDecorator[]
|
|
||||||
): TreeNode<T> {
|
|
||||||
const decoratedData = decorators.reduce(
|
|
||||||
(current, next) => Object.assign(current, next.getPersistentData?.()),
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
return createLazyProxy(feature => {
|
|
||||||
const treeNode =
|
|
||||||
optionsFunc?.call(feature, feature) ??
|
|
||||||
({} as ReturnType<NonNullable<typeof optionsFunc>>);
|
|
||||||
treeNode.id = getUniqueID("treeNode-");
|
|
||||||
treeNode.type = TreeNodeType;
|
|
||||||
treeNode[Component] = TreeNodeComponent as GenericComponent;
|
|
||||||
|
|
||||||
for (const decorator of decorators) {
|
|
||||||
decorator.preConstruct?.(treeNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.assign(decoratedData);
|
|
||||||
|
|
||||||
processComputable(treeNode as T, "visibility");
|
|
||||||
setDefault(treeNode, "visibility", Visibility.Visible);
|
|
||||||
processComputable(treeNode as T, "canClick");
|
|
||||||
setDefault(treeNode, "canClick", true);
|
|
||||||
processComputable(treeNode as T, "color");
|
|
||||||
processComputable(treeNode as T, "display");
|
|
||||||
processComputable(treeNode as T, "glowColor");
|
|
||||||
processComputable(treeNode as T, "classes");
|
|
||||||
processComputable(treeNode as T, "style");
|
|
||||||
processComputable(treeNode as T, "mark");
|
|
||||||
|
|
||||||
for (const decorator of decorators) {
|
|
||||||
decorator.postConstruct?.(treeNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (treeNode.onClick) {
|
|
||||||
const onClick = treeNode.onClick.bind(treeNode);
|
|
||||||
treeNode.onClick = function (e) {
|
|
||||||
if (unref(treeNode.canClick) !== false) {
|
|
||||||
onClick(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (treeNode.onHold) {
|
|
||||||
const onHold = treeNode.onHold.bind(treeNode);
|
|
||||||
treeNode.onHold = function () {
|
|
||||||
if (unref(treeNode.canClick) !== false) {
|
|
||||||
onHold();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const decoratedProps = decorators.reduce(
|
|
||||||
(current, next) => Object.assign(current, next.getGatheredProps?.(treeNode)),
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
treeNode[GatherProps] = function (this: GenericTreeNode) {
|
|
||||||
const {
|
|
||||||
display,
|
|
||||||
visibility,
|
|
||||||
style,
|
|
||||||
classes,
|
|
||||||
onClick,
|
|
||||||
onHold,
|
|
||||||
color,
|
|
||||||
glowColor,
|
|
||||||
canClick,
|
|
||||||
mark,
|
|
||||||
id
|
|
||||||
} = this;
|
|
||||||
return {
|
|
||||||
display,
|
|
||||||
visibility,
|
|
||||||
style,
|
|
||||||
classes,
|
|
||||||
onClick,
|
|
||||||
onHold,
|
|
||||||
color,
|
|
||||||
glowColor,
|
|
||||||
canClick,
|
|
||||||
mark,
|
|
||||||
id,
|
|
||||||
...decoratedProps
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return treeNode as unknown as TreeNode<T>;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Represents a branch between two nodes in a tree. */
|
|
||||||
export interface TreeBranch extends Omit<Link, "startNode" | "endNode"> {
|
|
||||||
startNode: GenericTreeNode;
|
|
||||||
endNode: GenericTreeNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An object that configures a {@link Tree}.
|
|
||||||
*/
|
|
||||||
export interface TreeOptions {
|
|
||||||
/** Whether this clickable should be visible. */
|
|
||||||
visibility?: Computable<Visibility | boolean>;
|
|
||||||
/** The nodes within the tree, in a 2D array. */
|
|
||||||
nodes: Computable<GenericTreeNode[][]>;
|
|
||||||
/** Nodes to show on the left side of the tree. */
|
|
||||||
leftSideNodes?: Computable<GenericTreeNode[]>;
|
|
||||||
/** Nodes to show on the right side of the tree. */
|
|
||||||
rightSideNodes?: Computable<GenericTreeNode[]>;
|
|
||||||
/** The branches between nodes within this tree. */
|
|
||||||
branches?: Computable<TreeBranch[]>;
|
|
||||||
/** How to propagate resets through the tree. */
|
|
||||||
resetPropagation?: ResetPropagation;
|
|
||||||
/** A function that is called when a node within the tree is reset. */
|
|
||||||
onReset?: (node: GenericTreeNode) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BaseTree {
|
|
||||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
|
||||||
id: string;
|
|
||||||
/** The link objects for each of the branches of the tree. */
|
|
||||||
links: Ref<Link[]>;
|
|
||||||
/** Cause a reset on this node and propagate it through the tree according to {@link resetPropagation}. */
|
|
||||||
reset: (node: GenericTreeNode) => void;
|
|
||||||
/** A flag that is true while the reset is still propagating through the tree. */
|
|
||||||
isResetting: Ref<boolean>;
|
|
||||||
/** A reference to the node that caused the currently propagating reset. */
|
|
||||||
resettingNode: Ref<GenericTreeNode | null>;
|
|
||||||
/** A symbol that helps identify features of the same type. */
|
|
||||||
type: typeof TreeType;
|
|
||||||
/** The Vue component used to render this feature. */
|
|
||||||
[Component]: GenericComponent;
|
|
||||||
/** A function to gather the props the vue component requires for this feature. */
|
|
||||||
[GatherProps]: () => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An object that represents a feature that is a tree of nodes with branches between them. Contains support for reset mechanics that can propagate through the tree. */
|
|
||||||
export type Tree<T extends TreeOptions> = Replace<
|
|
||||||
T & BaseTree,
|
|
||||||
{
|
|
||||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
|
||||||
nodes: GetComputableType<T["nodes"]>;
|
|
||||||
leftSideNodes: GetComputableType<T["leftSideNodes"]>;
|
|
||||||
rightSideNodes: GetComputableType<T["rightSideNodes"]>;
|
|
||||||
branches: GetComputableType<T["branches"]>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** A type that matches any valid {@link Tree} object. */
|
|
||||||
export type GenericTree = Replace<
|
|
||||||
Tree<TreeOptions>,
|
|
||||||
{
|
|
||||||
visibility: ProcessedComputable<Visibility | boolean>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lazily creates a tree with the given options.
|
|
||||||
* @param optionsFunc Tree options.
|
|
||||||
*/
|
|
||||||
export function createTree<T extends TreeOptions>(
|
|
||||||
optionsFunc: OptionsFunc<T, BaseTree, GenericTree>
|
|
||||||
): Tree<T> {
|
|
||||||
return createLazyProxy(feature => {
|
|
||||||
const tree = optionsFunc.call(feature, feature);
|
|
||||||
tree.id = getUniqueID("tree-");
|
|
||||||
tree.type = TreeType;
|
|
||||||
tree[Component] = TreeComponent as GenericComponent;
|
|
||||||
|
|
||||||
tree.isResetting = ref(false);
|
|
||||||
tree.resettingNode = shallowRef(null);
|
|
||||||
|
|
||||||
tree.reset = function (node) {
|
|
||||||
const genericTree = tree as GenericTree;
|
|
||||||
genericTree.isResetting.value = true;
|
|
||||||
genericTree.resettingNode.value = node;
|
|
||||||
genericTree.resetPropagation?.(genericTree, node);
|
|
||||||
genericTree.onReset?.(node);
|
|
||||||
genericTree.isResetting.value = false;
|
|
||||||
genericTree.resettingNode.value = null;
|
|
||||||
};
|
|
||||||
tree.links = computed(() => {
|
|
||||||
const genericTree = tree as GenericTree;
|
|
||||||
return unref(genericTree.branches) ?? [];
|
|
||||||
});
|
|
||||||
|
|
||||||
processComputable(tree as T, "visibility");
|
|
||||||
setDefault(tree, "visibility", Visibility.Visible);
|
|
||||||
processComputable(tree as T, "nodes");
|
|
||||||
processComputable(tree as T, "leftSideNodes");
|
|
||||||
processComputable(tree as T, "rightSideNodes");
|
|
||||||
processComputable(tree as T, "branches");
|
|
||||||
|
|
||||||
tree[GatherProps] = function (this: GenericTree) {
|
|
||||||
const { nodes, leftSideNodes, rightSideNodes, branches } = this;
|
|
||||||
return { nodes, leftSideNodes, rightSideNodes, branches };
|
|
||||||
};
|
|
||||||
|
|
||||||
return tree as unknown as Tree<T>;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A function that is used to propagate resets through a tree. */
|
|
||||||
export type ResetPropagation = {
|
|
||||||
(tree: GenericTree, resettingNode: GenericTreeNode): void;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Propagate resets down the tree by resetting every node in a lower row. */
|
|
||||||
export const defaultResetPropagation = function (
|
|
||||||
tree: GenericTree,
|
|
||||||
resettingNode: GenericTreeNode
|
|
||||||
): void {
|
|
||||||
const nodes = unref(tree.nodes);
|
|
||||||
const row = nodes.findIndex(nodes => nodes.includes(resettingNode)) - 1;
|
|
||||||
for (let x = row; x >= 0; x--) {
|
|
||||||
nodes[x].forEach(node => node.reset?.reset());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Propagate resets down the tree by resetting every node in a lower row. */
|
|
||||||
export const invertedResetPropagation = function (
|
|
||||||
tree: GenericTree,
|
|
||||||
resettingNode: GenericTreeNode
|
|
||||||
): void {
|
|
||||||
const nodes = unref(tree.nodes);
|
|
||||||
const row = nodes.findIndex(nodes => nodes.includes(resettingNode)) + 1;
|
|
||||||
for (let x = row; x < nodes.length; x++) {
|
|
||||||
nodes[x].forEach(node => node.reset?.reset());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Propagate resets down the branches of the tree. */
|
|
||||||
export const branchedResetPropagation = function (
|
|
||||||
tree: GenericTree,
|
|
||||||
resettingNode: GenericTreeNode
|
|
||||||
): void {
|
|
||||||
const links = unref(tree.branches);
|
|
||||||
if (links == null) return;
|
|
||||||
const reset: GenericTreeNode[] = [];
|
|
||||||
let current = [resettingNode];
|
|
||||||
while (current.length != 0) {
|
|
||||||
const next: GenericTreeNode[] = [];
|
|
||||||
for (const node of current) {
|
|
||||||
for (const link of links.filter(link => link.startNode === node)) {
|
|
||||||
if ([...reset, ...current].includes(link.endNode)) continue
|
|
||||||
next.push(link.endNode);
|
|
||||||
link.endNode.reset?.reset();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reset.push(...current);
|
|
||||||
current = next;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility for creating a tooltip for a tree node that displays a resource-based unlock requirement, and after unlock shows the amount of another resource.
|
|
||||||
* It sounds oddly specific, but comes up a lot.
|
|
||||||
*/
|
|
||||||
export function createResourceTooltip(
|
|
||||||
resource: Resource,
|
|
||||||
requiredResource: Resource | null = null,
|
|
||||||
requirement: Computable<DecimalSource> = 0
|
|
||||||
): Ref<string> {
|
|
||||||
const req = convertComputable(requirement);
|
|
||||||
return computed(() => {
|
|
||||||
if (requiredResource == null || Decimal.gte(resource.value, unref(req))) {
|
|
||||||
return displayResource(resource) + " " + resource.displayName;
|
|
||||||
}
|
|
||||||
return `Reach ${
|
|
||||||
Decimal.eq(requiredResource.precision, 0)
|
|
||||||
? formatWhole(unref(req))
|
|
||||||
: format(unref(req), requiredResource.precision)
|
|
||||||
} ${requiredResource.displayName} to unlock (You have ${
|
|
||||||
Decimal.eq(requiredResource.precision, 0)
|
|
||||||
? formatWhole(requiredResource.value)
|
|
||||||
: format(requiredResource.value, requiredResource.precision)
|
|
||||||
})`;
|
|
||||||
});
|
|
||||||
}
|
|
284
src/features/trees/tree.tsx
Normal file
284
src/features/trees/tree.tsx
Normal file
|
@ -0,0 +1,284 @@
|
||||||
|
import { Link } from "features/links/links";
|
||||||
|
import type { Reset } from "features/reset";
|
||||||
|
import type { Resource } from "features/resources/resource";
|
||||||
|
import { displayResource } from "features/resources/resource";
|
||||||
|
import Tree from "features/trees/Tree.vue";
|
||||||
|
import TreeNode from "features/trees/TreeNode.vue";
|
||||||
|
import { noPersist } from "game/persistence";
|
||||||
|
import type { DecimalSource } from "util/bignum";
|
||||||
|
import Decimal, { format, formatWhole } from "util/bignum";
|
||||||
|
import { processGetter } from "util/computed";
|
||||||
|
import { createLazyProxy } from "util/proxies";
|
||||||
|
import { Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
|
||||||
|
import type { MaybeRef, MaybeRefOrGetter, Ref } from "vue";
|
||||||
|
import { computed, ref, shallowRef, unref } from "vue";
|
||||||
|
|
||||||
|
/** A symbol used to identify {@link TreeNode} features. */
|
||||||
|
export const TreeNodeType = Symbol("TreeNode");
|
||||||
|
/** A symbol used to identify {@link Tree} features. */
|
||||||
|
export const TreeType = Symbol("Tree");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object that configures a {@link TreeNode}.
|
||||||
|
*/
|
||||||
|
export interface TreeNodeOptions extends VueFeatureOptions {
|
||||||
|
/** Whether or not this tree node can be clicked. */
|
||||||
|
canClick?: MaybeRefOrGetter<boolean>;
|
||||||
|
/** The background color for this node. */
|
||||||
|
color?: MaybeRefOrGetter<string>;
|
||||||
|
/** The label to display on this tree node. */
|
||||||
|
display?: MaybeRefOrGetter<Renderable>;
|
||||||
|
/** The color of the glow effect shown to notify the user there's something to do with this node. */
|
||||||
|
glowColor?: MaybeRefOrGetter<string>;
|
||||||
|
/** A reset object attached to this node, used for propagating resets through the tree. */
|
||||||
|
reset?: Reset;
|
||||||
|
/** A function that is called when the tree node is clicked. */
|
||||||
|
onClick?: (e?: MouseEvent | TouchEvent) => void;
|
||||||
|
/** A function that is called when the tree node is held down. */
|
||||||
|
onHold?: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The properties that are added onto a processed {@link TreeNodeOptions} to create an {@link TreeNode}.
|
||||||
|
*/
|
||||||
|
export interface TreeNode extends VueFeature {
|
||||||
|
/** Whether or not this tree node can be clicked. */
|
||||||
|
canClick?: MaybeRef<boolean>;
|
||||||
|
/** The background color for this node. */
|
||||||
|
color?: MaybeRef<string>;
|
||||||
|
/** The label to display on this tree node. */
|
||||||
|
display?: MaybeRef<Renderable>;
|
||||||
|
/** The color of the glow effect shown to notify the user there's something to do with this node. */
|
||||||
|
glowColor?: MaybeRef<string>;
|
||||||
|
/** A reset object attached to this node, used for propagating resets through the tree. */
|
||||||
|
reset?: Reset;
|
||||||
|
/** A function that is called when the tree node is clicked. */
|
||||||
|
onClick?: (e?: MouseEvent | TouchEvent) => void;
|
||||||
|
/** A function that is called when the tree node is held down. */
|
||||||
|
onHold?: VoidFunction;
|
||||||
|
/** A symbol that helps identify features of the same type. */
|
||||||
|
type: typeof TreeNodeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily creates a tree node with the given options.
|
||||||
|
* @param optionsFunc Tree Node options.
|
||||||
|
*/
|
||||||
|
export function createTreeNode<T extends TreeNodeOptions>(optionsFunc?: () => T) {
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const options = optionsFunc?.() ?? ({} as T);
|
||||||
|
const { canClick, color, display, glowColor, onClick, onHold, ...props } = options;
|
||||||
|
|
||||||
|
const treeNode = {
|
||||||
|
type: TreeNodeType,
|
||||||
|
...(props as Omit<typeof props, keyof VueFeature | keyof TreeNodeOptions>),
|
||||||
|
...vueFeatureMixin("treeNode", options, () => (
|
||||||
|
<TreeNode
|
||||||
|
canClick={treeNode.canClick}
|
||||||
|
display={treeNode.display}
|
||||||
|
onClick={treeNode.onClick}
|
||||||
|
onHold={treeNode.onHold}
|
||||||
|
color={treeNode.color}
|
||||||
|
glowColor={treeNode.glowColor}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
canClick: processGetter(canClick) ?? true,
|
||||||
|
color: processGetter(color),
|
||||||
|
display: processGetter(display),
|
||||||
|
glowColor: processGetter(glowColor),
|
||||||
|
onClick:
|
||||||
|
onClick == null
|
||||||
|
? undefined
|
||||||
|
: function (e) {
|
||||||
|
if (unref(treeNode.canClick) !== false) {
|
||||||
|
onClick.call(treeNode, e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onHold:
|
||||||
|
onHold == null
|
||||||
|
? undefined
|
||||||
|
: function () {
|
||||||
|
if (unref(treeNode.canClick) !== false) {
|
||||||
|
onHold.call(treeNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} satisfies TreeNode;
|
||||||
|
|
||||||
|
return treeNode;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Represents a branch between two nodes in a tree. */
|
||||||
|
export interface TreeBranch extends Omit<Link, "startNode" | "endNode"> {
|
||||||
|
startNode: TreeNode;
|
||||||
|
endNode: TreeNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object that configures a {@link Tree}.
|
||||||
|
*/
|
||||||
|
export interface TreeOptions extends VueFeatureOptions {
|
||||||
|
/** The nodes within the tree, in a 2D array. */
|
||||||
|
nodes: MaybeRefOrGetter<TreeNode[][]>;
|
||||||
|
/** Nodes to show on the left side of the tree. */
|
||||||
|
leftSideNodes?: MaybeRefOrGetter<TreeNode[]>;
|
||||||
|
/** Nodes to show on the right side of the tree. */
|
||||||
|
rightSideNodes?: MaybeRefOrGetter<TreeNode[]>;
|
||||||
|
/** The branches between nodes within this tree. */
|
||||||
|
branches?: MaybeRefOrGetter<TreeBranch[]>;
|
||||||
|
/** How to propagate resets through the tree. */
|
||||||
|
resetPropagation?: ResetPropagation;
|
||||||
|
/** A function that is called when a node within the tree is reset. */
|
||||||
|
onReset?: (node: TreeNode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tree extends VueFeature {
|
||||||
|
/** The nodes within the tree, in a 2D array. */
|
||||||
|
nodes: MaybeRef<TreeNode[][]>;
|
||||||
|
/** Nodes to show on the left side of the tree. */
|
||||||
|
leftSideNodes?: MaybeRef<TreeNode[]>;
|
||||||
|
/** Nodes to show on the right side of the tree. */
|
||||||
|
rightSideNodes?: MaybeRef<TreeNode[]>;
|
||||||
|
/** The branches between nodes within this tree. */
|
||||||
|
branches?: MaybeRef<TreeBranch[]>;
|
||||||
|
/** How to propagate resets through the tree. */
|
||||||
|
resetPropagation?: ResetPropagation;
|
||||||
|
/** A function that is called when a node within the tree is reset. */
|
||||||
|
onReset?: (node: TreeNode) => void;
|
||||||
|
/** The link objects for each of the branches of the tree. */
|
||||||
|
links: MaybeRef<Link[]>;
|
||||||
|
/** Cause a reset on this node and propagate it through the tree according to {@link TreeOptions.resetPropagation}. */
|
||||||
|
reset: (node: TreeNode) => void;
|
||||||
|
/** A flag that is true while the reset is still propagating through the tree. */
|
||||||
|
isResetting: Ref<boolean>;
|
||||||
|
/** A reference to the node that caused the currently propagating reset. */
|
||||||
|
resettingNode: Ref<TreeNode | null>;
|
||||||
|
/** A symbol that helps identify features of the same type. */
|
||||||
|
type: typeof TreeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily creates a tree with the given options.
|
||||||
|
* @param optionsFunc Tree options.
|
||||||
|
*/
|
||||||
|
export function createTree<T extends TreeOptions>(optionsFunc: () => T) {
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const options = optionsFunc();
|
||||||
|
const {
|
||||||
|
branches: _branches,
|
||||||
|
nodes,
|
||||||
|
leftSideNodes,
|
||||||
|
rightSideNodes,
|
||||||
|
resetPropagation,
|
||||||
|
onReset,
|
||||||
|
style: _style,
|
||||||
|
...props
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const style = processGetter(_style);
|
||||||
|
options.style = () => ({ position: "static", ...(unref(style) ?? {}) });
|
||||||
|
|
||||||
|
const branches = _branches == null ? undefined : processGetter(_branches);
|
||||||
|
|
||||||
|
const tree = {
|
||||||
|
type: TreeType,
|
||||||
|
...(props as Omit<typeof props, keyof VueFeature | keyof TreeOptions>),
|
||||||
|
...vueFeatureMixin("tree", options, () => (
|
||||||
|
<Tree
|
||||||
|
nodes={tree.nodes}
|
||||||
|
leftSideNodes={tree.leftSideNodes}
|
||||||
|
rightSideNodes={tree.rightSideNodes}
|
||||||
|
branches={tree.branches}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
branches,
|
||||||
|
isResetting: ref(false),
|
||||||
|
resettingNode: shallowRef<TreeNode | null>(null),
|
||||||
|
nodes: processGetter(nodes),
|
||||||
|
leftSideNodes: processGetter(leftSideNodes),
|
||||||
|
rightSideNodes: processGetter(rightSideNodes),
|
||||||
|
links: branches == null ? [] : noPersist(branches),
|
||||||
|
resetPropagation,
|
||||||
|
onReset,
|
||||||
|
reset: function (node: TreeNode) {
|
||||||
|
tree.isResetting.value = true;
|
||||||
|
tree.resettingNode.value = node;
|
||||||
|
tree.resetPropagation?.(tree, node);
|
||||||
|
tree.onReset?.(node);
|
||||||
|
tree.isResetting.value = false;
|
||||||
|
tree.resettingNode.value = null;
|
||||||
|
}
|
||||||
|
} satisfies Tree;
|
||||||
|
|
||||||
|
return tree;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A function that is used to propagate resets through a tree. */
|
||||||
|
export type ResetPropagation = {
|
||||||
|
(tree: Tree, resettingNode: TreeNode): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Propagate resets down the tree by resetting every node in a lower row. */
|
||||||
|
export const defaultResetPropagation = function (tree: Tree, resettingNode: TreeNode): void {
|
||||||
|
const nodes = unref(tree.nodes);
|
||||||
|
const row = nodes.findIndex(nodes => nodes.includes(resettingNode)) - 1;
|
||||||
|
for (let x = row; x >= 0; x--) {
|
||||||
|
nodes[x].forEach(node => node.reset?.reset());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Propagate resets down the tree by resetting every node in a lower row. */
|
||||||
|
export const invertedResetPropagation = function (tree: Tree, resettingNode: TreeNode): void {
|
||||||
|
const nodes = unref(tree.nodes);
|
||||||
|
const row = nodes.findIndex(nodes => nodes.includes(resettingNode)) + 1;
|
||||||
|
for (let x = row; x < nodes.length; x++) {
|
||||||
|
nodes[x].forEach(node => node.reset?.reset());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Propagate resets down the branches of the tree. */
|
||||||
|
export const branchedResetPropagation = function (tree: Tree, resettingNode: TreeNode): void {
|
||||||
|
const links = unref(tree.branches);
|
||||||
|
if (links == null) return;
|
||||||
|
const reset: TreeNode[] = [];
|
||||||
|
let current = [resettingNode];
|
||||||
|
while (current.length !== 0) {
|
||||||
|
const next: TreeNode[] = [];
|
||||||
|
for (const node of current) {
|
||||||
|
for (const link of links.filter(link => link.startNode === node)) {
|
||||||
|
if ([...reset, ...current].includes(link.endNode)) continue;
|
||||||
|
next.push(link.endNode);
|
||||||
|
link.endNode.reset?.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reset.push(...current);
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility for creating a tooltip for a tree node that displays a resource-based unlock requirement, and after unlock shows the amount of another resource.
|
||||||
|
* It sounds oddly specific, but comes up a lot.
|
||||||
|
*/
|
||||||
|
export function createResourceTooltip(
|
||||||
|
resource: Resource,
|
||||||
|
requiredResource: Resource | null = null,
|
||||||
|
requirement: MaybeRefOrGetter<DecimalSource> = 0
|
||||||
|
): Ref<string> {
|
||||||
|
const req = processGetter(requirement);
|
||||||
|
return computed(() => {
|
||||||
|
if (requiredResource == null || Decimal.gte(resource.value, unref(req))) {
|
||||||
|
return displayResource(resource) + " " + resource.displayName;
|
||||||
|
}
|
||||||
|
return `Reach ${
|
||||||
|
Decimal.eq(requiredResource.precision, 0)
|
||||||
|
? formatWhole(unref(req))
|
||||||
|
: format(unref(req), requiredResource.precision)
|
||||||
|
} ${requiredResource.displayName} to unlock (You have ${
|
||||||
|
Decimal.eq(requiredResource.precision, 0)
|
||||||
|
? formatWhole(requiredResource.value)
|
||||||
|
: format(requiredResource.value, requiredResource.precision)
|
||||||
|
})`;
|
||||||
|
});
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue