Compare commits
4 commits
main
...
fix/lazy-p
Author | SHA1 | Date | |
---|---|---|---|
2f8c37d730 | |||
a8c550c551 | |||
cf069cee61 | |||
c39982b1bc |
147 changed files with 16494 additions and 12902 deletions
|
@ -1 +0,0 @@
|
|||
.eslintrc.cjs
|
|
@ -5,22 +5,15 @@ module.exports = {
|
|||
env: {
|
||||
node: true
|
||||
},
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ["@typescript-eslint"],
|
||||
overrides: [
|
||||
{
|
||||
files: ['*.ts', '*.tsx'],
|
||||
extends: [
|
||||
"plugin:vue/vue3-essential",
|
||||
"@vue/eslint-config-typescript/recommended",
|
||||
"@vue/eslint-config-prettier"
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
project: "./tsconfig.json"
|
||||
},
|
||||
}
|
||||
extends: [
|
||||
"plugin:vue/vue3-essential",
|
||||
"@vue/eslint-config-typescript/recommended",
|
||||
"@vue/eslint-config-prettier"
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
project: "tsconfig.json"
|
||||
},
|
||||
ignorePatterns: ["src/lib"],
|
||||
rules: {
|
||||
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||
|
@ -34,13 +27,6 @@ module.exports = {
|
|||
allowNullableObject: true,
|
||||
allowNullableBoolean: true
|
||||
}
|
||||
],
|
||||
"eqeqeq": [
|
||||
"error",
|
||||
"always",
|
||||
{
|
||||
"null": "never"
|
||||
}
|
||||
]
|
||||
},
|
||||
globals: {
|
|
@ -1,33 +0,0 @@
|
|||
name: Build and Deploy
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
if: github.repository != 'profectus-engine/Profectus' # Don't build placeholder mod on main repo
|
||||
runs-on: docker
|
||||
container:
|
||||
image: node:21-bullseye
|
||||
steps:
|
||||
- name: Setup RSync
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y rsync
|
||||
|
||||
- name: Checkout 🛎️
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install and Build 🔧 # This example project is built using npm and outputs the result to the 'build' folder. Replace with the commands required to build your project, or remove this step entirely if your site is pre-built.
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Deploy 🚀
|
||||
uses: https://github.com/JamesIves/github-pages-deploy-action@v4.2.5
|
||||
with:
|
||||
branch: pages # The branch the action should deploy to.
|
||||
folder: dist # The folder the action should deploy.
|
|
@ -1,20 +0,0 @@
|
|||
name: Run Tests
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: node:21-bullseye
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
- run: npm ci
|
||||
- run: npm run build --if-present
|
||||
- run: npm test
|
||||
- run: npm run lint
|
1
.github/workflows/deploy.yml
vendored
1
.github/workflows/deploy.yml
vendored
|
@ -3,7 +3,6 @@ on:
|
|||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
if: github.repository != 'profectus-engine/Profectus' # Don't build placeholder mod on main repo
|
||||
|
|
9
.github/workflows/test.yml
vendored
9
.github/workflows/test.yml
vendored
|
@ -1,22 +1,21 @@
|
|||
name: Run Tests
|
||||
name: Build and Deploy
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
jobs:
|
||||
test:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
- name: Use Node.js 21.x
|
||||
- name: Use Node.js 16.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 21.x
|
||||
node-version: 16.x
|
||||
- run: npm ci
|
||||
- run: npm run build --if-present
|
||||
- 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",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"git.ignoreLimitWarning": true,
|
||||
|
|
100
CHANGELOG.md
100
CHANGELOG.md
|
@ -6,106 +6,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.7.0] - 2024-12-31
|
||||
### Additions
|
||||
- Added modal to take a mental health break (can be disabled via projInfo.json)
|
||||
- Added `ConversionType` symbol
|
||||
- Added `isType` function that uses a type symbol to check
|
||||
- Added `MaybeGetter` utility type for something that may be a getter function or a static value (but not a ref)
|
||||
|
||||
### Changes
|
||||
- **BREAKING** Replaced Board feature with generic Board system that works with SVG and DOM elements
|
||||
- **BREAKING** Rewrote how features are written, simplifying them greatly
|
||||
- **BREAKING** Replaced decorators with mixins and wrappers
|
||||
- **BREAKING** Moved modals to `src/components/modals`
|
||||
- **BREAKING** Updated a very large amount of dependencies, making any necessary adjustments
|
||||
- **BREAKING** Removed Grid component
|
||||
- **BREAKING** `dontMerge` is now a property on rows and columns rather than an undocumented css class you'd have to include on every feature within the row or column
|
||||
- **BREAKING** Moved all features that use the clickable component into the clickable folder
|
||||
- **BREAKING** Removed small property from clickable, since its a single css rule (min-height: unset)
|
||||
- **BREAKING** Removed `setDefault`, just use `??=`
|
||||
- **BREAKING** Made Achievement.vue use a Renderable for the display. The object of components can still be passed to createAchievement
|
||||
- **BREAKING** Made Challenge.vue use a Renderable for the display. The object of components can still be passed to createChallenge
|
||||
- Upgrades now use the clickable component
|
||||
|
||||
### Fixes
|
||||
- Hotkey descriptions were not being wrapped in `unref`
|
||||
- Links wouldn't check if the end node existed when determining valid links
|
||||
- `forceHideGoBack` was not being respected
|
||||
- Saves manager not being imported in addiction warning component
|
||||
|
||||
Contributors: thepaperpilot
|
||||
|
||||
## [0.6.2] - 2024-04-01
|
||||
### Added
|
||||
- Export save button in error boundaries
|
||||
- isRendered utility function
|
||||
- Automatic galaxy.click cloud saves support
|
||||
- Support for null and undefined in persistent refs
|
||||
### Changes
|
||||
- round, floor, ceil, trunc, and add now invert as no-ops
|
||||
- "The Paper Pilot Community" renamed to "Profectus & Friends"
|
||||
- Updated CI etc. to work with Forgejo
|
||||
- Improved modifier typing
|
||||
- Rename `printFormula` to `Formula.stringify`
|
||||
### Fixed
|
||||
- Hotkeys not working correctly with most combinations of modifiers
|
||||
- Reset button using `currentAt` when not gaining
|
||||
- Formulas not using modifiers that are disabled initially
|
||||
- branchedResetPropagation logic being incorrect
|
||||
- Fixed default elementsd in the main layer not updating Context when being added or removed
|
||||
- Board links props not working in camelCase
|
||||
- Board links absorbing pointer events
|
||||
- Thrown errors not appearing in console
|
||||
- Disabled elements would eat mouse events
|
||||
- Fixed cost requirement without formula counting as being able to afford infinite purchases rather than just one
|
||||
- Pinnable tooltips causing innocuous console error
|
||||
- Bars with direction as "Left" wouldn't appear correctly
|
||||
### Documentation
|
||||
- Clarified expected progress values for board nodes
|
||||
- Added CONTRIBUTING.md and enforce eslint on all PRs
|
||||
### Tests
|
||||
- Update formula test cases
|
||||
- Tree reset propagation
|
||||
|
||||
Contributors: thepaperpilot, escapee, nif
|
||||
|
||||
## [0.6.1] - 2023-05-17
|
||||
### Added
|
||||
- Error boundaries around each layer, and errors now display on the page when in development
|
||||
- Utility for creating requirement based on whether a conversion has met a requirement
|
||||
### Changed
|
||||
- **BREAKING** Formulas/requirements refactor
|
||||
- spendResources renamed to cumulativeCost
|
||||
- summedPurchases renamed to directSum
|
||||
- calculateMaxAffordable now takes optional 'maxBulkAmount' parameter
|
||||
- cost requirements now pass cumulativeCost, maxBulkAmount, and directSum to calculateMaxAffordable
|
||||
- Non-integrable and non-invertible formulas will now work in more situations
|
||||
- Repeatable.maximize is removed
|
||||
- Challenge.maximize is removed
|
||||
- Formulas have better typing information now
|
||||
- Integrate functions now log errors if the variable input is not integrable
|
||||
- Cyclical proxies now throw errors
|
||||
- createFormulaPreview is now a JSX function
|
||||
- Tree nodes are not automatically capitalized anymore
|
||||
- upgrade.canPurchase now returns false if the upgrade is already bought
|
||||
- TPS display is simplified and more performant now
|
||||
### Fixed
|
||||
- Actions could not be constructed
|
||||
- Progress bar on actions was misaligned
|
||||
- Many different issues the Board features (and many changes/improvements)
|
||||
- Calculating max affordable could sometimes infinite loop
|
||||
- Non-integrable formulas could cause errors in cost requirements
|
||||
- estimateTime would not show "never" when production is 0
|
||||
- isInvertible and isIntegrable now properly handle nested formulas
|
||||
- Repeatables' amount display would show the literal text "joinJSX"
|
||||
- Repeatables would not buy max properly
|
||||
- Reset buttons were showing wrong "currentAt" vs "nextAt"
|
||||
- Step-wise formulas not updating their value correctly
|
||||
- Bonus amount decorator now checks for `amount` property in the post construct callback
|
||||
### Documentation
|
||||
- Various typos fixed and a few sections made more thorough
|
||||
|
||||
## [0.6.0] - 2023-04-20
|
||||
### Added
|
||||
- **BREAKING** New requirements system
|
||||
|
|
|
@ -1,31 +0,0 @@
|
|||
# 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).
|
|
@ -26,6 +26,11 @@ npm run build
|
|||
npm run preview
|
||||
```
|
||||
|
||||
### Lints and fixes files
|
||||
```
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### Runs the tests using vite-jest
|
||||
```
|
||||
npm run test
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="alternate icon" type="image/png" sizes="48x48" href="/favicon.ico">
|
||||
<link rel="mask-icon" href="/favicon.svg" color="#2E3440">
|
||||
<meta name="theme-color" content="#2E3440">
|
||||
|
||||
<title>Profectus</title>
|
||||
|
|
6896
package-lock.json
generated
6896
package-lock.json
generated
File diff suppressed because it is too large
Load diff
74
package.json
74
package.json
|
@ -1,8 +1,7 @@
|
|||
{
|
||||
"name": "profectus",
|
||||
"version": "0.7.0",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"dev": "vite",
|
||||
|
@ -10,56 +9,49 @@
|
|||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"testw": "vitest",
|
||||
"serve": "vite preview --host",
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"lint:fix": "eslint --fix --max-warnings 0 src"
|
||||
"serve": "vite preview --host"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/material-icons": "^5.1.0",
|
||||
"@fontsource/roboto-mono": "^5.1.0",
|
||||
"@pixi/app": "^6.5.10",
|
||||
"@pixi/constants": "~6.5.10",
|
||||
"@pixi/core": "^6.5.10",
|
||||
"@pixi/display": "~6.5.10",
|
||||
"@pixi/math": "~6.5.10",
|
||||
"@fontsource/material-icons": "^4.5.4",
|
||||
"@fontsource/roboto-mono": "^4.5.8",
|
||||
"@pixi/app": "~6.3.2",
|
||||
"@pixi/constants": "~6.3.2",
|
||||
"@pixi/core": "~6.3.2",
|
||||
"@pixi/display": "~6.3.2",
|
||||
"@pixi/math": "~6.3.2",
|
||||
"@pixi/particle-emitter": "^5.0.7",
|
||||
"@pixi/sprite": "~6.5.10",
|
||||
"@pixi/ticker": "~6.5.10",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"@vitejs/plugin-vue-jsx": "^4.0.1",
|
||||
"@pixi/sprite": "~6.3.2",
|
||||
"@pixi/ticker": "~6.3.2",
|
||||
"@vitejs/plugin-vue": "^2.3.3",
|
||||
"@vitejs/plugin-vue-jsx": "^1.3.10",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"lz-string": "^1.5.0",
|
||||
"nanoevents": "^9.0.0",
|
||||
"unofficial-galaxy-sdk": "git+https://code.incremental.social/thepaperpilot/unofficial-galaxy-sdk.git#1.0.1",
|
||||
"vite": "^5.1.8",
|
||||
"vite-plugin-pwa": "^0.20.5",
|
||||
"vite-tsconfig-paths": "^4.3.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-next-select": "^2.10.5",
|
||||
"lz-string": "^1.4.4",
|
||||
"nanoevents": "^6.0.2",
|
||||
"vite": "^2.9.12",
|
||||
"vite-plugin-pwa": "^0.12.0",
|
||||
"vite-tsconfig-paths": "^3.5.0",
|
||||
"vue": "^3.2.26",
|
||||
"vue-next-select": "^2.10.2",
|
||||
"vue-panzoom": "https://github.com/thepaperpilot/vue-panzoom.git",
|
||||
"vue-textarea-autosize": "^1.1.1",
|
||||
"vue-toastification": "^2.0.0-rc.5",
|
||||
"vue-toastification": "^2.0.0-rc.1",
|
||||
"vue-transition-expand": "^0.1.0",
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ivanv/vue-collapse-transition": "^1.0.2",
|
||||
"@rushstack/eslint-patch": "^1.7.2",
|
||||
"@types/lz-string": "^1.5.0",
|
||||
"@types/node": "^22.7.6",
|
||||
"@typescript-eslint/parser": "^7.2.0",
|
||||
"@vue/eslint-config-prettier": "^9.0.0",
|
||||
"@vue/eslint-config-typescript": "^13.0.0",
|
||||
"eslint": "^8.57.0",
|
||||
"jsdom": "^24.0.0",
|
||||
"prettier": "^3.2.5",
|
||||
"typescript": "~5.5.4",
|
||||
"vitest": "^1.4.0",
|
||||
"vue-tsc": "^2.0.6"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-linux-x64-gnu": "^4.24.0"
|
||||
"@rushstack/eslint-patch": "^1.1.0",
|
||||
"@types/lz-string": "^1.3.34",
|
||||
"@vue/eslint-config-prettier": "^7.0.0",
|
||||
"@vue/eslint-config-typescript": "^10.0.0",
|
||||
"eslint": "^8.6.0",
|
||||
"jsdom": "^20.0.0",
|
||||
"prettier": "^2.5.1",
|
||||
"typescript": "^5.0.2",
|
||||
"vitest": "^0.29.3",
|
||||
"vue-tsc": "^0.38.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "21.x"
|
||||
"node": "16.x"
|
||||
}
|
||||
}
|
||||
|
|
54
src/App.vue
54
src/App.vue
|
@ -1,47 +1,38 @@
|
|||
<template>
|
||||
<div v-if="appErrors.length > 0" class="error-container" :style="theme">
|
||||
<Error :errors="appErrors" />
|
||||
<div id="modal-root" :style="theme" />
|
||||
<div class="app" :style="theme" :class="{ useHeader }">
|
||||
<Nav v-if="useHeader" />
|
||||
<Game />
|
||||
<TPS v-if="unref(showTPS)" />
|
||||
<GameOverScreen />
|
||||
<NaNScreen />
|
||||
<component :is="gameComponent" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<div id="modal-root" :style="theme" />
|
||||
<div class="app" :style="theme" :class="{ useHeader }">
|
||||
<Nav v-if="useHeader" />
|
||||
<Game />
|
||||
<TPS v-if="unref(showTPS)" />
|
||||
<AddictionWarning />
|
||||
<GameOverScreen />
|
||||
<NaNScreen />
|
||||
<CloudSaveResolver />
|
||||
<GameComponent />
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx">
|
||||
import "@fontsource/roboto-mono";
|
||||
import Error from "components/Error.vue";
|
||||
import AddictionWarning from "components/modals/AddictionWarning.vue";
|
||||
import CloudSaveResolver from "components/modals/CloudSaveResolver.vue";
|
||||
import GameOverScreen from "components/modals/GameOverScreen.vue";
|
||||
import NaNScreen from "components/modals/NaNScreen.vue";
|
||||
import state from "game/state";
|
||||
import { render } from "util/vue";
|
||||
import type { CSSProperties } from "vue";
|
||||
import { jsx } from "features/feature";
|
||||
import { coerceComponent, render } from "util/vue";
|
||||
import { computed, toRef, unref } from "vue";
|
||||
import Game from "./components/Game.vue";
|
||||
import GameOverScreen from "./components/GameOverScreen.vue";
|
||||
import NaNScreen from "./components/NaNScreen.vue";
|
||||
import Nav from "./components/Nav.vue";
|
||||
import TPS from "./components/TPS.vue";
|
||||
import projInfo from "./data/projInfo.json";
|
||||
import themes from "./data/themes";
|
||||
import settings, { gameComponents } from "./game/settings";
|
||||
import "./main.css";
|
||||
import "@fontsource/roboto-mono";
|
||||
import type { CSSProperties } from "vue";
|
||||
|
||||
const useHeader = projInfo.useHeader;
|
||||
const theme = computed(() => themes[settings.theme].variables as CSSProperties);
|
||||
const showTPS = toRef(settings, "showTPS");
|
||||
const appErrors = toRef(state, "errors");
|
||||
|
||||
const GameComponent = () => gameComponents.map(c => render(c));
|
||||
const gameComponent = computed(() => {
|
||||
return coerceComponent(jsx(() => (<>{gameComponents.map(render)}</>)));
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
@ -60,15 +51,4 @@ const GameComponent = () => gameComponents.map(c => render(c));
|
|||
height: 100%;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.error-container {
|
||||
background: var(--background);
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.error-container > .error {
|
||||
position: static;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,135 +0,0 @@
|
|||
<template>
|
||||
<div class="error">
|
||||
<h1 class="error-title">{{ firstError.name }}: {{ firstError.message }}</h1>
|
||||
<div class="error-details" style="margin-top: -10px">
|
||||
<div v-if="firstError.cause">
|
||||
<div v-for="row in causes[0]" :key="row">{{ row }}</div>
|
||||
</div>
|
||||
<div v-if="firstError.stack" :style="firstError.cause ? 'margin-top: 10px' : ''">
|
||||
<div v-for="row in stacks[0]" :key="row">{{ row }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="instructions">
|
||||
Check the console for more details, and consider sharing it with the developers on
|
||||
<a :href="projInfo.discordLink || 'https://discord.gg/yJ4fjnjU54'" class="discord-link"
|
||||
>discord</a
|
||||
>!
|
||||
<FeedbackButton @click="exportSave" class="button" style="display: inline-flex"
|
||||
><span class="material-icons" style="font-size: 16px">content_paste</span
|
||||
><span style="margin-left: 8px; font-size: medium">Copy Save</span></FeedbackButton
|
||||
><br />
|
||||
<div v-if="errors.length > 1" style="margin-top: 20px"><h3>Other errors</h3></div>
|
||||
<div v-for="(error, i) in errors.slice(1)" :key="i" style="margin-top: 20px">
|
||||
<details class="error-details">
|
||||
<summary>{{ error.name }}: {{ error.message }}</summary>
|
||||
<div v-if="error.cause" style="margin-top: 10px">
|
||||
<div v-for="row in causes[i + 1]" :key="row">{{ row }}</div>
|
||||
</div>
|
||||
<div v-if="error.stack" style="margin-top: 10px">
|
||||
<div v-for="row in stacks[i + 1]" :key="row">{{ row }}</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import projInfo from "data/projInfo.json";
|
||||
import player, { stringifySave } from "game/player";
|
||||
import LZString from "lz-string";
|
||||
import { computed, onMounted } from "vue";
|
||||
import FeedbackButton from "./fields/FeedbackButton.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
errors: Error[];
|
||||
}>();
|
||||
|
||||
const firstError = computed(() => props.errors[0]);
|
||||
const stacks = computed(() =>
|
||||
props.errors.map(error => (error.stack == null ? [] : error.stack.split("\n")))
|
||||
);
|
||||
const causes = computed(() =>
|
||||
props.errors.map(error =>
|
||||
error.cause == null
|
||||
? []
|
||||
: (typeof error.cause === "string" ? error.cause : JSON.stringify(error.cause)).split(
|
||||
"\n"
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
function exportSave() {
|
||||
let saveToExport = stringifySave(player);
|
||||
switch (projInfo.exportEncoding) {
|
||||
default:
|
||||
console.warn(`Unknown save encoding: ${projInfo.exportEncoding}. Defaulting to lz`);
|
||||
case "lz":
|
||||
saveToExport = LZString.compressToUTF16(saveToExport);
|
||||
break;
|
||||
case "base64":
|
||||
saveToExport = btoa(unescape(encodeURIComponent(saveToExport)));
|
||||
break;
|
||||
case "plain":
|
||||
break;
|
||||
}
|
||||
console.log(saveToExport);
|
||||
|
||||
// Put on clipboard. Using the clipboard API asks for permissions and stuff
|
||||
const el = document.createElement("textarea");
|
||||
el.value = saveToExport;
|
||||
document.body.appendChild(el);
|
||||
el.select();
|
||||
el.setSelectionRange(0, 99999);
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(el);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
player.autosave = false;
|
||||
player.devSpeed = 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.error {
|
||||
border: solid 10px var(--danger);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: left;
|
||||
min-height: calc(100% - 20px);
|
||||
text-align: left;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.error-title {
|
||||
background: var(--danger);
|
||||
color: var(--feature-foreground);
|
||||
display: block;
|
||||
margin: -10px 0 10px 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.error-details {
|
||||
white-space: nowrap;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
background-color: var(--raised-background);
|
||||
}
|
||||
|
||||
.instructions {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.discord-link {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
|
@ -9,9 +9,9 @@
|
|||
>
|
||||
<Nav v-if="index === 0 && !useHeader" />
|
||||
<div class="inner-tab">
|
||||
<LayerVue
|
||||
<Layer
|
||||
v-if="layerKeys.includes(tab)"
|
||||
v-bind="gatherLayerProps(layers[tab])"
|
||||
v-bind="gatherLayerProps(layers[tab]!)"
|
||||
:index="index"
|
||||
@set-minimized="(value: boolean) => (layers[tab]!.minimized.value = value)"
|
||||
/>
|
||||
|
@ -23,37 +23,20 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import projInfo from "data/projInfo.json";
|
||||
import { type Layer, layers } from "game/layers";
|
||||
import type { GenericLayer } from "game/layers";
|
||||
import { layers } from "game/layers";
|
||||
import player from "game/player";
|
||||
import { computed, toRef, unref } from "vue";
|
||||
import LayerVue from "./Layer.vue";
|
||||
import Layer from "./Layer.vue";
|
||||
import Nav from "./Nav.vue";
|
||||
|
||||
const tabs = toRef(player, "tabs");
|
||||
const layerKeys = computed(() => Object.keys(layers));
|
||||
const useHeader = projInfo.useHeader;
|
||||
|
||||
function gatherLayerProps(layer: Layer) {
|
||||
const {
|
||||
display,
|
||||
name,
|
||||
color,
|
||||
minimizable,
|
||||
minimizedDisplay,
|
||||
minimized,
|
||||
nodes,
|
||||
forceHideGoBack
|
||||
} = layer;
|
||||
return {
|
||||
display,
|
||||
name,
|
||||
color,
|
||||
minimizable,
|
||||
minimizedDisplay,
|
||||
minimized,
|
||||
nodes,
|
||||
forceHideGoBack
|
||||
};
|
||||
function gatherLayerProps(layer: GenericLayer) {
|
||||
const { display, minimized, name, color, minimizable, nodes, minimizedDisplay } = layer;
|
||||
return { display, minimized, name, color, minimizable, nodes, minimizedDisplay };
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -18,18 +18,12 @@
|
|||
updates!
|
||||
</div>
|
||||
<br />
|
||||
<div v-if="discordLink && discordName">
|
||||
<div>
|
||||
<a :href="discordLink" class="game-over-modal-discord-link">
|
||||
<span class="material-icons game-over-modal-discord">discord</span>
|
||||
{{ discordName }}
|
||||
</a>
|
||||
</div>
|
||||
<div v-else>
|
||||
<a href="https://discord.gg/yJ4fjnjU54" class="game-over-modal-discord-link">
|
||||
<span class="material-icons game-over-modal-discord">discord</span>
|
||||
Profectus & Friends
|
||||
</a>
|
||||
</div>
|
||||
<Toggle title="Autosave" v-model="autosave" />
|
||||
</div>
|
||||
</template>
|
||||
|
@ -43,14 +37,14 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Modal from "components/Modal.vue";
|
||||
import { hasWon } from "data/projEntry";
|
||||
import projInfo from "data/projInfo.json";
|
||||
import player from "game/player";
|
||||
import { formatTime } from "util/bignum";
|
||||
import { loadSave, newSave } from "util/save";
|
||||
import { computed, toRef } from "vue";
|
||||
import Toggle from "../fields/Toggle.vue";
|
||||
import Modal from "./Modal.vue";
|
||||
import Toggle from "./fields/Toggle.vue";
|
||||
|
||||
const { title, logo, discordName, discordLink, versionNumber, versionTitle } = projInfo;
|
||||
|
|
@ -12,11 +12,11 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Hotkey } from "features/hotkey";
|
||||
import { GenericHotkey } from "features/hotkey";
|
||||
import { watchEffect } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
hotkey: Hotkey;
|
||||
hotkey: GenericHotkey;
|
||||
}>();
|
||||
|
||||
let key = "";
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
Made in Profectus, by thepaperpilot with inspiration from Acameada and Jacorb
|
||||
</div>
|
||||
<br />
|
||||
<div class="link" @click="emits('openChangelog')">Changelog</div>
|
||||
<div class="link" @click="openChangelog">Changelog</div>
|
||||
<br />
|
||||
<div>
|
||||
<a
|
||||
|
@ -38,7 +38,7 @@
|
|||
target="_blank"
|
||||
>
|
||||
<span class="material-icons info-modal-discord">discord</span>
|
||||
Profectus & Friends
|
||||
The Paper Pilot Community
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
|
@ -53,38 +53,45 @@
|
|||
</div>
|
||||
<br />
|
||||
<div>Time Played: {{ timePlayed }}</div>
|
||||
<InfoComponents />
|
||||
<component :is="infoComponent" />
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx">
|
||||
import Modal from "components/Modal.vue";
|
||||
import type Changelog from "data/Changelog.vue";
|
||||
import projInfo from "data/projInfo.json";
|
||||
import { jsx } from "features/feature";
|
||||
import player from "game/player";
|
||||
import { infoComponents } from "game/settings";
|
||||
import { formatTime } from "util/bignum";
|
||||
import { render } from "util/vue";
|
||||
import { computed, ref } from "vue";
|
||||
import Modal from "./Modal.vue";
|
||||
import { coerceComponent, render } from "util/vue";
|
||||
import { computed, ref, toRefs, unref } from "vue";
|
||||
|
||||
const { title, logo, author, discordName, discordLink, versionNumber, versionTitle } = projInfo;
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: "openChangelog"): void;
|
||||
}>();
|
||||
const _props = defineProps<{ changelog: typeof Changelog | null }>();
|
||||
const props = toRefs(_props);
|
||||
|
||||
const isOpen = ref(false);
|
||||
|
||||
const timePlayed = computed(() => formatTime(player.timePlayed));
|
||||
|
||||
const InfoComponents = () => infoComponents.map(f => render(f));
|
||||
const infoComponent = computed(() => {
|
||||
return coerceComponent(jsx(() => (<>{infoComponents.map(render)}</>)));
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
open() {
|
||||
isOpen.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
function openChangelog() {
|
||||
unref(props.changelog)?.open();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
|
@ -1,6 +1,5 @@
|
|||
<template>
|
||||
<ErrorVue v-if="errors.length > 0" :errors="errors" />
|
||||
<div class="layer-container" :style="{ '--layer-color': unref(color) }" v-bind="$attrs" v-else>
|
||||
<div class="layer-container" :style="{ '--layer-color': unref(color) }">
|
||||
<button v-if="showGoBack" class="goBack" @click="goBack">❌</button>
|
||||
|
||||
<button
|
||||
|
@ -8,12 +7,12 @@
|
|||
v-if="unref(minimized)"
|
||||
@click="$emit('setMinimized', false)"
|
||||
>
|
||||
<MinimizedComponent v-if="minimizedDisplay" />
|
||||
<component v-if="minimizedComponent" :is="minimizedComponent" />
|
||||
<div v-else>{{ unref(name) }}</div>
|
||||
</button>
|
||||
<div class="layer-tab" :class="{ showGoBack }" v-else>
|
||||
<Context @update-nodes="updateNodes">
|
||||
<Component />
|
||||
<component :is="component" />
|
||||
</Context>
|
||||
</div>
|
||||
|
||||
|
@ -23,49 +22,70 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import projInfo from "data/projInfo.json";
|
||||
import { type FeatureNode } from "game/layers";
|
||||
import type { CoercableComponent } from "features/feature";
|
||||
import type { FeatureNode } from "game/layers";
|
||||
import player from "game/player";
|
||||
import { MaybeGetter } from "util/computed";
|
||||
import { render, Renderable } from "util/vue";
|
||||
import { computed, MaybeRef, onErrorCaptured, Ref, ref, unref } from "vue";
|
||||
import { computeComponent, computeOptionalComponent, processedPropType, unwrapRef } from "util/vue";
|
||||
import type { PropType, Ref } from "vue";
|
||||
import { computed, defineComponent, toRefs, unref } from "vue";
|
||||
import Context from "./Context.vue";
|
||||
import ErrorVue from "./Error.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
display: MaybeGetter<Renderable>;
|
||||
minimizedDisplay?: MaybeGetter<Renderable>;
|
||||
minimized: Ref<boolean>;
|
||||
name?: MaybeRef<string>;
|
||||
color?: MaybeRef<string>;
|
||||
minimizable?: MaybeRef<boolean>;
|
||||
nodes: Ref<Record<string, FeatureNode | undefined>>;
|
||||
forceHideGoBack?: MaybeRef<boolean>;
|
||||
index: number;
|
||||
}>();
|
||||
export default defineComponent({
|
||||
components: { Context },
|
||||
props: {
|
||||
index: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
display: {
|
||||
type: processedPropType<CoercableComponent>(Object, String, Function),
|
||||
required: true
|
||||
},
|
||||
minimizedDisplay: processedPropType<CoercableComponent>(Object, String, Function),
|
||||
minimized: {
|
||||
type: Object as PropType<Ref<boolean>>,
|
||||
required: true
|
||||
},
|
||||
name: {
|
||||
type: processedPropType<string>(String),
|
||||
required: true
|
||||
},
|
||||
color: processedPropType<string>(String),
|
||||
minimizable: processedPropType<boolean>(Boolean),
|
||||
nodes: {
|
||||
type: Object as PropType<Ref<Record<string, FeatureNode | undefined>>>,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ["setMinimized"],
|
||||
setup(props) {
|
||||
const { display, index, minimized, minimizedDisplay } = toRefs(props);
|
||||
|
||||
const Component = () => render(props.display);
|
||||
const MinimizedComponent = () => props.minimizedDisplay == null ? undefined : render(props.minimizedDisplay);
|
||||
const showGoBack = computed(
|
||||
() => projInfo.allowGoBack && !unref(props.forceHideGoBack) && props.index > 0 && !unref(props.minimized)
|
||||
);
|
||||
const component = computeComponent(display);
|
||||
const minimizedComponent = computeOptionalComponent(minimizedDisplay);
|
||||
const showGoBack = computed(
|
||||
() => projInfo.allowGoBack && index.value > 0 && !unwrapRef(minimized)
|
||||
);
|
||||
|
||||
function goBack() {
|
||||
player.tabs.splice(unref(props.index), Infinity);
|
||||
}
|
||||
function goBack() {
|
||||
player.tabs.splice(unref(props.index), Infinity);
|
||||
}
|
||||
|
||||
function updateNodes(nodes: Record<string, FeatureNode | undefined>) {
|
||||
props.nodes.value = nodes;
|
||||
}
|
||||
function updateNodes(nodes: Record<string, FeatureNode | undefined>) {
|
||||
props.nodes.value = nodes;
|
||||
}
|
||||
|
||||
const errors = ref<Error[]>([]);
|
||||
onErrorCaptured((err, instance, info) => {
|
||||
console.warn(`Error caught in "${props.name}" layer`, err, instance, info);
|
||||
errors.value.push(
|
||||
err instanceof Error ? (err as Error) : new Error(JSON.stringify(err))
|
||||
);
|
||||
return false;
|
||||
return {
|
||||
component,
|
||||
minimizedComponent,
|
||||
showGoBack,
|
||||
updateNodes,
|
||||
unref,
|
||||
goBack
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
<template>
|
||||
<div v-if="unref(mark) === true" class="mark star"></div>
|
||||
<img v-else-if="unref(mark) !== false" class="mark" :src="unref(mark) as string" />
|
||||
<slot />
|
||||
<div v-if="mark">
|
||||
<div v-if="mark === true" class="mark star"></div>
|
||||
<img v-else class="mark" :src="mark" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MaybeRef, unref } from 'vue';
|
||||
|
||||
defineProps<{ mark: MaybeRef<boolean | string> }>();
|
||||
defineProps<{ mark?: boolean | string }>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
|
@ -4,7 +4,6 @@
|
|||
name="modal"
|
||||
@before-enter="isAnimating = true"
|
||||
@after-leave="isAnimating = false"
|
||||
appear
|
||||
>
|
||||
<div
|
||||
class="modal-mask"
|
||||
|
@ -13,28 +12,16 @@
|
|||
v-bind="$attrs"
|
||||
>
|
||||
<div class="modal-wrapper">
|
||||
<div class="modal-container" :width="width">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<!--
|
||||
@slot Modal Header
|
||||
@binding {boolean} shown Whether the modal is currently open or animating
|
||||
-->
|
||||
<slot name="header" :shown="isOpen" />
|
||||
<slot name="header" :shown="isOpen"> default header </slot>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<Context ref="contextRef">
|
||||
<!--
|
||||
@slot Modal Body
|
||||
@binding {boolean} shown Whether the modal is currently open or animating
|
||||
-->
|
||||
<slot name="body" :shown="isOpen" />
|
||||
<slot name="body" :shown="isOpen"> default body </slot>
|
||||
</Context>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<!--
|
||||
@slot Modal Footer
|
||||
@binding {boolean} shown Whether the modal is currently open or animating
|
||||
-->
|
||||
<slot name="footer" :shown="isOpen">
|
||||
<div class="modal-default-footer">
|
||||
<div class="modal-default-flex-grow"></div>
|
||||
|
@ -53,24 +40,20 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import type { FeatureNode } from "game/layers";
|
||||
import { computed, ref } from "vue";
|
||||
import Context from "../Context.vue";
|
||||
import { computed, ref, toRefs, unref } from "vue";
|
||||
import Context from "./Context.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
const _props = defineProps<{
|
||||
modelValue: boolean;
|
||||
preventClosing?: boolean;
|
||||
width?: string;
|
||||
}>();
|
||||
|
||||
const props = toRefs(_props);
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: boolean): void;
|
||||
}>();
|
||||
|
||||
const isOpen = computed(() => props.modelValue || isAnimating.value);
|
||||
const isOpen = computed(() => unref(props.modelValue) || isAnimating.value);
|
||||
function close() {
|
||||
if (props.preventClosing !== true) {
|
||||
emit("update:modelValue", false);
|
||||
}
|
||||
emit("update:modelValue", false);
|
||||
}
|
||||
|
||||
const isAnimating = ref(false);
|
|
@ -19,7 +19,7 @@
|
|||
class="nan-modal-discord-link"
|
||||
>
|
||||
<span class="material-icons nan-modal-discord">discord</span>
|
||||
{{ discordName || "Profectus & Friends" }}
|
||||
{{ discordName || "The Paper Pilot Community" }}
|
||||
</a>
|
||||
</div>
|
||||
<br />
|
||||
|
@ -46,6 +46,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Modal from "components/Modal.vue";
|
||||
import projInfo from "data/projInfo.json";
|
||||
import player from "game/player";
|
||||
import state from "game/state";
|
||||
|
@ -53,8 +54,7 @@ import type { DecimalSource } from "util/bignum";
|
|||
import Decimal, { format } from "util/bignum";
|
||||
import type { ComponentPublicInstance } from "vue";
|
||||
import { computed, ref, toRef, watch } from "vue";
|
||||
import Toggle from "../fields/Toggle.vue";
|
||||
import Modal from "./Modal.vue";
|
||||
import Toggle from "./fields/Toggle.vue";
|
||||
import SavesManager from "./SavesManager.vue";
|
||||
|
||||
const { discordName, discordLink } = projInfo;
|
|
@ -15,7 +15,9 @@
|
|||
<a :href="discordLink" target="_blank">{{ discordName }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://discord.gg/yJ4fjnjU54" target="_blank">Profectus & Friends</a>
|
||||
<a href="https://discord.gg/yJ4fjnjU54" target="_blank"
|
||||
>The Paper Pilot Community</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://discord.gg/F3xveHV" target="_blank">The Modding Tree</a>
|
||||
|
@ -36,7 +38,7 @@
|
|||
</div>
|
||||
<div @click="savesManager?.open()">
|
||||
<Tooltip display="Saves" :direction="Direction.Down" xoffset="-20px">
|
||||
<span class="material-icons" :class="{ needsSync }">library_books</span>
|
||||
<span class="material-icons">library_books</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div @click="options?.open()">
|
||||
|
@ -53,7 +55,7 @@
|
|||
</div>
|
||||
<div @click="savesManager?.open()">
|
||||
<Tooltip display="Saves" :direction="Direction.Right">
|
||||
<span class="material-icons" :class="{ needsSync }">library_books</span>
|
||||
<span class="material-icons">library_books</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div @click="options?.open()">
|
||||
|
@ -80,7 +82,9 @@
|
|||
<a :href="discordLink" target="_blank">{{ discordName }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://discord.gg/yJ4fjnjU54" target="_blank">Profectus & Friends</a>
|
||||
<a href="https://discord.gg/yJ4fjnjU54" target="_blank"
|
||||
>The Paper Pilot Community</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://discord.gg/F3xveHV" target="_blank">The Modding Tree</a>
|
||||
|
@ -88,7 +92,7 @@
|
|||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<Info ref="info" @open-changelog="changelog?.open()" />
|
||||
<Info ref="info" :changelog="changelog" />
|
||||
<SavesManager ref="savesManager" />
|
||||
<Options ref="options" />
|
||||
<Changelog ref="changelog" />
|
||||
|
@ -97,29 +101,26 @@
|
|||
<script setup lang="ts">
|
||||
import Changelog from "data/Changelog.vue";
|
||||
import projInfo from "data/projInfo.json";
|
||||
import settings from "game/settings";
|
||||
import Tooltip from "features/tooltips/Tooltip.vue";
|
||||
import { Direction } from "util/common";
|
||||
import { galaxy, syncedSaves } from "util/galaxy";
|
||||
import { computed, ref } from "vue";
|
||||
import Tooltip from "wrappers/tooltips/Tooltip.vue";
|
||||
import Info from "./modals/Info.vue";
|
||||
import Options from "./modals/Options.vue";
|
||||
import SavesManager from "./modals/SavesManager.vue";
|
||||
import type { ComponentPublicInstance } from "vue";
|
||||
import { ref } from "vue";
|
||||
import Info from "./Info.vue";
|
||||
import Options from "./Options.vue";
|
||||
import SavesManager from "./SavesManager.vue";
|
||||
|
||||
const info = ref<typeof Info | null>(null);
|
||||
const savesManager = ref<typeof SavesManager | null>(null);
|
||||
const options = ref<typeof Options | null>(null);
|
||||
const changelog = ref<typeof Changelog | null>(null);
|
||||
const info = ref<ComponentPublicInstance<typeof Info> | null>(null);
|
||||
const savesManager = ref<ComponentPublicInstance<typeof SavesManager> | null>(null);
|
||||
const options = ref<ComponentPublicInstance<typeof Options> | null>(null);
|
||||
// For some reason Info won't accept the changelog unless I do this:
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const changelog = ref<ComponentPublicInstance<any> | null>(null);
|
||||
|
||||
const { useHeader, banner, title, discordName, discordLink, versionNumber } = projInfo;
|
||||
|
||||
function openDiscord() {
|
||||
window.open(discordLink, "mywindow");
|
||||
}
|
||||
|
||||
const needsSync = computed(
|
||||
() => galaxy.value?.loggedIn === true && !syncedSaves.value.includes(settings.active)
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
@ -267,32 +268,4 @@ const needsSync = computed(
|
|||
color: var(--foreground);
|
||||
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>
|
||||
|
|
|
@ -4,9 +4,10 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { RegisterNodeInjectionKey, UnregisterNodeInjectionKey } from "game/layers";
|
||||
import { computed, inject, onUnmounted, shallowRef, toRef, unref, watch } from "vue";
|
||||
import { computed, inject, onUnmounted, shallowRef, toRefs, 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
|
||||
const register = inject(RegisterNodeInjectionKey, () => {});
|
||||
|
@ -16,7 +17,7 @@ const unregister = inject(UnregisterNodeInjectionKey, () => {});
|
|||
const node = shallowRef<HTMLElement | null>(null);
|
||||
const parentNode = computed(() => node.value && node.value.parentElement);
|
||||
|
||||
watch([parentNode, toRef(props, "id")], ([newNode, newID], [prevNode, prevID]) => {
|
||||
watch([parentNode, props.id], ([newNode, newID], [prevNode, prevID]) => {
|
||||
if (prevNode) {
|
||||
unregister(unref(prevID));
|
||||
}
|
||||
|
@ -25,7 +26,7 @@ watch([parentNode, toRef(props, "id")], ([newNode, newID], [prevNode, prevID]) =
|
|||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => unregister(props.id));
|
||||
onUnmounted(() => unregister(unref(props.id)));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
@ -14,13 +14,12 @@
|
|||
<Toggle :title="unthrottledTitle" v-model="unthrottled" />
|
||||
<Toggle v-if="projInfo.enablePausing" :title="isPausedTitle" v-model="isPaused" />
|
||||
<Toggle :title="offlineProdTitle" v-model="offlineProd" />
|
||||
<Toggle :title="showHealthWarningTitle" v-model="showHealthWarning" v-if="!projInfo.disableHealthWarning" />
|
||||
<Toggle :title="autosaveTitle" v-model="autosave" />
|
||||
<FeedbackButton v-if="!autosave" class="button save-button" @click="save()">Manually save</FeedbackButton>
|
||||
</div>
|
||||
<div v-if="isTab('appearance')">
|
||||
<Select :title="themeTitle" :options="themes" v-model="theme" />
|
||||
<SettingFields />
|
||||
<component :is="settingFieldsComponent" />
|
||||
<Toggle :title="showTPSTitle" v-model="showTPS" />
|
||||
<Toggle :title="alignModifierUnitsTitle" v-model="alignUnits" />
|
||||
</div>
|
||||
|
@ -29,19 +28,20 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="tsx">
|
||||
import Modal from "components/Modal.vue";
|
||||
import projInfo from "data/projInfo.json";
|
||||
import { save } from "util/save";
|
||||
import rawThemes from "data/themes";
|
||||
import { jsx } from "features/feature";
|
||||
import Tooltip from "features/tooltips/Tooltip.vue";
|
||||
import player from "game/player";
|
||||
import settings, { settingFields } from "game/settings";
|
||||
import { camelToTitle, Direction } from "util/common";
|
||||
import { save } from "util/save";
|
||||
import { render } from "util/vue";
|
||||
import { coerceComponent, render } from "util/vue";
|
||||
import { computed, ref, toRefs } from "vue";
|
||||
import Tooltip from "wrappers/tooltips/Tooltip.vue";
|
||||
import FeedbackButton from "../fields/FeedbackButton.vue";
|
||||
import Select from "../fields/Select.vue";
|
||||
import Toggle from "../fields/Toggle.vue";
|
||||
import Modal from "./Modal.vue";
|
||||
import Select from "./fields/Select.vue";
|
||||
import Toggle from "./fields/Toggle.vue";
|
||||
import FeedbackButton from "./fields/FeedbackButton.vue";
|
||||
|
||||
const isOpen = ref(false);
|
||||
const currentTab = ref("behaviour");
|
||||
|
@ -68,9 +68,11 @@ const themes = Object.keys(rawThemes).map(theme => ({
|
|||
value: theme
|
||||
}));
|
||||
|
||||
const SettingFields = () => settingFields.map(f => render(f));
|
||||
const settingFieldsComponent = computed(() => {
|
||||
return coerceComponent(jsx(() => (<>{settingFields.map(render)}</>)));
|
||||
});
|
||||
|
||||
const { showTPS, theme, unthrottled, alignUnits, showHealthWarning } = toRefs(settings);
|
||||
const { showTPS, theme, unthrottled, alignUnits } = toRefs(settings);
|
||||
const { autosave, offlineProd } = toRefs(player);
|
||||
const isPaused = computed({
|
||||
get() {
|
||||
|
@ -81,38 +83,48 @@ const isPaused = computed({
|
|||
}
|
||||
});
|
||||
|
||||
const unthrottledTitle = <span class="option-title">
|
||||
Unthrottled
|
||||
<desc>Allow the game to run as fast as possible. Not battery friendly.</desc>
|
||||
</span>;
|
||||
const offlineProdTitle = <span class="option-title">
|
||||
Offline production<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip>
|
||||
<desc>Simulate production that occurs while the game is closed.</desc>
|
||||
</span>;
|
||||
const showHealthWarningTitle = <span class="option-title">
|
||||
Show videogame addiction warning
|
||||
<desc>Show a helpful warning after playing for a long time about video game addiction and encouraging you to take a break.</desc>
|
||||
</span>;
|
||||
const autosaveTitle = <span class="option-title">
|
||||
Autosave<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip>
|
||||
<desc>Automatically save the game every second or when the game is closed.</desc>
|
||||
</span>;
|
||||
const isPausedTitle = <span class="option-title">
|
||||
Pause game<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip>
|
||||
<desc>Stop everything from moving.</desc>
|
||||
</span>;
|
||||
const themeTitle = <span class="option-title">
|
||||
Theme
|
||||
<desc>How the game looks.</desc>
|
||||
</span>;
|
||||
const showTPSTitle = <span class="option-title">
|
||||
Show TPS
|
||||
<desc>Show TPS meter at the bottom-left corner of the page.</desc>
|
||||
</span>;
|
||||
const alignModifierUnitsTitle = <span class="option-title">
|
||||
Align modifier units
|
||||
<desc>Align numbers to the beginning of the unit in modifier view.</desc>
|
||||
</span>;
|
||||
const unthrottledTitle = jsx(() => (
|
||||
<span class="option-title">
|
||||
Unthrottled
|
||||
<desc>Allow the game to run as fast as possible. Not battery friendly.</desc>
|
||||
</span>
|
||||
));
|
||||
const offlineProdTitle = jsx(() => (
|
||||
<span class="option-title">
|
||||
Offline Production<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip>
|
||||
<desc>Simulate production that occurs while the game is closed.</desc>
|
||||
</span>
|
||||
));
|
||||
const autosaveTitle = jsx(() => (
|
||||
<span class="option-title">
|
||||
Autosave<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip>
|
||||
<desc>Automatically save the game every second or when the game is closed.</desc>
|
||||
</span>
|
||||
));
|
||||
const isPausedTitle = jsx(() => (
|
||||
<span class="option-title">
|
||||
Pause game<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip>
|
||||
<desc>Stop everything from moving.</desc>
|
||||
</span>
|
||||
));
|
||||
const themeTitle = jsx(() => (
|
||||
<span class="option-title">
|
||||
Theme
|
||||
<desc>How the game looks.</desc>
|
||||
</span>
|
||||
));
|
||||
const showTPSTitle = jsx(() => (
|
||||
<span class="option-title">
|
||||
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>
|
||||
|
||||
<style>
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div class="save" :class="{ active: isActive, readonly }">
|
||||
<div class="handle material-icons" v-if="readonly !== true">drag_handle</div>
|
||||
<div class="actions" v-if="!isEditing && readonly !== true">
|
||||
<div class="save" :class="{ active: isActive }">
|
||||
<div class="handle material-icons">drag_handle</div>
|
||||
<div class="actions" v-if="!isEditing">
|
||||
<FeedbackButton
|
||||
@click="emit('export')"
|
||||
class="button"
|
||||
|
@ -40,7 +40,7 @@
|
|||
</Tooltip>
|
||||
</DangerButton>
|
||||
</div>
|
||||
<div class="actions" v-else-if="readonly !== true">
|
||||
<div class="actions" v-else>
|
||||
<button @click="changeName" class="button">
|
||||
<Tooltip display="Save" :direction="Direction.Left" class="info">
|
||||
<span class="material-icons">check</span>
|
||||
|
@ -53,17 +53,12 @@
|
|||
</button>
|
||||
</div>
|
||||
<div class="details" v-if="save.error == undefined && !isEditing">
|
||||
<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">
|
||||
<button class="button open" @click="emit('open')">
|
||||
<h3>{{ save.name }}</h3>
|
||||
</button>
|
||||
<span class="save-version">v{{ save.modVersion }}</span
|
||||
><br />
|
||||
<div v-if="currentTime" class="time">
|
||||
Last played {{ dateFormat.format(currentTime) }}
|
||||
</div>
|
||||
<div v-if="currentTime">Last played {{ dateFormat.format(currentTime) }}</div>
|
||||
</div>
|
||||
<div class="details" v-else-if="save.error == undefined && isEditing">
|
||||
<Text v-model="newName" class="editname" @submit="changeName" />
|
||||
|
@ -75,21 +70,19 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Tooltip from "features/tooltips/Tooltip.vue";
|
||||
import player from "game/player";
|
||||
import { Direction } from "util/common";
|
||||
import { galaxy, syncedSaves } from "util/galaxy";
|
||||
import { LoadablePlayerData } from "util/save";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import Tooltip from "wrappers/tooltips/Tooltip.vue";
|
||||
import DangerButton from "../fields/DangerButton.vue";
|
||||
import FeedbackButton from "../fields/FeedbackButton.vue";
|
||||
import Text from "../fields/Text.vue";
|
||||
import { computed, ref, toRefs, watch } from "vue";
|
||||
import DangerButton from "./fields/DangerButton.vue";
|
||||
import FeedbackButton from "./fields/FeedbackButton.vue";
|
||||
import Text from "./fields/Text.vue";
|
||||
import type { LoadablePlayerData } from "./SavesManager.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
const _props = defineProps<{
|
||||
save: LoadablePlayerData;
|
||||
readonly?: boolean;
|
||||
}>();
|
||||
|
||||
const { save } = toRefs(_props);
|
||||
const emit = defineEmits<{
|
||||
(e: "export"): void;
|
||||
(e: "open"): void;
|
||||
|
@ -111,19 +104,11 @@ const isEditing = ref(false);
|
|||
const isConfirming = ref(false);
|
||||
const newName = ref("");
|
||||
|
||||
watch(isEditing, () => (newName.value = props.save.name ?? ""));
|
||||
watch(isEditing, () => (newName.value = save.value.name ?? ""));
|
||||
|
||||
const isActive = computed(
|
||||
() => props.save != null && props.save.id === player.id && !props.readonly
|
||||
);
|
||||
const isActive = computed(() => save.value != null && save.value.id === player.id);
|
||||
const currentTime = computed(() =>
|
||||
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)
|
||||
isActive.value ? player.time : (save.value != null && save.value.time) ?? 0
|
||||
);
|
||||
|
||||
function changeName() {
|
||||
|
@ -154,13 +139,6 @@ function changeName() {
|
|||
padding-left: 0;
|
||||
}
|
||||
|
||||
.open:disabled {
|
||||
cursor: inherit;
|
||||
color: var(--foreground);
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.handle {
|
||||
flex-grow: 0;
|
||||
margin-right: 8px;
|
||||
|
@ -174,10 +152,6 @@ function changeName() {
|
|||
margin-right: 80px;
|
||||
}
|
||||
|
||||
.save.readonly .details {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: 0.8em;
|
||||
color: var(--danger);
|
||||
|
@ -202,17 +176,6 @@ function changeName() {
|
|||
.editname {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.synced {
|
||||
font-size: 100%;
|
||||
margin-right: 0.5em;
|
||||
vertical-align: middle;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
|
@ -238,8 +201,4 @@ function changeName() {
|
|||
.save .field {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.details > .tooltip-container {
|
||||
display: inline;
|
||||
}
|
||||
</style>
|
|
@ -4,9 +4,6 @@
|
|||
<h2>Saves Manager</h2>
|
||||
</template>
|
||||
<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
|
||||
:list="settings.saves"
|
||||
handle=".handle"
|
||||
|
@ -60,31 +57,22 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Modal from "components/Modal.vue";
|
||||
import projInfo from "data/projInfo.json";
|
||||
import type { Player } from "game/player";
|
||||
import player, { stringifySave } from "game/player";
|
||||
import settings from "game/settings";
|
||||
import LZString from "lz-string";
|
||||
import { galaxy, syncedSaves } from "util/galaxy";
|
||||
import {
|
||||
clearCachedSave,
|
||||
clearCachedSaves,
|
||||
decodeSave,
|
||||
getCachedSave,
|
||||
getUniqueID,
|
||||
LoadablePlayerData,
|
||||
loadSave,
|
||||
newSave,
|
||||
save
|
||||
} from "util/save";
|
||||
import { getUniqueID, loadSave, newSave, save } from "util/save";
|
||||
import type { ComponentPublicInstance } from "vue";
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { computed, nextTick, ref, shallowReactive, watch } from "vue";
|
||||
import Draggable from "vuedraggable";
|
||||
import Select from "../fields/Select.vue";
|
||||
import Text from "../fields/Text.vue";
|
||||
import Modal from "./Modal.vue";
|
||||
import Select from "./fields/Select.vue";
|
||||
import Text from "./fields/Text.vue";
|
||||
import Save from "./Save.vue";
|
||||
|
||||
export type LoadablePlayerData = Omit<Partial<Player>, "id"> & { id: string; error?: unknown };
|
||||
|
||||
const isOpen = ref(false);
|
||||
const modal = ref<ComponentPublicInstance<typeof Modal> | null>(null);
|
||||
|
||||
|
@ -102,8 +90,16 @@ watch(saveToImport, importedSave => {
|
|||
if (importedSave) {
|
||||
nextTick(() => {
|
||||
try {
|
||||
importedSave = decodeSave(importedSave) ?? "";
|
||||
if (importedSave === "") {
|
||||
if (importedSave[0] === "{") {
|
||||
// plaintext. No processing needed
|
||||
} 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);
|
||||
importingFailed.value = true;
|
||||
return;
|
||||
|
@ -129,22 +125,62 @@ watch(saveToImport, importedSave => {
|
|||
}
|
||||
});
|
||||
|
||||
let bankContext = import.meta.glob("./../../../saves/*.txt", { query: "?raw", eager: true });
|
||||
let bankContext = import.meta.globEager("./../../saves/*.txt", { as: "raw" });
|
||||
let bank = ref(
|
||||
Object.keys(bankContext).reduce((acc: Array<{ label: string; value: string }>, curr) => {
|
||||
acc.push({
|
||||
// .slice(2, -4) strips the leading ./ and the trailing .txt
|
||||
label: curr.split("/").slice(-1)[0].slice(0, -4),
|
||||
value: bankContext[curr] as string
|
||||
// Have to perform this unholy cast because globEager's typing doesn't appear to know
|
||||
// adding { as: "raw" } will make the object contain strings rather than modules
|
||||
value: bankContext[curr] as unknown as string
|
||||
});
|
||||
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
|
||||
watch(isOpen, isOpen => {
|
||||
if (isOpen) {
|
||||
clearCachedSaves();
|
||||
Object.keys(cachedSaves).forEach(key => delete cachedSaves[key]);
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -155,10 +191,6 @@ const saves = computed(() =>
|
|||
}, {})
|
||||
);
|
||||
|
||||
const showNotSyncedWarning = computed(
|
||||
() => galaxy.value?.loggedIn === true && settings.saves.length < syncedSaves.value.length
|
||||
);
|
||||
|
||||
function exportSave(id: string) {
|
||||
let saveToExport;
|
||||
if (player.id === id) {
|
||||
|
@ -201,37 +233,20 @@ function duplicateSave(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);
|
||||
localStorage.removeItem(id);
|
||||
clearCachedSave(id);
|
||||
cachedSaves[id] = undefined;
|
||||
}
|
||||
|
||||
function openSave(id: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
saves.value[player.id]!.time = player.time;
|
||||
save();
|
||||
clearCachedSave(player.id);
|
||||
cachedSaves[player.id] = undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
loadSave(saves.value[id]!);
|
||||
// Delete cached version in case of opening it again
|
||||
clearCachedSave(id);
|
||||
cachedSaves[id] = undefined;
|
||||
}
|
||||
|
||||
function newFromPreset(preset: string) {
|
||||
|
@ -241,8 +256,16 @@ function newFromPreset(preset: string) {
|
|||
selectedPreset.value = null;
|
||||
});
|
||||
|
||||
preset = decodeSave(preset) ?? "";
|
||||
if (preset === "") {
|
||||
if (preset[0] === "{") {
|
||||
// plaintext. No processing needed
|
||||
} 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);
|
||||
return;
|
||||
}
|
||||
|
@ -264,7 +287,7 @@ function editSave(id: string, newName: string) {
|
|||
save();
|
||||
} else {
|
||||
save(currSave as Player);
|
||||
clearCachedSave(id);
|
||||
cachedSaves[id] = undefined;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,11 +1,17 @@
|
|||
<template>
|
||||
<div class="tpsDisplay" v-if="!tps.isNan()">TPS: {{ formatWhole(tps) }}</div>
|
||||
<div class="tpsDisplay" v-if="!tps.isNan()">
|
||||
TPS: {{ formatWhole(tps) }}
|
||||
<transition name="fade"
|
||||
><span v-if="showLow" class="low">{{ formatWhole(low) }}</span></transition
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import state from "game/state";
|
||||
import type { DecimalSource } from "util/bignum";
|
||||
import Decimal, { formatWhole } from "util/bignum";
|
||||
import { computed } from "vue";
|
||||
import { computed, ref, watchEffect } from "vue";
|
||||
|
||||
const tps = computed(() =>
|
||||
Decimal.div(
|
||||
|
@ -13,6 +19,20 @@ const tps = computed(() =>
|
|||
state.lastTenTicks.reduce((acc, curr) => acc + curr, 0)
|
||||
)
|
||||
);
|
||||
|
||||
const lastTenFPS = ref<number[]>([]);
|
||||
watchEffect(() => {
|
||||
lastTenFPS.value.push(Math.round(tps.value.toNumber()));
|
||||
if (lastTenFPS.value.length > 10) {
|
||||
lastTenFPS.value = lastTenFPS.value.slice(1);
|
||||
}
|
||||
});
|
||||
|
||||
const low = computed(() =>
|
||||
lastTenFPS.value.reduce<DecimalSource>((acc, curr) => Decimal.max(acc, curr), 0)
|
||||
);
|
||||
|
||||
const showLow = computed(() => Decimal.sub(tps.value, low.value).gt(1));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
.feature {
|
||||
.feature:not(li),
|
||||
.feature:not(li) button {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
button.feature,
|
||||
.feature button {
|
||||
padding: 5px;
|
||||
border-radius: var(--border-radius);
|
||||
border: 2px solid rgba(0, 0, 0, 0.125);
|
||||
|
@ -14,17 +11,13 @@ button.feature,
|
|||
transition: all 0.5s, z-index 0s 0.5s;
|
||||
}
|
||||
|
||||
.feature button {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
button.can,
|
||||
.can,
|
||||
.can button {
|
||||
background-color: var(--layer-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.can:hover,
|
||||
.can:hover,
|
||||
.can:hover button {
|
||||
transform: scale(1.15, 1.15);
|
||||
box-shadow: 0 0 20px var(--points);
|
||||
|
@ -32,13 +25,13 @@ button.can:hover,
|
|||
transition: all 0.5s, z-index 0s;
|
||||
}
|
||||
|
||||
button.locked,
|
||||
.locked,
|
||||
.locked button {
|
||||
background-color: var(--locked);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.bought,
|
||||
.bought,
|
||||
.bought button {
|
||||
background-color: var(--bought);
|
||||
cursor: default;
|
||||
|
|
|
@ -20,6 +20,11 @@
|
|||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.row > :not(.feature) {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.col {
|
||||
display: flex;
|
||||
flex-flow: column wrap;
|
||||
|
@ -29,148 +34,95 @@
|
|||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.row.mergeAdjacent *,
|
||||
.row.mergeAdjacent button.feature,
|
||||
.row.mergeAdjacent .feature button {
|
||||
.row.mergeAdjacent > .feature:not(.dontMerge),
|
||||
.row.mergeAdjacent > .tooltip-container > .feature:not(.dontMerge) {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.row.mergeAdjacent button.feature,
|
||||
.row.mergeAdjacent .feature button {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.row.mergeAdjacent > button.feature:first-child,
|
||||
.row.mergeAdjacent > .feature:first-child button,
|
||||
.row.mergeAdjacent > :first-child button.feature,
|
||||
.row.mergeAdjacent > :first-child .feature button {
|
||||
.row.mergeAdjacent > .feature:not(.dontMerge):first-child,
|
||||
.row.mergeAdjacent > .tooltip-container:first-child > .feature:not(.dontMerge) {
|
||||
border-radius: var(--border-radius) 0 0 var(--border-radius);
|
||||
}
|
||||
|
||||
.row.mergeAdjacent > button.feature:last-child,
|
||||
.row.mergeAdjacent > .feature:last-child button,
|
||||
.row.mergeAdjacent > :last-child button.feature,
|
||||
.row.mergeAdjacent > :last-child .feature button {
|
||||
.row.mergeAdjacent > .feature:not(.dontMerge):last-child,
|
||||
.row.mergeAdjacent > .tooltip-container:last-child > .feature:not(.dontMerge) {
|
||||
border-radius: 0 var(--border-radius) var(--border-radius) 0;
|
||||
}
|
||||
|
||||
.row.mergeAdjacent > button.feature:first-child:last-child,
|
||||
.row.mergeAdjacent > .feature:first-child:last-child button,
|
||||
.row.mergeAdjacent > :first-child:last-child button.feature,
|
||||
.row.mergeAdjacent > :first-child:last-child .feature button {
|
||||
.row.mergeAdjacent > .feature:not(.dontMerge):first-child:last-child,
|
||||
.row.mergeAdjacent > .tooltip-container:first-child:last-child > .feature:not(.dontMerge) {
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.col.mergeAdjacent *,
|
||||
.col.mergeAdjacent button.feature,
|
||||
.col.mergeAdjacent .feature button {
|
||||
margin-top: 0;
|
||||
.row-grid.mergeAdjacent > .feature:not(.dontMerge),
|
||||
.row-grid.mergeAdjacent > .tooltip-container > .feature:not(.dontMerge) {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.col.mergeAdjacent button.feature,
|
||||
.col.mergeAdjacent .feature button {
|
||||
margin-top: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.col.mergeAdjacent > button.feature:first-child,
|
||||
.col.mergeAdjacent > .feature:first-child button,
|
||||
.col.mergeAdjacent > :first-child button.feature,
|
||||
.col.mergeAdjacent > :first-child .feature button {
|
||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||
.row-grid.mergeAdjacent > .feature:not(.dontMerge):last-child,
|
||||
.row-grid.mergeAdjacent > .tooltip-container:last-child > .feature:not(.dontMerge) {
|
||||
border-radius: 0 0 0 0;
|
||||
}
|
||||
|
||||
.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);
|
||||
|
||||
.row-grid.mergeAdjacent > .feature:not(.dontMerge):first-child,
|
||||
.row-grid.mergeAdjacent > .tooltip-container:first-child > .feature:not(.dontMerge) {
|
||||
border-radius: 0 0 0 0;
|
||||
}
|
||||
|
||||
.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);
|
||||
.table-grid > .row-grid.mergeAdjacent:last-child > .feature:not(.dontMerge):first-child {
|
||||
border-radius: 0 0 0 var(--border-radius);
|
||||
}
|
||||
|
||||
.col.mergeAdjacent > .table > .row.mergeAdjacent:first-child > button.feature:not(:first-child):not(:last-child),
|
||||
.col.mergeAdjacent > .table > .row.mergeAdjacent:first-child > .feature:not(:first-child):not(:last-child) button,
|
||||
.col.mergeAdjacent > .table > .row.mergeAdjacent:first-child > :not(:first-child):not(:last-child) button.feature,
|
||||
.col.mergeAdjacent > .table > .row.mergeAdjacent:first-child > :not(:first-child):not(:last-child) .feature button,
|
||||
|
||||
.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 {
|
||||
.table-grid > .row-grid.mergeAdjacent:first-child > .feature:not(.dontMerge):last-child {
|
||||
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,
|
||||
.table-grid > .row-grid.mergeAdjacent:first-child > .feature:not(.dontMerge):first-child {
|
||||
border-radius: var(--border-radius) 0 0 0;
|
||||
}
|
||||
|
||||
.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 {
|
||||
.table-grid > .row-grid.mergeAdjacent:last-child > .feature:not(.dontMerge):last-child {
|
||||
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);
|
||||
/*
|
||||
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;
|
||||
}
|
||||
|
||||
.col.mergeAdjacent .feature:not(.dontMerge):last-child {
|
||||
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
||||
}
|
||||
|
||||
.col.mergeAdjacent .feature:not(.dontMerge):first-child:last-child {
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
/*
|
||||
TODO how to implement mergeAdjacent for grids?
|
||||
.col.mergeAdjacent + .col.mergeAdjacent > .feature:not(.dontMerge) {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
*/
|
||||
|
|
|
@ -10,13 +10,13 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { ref, toRefs, unref, watch } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
const _props = defineProps<{
|
||||
disabled?: boolean;
|
||||
skipConfirm?: boolean;
|
||||
}>();
|
||||
|
||||
const props = toRefs(_props);
|
||||
const emit = defineEmits<{
|
||||
(e: "click"): void;
|
||||
(e: "confirmingChanged", value: boolean): void;
|
||||
|
@ -29,7 +29,7 @@ watch(isConfirming, isConfirming => {
|
|||
});
|
||||
|
||||
function click() {
|
||||
if (props.skipConfirm) {
|
||||
if (unref(props.skipConfirm)) {
|
||||
emit("click");
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -15,13 +15,13 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const activated = ref(false);
|
||||
const activatedTimeout = ref<NodeJS.Timeout | null>(null);
|
||||
const activatedTimeout = ref<NodeJS.Timer | null>(null);
|
||||
|
||||
function click() {
|
||||
emit("click");
|
||||
|
||||
// Give feedback to user
|
||||
if (activatedTimeout.value != null) {
|
||||
if (activatedTimeout.value) {
|
||||
clearTimeout(activatedTimeout.value);
|
||||
}
|
||||
activated.value = false;
|
||||
|
|
|
@ -1,30 +1,30 @@
|
|||
<template>
|
||||
<div class="field">
|
||||
<span class="field-title" v-if="title"><Title /></span>
|
||||
<span class="field-title" v-if="titleComponent"><component :is="titleComponent" /></span>
|
||||
<VueNextSelect
|
||||
:options="options"
|
||||
v-model="value"
|
||||
@update:model-value="onUpdate"
|
||||
:min="1"
|
||||
label-by="label"
|
||||
:placeholder="placeholder"
|
||||
:close-on-select="closeOnSelect"
|
||||
@update:model-value="onUpdate"
|
||||
label-by="label"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx">
|
||||
<script setup lang="ts">
|
||||
import "components/common/fields.css";
|
||||
import { MaybeGetter } from "util/computed";
|
||||
import { render, Renderable } from "util/vue";
|
||||
import { ref, toRef, unref, watch } from "vue";
|
||||
import type { CoercableComponent } from "features/feature";
|
||||
import { computeOptionalComponent, unwrapRef } from "util/vue";
|
||||
import { ref, toRef, watch } from "vue";
|
||||
import VueNextSelect from "vue-next-select";
|
||||
import "vue-next-select/dist/index.css";
|
||||
|
||||
export type SelectOption = { label: string; value: unknown };
|
||||
|
||||
const props = defineProps<{
|
||||
title?: MaybeGetter<Renderable>;
|
||||
title?: CoercableComponent;
|
||||
modelValue?: unknown;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
|
@ -34,13 +34,13 @@ const emit = defineEmits<{
|
|||
(e: "update:modelValue", value: unknown): void;
|
||||
}>();
|
||||
|
||||
const Title = () => props.title ? render(props.title, el => <span>{el}</span>) : <></>;
|
||||
const titleComponent = computeOptionalComponent(toRef(props, "title"), "span");
|
||||
|
||||
const value = ref<SelectOption | null>(
|
||||
props.options.find(option => option.value === props.modelValue) ?? null
|
||||
);
|
||||
watch(toRef(props, "modelValue"), modelValue => {
|
||||
if (unref(value) !== modelValue) {
|
||||
if (unwrapRef(value) !== modelValue) {
|
||||
value.value = props.options.find(option => option.value === modelValue) ?? null;
|
||||
}
|
||||
});
|
||||
|
|
|
@ -9,24 +9,24 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import "components/common/fields.css";
|
||||
import Tooltip from "wrappers/tooltips/Tooltip.vue";
|
||||
import Tooltip from "features/tooltips/Tooltip.vue";
|
||||
import { Direction } from "util/common";
|
||||
import { computed } from "vue";
|
||||
import { computed, toRefs, unref } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
const _props = defineProps<{
|
||||
title?: string;
|
||||
modelValue?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}>();
|
||||
|
||||
const props = toRefs(_props);
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: number): void;
|
||||
}>();
|
||||
|
||||
const value = computed({
|
||||
get() {
|
||||
return String(props.modelValue ?? 0);
|
||||
return String(unref(props.modelValue) ?? 0);
|
||||
},
|
||||
set(value: string) {
|
||||
emit("update:modelValue", Number(value));
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<template>
|
||||
<form @submit.prevent="submit">
|
||||
<div class="field">
|
||||
<span class="field-title" v-if="title">
|
||||
<Title />
|
||||
</span>
|
||||
<span class="field-title" v-if="titleComponent"
|
||||
><component :is="titleComponent"
|
||||
/></span>
|
||||
<VueTextareaAutosize
|
||||
v-if="textArea"
|
||||
v-model="value"
|
||||
|
@ -25,15 +25,15 @@
|
|||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx">
|
||||
<script setup lang="ts">
|
||||
import "components/common/fields.css";
|
||||
import { MaybeGetter } from "util/computed";
|
||||
import { render, Renderable } from "util/vue";
|
||||
import { computed, onMounted, shallowRef, unref } from "vue";
|
||||
import type { CoercableComponent } from "features/feature";
|
||||
import { computeOptionalComponent } from "util/vue";
|
||||
import { computed, onMounted, shallowRef, toRef, unref } from "vue";
|
||||
import VueTextareaAutosize from "vue-textarea-autosize";
|
||||
|
||||
const props = defineProps<{
|
||||
title?: MaybeGetter<Renderable>;
|
||||
title?: CoercableComponent;
|
||||
modelValue?: string;
|
||||
textArea?: boolean;
|
||||
placeholder?: string;
|
||||
|
@ -46,7 +46,7 @@ const emit = defineEmits<{
|
|||
(e: "cancel"): void;
|
||||
}>();
|
||||
|
||||
const Title = () => props.title == null ? <></> : render(props.title, el => <span>{el}</span>);
|
||||
const titleComponent = computeOptionalComponent(toRef(props, "title"), "span");
|
||||
|
||||
const field = shallowRef<HTMLElement | null>(null);
|
||||
onMounted(() => {
|
||||
|
|
|
@ -1,25 +1,25 @@
|
|||
<template>
|
||||
<label class="field">
|
||||
<input type="checkbox" class="toggle" v-model="value" />
|
||||
<Component />
|
||||
<component :is="component" />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx">
|
||||
<script setup lang="ts">
|
||||
import "components/common/fields.css";
|
||||
import { MaybeGetter } from "util/computed";
|
||||
import { render, Renderable } from "util/vue";
|
||||
import { computed } from "vue";
|
||||
import type { CoercableComponent } from "features/feature";
|
||||
import { coerceComponent } from "util/vue";
|
||||
import { computed, unref } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
title?: MaybeGetter<Renderable>;
|
||||
title?: CoercableComponent;
|
||||
modelValue?: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: boolean): void;
|
||||
}>();
|
||||
|
||||
const Component = () => render(props.title ?? "", el => <span>{el}</span>);
|
||||
const component = computed(() => coerceComponent(unref(props.title) ?? "<span></span>", "span"));
|
||||
|
||||
const value = computed({
|
||||
get() {
|
||||
|
|
|
@ -1,26 +1,27 @@
|
|||
<template>
|
||||
<Col class="collapsible-container">
|
||||
<button @click="collapsed.value = !collapsed.value" class="feature collapsible-toggle">
|
||||
<Display />
|
||||
<component :is="displayComponent" />
|
||||
</button>
|
||||
<Content v-if="!collapsed.value" />
|
||||
<component v-if="!collapsed.value" :is="contentComponent" />
|
||||
</Col>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MaybeGetter } from "util/computed";
|
||||
import { render, Renderable } from "util/vue";
|
||||
import type { CoercableComponent } from "features/feature";
|
||||
import { computeComponent } from "util/vue";
|
||||
import type { Ref } from "vue";
|
||||
import { toRef } from "vue";
|
||||
import Col from "./Column.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
collapsed: Ref<boolean>;
|
||||
display: MaybeGetter<Renderable>;
|
||||
content: MaybeGetter<Renderable>;
|
||||
display: CoercableComponent;
|
||||
content: CoercableComponent;
|
||||
}>();
|
||||
|
||||
const Display = () => render(props.display);
|
||||
const Content = () => render(props.content);
|
||||
const displayComponent = computeComponent(toRef(props, "display"));
|
||||
const contentComponent = computeComponent(toRef(props, "content"));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
@ -12,10 +12,5 @@ import themes from "data/themes";
|
|||
import settings from "game/settings";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
dontMerge?: boolean
|
||||
}>();
|
||||
|
||||
const mergeAdjacent = computed(() =>
|
||||
themes[settings.theme].mergeAdjacent && props.dontMerge !== true);
|
||||
const mergeAdjacent = computed(() => themes[settings.theme].mergeAdjacent);
|
||||
</script>
|
||||
|
|
|
@ -12,10 +12,5 @@ import themes from "data/themes";
|
|||
import settings from "game/settings";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
dontMerge?: boolean
|
||||
}>();
|
||||
|
||||
const mergeAdjacent = computed(() =>
|
||||
themes[settings.theme].mergeAdjacent && props.dontMerge !== true);
|
||||
const mergeAdjacent = computed(() => themes[settings.theme].mergeAdjacent);
|
||||
</script>
|
||||
|
|
|
@ -1,84 +0,0 @@
|
|||
<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>
|
|
@ -1,228 +0,0 @@
|
|||
<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>
|
|
@ -19,7 +19,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Modal from "components/modals/Modal.vue";
|
||||
import Modal from "components/Modal.vue";
|
||||
import { ref } from "vue";
|
||||
|
||||
const isOpen = ref(false);
|
||||
|
|
|
@ -7,12 +7,3 @@
|
|||
.modifier-toggle.collapsed {
|
||||
transform: translate(-5px, -5px) rotate(-90deg);
|
||||
}
|
||||
|
||||
.node-text {
|
||||
text-anchor: middle;
|
||||
dominant-baseline: middle;
|
||||
font-family: monospace;
|
||||
font-size: 200%;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(3px 3px 2px var(--tooltip-background));
|
||||
}
|
||||
|
|
|
@ -1,58 +1,63 @@
|
|||
import Collapsible from "components/layout/Collapsible.vue";
|
||||
import { Achievement } from "features/achievements/achievement";
|
||||
import type { Clickable, ClickableOptions } from "features/clickables/clickable";
|
||||
import { GenericAchievement } from "features/achievements/achievement";
|
||||
import type { Clickable, ClickableOptions, GenericClickable } from "features/clickables/clickable";
|
||||
import { createClickable } from "features/clickables/clickable";
|
||||
import { Conversion } from "features/conversion";
|
||||
import { getFirstFeature } from "features/feature";
|
||||
import { displayResource, Resource } from "features/resources/resource";
|
||||
import type { Tree, TreeNode, TreeNodeOptions } from "features/trees/tree";
|
||||
import type { GenericConversion } from "features/conversion";
|
||||
import type { CoercableComponent, JSXFunction, OptionsFunc, Replace } from "features/feature";
|
||||
import { jsx, setDefault } from "features/feature";
|
||||
import { Resource, displayResource } from "features/resources/resource";
|
||||
import type { GenericTree, GenericTreeNode, TreeNode, TreeNodeOptions } from "features/trees/tree";
|
||||
import { createTreeNode } from "features/trees/tree";
|
||||
import type { GenericFormula } from "game/formulas/types";
|
||||
import { BaseLayer } from "game/layers";
|
||||
import { Modifier } from "game/modifiers";
|
||||
import type { Modifier } from "game/modifiers";
|
||||
import type { Persistent } from "game/persistence";
|
||||
import { DefaultValue, persistent } from "game/persistence";
|
||||
import player from "game/player";
|
||||
import settings from "game/settings";
|
||||
import type { DecimalSource } from "util/bignum";
|
||||
import Decimal, { format, formatSmall, formatTime } from "util/bignum";
|
||||
import { WithRequired } from "util/common";
|
||||
import { MaybeGetter, processGetter } from "util/computed";
|
||||
import { render, Renderable, renderCol } from "util/vue";
|
||||
import type { ComputedRef, MaybeRef, MaybeRefOrGetter } from "vue";
|
||||
import { computed, ref, unref } from "vue";
|
||||
import { JSX } from "vue/jsx-runtime";
|
||||
import { WithRequired, camelToTitle } from "util/common";
|
||||
import type {
|
||||
Computable,
|
||||
GetComputableType,
|
||||
GetComputableTypeWithDefault,
|
||||
ProcessedComputable
|
||||
} 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";
|
||||
|
||||
/** An object that configures a {@link ResetButton} */
|
||||
export interface ResetButtonOptions extends ClickableOptions {
|
||||
/** The conversion the button uses to calculate how much resources will be gained on click */
|
||||
conversion: Conversion;
|
||||
conversion: GenericConversion;
|
||||
/** The tree this reset button is apart of */
|
||||
tree: Tree;
|
||||
tree: GenericTree;
|
||||
/** The specific tree node associated with this reset button */
|
||||
treeNode: TreeNode;
|
||||
treeNode: GenericTreeNode;
|
||||
/**
|
||||
* Text to display on low conversion amounts, describing what "resetting" is in this context.
|
||||
* Defaults to "Reset for ".
|
||||
*/
|
||||
resetDescription?: MaybeRefOrGetter<string>;
|
||||
resetDescription?: Computable<string>;
|
||||
/** Whether or not to show how much currency would be required to make the gain amount increase. */
|
||||
showNextAt?: MaybeRefOrGetter<boolean>;
|
||||
showNextAt?: Computable<boolean>;
|
||||
/**
|
||||
* The content to display on the button.
|
||||
* By default, this includes the reset description, and amount of currency to be gained.
|
||||
*/
|
||||
display?: MaybeGetter<Renderable>;
|
||||
display?: Computable<CoercableComponent>;
|
||||
/**
|
||||
* Whether or not this button can currently be clicked.
|
||||
* Defaults to checking the current gain amount is greater than {@link minimumGain}
|
||||
*/
|
||||
canClick?: MaybeRefOrGetter<boolean>;
|
||||
canClick?: Computable<boolean>;
|
||||
/**
|
||||
* When {@link canClick} is left to its default, minimumGain is used to only enable the reset button when a sufficient amount of currency to gain is available.
|
||||
*/
|
||||
minimumGain?: MaybeRefOrGetter<DecimalSource>;
|
||||
minimumGain?: Computable<DecimalSource>;
|
||||
/** A persistent ref to track how much time has passed since the last time this tree node was reset. */
|
||||
resetTime?: Persistent<DecimalSource>;
|
||||
}
|
||||
|
@ -62,115 +67,108 @@ export interface ResetButtonOptions extends ClickableOptions {
|
|||
* It will show how much can be converted currently, and can show when that amount will go up, as well as handle only being clickable when a sufficient amount of currency can be gained.
|
||||
* Assumes this button is associated with a specific node on a tree, and triggers that tree's reset propagation.
|
||||
*/
|
||||
export interface ResetButton extends Clickable {
|
||||
/** The conversion the button uses to calculate how much resources will be gained on click */
|
||||
conversion: Conversion;
|
||||
/** The tree this reset button is apart of */
|
||||
tree: Tree;
|
||||
/** The specific tree node associated with this reset button */
|
||||
treeNode: TreeNode;
|
||||
/**
|
||||
* Text to display on low conversion amounts, describing what "resetting" is in this context.
|
||||
* Defaults to "Reset for ".
|
||||
*/
|
||||
resetDescription?: MaybeRef<string>;
|
||||
/** Whether or not to show how much currency would be required to make the gain amount increase. */
|
||||
showNextAt?: MaybeRef<boolean>;
|
||||
/**
|
||||
* When {@link canClick} is left to its default, minimumGain is used to only enable the reset button when a sufficient amount of currency to gain is available.
|
||||
*/
|
||||
minimumGain?: MaybeRef<DecimalSource>;
|
||||
/** A persistent ref to track how much time has passed since the last time this tree node was reset. */
|
||||
resetTime?: Persistent<DecimalSource>;
|
||||
}
|
||||
export type ResetButton<T extends ResetButtonOptions> = Replace<
|
||||
Clickable<T>,
|
||||
{
|
||||
resetDescription: GetComputableTypeWithDefault<T["resetDescription"], Ref<string>>;
|
||||
showNextAt: GetComputableTypeWithDefault<T["showNextAt"], true>;
|
||||
display: GetComputableTypeWithDefault<T["display"], Ref<JSX.Element>>;
|
||||
canClick: GetComputableTypeWithDefault<T["canClick"], Ref<boolean>>;
|
||||
minimumGain: GetComputableTypeWithDefault<T["minimumGain"], 1>;
|
||||
onClick: (event?: MouseEvent | TouchEvent) => void;
|
||||
}
|
||||
>;
|
||||
|
||||
/** A type that matches any valid {@link ResetButton} object. */
|
||||
export type GenericResetButton = Replace<
|
||||
GenericClickable & ResetButton<ResetButtonOptions>,
|
||||
{
|
||||
resetDescription: ProcessedComputable<string>;
|
||||
showNextAt: ProcessedComputable<boolean>;
|
||||
display: ProcessedComputable<CoercableComponent>;
|
||||
canClick: ProcessedComputable<boolean>;
|
||||
minimumGain: ProcessedComputable<DecimalSource>;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* Lazily creates a reset button with the given options.
|
||||
* @param optionsFunc A function that returns the options object for this reset button.
|
||||
*/
|
||||
export function createResetButton<T extends ClickableOptions & ResetButtonOptions>(
|
||||
optionsFunc: () => T
|
||||
) {
|
||||
const resetButton = createClickable(() => {
|
||||
const options = optionsFunc();
|
||||
const {
|
||||
conversion,
|
||||
tree,
|
||||
treeNode,
|
||||
resetTime,
|
||||
resetDescription,
|
||||
showNextAt,
|
||||
minimumGain,
|
||||
display,
|
||||
canClick,
|
||||
onClick,
|
||||
...props
|
||||
} = options;
|
||||
optionsFunc: OptionsFunc<T>
|
||||
): ResetButton<T> {
|
||||
return createClickable(feature => {
|
||||
const resetButton = optionsFunc.call(feature, feature);
|
||||
|
||||
return {
|
||||
...(props as Omit<typeof props, keyof ResetButtonOptions>),
|
||||
conversion,
|
||||
tree,
|
||||
treeNode,
|
||||
resetTime,
|
||||
resetDescription:
|
||||
processGetter(resetDescription) ??
|
||||
computed((): string =>
|
||||
Decimal.lt(conversion.gainResource.value, 1e3) ? "Reset for " : ""
|
||||
),
|
||||
showNextAt: processGetter(showNextAt) ?? true,
|
||||
minimumGain: processGetter(minimumGain) ?? 1,
|
||||
canClick:
|
||||
processGetter(canClick) ??
|
||||
computed((): boolean =>
|
||||
Decimal.gte(unref(conversion.actualGain), unref(resetButton.minimumGain))
|
||||
),
|
||||
display:
|
||||
display ??
|
||||
((): JSX.Element => (
|
||||
<span>
|
||||
{unref(resetButton.resetDescription)}
|
||||
<b>
|
||||
processComputable(resetButton as T, "showNextAt");
|
||||
setDefault(resetButton, "showNextAt", true);
|
||||
setDefault(resetButton, "minimumGain", 1);
|
||||
|
||||
if (resetButton.resetDescription == null) {
|
||||
resetButton.resetDescription = computed(() =>
|
||||
Decimal.lt(resetButton.conversion.gainResource.value, 1e3) ? "Reset for " : ""
|
||||
);
|
||||
} else {
|
||||
processComputable(resetButton as T, "resetDescription");
|
||||
}
|
||||
|
||||
if (resetButton.display == null) {
|
||||
resetButton.display = jsx(() => (
|
||||
<span>
|
||||
{unref(resetButton.resetDescription as ProcessedComputable<string>)}
|
||||
<b>
|
||||
{displayResource(
|
||||
resetButton.conversion.gainResource,
|
||||
Decimal.max(
|
||||
unref(resetButton.conversion.actualGain),
|
||||
unref(resetButton.minimumGain as ProcessedComputable<DecimalSource>)
|
||||
)
|
||||
)}
|
||||
</b>{" "}
|
||||
{resetButton.conversion.gainResource.displayName}
|
||||
{unref(resetButton.showNextAt) != null ? (
|
||||
<div>
|
||||
<br />
|
||||
{unref(resetButton.conversion.buyMax) ? "Next:" : "Req:"}{" "}
|
||||
{displayResource(
|
||||
conversion.gainResource,
|
||||
Decimal.max(
|
||||
unref(conversion.actualGain),
|
||||
unref(resetButton.minimumGain)
|
||||
)
|
||||
)}
|
||||
</b>{" "}
|
||||
{conversion.gainResource.displayName}
|
||||
{unref(resetButton.showNextAt) != null ? (
|
||||
<div>
|
||||
<br />
|
||||
{unref(conversion.buyMax) ? "Next:" : "Req:"}{" "}
|
||||
{displayResource(
|
||||
conversion.baseResource,
|
||||
!unref<boolean>(conversion.buyMax) &&
|
||||
Decimal.gte(unref(conversion.actualGain), 1)
|
||||
? unref(conversion.currentAt)
|
||||
: unref(conversion.nextAt)
|
||||
)}{" "}
|
||||
{conversion.baseResource.displayName}
|
||||
</div>
|
||||
) : null}
|
||||
</span>
|
||||
)),
|
||||
onClick: function (e?: MouseEvent | TouchEvent) {
|
||||
if (unref(resetButton.canClick) === false) {
|
||||
return;
|
||||
}
|
||||
conversion.convert();
|
||||
tree.reset(treeNode);
|
||||
if (resetTime) {
|
||||
resetTime.value = resetTime[DefaultValue];
|
||||
}
|
||||
onClick?.call(resetButton, e);
|
||||
}
|
||||
};
|
||||
}) satisfies ResetButton;
|
||||
resetButton.conversion.baseResource,
|
||||
unref(resetButton.conversion.buyMax) ||
|
||||
Decimal.floor(unref(resetButton.conversion.actualGain)).neq(1)
|
||||
? unref(resetButton.conversion.nextAt)
|
||||
: unref(resetButton.conversion.currentAt)
|
||||
)}{" "}
|
||||
{resetButton.conversion.baseResource.displayName}
|
||||
</div>
|
||||
) : null}
|
||||
</span>
|
||||
));
|
||||
}
|
||||
|
||||
return resetButton;
|
||||
if (resetButton.canClick == null) {
|
||||
resetButton.canClick = computed(() =>
|
||||
Decimal.gte(
|
||||
unref(resetButton.conversion.actualGain),
|
||||
unref(resetButton.minimumGain as ProcessedComputable<DecimalSource>)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const onClick = resetButton.onClick;
|
||||
resetButton.onClick = function (event?: MouseEvent | TouchEvent) {
|
||||
if (unref(resetButton.canClick) === false) {
|
||||
return;
|
||||
}
|
||||
resetButton.conversion.convert();
|
||||
resetButton.tree.reset(resetButton.treeNode);
|
||||
if (resetButton.resetTime) {
|
||||
resetButton.resetTime.value = resetButton.resetTime[DefaultValue];
|
||||
}
|
||||
onClick?.(event);
|
||||
};
|
||||
|
||||
return resetButton;
|
||||
}) as unknown as ResetButton<T>;
|
||||
}
|
||||
|
||||
/** An object that configures a {@link LayerTreeNode} */
|
||||
|
@ -178,73 +176,75 @@ export interface LayerTreeNodeOptions extends TreeNodeOptions {
|
|||
/** The ID of the layer this tree node is associated with */
|
||||
layerID: string;
|
||||
/** The color to display this tree node as */
|
||||
color: MaybeRefOrGetter<string>; // marking as required
|
||||
color: Computable<string>; // marking as required
|
||||
/** Whether or not to append the layer to the tabs list.
|
||||
* If set to false, then the tree node will instead always remove all tabs to its right and then add the layer tab.
|
||||
* Defaults to true.
|
||||
*/
|
||||
append?: MaybeRefOrGetter<boolean>;
|
||||
append?: Computable<boolean>;
|
||||
}
|
||||
|
||||
/** A tree node that is associated with a given layer, and which opens the layer when clicked. */
|
||||
export interface LayerTreeNode extends TreeNode {
|
||||
/** The ID of the layer this tree node is associated with */
|
||||
layerID: string;
|
||||
/** Whether or not to append the layer to the tabs list.
|
||||
* If set to false, then the tree node will instead always remove all tabs to its right and then add the layer tab.
|
||||
* Defaults to true.
|
||||
*/
|
||||
append?: MaybeRef<boolean>;
|
||||
}
|
||||
export type LayerTreeNode<T extends LayerTreeNodeOptions> = Replace<
|
||||
TreeNode<T>,
|
||||
{
|
||||
display: GetComputableTypeWithDefault<T["display"], T["layerID"]>;
|
||||
append: GetComputableType<T["append"]>;
|
||||
}
|
||||
>;
|
||||
/** A type that matches any valid {@link LayerTreeNode} object. */
|
||||
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.
|
||||
* @param optionsFunc A function that returns the options object for this tree node.
|
||||
*/
|
||||
export function createLayerTreeNode<T extends LayerTreeNodeOptions>(optionsFunc: () => T) {
|
||||
const layerTreeNode = createTreeNode(() => {
|
||||
const options = optionsFunc();
|
||||
const { display, append, layerID, ...props } = options;
|
||||
|
||||
export function createLayerTreeNode<T extends LayerTreeNodeOptions>(
|
||||
optionsFunc: OptionsFunc<T>
|
||||
): LayerTreeNode<T> {
|
||||
return createTreeNode(feature => {
|
||||
const options = optionsFunc.call(feature, feature);
|
||||
setDefault(options, "display", camelToTitle(options.layerID));
|
||||
processComputable(options as T, "append");
|
||||
return {
|
||||
...(props as Omit<typeof props, keyof LayerTreeNodeOptions>),
|
||||
layerID,
|
||||
display: display ?? layerID,
|
||||
append: processGetter(append) ?? true,
|
||||
onClick() {
|
||||
if (unref<boolean>(layerTreeNode.append)) {
|
||||
if (player.tabs.includes(layerID)) {
|
||||
const index = player.tabs.lastIndexOf(layerID);
|
||||
player.tabs.splice(index, 1);
|
||||
} else {
|
||||
player.tabs.push(layerID);
|
||||
}
|
||||
} else {
|
||||
player.tabs.splice(1, 1, layerID);
|
||||
}
|
||||
}
|
||||
...options,
|
||||
onClick: unref((options as unknown as GenericLayerTreeNode).append)
|
||||
? function () {
|
||||
if (player.tabs.includes(options.layerID)) {
|
||||
const index = player.tabs.lastIndexOf(options.layerID);
|
||||
player.tabs.splice(index, 1);
|
||||
} else {
|
||||
player.tabs.push(options.layerID);
|
||||
}
|
||||
}
|
||||
: function () {
|
||||
player.tabs.splice(1, 1, options.layerID);
|
||||
}
|
||||
};
|
||||
}) satisfies LayerTreeNode;
|
||||
|
||||
return layerTreeNode;
|
||||
}) as unknown as LayerTreeNode<T>;
|
||||
}
|
||||
|
||||
/** An option object for a modifier display as a single section. **/
|
||||
export interface Section {
|
||||
/** The header for this modifier. **/
|
||||
title: MaybeRefOrGetter<string>;
|
||||
title: Computable<string>;
|
||||
/** A subtitle for this modifier, e.g. to explain the context for the modifier. **/
|
||||
subtitle?: MaybeRefOrGetter<string>;
|
||||
subtitle?: Computable<string>;
|
||||
/** The modifier to be displaying in this section. **/
|
||||
modifier: WithRequired<Modifier, "description">;
|
||||
/** The base value being modified. **/
|
||||
base?: MaybeRefOrGetter<DecimalSource>;
|
||||
base?: Computable<DecimalSource>;
|
||||
/** The unit of measurement for the base. **/
|
||||
unit?: string;
|
||||
/** The label to call the base amount. Defaults to "Base". **/
|
||||
baseText?: MaybeGetter<Renderable>;
|
||||
baseText?: Computable<CoercableComponent>;
|
||||
/** Whether or not this section should be currently visible to the player. **/
|
||||
visible?: MaybeRefOrGetter<boolean>;
|
||||
visible?: Computable<boolean>;
|
||||
/** Determines if numbers larger or smaller than the base should be displayed as red. */
|
||||
smallerIsBetter?: boolean;
|
||||
}
|
||||
|
@ -256,33 +256,33 @@ export interface Section {
|
|||
*/
|
||||
export function createCollapsibleModifierSections(
|
||||
sectionsFunc: () => Section[]
|
||||
): [() => Renderable, Persistent<Record<number, boolean>>] {
|
||||
): [JSXFunction, Persistent<Record<number, boolean>>] {
|
||||
const sections: Section[] = [];
|
||||
const processed:
|
||||
| {
|
||||
base: MaybeRef<DecimalSource | undefined>[];
|
||||
baseText: (MaybeGetter<Renderable> | undefined)[];
|
||||
visible: MaybeRef<boolean | undefined>[];
|
||||
title: MaybeRef<string | undefined>[];
|
||||
subtitle: MaybeRef<string | undefined>[];
|
||||
base: ProcessedComputable<DecimalSource | undefined>[];
|
||||
baseText: ProcessedComputable<CoercableComponent | undefined>[];
|
||||
visible: ProcessedComputable<boolean | undefined>[];
|
||||
title: ProcessedComputable<string | undefined>[];
|
||||
subtitle: ProcessedComputable<string | undefined>[];
|
||||
}
|
||||
| Record<string, never> = {};
|
||||
let calculated = false;
|
||||
function calculateSections() {
|
||||
if (!calculated) {
|
||||
sections.push(...sectionsFunc());
|
||||
processed.base = sections.map(s => processGetter(s.base));
|
||||
processed.baseText = sections.map(s => s.baseText);
|
||||
processed.visible = sections.map(s => processGetter(s.visible));
|
||||
processed.title = sections.map(s => processGetter(s.title));
|
||||
processed.subtitle = sections.map(s => processGetter(s.subtitle));
|
||||
processed.base = sections.map(s => convertComputable(s.base));
|
||||
processed.baseText = sections.map(s => convertComputable(s.baseText));
|
||||
processed.visible = sections.map(s => convertComputable(s.visible));
|
||||
processed.title = sections.map(s => convertComputable(s.title));
|
||||
processed.subtitle = sections.map(s => convertComputable(s.subtitle));
|
||||
calculated = true;
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
const collapsed = persistent<Record<number, boolean>>({}, false);
|
||||
const jsxFunc = () => {
|
||||
const jsxFunc = jsx(() => {
|
||||
const sections = calculateSections();
|
||||
|
||||
let firstVisibleSection = true;
|
||||
|
@ -309,14 +309,16 @@ export function createCollapsibleModifierSections(
|
|||
<>
|
||||
<div class="modifier-container">
|
||||
<span class="modifier-description">
|
||||
{render(unref(processed.baseText[i]) ?? "Base")}
|
||||
{renderJSX(unref(processed.baseText[i]) ?? "Base")}
|
||||
</span>
|
||||
<span class="modifier-amount">
|
||||
{format(unref(processed.base[i]) ?? 1)}
|
||||
{s.unit}
|
||||
</span>
|
||||
</div>
|
||||
{s.modifier.description == null ? null : render(unref(s.modifier.description))}
|
||||
{s.modifier.description == null
|
||||
? null
|
||||
: renderJSX(unref(s.modifier.description))}
|
||||
</>
|
||||
);
|
||||
|
||||
|
@ -362,7 +364,7 @@ export function createCollapsibleModifierSections(
|
|||
);
|
||||
});
|
||||
return <>{sectionJSX}</>;
|
||||
};
|
||||
});
|
||||
return [jsxFunc, collapsed];
|
||||
}
|
||||
|
||||
|
@ -379,7 +381,7 @@ export function colorText(textToColor: string, color = "var(--accent2)"): JSX.El
|
|||
* 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
|
||||
*/
|
||||
export function createCollapsibleAchievements(achievements: Record<string, Achievement>) {
|
||||
export function createCollapsibleAchievements(achievements: Record<string, GenericAchievement>) {
|
||||
// Achievements are typically defined from easiest to hardest, and we want to show hardest first
|
||||
const orderedAchievements = Object.values(achievements).reverse();
|
||||
const collapseAchievements = persistent<boolean>(true, false);
|
||||
|
@ -390,23 +392,25 @@ export function createCollapsibleAchievements(achievements: Record<string, Achie
|
|||
orderedAchievements,
|
||||
m => m.earned.value
|
||||
);
|
||||
const display = computed(() => {
|
||||
const display = jsx(() => {
|
||||
const achievementsToDisplay = [...lockedAchievements.value];
|
||||
if (firstFeature.value) {
|
||||
achievementsToDisplay.push(firstFeature.value);
|
||||
}
|
||||
return renderCol(
|
||||
return renderColJSX(
|
||||
...achievementsToDisplay,
|
||||
<Collapsible
|
||||
collapsed={collapseAchievements}
|
||||
content={collapsedContent}
|
||||
display={
|
||||
collapseAchievements.value
|
||||
? "Show other completed achievements"
|
||||
: "Hide other completed achievements"
|
||||
}
|
||||
v-show={unref(hasCollapsedContent)}
|
||||
/>
|
||||
jsx(() => (
|
||||
<Collapsible
|
||||
collapsed={collapseAchievements}
|
||||
content={collapsedContent}
|
||||
display={
|
||||
collapseAchievements.value
|
||||
? "Show other completed achievements"
|
||||
: "Hide other completed achievements"
|
||||
}
|
||||
v-show={unref(hasCollapsedContent)}
|
||||
/>
|
||||
))
|
||||
);
|
||||
});
|
||||
return {
|
||||
|
@ -423,11 +427,11 @@ export function createCollapsibleAchievements(achievements: Record<string, Achie
|
|||
*/
|
||||
export function estimateTime(
|
||||
resource: Resource,
|
||||
rate: MaybeRefOrGetter<DecimalSource>,
|
||||
target: MaybeRefOrGetter<DecimalSource>
|
||||
rate: Computable<DecimalSource>,
|
||||
target: Computable<DecimalSource>
|
||||
) {
|
||||
const processedRate = processGetter(rate);
|
||||
const processedTarget = processGetter(target);
|
||||
const processedRate = convertComputable(rate);
|
||||
const processedTarget = convertComputable(target);
|
||||
return computed(() => {
|
||||
const currRate = unref(processedRate);
|
||||
const currTarget = unref(processedTarget);
|
||||
|
@ -449,15 +453,15 @@ export function estimateTime(
|
|||
*/
|
||||
export function createFormulaPreview(
|
||||
formula: GenericFormula,
|
||||
showPreview: MaybeRefOrGetter<boolean>,
|
||||
previewAmount: MaybeRefOrGetter<DecimalSource> = 1
|
||||
showPreview: Computable<boolean>,
|
||||
previewAmount: Computable<DecimalSource> = 1
|
||||
) {
|
||||
const processedShowPreview = processGetter(showPreview);
|
||||
const processedPreviewAmount = processGetter(previewAmount);
|
||||
const processedShowPreview = convertComputable(showPreview);
|
||||
const processedPreviewAmount = convertComputable(previewAmount);
|
||||
if (!formula.hasVariable()) {
|
||||
console.error("Cannot create formula preview if the formula does not have a variable");
|
||||
throw new Error("Cannot create formula preview if the formula does not have a variable");
|
||||
}
|
||||
return computed(() => {
|
||||
return jsx(() => {
|
||||
if (unref(processedShowPreview)) {
|
||||
const curr = formatSmall(formula.evaluate());
|
||||
const preview = formatSmall(
|
||||
|
@ -481,40 +485,3 @@ export function createFormulaPreview(
|
|||
return <>{formatSmall(formula.evaluate())}</>;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function for getting a computed boolean for whether or not a given feature is currently rendered in the DOM.
|
||||
* Note it will have a true value even if the feature is off screen.
|
||||
* @param layer The layer the feature appears within
|
||||
* @param id The ID of the feature
|
||||
*/
|
||||
export function isRendered(layer: BaseLayer, id: string): ComputedRef<boolean>;
|
||||
/**
|
||||
* Utility function for getting a computed boolean for whether or not a given feature is currently rendered in the DOM.
|
||||
* Note it will have a true value even if the feature is off screen.
|
||||
* @param layer The layer the feature appears within
|
||||
* @param feature The feature that may be rendered
|
||||
*/
|
||||
export function isRendered(layer: BaseLayer, feature: { id: string }): ComputedRef<boolean>;
|
||||
export function isRendered(layer: BaseLayer, idOrFeature: string | { id: string }) {
|
||||
const id = typeof idOrFeature === "string" ? idOrFeature : idOrFeature.id;
|
||||
return computed(() => id in layer.nodes.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function for setting up a system where one of many things can be selected.
|
||||
* It's recommended to use an ID or index rather than the object itself, so that you can wrap the ref in a persistent without breaking anything.
|
||||
* @returns The ref containing the selection, as well as a select and deselect function
|
||||
*/
|
||||
export function setupSelectable<T>() {
|
||||
const selected = ref<T>();
|
||||
return {
|
||||
select: function (node: T) {
|
||||
selected.value = node;
|
||||
},
|
||||
deselect: function () {
|
||||
selected.value = undefined;
|
||||
},
|
||||
selected
|
||||
};
|
||||
}
|
||||
|
|
|
@ -4,19 +4,20 @@
|
|||
*/
|
||||
import { main } from "data/projEntry";
|
||||
import { createCumulativeConversion } from "features/conversion";
|
||||
import { jsx } from "features/feature";
|
||||
import { createHotkey } from "features/hotkey";
|
||||
import { createReset } from "features/reset";
|
||||
import MainDisplay from "features/resources/MainDisplay.vue";
|
||||
import { createResource } from "features/resources/resource";
|
||||
import { addTooltip } from "features/tooltips/tooltip";
|
||||
import { createResourceTooltip } from "features/trees/tree";
|
||||
import { createLayer } from "game/layers";
|
||||
import { BaseLayer, createLayer } from "game/layers";
|
||||
import type { DecimalSource } from "util/bignum";
|
||||
import { render } from "util/vue";
|
||||
import { addTooltip } from "wrappers/tooltips/tooltip";
|
||||
import { createLayerTreeNode, createResetButton } from "../common";
|
||||
|
||||
const id = "p";
|
||||
const layer = createLayer(id, () => {
|
||||
const layer = createLayer(id, function (this: BaseLayer) {
|
||||
const name = "Prestige";
|
||||
const color = "#4BDC13";
|
||||
const points = createResource<DecimalSource>(0, "prestige points");
|
||||
|
@ -36,10 +37,10 @@ const layer = createLayer(id, () => {
|
|||
color,
|
||||
reset
|
||||
}));
|
||||
const tooltip = addTooltip(treeNode, () => ({
|
||||
addTooltip(treeNode, {
|
||||
display: createResourceTooltip(points),
|
||||
pinnable: true
|
||||
}));
|
||||
});
|
||||
|
||||
const resetButton = createResetButton(() => ({
|
||||
conversion,
|
||||
|
@ -50,20 +51,19 @@ const layer = createLayer(id, () => {
|
|||
const hotkey = createHotkey(() => ({
|
||||
description: "Reset for prestige points",
|
||||
key: "p",
|
||||
onPress: resetButton.onClick!
|
||||
onPress: resetButton.onClick
|
||||
}));
|
||||
|
||||
return {
|
||||
name,
|
||||
color,
|
||||
points,
|
||||
tooltip,
|
||||
display: () => (
|
||||
display: jsx(() => (
|
||||
<>
|
||||
<MainDisplay resource={points} color={color} />
|
||||
{render(resetButton)}
|
||||
</>
|
||||
),
|
||||
)),
|
||||
treeNode,
|
||||
hotkey
|
||||
};
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import Node from "components/Node.vue";
|
||||
import Spacer from "components/layout/Spacer.vue";
|
||||
import { jsx } from "features/feature";
|
||||
import { createResource, trackBest, trackOOMPS, trackTotal } from "features/resources/resource";
|
||||
import { branchedResetPropagation, createTree, Tree } from "features/trees/tree";
|
||||
import type { Layer } from "game/layers";
|
||||
import type { GenericTree } from "features/trees/tree";
|
||||
import { branchedResetPropagation, createTree } from "features/trees/tree";
|
||||
import { globalBus } from "game/events";
|
||||
import type { BaseLayer, GenericLayer } from "game/layers";
|
||||
import { createLayer } from "game/layers";
|
||||
import { noPersist } from "game/persistence";
|
||||
import player, { Player } from "game/player";
|
||||
import type { Player } from "game/player";
|
||||
import player from "game/player";
|
||||
import type { DecimalSource } from "util/bignum";
|
||||
import Decimal, { format, formatTime } from "util/bignum";
|
||||
import { render } from "util/vue";
|
||||
|
@ -15,7 +17,7 @@ import prestige from "./layers/prestige";
|
|||
/**
|
||||
* @hidden
|
||||
*/
|
||||
export const main = createLayer("main", layer => {
|
||||
export const main = createLayer("main", function (this: BaseLayer) {
|
||||
const points = createResource<DecimalSource>(10);
|
||||
const best = trackBest(points);
|
||||
const total = trackTotal(points);
|
||||
|
@ -25,64 +27,44 @@ export const main = createLayer("main", layer => {
|
|||
let gain = new Decimal(1);
|
||||
return gain;
|
||||
});
|
||||
layer.on("update", diff => {
|
||||
globalBus.on("update", diff => {
|
||||
points.value = Decimal.add(points.value, Decimal.times(pointGain.value, diff));
|
||||
});
|
||||
const oomps = trackOOMPS(points, pointGain);
|
||||
|
||||
// Note: Casting as generic tree to avoid recursive type definitions
|
||||
const tree = createTree(() => ({
|
||||
nodes: noPersist([[prestige.treeNode]]),
|
||||
nodes: [[prestige.treeNode]],
|
||||
branches: [],
|
||||
onReset() {
|
||||
points.value = toRaw(tree.resettingNode.value) === toRaw(prestige.treeNode) ? 0 : 10;
|
||||
points.value = toRaw(this.resettingNode.value) === toRaw(prestige.treeNode) ? 0 : 10;
|
||||
best.value = points.value;
|
||||
total.value = points.value;
|
||||
},
|
||||
resetPropagation: branchedResetPropagation
|
||||
})) as Tree;
|
||||
})) as GenericTree;
|
||||
|
||||
// Note: layers don't _need_ a reference to everything,
|
||||
// but I'd recommend it over trying to remember what does and doesn't need to be included.
|
||||
// Officially all you need are anything with persistency or that you want to access elsewhere
|
||||
return {
|
||||
name: "Tree",
|
||||
links: tree.links,
|
||||
display: () => (
|
||||
display: jsx(() => (
|
||||
<>
|
||||
{player.devSpeed === 0 ? (
|
||||
<div>
|
||||
Game Paused
|
||||
<Node id="paused" />
|
||||
</div>
|
||||
) : null}
|
||||
{player.devSpeed === 0 ? <div>Game Paused</div> : null}
|
||||
{player.devSpeed != null && player.devSpeed !== 0 && player.devSpeed !== 1 ? (
|
||||
<div>
|
||||
Dev Speed: {format(player.devSpeed)}x
|
||||
<Node id="devspeed" />
|
||||
</div>
|
||||
<div>Dev Speed: {format(player.devSpeed)}x</div>
|
||||
) : null}
|
||||
{player.offlineTime != null && player.offlineTime !== 0 ? (
|
||||
<div>
|
||||
Offline Time: {formatTime(player.offlineTime)}
|
||||
<Node id="offline" />
|
||||
</div>
|
||||
<div>Offline Time: {formatTime(player.offlineTime)}</div>
|
||||
) : null}
|
||||
<div>
|
||||
{Decimal.lt(points.value, "1e1000") ? <span>You have </span> : null}
|
||||
<h2>{format(points.value)}</h2>
|
||||
{Decimal.lt(points.value, "1e1e6") ? <span> points</span> : null}
|
||||
</div>
|
||||
{Decimal.gt(pointGain.value, 0) ? (
|
||||
<div>
|
||||
({oomps.value})
|
||||
<Node id="oomps" />
|
||||
</div>
|
||||
) : null}
|
||||
{Decimal.gt(pointGain.value, 0) ? <div>({oomps.value})</div> : null}
|
||||
<Spacer />
|
||||
{render(tree)}
|
||||
</>
|
||||
),
|
||||
)),
|
||||
points,
|
||||
best,
|
||||
total,
|
||||
|
@ -98,7 +80,7 @@ export const main = createLayer("main", layer => {
|
|||
export const getInitialLayers = (
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
|
||||
player: Partial<Player>
|
||||
): Array<Layer> => [main, prestige];
|
||||
): Array<GenericLayer> => [main, prestige];
|
||||
|
||||
/**
|
||||
* A computed ref whose value is true whenever the game is over.
|
||||
|
|
|
@ -88,10 +88,6 @@
|
|||
"type": "string",
|
||||
"enum": ["base64", "lz", "plain"],
|
||||
"description": "The encoding to use when exporting to the clipboard. Plain-text is fast to generate but is easiest for the player to manipulate and cheat with. Base 64 is slightly slower and the string will be longer but will offer a small barrier to people trying to cheat. LZ-String is the slowest method, but produces the smallest strings and still offers a small barrier to those trying to cheat. Some sharing platforms like pastebin may automatically delete base64 encoded text, and some sites might not support all the characters used in lz-string exports."
|
||||
},
|
||||
"disableHealthWarning": {
|
||||
"type": "boolean",
|
||||
"description": "Whether or not to disable the health warning that appears to the player after excessive playtime (activity during 6 of the last 8 hours). If left enabled, the player will still be able to individually turn off the health warning in settings or by clicking \"Do not show again\" in the warning itself."
|
||||
}
|
||||
}
|
||||
}
|
|
@ -22,6 +22,5 @@
|
|||
"maxTickLength": 3600,
|
||||
"offlineLimit": 1,
|
||||
"enablePausing": true,
|
||||
"exportEncoding": "base64",
|
||||
"disableHealthWarning": false
|
||||
"exportEncoding": "base64"
|
||||
}
|
||||
|
|
|
@ -1,41 +0,0 @@
|
|||
<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 { MaybeGetter } from "util/computed";
|
||||
import { render, Renderable, Wrapper } from "util/vue";
|
||||
import { MaybeRef, unref, type CSSProperties } from "vue";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
id: string;
|
||||
components: MaybeGetter<Renderable>[];
|
||||
wrappers: Wrapper[];
|
||||
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,35 +1,112 @@
|
|||
<template>
|
||||
<button
|
||||
:style="{
|
||||
backgroundImage: (unref(earned) && unref(image) && `url(${image})`) || ''
|
||||
}"
|
||||
<div
|
||||
v-if="isVisible(visibility)"
|
||||
:style="[
|
||||
{
|
||||
visibility: isHidden(visibility) ? 'hidden' : undefined,
|
||||
backgroundImage: (earned && image && `url(${image})`) || ''
|
||||
},
|
||||
unref(style) ?? []
|
||||
]"
|
||||
:class="{
|
||||
feature: true,
|
||||
achievement: true,
|
||||
locked: !unref(earned),
|
||||
done: unref(earned),
|
||||
small: unref(small),
|
||||
...unref(classes)
|
||||
}"
|
||||
>
|
||||
<Component />
|
||||
</button>
|
||||
<component v-if="comp" :is="comp" />
|
||||
<MarkNode :mark="unref(mark)" />
|
||||
<Node :id="id" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx">
|
||||
<script lang="tsx">
|
||||
import "components/common/features.css";
|
||||
import { Requirements } from "game/requirements";
|
||||
import { MaybeGetter } from "util/computed";
|
||||
import { render, Renderable } from "util/vue";
|
||||
import { Component, MaybeRef, Ref, unref } from "vue";
|
||||
import MarkNode from "components/MarkNode.vue";
|
||||
import Node from "components/Node.vue";
|
||||
import { isHidden, isVisible, jsx, Visibility } from "features/feature";
|
||||
import { displayRequirements, Requirements } 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";
|
||||
|
||||
const props = defineProps<{
|
||||
display?: MaybeGetter<Renderable>;
|
||||
earned: Ref<boolean>;
|
||||
requirements?: Requirements;
|
||||
image?: MaybeRef<string>;
|
||||
small?: MaybeRef<boolean>;
|
||||
}>();
|
||||
export default defineComponent({
|
||||
props: {
|
||||
visibility: {
|
||||
type: processedPropType<Visibility | boolean>(Number, Boolean),
|
||||
required: true
|
||||
},
|
||||
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 Component = () => props.display == null ? <></> : render(props.display);
|
||||
const comp = shallowRef<Component | string>("");
|
||||
|
||||
watchEffect(() => {
|
||||
const currDisplay = unwrapRef(display);
|
||||
if (currDisplay == null) {
|
||||
comp.value = "";
|
||||
return;
|
||||
}
|
||||
if (isCoercableComponent(currDisplay)) {
|
||||
comp.value = coerceComponent(currDisplay);
|
||||
return;
|
||||
}
|
||||
const Requirement = coerceComponent(currDisplay.requirement ? currDisplay.requirement : jsx(() => displayRequirements(unwrapRef(requirements) ?? [])), "h3");
|
||||
const EffectDisplay = coerceComponent(currDisplay.effectDisplay || "", "b");
|
||||
const OptionsDisplay = unwrapRef(earned) ?
|
||||
coerceComponent(currDisplay.optionsDisplay || "", "span") :
|
||||
"";
|
||||
comp.value = coerceComponent(
|
||||
jsx(() => (
|
||||
<span>
|
||||
<Requirement />
|
||||
{currDisplay.effectDisplay != null ? (
|
||||
<div>
|
||||
<EffectDisplay />
|
||||
</div>
|
||||
) : null}
|
||||
{currDisplay.optionsDisplay != null ? (
|
||||
<div class="equal-spaced">
|
||||
<OptionsDisplay />
|
||||
</div>
|
||||
) : null}
|
||||
</span>
|
||||
))
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
comp,
|
||||
unref,
|
||||
Visibility,
|
||||
isVisible,
|
||||
isHidden
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
@ -1,32 +1,39 @@
|
|||
import { computed } from "@vue/reactivity";
|
||||
import { isArray } from "@vue/shared";
|
||||
import Select from "components/fields/Select.vue";
|
||||
import { Visibility } from "features/feature";
|
||||
import AchievementComponent from "features/achievements/Achievement.vue";
|
||||
import { GenericDecorator } from "features/decorators/common";
|
||||
import {
|
||||
CoercableComponent,
|
||||
Component,
|
||||
GatherProps,
|
||||
GenericComponent,
|
||||
OptionsFunc,
|
||||
StyleValue,
|
||||
Visibility,
|
||||
getUniqueID,
|
||||
jsx,
|
||||
setDefault
|
||||
} from "features/feature";
|
||||
import { globalBus } from "game/events";
|
||||
import "game/notifications";
|
||||
import type { Persistent } from "game/persistence";
|
||||
import { persistent } from "game/persistence";
|
||||
import player from "game/player";
|
||||
import {
|
||||
Requirements,
|
||||
createBooleanRequirement,
|
||||
createVisibilityRequirement,
|
||||
displayRequirements,
|
||||
Requirements,
|
||||
requirementsMet
|
||||
} from "game/requirements";
|
||||
import settings, { registerSettingField } from "game/settings";
|
||||
import { camelToTitle } from "util/common";
|
||||
import { MaybeGetter, processGetter } from "util/computed";
|
||||
import { Computable, Defaults, ProcessedFeature, convertComputable } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import {
|
||||
isJSXElement,
|
||||
render,
|
||||
Renderable,
|
||||
VueFeature,
|
||||
vueFeatureMixin,
|
||||
VueFeatureOptions
|
||||
} from "util/vue";
|
||||
import { computed, MaybeRef, MaybeRefOrGetter, unref, watchEffect } from "vue";
|
||||
import { coerceComponent, isCoercableComponent } from "util/vue";
|
||||
import { ComputedRef, unref, watchEffect } from "vue";
|
||||
import { useToast } from "vue-toastification";
|
||||
import Achievement from "./Achievement.vue";
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
|
@ -45,177 +52,209 @@ export enum AchievementDisplay {
|
|||
/**
|
||||
* An object that configures an {@link Achievement}.
|
||||
*/
|
||||
export interface AchievementOptions extends VueFeatureOptions {
|
||||
/** The requirement(s) to earn this achievement. Can be left null if using {@link Achievement.complete}. */
|
||||
export interface AchievementOptions {
|
||||
/** Whether this achievement should be visible. */
|
||||
visibility?: Computable<Visibility | boolean>;
|
||||
/** The requirement(s) to earn this achievement. Can be left null if using {@link BaseAchievement.complete}. */
|
||||
requirements?: Requirements;
|
||||
/** The display to use for this achievement. */
|
||||
display?:
|
||||
| MaybeGetter<Renderable>
|
||||
display?: Computable<
|
||||
| CoercableComponent
|
||||
| {
|
||||
/** Description of the requirement(s) for this achievement. If unspecified then the requirements will be displayed automatically based on {@link requirements}. */
|
||||
requirement?: MaybeGetter<Renderable>;
|
||||
requirement?: CoercableComponent;
|
||||
/** Description of what will change (if anything) for achieving this. */
|
||||
effectDisplay?: MaybeGetter<Renderable>;
|
||||
effectDisplay?: CoercableComponent;
|
||||
/** Any additional things to display on this achievement, such as a toggle for it's effect. */
|
||||
optionsDisplay?: MaybeGetter<Renderable>;
|
||||
};
|
||||
optionsDisplay?: CoercableComponent;
|
||||
}
|
||||
>;
|
||||
/** Shows a marker on the corner of the feature. */
|
||||
mark?: Computable<boolean | string>;
|
||||
/** Toggles a smaller design for the feature. */
|
||||
small?: MaybeRefOrGetter<boolean>;
|
||||
small?: Computable<boolean>;
|
||||
/** An image to display as the background for this achievement. */
|
||||
image?: MaybeRefOrGetter<string>;
|
||||
image?: Computable<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. */
|
||||
showPopups?: MaybeRefOrGetter<boolean>;
|
||||
showPopups?: Computable<boolean>;
|
||||
/** A function that is called when the achievement is completed. */
|
||||
onComplete?: VoidFunction;
|
||||
}
|
||||
|
||||
/** An object that represents a feature with requirements that is passively earned upon meeting certain requirements. */
|
||||
export interface Achievement extends VueFeature {
|
||||
/** The requirement(s) to earn this achievement. */
|
||||
requirements?: Requirements;
|
||||
/** A function that is called when the achievement is completed. */
|
||||
onComplete?: VoidFunction;
|
||||
/** The display to use for this achievement. */
|
||||
display?: MaybeGetter<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>;
|
||||
/**
|
||||
* The properties that are added onto a processed {@link AchievementOptions} to create an {@link Achievement}.
|
||||
*/
|
||||
export interface BaseAchievement {
|
||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
||||
id: string;
|
||||
/** Whether this achievement should be visible. */
|
||||
visibility: ComputedRef<Visibility | boolean>;
|
||||
/** Whether or not this achievement has been earned. */
|
||||
earned: Persistent<boolean>;
|
||||
/** A function to complete this achievement. */
|
||||
complete: VoidFunction;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
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>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily creates an achievement with the given options.
|
||||
* @param optionsFunc Achievement options.
|
||||
*/
|
||||
export function createAchievement<T extends AchievementOptions>(optionsFunc?: () => T) {
|
||||
const earned = persistent<boolean>(false, false);
|
||||
return createLazyProxy(() => {
|
||||
const options = optionsFunc?.() ?? ({} as T);
|
||||
const {
|
||||
requirements,
|
||||
display: _display,
|
||||
small,
|
||||
image,
|
||||
showPopups,
|
||||
onComplete,
|
||||
...props
|
||||
} = options;
|
||||
|
||||
const vueFeature = vueFeatureMixin("achievement", options, () => (
|
||||
<Achievement
|
||||
display={achievement.display}
|
||||
earned={achievement.earned}
|
||||
requirements={achievement.requirements}
|
||||
image={achievement.image}
|
||||
small={achievement.small}
|
||||
/>
|
||||
));
|
||||
|
||||
let display: MaybeGetter<Renderable> | undefined = undefined;
|
||||
if (typeof _display === "object" && !isJSXElement(_display)) {
|
||||
const { requirement, effectDisplay, optionsDisplay } = _display;
|
||||
display = () => (
|
||||
<span>
|
||||
{requirement == null
|
||||
? displayRequirements(requirements ?? [])
|
||||
: render(requirement, el => <h3>{el}</h3>)}
|
||||
{effectDisplay == null ? null : (
|
||||
<div>
|
||||
{render(effectDisplay, el => (
|
||||
<b>{el}</b>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{optionsDisplay != null ? (
|
||||
<div class="equal-spaced">{render(optionsDisplay)}</div>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
} else if (_display != null) {
|
||||
display = _display;
|
||||
export type Achievement<T extends AchievementOptions> = BaseAchievement &
|
||||
ProcessedFeature<AchievementOptions, Exclude<T, BaseAchievement>> &
|
||||
Defaults<
|
||||
Exclude<T, BaseAchievement>,
|
||||
{
|
||||
showPopups: true;
|
||||
}
|
||||
>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type GenericAchievement = Achievement<any>;
|
||||
|
||||
export function createAchievement<T extends AchievementOptions>(
|
||||
optionsFunc?: OptionsFunc<T, GenericAchievement>,
|
||||
...decorators: GenericDecorator[]
|
||||
): Achievement<T> {
|
||||
const earned = persistent<boolean>(false, false);
|
||||
const decoratedData = decorators.reduce(
|
||||
(current, next) => Object.assign(current, next.getPersistentData?.()),
|
||||
{}
|
||||
);
|
||||
return createLazyProxy(feature => {
|
||||
const { visibility, display, mark, small, image, style, classes, showPopups, ...options } =
|
||||
optionsFunc?.call(feature, feature) ??
|
||||
({} as ReturnType<NonNullable<typeof optionsFunc>>);
|
||||
|
||||
const optionsVisibility = convertComputable(visibility, feature) ?? Visibility.Visible;
|
||||
const processedVisibility = computed(() => {
|
||||
const display = unref(achievement.display);
|
||||
switch (settings.msDisplay) {
|
||||
default:
|
||||
case AchievementDisplay.All:
|
||||
return unref(optionsVisibility);
|
||||
case AchievementDisplay.Configurable:
|
||||
if (
|
||||
unref(achievement.earned) &&
|
||||
!(
|
||||
display != null &&
|
||||
typeof display == "object" &&
|
||||
"optionsDisplay" in (display as Record<string, unknown>)
|
||||
)
|
||||
) {
|
||||
return Visibility.None;
|
||||
}
|
||||
return unref(optionsVisibility);
|
||||
case AchievementDisplay.Incomplete:
|
||||
if (unref(achievement.earned)) {
|
||||
return Visibility.None;
|
||||
}
|
||||
return unref(optionsVisibility);
|
||||
case AchievementDisplay.None:
|
||||
return Visibility.None;
|
||||
}
|
||||
});
|
||||
|
||||
const achievement = {
|
||||
type: AchievementType,
|
||||
...(props as Omit<typeof props, keyof VueFeature | keyof AchievementOptions>),
|
||||
...vueFeature,
|
||||
visibility: computed(() => {
|
||||
switch (settings.msDisplay) {
|
||||
default:
|
||||
case AchievementDisplay.All:
|
||||
return unref(vueFeature.visibility) ?? true;
|
||||
case AchievementDisplay.Configurable:
|
||||
if (
|
||||
unref(earned) &&
|
||||
!(
|
||||
_display != null &&
|
||||
typeof _display === "object" &&
|
||||
!isJSXElement(_display)
|
||||
)
|
||||
) {
|
||||
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;
|
||||
}
|
||||
}),
|
||||
id: getUniqueID("achievement-"),
|
||||
visibility: processedVisibility,
|
||||
earned,
|
||||
onComplete,
|
||||
small: processGetter(small),
|
||||
image: processGetter(image),
|
||||
showPopups: processGetter(showPopups) ?? true,
|
||||
display,
|
||||
requirements:
|
||||
requirements == null
|
||||
? undefined
|
||||
: [
|
||||
createVisibilityRequirement(vueFeature.visibility ?? true),
|
||||
createBooleanRequirement(() => !earned.value),
|
||||
...(Array.isArray(requirements) ? requirements : [requirements])
|
||||
],
|
||||
complete() {
|
||||
if (earned.value) {
|
||||
return;
|
||||
complete,
|
||||
type: AchievementType,
|
||||
[Component]: AchievementComponent as GenericComponent,
|
||||
[GatherProps]: gatherProps,
|
||||
display: convertComputable(display, feature),
|
||||
mark: convertComputable(mark, feature),
|
||||
small: convertComputable(small, feature),
|
||||
image: convertComputable(image, feature),
|
||||
style: convertComputable(style, feature),
|
||||
classes: convertComputable(classes, feature),
|
||||
showPopups: convertComputable(showPopups, feature) ?? true,
|
||||
...options
|
||||
} satisfies Partial<Achievement<T>>;
|
||||
|
||||
for (const decorator of decorators) {
|
||||
decorator.preConstruct?.(achievement);
|
||||
}
|
||||
Object.assign(achievement, decoratedData);
|
||||
for (const decorator of decorators) {
|
||||
decorator.postConstruct?.(achievement);
|
||||
}
|
||||
const decoratedProps = decorators.reduce(
|
||||
(current, next) => Object.assign(current, next.getGatheredProps?.(achievement)),
|
||||
{}
|
||||
);
|
||||
|
||||
function complete() {
|
||||
earned.value = true;
|
||||
achievement.onComplete?.();
|
||||
if (achievement.display != null && unref(achievement.showPopups) === true) {
|
||||
const display = unref(achievement.display);
|
||||
let Display;
|
||||
if (isCoercableComponent(display)) {
|
||||
Display = coerceComponent(display);
|
||||
} else if (display.requirement != null) {
|
||||
Display = coerceComponent(display.requirement);
|
||||
} else {
|
||||
Display = displayRequirements(achievement.requirements ?? []);
|
||||
}
|
||||
earned.value = true;
|
||||
achievement.onComplete?.();
|
||||
if (achievement.display != null && unref(achievement.showPopups) === true) {
|
||||
let display = achievement.display;
|
||||
if (typeof _display === "object" && !isJSXElement(_display)) {
|
||||
if (_display.requirement != null) {
|
||||
display = _display.requirement;
|
||||
} else {
|
||||
display = displayRequirements(requirements ?? []);
|
||||
}
|
||||
}
|
||||
toast.info(
|
||||
toast.info(
|
||||
<div>
|
||||
<h3>Achievement earned!</h3>
|
||||
<div>
|
||||
<h3>Achievement earned!</h3>
|
||||
<div>{render(display)}</div>
|
||||
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
|
||||
{/* @ts-ignore */}
|
||||
<Display />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} satisfies Achievement;
|
||||
}
|
||||
|
||||
function gatherProps(this: GenericAchievement): Record<string, unknown> {
|
||||
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 != null) {
|
||||
const requirements = [
|
||||
createVisibilityRequirement(achievement),
|
||||
createBooleanRequirement(() => !earned.value),
|
||||
...(isArray(achievement.requirements)
|
||||
? achievement.requirements
|
||||
: [achievement.requirements])
|
||||
];
|
||||
watchEffect(() => {
|
||||
if (settings.active !== player.id) return;
|
||||
if (requirementsMet(achievement.requirements ?? [])) {
|
||||
if (requirementsMet(requirements)) {
|
||||
achievement.complete();
|
||||
}
|
||||
});
|
||||
|
@ -225,6 +264,26 @@ export function createAchievement<T extends AchievementOptions>(optionsFunc?: ()
|
|||
});
|
||||
}
|
||||
|
||||
const ach = createAchievement(ach => ({
|
||||
image: "",
|
||||
showPopups: computed(() => false),
|
||||
small: () => true,
|
||||
foo: "bar",
|
||||
bar: () => "foo"
|
||||
}));
|
||||
ach;
|
||||
ach.image; // string
|
||||
ach.showPopups; // ComputedRef<false>
|
||||
ach.small; // ComputedRef<true>
|
||||
ach.foo; // "bar"
|
||||
ach.bar; // () => "foo"
|
||||
ach.mark; // TS should yell about this not existing (or at least mark it undefined)
|
||||
ach.visibility; // ComputedRef<Visibility | boolean>
|
||||
|
||||
const badAch = createAchievement(() => ({
|
||||
requirements: "foo"
|
||||
}));
|
||||
|
||||
declare module "game/settings" {
|
||||
interface Settings {
|
||||
msDisplay: AchievementDisplay;
|
||||
|
@ -232,7 +291,7 @@ declare module "game/settings" {
|
|||
}
|
||||
|
||||
globalBus.on("loadSettings", settings => {
|
||||
settings.msDisplay ??= AchievementDisplay.All;
|
||||
setDefault(settings, "msDisplay", AchievementDisplay.All);
|
||||
});
|
||||
|
||||
const msDisplayOptions = Object.values(AchievementDisplay).map(option => ({
|
||||
|
@ -240,15 +299,15 @@ const msDisplayOptions = Object.values(AchievementDisplay).map(option => ({
|
|||
value: option
|
||||
}));
|
||||
|
||||
globalBus.on("setupVue", () =>
|
||||
registerSettingField(() => (
|
||||
registerSettingField(
|
||||
jsx(() => (
|
||||
<Select
|
||||
title={
|
||||
title={jsx(() => (
|
||||
<span class="option-title">
|
||||
Show achievements
|
||||
<desc>Select which achievements to display based on criterias.</desc>
|
||||
</span>
|
||||
}
|
||||
))}
|
||||
options={msDisplayOptions}
|
||||
onUpdate:modelValue={value => (settings.msDisplay = value as AchievementDisplay)}
|
||||
modelValue={settings.msDisplay}
|
||||
|
|
293
src/features/action.tsx
Normal file
293
src/features/action.tsx
Normal file
|
@ -0,0 +1,293 @@
|
|||
import { isArray } from "@vue/shared";
|
||||
import ClickableComponent from "features/clickables/Clickable.vue";
|
||||
import {
|
||||
Component,
|
||||
findFeatures,
|
||||
GatherProps,
|
||||
GenericComponent,
|
||||
getUniqueID,
|
||||
jsx,
|
||||
JSXFunction,
|
||||
OptionsFunc,
|
||||
Replace,
|
||||
setDefault,
|
||||
StyleValue,
|
||||
Visibility
|
||||
} from "features/feature";
|
||||
import { globalBus } from "game/events";
|
||||
import { persistent } from "game/persistence";
|
||||
import Decimal, { DecimalSource } from "lib/break_eternity";
|
||||
import { Unsubscribe } from "nanoevents";
|
||||
import { Direction } from "util/common";
|
||||
import type {
|
||||
Computable,
|
||||
GetComputableType,
|
||||
GetComputableTypeWithDefault,
|
||||
ProcessedComputable
|
||||
} from "util/computed";
|
||||
import { processComputable } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import { coerceComponent, isCoercableComponent, render } from "util/vue";
|
||||
import { computed, Ref, ref, unref } from "vue";
|
||||
import { BarOptions, createBar, GenericBar } from "./bars/bar";
|
||||
import { ClickableOptions } from "./clickables/clickable";
|
||||
import { Decorator, GenericDecorator } from "./decorators/common";
|
||||
|
||||
/** A symbol used to identify {@link Action} features. */
|
||||
export const ActionType = Symbol("Action");
|
||||
|
||||
/**
|
||||
* An object that configures an {@link Action}.
|
||||
*/
|
||||
export interface ActionOptions extends Omit<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,
|
||||
style: "margin-top: 8px",
|
||||
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,10 +1,18 @@
|
|||
<template>
|
||||
<div
|
||||
:style="{
|
||||
width: unref(width) + 'px',
|
||||
height: unref(height) + 'px',
|
||||
v-if="isVisible(visibility)"
|
||||
:style="[
|
||||
{
|
||||
width: unref(width) + 'px',
|
||||
height: unref(height) + 'px',
|
||||
visibility: isHidden(visibility) ? 'hidden' : undefined
|
||||
},
|
||||
unref(style) ?? {}
|
||||
]"
|
||||
:class="{
|
||||
bar: true,
|
||||
...unref(classes)
|
||||
}"
|
||||
class="bar"
|
||||
>
|
||||
<div
|
||||
class="overlayTextContainer border"
|
||||
|
@ -13,79 +21,127 @@
|
|||
unref(borderStyle) ?? {}
|
||||
]"
|
||||
>
|
||||
<span v-if="display" class="overlayText" :style="unref(textStyle)">
|
||||
<Component />
|
||||
<span v-if="component" class="overlayText" :style="unref(textStyle)">
|
||||
<component :is="component" />
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="border"
|
||||
:style="[
|
||||
{ width: unref(width) + 'px', height: unref(height) + 'px' },
|
||||
unref(style) ?? {},
|
||||
unref(baseStyle) ?? {},
|
||||
unref(borderStyle) ?? {}
|
||||
]"
|
||||
>
|
||||
<div class="fill" :style="[barStyle, unref(fillStyle) ?? {}]" />
|
||||
<div class="fill" :style="[barStyle, unref(style) ?? {}, unref(fillStyle) ?? {}]" />
|
||||
</div>
|
||||
<MarkNode :mark="unref(mark)" />
|
||||
<Node :id="id" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Decimal, { DecimalSource } from "util/bignum";
|
||||
<script 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 { Direction } from "util/common";
|
||||
import { MaybeGetter } from "util/computed";
|
||||
import { render, Renderable } from "util/vue";
|
||||
import type { CSSProperties, MaybeRef } from "vue";
|
||||
import { computed, unref } from "vue";
|
||||
import { computeOptionalComponent, processedPropType, unwrapRef } from "util/vue";
|
||||
import type { CSSProperties, StyleValue } from "vue";
|
||||
import { computed, defineComponent, toRefs, unref } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
width: MaybeRef<number>;
|
||||
height: MaybeRef<number>;
|
||||
direction: MaybeRef<Direction>;
|
||||
borderStyle?: MaybeRef<CSSProperties>;
|
||||
baseStyle?: MaybeRef<CSSProperties>;
|
||||
textStyle?: MaybeRef<CSSProperties>;
|
||||
fillStyle?: MaybeRef<CSSProperties>;
|
||||
progress: MaybeRef<DecimalSource>;
|
||||
display?: MaybeGetter<Renderable>;
|
||||
}>();
|
||||
export default defineComponent({
|
||||
props: {
|
||||
progress: {
|
||||
type: processedPropType<DecimalSource>(String, Object, Number),
|
||||
required: true
|
||||
},
|
||||
width: {
|
||||
type: processedPropType<number>(Number),
|
||||
required: true
|
||||
},
|
||||
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(() => {
|
||||
let progressNumber =
|
||||
props.progress instanceof Decimal
|
||||
? props.progress.toNumber()
|
||||
: Number(props.progress);
|
||||
return (1 - Math.min(Math.max(progressNumber, 0), 1)) * 100;
|
||||
});
|
||||
const normalizedProgress = computed(() => {
|
||||
let progressNumber =
|
||||
progress.value instanceof Decimal
|
||||
? progress.value.toNumber()
|
||||
: Number(progress.value);
|
||||
return (1 - Math.min(Math.max(progressNumber, 0), 1)) * 100;
|
||||
});
|
||||
|
||||
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;
|
||||
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
|
||||
};
|
||||
}
|
||||
return barStyle;
|
||||
});
|
||||
|
||||
const Component = () => props.display ? render(props.display) : null;
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
185
src/features/bars/bar.ts
Normal file
185
src/features/bars/bar.ts
Normal file
|
@ -0,0 +1,185 @@
|
|||
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>;
|
||||
});
|
||||
}
|
|
@ -1,109 +0,0 @@
|
|||
import Bar from "features/bars/Bar.vue";
|
||||
import type { DecimalSource } from "util/bignum";
|
||||
import { Direction } from "util/common";
|
||||
import { MaybeGetter, 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?: MaybeGetter<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?: MaybeGetter<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
|
||||
} satisfies Bar;
|
||||
|
||||
return bar;
|
||||
});
|
||||
}
|
279
src/features/boards/Board.vue
Normal file
279
src/features/boards/Board.vue
Normal file
|
@ -0,0 +1,279 @@
|
|||
<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))"
|
||||
>
|
||||
<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" />
|
||||
</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="hasDragged"
|
||||
:receivingNode="unref(receivingNode)?.id === node.id"
|
||||
:selectedNode="unref(selectedNode)"
|
||||
:selectedAction="unref(selectedAction)"
|
||||
@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.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) {
|
||||
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) {
|
||||
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>
|
63
src/features/boards/BoardLink.vue
Normal file
63
src/features/boards/BoardLink.vue
Normal file
|
@ -0,0 +1,63 @@
|
|||
<template>
|
||||
<line
|
||||
class="link"
|
||||
v-bind="link"
|
||||
:class="{ pulsing: link.pulsing }"
|
||||
:x1="startPosition.x"
|
||||
:y1="startPosition.y"
|
||||
:x2="endPosition.x"
|
||||
:y2="endPosition.y"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { BoardNodeLink } from "features/boards/board";
|
||||
import { computed, toRefs, unref } from "vue";
|
||||
|
||||
const _props = defineProps<{
|
||||
link: BoardNodeLink;
|
||||
}>();
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
return position;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.link {
|
||||
transition-duration: 0s;
|
||||
}
|
||||
|
||||
.link.pulsing {
|
||||
animation: pulsing 2s ease-in infinite;
|
||||
}
|
||||
|
||||
@keyframes pulsing {
|
||||
0% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
</style>
|
340
src/features/boards/BoardNode.vue
Normal file
340
src/features/boards/BoardNode.vue
Normal file
|
@ -0,0 +1,340 @@
|
|||
<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;
|
||||
selectedNode: BoardNode | null;
|
||||
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 isSelected = computed(() => unref(props.selectedNode) === unref(props.node));
|
||||
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(
|
||||
() =>
|
||||
(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>
|
109
src/features/boards/BoardNodeAction.vue
Normal file
109
src/features/boards/BoardNodeAction.vue
Normal file
|
@ -0,0 +1,109 @@
|
|||
<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>
|
631
src/features/boards/board.ts
Normal file
631
src/features/boards/board.ts
Normal file
|
@ -0,0 +1,631 @@
|
|||
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. */
|
||||
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,71 +1,178 @@
|
|||
<template>
|
||||
<div
|
||||
:style="notifyStyle"
|
||||
v-if="isVisible(visibility)"
|
||||
:style="[
|
||||
{
|
||||
visibility: isHidden(visibility) ? 'hidden' : undefined
|
||||
},
|
||||
notifyStyle,
|
||||
unref(style) ?? {}
|
||||
]"
|
||||
:class="{
|
||||
feature: true,
|
||||
challenge: true,
|
||||
done: unref(completed),
|
||||
canStart: unref(canStart) && !unref(maxed),
|
||||
maxed: unref(maxed)
|
||||
maxed: unref(maxed),
|
||||
...unref(classes)
|
||||
}"
|
||||
>
|
||||
<button
|
||||
class="toggleChallenge"
|
||||
@click="emits('toggle')"
|
||||
@click="toggle"
|
||||
:disabled="!unref(canStart) || unref(maxed)"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</button>
|
||||
<Component v-if="props.display" />
|
||||
<component v-if="unref(comp)" :is="unref(comp)" />
|
||||
<MarkNode :mark="unref(mark)" />
|
||||
<Node :id="id" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx">
|
||||
<script lang="tsx">
|
||||
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 { Requirements } from "game/requirements";
|
||||
import { DecimalSource } from "util/bignum";
|
||||
import { MaybeGetter } from "util/computed";
|
||||
import { render, Renderable } from "util/vue";
|
||||
import type { Component, MaybeRef, Ref } from "vue";
|
||||
import { computed, unref } from "vue";
|
||||
import { displayRequirements, Requirements } from "game/requirements";
|
||||
import { coerceComponent, isCoercableComponent, processedPropType, unwrapRef } from "util/vue";
|
||||
import type { Component, PropType, UnwrapRef } from "vue";
|
||||
import { computed, defineComponent, shallowRef, toRefs, unref, watchEffect } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
active: Ref<boolean>;
|
||||
maxed: Ref<boolean>;
|
||||
canComplete: Ref<DecimalSource>;
|
||||
display?: MaybeGetter<Renderable>;
|
||||
requirements: Requirements;
|
||||
completed: Ref<boolean>;
|
||||
canStart?: MaybeRef<boolean>;
|
||||
}>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: "toggle"): void;
|
||||
}>();
|
||||
|
||||
const buttonText = computed(() => {
|
||||
if (unref(props.active)) {
|
||||
return unref(props.canComplete) ? "Finish" : "Exit Early";
|
||||
}
|
||||
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();
|
||||
export default defineComponent({
|
||||
props: {
|
||||
active: {
|
||||
type: processedPropType<boolean>(Boolean),
|
||||
required: true
|
||||
},
|
||||
maxed: {
|
||||
type: processedPropType<boolean>(Boolean),
|
||||
required: true
|
||||
},
|
||||
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
|
||||
}
|
||||
return getNotifyStyle();
|
||||
}
|
||||
return {};
|
||||
});
|
||||
},
|
||||
components: {
|
||||
MarkNode,
|
||||
Node
|
||||
},
|
||||
setup(props) {
|
||||
const { active, maxed, canComplete, display, requirements } = toRefs(props);
|
||||
|
||||
const Component = () => props.display == null ? <></> : render(props.display);
|
||||
const buttonText = computed(() => {
|
||||
if (active.value) {
|
||||
return canComplete.value ? "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
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
@ -1,26 +1,41 @@
|
|||
import { isArray } from "@vue/shared";
|
||||
import Toggle from "components/fields/Toggle.vue";
|
||||
import { isVisible } from "features/feature";
|
||||
import type { Reset } from "features/reset";
|
||||
import ChallengeComponent from "features/challenges/Challenge.vue";
|
||||
import { GenericDecorator } from "features/decorators/common";
|
||||
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 type { Persistent } from "game/persistence";
|
||||
import { persistent } from "game/persistence";
|
||||
import { Requirements, displayRequirements, maxRequirementsMet } from "game/requirements";
|
||||
import { Requirements, maxRequirementsMet } from "game/requirements";
|
||||
import settings, { registerSettingField } from "game/settings";
|
||||
import type { DecimalSource } from "util/bignum";
|
||||
import Decimal from "util/bignum";
|
||||
import { MaybeGetter, processGetter } from "util/computed";
|
||||
import type {
|
||||
Computable,
|
||||
GetComputableType,
|
||||
GetComputableTypeWithDefault,
|
||||
ProcessedComputable
|
||||
} from "util/computed";
|
||||
import { processComputable } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import {
|
||||
Renderable,
|
||||
VueFeature,
|
||||
VueFeatureOptions,
|
||||
isJSXElement,
|
||||
render,
|
||||
vueFeatureMixin
|
||||
} from "util/vue";
|
||||
import type { MaybeRef, MaybeRefOrGetter, Ref, WatchStopHandle } from "vue";
|
||||
import type { Ref, WatchStopHandle } from "vue";
|
||||
import { computed, unref, watch } from "vue";
|
||||
import Challenge from "./Challenge.vue";
|
||||
|
||||
/** A symbol used to identify {@link Challenge} features. */
|
||||
export const ChallengeType = Symbol("Challenge");
|
||||
|
@ -28,30 +43,41 @@ export const ChallengeType = Symbol("Challenge");
|
|||
/**
|
||||
* An object that configures a {@link Challenge}.
|
||||
*/
|
||||
export interface ChallengeOptions extends VueFeatureOptions {
|
||||
export interface ChallengeOptions {
|
||||
/** Whether this challenge should be visible. */
|
||||
visibility?: Computable<Visibility | boolean>;
|
||||
/** Whether this challenge can be started. */
|
||||
canStart?: MaybeRefOrGetter<boolean>;
|
||||
canStart?: Computable<boolean>;
|
||||
/** The reset function for this challenge. */
|
||||
reset?: Reset;
|
||||
reset?: GenericReset;
|
||||
/** The requirement(s) to complete this challenge. */
|
||||
requirements: Requirements;
|
||||
/** Whether or not completing this challenge should grant multiple completions if requirements met. Requires {@link requirements} to be a requirement or array of requirements with {@link Requirement.canMaximize} true. */
|
||||
maximize?: Computable<boolean>;
|
||||
/** The maximum number of times the challenge can be completed. */
|
||||
completionLimit?: MaybeRefOrGetter<DecimalSource>;
|
||||
completionLimit?: Computable<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. */
|
||||
display?:
|
||||
| MaybeGetter<Renderable>
|
||||
display?: Computable<
|
||||
| CoercableComponent
|
||||
| {
|
||||
/** A header to appear at the top of the display. */
|
||||
title?: MaybeGetter<Renderable>;
|
||||
title?: CoercableComponent;
|
||||
/** The main text that appears in the display. */
|
||||
description: MaybeGetter<Renderable>;
|
||||
description: CoercableComponent;
|
||||
/** A description of the current goal for this challenge. If unspecified then the requirements will be displayed automatically based on {@link requirements}. */
|
||||
goal?: MaybeGetter<Renderable>;
|
||||
goal?: CoercableComponent;
|
||||
/** A description of what will change upon completing this challenge. */
|
||||
reward?: MaybeGetter<Renderable>;
|
||||
reward?: CoercableComponent;
|
||||
/** A description of the current effect of this challenge. */
|
||||
effectDisplay?: MaybeGetter<Renderable>;
|
||||
};
|
||||
effectDisplay?: CoercableComponent;
|
||||
}
|
||||
>;
|
||||
/** A function that is called when the challenge is completed. */
|
||||
onComplete?: VoidFunction;
|
||||
/** A function that is called when the challenge is exited. */
|
||||
|
@ -60,24 +86,12 @@ export interface ChallengeOptions extends VueFeatureOptions {
|
|||
onEnter?: VoidFunction;
|
||||
}
|
||||
|
||||
/** An object that represents a feature that can be entered and exited, and have one or more completions with scaling requirements. */
|
||||
export interface Challenge extends VueFeature {
|
||||
/** The reset function for this challenge. */
|
||||
reset?: Reset;
|
||||
/** The requirement(s) to complete this challenge. */
|
||||
requirements: Requirements;
|
||||
/** A function that is called when the challenge is completed. */
|
||||
onComplete?: VoidFunction;
|
||||
/** A function that is called when the challenge is exited. */
|
||||
onExit?: VoidFunction;
|
||||
/** A function that is called when the challenge is entered. */
|
||||
onEnter?: VoidFunction;
|
||||
/** Whether this challenge can be started. */
|
||||
canStart?: MaybeRef<boolean>;
|
||||
/** The maximum number of times the challenge can be completed. */
|
||||
completionLimit?: MaybeRef<DecimalSource>;
|
||||
/** The display to use for this challenge. */
|
||||
display?: MaybeGetter<Renderable>;
|
||||
/**
|
||||
* The properties that are added onto a processed {@link ChallengeOptions} to create a {@link Challenge}.
|
||||
*/
|
||||
export interface BaseChallenge {
|
||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
||||
id: string;
|
||||
/** The current amount of times this challenge can be completed. */
|
||||
canComplete: Ref<DecimalSource>;
|
||||
/** The current number of times this challenge has been completed. */
|
||||
|
@ -97,152 +111,208 @@ export interface Challenge extends VueFeature {
|
|||
complete: (remainInChallenge?: boolean) => void;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
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"]>;
|
||||
maximize: GetComputableType<T["maximize"]>;
|
||||
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.
|
||||
* @param optionsFunc Challenge options.
|
||||
*/
|
||||
export function createChallenge<T extends ChallengeOptions>(optionsFunc: () => T) {
|
||||
const completions = persistent<DecimalSource>(0);
|
||||
const active = persistent<boolean>(false, false);
|
||||
return createLazyProxy(() => {
|
||||
const options = optionsFunc();
|
||||
const {
|
||||
requirements,
|
||||
canStart,
|
||||
completionLimit,
|
||||
display: _display,
|
||||
reset,
|
||||
onComplete,
|
||||
onEnter,
|
||||
onExit,
|
||||
...props
|
||||
} = options;
|
||||
export function createChallenge<T extends ChallengeOptions>(
|
||||
optionsFunc: OptionsFunc<T, BaseChallenge, GenericChallenge>,
|
||||
...decorators: GenericDecorator[]
|
||||
): Challenge<T> {
|
||||
const completions = persistent(0);
|
||||
const active = persistent(false, false);
|
||||
const decoratedData = decorators.reduce(
|
||||
(current, next) => Object.assign(current, next.getPersistentData?.()),
|
||||
{}
|
||||
);
|
||||
return createLazyProxy(feature => {
|
||||
const challenge = optionsFunc.call(feature, feature);
|
||||
|
||||
const vueFeature = vueFeatureMixin("challenge", options, () => (
|
||||
<Challenge
|
||||
active={challenge.active}
|
||||
maxed={challenge.maxed}
|
||||
canComplete={challenge.canComplete}
|
||||
display={challenge.display}
|
||||
requirements={challenge.requirements}
|
||||
completed={challenge.completed}
|
||||
canStart={challenge.canStart}
|
||||
onToggle={challenge.toggle}
|
||||
/>
|
||||
));
|
||||
challenge.id = getUniqueID("challenge-");
|
||||
challenge.type = ChallengeType;
|
||||
challenge[Component] = ChallengeComponent as GenericComponent;
|
||||
|
||||
let display: MaybeGetter<Renderable> | undefined = undefined;
|
||||
if (typeof _display === "object" && !isJSXElement(_display)) {
|
||||
const { title, description, goal, reward, effectDisplay } = _display;
|
||||
display = () => (
|
||||
<span>
|
||||
{title != null ? (
|
||||
<div>
|
||||
{render(title, el => (
|
||||
<h3>{el}</h3>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{render(description, el => (
|
||||
<div>{el}</div>
|
||||
))}
|
||||
<div>
|
||||
<br />
|
||||
Goal:{" "}
|
||||
{goal == null
|
||||
? displayRequirements(challenge.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>
|
||||
);
|
||||
} else if (_display != null) {
|
||||
display = _display;
|
||||
for (const decorator of decorators) {
|
||||
decorator.preConstruct?.(challenge);
|
||||
}
|
||||
|
||||
const challenge = {
|
||||
type: ChallengeType,
|
||||
...(props as Omit<typeof props, keyof VueFeature | keyof ChallengeOptions>),
|
||||
...vueFeature,
|
||||
completions,
|
||||
active,
|
||||
completed: computed(() => Decimal.gt(completions.value, 0)),
|
||||
canComplete: computed(() => maxRequirementsMet(requirements)),
|
||||
maxed: computed((): boolean =>
|
||||
Decimal.gte(completions.value, unref(challenge.completionLimit))
|
||||
),
|
||||
canStart: processGetter(canStart) ?? true,
|
||||
completionLimit: processGetter(completionLimit) ?? 1,
|
||||
requirements,
|
||||
reset,
|
||||
onComplete,
|
||||
onEnter,
|
||||
onExit,
|
||||
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)
|
||||
) {
|
||||
challenge.reset?.reset();
|
||||
active.value = true;
|
||||
onEnter?.();
|
||||
}
|
||||
},
|
||||
complete: function (remainInChallenge?: boolean) {
|
||||
const newCompletions = unref(challenge.canComplete);
|
||||
challenge.completions = completions;
|
||||
challenge.active = active;
|
||||
Object.assign(challenge, decoratedData);
|
||||
|
||||
challenge.completed = computed(() =>
|
||||
Decimal.gt((challenge as GenericChallenge).completions.value, 0)
|
||||
);
|
||||
challenge.maxed = computed(() =>
|
||||
Decimal.gte(
|
||||
(challenge as GenericChallenge).completions.value,
|
||||
unref((challenge as GenericChallenge).completionLimit)
|
||||
)
|
||||
);
|
||||
challenge.toggle = function () {
|
||||
const genericChallenge = challenge as GenericChallenge;
|
||||
if (genericChallenge.active.value) {
|
||||
if (
|
||||
active.value &&
|
||||
Decimal.gt(newCompletions, 0) &&
|
||||
!unref<boolean>(challenge.maxed)
|
||||
Decimal.gt(unref(genericChallenge.canComplete), 0) &&
|
||||
!genericChallenge.maxed.value
|
||||
) {
|
||||
completions.value = Decimal.min(
|
||||
Decimal.add(challenge.completions.value, newCompletions),
|
||||
unref(challenge.completionLimit)
|
||||
const completions = unref(genericChallenge.canComplete);
|
||||
genericChallenge.completions.value = Decimal.min(
|
||||
Decimal.add(genericChallenge.completions.value, completions),
|
||||
unref(genericChallenge.completionLimit)
|
||||
);
|
||||
onComplete?.();
|
||||
if (remainInChallenge !== true) {
|
||||
active.value = false;
|
||||
onExit?.();
|
||||
reset?.reset();
|
||||
}
|
||||
genericChallenge.onComplete?.();
|
||||
}
|
||||
genericChallenge.active.value = false;
|
||||
genericChallenge.onExit?.();
|
||||
genericChallenge.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(() =>
|
||||
Decimal.max(
|
||||
maxRequirementsMet((challenge as GenericChallenge).requirements),
|
||||
unref((challenge as GenericChallenge).maximize) ? Decimal.dInf : 1
|
||||
)
|
||||
);
|
||||
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, "maximize");
|
||||
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) {
|
||||
globalBus.on("reset", currentReset => {
|
||||
if (currentReset === challenge.reset && active.value) {
|
||||
challenge.toggle();
|
||||
if (currentReset === challenge.reset && (challenge.active as Ref<boolean>).value) {
|
||||
(challenge.toggle as VoidFunction)();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return challenge;
|
||||
for (const decorator of decorators) {
|
||||
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>;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -253,8 +323,8 @@ export function createChallenge<T extends ChallengeOptions>(optionsFunc: () => T
|
|||
* @param exitOnComplete Whether or not to exit the challenge after auto-completion
|
||||
*/
|
||||
export function setupAutoComplete(
|
||||
challenge: Challenge,
|
||||
autoActive: MaybeRefOrGetter<boolean> = true,
|
||||
challenge: GenericChallenge,
|
||||
autoActive: Computable<boolean> = true,
|
||||
exitOnComplete = true
|
||||
): WatchStopHandle {
|
||||
const isActive = typeof autoActive === "function" ? computed(autoActive) : autoActive;
|
||||
|
@ -272,7 +342,9 @@ 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)
|
||||
* @param challenges The list of challenges that are mutually exclusive
|
||||
*/
|
||||
export function createActiveChallenge(challenges: Challenge[]): Ref<Challenge | null> {
|
||||
export function createActiveChallenge(
|
||||
challenges: GenericChallenge[]
|
||||
): Ref<GenericChallenge | null> {
|
||||
return computed(() => challenges.find(challenge => challenge.active.value) ?? null);
|
||||
}
|
||||
|
||||
|
@ -281,12 +353,12 @@ export function createActiveChallenge(challenges: Challenge[]): Ref<Challenge |
|
|||
* @param challenges List of challenges that are mutually exclusive
|
||||
*/
|
||||
export function isAnyChallengeActive(
|
||||
challenges: Challenge[] | Ref<Challenge | null>
|
||||
challenges: GenericChallenge[] | Ref<GenericChallenge | null>
|
||||
): Ref<boolean> {
|
||||
if (Array.isArray(challenges)) {
|
||||
if (isArray(challenges)) {
|
||||
challenges = createActiveChallenge(challenges);
|
||||
}
|
||||
return computed(() => (challenges as Ref<Challenge | null>).value != null);
|
||||
return computed(() => (challenges as Ref<GenericChallenge | null>).value != null);
|
||||
}
|
||||
|
||||
declare module "game/settings" {
|
||||
|
@ -296,18 +368,18 @@ declare module "game/settings" {
|
|||
}
|
||||
|
||||
globalBus.on("loadSettings", settings => {
|
||||
settings.hideChallenges ??= false;
|
||||
setDefault(settings, "hideChallenges", false);
|
||||
});
|
||||
|
||||
globalBus.on("setupVue", () =>
|
||||
registerSettingField(() => (
|
||||
registerSettingField(
|
||||
jsx(() => (
|
||||
<Toggle
|
||||
title={
|
||||
title={jsx(() => (
|
||||
<span class="option-title">
|
||||
Hide maxed challenges
|
||||
<desc>Hide challenges that have been fully completed.</desc>
|
||||
</span>
|
||||
}
|
||||
))}
|
||||
onUpdate:modelValue={value => (settings.hideChallenges = value)}
|
||||
modelValue={settings.hideChallenges}
|
||||
/>
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
<template>
|
||||
<button
|
||||
@click="e => emits('click', e)"
|
||||
v-if="isVisible(visibility)"
|
||||
:style="[
|
||||
{ visibility: isHidden(visibility) ? 'hidden' : undefined },
|
||||
unref(style) ?? []
|
||||
]"
|
||||
@click="onClick"
|
||||
@mousedown="start"
|
||||
@mouseleave="stop"
|
||||
@mouseup="stop"
|
||||
|
@ -8,40 +13,114 @@
|
|||
@touchend.passive="stop"
|
||||
@touchcancel.passive="stop"
|
||||
:class="{
|
||||
feature: true,
|
||||
clickable: true,
|
||||
can: unref(canClick),
|
||||
locked: !unref(canClick)
|
||||
locked: !unref(canClick),
|
||||
small,
|
||||
...unref(classes)
|
||||
}"
|
||||
:disabled="!unref(canClick)"
|
||||
>
|
||||
<Component />
|
||||
<component v-if="unref(comp)" :is="unref(comp)" />
|
||||
<MarkNode :mark="unref(mark)" />
|
||||
<Node :id="id" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx">
|
||||
<script lang="tsx">
|
||||
import "components/common/features.css";
|
||||
import { MaybeGetter } from "util/computed";
|
||||
import MarkNode from "components/MarkNode.vue";
|
||||
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 {
|
||||
render,
|
||||
Renderable,
|
||||
setupHoldToClick
|
||||
coerceComponent,
|
||||
isCoercableComponent,
|
||||
processedPropType,
|
||||
setupHoldToClick,
|
||||
unwrapRef
|
||||
} from "util/vue";
|
||||
import type { Component, MaybeRef } from "vue";
|
||||
import { unref } from "vue";
|
||||
import type { Component, PropType, UnwrapRef } from "vue";
|
||||
import { defineComponent, shallowRef, toRefs, unref, watchEffect } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
canClick: MaybeRef<boolean>;
|
||||
display?: MaybeGetter<Renderable>;
|
||||
}>();
|
||||
export default defineComponent({
|
||||
props: {
|
||||
display: {
|
||||
type: processedPropType<UnwrapRef<GenericClickable["display"]>>(
|
||||
Object,
|
||||
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 emits = defineEmits<{
|
||||
(e: "click", event?: MouseEvent | TouchEvent): void;
|
||||
(e: "hold"): void;
|
||||
}>();
|
||||
const comp = shallowRef<Component | string>("");
|
||||
|
||||
const Component = () => props.display == null ? <></> : render(props.display);
|
||||
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");
|
||||
comp.value = coerceComponent(
|
||||
jsx(() => (
|
||||
<span>
|
||||
{currDisplay.title != null ? (
|
||||
<div>
|
||||
<Title />
|
||||
</div>
|
||||
) : null}
|
||||
<Description />
|
||||
</span>
|
||||
))
|
||||
);
|
||||
});
|
||||
|
||||
const { start, stop } = setupHoldToClick(() => emits("hold"));
|
||||
const { start, stop } = setupHoldToClick(onClick, onHold);
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
comp,
|
||||
Visibility,
|
||||
isVisible,
|
||||
isHidden,
|
||||
unref
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
@ -51,6 +130,10 @@ const { start, stop } = setupHoldToClick(() => emits("hold"));
|
|||
font-size: 10px;
|
||||
}
|
||||
|
||||
.clickable.small {
|
||||
min-height: unset;
|
||||
}
|
||||
|
||||
.clickable > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
|
|
@ -1,186 +0,0 @@
|
|||
import ClickableVue from "features/clickables/Clickable.vue";
|
||||
import { findFeatures } from "features/feature";
|
||||
import { globalBus } from "game/events";
|
||||
import { persistent } from "game/persistence";
|
||||
import { Unsubscribe } from "nanoevents";
|
||||
import Decimal, { DecimalSource } from "util/bignum";
|
||||
import { Direction } from "util/common";
|
||||
import { MaybeGetter, processGetter } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import { isJSXElement, render, Renderable, VueFeature, vueFeatureMixin } from "util/vue";
|
||||
import { computed, MaybeRef, MaybeRefOrGetter, Ref, ref, unref } from "vue";
|
||||
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?: MaybeGetter<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: _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 display: MaybeGetter<Renderable>;
|
||||
if (typeof _display === "object" && !isJSXElement(_display)) {
|
||||
display = () => (
|
||||
<span>
|
||||
{_display.title != null ? (
|
||||
<div>
|
||||
{render(_display.title, el => (
|
||||
<h3>{el}</h3>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{render(_display.description, el => (
|
||||
<div>{el}</div>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
} else if (_display != null) {
|
||||
display = _display;
|
||||
}
|
||||
|
||||
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}
|
||||
onHold={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: () => (
|
||||
<>
|
||||
<div style="flex-grow: 1" />
|
||||
{display == null ? null : render(display)}
|
||||
<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;
|
||||
});
|
204
src/features/clickables/clickable.ts
Normal file
204
src/features/clickables/clickable.ts
Normal file
|
@ -0,0 +1,204 @@
|
|||
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?.();
|
||||
}
|
||||
});
|
||||
}
|
|
@ -1,136 +0,0 @@
|
|||
import Clickable from "features/clickables/Clickable.vue";
|
||||
import type { BaseLayer } from "game/layers";
|
||||
import type { Unsubscribe } from "nanoevents";
|
||||
import { MaybeGetter, processGetter } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import {
|
||||
isJSXElement,
|
||||
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?:
|
||||
| MaybeGetter<Renderable>
|
||||
| {
|
||||
/** A header to appear at the top of the display. */
|
||||
title?: MaybeGetter<Renderable>;
|
||||
/** The main text that appears in the display. */
|
||||
description: MaybeGetter<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?: MaybeGetter<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: MaybeGetter<Renderable> | undefined = undefined;
|
||||
if (typeof _display === "object" && !isJSXElement(_display)) {
|
||||
display = () => (
|
||||
<span>
|
||||
{_display.title != null ? (
|
||||
<div>
|
||||
{render(_display.title, el => (
|
||||
<h3>{el}</h3>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{render(_display.description, el => (
|
||||
<div>{el}</div>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
} else if (_display != null) {
|
||||
display = _display;
|
||||
}
|
||||
|
||||
const clickable = {
|
||||
type: ClickableType,
|
||||
...(props as Omit<typeof props, keyof VueFeature | keyof ClickableOptions>),
|
||||
...vueFeatureMixin("clickable", options, () => (
|
||||
<Clickable
|
||||
canClick={clickable.canClick}
|
||||
onClick={clickable.onClick}
|
||||
onHold={clickable.onClick}
|
||||
display={clickable.display}
|
||||
/>
|
||||
)),
|
||||
canClick: processGetter(canClick) ?? true,
|
||||
display,
|
||||
onClick:
|
||||
onClick == null
|
||||
? undefined
|
||||
: function (e?: MouseEvent | TouchEvent) {
|
||||
if (unref(clickable.canClick) !== false) {
|
||||
onClick.call(clickable, e);
|
||||
}
|
||||
},
|
||||
onHold:
|
||||
onHold == null
|
||||
? undefined
|
||||
: function () {
|
||||
if (unref(clickable.canClick) !== false) {
|
||||
onHold.call(clickable);
|
||||
}
|
||||
}
|
||||
} satisfies Clickable;
|
||||
|
||||
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?.();
|
||||
}
|
||||
});
|
||||
}
|
|
@ -1,197 +0,0 @@
|
|||
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 { MaybeGetter, 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, 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?:
|
||||
| MaybeGetter<Renderable>
|
||||
| {
|
||||
/** A header to appear at the top of the display. */
|
||||
title?: MaybeGetter<Renderable>;
|
||||
/** The main text that appears in the display. */
|
||||
description: MaybeGetter<Renderable>;
|
||||
/** A description of the current effect of this repeatable, based off its amount. */
|
||||
effectDisplay?: MaybeGetter<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?: MaybeGetter<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}
|
||||
onHold={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" && !isJSXElement(_display)) {
|
||||
const { title, description, effectDisplay, showAmount } = _display;
|
||||
|
||||
display = () => (
|
||||
<span>
|
||||
{title == null ? null : (
|
||||
<div>
|
||||
{render(title, el => (
|
||||
<h3>{el}</h3>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{render(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: {render(effectDisplay)}
|
||||
</div>
|
||||
)}
|
||||
{unref(repeatable.maxed) ? null : (
|
||||
<div>
|
||||
<br />
|
||||
{displayRequirements(requirements, unref(repeatable.amountToIncrease))}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
} else if (_display != null) {
|
||||
display = _display;
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
|
@ -1,174 +0,0 @@
|
|||
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 { MaybeGetter, processGetter } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import {
|
||||
Renderable,
|
||||
VueFeature,
|
||||
VueFeatureOptions,
|
||||
isJSXElement,
|
||||
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?:
|
||||
| MaybeGetter<Renderable>
|
||||
| {
|
||||
/** A header to appear at the top of the display. */
|
||||
title?: MaybeGetter<Renderable>;
|
||||
/** The main text that appears in the display. */
|
||||
description: MaybeGetter<Renderable>;
|
||||
/** A description of the current effect of the achievement. Useful when the effect changes dynamically. */
|
||||
effectDisplay?: MaybeGetter<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?: MaybeGetter<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;
|
||||
if (typeof _display === "object" && !isJSXElement(_display)) {
|
||||
const { title, description, effectDisplay } = _display;
|
||||
|
||||
display = () => (
|
||||
<span>
|
||||
{title != null ? (
|
||||
<div>
|
||||
{render(title, el => (
|
||||
<h3>{el}</h3>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{render(description, el => (
|
||||
<div>{el}</div>
|
||||
))}
|
||||
{effectDisplay != null ? <div>Currently: {render(effectDisplay)}</div> : null}
|
||||
{bought.value ? null : (
|
||||
<>
|
||||
<br />
|
||||
{displayRequirements(requirements)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
} else if (_display != null) {
|
||||
display = _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,17 +1,17 @@
|
|||
import type { OptionsFunc, Replace } from "features/feature";
|
||||
import { setDefault } from "features/feature";
|
||||
import type { Resource } from "features/resources/resource";
|
||||
import Formula from "game/formulas/formulas";
|
||||
import { InvertibleFormula, InvertibleIntegralFormula } from "game/formulas/types";
|
||||
import type { BaseLayer } from "game/layers";
|
||||
import { createBooleanRequirement } from "game/requirements";
|
||||
import type { DecimalSource } from "util/bignum";
|
||||
import Decimal from "util/bignum";
|
||||
import { MaybeGetter, processGetter } from "util/computed";
|
||||
import type { Computable, GetComputableTypeWithDefault, ProcessedComputable } from "util/computed";
|
||||
import { convertComputable, processComputable } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import { Renderable } from "util/vue";
|
||||
import { computed, MaybeRef, MaybeRefOrGetter, unref } from "vue";
|
||||
|
||||
/** A symbol used to identify {@link Conversion} features. */
|
||||
export const ConversionType = Symbol("Conversion");
|
||||
import type { Ref } from "vue";
|
||||
import { computed, unref } from "vue";
|
||||
import { GenericDecorator } from "./decorators/common";
|
||||
|
||||
/** An object that configures a {@link Conversion}. */
|
||||
export interface ConversionOptions {
|
||||
|
@ -24,24 +24,24 @@ export interface ConversionOptions {
|
|||
* How much of the output resource the conversion can currently convert for.
|
||||
* Typically this will be set for you in a conversion constructor.
|
||||
*/
|
||||
currentGain?: MaybeRefOrGetter<DecimalSource>;
|
||||
currentGain?: Computable<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?: MaybeRefOrGetter<DecimalSource>;
|
||||
actualGain?: Computable<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?: MaybeRefOrGetter<DecimalSource>;
|
||||
currentAt?: Computable<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?: MaybeRefOrGetter<DecimalSource>;
|
||||
nextAt?: Computable<DecimalSource>;
|
||||
/**
|
||||
* The input {@link features/resources/resource.Resource} for this conversion.
|
||||
*/
|
||||
|
@ -54,7 +54,7 @@ export interface ConversionOptions {
|
|||
* Whether or not to cap the amount of the output resource gained by converting at 1.
|
||||
* Defaults to true.
|
||||
*/
|
||||
buyMax?: MaybeRefOrGetter<boolean>;
|
||||
buyMax?: Computable<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.
|
||||
|
@ -76,64 +76,40 @@ export interface ConversionOptions {
|
|||
/**
|
||||
* The properties that are added onto a processed {@link ConversionOptions} to create a {@link Conversion}.
|
||||
*/
|
||||
export interface Conversion {
|
||||
export interface BaseConversion {
|
||||
/**
|
||||
* 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.
|
||||
* The function that performs the actual conversion.
|
||||
*/
|
||||
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.
|
||||
* You typically shouldn't use this function directly. Instead use one of the other conversion constructors, which will then call this.
|
||||
|
@ -141,77 +117,81 @@ export interface Conversion {
|
|||
* @see {@link createCumulativeConversion}.
|
||||
* @see {@link createIndependentConversion}.
|
||||
*/
|
||||
export function createConversion<T extends ConversionOptions>(optionsFunc: () => T) {
|
||||
return createLazyProxy(() => {
|
||||
const options = optionsFunc();
|
||||
const {
|
||||
baseResource,
|
||||
gainResource,
|
||||
formula,
|
||||
currentGain: _currentGain,
|
||||
actualGain,
|
||||
currentAt,
|
||||
nextAt,
|
||||
convert,
|
||||
spend,
|
||||
buyMax,
|
||||
onConvert,
|
||||
...props
|
||||
} = options;
|
||||
export function createConversion<T extends ConversionOptions>(
|
||||
optionsFunc: OptionsFunc<T, BaseConversion, GenericConversion>,
|
||||
...decorators: GenericDecorator[]
|
||||
): Conversion<T> {
|
||||
return createLazyProxy(feature => {
|
||||
const conversion = optionsFunc.call(feature, feature);
|
||||
|
||||
const currentGain =
|
||||
_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);
|
||||
for (const decorator of decorators) {
|
||||
decorator.preConstruct?.(conversion);
|
||||
}
|
||||
|
||||
const conversion = {
|
||||
type: ConversionType,
|
||||
...(props as Omit<typeof props, keyof ConversionOptions>),
|
||||
baseResource,
|
||||
gainResource,
|
||||
formula: formula(Formula.variable(baseResource)),
|
||||
currentGain,
|
||||
actualGain: actualGain == null ? currentGain : processGetter(actualGain),
|
||||
currentAt:
|
||||
currentAt == null
|
||||
? computed(
|
||||
(): DecimalSource =>
|
||||
conversion.formula.invert(
|
||||
Decimal.floor(unref(conversion.currentGain))
|
||||
)
|
||||
)
|
||||
: processGetter(currentAt),
|
||||
nextAt:
|
||||
nextAt == null
|
||||
? computed(
|
||||
(): DecimalSource =>
|
||||
conversion.formula.invert(
|
||||
Decimal.floor(unref(conversion.currentGain)).add(1)
|
||||
)
|
||||
)
|
||||
: processGetter(nextAt),
|
||||
convert:
|
||||
convert ??
|
||||
function () {
|
||||
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;
|
||||
(conversion as GenericConversion).formula = conversion.formula(
|
||||
Formula.variable(conversion.baseResource)
|
||||
);
|
||||
if (conversion.currentGain == null) {
|
||||
conversion.currentGain = computed(() => {
|
||||
let gain = Decimal.floor(
|
||||
(conversion as GenericConversion).formula.evaluate(
|
||||
conversion.baseResource.value
|
||||
)
|
||||
).max(0);
|
||||
if (unref(conversion.buyMax) === false) {
|
||||
gain = gain.min(1);
|
||||
}
|
||||
return gain;
|
||||
});
|
||||
}
|
||||
if (conversion.actualGain == null) {
|
||||
conversion.actualGain = conversion.currentGain;
|
||||
}
|
||||
if (conversion.currentAt == null) {
|
||||
conversion.currentAt = computed(() => {
|
||||
return (conversion as GenericConversion).formula.invert(
|
||||
Decimal.floor(unref((conversion as GenericConversion).currentGain))
|
||||
);
|
||||
});
|
||||
}
|
||||
if (conversion.nextAt == null) {
|
||||
conversion.nextAt = computed(() => {
|
||||
return (conversion as GenericConversion).formula.invert(
|
||||
Decimal.floor(unref((conversion as GenericConversion).currentGain)).add(1)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return conversion;
|
||||
if (conversion.convert == null) {
|
||||
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>;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -221,7 +201,9 @@ export function createConversion<T extends ConversionOptions>(optionsFunc: () =>
|
|||
* This is equivalent to just calling createConversion directly.
|
||||
* @param optionsFunc Conversion options.
|
||||
*/
|
||||
export function createCumulativeConversion<T extends ConversionOptions>(optionsFunc: () => T) {
|
||||
export function createCumulativeConversion<S extends ConversionOptions>(
|
||||
optionsFunc: OptionsFunc<S, BaseConversion, GenericConversion>
|
||||
): Conversion<S> {
|
||||
return createConversion(optionsFunc);
|
||||
}
|
||||
|
||||
|
@ -230,46 +212,55 @@ export function createCumulativeConversion<T extends ConversionOptions>(optionsF
|
|||
* This is similar to the behavior of "static" layers in The Modding Tree.
|
||||
* @param optionsFunc Converison options.
|
||||
*/
|
||||
export function createIndependentConversion<T extends ConversionOptions>(optionsFunc: () => T) {
|
||||
const conversion = createConversion(() => {
|
||||
const options = optionsFunc();
|
||||
export function createIndependentConversion<S extends ConversionOptions>(
|
||||
optionsFunc: OptionsFunc<S, BaseConversion, GenericConversion>
|
||||
): Conversion<S> {
|
||||
return createConversion(feature => {
|
||||
const conversion: S = optionsFunc.call(feature, feature);
|
||||
|
||||
options.buyMax ??= false;
|
||||
setDefault(conversion, "buyMax", false);
|
||||
|
||||
options.currentGain ??= computed(() => {
|
||||
let gain = Decimal.floor(conversion.formula.evaluate(options.baseResource.value)).max(
|
||||
options.gainResource.value
|
||||
if (conversion.currentGain == null) {
|
||||
conversion.currentGain = computed(() => {
|
||||
let gain = Decimal.floor(
|
||||
(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
|
||||
);
|
||||
if (unref(options.buyMax as MaybeRef<boolean>) === false) {
|
||||
gain = gain.min(Decimal.add(options.gainResource.value, 1));
|
||||
}
|
||||
return gain;
|
||||
});
|
||||
|
||||
options.actualGain ??= computed(() => {
|
||||
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 as unknown as GenericConversion).spend(amountGained);
|
||||
conversion.onConvert?.(amountGained);
|
||||
};
|
||||
});
|
||||
|
||||
return options;
|
||||
});
|
||||
return conversion;
|
||||
return conversion;
|
||||
}) as Conversion<S>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -283,12 +274,12 @@ export function createIndependentConversion<T extends ConversionOptions>(options
|
|||
*/
|
||||
export function setupPassiveGeneration(
|
||||
layer: BaseLayer,
|
||||
conversion: Conversion,
|
||||
rate: MaybeRefOrGetter<DecimalSource> = 1,
|
||||
cap: MaybeRefOrGetter<DecimalSource> = Decimal.dInf
|
||||
conversion: GenericConversion,
|
||||
rate: Computable<DecimalSource> = 1,
|
||||
cap: Computable<DecimalSource> = Decimal.dInf
|
||||
): void {
|
||||
const processedRate = processGetter(rate);
|
||||
const processedCap = processGetter(cap);
|
||||
const processedRate = convertComputable(rate);
|
||||
const processedCap = convertComputable(cap);
|
||||
layer.on("preUpdate", diff => {
|
||||
const currRate = unref(processedRate);
|
||||
if (Decimal.neq(currRate, 0)) {
|
||||
|
@ -301,20 +292,3 @@ export function setupPassiveGeneration(
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates requirement that is met when the conversion hits a specified gain amount
|
||||
* @param conversion The conversion to check the gain amount of
|
||||
* @param minGainAmount The minimum gain amount that must be met for the requirement to be met
|
||||
*/
|
||||
export function createCanConvertRequirement(
|
||||
conversion: Conversion,
|
||||
minGainAmount: MaybeRefOrGetter<DecimalSource> = 1,
|
||||
display?: MaybeGetter<Renderable>
|
||||
) {
|
||||
const computedMinGainAmount = processGetter(minGainAmount);
|
||||
return createBooleanRequirement(
|
||||
() => Decimal.gte(unref(conversion.actualGain), unref(computedMinGainAmount)),
|
||||
display
|
||||
);
|
||||
}
|
||||
|
|
119
src/features/decorators/bonusDecorator.ts
Normal file
119
src/features/decorators/bonusDecorator.ts
Normal file
|
@ -0,0 +1,119 @@
|
|||
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
|
||||
> = {
|
||||
preConstruct(feature) {
|
||||
if (feature.amount === undefined) {
|
||||
console.error(
|
||||
`Decorated feature ${feature.id} does not contain the required 'amount' property"`
|
||||
);
|
||||
}
|
||||
},
|
||||
postConstruct(feature) {
|
||||
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>)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
59
src/features/decorators/common.ts
Normal file
59
src/features/decorators/common.ts
Normal file
|
@ -0,0 +1,59 @@
|
|||
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 {
|
||||
effect: Computable<unknown>;
|
||||
}
|
||||
|
||||
export type EffectFeature<T extends EffectFeatureOptions> = Replace<
|
||||
T,
|
||||
{ effect: GetComputableType<T["effect"]> }
|
||||
>;
|
||||
|
||||
export type GenericEffectFeature = Replace<
|
||||
EffectFeature<EffectFeatureOptions>,
|
||||
{ effect: ProcessedComputable<unknown> }
|
||||
>;
|
||||
|
||||
/**
|
||||
* 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,6 +1,50 @@
|
|||
import Decimal from "util/bignum";
|
||||
import { Renderable, renderCol, VueFeature } from "util/vue";
|
||||
import { computed, isRef, MaybeRef, Ref, unref } from "vue";
|
||||
import { DoNotCache, ProcessedComputable } from "util/computed";
|
||||
import type { CSSProperties, DefineComponent } 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> = (this: R, obj: R) => OptionsObject<T, R>;
|
||||
|
||||
export type OptionsObject<T, R = unknown> = Exclude<T, R> & Partial<R>;
|
||||
|
||||
let id = 0;
|
||||
/**
|
||||
|
@ -23,37 +67,34 @@ export enum Visibility {
|
|||
None
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function for determining if a visibility value is anything but Visibility.None.
|
||||
Booleans are allowed and false will be considered to be Visibility.None.
|
||||
* @param visibility The ref to either a visibility value or boolean
|
||||
* @returns True if the visibility is either true, Visibility.Visible, or Visibility.Hidden
|
||||
*/
|
||||
export function isVisible(visibility: MaybeRef<Visibility | boolean>) {
|
||||
export function isVisible(visibility: ProcessedComputable<Visibility | boolean>) {
|
||||
const currVisibility = unref(visibility);
|
||||
return currVisibility !== Visibility.None && currVisibility !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function for determining if a visibility value is Visibility.Hidden.
|
||||
Booleans are allowed but will never be considered to be Visible.Hidden.
|
||||
* @param visibility The ref to either a visibility value or boolean
|
||||
* @returns True if the visibility is Visibility.Hidden
|
||||
*/
|
||||
export function isHidden(visibility: MaybeRef<Visibility | boolean>) {
|
||||
export function isHidden(visibility: ProcessedComputable<Visibility | boolean>) {
|
||||
const currVisibility = unref(visibility);
|
||||
return currVisibility === Visibility.Hidden;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function for narrowing something that may or may not be a specified type of feature.
|
||||
* Works off the principle that all features have a unique symbol to identify themselves with.
|
||||
* @param object The object to determine whether or not is of the specified type
|
||||
* @param type The symbol to look for in the object's "type" property
|
||||
* @returns Whether or not the object is the specified type
|
||||
* Takes a function and marks it as JSX so it won't get auto-wrapped into a ComputedRef.
|
||||
* The function may also return empty string as empty JSX tags cause issues.
|
||||
*/
|
||||
export function isType<T extends symbol>(object: unknown, type: T): object is { type: T } {
|
||||
return object != null && typeof object === "object" && "type" in object && object.type === type;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -61,17 +102,14 @@ export function isType<T extends symbol>(object: unknown, type: T): object is {
|
|||
* @param obj The object to traverse
|
||||
* @param types The feature types that will be searched for
|
||||
*/
|
||||
export function findFeatures(obj: object, ...types: symbol[]): unknown[] {
|
||||
export function findFeatures(obj: Record<string, unknown>, ...types: symbol[]): unknown[] {
|
||||
const objects: unknown[] = [];
|
||||
const handleObject = (obj: object) => {
|
||||
const handleObject = (obj: Record<string, unknown>) => {
|
||||
Object.keys(obj).forEach(key => {
|
||||
const value: unknown = obj[key as keyof typeof obj];
|
||||
if (
|
||||
value != null &&
|
||||
typeof value === "object" &&
|
||||
(value as Record<string, unknown>).__v_isVNode !== true
|
||||
) {
|
||||
if (types.includes((value as Record<string, unknown>).type as symbol)) {
|
||||
const value = obj[key];
|
||||
if (value != null && typeof value === "object") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (types.includes((value as Record<string, any>).type)) {
|
||||
objects.push(value);
|
||||
} else if (!(value instanceof Decimal) && !isRef(value)) {
|
||||
handleObject(value as Record<string, unknown>);
|
||||
|
@ -83,30 +121,6 @@ export function findFeatures(obj: object, ...types: symbol[]): unknown[] {
|
|||
return objects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function for taking a list of features and filtering them out, but keeping a reference to the first filtered out feature. Used for having a collapsible of the filtered out content, with the first filtered out item remaining outside the collapsible for easy reference.
|
||||
* @param features The list of features to search through
|
||||
* @param filter The filter to use to determine features that shouldn't be collapsible
|
||||
* @returns An object containing a ref to the first filtered _out_ feature, a render function for the collapsed content, and a ref for whether or not there is any collapsed content to show
|
||||
*/
|
||||
export function getFirstFeature<T extends VueFeature>(
|
||||
features: T[],
|
||||
filter: (feature: T) => boolean
|
||||
): {
|
||||
firstFeature: Ref<T | undefined>;
|
||||
collapsedContent: () => Renderable;
|
||||
hasCollapsedContent: Ref<boolean>;
|
||||
} {
|
||||
const filteredFeatures = computed(() =>
|
||||
features.filter(feature => isVisible(feature.visibility ?? true) && filter(feature))
|
||||
);
|
||||
return {
|
||||
firstFeature: computed(() => filteredFeatures.value[0]),
|
||||
collapsedContent: () => 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.
|
||||
* Features are any object with a "type" property that has a symbol value.
|
||||
|
@ -118,13 +132,13 @@ export function excludeFeatures(obj: Record<string, unknown>, ...types: symbol[]
|
|||
const handleObject = (obj: Record<string, unknown>) => {
|
||||
Object.keys(obj).forEach(key => {
|
||||
const value = obj[key];
|
||||
if (
|
||||
value != null &&
|
||||
typeof value === "object" &&
|
||||
(value as Record<string, unknown>).__v_isVNode !== true
|
||||
) {
|
||||
const type = (value as Record<string, unknown>).type;
|
||||
if (typeof type === "symbol" && !types.includes(type)) {
|
||||
if (value != null && typeof value === "object") {
|
||||
if (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
typeof (value as Record<string, any>).type == "symbol" &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
!types.includes((value as Record<string, any>).type)
|
||||
) {
|
||||
objects.push(value);
|
||||
} else if (!(value instanceof Decimal) && !isRef(value)) {
|
||||
handleObject(value as Record<string, unknown>);
|
||||
|
|
60
src/features/grids/Grid.vue
Normal file
60
src/features/grids/Grid.vue
Normal file
|
@ -0,0 +1,60 @@
|
|||
<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>
|
98
src/features/grids/GridCell.vue
Normal file
98
src/features/grids/GridCell.vue
Normal file
|
@ -0,0 +1,98 @@
|
|||
<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>
|
370
src/features/grids/grid.ts
Normal file
370
src/features/grids/grid.ts
Normal file
|
@ -0,0 +1,370 @@
|
|||
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>;
|
||||
});
|
||||
}
|
|
@ -1,15 +1,22 @@
|
|||
import Hotkey from "components/Hotkey.vue";
|
||||
import { hasWon } from "data/projEntry";
|
||||
import { findFeatures } from "features/feature";
|
||||
import type { OptionsFunc, Replace } from "features/feature";
|
||||
import { findFeatures, jsx, setDefault } from "features/feature";
|
||||
import { globalBus } from "game/events";
|
||||
import player from "game/player";
|
||||
import { registerInfoComponent } from "game/settings";
|
||||
import { processGetter } from "util/computed";
|
||||
import type {
|
||||
Computable,
|
||||
GetComputableType,
|
||||
GetComputableTypeWithDefault,
|
||||
ProcessedComputable
|
||||
} from "util/computed";
|
||||
import { processComputable } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import { MaybeRef, MaybeRefOrGetter, shallowReactive, unref } from "vue";
|
||||
import { shallowReactive, unref } from "vue";
|
||||
import Hotkey from "components/Hotkey.vue";
|
||||
|
||||
/** A dictionary of all hotkeys. */
|
||||
export const hotkeys: Record<string, Hotkey | undefined> = shallowReactive({});
|
||||
export const hotkeys: Record<string, GenericHotkey | undefined> = shallowReactive({});
|
||||
/** A symbol used to identify {@link Hotkey} features. */
|
||||
export const HotkeyType = Symbol("Hotkey");
|
||||
|
||||
|
@ -18,61 +25,69 @@ export const HotkeyType = Symbol("Hotkey");
|
|||
*/
|
||||
export interface HotkeyOptions {
|
||||
/** Whether or not this hotkey is currently enabled. */
|
||||
enabled?: MaybeRefOrGetter<boolean>;
|
||||
enabled?: Computable<boolean>;
|
||||
/** The key tied to this hotkey */
|
||||
key: string;
|
||||
/** The description of this hotkey, to display in the settings. */
|
||||
description: MaybeRefOrGetter<string>;
|
||||
description: Computable<string>;
|
||||
/** What to do upon pressing the key. */
|
||||
onPress: (e?: MouseEvent | TouchEvent) => void;
|
||||
onPress: VoidFunction;
|
||||
}
|
||||
|
||||
/** An object that represents a hotkey shortcut that performs an action upon a key sequence being pressed. */
|
||||
export interface Hotkey {
|
||||
/** Whether or not this hotkey is currently enabled. */
|
||||
enabled: MaybeRef<boolean>;
|
||||
/** The key tied to this hotkey */
|
||||
key: string;
|
||||
/** The description of this hotkey, to display in the settings. */
|
||||
description: MaybeRef<string>;
|
||||
/** What to do upon pressing the key. */
|
||||
onPress: (e?: MouseEvent | TouchEvent) => void;
|
||||
/**
|
||||
* 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. */
|
||||
export type Hotkey<T extends HotkeyOptions> = Replace<
|
||||
T & BaseHotkey,
|
||||
{
|
||||
enabled: GetComputableTypeWithDefault<T["enabled"], true>;
|
||||
description: GetComputableType<T["description"]>;
|
||||
}
|
||||
>;
|
||||
|
||||
/** A type that matches any valid {@link Hotkey} object. */
|
||||
export type GenericHotkey = Replace<
|
||||
Hotkey<HotkeyOptions>,
|
||||
{
|
||||
enabled: ProcessedComputable<boolean>;
|
||||
}
|
||||
>;
|
||||
|
||||
const uppercaseNumbers = [")", "!", "@", "#", "$", "%", "^", "&", "*", "("];
|
||||
|
||||
/**
|
||||
* Lazily creates a hotkey with the given options.
|
||||
* @param optionsFunc Hotkey options.
|
||||
*/
|
||||
export function createHotkey<T extends HotkeyOptions>(optionsFunc: () => T) {
|
||||
return createLazyProxy(() => {
|
||||
const options = optionsFunc();
|
||||
const { enabled, description, key, onPress, ...props } = options;
|
||||
export function createHotkey<T extends HotkeyOptions>(
|
||||
optionsFunc: OptionsFunc<T, BaseHotkey, GenericHotkey>
|
||||
): Hotkey<T> {
|
||||
return createLazyProxy(feature => {
|
||||
const hotkey = optionsFunc.call(feature, feature);
|
||||
hotkey.type = HotkeyType;
|
||||
|
||||
const hotkey = {
|
||||
type: HotkeyType,
|
||||
...(props as Omit<typeof props, keyof HotkeyOptions>),
|
||||
enabled: processGetter(enabled) ?? true,
|
||||
description: processGetter(description),
|
||||
key,
|
||||
onPress
|
||||
} satisfies Hotkey;
|
||||
processComputable(hotkey as T, "enabled");
|
||||
setDefault(hotkey, "enabled", true);
|
||||
processComputable(hotkey as T, "description");
|
||||
|
||||
return hotkey;
|
||||
return hotkey as unknown as Hotkey<T>;
|
||||
});
|
||||
}
|
||||
|
||||
globalBus.on("addLayer", layer => {
|
||||
(findFeatures(layer, HotkeyType) as Hotkey[]).forEach(hotkey => {
|
||||
(findFeatures(layer, HotkeyType) as GenericHotkey[]).forEach(hotkey => {
|
||||
hotkeys[hotkey.key] = hotkey;
|
||||
});
|
||||
});
|
||||
|
||||
globalBus.on("removeLayer", layer => {
|
||||
(findFeatures(layer, HotkeyType) as Hotkey[]).forEach(hotkey => {
|
||||
(findFeatures(layer, HotkeyType) as GenericHotkey[]).forEach(hotkey => {
|
||||
hotkeys[hotkey.key] = undefined;
|
||||
});
|
||||
});
|
||||
|
@ -84,38 +99,24 @@ document.onkeydown = function (e) {
|
|||
if (hasWon.value && !player.keepGoing) {
|
||||
return;
|
||||
}
|
||||
const keysToCheck: string[] = [e.key];
|
||||
if (e.shiftKey && e.ctrlKey) {
|
||||
keysToCheck.splice(0, 1);
|
||||
keysToCheck.push("ctrl+shift+" + e.key.toUpperCase());
|
||||
keysToCheck.push("shift+ctrl+" + e.key.toUpperCase());
|
||||
if (uppercaseNumbers.includes(e.key)) {
|
||||
keysToCheck.push("ctrl+shift+" + uppercaseNumbers.indexOf(e.key));
|
||||
keysToCheck.push("shift+ctrl+" + uppercaseNumbers.indexOf(e.key));
|
||||
} else {
|
||||
keysToCheck.push("ctrl+shift+" + e.key.toLowerCase());
|
||||
keysToCheck.push("shift+ctrl+" + e.key.toLowerCase());
|
||||
}
|
||||
} else if (uppercaseNumbers.includes(e.key)) {
|
||||
keysToCheck.push("shift+" + e.key);
|
||||
keysToCheck.push("shift+" + uppercaseNumbers.indexOf(e.key));
|
||||
let key = e.key;
|
||||
if (uppercaseNumbers.includes(key)) {
|
||||
key = "shift+" + uppercaseNumbers.indexOf(key);
|
||||
} else if (e.shiftKey) {
|
||||
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);
|
||||
key = "shift+" + key;
|
||||
}
|
||||
const hotkey = hotkeys[keysToCheck.find(key => key in hotkeys) ?? ""];
|
||||
if (hotkey != null && unref(hotkey.enabled) !== false) {
|
||||
if (e.ctrlKey) {
|
||||
key = "ctrl+" + key;
|
||||
}
|
||||
const hotkey = hotkeys[key];
|
||||
if (hotkey && unref(hotkey.enabled)) {
|
||||
e.preventDefault();
|
||||
hotkey.onPress();
|
||||
}
|
||||
};
|
||||
|
||||
globalBus.on("setupVue", () =>
|
||||
registerInfoComponent(() => {
|
||||
registerInfoComponent(
|
||||
jsx(() => {
|
||||
const keys = Object.values(hotkeys).filter(hotkey => unref(hotkey?.enabled));
|
||||
if (keys.length === 0) {
|
||||
return "";
|
||||
|
@ -127,7 +128,7 @@ globalBus.on("setupVue", () =>
|
|||
<div style="column-count: 2">
|
||||
{keys.map(hotkey => (
|
||||
<div>
|
||||
<Hotkey hotkey={hotkey as Hotkey} /> {unref(hotkey?.description)}
|
||||
<Hotkey hotkey={hotkey as GenericHotkey} /> {hotkey?.description}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
@ -1,10 +1,15 @@
|
|||
<template>
|
||||
<div
|
||||
class="infobox"
|
||||
:style="{
|
||||
v-if="isVisible(visibility)"
|
||||
:style="[
|
||||
{
|
||||
borderColor: unref(color),
|
||||
}"
|
||||
:class="{ collapsed: unref(collapsed), stacked }"
|
||||
visibility: isHidden(visibility) ? 'hidden' : undefined
|
||||
},
|
||||
unref(style) ?? {}
|
||||
]"
|
||||
:class="{ collapsed: unref(collapsed), stacked, ...unref(classes) }"
|
||||
>
|
||||
<button
|
||||
class="title"
|
||||
|
@ -12,37 +17,78 @@
|
|||
@click="collapsed.value = !unref(collapsed)"
|
||||
>
|
||||
<span class="toggle">▼</span>
|
||||
<Title />
|
||||
<component :is="titleComponent" />
|
||||
</button>
|
||||
<CollapseTransition>
|
||||
<div v-if="!unref(collapsed)" class="body" :style="unref(bodyStyle)">
|
||||
<Body />
|
||||
<div v-if="!unref(collapsed)" class="body" :style="{ backgroundColor: unref(color) }">
|
||||
<component :is="bodyComponent" :style="unref(bodyStyle)" />
|
||||
</div>
|
||||
</CollapseTransition>
|
||||
<Node :id="id" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import CollapseTransition from "@ivanv/vue-collapse-transition/src/CollapseTransition.vue";
|
||||
import Node from "components/Node.vue";
|
||||
import themes from "data/themes";
|
||||
import type { CoercableComponent } from "features/feature";
|
||||
import { isHidden, isVisible, Visibility } from "features/feature";
|
||||
import settings from "game/settings";
|
||||
import { MaybeGetter } from "util/computed";
|
||||
import { render, Renderable } from "util/vue";
|
||||
import { computed, CSSProperties, MaybeRef, Ref, unref } from "vue";
|
||||
import { computeComponent, processedPropType } from "util/vue";
|
||||
import type { PropType, Ref, StyleValue } from "vue";
|
||||
import { computed, defineComponent, toRefs, unref } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
color?: MaybeRef<string>;
|
||||
titleStyle?: MaybeRef<CSSProperties>;
|
||||
bodyStyle?: MaybeRef<CSSProperties>;
|
||||
collapsed: Ref<boolean>;
|
||||
display: MaybeGetter<Renderable>;
|
||||
title: MaybeGetter<Renderable>;
|
||||
}>();
|
||||
export default defineComponent({
|
||||
props: {
|
||||
visibility: {
|
||||
type: processedPropType<Visibility | boolean>(Number, Boolean),
|
||||
required: true
|
||||
},
|
||||
display: {
|
||||
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 Title = () => render(props.title);
|
||||
const Body = () => render(props.display);
|
||||
const titleComponent = computeComponent(title);
|
||||
const bodyComponent = computeComponent(display);
|
||||
const stacked = computed(() => themes[settings.theme].mergeAdjacent);
|
||||
|
||||
const stacked = computed(() => themes[settings.theme].mergeAdjacent);
|
||||
return {
|
||||
titleComponent,
|
||||
bodyComponent,
|
||||
stacked,
|
||||
unref,
|
||||
Visibility,
|
||||
isVisible,
|
||||
isHidden
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
@ -79,8 +125,6 @@ const stacked = computed(() => themes[settings.theme].mergeAdjacent);
|
|||
width: auto;
|
||||
text-align: left;
|
||||
padding-left: 30px;
|
||||
border-radius: 0;
|
||||
margin: 00;
|
||||
}
|
||||
|
||||
.infobox:not(.stacked) .title {
|
||||
|
@ -119,15 +163,21 @@ const stacked = computed(() => themes[settings.theme].mergeAdjacent);
|
|||
|
||||
.body {
|
||||
transition-duration: 0.5s;
|
||||
padding: 8px;
|
||||
width: 100%;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--background);
|
||||
border-radius: 0 0 var(--feature-margin) var(--feature-margin);
|
||||
border-radius: 5px;
|
||||
border-top-left-radius: 0;
|
||||
}
|
||||
|
||||
.infobox:not(.stacked) .body {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.body > * {
|
||||
padding: 8px;
|
||||
width: 100%;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
border-radius: 5px;
|
||||
border-top-left-radius: 0;
|
||||
background-color: var(--background);
|
||||
}
|
||||
</style>
|
||||
|
|
141
src/features/infoboxes/infobox.ts
Normal file
141
src/features/infoboxes/infobox.ts
Normal file
|
@ -0,0 +1,141 @@
|
|||
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>;
|
||||
});
|
||||
}
|
|
@ -1,79 +0,0 @@
|
|||
import Infobox from "features/infoboxes/Infobox.vue";
|
||||
import type { Persistent } from "game/persistence";
|
||||
import { persistent } from "game/persistence";
|
||||
import { MaybeGetter, 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: MaybeGetter<Renderable>;
|
||||
/** The main text that appears in the display. */
|
||||
display: MaybeGetter<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: MaybeGetter<Renderable>;
|
||||
/** The main text that appears in the display. */
|
||||
display: MaybeGetter<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,
|
||||
display
|
||||
} satisfies Infobox;
|
||||
|
||||
return infobox;
|
||||
});
|
||||
}
|
|
@ -2,7 +2,7 @@
|
|||
<line
|
||||
stroke-width="15px"
|
||||
stroke="white"
|
||||
v-bind="linkProps"
|
||||
v-bind="link"
|
||||
:x1="startPosition.x"
|
||||
:y1="startPosition.y"
|
||||
:x2="endPosition.x"
|
||||
|
@ -13,47 +13,45 @@
|
|||
<script setup lang="ts">
|
||||
import type { Link } from "features/links/links";
|
||||
import type { FeatureNode } from "game/layers";
|
||||
import { kebabifyObject } from "util/vue";
|
||||
import { computed } from "vue";
|
||||
import { computed, toRefs } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
const _props = defineProps<{
|
||||
link: Link;
|
||||
startNode: FeatureNode;
|
||||
endNode: FeatureNode;
|
||||
boundingRect: DOMRect | undefined;
|
||||
}>();
|
||||
const props = toRefs(_props);
|
||||
|
||||
const startPosition = computed(() => {
|
||||
const rect = props.startNode.rect;
|
||||
const boundingRect = props.boundingRect;
|
||||
const rect = props.startNode.value.rect;
|
||||
const boundingRect = props.boundingRect.value;
|
||||
const position = boundingRect
|
||||
? {
|
||||
x: rect.x + rect.width / 2 - boundingRect.x,
|
||||
y: rect.y + rect.height / 2 - boundingRect.y
|
||||
}
|
||||
: { x: 0, y: 0 };
|
||||
if (props.link.offsetStart) {
|
||||
position.x += props.link.offsetStart.x;
|
||||
position.y += props.link.offsetStart.y;
|
||||
if (props.link.value.offsetStart) {
|
||||
position.x += props.link.value.offsetStart.x;
|
||||
position.y += props.link.value.offsetStart.y;
|
||||
}
|
||||
return position;
|
||||
});
|
||||
|
||||
const endPosition = computed(() => {
|
||||
const rect = props.endNode.rect;
|
||||
const boundingRect = props.boundingRect;
|
||||
const rect = props.endNode.value.rect;
|
||||
const boundingRect = props.boundingRect.value;
|
||||
const position = boundingRect
|
||||
? {
|
||||
x: rect.x + rect.width / 2 - boundingRect.x,
|
||||
y: rect.y + rect.height / 2 - boundingRect.y
|
||||
}
|
||||
: { x: 0, y: 0 };
|
||||
if (props.link.offsetEnd) {
|
||||
position.x += props.link.offsetEnd.x;
|
||||
position.y += props.link.offsetEnd.y;
|
||||
if (props.link.value.offsetEnd) {
|
||||
position.x += props.link.value.offsetEnd.x;
|
||||
position.y += props.link.value.offsetEnd.y;
|
||||
}
|
||||
return position;
|
||||
});
|
||||
|
||||
const linkProps = computed(() => kebabifyObject(props.link as unknown as Record<string, unknown>));
|
||||
</script>
|
||||
|
|
|
@ -13,51 +13,52 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Link } from "features/links/links";
|
||||
import type { FeatureNode } from "game/layers";
|
||||
import { BoundsInjectionKey, NodesInjectionKey } from "game/layers";
|
||||
import { computed, inject, MaybeRef, onMounted, ref, shallowRef, unref, watch } from "vue";
|
||||
import { computed, inject, onMounted, ref, toRef, watch } from "vue";
|
||||
import LinkVue from "./Link.vue";
|
||||
import { Link } from "./links";
|
||||
|
||||
const props = defineProps<{ links: MaybeRef<Link[]> }>();
|
||||
const _props = defineProps<{ links?: Link[] }>();
|
||||
const links = toRef(_props, "links");
|
||||
|
||||
function updateBounds() {
|
||||
boundingRect.value = resizeListener.value?.getBoundingClientRect();
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateBounds);
|
||||
const resizeListener = shallowRef<HTMLElement | null>(null);
|
||||
const resizeListener = ref<Element | null>(null);
|
||||
|
||||
const nodes = inject(NodesInjectionKey, ref<Record<string, FeatureNode | undefined>>({}));
|
||||
const outerBoundingRect = inject(BoundsInjectionKey, ref<DOMRect | undefined>(undefined));
|
||||
const boundingRect = ref<DOMRect | undefined>(resizeListener.value?.getBoundingClientRect());
|
||||
watch(outerBoundingRect, updateBounds);
|
||||
onMounted(() => {
|
||||
const resListener = resizeListener.value;
|
||||
if (resListener != null) {
|
||||
resizeObserver.observe(resListener);
|
||||
}
|
||||
updateBounds();
|
||||
});
|
||||
watch(
|
||||
outerBoundingRect,
|
||||
() => (boundingRect.value = resizeListener.value?.getBoundingClientRect())
|
||||
);
|
||||
onMounted(() => (boundingRect.value = resizeListener.value?.getBoundingClientRect()));
|
||||
|
||||
const validLinks = computed(() => {
|
||||
const n = nodes.value;
|
||||
return (
|
||||
unref(props.links)?.filter(link =>
|
||||
n[link.startNode.id]?.rect && n[link.endNode.id]?.rect) ?? []
|
||||
links.value?.filter(link => n[link.startNode.id]?.rect && n[link.startNode.id]?.rect) ?? []
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.resize-listener, svg {
|
||||
.resize-listener {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0;
|
||||
right: -4px;
|
||||
bottom: 5px;
|
||||
z-index: -10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: -10;
|
||||
pointer-events: none;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -10;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
|
78
src/features/links/links.ts
Normal file
78
src/features/links/links.ts
Normal file
|
@ -0,0 +1,78 @@
|
|||
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>;
|
||||
});
|
||||
}
|
|
@ -1,54 +0,0 @@
|
|||
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,58 +2,83 @@
|
|||
<div
|
||||
ref="resizeListener"
|
||||
class="resize-listener"
|
||||
:style="unref(style)"
|
||||
:class="unref(classes)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx">
|
||||
<script lang="tsx">
|
||||
import { Application } from "@pixi/app";
|
||||
import type { StyleValue } from "features/feature";
|
||||
import { globalBus } from "game/events";
|
||||
import "lib/pixi";
|
||||
import { nextTick, onBeforeUnmount, onMounted, shallowRef } from "vue";
|
||||
import { processedPropType } from "util/vue";
|
||||
import type { PropType } from "vue";
|
||||
import { defineComponent, nextTick, onBeforeUnmount, onMounted, shallowRef, unref } from "vue";
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: "containerResized", boundingRect: DOMRect): void;
|
||||
(e: "hotReload"): void;
|
||||
(e: "init", app: Application): void;
|
||||
}>();
|
||||
// TODO get typing support on the Particles component
|
||||
export default defineComponent({
|
||||
props: {
|
||||
style: processedPropType<StyleValue>(String, Object, Array),
|
||||
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 app = shallowRef<null | Application>(null);
|
||||
const resizeObserver = new ResizeObserver(updateBounds);
|
||||
const resizeListener = shallowRef<HTMLElement | null>(null);
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateBounds);
|
||||
const resizeListener = shallowRef<HTMLElement | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
const resListener = resizeListener.value;
|
||||
if (resListener != null) {
|
||||
resizeObserver.observe(resListener);
|
||||
app.value = new Application({
|
||||
resizeTo: resListener,
|
||||
backgroundAlpha: 0
|
||||
});
|
||||
resizeListener.value?.appendChild(app.value.view);
|
||||
emits("init", app.value);
|
||||
}
|
||||
updateBounds();
|
||||
nextTick(() => emits("hotReload"));
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
app.value?.destroy();
|
||||
app.value = null;
|
||||
});
|
||||
|
||||
let isDirty = true;
|
||||
function updateBounds() {
|
||||
if (isDirty) {
|
||||
isDirty = false;
|
||||
nextTick(() => {
|
||||
if (resizeListener.value != null) {
|
||||
emits("containerResized", resizeListener.value.getBoundingClientRect());
|
||||
onMounted(() => {
|
||||
// ResizeListener exists because ResizeObserver's don't work when told to observe an SVG element
|
||||
const resListener = resizeListener.value;
|
||||
if (resListener != null) {
|
||||
resizeObserver.observe(resListener);
|
||||
app.value = new Application({
|
||||
resizeTo: resListener,
|
||||
backgroundAlpha: 0
|
||||
});
|
||||
resizeListener.value?.appendChild(app.value.view);
|
||||
props.onInit?.(app.value as Application);
|
||||
}
|
||||
updateBounds();
|
||||
if (props.onHotReload) {
|
||||
nextTick(props.onHotReload);
|
||||
}
|
||||
isDirty = true;
|
||||
});
|
||||
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);
|
||||
|
||||
return {
|
||||
unref,
|
||||
resizeListener
|
||||
};
|
||||
}
|
||||
}
|
||||
globalBus.on("fontsLoaded", updateBounds);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { Application } from "@pixi/app";
|
||||
import type { EmitterConfigV3 } 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 { VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
|
||||
import { Ref, shallowRef, unref } from "vue";
|
||||
import Particles from "./Particles.vue";
|
||||
import { processGetter } from "util/computed";
|
||||
|
||||
/** A symbol used to identify {@link Particles} features. */
|
||||
export const ParticlesType = Symbol("Particles");
|
||||
|
@ -13,7 +14,11 @@ export const ParticlesType = Symbol("Particles");
|
|||
/**
|
||||
* An object that configures {@link Particles}.
|
||||
*/
|
||||
export interface ParticlesOptions extends VueFeatureOptions {
|
||||
export interface ParticlesOptions {
|
||||
/** 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. */
|
||||
onContainerResized?: (boundingRect: DOMRect) => void;
|
||||
/** A function that is called whenever the particles element is reloaded during development. For restarting particle effects. */
|
||||
|
@ -21,14 +26,11 @@ export interface ParticlesOptions extends VueFeatureOptions {
|
|||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The properties that are added onto a processed {@link ParticlesOptions} to create an {@link Particles}.
|
||||
*/
|
||||
export interface Particles extends VueFeature {
|
||||
/** A function that is called when the particles canvas is resized. */
|
||||
onContainerResized?: (boundingRect: DOMRect) => void;
|
||||
/** A function that is called whenever the particles element is reloaded during development. For restarting particle effects. */
|
||||
onHotReload?: VoidFunction;
|
||||
export interface BaseParticles {
|
||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
||||
id: string;
|
||||
/** The Pixi.JS Application powering this particles canvas. */
|
||||
app: Ref<null | Application>;
|
||||
/**
|
||||
|
@ -39,19 +41,52 @@ export interface Particles extends VueFeature {
|
|||
addEmitter: (config: EmitterConfigV3) => Promise<Emitter>;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
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.
|
||||
* @param optionsFunc Particles options.
|
||||
*/
|
||||
export function createParticles<T extends ParticlesOptions>(optionsFunc?: () => T) {
|
||||
return createLazyProxy(() => {
|
||||
const options = optionsFunc?.() ?? ({} as T);
|
||||
const { onContainerResized, onHotReload, style: _style, ...props } = options;
|
||||
export function createParticles<T extends ParticlesOptions>(
|
||||
optionsFunc?: OptionsFunc<T, BaseParticles, GenericParticles>
|
||||
): Particles<T> {
|
||||
return createLazyProxy(feature => {
|
||||
const particles =
|
||||
optionsFunc?.call(feature, feature) ??
|
||||
({} as ReturnType<NonNullable<typeof optionsFunc>>);
|
||||
particles.id = getUniqueID("particles-");
|
||||
particles.type = ParticlesType;
|
||||
particles[Component] = ParticlesComponent as GenericComponent;
|
||||
|
||||
const style = processGetter(_style);
|
||||
options.style = () => ({ position: "static", ...(unref(style) ?? {}) });
|
||||
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: {
|
||||
resolve: (value: Emitter | PromiseLike<Emitter>) => void;
|
||||
|
@ -59,35 +94,27 @@ export function createParticles<T extends ParticlesOptions>(optionsFunc?: () =>
|
|||
}[] = [];
|
||||
|
||||
function onInit(app: Application) {
|
||||
const genericParticles = particles as GenericParticles;
|
||||
genericParticles.app.value = app;
|
||||
emittersToAdd.forEach(({ resolve, config }) => resolve(new Emitter(app.stage, config)));
|
||||
emittersToAdd = [];
|
||||
particles.app.value = app;
|
||||
}
|
||||
|
||||
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.onContainerResized = particles.onContainerResized?.bind(particles);
|
||||
|
||||
return particles;
|
||||
particles[GatherProps] = function (this: GenericParticles) {
|
||||
const { id, style, classes, onContainerResized, onHotReload } = this;
|
||||
return {
|
||||
id,
|
||||
style: unref(style),
|
||||
classes,
|
||||
onContainerResized,
|
||||
onHotReload,
|
||||
onInit
|
||||
};
|
||||
};
|
||||
|
||||
return particles as unknown as Particles<T>;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
303
src/features/repeatable.tsx
Normal file
303
src/features/repeatable.tsx
Normal file
|
@ -0,0 +1,303 @@
|
|||
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>;
|
||||
/** Whether or not clicking this repeatable should attempt to maximize amount based on the requirements met. Requires {@link requirements} to be a requirement or array of requirements with {@link Requirement.canMaximize} true. */
|
||||
maximize?: 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.
|
||||
* Capped at 1 if {@link RepeatableOptions.maximize} is false.
|
||||
**/
|
||||
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"]>;
|
||||
maximize: GetComputableType<T["maximize"]>;
|
||||
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
|
||||
} 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(() =>
|
||||
unref((repeatable as GenericRepeatable).maximize)
|
||||
? 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;
|
||||
}
|
||||
payRequirements(repeatable.requirements, unref(repeatable.amountToIncrease));
|
||||
genericRepeatable.amount.value = Decimal.add(genericRepeatable.amount.value, 1);
|
||||
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 />
|
||||
joinJSX(
|
||||
<>Amount: {formatWhole(genericRepeatable.amount.value)}</>,
|
||||
{unref(genericRepeatable.limit) !== Decimal.dInf ? (
|
||||
<> / {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");
|
||||
processComputable(repeatable as T, "maximize");
|
||||
|
||||
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,86 +1,85 @@
|
|||
import type { OptionsFunc, Replace } from "features/feature";
|
||||
import { getUniqueID } from "features/feature";
|
||||
import { globalBus } from "game/events";
|
||||
import Formula from "game/formulas/formulas";
|
||||
import type { BaseLayer } from "game/layers";
|
||||
import {
|
||||
DefaultValue,
|
||||
NonPersistent,
|
||||
Persistent,
|
||||
persistent,
|
||||
SkipPersistence
|
||||
} from "game/persistence";
|
||||
import type { NonPersistent, Persistent } from "game/persistence";
|
||||
import { DefaultValue, persistent } from "game/persistence";
|
||||
import type { Unsubscribe } from "nanoevents";
|
||||
import Decimal from "util/bignum";
|
||||
import { processGetter } from "util/computed";
|
||||
import type { Computable, GetComputableType } from "util/computed";
|
||||
import { processComputable } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import { isRef, MaybeRef, MaybeRefOrGetter, unref } from "vue";
|
||||
import { isRef, unref } from "vue";
|
||||
|
||||
/** A symbol used to identify {@link Reset} features. */
|
||||
export const ResetType = Symbol("Reset");
|
||||
|
||||
/**
|
||||
* An object that configures a {@link features/clickables/clickable.Clickable}.
|
||||
* An object that configures a {@link Clickable}.
|
||||
*/
|
||||
export interface ResetOptions {
|
||||
/** List of things to reset. Can include objects which will be recursed over for persistent values. */
|
||||
thingsToReset: MaybeRefOrGetter<unknown[]>;
|
||||
thingsToReset: Computable<Record<string, unknown>[]>;
|
||||
/** A function that is called when the reset is performed. */
|
||||
onReset?: VoidFunction;
|
||||
}
|
||||
|
||||
/** An object that represents a reset mechanic, which resets progress back to its initial state. */
|
||||
export interface Reset {
|
||||
/** List of things to reset. Can include objects which will be recursed over for persistent values. */
|
||||
thingsToReset: MaybeRef<unknown[]>;
|
||||
/** A function that is called when the reset is performed. */
|
||||
onReset?: VoidFunction;
|
||||
/**
|
||||
* The properties that are added onto a processed {@link ResetOptions} to create an {@link Reset}.
|
||||
*/
|
||||
export interface BaseReset {
|
||||
/** An auto-generated ID for identifying which reset is being performed. Will not persist between refreshes or updates. */
|
||||
id: string;
|
||||
/** Trigger the reset. */
|
||||
reset: VoidFunction;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof ResetType;
|
||||
}
|
||||
|
||||
/** An object that represents a reset mechanic, which resets progress back to its initial state. */
|
||||
export type Reset<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.
|
||||
* @param optionsFunc Reset options.
|
||||
*/
|
||||
export function createReset<T extends ResetOptions>(optionsFunc: () => T) {
|
||||
return createLazyProxy(() => {
|
||||
const options = optionsFunc();
|
||||
const { thingsToReset, onReset, ...props } = options;
|
||||
export function createReset<T extends ResetOptions>(
|
||||
optionsFunc: OptionsFunc<T, BaseReset, GenericReset>
|
||||
): Reset<T> {
|
||||
return createLazyProxy(feature => {
|
||||
const reset = optionsFunc.call(feature, feature);
|
||||
reset.id = getUniqueID("reset-");
|
||||
reset.type = ResetType;
|
||||
|
||||
const reset = {
|
||||
type: ResetType,
|
||||
...(props as Omit<typeof props, keyof ResetOptions>),
|
||||
onReset,
|
||||
thingsToReset: processGetter(thingsToReset),
|
||||
reset: function () {
|
||||
const handleObject = (obj: unknown) => {
|
||||
if (
|
||||
obj != null &&
|
||||
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>)
|
||||
);
|
||||
}
|
||||
reset.reset = function () {
|
||||
const handleObject = (obj: unknown) => {
|
||||
if (obj != null && typeof obj === "object") {
|
||||
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>)
|
||||
);
|
||||
}
|
||||
};
|
||||
unref(reset.thingsToReset).forEach(handleObject);
|
||||
globalBus.emit("reset", reset);
|
||||
onReset?.();
|
||||
}
|
||||
} satisfies Reset;
|
||||
}
|
||||
};
|
||||
unref((reset as GenericReset).thingsToReset).forEach(handleObject);
|
||||
globalBus.emit("reset", reset as GenericReset);
|
||||
reset.onReset?.();
|
||||
};
|
||||
|
||||
return reset;
|
||||
processComputable(reset as T, "thingsToReset");
|
||||
|
||||
return reset as unknown as Reset<T>;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -90,7 +89,7 @@ const listeners: Record<string, Unsubscribe | undefined> = {};
|
|||
* @param layer The layer the reset is attached to
|
||||
* @param reset The reset mechanic to track the time since
|
||||
*/
|
||||
export function trackResetTime(layer: BaseLayer, reset: Reset): Persistent<Decimal> {
|
||||
export function trackResetTime(layer: BaseLayer, reset: GenericReset): Persistent<Decimal> {
|
||||
const resetTime = persistent<Decimal>(new Decimal(0));
|
||||
globalBus.on("addLayer", layerBeingAdded => {
|
||||
if (layer.id === layerBeingAdded.id) {
|
||||
|
@ -115,6 +114,6 @@ globalBus.on("removeLayer", layer => {
|
|||
|
||||
declare module "game/events" {
|
||||
interface GlobalEvents {
|
||||
reset: (reset: Reset) => void;
|
||||
reset: (reset: GenericReset) => void;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,12 +3,16 @@
|
|||
<div
|
||||
class="main-display-container"
|
||||
:class="classes ?? {}"
|
||||
:style="[{ height: `${(displayRef?.clientHeight ?? 0) + 20}px` }, style ?? {}]">
|
||||
<div class="main-display" ref="displayRef">
|
||||
:style="[{ height: `${(effectRef?.$el.clientHeight ?? 0) + 50}px` }, style ?? {}]"
|
||||
>
|
||||
<div class="main-display">
|
||||
<span v-if="showPrefix">You have </span>
|
||||
<ResourceVue :resource="resource" :color="color || 'white'" />
|
||||
{{ resource.displayName }}<!-- remove whitespace -->
|
||||
<span v-if="effectDisplay">, <Effect /></span>
|
||||
{{ resource.displayName
|
||||
}}<!-- remove whitespace -->
|
||||
<span v-if="effectComponent"
|
||||
>, <component :is="effectComponent" ref="effectRef"
|
||||
/></span>
|
||||
</div>
|
||||
</div>
|
||||
</Sticky>
|
||||
|
@ -16,24 +20,28 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import Sticky from "components/layout/Sticky.vue";
|
||||
import type { CoercableComponent } from "features/feature";
|
||||
import type { Resource } from "features/resources/resource";
|
||||
import ResourceVue from "features/resources/Resource.vue";
|
||||
import Decimal from "util/bignum";
|
||||
import { MaybeGetter } from "util/computed";
|
||||
import { Renderable } from "util/vue";
|
||||
import { computed, CSSProperties, ref, toValue } from "vue";
|
||||
import { computeOptionalComponent } from "util/vue";
|
||||
import { ComponentPublicInstance, ref, Ref, StyleValue } from "vue";
|
||||
import { computed, toRefs } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
const _props = defineProps<{
|
||||
resource: Resource;
|
||||
color?: string;
|
||||
classes?: Record<string, boolean>;
|
||||
style?: CSSProperties;
|
||||
effectDisplay?: MaybeGetter<Renderable>;
|
||||
style?: StyleValue;
|
||||
effectDisplay?: CoercableComponent;
|
||||
}>();
|
||||
const props = toRefs(_props);
|
||||
|
||||
const displayRef = ref<Element | null>(null);
|
||||
const effectRef = ref<ComponentPublicInstance | null>(null);
|
||||
|
||||
const Effect = () => toValue(props.effectDisplay);
|
||||
const effectComponent = computeOptionalComponent(
|
||||
props.effectDisplay as Ref<CoercableComponent | undefined>
|
||||
);
|
||||
|
||||
const showPrefix = computed(() => {
|
||||
return Decimal.lt(props.resource.value, "1e1000");
|
||||
|
|
|
@ -3,8 +3,9 @@ import type { Persistent, State } from "game/persistence";
|
|||
import { NonPersistent, persistent } from "game/persistence";
|
||||
import type { DecimalSource } from "util/bignum";
|
||||
import Decimal, { format, formatWhole } from "util/bignum";
|
||||
import type { ProcessedComputable } from "util/computed";
|
||||
import { loadingSave } from "util/save";
|
||||
import type { ComputedRef, MaybeRef, Ref } from "vue";
|
||||
import type { ComputedRef, Ref } from "vue";
|
||||
import { computed, isRef, ref, unref, watch } from "vue";
|
||||
|
||||
/** An object that represents a named and quantifiable resource in the game. */
|
||||
|
@ -158,7 +159,7 @@ export function displayResource(resource: Resource, overrideAmount?: DecimalSour
|
|||
}
|
||||
|
||||
/** Utility for unwrapping a resource that may or may not be inside a ref. */
|
||||
export function unwrapResource(resource: MaybeRef<Resource>): Resource {
|
||||
export function unwrapResource(resource: ProcessedComputable<Resource>): Resource {
|
||||
if ("displayName" in resource) {
|
||||
return resource;
|
||||
}
|
||||
|
|
13
src/features/tabs/Tab.vue
Normal file
13
src/features/tabs/Tab.vue
Normal file
|
@ -0,0 +1,13 @@
|
|||
<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,47 +1,79 @@
|
|||
<template>
|
||||
<button @click="selectTab" class="tabButton" :style="glowColorStyle" :class="{ active }">
|
||||
<Component />
|
||||
<button
|
||||
v-if="isVisible(visibility)"
|
||||
@click="selectTab"
|
||||
class="tabButton"
|
||||
:style="[
|
||||
{
|
||||
visibility: isHidden(visibility) ? 'hidden' : undefined
|
||||
},
|
||||
glowColorStyle,
|
||||
unref(style) ?? {}
|
||||
]"
|
||||
:class="{
|
||||
active,
|
||||
...unref(classes)
|
||||
}"
|
||||
>
|
||||
<component :is="component" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import themes from "data/themes";
|
||||
<script lang="ts">
|
||||
import type { CoercableComponent, StyleValue } from "features/feature";
|
||||
import { isHidden, isVisible, Visibility } from "features/feature";
|
||||
import { getNotifyStyle } from "game/notifications";
|
||||
import settings from "game/settings";
|
||||
import { MaybeGetter } from "util/computed";
|
||||
import { render, Renderable } from "util/vue";
|
||||
import { computed, MaybeRef, unref } from "vue";
|
||||
import { computeComponent, processedPropType, unwrapRef } from "util/vue";
|
||||
import { computed, defineComponent, toRefs, unref } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
display: MaybeGetter<Renderable>;
|
||||
glowColor?: MaybeRef<string>;
|
||||
active?: boolean;
|
||||
}>();
|
||||
export default defineComponent({
|
||||
props: {
|
||||
visibility: {
|
||||
type: processedPropType<Visibility | boolean>(Number, 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 emit = defineEmits<{
|
||||
selectTab: [];
|
||||
}>();
|
||||
const component = computeComponent(display);
|
||||
|
||||
const Component = () => render(props.display);
|
||||
const glowColorStyle = computed(() => {
|
||||
const color = unwrapRef(glowColor);
|
||||
if (color == null || color === "") {
|
||||
return {};
|
||||
}
|
||||
if (unref(floating)) {
|
||||
return getNotifyStyle(color);
|
||||
}
|
||||
return { boxShadow: `0px 9px 5px -6px ${color}` };
|
||||
});
|
||||
|
||||
const glowColorStyle = computed(() => {
|
||||
const color = unref(props.glowColor);
|
||||
if (color == null || color === "") {
|
||||
return {};
|
||||
function selectTab() {
|
||||
emit("selectTab");
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
@ -1,62 +1,132 @@
|
|||
<template>
|
||||
<div class="tab-family-container" :class="tabClasses" :style="tabStyle">
|
||||
<div
|
||||
v-if="isVisible(visibility)"
|
||||
class="tab-family-container"
|
||||
:class="{ ...unref(classes), ...tabClasses }"
|
||||
:style="[
|
||||
{
|
||||
visibility: isHidden(visibility) ? 'hidden' : undefined
|
||||
},
|
||||
unref(style) ?? [],
|
||||
tabStyle ?? []
|
||||
]"
|
||||
>
|
||||
<Sticky
|
||||
class="tab-buttons-container"
|
||||
:class="unref(buttonContainerClasses)"
|
||||
:style="unref(buttonContainerStyle)"
|
||||
>
|
||||
<div class="tab-buttons" :class="{ floating }">
|
||||
<TabButtons />
|
||||
<TabButton
|
||||
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>
|
||||
</Sticky>
|
||||
<Component v-if="unref(activeTab) != null" />
|
||||
<template v-if="unref(activeTab)">
|
||||
<component :is="unref(component)" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import Sticky from "components/layout/Sticky.vue";
|
||||
import themes from "data/themes";
|
||||
import { isType } from "features/feature";
|
||||
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 { MaybeGetter } from "util/computed";
|
||||
import { render, Renderable } from "util/vue";
|
||||
import type { Component, CSSProperties, MaybeRef, Ref } from "vue";
|
||||
import { computed, unref } from "vue";
|
||||
import { Tab, TabType } from "./tab";
|
||||
import { TabButton } from "./tabFamily";
|
||||
import { coerceComponent, isCoercableComponent, processedPropType, unwrapRef } from "util/vue";
|
||||
import type { Component, PropType, Ref } from "vue";
|
||||
import { computed, defineComponent, shallowRef, toRefs, unref, watchEffect } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
activeTab: Ref<MaybeGetter<Renderable> | Tab | null>;
|
||||
tabs: Record<string, TabButton>;
|
||||
buttonContainerClasses?: MaybeRef<Record<string, boolean>>;
|
||||
buttonContainerStyle?: MaybeRef<CSSProperties>;
|
||||
}>();
|
||||
export default defineComponent({
|
||||
props: {
|
||||
visibility: {
|
||||
type: processedPropType<Visibility | boolean>(Number, Boolean),
|
||||
required: true
|
||||
},
|
||||
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 Component = () => {
|
||||
const activeTab = unref(props.activeTab);
|
||||
if (activeTab == null) {
|
||||
return;
|
||||
}
|
||||
return render(activeTab);
|
||||
};
|
||||
const floating = computed(() => {
|
||||
return themes[settings.theme].floatingTabs;
|
||||
});
|
||||
|
||||
const floating = computed(() => {
|
||||
return themes[settings.theme].floatingTabs;
|
||||
});
|
||||
const component = shallowRef<Component | string>("");
|
||||
|
||||
const TabButtons = () => Object.values(props.tabs).map(tab => render(tab));
|
||||
watchEffect(() => {
|
||||
const currActiveTab = unwrapRef(activeTab);
|
||||
if (currActiveTab == null) {
|
||||
component.value = "";
|
||||
return;
|
||||
}
|
||||
if (isCoercableComponent(currActiveTab)) {
|
||||
component.value = coerceComponent(currActiveTab);
|
||||
return;
|
||||
}
|
||||
component.value = coerceComponent(unref(currActiveTab.display));
|
||||
});
|
||||
|
||||
const tabClasses = computed(() => {
|
||||
const activeTab = unref(props.activeTab);
|
||||
if (isType(activeTab, TabType)) {
|
||||
return unref(activeTab.classes);
|
||||
}
|
||||
});
|
||||
const tabClasses = computed(() => {
|
||||
const currActiveTab = unwrapRef(activeTab);
|
||||
const tabClasses =
|
||||
isCoercableComponent(currActiveTab) || !currActiveTab
|
||||
? undefined
|
||||
: unref(currActiveTab.classes);
|
||||
return tabClasses;
|
||||
});
|
||||
|
||||
const tabStyle = computed(() => {
|
||||
const activeTab = unref(props.activeTab);
|
||||
if (isType(activeTab, TabType)) {
|
||||
return unref(activeTab.style);
|
||||
const tabStyle = computed(() => {
|
||||
const currActiveTab = unwrapRef(activeTab);
|
||||
return isCoercableComponent(currActiveTab) || !currActiveTab
|
||||
? undefined
|
||||
: unref(currActiveTab.style);
|
||||
});
|
||||
|
||||
function gatherButtonProps(button: GenericTabButton) {
|
||||
const { display, style, classes, glowColor, visibility } = button;
|
||||
return { display, style: unref(style), classes, glowColor, visibility };
|
||||
}
|
||||
|
||||
return {
|
||||
floating,
|
||||
tabClasses,
|
||||
tabStyle,
|
||||
Visibility,
|
||||
component,
|
||||
gatherButtonProps,
|
||||
unref,
|
||||
isVisible,
|
||||
isHidden
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
@ -129,10 +199,6 @@ const tabStyle = computed(() => {
|
|||
z-index: 4;
|
||||
}
|
||||
|
||||
.tab-buttons > * {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.layer-tab
|
||||
> .tab-family-container:first-child:nth-last-child(3)
|
||||
> .tab-buttons-container
|
||||
|
|
|
@ -1,6 +1,14 @@
|
|||
import { MaybeGetter } from "util/computed";
|
||||
import type {
|
||||
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 { Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
|
||||
|
||||
/** A symbol used to identify {@link Tab} features. */
|
||||
export const TabType = Symbol("Tab");
|
||||
|
@ -8,38 +16,63 @@ export const TabType = Symbol("Tab");
|
|||
/**
|
||||
* An object that configures a {@link Tab}.
|
||||
*/
|
||||
export interface TabOptions extends VueFeatureOptions {
|
||||
export interface TabOptions {
|
||||
/** 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. */
|
||||
display: MaybeGetter<Renderable>;
|
||||
display: Computable<CoercableComponent>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @see {@link features/tabs/tabFamily.TabFamily}
|
||||
* @see {@link TabFamily}
|
||||
*/
|
||||
export interface Tab extends VueFeature {
|
||||
/** The display to use for this tab. */
|
||||
display: MaybeGetter<Renderable>;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof TabType;
|
||||
}
|
||||
export type Tab<T extends TabOptions> = Replace<
|
||||
T & BaseTab,
|
||||
{
|
||||
classes: GetComputableType<T["classes"]>;
|
||||
style: GetComputableType<T["style"]>;
|
||||
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.
|
||||
* @param optionsFunc Tab options.
|
||||
*/
|
||||
export function createTab<T extends TabOptions>(optionsFunc: () => T) {
|
||||
return createLazyProxy(() => {
|
||||
const options = optionsFunc?.() ?? ({} as T);
|
||||
const { display, ...props } = options;
|
||||
export function createTab<T extends TabOptions>(
|
||||
optionsFunc: OptionsFunc<T, BaseTab, GenericTab>
|
||||
): Tab<T> {
|
||||
return createLazyProxy(feature => {
|
||||
const tab = optionsFunc.call(feature, feature);
|
||||
tab.id = getUniqueID("tab-");
|
||||
tab.type = TabType;
|
||||
tab[Component] = TabComponent as GenericComponent;
|
||||
|
||||
const tab = {
|
||||
type: TabType,
|
||||
...(props as Omit<typeof props, keyof VueFeature | keyof TabOptions>),
|
||||
...vueFeatureMixin("tab", options, display),
|
||||
display
|
||||
} satisfies Tab;
|
||||
tab[GatherProps] = function (this: GenericTab) {
|
||||
const { display } = this;
|
||||
return { display };
|
||||
};
|
||||
|
||||
return tab;
|
||||
return tab as unknown as Tab<T>;
|
||||
});
|
||||
}
|
||||
|
|
233
src/features/tabs/tabFamily.ts
Normal file
233
src/features/tabs/tabFamily.ts
Normal file
|
@ -0,0 +1,233 @@
|
|||
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.warn("Cannot create tab family with 0 tabs");
|
||||
throw new 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;
|
||||
});
|
||||
}
|
|
@ -1,147 +0,0 @@
|
|||
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 { MaybeGetter, 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 | MaybeGetter<Renderable>;
|
||||
/** The label on this button. */
|
||||
display: MaybeGetter<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 | MaybeGetter<Renderable>;
|
||||
/** The label on this button. */
|
||||
display: MaybeGetter<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 | MaybeGetter<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: buttonTab,
|
||||
glowColor: processGetter(glowColor),
|
||||
display
|
||||
} satisfies TabButton;
|
||||
|
||||
parsedTabs[tab] = tabButton;
|
||||
return parsedTabs;
|
||||
}, {}),
|
||||
buttonContainerClasses: processGetter(buttonContainerClasses),
|
||||
buttonContainerStyle: processGetter(buttonContainerStyle),
|
||||
selected,
|
||||
activeTab: computed((): Tab | MaybeGetter<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;
|
||||
});
|
||||
}
|
|
@ -7,6 +7,7 @@
|
|||
@click.capture="togglePinned"
|
||||
>
|
||||
<slot />
|
||||
<component v-if="elementComp" :is="elementComp" />
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="isShown"
|
||||
|
@ -27,46 +28,83 @@
|
|||
]"
|
||||
>
|
||||
<span v-if="showPin" class="material-icons pinned">push_pin</span>
|
||||
<Component />
|
||||
<component v-if="comp" :is="comp" />
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx">
|
||||
<script lang="tsx">
|
||||
import themes from "data/themes";
|
||||
import type { CoercableComponent } from "features/feature";
|
||||
import { jsx, StyleValue } from "features/feature";
|
||||
import type { Persistent } from "game/persistence";
|
||||
import settings from "game/settings";
|
||||
import { Direction } from "util/common";
|
||||
import { MaybeGetter } from "util/computed";
|
||||
import { render, Renderable } from "util/vue";
|
||||
import type { Component, CSSProperties, MaybeRef, Ref } from "vue";
|
||||
import { computed, ref, unref } from "vue";
|
||||
import type { VueFeature } from "util/vue";
|
||||
import {
|
||||
coerceComponent,
|
||||
computeOptionalComponent,
|
||||
processedPropType,
|
||||
renderJSX,
|
||||
unwrapRef
|
||||
} from "util/vue";
|
||||
import type { Component, PropType } from "vue";
|
||||
import { computed, defineComponent, ref, shallowRef, toRefs, unref } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
pinned?: Ref<boolean>;
|
||||
display: MaybeGetter<Renderable>;
|
||||
style?: MaybeRef<CSSProperties>;
|
||||
classes?: MaybeRef<Record<string, boolean>>;
|
||||
direction: MaybeRef<Direction>;
|
||||
xoffset?: MaybeRef<string>;
|
||||
yoffset?: MaybeRef<string>;
|
||||
}>();
|
||||
export default defineComponent({
|
||||
props: {
|
||||
element: Object as PropType<VueFeature>,
|
||||
display: {
|
||||
type: processedPropType<CoercableComponent>(Object, String, Function),
|
||||
required: true
|
||||
},
|
||||
style: processedPropType<StyleValue>(Object, String, Array),
|
||||
classes: processedPropType<Record<string, boolean>>(Object),
|
||||
direction: processedPropType<Direction>(String),
|
||||
xoffset: processedPropType<string>(String),
|
||||
yoffset: processedPropType<string>(String),
|
||||
pinned: Object as PropType<Persistent<boolean>>
|
||||
},
|
||||
setup(props) {
|
||||
const { element, display, pinned } = toRefs(props);
|
||||
|
||||
const isHovered = ref(false);
|
||||
const isShown = computed(() => props.pinned?.value === true || isHovered.value);
|
||||
const isHovered = ref(false);
|
||||
const isShown = computed(() => (unwrapRef(pinned) || isHovered.value) && comp.value);
|
||||
const comp = computeOptionalComponent(display);
|
||||
|
||||
const Component = () => render(props.display);
|
||||
const elementComp = shallowRef<Component | "" | null>(
|
||||
coerceComponent(
|
||||
jsx(() => {
|
||||
const currComponent = unwrapRef(element);
|
||||
return currComponent == null ? "" : renderJSX(currComponent);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
function togglePinned(e: MouseEvent) {
|
||||
const isPinned = props.pinned;
|
||||
if (e.shiftKey && isPinned != null) {
|
||||
isPinned.value = !isPinned.value;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
function togglePinned(e: MouseEvent) {
|
||||
const isPinned = pinned as unknown as Persistent<boolean> | undefined; // Vue typing :/
|
||||
if (e.shiftKey && isPinned) {
|
||||
isPinned.value = !isPinned.value;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
const showPin = computed(() => unwrapRef(pinned) && themes[settings.theme].showPin);
|
||||
|
||||
return {
|
||||
Direction,
|
||||
isHovered,
|
||||
isShown,
|
||||
comp,
|
||||
elementComp,
|
||||
unref,
|
||||
togglePinned,
|
||||
showPin
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const showPin = computed(() => props.pinned?.value === true && themes[settings.theme].showPin);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
132
src/features/tooltips/tooltip.ts
Normal file
132
src/features/tooltips/tooltip.ts
Normal file
|
@ -0,0 +1,132 @@
|
|||
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: T["pinnable"] extends undefined ? 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(() => {
|
||||
if (options.pinnable) {
|
||||
if ("pinned" in element) {
|
||||
console.error(
|
||||
"Cannot add pinnable tooltip to element that already has a property called 'pinned'"
|
||||
);
|
||||
options.pinnable = false;
|
||||
deletePersistent(options.pinned as Persistent<boolean>);
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(element as any).pinned = options.pinned;
|
||||
}
|
||||
}
|
||||
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,38 +1,79 @@
|
|||
<template>
|
||||
<Nodes />
|
||||
<LeftNodes v-if="leftSideNodes" />
|
||||
<RightNodes v-if="rightSideNodes" />
|
||||
<component :is="nodesComp" />
|
||||
<component v-if="leftNodesComp" :is="leftNodesComp" />
|
||||
<component v-if="rightNodesComp" :is="rightNodesComp" />
|
||||
<Links v-if="branches" :links="unref(branches)" />
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx">
|
||||
<script lang="tsx">
|
||||
import "components/common/table.css";
|
||||
import { jsx } from "features/feature";
|
||||
import Links from "features/links/Links.vue";
|
||||
import type { Tree, TreeBranch, TreeNode } from "features/trees/tree";
|
||||
import { render } from "util/vue";
|
||||
import { MaybeRef, unref } from "vue";
|
||||
import type { GenericTreeNode, TreeBranch } from "features/trees/tree";
|
||||
import { coerceComponent, processedPropType, renderJSX, unwrapRef } from "util/vue";
|
||||
import type { Component } from "vue";
|
||||
import { defineComponent, shallowRef, toRefs, unref, watchEffect } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
nodes: MaybeRef<TreeNode[][]>;
|
||||
leftSideNodes?: MaybeRef<TreeNode[]>;
|
||||
rightSideNodes?: MaybeRef<TreeNode[]>;
|
||||
branches?: MaybeRef<TreeBranch[]>;
|
||||
}>();
|
||||
export default defineComponent({
|
||||
props: {
|
||||
nodes: {
|
||||
type: processedPropType<GenericTreeNode[][]>(Array),
|
||||
required: true
|
||||
},
|
||||
leftSideNodes: processedPropType<GenericTreeNode[]>(Array),
|
||||
rightSideNodes: processedPropType<GenericTreeNode[]>(Array),
|
||||
branches: processedPropType<TreeBranch[]>(Array)
|
||||
},
|
||||
components: { Links },
|
||||
setup(props) {
|
||||
const { nodes, leftSideNodes, rightSideNodes } = toRefs(props);
|
||||
|
||||
const Nodes = () => unref(props.nodes).map(nodes =>
|
||||
<span class="row tree-row" style="margin: 50px auto;">
|
||||
{nodes.map(node => render(node))}
|
||||
</span>);
|
||||
|
||||
const LeftNodes = () => props.leftSideNodes == null ? <></> :
|
||||
<span class="left-side-nodes small">
|
||||
{unref(props.leftSideNodes).map(node => render(node))}
|
||||
</span>;
|
||||
const nodesComp = shallowRef<Component | "">();
|
||||
watchEffect(() => {
|
||||
const currNodes = unwrapRef(nodes);
|
||||
nodesComp.value = coerceComponent(
|
||||
jsx(() => (
|
||||
<>
|
||||
{currNodes.map(row => (
|
||||
<span class="row tree-row" style="margin: 50px auto;">
|
||||
{row.map(renderJSX)}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
))
|
||||
);
|
||||
});
|
||||
|
||||
const RightNodes = () => props.rightSideNodes == null ? <></> :
|
||||
<span class="side-nodes small">
|
||||
{unref(props.rightSideNodes).map(node => render(node))}
|
||||
</span>;
|
||||
const leftNodesComp = shallowRef<Component | "">();
|
||||
watchEffect(() => {
|
||||
const currNodes = unwrapRef(leftSideNodes);
|
||||
leftNodesComp.value = currNodes
|
||||
? coerceComponent(
|
||||
jsx(() => (
|
||||
<span class="left-side-nodes small">{currNodes.map(renderJSX)}</span>
|
||||
))
|
||||
)
|
||||
: "";
|
||||
});
|
||||
|
||||
const rightNodesComp = shallowRef<Component | "">();
|
||||
watchEffect(() => {
|
||||
const currNodes = unwrapRef(rightSideNodes);
|
||||
rightNodesComp.value = currNodes
|
||||
? coerceComponent(
|
||||
jsx(() => <span class="side-nodes small">{currNodes.map(renderJSX)}</span>)
|
||||
)
|
||||
: "";
|
||||
});
|
||||
|
||||
return {
|
||||
unref,
|
||||
nodesComp,
|
||||
leftNodesComp,
|
||||
rightNodesComp
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
@ -1,16 +1,13 @@
|
|||
<template>
|
||||
<button
|
||||
:style="{
|
||||
backgroundColor: unref(color),
|
||||
boxShadow: `-4px -4px 4px rgba(0, 0, 0, 0.25) inset, 0 0 20px ${unref(
|
||||
glowColor
|
||||
)}`
|
||||
}"
|
||||
<div
|
||||
v-if="isVisible(visibility)"
|
||||
:style="{ visibility: isHidden(visibility) ? 'hidden' : undefined }"
|
||||
:class="{
|
||||
treeNode: true,
|
||||
can: unref(canClick)
|
||||
can: unref(canClick),
|
||||
...unref(classes)
|
||||
}"
|
||||
@click="e => emits('click', e)"
|
||||
@click="onClick"
|
||||
@mousedown="start"
|
||||
@mouseleave="stop"
|
||||
@mouseup="stop"
|
||||
|
@ -18,48 +15,108 @@
|
|||
@touchend.passive="stop"
|
||||
@touchcancel.passive="stop"
|
||||
>
|
||||
<Component />
|
||||
</button>
|
||||
<div
|
||||
:style="[
|
||||
{
|
||||
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>
|
||||
|
||||
<script setup lang="tsx">
|
||||
import { MaybeGetter } from "util/computed";
|
||||
import { render, Renderable, setupHoldToClick } from "util/vue";
|
||||
import { MaybeRef, toRef, unref } from "vue";
|
||||
<script lang="ts">
|
||||
import MarkNode from "components/MarkNode.vue";
|
||||
import Node from "components/Node.vue";
|
||||
import type { CoercableComponent, StyleValue } from "features/feature";
|
||||
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";
|
||||
|
||||
const props = defineProps<{
|
||||
canClick?: MaybeRef<boolean>;
|
||||
display?: MaybeGetter<Renderable>;
|
||||
color?: MaybeRef<string>;
|
||||
glowColor?: MaybeRef<string>;
|
||||
}>();
|
||||
export default defineComponent({
|
||||
props: {
|
||||
display: processedPropType<CoercableComponent>(Object, String, Function),
|
||||
visibility: {
|
||||
type: processedPropType<Visibility | boolean>(Number, Boolean),
|
||||
required: true
|
||||
},
|
||||
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 emits = defineEmits<{
|
||||
(e: "click", event?: MouseEvent | TouchEvent): void;
|
||||
(e: "hold"): void;
|
||||
}>();
|
||||
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(() => emits("hold"));
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
comp,
|
||||
unref,
|
||||
Visibility,
|
||||
isCoercableComponent,
|
||||
isVisible,
|
||||
isHidden
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.treeNode {
|
||||
height: 100px;
|
||||
width: 100px;
|
||||
border: 2px solid rgba(0, 0, 0, 0.125);
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
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;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.25);
|
||||
box-shadow: -4px -4px 4px rgba(0, 0, 0, 0.25) inset, 0px 0px 20px var(--background);
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.treeNode > * {
|
||||
.treeNode > *:first-child > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue