Merge remote-tracking branch 'template/feature/feat-and-board-rewrite' into feat/board-feature-rewrite
This commit is contained in:
commit
ffd9529e9a
147 changed files with 13384 additions and 16765 deletions
1
.eslintignore
Normal file
1
.eslintignore
Normal file
|
@ -0,0 +1 @@
|
|||
.eslintrc.cjs
|
|
@ -5,15 +5,22 @@ module.exports = {
|
|||
env: {
|
||||
node: true
|
||||
},
|
||||
extends: [
|
||||
"plugin:vue/vue3-essential",
|
||||
"@vue/eslint-config-typescript/recommended",
|
||||
"@vue/eslint-config-prettier"
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ["@typescript-eslint"],
|
||||
overrides: [
|
||||
{
|
||||
files: ['*.ts', '*.tsx'],
|
||||
extends: [
|
||||
"plugin:vue/vue3-essential",
|
||||
"@vue/eslint-config-typescript/recommended",
|
||||
"@vue/eslint-config-prettier"
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
project: "./tsconfig.json"
|
||||
},
|
||||
}
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
project: "tsconfig.json"
|
||||
},
|
||||
ignorePatterns: ["src/lib"],
|
||||
rules: {
|
||||
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||
|
@ -27,6 +34,13 @@ module.exports = {
|
|||
allowNullableObject: true,
|
||||
allowNullableBoolean: true
|
||||
}
|
||||
],
|
||||
"eqeqeq": [
|
||||
"error",
|
||||
"always",
|
||||
{
|
||||
"null": "never"
|
||||
}
|
||||
]
|
||||
},
|
||||
globals: {
|
|
@ -8,6 +8,8 @@ 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: |
|
||||
|
|
|
@ -7,15 +7,14 @@ on:
|
|||
jobs:
|
||||
test:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: node:21-bullseye
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
- name: Use Node.js 16.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16.x
|
||||
- run: npm ci
|
||||
- run: npm run build --if-present
|
||||
- run: npm test
|
||||
- run: npm run lint
|
||||
|
|
5
.github/workflows/test.yml
vendored
5
.github/workflows/test.yml
vendored
|
@ -12,10 +12,11 @@ jobs:
|
|||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
- name: Use Node.js 16.x
|
||||
- name: Use Node.js 21.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16.x
|
||||
node-version: 21.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": true
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"git.ignoreLimitWarning": true,
|
||||
|
|
34
CHANGELOG.md
34
CHANGELOG.md
|
@ -6,6 +6,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
|
31
CONTRIBUTING.md
Normal file
31
CONTRIBUTING.md
Normal file
|
@ -0,0 +1,31 @@
|
|||
# Contributing to Profectus
|
||||
|
||||
Thank you for considering contributing to Profectus! We appreciate your interest in improving our project. Please take a moment to review the following guidelines to streamline the contribution process.
|
||||
|
||||
## Getting Started
|
||||
|
||||
For detailed instructions on setting up local development environment, please refer to the [Setup Guide](https://moddingtree.com/guide/getting-started/setup).
|
||||
|
||||
## Issue Reporting
|
||||
|
||||
If you encounter a bug or have a suggestion for improvement, please open an issue on Incremental Social. Provide as much detail as possible, including an example repo or steps to reproduce the issue if applicable.
|
||||
|
||||
## Contributing
|
||||
|
||||
Make sure to open your PR on [Incremental Social](https://code.incremental.social/profectus/Profectus) - the GitHub repo is just a mirror!
|
||||
|
||||
### Code Review
|
||||
|
||||
All PRs must be reviewed and approved by at least one of the project maintainers before merging. Please be patient during the review process and be open to feedback.
|
||||
|
||||
### Testing
|
||||
|
||||
Ensure that your changes pass all existing tests and, if applicable, add new tests to cover the changes you've made. Run `npm run test` to run all the tests.
|
||||
|
||||
### Code Style
|
||||
|
||||
We use ESLint and Prettier to enforce consistent code style throughout the project. Before submitting a PR, run `npm run lint:fix` to automatically fix any linting issues.
|
||||
|
||||
## License
|
||||
|
||||
By contributing to Profectus, you agree that your contributions will be licensed under the project's [LICENSE](./LICENSE).
|
|
@ -8,6 +8,7 @@
|
|||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="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>
|
||||
|
|
6738
package-lock.json
generated
6738
package-lock.json
generated
File diff suppressed because it is too large
Load diff
74
package.json
74
package.json
|
@ -1,7 +1,8 @@
|
|||
{
|
||||
"name": "profectus",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"dev": "vite",
|
||||
|
@ -9,49 +10,56 @@
|
|||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"testw": "vitest",
|
||||
"serve": "vite preview --host"
|
||||
"serve": "vite preview --host",
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"lint:fix": "eslint --fix --max-warnings 0 src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/material-icons": "^4.5.4",
|
||||
"@fontsource/roboto-mono": "^4.5.8",
|
||||
"@pixi/app": "~6.3.2",
|
||||
"@pixi/constants": "~6.3.2",
|
||||
"@pixi/core": "~6.3.2",
|
||||
"@pixi/display": "~6.3.2",
|
||||
"@pixi/math": "~6.3.2",
|
||||
"@fontsource/material-icons": "^5.1.0",
|
||||
"@fontsource/roboto-mono": "^5.1.0",
|
||||
"@pixi/app": "^6.5.10",
|
||||
"@pixi/constants": "~6.5.10",
|
||||
"@pixi/core": "^6.5.10",
|
||||
"@pixi/display": "~6.5.10",
|
||||
"@pixi/math": "~6.5.10",
|
||||
"@pixi/particle-emitter": "^5.0.7",
|
||||
"@pixi/sprite": "~6.3.2",
|
||||
"@pixi/ticker": "~6.3.2",
|
||||
"@vitejs/plugin-vue": "^2.3.3",
|
||||
"@vitejs/plugin-vue-jsx": "^1.3.10",
|
||||
"@pixi/sprite": "~6.5.10",
|
||||
"@pixi/ticker": "~6.5.10",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"@vitejs/plugin-vue-jsx": "^4.0.1",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"lz-string": "^1.4.4",
|
||||
"nanoevents": "^6.0.2",
|
||||
"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",
|
||||
"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",
|
||||
"vue-panzoom": "https://github.com/thepaperpilot/vue-panzoom.git",
|
||||
"vue-textarea-autosize": "^1.1.1",
|
||||
"vue-toastification": "^2.0.0-rc.1",
|
||||
"vue-transition-expand": "^0.1.0",
|
||||
"vue-toastification": "^2.0.0-rc.5",
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ivanv/vue-collapse-transition": "^1.0.2",
|
||||
"@rushstack/eslint-patch": "^1.1.0",
|
||||
"@types/lz-string": "^1.3.34",
|
||||
"@vue/eslint-config-prettier": "^7.0.0",
|
||||
"@vue/eslint-config-typescript": "^10.0.0",
|
||||
"eslint": "^8.6.0",
|
||||
"jsdom": "^20.0.0",
|
||||
"prettier": "^2.5.1",
|
||||
"typescript": "^5.0.2",
|
||||
"vitest": "^0.29.3",
|
||||
"vue-tsc": "^0.38.1"
|
||||
"@rushstack/eslint-patch": "^1.7.2",
|
||||
"@types/lz-string": "^1.5.0",
|
||||
"@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"
|
||||
},
|
||||
"engines": {
|
||||
"node": "16.x"
|
||||
"node": "21.x"
|
||||
}
|
||||
}
|
||||
|
|
23
src/App.vue
23
src/App.vue
|
@ -1,14 +1,18 @@
|
|||
<template>
|
||||
<div v-if="appErrors.length > 0" class="error-container" :style="theme"><Error :errors="appErrors" /></div>
|
||||
<div v-if="appErrors.length > 0" class="error-container" :style="theme">
|
||||
<Error :errors="appErrors" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<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 />
|
||||
<component :is="gameComponent" />
|
||||
<CloudSaveResolver />
|
||||
<GameComponent />
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
@ -16,14 +20,15 @@
|
|||
<script setup lang="tsx">
|
||||
import "@fontsource/roboto-mono";
|
||||
import Error from "components/Error.vue";
|
||||
import { jsx } from "features/feature";
|
||||
import AddictionWarning from "components/modals/AddictionWarning.vue";
|
||||
import CloudSaveResolver from "components/modals/CloudSaveResolver.vue";
|
||||
import GameOverScreen from "components/modals/GameOverScreen.vue";
|
||||
import NaNScreen from "components/modals/NaNScreen.vue";
|
||||
import state from "game/state";
|
||||
import { coerceComponent, render } from "util/vue";
|
||||
import { CSSProperties, watch } from "vue";
|
||||
import { render } from "util/vue";
|
||||
import type { CSSProperties } from "vue";
|
||||
import { computed, toRef, unref } from "vue";
|
||||
import Game from "./components/Game.vue";
|
||||
import GameOverScreen from "./components/GameOverScreen.vue";
|
||||
import NaNScreen from "./components/NaNScreen.vue";
|
||||
import Nav from "./components/Nav.vue";
|
||||
import TPS from "./components/TPS.vue";
|
||||
import projInfo from "./data/projInfo.json";
|
||||
|
@ -36,9 +41,7 @@ const theme = computed(() => themes[settings.theme].variables as CSSProperties);
|
|||
const showTPS = toRef(settings, "showTPS");
|
||||
const appErrors = toRef(state, "errors");
|
||||
|
||||
const gameComponent = computed(() => {
|
||||
return coerceComponent(jsx(() => (<>{gameComponents.map(render)}</>)));
|
||||
});
|
||||
const GameComponent = () => gameComponents.map(c => render(c));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
@ -9,9 +9,9 @@
|
|||
>
|
||||
<Nav v-if="index === 0 && !useHeader" />
|
||||
<div class="inner-tab">
|
||||
<Layer
|
||||
<LayerVue
|
||||
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,20 +23,37 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import projInfo from "data/projInfo.json";
|
||||
import type { GenericLayer } from "game/layers";
|
||||
import { layers } from "game/layers";
|
||||
import { type Layer, layers } from "game/layers";
|
||||
import player from "game/player";
|
||||
import { computed, toRef, unref } from "vue";
|
||||
import Layer from "./Layer.vue";
|
||||
import LayerVue 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: GenericLayer) {
|
||||
const { display, minimized, name, color, minimizable, nodes, minimizedDisplay } = layer;
|
||||
return { display, minimized, name, color, minimizable, nodes, minimizedDisplay };
|
||||
function gatherLayerProps(layer: Layer) {
|
||||
const {
|
||||
display,
|
||||
name,
|
||||
color,
|
||||
minimizable,
|
||||
minimizedDisplay,
|
||||
minimized,
|
||||
nodes,
|
||||
forceHideGoBack
|
||||
} = layer;
|
||||
return {
|
||||
display,
|
||||
name,
|
||||
color,
|
||||
minimizable,
|
||||
minimizedDisplay,
|
||||
minimized,
|
||||
nodes,
|
||||
forceHideGoBack
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -12,11 +12,11 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GenericHotkey } from "features/hotkey";
|
||||
import { Hotkey } from "features/hotkey";
|
||||
import { watchEffect } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
hotkey: GenericHotkey;
|
||||
hotkey: Hotkey;
|
||||
}>();
|
||||
|
||||
let key = "";
|
||||
|
|
|
@ -8,12 +8,12 @@
|
|||
v-if="unref(minimized)"
|
||||
@click="$emit('setMinimized', false)"
|
||||
>
|
||||
<component v-if="minimizedComponent" :is="minimizedComponent" />
|
||||
<MinimizedComponent v-if="minimizedDisplay" />
|
||||
<div v-else>{{ unref(name) }}</div>
|
||||
</button>
|
||||
<div class="layer-tab" :class="{ showGoBack }" v-else>
|
||||
<Context @update-nodes="updateNodes">
|
||||
<component :is="component" />
|
||||
<Component />
|
||||
</Context>
|
||||
</div>
|
||||
|
||||
|
@ -23,80 +23,48 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import projInfo from "data/projInfo.json";
|
||||
import type { CoercableComponent } from "features/feature";
|
||||
import type { FeatureNode } from "game/layers";
|
||||
import { Layer, type FeatureNode } from "game/layers";
|
||||
import player from "game/player";
|
||||
import { computeComponent, computeOptionalComponent, processedPropType, unwrapRef } from "util/vue";
|
||||
import { PropType, Ref, computed, defineComponent, onErrorCaptured, ref, toRefs, unref } from "vue";
|
||||
import { render } from "util/vue";
|
||||
import { computed, onErrorCaptured, ref, unref } from "vue";
|
||||
import Context from "./Context.vue";
|
||||
import ErrorVue from "./Error.vue";
|
||||
|
||||
export default defineComponent({
|
||||
components: { Context, ErrorVue },
|
||||
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 props = defineProps<{
|
||||
display: Layer["display"];
|
||||
minimizedDisplay: Layer["minimizedDisplay"];
|
||||
minimized: Layer["minimized"];
|
||||
name: Layer["name"];
|
||||
color: Layer["color"];
|
||||
minimizable: Layer["minimizable"];
|
||||
nodes: Layer["nodes"];
|
||||
forceHideGoBack: Layer["forceHideGoBack"];
|
||||
index: number;
|
||||
}>();
|
||||
|
||||
const component = computeComponent(display);
|
||||
const minimizedComponent = computeOptionalComponent(minimizedDisplay);
|
||||
const showGoBack = computed(
|
||||
() => projInfo.allowGoBack && index.value > 0 && !unwrapRef(minimized)
|
||||
);
|
||||
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)
|
||||
);
|
||||
|
||||
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,
|
||||
errors
|
||||
};
|
||||
}
|
||||
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;
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
@ -36,7 +36,7 @@
|
|||
</div>
|
||||
<div @click="savesManager?.open()">
|
||||
<Tooltip display="Saves" :direction="Direction.Down" xoffset="-20px">
|
||||
<span class="material-icons">library_books</span>
|
||||
<span class="material-icons" :class="{ needsSync }">library_books</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div @click="options?.open()">
|
||||
|
@ -53,7 +53,7 @@
|
|||
</div>
|
||||
<div @click="savesManager?.open()">
|
||||
<Tooltip display="Saves" :direction="Direction.Right">
|
||||
<span class="material-icons">library_books</span>
|
||||
<span class="material-icons" :class="{ needsSync }">library_books</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div @click="options?.open()">
|
||||
|
@ -97,13 +97,15 @@
|
|||
<script setup lang="ts">
|
||||
import Changelog from "data/Changelog.vue";
|
||||
import projInfo from "data/projInfo.json";
|
||||
import Tooltip from "features/tooltips/Tooltip.vue";
|
||||
import Tooltip from "wrappers/tooltips/Tooltip.vue";
|
||||
import settings from "game/settings";
|
||||
import { Direction } from "util/common";
|
||||
import { galaxy, syncedSaves } from "util/galaxy";
|
||||
import type { ComponentPublicInstance } from "vue";
|
||||
import { ref } from "vue";
|
||||
import Info from "./Info.vue";
|
||||
import Options from "./Options.vue";
|
||||
import SavesManager from "./SavesManager.vue";
|
||||
import { computed, ref } from "vue";
|
||||
import Info from "./modals/Info.vue";
|
||||
import Options from "./modals/Options.vue";
|
||||
import SavesManager from "./modals/SavesManager.vue";
|
||||
|
||||
const info = ref<ComponentPublicInstance<typeof Info> | null>(null);
|
||||
const savesManager = ref<ComponentPublicInstance<typeof SavesManager> | null>(null);
|
||||
|
@ -117,6 +119,10 @@ const { useHeader, banner, title, discordName, discordLink, versionNumber } = pr
|
|||
function openDiscord() {
|
||||
window.open(discordLink, "mywindow");
|
||||
}
|
||||
|
||||
const needsSync = computed(
|
||||
() => galaxy.value?.loggedIn === true && !syncedSaves.value.includes(settings.active)
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
@ -264,4 +270,32 @@ function openDiscord() {
|
|||
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,10 +4,9 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { RegisterNodeInjectionKey, UnregisterNodeInjectionKey } from "game/layers";
|
||||
import { computed, inject, onUnmounted, shallowRef, toRefs, unref, watch } from "vue";
|
||||
import { computed, inject, onUnmounted, shallowRef, toRef, unref, watch } from "vue";
|
||||
|
||||
const _props = defineProps<{ id: string }>();
|
||||
const props = toRefs(_props);
|
||||
const props = defineProps<{ id: string }>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const register = inject(RegisterNodeInjectionKey, () => {});
|
||||
|
@ -17,7 +16,7 @@ const unregister = inject(UnregisterNodeInjectionKey, () => {});
|
|||
const node = shallowRef<HTMLElement | null>(null);
|
||||
const parentNode = computed(() => node.value && node.value.parentElement);
|
||||
|
||||
watch([parentNode, props.id], ([newNode, newID], [prevNode, prevID]) => {
|
||||
watch([parentNode, toRef(props, "id")], ([newNode, newID], [prevNode, prevID]) => {
|
||||
if (prevNode) {
|
||||
unregister(unref(prevID));
|
||||
}
|
||||
|
@ -26,7 +25,7 @@ watch([parentNode, props.id], ([newNode, newID], [prevNode, prevID]) => {
|
|||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => unregister(unref(props.id)));
|
||||
onUnmounted(() => unregister(props.id));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
.feature:not(li),
|
||||
.feature:not(li) button {
|
||||
.feature {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
button.feature,
|
||||
.feature button {
|
||||
padding: 5px;
|
||||
border-radius: var(--border-radius);
|
||||
border: 2px solid rgba(0, 0, 0, 0.125);
|
||||
|
@ -11,13 +14,17 @@
|
|||
transition: all 0.5s, z-index 0s 0.5s;
|
||||
}
|
||||
|
||||
.can,
|
||||
.feature button {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
button.can,
|
||||
.can button {
|
||||
background-color: var(--layer-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.can:hover,
|
||||
button.can:hover,
|
||||
.can:hover button {
|
||||
transform: scale(1.15, 1.15);
|
||||
box-shadow: 0 0 20px var(--points);
|
||||
|
@ -25,13 +32,13 @@
|
|||
transition: all 0.5s, z-index 0s;
|
||||
}
|
||||
|
||||
.locked,
|
||||
button.locked,
|
||||
.locked button {
|
||||
background-color: var(--locked);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bought,
|
||||
button.bought,
|
||||
.bought button {
|
||||
background-color: var(--bought);
|
||||
cursor: default;
|
||||
|
|
|
@ -20,11 +20,6 @@
|
|||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.row > :not(.feature) {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.col {
|
||||
display: flex;
|
||||
flex-flow: column wrap;
|
||||
|
@ -34,95 +29,148 @@
|
|||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.row.mergeAdjacent > .feature:not(.dontMerge),
|
||||
.row.mergeAdjacent > .tooltip-container > .feature:not(.dontMerge) {
|
||||
.row.mergeAdjacent *,
|
||||
.row.mergeAdjacent button.feature,
|
||||
.row.mergeAdjacent .feature button {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.row.mergeAdjacent button.feature,
|
||||
.row.mergeAdjacent .feature button {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.row.mergeAdjacent > .feature:not(.dontMerge):first-child,
|
||||
.row.mergeAdjacent > .tooltip-container:first-child > .feature:not(.dontMerge) {
|
||||
.row.mergeAdjacent > button.feature:first-child,
|
||||
.row.mergeAdjacent > .feature:first-child button,
|
||||
.row.mergeAdjacent > :first-child button.feature,
|
||||
.row.mergeAdjacent > :first-child .feature button {
|
||||
border-radius: var(--border-radius) 0 0 var(--border-radius);
|
||||
}
|
||||
|
||||
.row.mergeAdjacent > .feature:not(.dontMerge):last-child,
|
||||
.row.mergeAdjacent > .tooltip-container:last-child > .feature:not(.dontMerge) {
|
||||
.row.mergeAdjacent > button.feature:last-child,
|
||||
.row.mergeAdjacent > .feature:last-child button,
|
||||
.row.mergeAdjacent > :last-child button.feature,
|
||||
.row.mergeAdjacent > :last-child .feature button {
|
||||
border-radius: 0 var(--border-radius) var(--border-radius) 0;
|
||||
}
|
||||
|
||||
.row.mergeAdjacent > .feature:not(.dontMerge):first-child:last-child,
|
||||
.row.mergeAdjacent > .tooltip-container:first-child:last-child > .feature:not(.dontMerge) {
|
||||
.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 {
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.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 *,
|
||||
.col.mergeAdjacent button.feature,
|
||||
.col.mergeAdjacent .feature button {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.col.mergeAdjacent button.feature,
|
||||
.col.mergeAdjacent .feature button {
|
||||
border-radius: 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;
|
||||
}
|
||||
|
||||
|
||||
.row-grid.mergeAdjacent > .feature:not(.dontMerge):first-child,
|
||||
.row-grid.mergeAdjacent > .tooltip-container:first-child > .feature:not(.dontMerge) {
|
||||
border-radius: 0 0 0 0;
|
||||
}
|
||||
|
||||
.table-grid > .row-grid.mergeAdjacent:last-child > .feature:not(.dontMerge):first-child {
|
||||
border-radius: 0 0 0 var(--border-radius);
|
||||
}
|
||||
|
||||
.table-grid > .row-grid.mergeAdjacent:first-child > .feature:not(.dontMerge):last-child {
|
||||
border-radius: 0 var(--border-radius) 0 0;
|
||||
}
|
||||
|
||||
.table-grid > .row-grid.mergeAdjacent:first-child > .feature:not(.dontMerge):first-child {
|
||||
border-radius: var(--border-radius) 0 0 0;
|
||||
}
|
||||
|
||||
.table-grid > .row-grid.mergeAdjacent:last-child > .feature:not(.dontMerge):last-child {
|
||||
border-radius: 0 0 var(--border-radius) 0;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
TODO how to implement mergeAdjacent for grids?
|
||||
.row.mergeAdjacent + .row.mergeAdjacent > .feature:not(.dontMerge) {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
*/
|
||||
|
||||
.col.mergeAdjacent .feature:not(.dontMerge) {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.col.mergeAdjacent .feature:not(.dontMerge):first-child {
|
||||
.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;
|
||||
}
|
||||
|
||||
.col.mergeAdjacent .feature:not(.dontMerge):last-child {
|
||||
.col.mergeAdjacent > button.feature:last-child,
|
||||
.col.mergeAdjacent > .feature:last-child button,
|
||||
.col.mergeAdjacent > :last-child button.feature,
|
||||
.col.mergeAdjacent > :last-child .feature button {
|
||||
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
||||
}
|
||||
|
||||
.col.mergeAdjacent .feature:not(.dontMerge):first-child:last-child {
|
||||
.col.mergeAdjacent > button.feature:first-child:last-child,
|
||||
.col.mergeAdjacent > .feature:first-child:last-child button,
|
||||
.col.mergeAdjacent > :first-child:last-child button.feature,
|
||||
.col.mergeAdjacent > :first-child:last-child .feature button {
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
/*
|
||||
TODO how to implement mergeAdjacent for grids?
|
||||
.col.mergeAdjacent + .col.mergeAdjacent > .feature:not(.dontMerge) {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
.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 {
|
||||
border-radius: 0 var(--border-radius) 0 0;
|
||||
}
|
||||
|
||||
.col.mergeAdjacent > .table:last-child > .row.mergeAdjacent > button.feature:last-child,
|
||||
.col.mergeAdjacent > .table:last-child > .row.mergeAdjacent > .feature:last-child button,
|
||||
.col.mergeAdjacent > .table:last-child > .row.mergeAdjacent > :last-child button.feature,
|
||||
.col.mergeAdjacent > .table:last-child > .row.mergeAdjacent > :last-child .feature button,
|
||||
|
||||
.row.mergeAdjacent > .table:last-child > .col.mergeAdjacent > button.feature:last-child,
|
||||
.row.mergeAdjacent > .table:last-child > .col.mergeAdjacent > .feature:last-child button,
|
||||
.row.mergeAdjacent > .table:last-child > .col.mergeAdjacent > :last-child button.feature,
|
||||
.row.mergeAdjacent > .table:last-child > .col.mergeAdjacent > :last-child .feature button {
|
||||
border-radius: 0 0 var(--border-radius) 0;
|
||||
}
|
||||
|
||||
.col.mergeAdjacent > .table:last-child > .row.mergeAdjacent > button.feature:first-child,
|
||||
.col.mergeAdjacent > .table:last-child > .row.mergeAdjacent > .feature:first-child button,
|
||||
.col.mergeAdjacent > .table:last-child > .row.mergeAdjacent > :first-child button.feature,
|
||||
.col.mergeAdjacent > .table:last-child > .row.mergeAdjacent > :first-child .feature button,
|
||||
|
||||
.row.mergeAdjacent > .table:last-child > .col.mergeAdjacent > button.feature:first-child,
|
||||
.row.mergeAdjacent > .table:last-child > .col.mergeAdjacent > .feature:first-child button,
|
||||
.row.mergeAdjacent > .table:last-child > .col.mergeAdjacent > :first-child button.feature,
|
||||
.row.mergeAdjacent > .table:last-child > .col.mergeAdjacent > :first-child .feature button {
|
||||
border-radius: 0 0 0 var(--border-radius);
|
||||
}
|
||||
*/
|
||||
|
|
|
@ -10,13 +10,13 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, toRefs, unref, watch } from "vue";
|
||||
import { ref, 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 (unref(props.skipConfirm)) {
|
||||
if (props.skipConfirm) {
|
||||
emit("click");
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -15,13 +15,13 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const activated = ref(false);
|
||||
const activatedTimeout = ref<NodeJS.Timer | null>(null);
|
||||
const activatedTimeout = ref<NodeJS.Timeout | null>(null);
|
||||
|
||||
function click() {
|
||||
emit("click");
|
||||
|
||||
// Give feedback to user
|
||||
if (activatedTimeout.value) {
|
||||
if (activatedTimeout.value != null) {
|
||||
clearTimeout(activatedTimeout.value);
|
||||
}
|
||||
activated.value = false;
|
||||
|
|
|
@ -1,30 +1,29 @@
|
|||
<template>
|
||||
<div class="field">
|
||||
<span class="field-title" v-if="titleComponent"><component :is="titleComponent" /></span>
|
||||
<span class="field-title" v-if="title"><Title /></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="ts">
|
||||
<script setup lang="tsx">
|
||||
import "components/common/fields.css";
|
||||
import type { CoercableComponent } from "features/feature";
|
||||
import { computeOptionalComponent, unwrapRef } from "util/vue";
|
||||
import { ref, toRef, watch } from "vue";
|
||||
import { render, Renderable } from "util/vue";
|
||||
import { MaybeRef, ref, toRef, unref, 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?: CoercableComponent;
|
||||
title?: MaybeRef<Renderable>;
|
||||
modelValue?: unknown;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
|
@ -34,13 +33,13 @@ const emit = defineEmits<{
|
|||
(e: "update:modelValue", value: unknown): void;
|
||||
}>();
|
||||
|
||||
const titleComponent = computeOptionalComponent(toRef(props, "title"), "span");
|
||||
const Title = () => props.title ? render(props.title, el => <span>{el}</span>) : <></>;
|
||||
|
||||
const value = ref<SelectOption | null>(
|
||||
props.options.find(option => option.value === props.modelValue) ?? null
|
||||
);
|
||||
watch(toRef(props, "modelValue"), modelValue => {
|
||||
if (unwrapRef(value) !== modelValue) {
|
||||
if (unref(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 "features/tooltips/Tooltip.vue";
|
||||
import Tooltip from "wrappers/tooltips/Tooltip.vue";
|
||||
import { Direction } from "util/common";
|
||||
import { computed, toRefs, unref } from "vue";
|
||||
import { computed } 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(unref(props.modelValue) ?? 0);
|
||||
return String(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="titleComponent"
|
||||
><component :is="titleComponent"
|
||||
/></span>
|
||||
<span class="field-title" v-if="title">
|
||||
<Title />
|
||||
</span>
|
||||
<VueTextareaAutosize
|
||||
v-if="textArea"
|
||||
v-model="value"
|
||||
|
@ -25,15 +25,14 @@
|
|||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script setup lang="tsx">
|
||||
import "components/common/fields.css";
|
||||
import type { CoercableComponent } from "features/feature";
|
||||
import { computeOptionalComponent } from "util/vue";
|
||||
import { computed, onMounted, shallowRef, toRef, unref } from "vue";
|
||||
import { render, Renderable } from "util/vue";
|
||||
import { computed, MaybeRef, onMounted, shallowRef, unref } from "vue";
|
||||
import VueTextareaAutosize from "vue-textarea-autosize";
|
||||
|
||||
const props = defineProps<{
|
||||
title?: CoercableComponent;
|
||||
title?: MaybeRef<Renderable>;
|
||||
modelValue?: string;
|
||||
textArea?: boolean;
|
||||
placeholder?: string;
|
||||
|
@ -46,7 +45,7 @@ const emit = defineEmits<{
|
|||
(e: "cancel"): void;
|
||||
}>();
|
||||
|
||||
const titleComponent = computeOptionalComponent(toRef(props, "title"), "span");
|
||||
const Title = () => props.title == null ? <></> : render(props.title, el => <span>{el}</span>);
|
||||
|
||||
const field = shallowRef<HTMLElement | null>(null);
|
||||
onMounted(() => {
|
||||
|
|
|
@ -1,25 +1,24 @@
|
|||
<template>
|
||||
<label class="field">
|
||||
<input type="checkbox" class="toggle" v-model="value" />
|
||||
<component :is="component" />
|
||||
<Component />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script setup lang="tsx">
|
||||
import "components/common/fields.css";
|
||||
import type { CoercableComponent } from "features/feature";
|
||||
import { coerceComponent } from "util/vue";
|
||||
import { computed, unref } from "vue";
|
||||
import { render, Renderable } from "util/vue";
|
||||
import { computed, MaybeRef } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
title?: CoercableComponent;
|
||||
title?: MaybeRef<Renderable>;
|
||||
modelValue?: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: boolean): void;
|
||||
}>();
|
||||
|
||||
const component = computed(() => coerceComponent(unref(props.title) ?? "<span></span>", "span"));
|
||||
const Component = () => render(props.title ?? "", el => <span>{el}</span>);
|
||||
|
||||
const value = computed({
|
||||
get() {
|
||||
|
|
|
@ -1,27 +1,25 @@
|
|||
<template>
|
||||
<Col class="collapsible-container">
|
||||
<button @click="collapsed.value = !collapsed.value" class="feature collapsible-toggle">
|
||||
<component :is="displayComponent" />
|
||||
<Display />
|
||||
</button>
|
||||
<component v-if="!collapsed.value" :is="contentComponent" />
|
||||
<Content v-if="!collapsed.value" />
|
||||
</Col>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CoercableComponent } from "features/feature";
|
||||
import { computeComponent } from "util/vue";
|
||||
import type { Ref } from "vue";
|
||||
import { toRef } from "vue";
|
||||
import { render, Renderable } from "util/vue";
|
||||
import type { MaybeRef, Ref } from "vue";
|
||||
import Col from "./Column.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
collapsed: Ref<boolean>;
|
||||
display: CoercableComponent;
|
||||
content: CoercableComponent;
|
||||
display: MaybeRef<Renderable>;
|
||||
content: MaybeRef<Renderable>;
|
||||
}>();
|
||||
|
||||
const displayComponent = computeComponent(toRef(props, "display"));
|
||||
const contentComponent = computeComponent(toRef(props, "content"));
|
||||
const Display = () => render(props.display);
|
||||
const Content = () => render(props.content);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
@ -12,5 +12,10 @@ import themes from "data/themes";
|
|||
import settings from "game/settings";
|
||||
import { computed } from "vue";
|
||||
|
||||
const mergeAdjacent = computed(() => themes[settings.theme].mergeAdjacent);
|
||||
const props = defineProps<{
|
||||
dontMerge?: boolean
|
||||
}>();
|
||||
|
||||
const mergeAdjacent = computed(() =>
|
||||
themes[settings.theme].mergeAdjacent && props.dontMerge !== true);
|
||||
</script>
|
||||
|
|
|
@ -12,5 +12,10 @@ import themes from "data/themes";
|
|||
import settings from "game/settings";
|
||||
import { computed } from "vue";
|
||||
|
||||
const mergeAdjacent = computed(() => themes[settings.theme].mergeAdjacent);
|
||||
const props = defineProps<{
|
||||
dontMerge?: boolean
|
||||
}>();
|
||||
|
||||
const mergeAdjacent = computed(() =>
|
||||
themes[settings.theme].mergeAdjacent && props.dontMerge !== true);
|
||||
</script>
|
||||
|
|
84
src/components/modals/AddictionWarning.vue
Normal file
84
src/components/modals/AddictionWarning.vue
Normal file
|
@ -0,0 +1,84 @@
|
|||
<template>
|
||||
<Modal v-model="isOpen" v-bind="$attrs">
|
||||
<template v-slot:header>
|
||||
<div class="vga-modal-header">
|
||||
<h2>Kindly consider taking a break.</h2>
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:body>
|
||||
<p>
|
||||
You've been actively enjoying this game for awhile recently - and it's great that
|
||||
you've been having a good time! That said, there are dangers to games like these that you should be aware of:
|
||||
</p>
|
||||
<p>
|
||||
While incremental games can be fun and even healthy in certain contexts, they can
|
||||
exacerbate video game addiction even more than other genres. If you feel like
|
||||
playing incremental games is taking priority over other things in your life, or
|
||||
manipulating your sleep schedule, it may be prudent to seek help.
|
||||
</p>
|
||||
<h4>Resources:</h4>
|
||||
<p>
|
||||
<span>
|
||||
<a style="display: inline" href="https://www.samhsa.gov/" target="_blank">
|
||||
SAMHSA
|
||||
</a>
|
||||
(<a style="display: inline" href="tel:1-800-662-4357">1-800-662-HELP</a>)
|
||||
</span>
|
||||
<br />
|
||||
<a href="https://www.reddit.com/r/StopGaming/">r/StopGaming</a>
|
||||
</p>
|
||||
</template>
|
||||
<template v-slot:footer>
|
||||
<div class="vga-footer">
|
||||
<button @click="neverShow" class="button">Never show this again</button>
|
||||
<button @click="isOpen = false" class="button">Close</button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
<SavesManager ref="savesManager" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import projInfo from "data/projInfo.json";
|
||||
import settings from "game/settings";
|
||||
import state from "game/state";
|
||||
import { ref, watchEffect } from "vue";
|
||||
import Modal from "./Modal.vue";
|
||||
import SavesManager from "./SavesManager.vue";
|
||||
|
||||
const isOpen = ref(false);
|
||||
watchEffect(() => {
|
||||
if (
|
||||
projInfo.disableHealthWarning === false &&
|
||||
settings.showHealthWarning &&
|
||||
state.mouseActivity.filter(i => i).length > 6
|
||||
) {
|
||||
isOpen.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
function neverShow() {
|
||||
settings.showHealthWarning = false;
|
||||
isOpen.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vga-modal-header {
|
||||
padding-top: 10px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.vga-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.vga-footer button {
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
228
src/components/modals/CloudSaveResolver.vue
Normal file
228
src/components/modals/CloudSaveResolver.vue
Normal file
|
@ -0,0 +1,228 @@
|
|||
<template>
|
||||
<Modal v-model="isOpen" width="960px" ref="modal" :prevent-closing="true">
|
||||
<template v-slot:header>
|
||||
<div class="cloud-saves-modal-header">
|
||||
<h2>Cloud {{ pluralizedSave }} loaded!</h2>
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:body>
|
||||
<div>
|
||||
Upon loading, your cloud {{ pluralizedSave }}
|
||||
{{ conflictingSaves.length > 1 ? "appear" : "appears" }} to be out of sync with your
|
||||
local {{ pluralizedSave }}. Which
|
||||
{{ pluralizedSave }}
|
||||
do you want to keep?
|
||||
</div>
|
||||
<br />
|
||||
<div
|
||||
v-for="(conflict, i) in unref(conflictingSaves)"
|
||||
:key="conflict.id"
|
||||
class="conflict-container"
|
||||
>
|
||||
<div @click="selectCloud(i)" :class="{ selected: selectedSaves[i] === 'cloud' }">
|
||||
<h2>
|
||||
Cloud
|
||||
<span
|
||||
v-if="(conflict.cloud.time ?? 0) > (conflict.local.time ?? 0)"
|
||||
class="note"
|
||||
>(more recent)</span
|
||||
>
|
||||
<span
|
||||
v-if="
|
||||
(conflict.cloud.timePlayed ?? 0) > (conflict.local.timePlayed ?? 0)
|
||||
"
|
||||
class="note"
|
||||
>(more playtime)</span
|
||||
>
|
||||
</h2>
|
||||
<Save :save="conflict.cloud" :readonly="true" />
|
||||
</div>
|
||||
<div @click="selectLocal(i)" :class="{ selected: selectedSaves[i] === 'local' }">
|
||||
<h2>
|
||||
Local
|
||||
<span
|
||||
v-if="(conflict.cloud.time ?? 0) <= (conflict.local.time ?? 0)"
|
||||
class="note"
|
||||
>(more recent)</span
|
||||
>
|
||||
<span
|
||||
v-if="
|
||||
(conflict.cloud.timePlayed ?? 0) <= (conflict.local.timePlayed ?? 0)
|
||||
"
|
||||
class="note"
|
||||
>(more playtime)</span
|
||||
>
|
||||
</h2>
|
||||
<Save :save="conflict.local" :readonly="true" />
|
||||
</div>
|
||||
<div
|
||||
@click="selectBoth(i)"
|
||||
:class="{ selected: selectedSaves[i] === 'both' }"
|
||||
style="flex-basis: 30%"
|
||||
>
|
||||
<h2>Both</h2>
|
||||
<div class="save">Keep Both</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:footer>
|
||||
<div class="cloud-saves-footer">
|
||||
<button @click="close" class="button">Confirm</button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { stringifySave } from "game/player";
|
||||
import settings from "game/settings";
|
||||
import LZString from "lz-string";
|
||||
import { conflictingSaves, galaxy } from "util/galaxy";
|
||||
import { getUniqueID, save, setupInitialStore } from "util/save";
|
||||
import { ComponentPublicInstance, computed, ref, unref, watch } from "vue";
|
||||
import Modal from "./Modal.vue";
|
||||
import Save from "./Save.vue";
|
||||
|
||||
const isOpen = ref(false);
|
||||
// True means replacing local save with cloud save
|
||||
const selectedSaves = ref<("cloud" | "local" | "both")[]>([]);
|
||||
|
||||
const pluralizedSave = computed(() => (conflictingSaves.value.length > 1 ? "saves" : "save"));
|
||||
|
||||
const modal = ref<ComponentPublicInstance<typeof Modal> | null>(null);
|
||||
|
||||
watch(
|
||||
() => conflictingSaves.value.length > 0,
|
||||
shouldOpen => {
|
||||
if (shouldOpen) {
|
||||
selectedSaves.value = conflictingSaves.value.map(({ local, cloud }) => {
|
||||
return (local.time ?? 0) < (cloud.time ?? 0) ? "cloud" : "local";
|
||||
});
|
||||
isOpen.value = true;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => modal.value?.isOpen,
|
||||
open => {
|
||||
if (open === false) {
|
||||
conflictingSaves.value = [];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function selectLocal(index: number) {
|
||||
selectedSaves.value[index] = "local";
|
||||
}
|
||||
|
||||
function selectCloud(index: number) {
|
||||
selectedSaves.value[index] = "cloud";
|
||||
}
|
||||
|
||||
function selectBoth(index: number) {
|
||||
selectedSaves.value[index] = "both";
|
||||
}
|
||||
|
||||
function close() {
|
||||
for (let i = 0; i < selectedSaves.value.length; i++) {
|
||||
const { slot, local, cloud } = conflictingSaves.value[i];
|
||||
switch (selectedSaves.value[i]) {
|
||||
case "local":
|
||||
// Replace cloud save with local
|
||||
galaxy.value
|
||||
?.save(
|
||||
slot,
|
||||
LZString.compressToUTF16(stringifySave(setupInitialStore(local))),
|
||||
cloud.name
|
||||
)
|
||||
.catch(console.error);
|
||||
break;
|
||||
case "cloud":
|
||||
// Replace local save with cloud
|
||||
save(setupInitialStore(cloud));
|
||||
break;
|
||||
case "both":
|
||||
// Get a new save ID for the cloud save, and sync the local one to the cloud
|
||||
const id = getUniqueID();
|
||||
save({ ...setupInitialStore(cloud), id });
|
||||
settings.saves.push(id);
|
||||
galaxy.value
|
||||
?.save(
|
||||
slot,
|
||||
LZString.compressToUTF16(stringifySave(setupInitialStore(local))),
|
||||
cloud.name
|
||||
)
|
||||
.catch(console.error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
isOpen.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cloud-saves-modal-header {
|
||||
padding: 10px 0;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.cloud-saves-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cloud-saves-footer button {
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.conflict-container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.conflict-container > * {
|
||||
flex-basis: 50%;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.conflict-container + .conflict-container {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.conflict-container h2 {
|
||||
display: flex;
|
||||
flex-flow: column wrap;
|
||||
height: 1.5em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.note {
|
||||
font-size: x-small;
|
||||
opacity: 0.7;
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
.save {
|
||||
border: solid 4px var(--outline);
|
||||
padding: 4px;
|
||||
background: var(--raised-background);
|
||||
margin: var(--feature-margin);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 30px;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.conflict-container .save {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.conflict-container .selected .save {
|
||||
border-color: var(--bought);
|
||||
}
|
||||
</style>
|
|
@ -18,12 +18,18 @@
|
|||
updates!
|
||||
</div>
|
||||
<br />
|
||||
<div>
|
||||
<div v-if="discordLink && discordName">
|
||||
<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>
|
||||
|
@ -37,14 +43,14 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Modal from "components/Modal.vue";
|
||||
import { hasWon } from "data/projEntry";
|
||||
import projInfo from "data/projInfo.json";
|
||||
import player from "game/player";
|
||||
import { formatTime } from "util/bignum";
|
||||
import { loadSave, newSave } from "util/save";
|
||||
import { computed, toRef } from "vue";
|
||||
import Toggle from "./fields/Toggle.vue";
|
||||
import Toggle from "../fields/Toggle.vue";
|
||||
import Modal from "./Modal.vue";
|
||||
|
||||
const { title, logo, discordName, discordLink, versionNumber, versionTitle } = projInfo;
|
||||
|
|
@ -53,35 +53,31 @@
|
|||
</div>
|
||||
<br />
|
||||
<div>Time Played: {{ timePlayed }}</div>
|
||||
<component :is="infoComponent" />
|
||||
<Info />
|
||||
</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 { coerceComponent, render } from "util/vue";
|
||||
import { computed, ref, toRefs, unref } from "vue";
|
||||
import { render } from "util/vue";
|
||||
import { computed, ref } from "vue";
|
||||
import Modal from "./Modal.vue";
|
||||
|
||||
const { title, logo, author, discordName, discordLink, versionNumber, versionTitle } = projInfo;
|
||||
|
||||
const _props = defineProps<{ changelog: typeof Changelog | null }>();
|
||||
const props = toRefs(_props);
|
||||
const props = defineProps<{ changelog: typeof Changelog | null }>();
|
||||
|
||||
const isOpen = ref(false);
|
||||
|
||||
const timePlayed = computed(() => formatTime(player.timePlayed));
|
||||
|
||||
const infoComponent = computed(() => {
|
||||
return coerceComponent(jsx(() => (<>{infoComponents.map(render)}</>)));
|
||||
});
|
||||
const Info = () => infoComponents.map(f => render(f));
|
||||
|
||||
defineExpose({
|
||||
open() {
|
||||
|
@ -90,7 +86,7 @@ defineExpose({
|
|||
});
|
||||
|
||||
function openChangelog() {
|
||||
unref(props.changelog)?.open();
|
||||
props.changelog?.open();
|
||||
}
|
||||
</script>
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
name="modal"
|
||||
@before-enter="isAnimating = true"
|
||||
@after-leave="isAnimating = false"
|
||||
appear
|
||||
>
|
||||
<div
|
||||
class="modal-mask"
|
||||
|
@ -12,7 +13,7 @@
|
|||
v-bind="$attrs"
|
||||
>
|
||||
<div class="modal-wrapper">
|
||||
<div class="modal-container">
|
||||
<div class="modal-container" :width="width">
|
||||
<div class="modal-header">
|
||||
<slot name="header" :shown="isOpen"> default header </slot>
|
||||
</div>
|
||||
|
@ -40,20 +41,24 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import type { FeatureNode } from "game/layers";
|
||||
import { computed, ref, toRefs, unref } from "vue";
|
||||
import Context from "./Context.vue";
|
||||
import { computed, ref } 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(() => unref(props.modelValue) || isAnimating.value);
|
||||
const isOpen = computed(() => props.modelValue || isAnimating.value);
|
||||
function close() {
|
||||
emit("update:modelValue", false);
|
||||
if (props.preventClosing !== true) {
|
||||
emit("update:modelValue", false);
|
||||
}
|
||||
}
|
||||
|
||||
const isAnimating = ref(false);
|
|
@ -46,7 +46,6 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Modal from "components/Modal.vue";
|
||||
import projInfo from "data/projInfo.json";
|
||||
import player from "game/player";
|
||||
import state from "game/state";
|
||||
|
@ -54,7 +53,8 @@ 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 Toggle from "../fields/Toggle.vue";
|
||||
import Modal from "./Modal.vue";
|
||||
import SavesManager from "./SavesManager.vue";
|
||||
|
||||
const { discordName, discordLink } = projInfo;
|
|
@ -14,12 +14,13 @@
|
|||
<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" />
|
||||
<component :is="settingFieldsComponent" />
|
||||
<SettingFields />
|
||||
<Toggle :title="showTPSTitle" v-model="showTPS" />
|
||||
<Toggle :title="alignModifierUnitsTitle" v-model="alignUnits" />
|
||||
</div>
|
||||
|
@ -28,20 +29,19 @@
|
|||
</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 { coerceComponent, render } from "util/vue";
|
||||
import { save } from "util/save";
|
||||
import { render } from "util/vue";
|
||||
import { computed, ref, toRefs } from "vue";
|
||||
import Select from "./fields/Select.vue";
|
||||
import Toggle from "./fields/Toggle.vue";
|
||||
import FeedbackButton from "./fields/FeedbackButton.vue";
|
||||
import 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";
|
||||
|
||||
const isOpen = ref(false);
|
||||
const currentTab = ref("behaviour");
|
||||
|
@ -68,11 +68,9 @@ const themes = Object.keys(rawThemes).map(theme => ({
|
|||
value: theme
|
||||
}));
|
||||
|
||||
const settingFieldsComponent = computed(() => {
|
||||
return coerceComponent(jsx(() => (<>{settingFields.map(render)}</>)));
|
||||
});
|
||||
const SettingFields = () => settingFields.map(f => render(f));
|
||||
|
||||
const { showTPS, theme, unthrottled, alignUnits } = toRefs(settings);
|
||||
const { showTPS, theme, unthrottled, alignUnits, showHealthWarning } = toRefs(settings);
|
||||
const { autosave, offlineProd } = toRefs(player);
|
||||
const isPaused = computed({
|
||||
get() {
|
||||
|
@ -83,48 +81,38 @@ const isPaused = computed({
|
|||
}
|
||||
});
|
||||
|
||||
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>
|
||||
));
|
||||
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>;
|
||||
</script>
|
||||
|
||||
<style>
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div class="save" :class="{ active: isActive }">
|
||||
<div class="handle material-icons">drag_handle</div>
|
||||
<div class="actions" v-if="!isEditing">
|
||||
<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">
|
||||
<FeedbackButton
|
||||
@click="emit('export')"
|
||||
class="button"
|
||||
|
@ -40,7 +40,7 @@
|
|||
</Tooltip>
|
||||
</DangerButton>
|
||||
</div>
|
||||
<div class="actions" v-else>
|
||||
<div class="actions" v-else-if="readonly !== true">
|
||||
<button @click="changeName" class="button">
|
||||
<Tooltip display="Save" :direction="Direction.Left" class="info">
|
||||
<span class="material-icons">check</span>
|
||||
|
@ -53,12 +53,17 @@
|
|||
</button>
|
||||
</div>
|
||||
<div class="details" v-if="save.error == undefined && !isEditing">
|
||||
<button class="button open" @click="emit('open')">
|
||||
<Tooltip display="Synced!" :direction="Direction.Right" v-if="synced"
|
||||
><span class="material-icons synced">cloud</span></Tooltip
|
||||
>
|
||||
<button class="button open" @click="emit('open')" :disabled="readonly">
|
||||
<h3>{{ save.name }}</h3>
|
||||
</button>
|
||||
<span class="save-version">v{{ save.modVersion }}</span
|
||||
><br />
|
||||
<div v-if="currentTime">Last played {{ dateFormat.format(currentTime) }}</div>
|
||||
<div v-if="currentTime" class="time">
|
||||
Last played {{ dateFormat.format(currentTime) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="details" v-else-if="save.error == undefined && isEditing">
|
||||
<Text v-model="newName" class="editname" @submit="changeName" />
|
||||
|
@ -70,19 +75,21 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Tooltip from "features/tooltips/Tooltip.vue";
|
||||
import Tooltip from "wrappers/tooltips/Tooltip.vue";
|
||||
import player from "game/player";
|
||||
import { Direction } from "util/common";
|
||||
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 { galaxy, syncedSaves } from "util/galaxy";
|
||||
import { computed, ref, 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;
|
||||
|
@ -104,11 +111,19 @@ const isEditing = ref(false);
|
|||
const isConfirming = ref(false);
|
||||
const newName = ref("");
|
||||
|
||||
watch(isEditing, () => (newName.value = save.value.name ?? ""));
|
||||
watch(isEditing, () => (newName.value = props.save.name ?? ""));
|
||||
|
||||
const isActive = computed(() => save.value != null && save.value.id === player.id);
|
||||
const isActive = computed(
|
||||
() => props.save != null && props.save.id === player.id && !props.readonly
|
||||
);
|
||||
const currentTime = computed(() =>
|
||||
isActive.value ? player.time : (save.value != null && save.value.time) ?? 0
|
||||
isActive.value ? player.time : (props.save != null && props.save.time) ?? 0
|
||||
);
|
||||
const synced = computed(
|
||||
() =>
|
||||
!props.readonly &&
|
||||
galaxy.value?.loggedIn === true &&
|
||||
syncedSaves.value.includes(props.save.id)
|
||||
);
|
||||
|
||||
function changeName() {
|
||||
|
@ -139,6 +154,13 @@ function changeName() {
|
|||
padding-left: 0;
|
||||
}
|
||||
|
||||
.open:disabled {
|
||||
cursor: inherit;
|
||||
color: var(--foreground);
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.handle {
|
||||
flex-grow: 0;
|
||||
margin-right: 8px;
|
||||
|
@ -152,6 +174,10 @@ function changeName() {
|
|||
margin-right: 80px;
|
||||
}
|
||||
|
||||
.save.readonly .details {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: 0.8em;
|
||||
color: var(--danger);
|
||||
|
@ -176,6 +202,17 @@ function changeName() {
|
|||
.editname {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.synced {
|
||||
font-size: 100%;
|
||||
margin-right: 0.5em;
|
||||
vertical-align: middle;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
|
@ -201,4 +238,8 @@ function changeName() {
|
|||
.save .field {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.details > .tooltip-container {
|
||||
display: inline;
|
||||
}
|
||||
</style>
|
|
@ -4,6 +4,9 @@
|
|||
<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"
|
||||
|
@ -57,18 +60,28 @@
|
|||
</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 { getUniqueID, loadSave, newSave, save } from "util/save";
|
||||
import { galaxy, syncedSaves } from "util/galaxy";
|
||||
import {
|
||||
clearCachedSave,
|
||||
clearCachedSaves,
|
||||
decodeSave,
|
||||
getCachedSave,
|
||||
getUniqueID,
|
||||
loadSave,
|
||||
newSave,
|
||||
save
|
||||
} from "util/save";
|
||||
import type { ComponentPublicInstance } from "vue";
|
||||
import { computed, nextTick, ref, shallowReactive, watch } from "vue";
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import Draggable from "vuedraggable";
|
||||
import Select from "./fields/Select.vue";
|
||||
import Text from "./fields/Text.vue";
|
||||
import Select from "../fields/Select.vue";
|
||||
import Text from "../fields/Text.vue";
|
||||
import Modal from "./Modal.vue";
|
||||
import Save from "./Save.vue";
|
||||
|
||||
export type LoadablePlayerData = Omit<Partial<Player>, "id"> & { id: string; error?: unknown };
|
||||
|
@ -90,16 +103,8 @@ watch(saveToImport, importedSave => {
|
|||
if (importedSave) {
|
||||
nextTick(() => {
|
||||
try {
|
||||
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 {
|
||||
importedSave = decodeSave(importedSave) ?? "";
|
||||
if (importedSave === "") {
|
||||
console.warn("Unable to determine preset encoding", importedSave);
|
||||
importingFailed.value = true;
|
||||
return;
|
||||
|
@ -125,62 +130,22 @@ watch(saveToImport, importedSave => {
|
|||
}
|
||||
});
|
||||
|
||||
let bankContext = import.meta.globEager("./../../saves/*.txt", { as: "raw" });
|
||||
let bankContext = import.meta.glob("./../../../saves/*.txt", { query: "?raw", eager: true });
|
||||
let bank = ref(
|
||||
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),
|
||||
// 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
|
||||
value: bankContext[curr] 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) {
|
||||
Object.keys(cachedSaves).forEach(key => delete cachedSaves[key]);
|
||||
clearCachedSaves();
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -191,6 +156,10 @@ const saves = computed(() =>
|
|||
}, {})
|
||||
);
|
||||
|
||||
const showNotSyncedWarning = computed(
|
||||
() => galaxy.value?.loggedIn === true && settings.saves.length < syncedSaves.value.length
|
||||
);
|
||||
|
||||
function exportSave(id: string) {
|
||||
let saveToExport;
|
||||
if (player.id === id) {
|
||||
|
@ -233,20 +202,37 @@ 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);
|
||||
cachedSaves[id] = undefined;
|
||||
clearCachedSave(id);
|
||||
}
|
||||
|
||||
function openSave(id: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
saves.value[player.id]!.time = player.time;
|
||||
save();
|
||||
cachedSaves[player.id] = undefined;
|
||||
clearCachedSave(player.id);
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
loadSave(saves.value[id]!);
|
||||
// Delete cached version in case of opening it again
|
||||
cachedSaves[id] = undefined;
|
||||
clearCachedSave(id);
|
||||
}
|
||||
|
||||
function newFromPreset(preset: string) {
|
||||
|
@ -256,16 +242,8 @@ function newFromPreset(preset: string) {
|
|||
selectedPreset.value = null;
|
||||
});
|
||||
|
||||
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 {
|
||||
preset = decodeSave(preset) ?? "";
|
||||
if (preset === "") {
|
||||
console.warn("Unable to determine preset encoding", preset);
|
||||
return;
|
||||
}
|
||||
|
@ -287,7 +265,7 @@ function editSave(id: string, newName: string) {
|
|||
save();
|
||||
} else {
|
||||
save(currSave as Player);
|
||||
cachedSaves[id] = undefined;
|
||||
clearCachedSave(id);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -19,7 +19,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Modal from "components/Modal.vue";
|
||||
import Modal from "components/modals/Modal.vue";
|
||||
import { ref } from "vue";
|
||||
|
||||
const isOpen = ref(false);
|
||||
|
|
|
@ -7,3 +7,12 @@
|
|||
.modifier-toggle.collapsed {
|
||||
transform: translate(-5px, -5px) rotate(-90deg);
|
||||
}
|
||||
|
||||
.node-text {
|
||||
text-anchor: middle;
|
||||
dominant-baseline: middle;
|
||||
font-family: monospace;
|
||||
font-size: 200%;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(3px 3px 2px var(--tooltip-background));
|
||||
}
|
||||
|
|
|
@ -1,64 +1,58 @@
|
|||
import Collapsible from "components/layout/Collapsible.vue";
|
||||
import { GenericAchievement } from "features/achievements/achievement";
|
||||
import type { Clickable, ClickableOptions, GenericClickable } from "features/clickables/clickable";
|
||||
import { Achievement } from "features/achievements/achievement";
|
||||
import type { Clickable, ClickableOptions } from "features/clickables/clickable";
|
||||
import { createClickable } from "features/clickables/clickable";
|
||||
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 { 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 { createTreeNode } from "features/trees/tree";
|
||||
import type { GenericFormula } from "game/formulas/types";
|
||||
import { BaseLayer } from "game/layers";
|
||||
import type { Modifier } from "game/modifiers";
|
||||
import { 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, 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 { WithRequired } from "util/common";
|
||||
import { 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 "./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: GenericConversion;
|
||||
conversion: Conversion;
|
||||
/** The tree this reset button is apart of */
|
||||
tree: GenericTree;
|
||||
tree: Tree;
|
||||
/** The specific tree node associated with this reset button */
|
||||
treeNode: GenericTreeNode;
|
||||
treeNode: TreeNode;
|
||||
/**
|
||||
* Text to display on low conversion amounts, describing what "resetting" is in this context.
|
||||
* Defaults to "Reset for ".
|
||||
*/
|
||||
resetDescription?: Computable<string>;
|
||||
resetDescription?: MaybeRefOrGetter<string>;
|
||||
/** Whether or not to show how much currency would be required to make the gain amount increase. */
|
||||
showNextAt?: Computable<boolean>;
|
||||
showNextAt?: MaybeRefOrGetter<boolean>;
|
||||
/**
|
||||
* The content to display on the button.
|
||||
* By default, this includes the reset description, and amount of currency to be gained.
|
||||
*/
|
||||
display?: Computable<CoercableComponent>;
|
||||
display?: MaybeRefOrGetter<Renderable>;
|
||||
/**
|
||||
* Whether or not this button can currently be clicked.
|
||||
* Defaults to checking the current gain amount is greater than {@link minimumGain}
|
||||
*/
|
||||
canClick?: Computable<boolean>;
|
||||
canClick?: MaybeRefOrGetter<boolean>;
|
||||
/**
|
||||
* When {@link canClick} is left to its default, minimumGain is used to only enable the reset button when a sufficient amount of currency to gain is available.
|
||||
*/
|
||||
minimumGain?: Computable<DecimalSource>;
|
||||
minimumGain?: MaybeRefOrGetter<DecimalSource>;
|
||||
/** A persistent ref to track how much time has passed since the last time this tree node was reset. */
|
||||
resetTime?: Persistent<DecimalSource>;
|
||||
}
|
||||
|
@ -68,108 +62,117 @@ export interface ResetButtonOptions extends ClickableOptions {
|
|||
* It will show how much can be converted currently, and can show when that amount will go up, as well as handle only being clickable when a sufficient amount of currency can be gained.
|
||||
* Assumes this button is associated with a specific node on a tree, and triggers that tree's reset propagation.
|
||||
*/
|
||||
export type ResetButton<T extends ResetButtonOptions> = Replace<
|
||||
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>;
|
||||
}
|
||||
>;
|
||||
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>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: OptionsFunc<T>
|
||||
): ResetButton<T> {
|
||||
return createClickable(feature => {
|
||||
const resetButton = optionsFunc.call(feature, feature);
|
||||
optionsFunc: () => T
|
||||
) {
|
||||
const resetButton = createClickable(() => {
|
||||
const options = optionsFunc();
|
||||
const {
|
||||
conversion,
|
||||
tree,
|
||||
treeNode,
|
||||
resetTime,
|
||||
resetDescription,
|
||||
showNextAt,
|
||||
minimumGain,
|
||||
display,
|
||||
canClick,
|
||||
onClick,
|
||||
...props
|
||||
} = options;
|
||||
|
||||
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(
|
||||
resetButton.conversion.baseResource,
|
||||
!unref(resetButton.conversion.buyMax) &&
|
||||
Decimal.gte(unref(resetButton.conversion.actualGain), 1)
|
||||
? unref(resetButton.conversion.currentAt)
|
||||
: unref(resetButton.conversion.nextAt)
|
||||
)}{" "}
|
||||
{resetButton.conversion.baseResource.displayName}
|
||||
</div>
|
||||
) : null}
|
||||
</span>
|
||||
));
|
||||
}
|
||||
|
||||
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;
|
||||
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:
|
||||
processGetter(display) ??
|
||||
computed(
|
||||
(): JSX.Element => (
|
||||
<span>
|
||||
{unref(resetButton.resetDescription)}
|
||||
<b>
|
||||
{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);
|
||||
}
|
||||
resetButton.conversion.convert();
|
||||
resetButton.tree.reset(resetButton.treeNode);
|
||||
if (resetButton.resetTime) {
|
||||
resetButton.resetTime.value = resetButton.resetTime[DefaultValue];
|
||||
}
|
||||
onClick?.(event);
|
||||
};
|
||||
}) satisfies ResetButton;
|
||||
|
||||
return resetButton;
|
||||
}) as unknown as ResetButton<T>;
|
||||
return resetButton;
|
||||
}
|
||||
|
||||
/** An object that configures a {@link LayerTreeNode} */
|
||||
|
@ -177,75 +180,73 @@ export interface LayerTreeNodeOptions extends TreeNodeOptions {
|
|||
/** The ID of the layer this tree node is associated with */
|
||||
layerID: string;
|
||||
/** The color to display this tree node as */
|
||||
color: Computable<string>; // marking as required
|
||||
color: MaybeRefOrGetter<string>; // marking as required
|
||||
/** Whether or not to append the layer to the tabs list.
|
||||
* 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?: Computable<boolean>;
|
||||
append?: MaybeRefOrGetter<boolean>;
|
||||
}
|
||||
|
||||
/** A tree node that is associated with a given layer, and which opens the layer when clicked. */
|
||||
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>;
|
||||
}
|
||||
>;
|
||||
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>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: OptionsFunc<T>
|
||||
): LayerTreeNode<T> {
|
||||
return createTreeNode(feature => {
|
||||
const options = optionsFunc.call(feature, feature);
|
||||
setDefault(options, "display", camelToTitle(options.layerID));
|
||||
processComputable(options as T, "append");
|
||||
export function createLayerTreeNode<T extends LayerTreeNodeOptions>(optionsFunc: () => T) {
|
||||
const layerTreeNode = createTreeNode(() => {
|
||||
const options = optionsFunc();
|
||||
const { display, append, layerID, ...props } = options;
|
||||
|
||||
return {
|
||||
...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);
|
||||
}
|
||||
...(props as Omit<typeof props, keyof LayerTreeNodeOptions>),
|
||||
layerID,
|
||||
display: processGetter(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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}) as unknown as LayerTreeNode<T>;
|
||||
}) satisfies LayerTreeNode;
|
||||
|
||||
return layerTreeNode;
|
||||
}
|
||||
|
||||
/** An option object for a modifier display as a single section. **/
|
||||
export interface Section {
|
||||
/** The header for this modifier. **/
|
||||
title: Computable<string>;
|
||||
title: MaybeRefOrGetter<string>;
|
||||
/** A subtitle for this modifier, e.g. to explain the context for the modifier. **/
|
||||
subtitle?: Computable<string>;
|
||||
subtitle?: MaybeRefOrGetter<string>;
|
||||
/** The modifier to be displaying in this section. **/
|
||||
modifier: WithRequired<Modifier, "description">;
|
||||
/** The base value being modified. **/
|
||||
base?: Computable<DecimalSource>;
|
||||
base?: MaybeRefOrGetter<DecimalSource>;
|
||||
/** The unit of measurement for the base. **/
|
||||
unit?: string;
|
||||
/** The label to call the base amount. Defaults to "Base". **/
|
||||
baseText?: Computable<CoercableComponent>;
|
||||
baseText?: MaybeRefOrGetter<Renderable>;
|
||||
/** Whether or not this section should be currently visible to the player. **/
|
||||
visible?: Computable<boolean>;
|
||||
visible?: MaybeRefOrGetter<boolean>;
|
||||
/** Determines if numbers larger or smaller than the base should be displayed as red. */
|
||||
smallerIsBetter?: boolean;
|
||||
}
|
||||
|
@ -257,33 +258,33 @@ export interface Section {
|
|||
*/
|
||||
export function createCollapsibleModifierSections(
|
||||
sectionsFunc: () => Section[]
|
||||
): [JSXFunction, Persistent<Record<number, boolean>>] {
|
||||
): [MaybeRef<Renderable>, Persistent<Record<number, boolean>>] {
|
||||
const sections: Section[] = [];
|
||||
const processed:
|
||||
| {
|
||||
base: ProcessedComputable<DecimalSource | undefined>[];
|
||||
baseText: ProcessedComputable<CoercableComponent | undefined>[];
|
||||
visible: ProcessedComputable<boolean | undefined>[];
|
||||
title: ProcessedComputable<string | undefined>[];
|
||||
subtitle: ProcessedComputable<string | undefined>[];
|
||||
base: MaybeRef<DecimalSource | undefined>[];
|
||||
baseText: (MaybeRef<Renderable> | undefined)[];
|
||||
visible: MaybeRef<boolean | undefined>[];
|
||||
title: MaybeRef<string | undefined>[];
|
||||
subtitle: MaybeRef<string | undefined>[];
|
||||
}
|
||||
| Record<string, never> = {};
|
||||
let calculated = false;
|
||||
function calculateSections() {
|
||||
if (!calculated) {
|
||||
sections.push(...sectionsFunc());
|
||||
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));
|
||||
processed.base = sections.map(s => processGetter(s.base));
|
||||
processed.baseText = sections.map(s => processGetter(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));
|
||||
calculated = true;
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
const collapsed = persistent<Record<number, boolean>>({}, false);
|
||||
const jsxFunc = jsx(() => {
|
||||
const jsxFunc = computed(() => {
|
||||
const sections = calculateSections();
|
||||
|
||||
let firstVisibleSection = true;
|
||||
|
@ -310,16 +311,14 @@ export function createCollapsibleModifierSections(
|
|||
<>
|
||||
<div class="modifier-container">
|
||||
<span class="modifier-description">
|
||||
{renderJSX(unref(processed.baseText[i]) ?? "Base")}
|
||||
{render(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
|
||||
: renderJSX(unref(s.modifier.description))}
|
||||
{s.modifier.description == null ? null : render(unref(s.modifier.description))}
|
||||
</>
|
||||
);
|
||||
|
||||
|
@ -382,7 +381,7 @@ export function colorText(textToColor: string, color = "var(--accent2)"): JSX.El
|
|||
* Creates a collapsible display of a list of achievements
|
||||
* @param achievements A dictionary of the achievements to display, inserted in the order from easiest to hardest
|
||||
*/
|
||||
export function createCollapsibleAchievements(achievements: Record<string, GenericAchievement>) {
|
||||
export function createCollapsibleAchievements(achievements: Record<string, Achievement>) {
|
||||
// Achievements are typically defined from easiest to hardest, and we want to show hardest first
|
||||
const orderedAchievements = Object.values(achievements).reverse();
|
||||
const collapseAchievements = persistent<boolean>(true, false);
|
||||
|
@ -393,25 +392,23 @@ export function createCollapsibleAchievements(achievements: Record<string, Gener
|
|||
orderedAchievements,
|
||||
m => m.earned.value
|
||||
);
|
||||
const display = jsx(() => {
|
||||
const display = computed(() => {
|
||||
const achievementsToDisplay = [...lockedAchievements.value];
|
||||
if (firstFeature.value) {
|
||||
achievementsToDisplay.push(firstFeature.value);
|
||||
}
|
||||
return renderColJSX(
|
||||
return renderCol(
|
||||
...achievementsToDisplay,
|
||||
jsx(() => (
|
||||
<Collapsible
|
||||
collapsed={collapseAchievements}
|
||||
content={collapsedContent}
|
||||
display={
|
||||
collapseAchievements.value
|
||||
? "Show other completed achievements"
|
||||
: "Hide other completed achievements"
|
||||
}
|
||||
v-show={unref(hasCollapsedContent)}
|
||||
/>
|
||||
))
|
||||
<Collapsible
|
||||
collapsed={collapseAchievements}
|
||||
content={collapsedContent}
|
||||
display={
|
||||
collapseAchievements.value
|
||||
? "Show other completed achievements"
|
||||
: "Hide other completed achievements"
|
||||
}
|
||||
v-show={unref(hasCollapsedContent)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
return {
|
||||
|
@ -428,11 +425,11 @@ export function createCollapsibleAchievements(achievements: Record<string, Gener
|
|||
*/
|
||||
export function estimateTime(
|
||||
resource: Resource,
|
||||
rate: Computable<DecimalSource>,
|
||||
target: Computable<DecimalSource>
|
||||
rate: MaybeRefOrGetter<DecimalSource>,
|
||||
target: MaybeRefOrGetter<DecimalSource>
|
||||
) {
|
||||
const processedRate = convertComputable(rate);
|
||||
const processedTarget = convertComputable(target);
|
||||
const processedRate = processGetter(rate);
|
||||
const processedTarget = processGetter(target);
|
||||
return computed(() => {
|
||||
const currRate = unref(processedRate);
|
||||
const currTarget = unref(processedTarget);
|
||||
|
@ -454,15 +451,15 @@ export function estimateTime(
|
|||
*/
|
||||
export function createFormulaPreview(
|
||||
formula: GenericFormula,
|
||||
showPreview: Computable<boolean>,
|
||||
previewAmount: Computable<DecimalSource> = 1
|
||||
showPreview: MaybeRefOrGetter<boolean>,
|
||||
previewAmount: MaybeRefOrGetter<DecimalSource> = 1
|
||||
) {
|
||||
const processedShowPreview = convertComputable(showPreview);
|
||||
const processedPreviewAmount = convertComputable(previewAmount);
|
||||
const processedShowPreview = processGetter(showPreview);
|
||||
const processedPreviewAmount = processGetter(previewAmount);
|
||||
if (!formula.hasVariable()) {
|
||||
console.error("Cannot create formula preview if the formula does not have a variable");
|
||||
}
|
||||
return jsx(() => {
|
||||
return computed(() => {
|
||||
if (unref(processedShowPreview)) {
|
||||
const curr = formatSmall(formula.evaluate());
|
||||
const preview = formatSmall(
|
||||
|
@ -505,3 +502,21 @@ export function isRendered(layer: BaseLayer, idOrFeature: string | { id: string
|
|||
const id = typeof idOrFeature === "string" ? idOrFeature : idOrFeature.id;
|
||||
return computed(() => id in layer.nodes.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function for setting up a system where one of many things can be selected.
|
||||
* It's recommended to use an ID or index rather than the object itself, so that you can wrap the ref in a persistent without breaking anything.
|
||||
* @returns The ref containing the selection, as well as a select and deselect function
|
||||
*/
|
||||
export function setupSelectable<T>() {
|
||||
const selected = ref<T>();
|
||||
return {
|
||||
select: function (node: T) {
|
||||
selected.value = node;
|
||||
},
|
||||
deselect: function () {
|
||||
selected.value = undefined;
|
||||
},
|
||||
selected
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,16 +1,17 @@
|
|||
import { main } from "data/projEntry";
|
||||
import { createAchievement } from "features/achievements/achievement";
|
||||
import { jsx } from "features/feature";
|
||||
import { createGrid } from "features/grids/grid";
|
||||
import { createResource } from "features/resources/resource";
|
||||
import Tooltip from "features/tooltips/Tooltip.vue";
|
||||
import { addTooltip } from "features/tooltips/tooltip";
|
||||
import { createTreeNode } from "features/trees/tree";
|
||||
import { createLayer } from "game/layers";
|
||||
import { noPersist } from "game/persistence";
|
||||
import { createCostRequirement } from "game/requirements";
|
||||
import { DecimalSource } from "lib/break_eternity";
|
||||
import Decimal from "util/bignum";
|
||||
import { Direction } from "util/common";
|
||||
import { renderRow } from "util/vue";
|
||||
import { addTooltip } from "wrappers/tooltips/tooltip";
|
||||
import Tooltip from "wrappers/tooltips/Tooltip.vue";
|
||||
|
||||
const id = "a";
|
||||
const layer = createLayer(id, () => {
|
||||
|
@ -36,87 +37,93 @@ const layer = createLayer(id, () => {
|
|||
requirements: [],
|
||||
small: true
|
||||
}));
|
||||
addTooltip(ach1, {
|
||||
addTooltip(ach1, () => ({
|
||||
display() {
|
||||
if (ach1.earned.value) {
|
||||
if (ach1.earned.value === true) {
|
||||
return "You did it!";
|
||||
}
|
||||
return "How did this happen?";
|
||||
},
|
||||
direction: Direction.Down
|
||||
});
|
||||
direction: Direction.Right
|
||||
}));
|
||||
const ach2 = createAchievement(() => ({
|
||||
display: "Impossible!",
|
||||
style: { color: "#04e050" }
|
||||
style: { color: "#04e050" },
|
||||
small: true
|
||||
}));
|
||||
addTooltip(ach2, {
|
||||
addTooltip(ach2, () => ({
|
||||
display() {
|
||||
if (ach2.earned.value) {
|
||||
if (ach2.earned.value === true) {
|
||||
return "HOW????";
|
||||
}
|
||||
return "Mwahahaha!";
|
||||
},
|
||||
direction: Direction.Down
|
||||
});
|
||||
direction: Direction.Right
|
||||
}));
|
||||
const ach3 = createAchievement(() => ({
|
||||
display: "EIEIO",
|
||||
requirements: [],
|
||||
requirements: [
|
||||
createCostRequirement(() => ({
|
||||
cost: 1,
|
||||
resource: noPersist(points),
|
||||
requiresPay: false
|
||||
}))
|
||||
],
|
||||
onComplete() {
|
||||
console.log("Bork bork bork!");
|
||||
},
|
||||
small: true
|
||||
}));
|
||||
addTooltip(ach3, {
|
||||
addTooltip(ach3, () => ({
|
||||
display:
|
||||
"Get a farm point.\n\nReward: The dinosaur is now your friend (you can max Farm Points).",
|
||||
direction: Direction.Down
|
||||
});
|
||||
direction: Direction.Right
|
||||
}));
|
||||
const achievements = [ach1, ach2, ach3];
|
||||
|
||||
const grid = createGrid(() => ({
|
||||
rows: 2,
|
||||
cols: 2,
|
||||
getStartState(id) {
|
||||
return id;
|
||||
getStartState(row, col) {
|
||||
return row * 100 + col;
|
||||
},
|
||||
getStyle(id, state) {
|
||||
return { backgroundColor: `#${(Number(state) * 1234) % 999999}` };
|
||||
getStyle(row, col, state) {
|
||||
return { "--layer-color": `#${(Number(state) * 1234) % 999999}` };
|
||||
},
|
||||
// TODO display should return an object
|
||||
getTitle(id) {
|
||||
let direction = "";
|
||||
if (id === "101") {
|
||||
direction = "top";
|
||||
} else if (id === "102") {
|
||||
direction = "bottom";
|
||||
} else if (id === "201") {
|
||||
direction = "left";
|
||||
} else if (id === "202") {
|
||||
direction = "right";
|
||||
getDisplay: {
|
||||
getTitle(row, col) {
|
||||
const direction = [
|
||||
["top", "bottom"],
|
||||
["left", "right"]
|
||||
][row][col];
|
||||
return (
|
||||
<Tooltip
|
||||
display={JSON.stringify(grid.cells[row][col].style)}
|
||||
{...{ [direction]: true }}
|
||||
direction={Direction.Down}
|
||||
>
|
||||
<h3>Gridable #{`${row}0${col}`}</h3>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
getDescription(row, col, state) {
|
||||
return String(state);
|
||||
}
|
||||
return jsx(() => (
|
||||
<Tooltip display={JSON.stringify(this.cells[id].style)} {...{ [direction]: true }}>
|
||||
<h3>Gridable #{id}</h3>
|
||||
</Tooltip>
|
||||
));
|
||||
},
|
||||
getDisplay(id, state) {
|
||||
return String(state);
|
||||
},
|
||||
getCanClick() {
|
||||
return Decimal.eq(main.points.value, 10);
|
||||
return Decimal.gte(main.points.value, 10);
|
||||
},
|
||||
onClick(id, state) {
|
||||
this.cells[id].state = Number(state) + 1;
|
||||
onClick(row, col, state) {
|
||||
grid.cells[row][col].state = Number(state) + 1;
|
||||
}
|
||||
}));
|
||||
|
||||
const display = jsx(() => (
|
||||
const display = () => (
|
||||
<>
|
||||
{renderRow(...achievements)}
|
||||
{renderRow(grid)}
|
||||
</>
|
||||
));
|
||||
);
|
||||
|
||||
return {
|
||||
id,
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import Modal from "components/Modal.vue";
|
||||
import Slider from "components/fields/Slider.vue";
|
||||
import Text from "components/fields/Text.vue";
|
||||
import Toggle from "components/fields/Toggle.vue";
|
||||
|
@ -7,33 +6,35 @@ import Row from "components/layout/Row.vue";
|
|||
import Spacer from "components/layout/Spacer.vue";
|
||||
import Sticky from "components/layout/Sticky.vue";
|
||||
import VerticalRule from "components/layout/VerticalRule.vue";
|
||||
import Modal from "components/modals/Modal.vue";
|
||||
import { createLayerTreeNode, createResetButton } from "data/common";
|
||||
import { main } from "data/projEntry";
|
||||
import themes from "data/themes";
|
||||
import { createAchievement } from "features/achievements/achievement";
|
||||
import { createBar } from "features/bars/bar";
|
||||
import { createChallenge } from "features/challenges/challenge";
|
||||
import { createClickable } from "features/clickables/clickable";
|
||||
import { createRepeatable } from "features/clickables/repeatable";
|
||||
import { createUpgrade } from "features/clickables/upgrade";
|
||||
import { createCumulativeConversion } from "features/conversion";
|
||||
import { Visibility, jsx } from "features/feature";
|
||||
import { Visibility } from "features/feature";
|
||||
import { createHotkey } from "features/hotkey";
|
||||
import { createInfobox } from "features/infoboxes/infobox";
|
||||
import { createLinks } from "features/links/links";
|
||||
import { createRepeatable } from "features/repeatable";
|
||||
import { createReset } from "features/reset";
|
||||
import MainDisplay from "features/resources/MainDisplay.vue";
|
||||
import Resource from "features/resources/Resource.vue";
|
||||
import { createResource, displayResource, trackBest } from "features/resources/resource";
|
||||
import { createTab } from "features/tabs/tab";
|
||||
import { GenericTabFamily, createTabFamily } from "features/tabs/tabFamily";
|
||||
import { addTooltip } from "features/tooltips/tooltip";
|
||||
import { TabFamily, createTabFamily } from "features/tabs/tabFamily";
|
||||
import {
|
||||
GenericTreeNode,
|
||||
Tree,
|
||||
TreeBranch,
|
||||
createResourceTooltip,
|
||||
createTree,
|
||||
createTreeNode
|
||||
} from "features/trees/tree";
|
||||
import { createUpgrade } from "features/upgrades/upgrade";
|
||||
import { InvertibleFormula } from "game/formulas/types";
|
||||
import { createLayer } from "game/layers";
|
||||
import {
|
||||
createAdditiveModifier,
|
||||
|
@ -47,12 +48,10 @@ import settings from "game/settings";
|
|||
import { DecimalSource } from "lib/break_eternity";
|
||||
import Decimal, { format, formatWhole } from "util/bignum";
|
||||
import { Direction } from "util/common";
|
||||
import { render, renderCol, renderRow } from "util/vue";
|
||||
import { render, Renderable, renderCol, renderRow } from "util/vue";
|
||||
import { ComputedRef, Ref, computed, ref, unref } from "vue";
|
||||
import { addTooltip } from "wrappers/tooltips/tooltip";
|
||||
import f from "./f";
|
||||
import { ProcessedComputable } from "util/computed";
|
||||
import { createAchievement } from "features/achievements/achievement";
|
||||
import { InvertibleFormula } from "game/formulas/types";
|
||||
|
||||
const id = "c";
|
||||
const layer = createLayer(id, () => {
|
||||
|
@ -72,7 +71,7 @@ const layer = createLayer(id, () => {
|
|||
title: "Lore",
|
||||
titleStyle: { color: "#FE0000" },
|
||||
display: "DEEP LORE!",
|
||||
bodyStyle: { backgroundColor: "#0000EE" },
|
||||
bodyStyle: { "backgroundColor": "#0000EE" },
|
||||
color: "rgb(75, 220, 19)"
|
||||
}));
|
||||
|
||||
|
@ -89,7 +88,7 @@ const layer = createLayer(id, () => {
|
|||
display: {
|
||||
requirement: "4 Lollipops",
|
||||
effectDisplay: "You can toggle beep and boop (which do nothing)",
|
||||
optionsDisplay: jsx(() => (
|
||||
optionsDisplay: () => (
|
||||
<>
|
||||
<Toggle
|
||||
title="beep"
|
||||
|
@ -102,11 +101,11 @@ const layer = createLayer(id, () => {
|
|||
modelValue={f.boop.value}
|
||||
/>
|
||||
</>
|
||||
))
|
||||
)
|
||||
},
|
||||
style() {
|
||||
if (unref(this.earned)) {
|
||||
return { backgroundColor: "#1111DD" };
|
||||
if (unref(lollipopMilestone4.earned.value) !== false) {
|
||||
return { "--layer-color": "#1111DD" };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -121,15 +120,15 @@ const layer = createLayer(id, () => {
|
|||
spendResources: false
|
||||
})),
|
||||
completionLimit: 3,
|
||||
display() {
|
||||
return {
|
||||
description: `Makes the game 0% harder<br>${formatWhole(this.completions.value)}/${
|
||||
this.completionLimit
|
||||
} completions`,
|
||||
goal: "Have 20 points I guess",
|
||||
reward: "Says hi",
|
||||
effectDisplay: format(funEffect.value) + "x"
|
||||
};
|
||||
display: {
|
||||
description: (): Renderable => <>
|
||||
Makes the game 0% harder<br/>{formatWhole(funChallenge.completions.value)}/{
|
||||
funChallenge.completionLimit
|
||||
} completions
|
||||
</>,
|
||||
goal: "Have 20 points I guess",
|
||||
reward: "Says hi",
|
||||
effectDisplay: format(funEffect.value) + "x"
|
||||
},
|
||||
visibility: () => Decimal.gt(best.value, 0),
|
||||
onComplete() {
|
||||
|
@ -145,7 +144,7 @@ const layer = createLayer(id, () => {
|
|||
console.log("Sweet freedom!");
|
||||
},
|
||||
style: {
|
||||
height: "200px"
|
||||
height: "400px"
|
||||
}
|
||||
}));
|
||||
const funEffect = computed(() => Decimal.add(points.value, 1).tetrate(0.02));
|
||||
|
@ -161,10 +160,10 @@ const layer = createLayer(id, () => {
|
|||
}))
|
||||
}));
|
||||
const lollipopMultiplierUpgrade = createUpgrade(() => ({
|
||||
display: () => ({
|
||||
display: {
|
||||
description: "Point generation is faster based on your unspent Lollipops",
|
||||
effectDisplay: `${format(lollipopMultiplierEffect.value)}x`
|
||||
}),
|
||||
effectDisplay: () => `${format(lollipopMultiplierEffect.value)}x`
|
||||
},
|
||||
requirements: createCostRequirement(() => ({
|
||||
cost: 1,
|
||||
resource: noPersist(points)
|
||||
|
@ -189,11 +188,11 @@ const layer = createLayer(id, () => {
|
|||
display:
|
||||
"Only buyable with less than 7 points, and gives you 7 more. Unlocks a secret subtab.",
|
||||
style() {
|
||||
if (unref(this.bought)) {
|
||||
return { backgroundColor: "#1111dd" };
|
||||
if (unref(unlockIlluminatiUpgrade.bought)) {
|
||||
return { "--layer-color": "#1111dd" };
|
||||
}
|
||||
if (!unref(this.canPurchase)) {
|
||||
return { backgroundColor: "#dd1111" };
|
||||
if (!unref(unlockIlluminatiUpgrade.canPurchase)) {
|
||||
return { "--layer-color": "#dd1111" };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -220,21 +219,20 @@ const layer = createLayer(id, () => {
|
|||
const cost = Decimal.pow(2, x.pow(1.5));
|
||||
return cost.floor();
|
||||
},
|
||||
pay(amount) {
|
||||
const cost = unref(this.cost as unknown as ProcessedComputable<DecimalSource>);
|
||||
spentOnBuyables.value = Decimal.add(spentOnBuyables.value, cost ?? 0);
|
||||
this.resource.value = Decimal.sub(this.resource.value, cost).max(0);
|
||||
pay() {
|
||||
const cost = unref(exhancersCost.cost) as DecimalSource;
|
||||
spentOnBuyables.value = Decimal.add(spentOnBuyables.value, cost);
|
||||
exhancersCost.resource.value = Decimal.sub(exhancersCost.resource.value, cost).max(0);
|
||||
}
|
||||
}));
|
||||
const exhancers = createRepeatable(() => ({
|
||||
requirements: exhancersCost,
|
||||
display() {
|
||||
return {
|
||||
title: "Exhancers",
|
||||
description: `Adds ${format(
|
||||
display: {
|
||||
title: "Exhancers",
|
||||
description: () =>
|
||||
`Adds ${format(
|
||||
thingEffect.value
|
||||
)} things and multiplies stuff by ${format(stuffEffect.value)}.`
|
||||
};
|
||||
},
|
||||
style: { height: "222px" },
|
||||
purchaseLimit: 4
|
||||
|
@ -282,7 +280,7 @@ const layer = createLayer(id, () => {
|
|||
spentOnBuyables.value = Decimal.sub(spentOnBuyables.value, cost);
|
||||
}
|
||||
}));
|
||||
const buyablesDisplay = jsx(() => (
|
||||
const buyablesDisplay = computed(() => (
|
||||
<Column>
|
||||
<Row>
|
||||
<Toggle
|
||||
|
@ -311,8 +309,8 @@ const layer = createLayer(id, () => {
|
|||
</button>
|
||||
<button
|
||||
class="button modal-default-button danger"
|
||||
onClick={() => {
|
||||
respecBuyables.onClick();
|
||||
onClick={e => {
|
||||
respecBuyables.onClick?.(e);
|
||||
confirming.value = false;
|
||||
}}
|
||||
>
|
||||
|
@ -396,8 +394,8 @@ const layer = createLayer(id, () => {
|
|||
key: "c",
|
||||
description: "reset for lollipops or whatever",
|
||||
onPress() {
|
||||
if (resetButton.canClick.value) {
|
||||
resetButton.onClick();
|
||||
if (unref(resetButton.canClick) !== false) {
|
||||
resetButton.onClick?.(undefined);
|
||||
}
|
||||
}
|
||||
})),
|
||||
|
@ -405,7 +403,7 @@ const layer = createLayer(id, () => {
|
|||
key: "ctrl+c",
|
||||
description: "respec things",
|
||||
onPress() {
|
||||
respecBuyables.onClick();
|
||||
respecBuyables.onClick?.(undefined);
|
||||
}
|
||||
}))
|
||||
];
|
||||
|
@ -429,10 +427,10 @@ const layer = createLayer(id, () => {
|
|||
textDecoration: "underline"
|
||||
}
|
||||
}));
|
||||
const treeNodeTooltip = addTooltip(treeNode, {
|
||||
const treeNodeTooltip = addTooltip(treeNode, () => ({
|
||||
display: createResourceTooltip(points),
|
||||
pinnable: true
|
||||
});
|
||||
}));
|
||||
|
||||
const resetButton = createResetButton(() => ({
|
||||
conversion,
|
||||
|
@ -443,8 +441,8 @@ const layer = createLayer(id, () => {
|
|||
},
|
||||
resetDescription: "Melt your points into "
|
||||
}));
|
||||
const resetButtonTooltip = addTooltip(resetButton, {
|
||||
display: jsx(() =>
|
||||
const resetButtonTooltip = addTooltip(resetButton, () => ({
|
||||
display: computed(() =>
|
||||
createModifierSection({
|
||||
title: "Modifiers",
|
||||
modifier: conversionModifier
|
||||
|
@ -452,8 +450,8 @@ const layer = createLayer(id, () => {
|
|||
),
|
||||
pinnable: true,
|
||||
direction: Direction.Down,
|
||||
style: "width: 400px; text-align: left"
|
||||
});
|
||||
style: { width: "400px", textAlign: "left" }
|
||||
}));
|
||||
|
||||
const g = createTreeNode(() => ({
|
||||
display: "TH",
|
||||
|
@ -493,7 +491,7 @@ const layer = createLayer(id, () => {
|
|||
visibility: Visibility.Hidden
|
||||
}));
|
||||
const tree = createTree(() => ({
|
||||
nodes(): GenericTreeNode[][] {
|
||||
nodes() {
|
||||
return [
|
||||
[f.treeNode, treeNode],
|
||||
[g, spook, h]
|
||||
|
@ -514,7 +512,7 @@ const layer = createLayer(id, () => {
|
|||
{ startNode: g, endNode: h }
|
||||
];
|
||||
}
|
||||
}));
|
||||
})) as Tree;
|
||||
|
||||
const links = createLinks(() => ({
|
||||
links: [
|
||||
|
@ -523,7 +521,10 @@ const layer = createLayer(id, () => {
|
|||
endNode: flatBoi,
|
||||
"stroke-width": "5px",
|
||||
stroke: "red",
|
||||
offsetEnd: { x: -50 + 100 * flatBoi.progress.value.toNumber(), y: 0 }
|
||||
offsetEnd: {
|
||||
x: -50 + 100 * Number(new Decimal(unref(flatBoi.progress).toString())),
|
||||
y: 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
@ -531,13 +532,13 @@ const layer = createLayer(id, () => {
|
|||
const illuminatiTabs = createTabFamily(
|
||||
{
|
||||
first: () => ({
|
||||
tab: jsx(() => (
|
||||
tab: () => (
|
||||
<>
|
||||
{renderRow(...upgrades)}
|
||||
{renderRow(quasiUpgrade)}
|
||||
<div>confirmed</div>
|
||||
</>
|
||||
)),
|
||||
),
|
||||
display: "first"
|
||||
}),
|
||||
second: () => ({
|
||||
|
@ -555,21 +556,13 @@ const layer = createLayer(id, () => {
|
|||
marginRight: "auto"
|
||||
}
|
||||
})
|
||||
) as GenericTabFamily;
|
||||
) as TabFamily;
|
||||
|
||||
const tabs = createTabFamily({
|
||||
mainTab: () => ({
|
||||
tab: createTab(() => ({
|
||||
display: jsx(() => (
|
||||
display: () => (
|
||||
<>
|
||||
<MainDisplay
|
||||
resource={points}
|
||||
color={color}
|
||||
effectDisplay={`which are boosting waffles by ${format(
|
||||
waffleBoost.value
|
||||
)} and increasing the Ice Cream cap by ${format(icecreamCap.value)}`}
|
||||
/>
|
||||
<Sticky>{render(resetButton)}</Sticky>
|
||||
<Resource resource={points} color={color} />
|
||||
<Spacer height="5px" />
|
||||
<button onClick={() => console.log("yeet")}>'HI'</button>
|
||||
|
@ -588,7 +581,7 @@ const layer = createLayer(id, () => {
|
|||
{renderRow(quasiUpgrade)}
|
||||
{renderRow(funChallenge)}
|
||||
</>
|
||||
))
|
||||
)
|
||||
})),
|
||||
display: "main tab",
|
||||
glowColor() {
|
||||
|
@ -609,7 +602,7 @@ const layer = createLayer(id, () => {
|
|||
style() {
|
||||
return { backgroundColor: "#222222", "--background": "#222222" };
|
||||
},
|
||||
display: jsx(() => (
|
||||
display: () => (
|
||||
<>
|
||||
{render(buyablesDisplay)}
|
||||
<Spacer />
|
||||
|
@ -628,7 +621,7 @@ const layer = createLayer(id, () => {
|
|||
<Spacer />
|
||||
<img src="https://unsoftcapped2.github.io/The-Modding-Tree-2/discord.png" />
|
||||
</>
|
||||
))
|
||||
)
|
||||
})),
|
||||
glowColor: "white",
|
||||
display: "thingies",
|
||||
|
@ -636,7 +629,7 @@ const layer = createLayer(id, () => {
|
|||
}),
|
||||
jail: () => ({
|
||||
tab: createTab(() => ({
|
||||
display: jsx(() => (
|
||||
display: () => (
|
||||
<>
|
||||
{render(coolInfo)}
|
||||
{render(longBoi)}
|
||||
|
@ -658,16 +651,14 @@ const layer = createLayer(id, () => {
|
|||
<div>It's jail because "bars"! So funny! Ha ha!</div>
|
||||
{render(tree)}
|
||||
</>
|
||||
))
|
||||
)
|
||||
})),
|
||||
display: "jail"
|
||||
}),
|
||||
illuminati: () => ({
|
||||
tab: createTab(() => ({
|
||||
display: jsx(() => (
|
||||
// This should really just be <> and </>, however for some reason the
|
||||
// typescript interpreter can't figure out this layer and f.tsx otherwise
|
||||
<div>
|
||||
display: () => (
|
||||
<>
|
||||
<h1> C O N F I R M E D </h1>
|
||||
<Spacer />
|
||||
{render(illuminatiTabs)}
|
||||
|
@ -678,8 +669,8 @@ const layer = createLayer(id, () => {
|
|||
min={1}
|
||||
max={30}
|
||||
/>
|
||||
</div>
|
||||
)),
|
||||
</>
|
||||
),
|
||||
style: {
|
||||
backgroundColor: "#3325CC"
|
||||
}
|
||||
|
@ -727,12 +718,20 @@ const layer = createLayer(id, () => {
|
|||
confirmRespec,
|
||||
minWidth: 800,
|
||||
tabs,
|
||||
display: jsx(() => (
|
||||
display: () => (
|
||||
<>
|
||||
<MainDisplay
|
||||
resource={points}
|
||||
color={color}
|
||||
effectDisplay={`which are boosting waffles by ${format(
|
||||
waffleBoost.value
|
||||
)} and increasing the Ice Cream cap by ${format(icecreamCap.value)}`}
|
||||
/>
|
||||
{render(resetButton)}
|
||||
{render(tabs)}
|
||||
{render(links)}
|
||||
</>
|
||||
)),
|
||||
),
|
||||
treeNodeTooltip,
|
||||
resetButtonTooltip
|
||||
};
|
||||
|
|
|
@ -3,22 +3,20 @@ import { createLayerTreeNode, createResetButton } from "data/common";
|
|||
import { main } from "data/projEntry";
|
||||
import { createClickable } from "features/clickables/clickable";
|
||||
import { createIndependentConversion } from "features/conversion";
|
||||
import { jsx } from "features/feature";
|
||||
import { createInfobox } from "features/infoboxes/infobox";
|
||||
import { createParticles } from "features/particles/particles";
|
||||
import { createReset } from "features/reset";
|
||||
import MainDisplay from "features/resources/MainDisplay.vue";
|
||||
import { createResource, displayResource } from "features/resources/resource";
|
||||
import { addTooltip } from "features/tooltips/tooltip";
|
||||
import { createResourceTooltip } from "features/trees/tree";
|
||||
import Formula from "game/formulas/formulas";
|
||||
import { createLayer } from "game/layers";
|
||||
import { noPersist, persistent } from "game/persistence";
|
||||
import Decimal, { DecimalSource, formatWhole } from "util/bignum";
|
||||
import { render, renderRow } from "util/vue";
|
||||
import { ref } from "vue";
|
||||
import { ref, unref } from "vue";
|
||||
import c from "./c";
|
||||
import confetti from "./confetti.json";
|
||||
import { addTooltip } from "wrappers/tooltips/tooltip";
|
||||
|
||||
const id = "f";
|
||||
const layer = createLayer(id, () => {
|
||||
|
@ -34,13 +32,11 @@ const layer = createLayer(id, () => {
|
|||
bodyStyle: { backgroundColor: "#0000EE" }
|
||||
}));
|
||||
|
||||
const clickableState = persistent<string>("Start");
|
||||
const clickableState = persistent<string>("Start", false);
|
||||
const clickable = createClickable(() => ({
|
||||
display() {
|
||||
return {
|
||||
title: "Clicky clicky!",
|
||||
description: "Current state:<br>" + clickableState.value
|
||||
};
|
||||
display: {
|
||||
title: "Clicky clicky!",
|
||||
description: () => <>Current state:<br/>{clickableState.value}</>
|
||||
},
|
||||
initialState: "Start",
|
||||
canClick() {
|
||||
|
@ -58,7 +54,7 @@ const layer = createLayer(id, () => {
|
|||
clickableState.value = "Maybe that's a bit too far...";
|
||||
break;
|
||||
case "Maybe that's a bit too far...":
|
||||
const pos = e == undefined ? undefined : "touches" in e ? e.touches[0] : e;
|
||||
const pos = e == null ? undefined : "touches" in e ? e.touches[0] : e;
|
||||
const confettiParticles = Object.assign({}, confetti, {
|
||||
pos: {
|
||||
x: (pos?.clientX ?? 0) - (particles.boundingRect?.value?.left ?? 0),
|
||||
|
@ -79,13 +75,13 @@ const layer = createLayer(id, () => {
|
|||
style() {
|
||||
switch (clickableState.value) {
|
||||
case "Start":
|
||||
return { "background-color": "green" };
|
||||
return { "--layer-color": "green" };
|
||||
case "A new state!":
|
||||
return { "background-color": "yellow" };
|
||||
return { "--layer-color": "yellow" };
|
||||
case "Keep going!":
|
||||
return { "background-color": "orange" };
|
||||
return { "--layer-color": "orange" };
|
||||
case "Maybe that's a bit too far...":
|
||||
return { "background-color": "red" };
|
||||
return { "--layer-color": "red" };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
|
@ -94,12 +90,12 @@ const layer = createLayer(id, () => {
|
|||
|
||||
const resetClickable = createClickable(() => ({
|
||||
onClick() {
|
||||
if (clickableState.value == "Borkened...") {
|
||||
if (clickableState.value === "Borkened...") {
|
||||
clickableState.value = "Start";
|
||||
}
|
||||
},
|
||||
display() {
|
||||
return clickableState.value == "Borkened..." ? "Fix the clickable!" : "Does nothing";
|
||||
return clickableState.value === "Borkened..." ? "Fix the clickable!" : "Does nothing";
|
||||
},
|
||||
small: true
|
||||
}));
|
||||
|
@ -119,7 +115,7 @@ const layer = createLayer(id, () => {
|
|||
color,
|
||||
reset,
|
||||
tooltip() {
|
||||
if (treeNode.canClick.value) {
|
||||
if (unref(treeNode.canClick) !== false) {
|
||||
return `${displayResource(points)} ${points.displayName}`;
|
||||
}
|
||||
return `This weird farmer dinosaur will only see you if you have at least 10 points. You only have ${displayResource(
|
||||
|
@ -130,23 +126,23 @@ const layer = createLayer(id, () => {
|
|||
return Decimal.gte(main.points.value, 10);
|
||||
}
|
||||
}));
|
||||
const tooltip = addTooltip(treeNode, {
|
||||
const tooltip = addTooltip(treeNode, () => ({
|
||||
display: createResourceTooltip(points),
|
||||
pinnable: true
|
||||
});
|
||||
}));
|
||||
|
||||
const resetButton = createResetButton(() => ({
|
||||
conversion,
|
||||
tree: main.tree,
|
||||
treeNode,
|
||||
display: jsx(() => {
|
||||
if (resetButton.conversion.buyMax) {
|
||||
display: () => {
|
||||
if (unref(resetButton.conversion.buyMax) !== false) {
|
||||
return (
|
||||
<span>
|
||||
Hi! I'm a <u>weird dinosaur</u> and I'll give you{" "}
|
||||
<b>{formatWhole(resetButton.conversion.currentGain.value)}</b> Farm Points
|
||||
<b>{formatWhole(unref(resetButton.conversion.currentGain))}</b> Farm Points
|
||||
in exchange for all of your points and lollipops! (You'll get another one at{" "}
|
||||
{formatWhole(resetButton.conversion.nextAt.value)} points)
|
||||
{formatWhole(unref(resetButton.conversion.nextAt))} points)
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
|
@ -154,39 +150,21 @@ const layer = createLayer(id, () => {
|
|||
<span>
|
||||
Hi! I'm a <u>weird dinosaur</u> and I'll give you a Farm Point in exchange
|
||||
for all of your points and lollipops! (At least{" "}
|
||||
{formatWhole(resetButton.conversion.nextAt.value)} points)
|
||||
{formatWhole(unref(resetButton.conversion.nextAt))} points)
|
||||
</span>
|
||||
);
|
||||
}
|
||||
})
|
||||
}
|
||||
}));
|
||||
|
||||
const particles = createParticles(() => ({
|
||||
boundingRect: ref<null | DOMRect>(null),
|
||||
onContainerResized(boundingRect) {
|
||||
this.boundingRect.value = boundingRect;
|
||||
particles.boundingRect.value = boundingRect;
|
||||
},
|
||||
style: "z-index: 2"
|
||||
style: { zIndex: 2 }
|
||||
}));
|
||||
|
||||
const tab = jsx(() => (
|
||||
<>
|
||||
{render(coolInfo)}
|
||||
<MainDisplay resource={points} color={color} />
|
||||
{render(resetButton)}
|
||||
<div>You have {formatWhole(conversion.baseResource.value)} points</div>
|
||||
<div>
|
||||
<br />
|
||||
<img src="https://images.beano.com/store/24ab3094eb95e5373bca1ccd6f330d4406db8d1f517fc4170b32e146f80d?auto=compress%2Cformat&dpr=1&w=390" />
|
||||
<div>Bork Bork!</div>
|
||||
</div>
|
||||
<Spacer />
|
||||
{renderRow(resetClickable)}
|
||||
{renderRow(clickable)}
|
||||
{render(particles)}
|
||||
</>
|
||||
));
|
||||
|
||||
return {
|
||||
id,
|
||||
color,
|
||||
|
@ -201,7 +179,23 @@ const layer = createLayer(id, () => {
|
|||
treeNode,
|
||||
resetButton,
|
||||
minWidth: 650,
|
||||
display: tab,
|
||||
display: () => (
|
||||
<>
|
||||
{render(coolInfo)}
|
||||
<MainDisplay resource={points} color={color} />
|
||||
{render(resetButton)}
|
||||
<div>You have {formatWhole(conversion.baseResource.value)} points</div>
|
||||
<div>
|
||||
<br />
|
||||
<img src="https://www.thepaperpilot.org/paperpilot.png" height="200px" />
|
||||
<div>Bork Bork!</div>
|
||||
</div>
|
||||
<Spacer />
|
||||
{renderRow(resetClickable)}
|
||||
{renderRow(clickable)}
|
||||
{render(particles)}
|
||||
</>
|
||||
),
|
||||
tooltip
|
||||
};
|
||||
});
|
||||
|
|
424
src/data/layers/board.tsx
Normal file
424
src/data/layers/board.tsx
Normal file
|
@ -0,0 +1,424 @@
|
|||
import { createUpgrade } from "features/clickables/upgrade";
|
||||
import { createResource } from "features/resources/resource";
|
||||
import Board from "game/boards/Board.vue";
|
||||
import CircleProgress from "game/boards/CircleProgress.vue";
|
||||
import SVGNode from "game/boards/SVGNode.vue";
|
||||
import SquareProgress from "game/boards/SquareProgress.vue";
|
||||
import {
|
||||
Draggable,
|
||||
MakeDraggableOptions,
|
||||
NodePosition,
|
||||
makeDraggable,
|
||||
placeInAvailableSpace,
|
||||
setupActions,
|
||||
setupDraggableNode,
|
||||
setupUniqueIds
|
||||
} from "game/boards/board";
|
||||
import type { BaseLayer } from "game/layers";
|
||||
import { createLayer } from "game/layers";
|
||||
import { persistent } from "game/persistence";
|
||||
import { createCostRequirement } from "game/requirements";
|
||||
import { render } from "util/vue";
|
||||
import { ComponentPublicInstance, computed, ref, watch } from "vue";
|
||||
import { setupSelectable } from "../common";
|
||||
|
||||
const board = createLayer("board", function (this: BaseLayer) {
|
||||
type ANode = NodePosition & { id: number; links: number[]; type: "anode"; z: number };
|
||||
type BNode = NodePosition & { id: number; links: number[]; type: "bnode"; z: number };
|
||||
type CNode = typeof cNode & { draggable: Draggable<number | "cNode"> };
|
||||
type NodeTypes = ANode | BNode;
|
||||
|
||||
const board = ref<ComponentPublicInstance<typeof Board>>();
|
||||
|
||||
const { select, deselect, selected } = setupSelectable<number>();
|
||||
const {
|
||||
select: selectAction,
|
||||
deselect: deselectAction,
|
||||
selected: selectedAction
|
||||
} = setupSelectable<number>();
|
||||
|
||||
watch(selected, selected => {
|
||||
if (selected == null) {
|
||||
deselectAction();
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
startDrag,
|
||||
endDrag,
|
||||
drag,
|
||||
nodeBeingDragged,
|
||||
hasDragged,
|
||||
receivingNodes,
|
||||
receivingNode,
|
||||
dragDelta
|
||||
} = setupDraggableNode<number | "cnode">({
|
||||
board,
|
||||
getPosition(id) {
|
||||
return nodesById.value[id] ?? (cNode as CNode).draggable.position.value;
|
||||
},
|
||||
setPosition(id, position) {
|
||||
const node = nodesById.value[id] ?? (cNode as CNode).draggable.position.value;
|
||||
node.x = position.x;
|
||||
node.y = position.y;
|
||||
}
|
||||
});
|
||||
|
||||
// a nodes can be slotted into b nodes to draw a branch between them, with limited connections
|
||||
// a nodes can be selected and have an action to spawn a b node, and vice versa
|
||||
// Newly spawned nodes should find a safe spot to spawn, and display a link to their creator
|
||||
// a nodes use all the stuff circles used to have, and b diamonds
|
||||
// c node also exists but is a single Upgrade element that cannot be selected, but can be dragged
|
||||
// d nodes are a performance test - 1000 simple nodes that have no interactions
|
||||
// Make all nodes animate in (decorator? `fadeIn(feature)?)
|
||||
const nodes = persistent<(ANode | BNode)[]>([
|
||||
{ id: 0, x: 0, y: 0, z: 0, links: [], type: "anode" }
|
||||
]);
|
||||
const nodesById = computed<Record<string, NodeTypes>>(() =>
|
||||
nodes.value.reduce((acc, curr) => ({ ...acc, [curr.id]: curr }), {})
|
||||
);
|
||||
function mouseDownNode(e: MouseEvent | TouchEvent, node: NodeTypes) {
|
||||
const oldZ = node.z;
|
||||
nodes.value.forEach(node => {
|
||||
if (node.z > oldZ) {
|
||||
node.z--;
|
||||
}
|
||||
});
|
||||
node.z = nextId.value;
|
||||
if (nodeBeingDragged.value == null) {
|
||||
startDrag(e, node.id);
|
||||
}
|
||||
deselect();
|
||||
}
|
||||
function mouseUpNode(e: MouseEvent | TouchEvent, node: NodeTypes) {
|
||||
if (!hasDragged.value) {
|
||||
endDrag();
|
||||
if (typeof node.id === "number") {
|
||||
select(node.id);
|
||||
}
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
function translate(node: NodePosition, isDragging: boolean) {
|
||||
let x = node.x;
|
||||
let y = node.y;
|
||||
if (isDragging) {
|
||||
x += dragDelta.value.x;
|
||||
y += dragDelta.value.y;
|
||||
}
|
||||
return ` translate(${x}px,${y}px)`;
|
||||
}
|
||||
function rotate(rotation: number) {
|
||||
return ` rotate(${rotation}deg) `;
|
||||
}
|
||||
function scale(nodeOrBool: NodeTypes | boolean) {
|
||||
const isSelected =
|
||||
typeof nodeOrBool === "boolean" ? nodeOrBool : selected.value === nodeOrBool.id;
|
||||
return isSelected ? " scale(1.2)" : "";
|
||||
}
|
||||
function opacity(node: NodeTypes) {
|
||||
const isDragging = selected.value !== node.id && nodeBeingDragged.value === node.id;
|
||||
if (isDragging) {
|
||||
return "; opacity: 0.5;";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
function zIndex(node: NodeTypes) {
|
||||
if (selected.value === node.id || nodeBeingDragged.value === node.id) {
|
||||
return "; z-index: 100000000";
|
||||
}
|
||||
return "; z-index: " + node.z;
|
||||
}
|
||||
|
||||
const renderANode = function (node: ANode) {
|
||||
return (
|
||||
<SVGNode
|
||||
style={`transform: ${translate(node, nodeBeingDragged.value === node.id)}${opacity(
|
||||
node
|
||||
)}${zIndex(node)}`}
|
||||
onMouseDown={e => mouseDownNode(e, node)}
|
||||
onMouseUp={e => mouseUpNode(e, node)}
|
||||
>
|
||||
<g style={`transform: ${scale(node)}`}>
|
||||
{receivingNodes.value.includes(node.id) && (
|
||||
<circle
|
||||
r="58"
|
||||
fill="var(--background)"
|
||||
stroke={receivingNode.value === node.id ? "#0F0" : "#0F03"}
|
||||
stroke-width="2"
|
||||
/>
|
||||
)}
|
||||
<CircleProgress r={54.5} progress={0.5} stroke="var(--accent2)" />
|
||||
<circle
|
||||
r="50"
|
||||
fill="var(--raised-background)"
|
||||
stroke="var(--outline)"
|
||||
stroke-width="4"
|
||||
/>
|
||||
</g>
|
||||
{selected.value === node.id && selectedAction.value === 0 && (
|
||||
<text y="140" fill="var(--foreground)" class="node-text">
|
||||
Spawn B Node
|
||||
</text>
|
||||
)}
|
||||
<text fill="var(--foreground)" class="node-text">
|
||||
A
|
||||
</text>
|
||||
</SVGNode>
|
||||
);
|
||||
};
|
||||
const aActions = setupActions({
|
||||
node: () => nodesById.value[selected.value ?? ""],
|
||||
shouldShowActions: node => node.type === "anode",
|
||||
actions(node) {
|
||||
return [
|
||||
p => (
|
||||
<g
|
||||
style={`transform: ${translate(p, selectedAction.value === 0)}${scale(
|
||||
selectedAction.value === 0
|
||||
)}`}
|
||||
onClick={() => {
|
||||
if (selectedAction.value === 0) {
|
||||
spawnBNode(node as ANode);
|
||||
} else {
|
||||
selectAction(0);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<circle fill="black" r="20"></circle>
|
||||
<text fill="white" class="material-icons" x="-12" y="12">
|
||||
add
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
];
|
||||
},
|
||||
distance: 100
|
||||
});
|
||||
const sqrtTwo = Math.sqrt(2);
|
||||
const renderBNode = function (node: BNode) {
|
||||
return (
|
||||
<SVGNode
|
||||
style={`transform: ${translate(node, nodeBeingDragged.value === node.id)}${opacity(
|
||||
node
|
||||
)}${zIndex(node)}`}
|
||||
onMouseDown={e => mouseDownNode(e, node)}
|
||||
onMouseUp={e => mouseUpNode(e, node)}
|
||||
>
|
||||
<g style={`transform: ${scale(node)}${rotate(45)}`}>
|
||||
{receivingNodes.value.includes(node.id) && (
|
||||
<rect
|
||||
width={50 * sqrtTwo + 16}
|
||||
height={50 * sqrtTwo + 16}
|
||||
style={`translate(${(-50 * sqrtTwo + 16) / 2}, ${
|
||||
(-50 * sqrtTwo + 16) / 2
|
||||
})`}
|
||||
fill="var(--background)"
|
||||
stroke={receivingNode.value === node.id ? "#0F0" : "#0F03"}
|
||||
stroke-width="2"
|
||||
/>
|
||||
)}
|
||||
<SquareProgress
|
||||
size={50 * sqrtTwo + 9}
|
||||
progress={0.5}
|
||||
stroke="var(--accent2)"
|
||||
/>
|
||||
<rect
|
||||
width={50 * sqrtTwo}
|
||||
height={50 * sqrtTwo}
|
||||
style={`transform: translate(${(-50 * sqrtTwo) / 2}px, ${
|
||||
(-50 * sqrtTwo) / 2
|
||||
}px)`}
|
||||
fill="var(--raised-background)"
|
||||
stroke="var(--outline)"
|
||||
stroke-width="4"
|
||||
/>
|
||||
</g>
|
||||
{selected.value === node.id && selectedAction.value === 0 && (
|
||||
<text y="140" fill="var(--foreground)" class="node-text">
|
||||
Spawn A Node
|
||||
</text>
|
||||
)}
|
||||
<text fill="var(--foreground)" class="node-text">
|
||||
B
|
||||
</text>
|
||||
</SVGNode>
|
||||
);
|
||||
};
|
||||
const bActions = setupActions({
|
||||
node: () => nodesById.value[selected.value ?? ""],
|
||||
shouldShowActions: node => node.type === "bnode",
|
||||
actions(node) {
|
||||
return [
|
||||
p => (
|
||||
<g
|
||||
style={`transform: ${translate(p, selectedAction.value === 0)}${scale(
|
||||
selectedAction.value === 0
|
||||
)}`}
|
||||
onClick={() => {
|
||||
if (selectedAction.value === 0) {
|
||||
spawnANode(node as BNode);
|
||||
} else {
|
||||
selectAction(0);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<circle fill="white" r="20"></circle>
|
||||
<text fill="black" class="material-icons" x="-12" y="12">
|
||||
add
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
];
|
||||
},
|
||||
distance: 100
|
||||
});
|
||||
function spawnANode(parent: ANode | BNode) {
|
||||
const node: ANode = {
|
||||
x: parent.x,
|
||||
y: parent.y,
|
||||
z: nextId.value,
|
||||
type: "anode",
|
||||
links: [parent.id],
|
||||
id: nextId.value
|
||||
};
|
||||
placeInAvailableSpace(node, nodes.value);
|
||||
nodes.value.push(node);
|
||||
}
|
||||
function spawnBNode(parent: ANode | BNode) {
|
||||
const node: BNode = {
|
||||
x: parent.x,
|
||||
y: parent.y,
|
||||
z: nextId.value,
|
||||
type: "bnode",
|
||||
links: [parent.id],
|
||||
id: nextId.value
|
||||
};
|
||||
placeInAvailableSpace(node, nodes.value);
|
||||
nodes.value.push(node);
|
||||
}
|
||||
|
||||
const points = createResource(10);
|
||||
const cNode = createUpgrade(() => ({
|
||||
display: <h1>C</h1>,
|
||||
// Purposefully not using noPersist
|
||||
requirements: createCostRequirement(() => ({ cost: 10, resource: points })),
|
||||
style: {
|
||||
x: "100px",
|
||||
y: "100px",
|
||||
"--layer-color": "var(--accent1)"
|
||||
},
|
||||
// no-op to prevent purchasing while dragging
|
||||
onHold: () => {}
|
||||
}));
|
||||
makeDraggable<number | "cnode", MakeDraggableOptions<number | "cnode">>(cNode, () => ({
|
||||
id: "cnode",
|
||||
endDrag,
|
||||
startDrag,
|
||||
hasDragged,
|
||||
nodeBeingDragged,
|
||||
dragDelta,
|
||||
onMouseUp: cNode.purchase
|
||||
}));
|
||||
|
||||
const dNodesPerAxis = 50;
|
||||
const dNodes = (
|
||||
<>
|
||||
{new Array(dNodesPerAxis * dNodesPerAxis).fill(0).map((_, i) => {
|
||||
const x = (Math.floor(i / dNodesPerAxis) - dNodesPerAxis / 2) * 100;
|
||||
const y = ((i % dNodesPerAxis) - dNodesPerAxis / 2) * 100;
|
||||
return (
|
||||
<path
|
||||
fill="var(--bought)"
|
||||
style={`transform: translate(${x}px, ${y}px) scale(0.05)`}
|
||||
d="M62.43,122.88h-1.98c0-16.15-6.04-30.27-18.11-42.34C30.27,68.47,16.16,62.43,0,62.43v-1.98 c16.16,0,30.27-6.04,42.34-18.14C54.41,30.21,60.45,16.1,60.45,0h1.98c0,16.15,6.04,30.27,18.11,42.34 c12.07,12.07,26.18,18.11,42.34,18.11v1.98c-16.15,0-30.27,6.04-42.34,18.11C68.47,92.61,62.43,106.72,62.43,122.88L62.43,122.88z"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
const links = computed(() => (
|
||||
<>
|
||||
{nodes.value
|
||||
.reduce(
|
||||
(acc, curr) => [
|
||||
...acc,
|
||||
...curr.links.map(l => ({ from: curr, to: nodesById.value[l] }))
|
||||
],
|
||||
[] as { from: NodeTypes; to: NodeTypes }[]
|
||||
)
|
||||
.map(link => (
|
||||
<line
|
||||
stroke="white"
|
||||
stroke-width={4}
|
||||
x1={
|
||||
nodeBeingDragged.value === link.from.id
|
||||
? dragDelta.value.x + link.from.x
|
||||
: link.from.x
|
||||
}
|
||||
y1={
|
||||
nodeBeingDragged.value === link.from.id
|
||||
? dragDelta.value.y + link.from.y
|
||||
: link.from.y
|
||||
}
|
||||
x2={
|
||||
nodeBeingDragged.value === link.to.id
|
||||
? dragDelta.value.x + link.to.x
|
||||
: link.to.x
|
||||
}
|
||||
y2={
|
||||
nodeBeingDragged.value === link.to.id
|
||||
? dragDelta.value.y + link.to.y
|
||||
: link.to.y
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
));
|
||||
|
||||
const nextId = setupUniqueIds(() => nodes.value);
|
||||
|
||||
function renderNode(node: NodeTypes | typeof cNode) {
|
||||
if (node.type === "anode") {
|
||||
return renderANode(node);
|
||||
} else if (node.type === "bnode") {
|
||||
return renderBNode(node);
|
||||
} else {
|
||||
return render(node);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: "Board",
|
||||
color: "var(--accent1)",
|
||||
display: () => (
|
||||
<>
|
||||
<Board
|
||||
onDrag={drag}
|
||||
onMouseDown={deselect}
|
||||
onMouseUp={endDrag}
|
||||
onMouseLeave={endDrag}
|
||||
ref={board}
|
||||
style={{ height: "600px" }}
|
||||
>
|
||||
<SVGNode>
|
||||
{dNodes}
|
||||
{links.value}
|
||||
</SVGNode>
|
||||
{nodes.value.map(renderNode)}
|
||||
{render(cNode)}
|
||||
<SVGNode>
|
||||
{aActions.value}
|
||||
{bActions.value}
|
||||
</SVGNode>
|
||||
</Board>
|
||||
</>
|
||||
),
|
||||
boardNodes: nodes,
|
||||
cNode,
|
||||
selected: persistent(selected)
|
||||
};
|
||||
});
|
||||
|
||||
export default board;
|
|
@ -1,12 +1,12 @@
|
|||
import Node from "components/Node.vue";
|
||||
import Profectus from "components/Profectus.vue";
|
||||
import 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 { globalBus } from "game/events";
|
||||
import type { BaseLayer, GenericLayer } from "game/layers";
|
||||
import { setupLayerModal } from "game/layers";
|
||||
import { createLayer } from "game/layers";
|
||||
import player from "game/player";
|
||||
import type { BaseLayer, Layer } from "game/layers";
|
||||
import { createLayer, setupLayerModal } from "game/layers";
|
||||
import player, { Player } from "game/player";
|
||||
import type { DecimalSource } from "util/bignum";
|
||||
import Decimal, { format, formatTime } from "util/bignum";
|
||||
import { render } from "util/vue";
|
||||
|
@ -14,8 +14,8 @@ import { computed, toRaw } from "vue";
|
|||
import a from "./layers/aca/a";
|
||||
import c from "./layers/aca/c";
|
||||
import f from "./layers/aca/f";
|
||||
import { Player } from "game/player";
|
||||
import { createTree, GenericTree, branchedResetPropagation } from "features/trees/tree";
|
||||
import board from "./layers/board";
|
||||
import { noPersist } from "game/persistence";
|
||||
|
||||
/**
|
||||
* @hidden
|
||||
|
@ -39,11 +39,13 @@ export const main = createLayer("main", function (this: BaseLayer) {
|
|||
|
||||
const { openModal, modal } = setupLayerModal(a);
|
||||
|
||||
const { openModal: openBoardModal, modal: boardModal } = setupLayerModal(board);
|
||||
|
||||
// Note: Casting as generic tree to avoid recursive type definitions
|
||||
const tree = createTree(() => ({
|
||||
nodes: [[c.treeNode], [f.treeNode, c.spook]],
|
||||
leftSideNodes: [a.treeNode, c.h],
|
||||
branches: [
|
||||
nodes: noPersist([[c.treeNode], [f.treeNode, c.spook]]),
|
||||
leftSideNodes: noPersist([a.treeNode, c.h]),
|
||||
branches: noPersist([
|
||||
{
|
||||
startNode: f.treeNode,
|
||||
endNode: c.treeNode,
|
||||
|
@ -53,42 +55,61 @@ export const main = createLayer("main", function (this: BaseLayer) {
|
|||
filter: "blur(5px)"
|
||||
}
|
||||
}
|
||||
],
|
||||
]),
|
||||
onReset() {
|
||||
points.value = toRaw(this.resettingNode.value) === toRaw(c.treeNode) ? 0 : 10;
|
||||
points.value = toRaw(tree.resettingNode.value) === toRaw(c.treeNode) ? 0 : 10;
|
||||
best.value = points.value;
|
||||
total.value = points.value;
|
||||
},
|
||||
resetPropagation: branchedResetPropagation
|
||||
})) as GenericTree;
|
||||
})) as Tree;
|
||||
|
||||
// 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",
|
||||
display: jsx(() => (
|
||||
links: tree.links,
|
||||
display: () => (
|
||||
<>
|
||||
{player.devSpeed === 0 ? <div>Game Paused</div> : null}
|
||||
{player.devSpeed != null && player.devSpeed != 0 && player.devSpeed !== 1 ? (
|
||||
<div>Dev Speed: {format(player.devSpeed)}x</div>
|
||||
{player.devSpeed === 0 ? (
|
||||
<div>
|
||||
Game Paused
|
||||
<Node id="paused" />
|
||||
</div>
|
||||
) : null}
|
||||
{player.offlineTime != null && player.offlineTime != 0 ? (
|
||||
<div>Offline Time: {formatTime(player.offlineTime)}</div>
|
||||
{player.devSpeed != null && player.devSpeed !== 0 && player.devSpeed !== 1 ? (
|
||||
<div>
|
||||
Dev Speed: {format(player.devSpeed)}x
|
||||
<Node id="devspeed" />
|
||||
</div>
|
||||
) : null}
|
||||
{player.offlineTime != null && player.offlineTime !== 0 ? (
|
||||
<div>
|
||||
Offline Time: {formatTime(player.offlineTime)}
|
||||
<Node id="offline" />
|
||||
</div>
|
||||
) : null}
|
||||
<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})</div> : null}
|
||||
{Decimal.gt(pointGain.value, 0) ? (
|
||||
<div>
|
||||
({oomps.value})
|
||||
<Node id="oomps" />
|
||||
</div>
|
||||
) : null}
|
||||
<Spacer />
|
||||
<button onClick={openModal}>open achievements</button>
|
||||
<button onClick={openBoardModal}>open board</button>
|
||||
{render(modal)}
|
||||
{render(boardModal)}
|
||||
{render(tree)}
|
||||
<Profectus height="200px" style="margin: 10px auto; display: block" />
|
||||
<Profectus style="margin: 10px auto; display: block; height: 200px" />
|
||||
</>
|
||||
)),
|
||||
),
|
||||
points,
|
||||
best,
|
||||
total,
|
||||
|
@ -105,7 +126,7 @@ export const main = createLayer("main", function (this: BaseLayer) {
|
|||
export const getInitialLayers = (
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
|
||||
player: Partial<Player>
|
||||
): Array<GenericLayer> => [main, f, c, a];
|
||||
): Array<Layer> => [main, f, c, a, board];
|
||||
|
||||
/**
|
||||
* A computed ref whose value is true whenever the game is over.
|
||||
|
|
|
@ -88,6 +88,10 @@
|
|||
"type": "string",
|
||||
"enum": ["base64", "lz", "plain"],
|
||||
"description": "The encoding to use when exporting to the clipboard. Plain-text is fast to generate but is easiest for the player to manipulate and cheat with. Base 64 is slightly slower and the string will be longer but will offer a small barrier to people trying to cheat. LZ-String is the slowest method, but produces the smallest strings and still offers a small barrier to those trying to cheat. Some sharing platforms like pastebin may automatically delete base64 encoded text, and some sites might not support all the characters used in lz-string exports."
|
||||
},
|
||||
"disableHealthWarning": {
|
||||
"type": "boolean",
|
||||
"description": "Whether or not to disable the health warning that appears to the player after excessive playtime (activity during 6 of the last 8 hours). If left enabled, the player will still be able to individually turn off the health warning in settings or by clicking \"Do not show again\" in the warning itself."
|
||||
}
|
||||
}
|
||||
}
|
|
@ -22,5 +22,6 @@
|
|||
"maxTickLength": 3600,
|
||||
"offlineLimit": 1,
|
||||
"enablePausing": true,
|
||||
"exportEncoding": "base64"
|
||||
"exportEncoding": "base64",
|
||||
"disableHealthWarning": false
|
||||
}
|
||||
|
|
40
src/features/VueFeature.vue
Normal file
40
src/features/VueFeature.vue
Normal file
|
@ -0,0 +1,40 @@
|
|||
<template>
|
||||
<div v-if="isVisible(unref(visibility))"
|
||||
:style="[
|
||||
{
|
||||
visibility: isHidden(unref(visibility)) ? 'hidden' : undefined
|
||||
},
|
||||
unref(style)
|
||||
]"
|
||||
:class="{ feature: true, ...unref(classes) }"
|
||||
>
|
||||
<Components />
|
||||
<Node :id="id" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx">
|
||||
import "components/common/features.css";
|
||||
import Node from "components/Node.vue";
|
||||
import type { Visibility } from "features/feature";
|
||||
import { isHidden, isVisible } from "features/feature";
|
||||
import { render, Renderable } from "util/vue";
|
||||
import { MaybeRef, unref, type CSSProperties } from "vue";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
id: string;
|
||||
components: MaybeRef<Renderable>[];
|
||||
wrappers: ((el: () => Renderable) => Renderable)[];
|
||||
visibility?: MaybeRef<Visibility | boolean>;
|
||||
style?: MaybeRef<CSSProperties>;
|
||||
classes?: MaybeRef<Record<string, boolean>>;
|
||||
}>(), {
|
||||
visibility: true,
|
||||
style: () => ({}),
|
||||
classes: () => ({})
|
||||
});
|
||||
|
||||
const Components = () => props.wrappers.reduce<() => Renderable>(
|
||||
(acc, curr) => (() => curr(acc)),
|
||||
() => <>{props.components.map(el => render(el))}</>)();
|
||||
</script>
|
|
@ -1,112 +1,63 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="isVisible(visibility)"
|
||||
:style="[
|
||||
{
|
||||
visibility: isHidden(visibility) ? 'hidden' : undefined,
|
||||
backgroundImage: (earned && image && `url(${image})`) || ''
|
||||
},
|
||||
unref(style) ?? []
|
||||
]"
|
||||
<button
|
||||
:style="{
|
||||
backgroundImage: (unref(earned) && unref(image) && `url(${image})`) || ''
|
||||
}"
|
||||
:class="{
|
||||
feature: true,
|
||||
achievement: true,
|
||||
locked: !unref(earned),
|
||||
done: unref(earned),
|
||||
small: unref(small),
|
||||
...unref(classes)
|
||||
}"
|
||||
>
|
||||
<component v-if="comp" :is="comp" />
|
||||
<MarkNode :mark="unref(mark)" />
|
||||
<Node :id="id" />
|
||||
</div>
|
||||
<Component />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="tsx">
|
||||
<script setup lang="tsx">
|
||||
import "components/common/features.css";
|
||||
import MarkNode from "components/MarkNode.vue";
|
||||
import Node from "components/Node.vue";
|
||||
import { isHidden, isVisible, jsx, Visibility } from "features/feature";
|
||||
import { 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";
|
||||
import { isJSXElement, render } from "util/vue";
|
||||
import { Component, isRef, unref } from "vue";
|
||||
import { Achievement } from "./achievement";
|
||||
import { displayRequirements } from "game/requirements";
|
||||
|
||||
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 props = defineProps<{
|
||||
display: Achievement["display"];
|
||||
earned: Achievement["earned"];
|
||||
requirements: Achievement["requirements"];
|
||||
image: Achievement["image"];
|
||||
small: Achievement["small"];
|
||||
}>();
|
||||
|
||||
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
|
||||
};
|
||||
const Component = () => {
|
||||
if (props.display == null) {
|
||||
return null;
|
||||
} else if (
|
||||
isRef(props.display) ||
|
||||
typeof props.display === "string" ||
|
||||
isJSXElement(props.display)
|
||||
) {
|
||||
return render(props.display);
|
||||
} else {
|
||||
const { requirement, effectDisplay, optionsDisplay } = props.display;
|
||||
return (
|
||||
<span>
|
||||
{requirement ?
|
||||
render(requirement, el => <h3>{el}</h3>) :
|
||||
displayRequirements(props.requirements ?? [])}
|
||||
{effectDisplay ? (
|
||||
<div>
|
||||
{render(effectDisplay, el => <b>{el}</b>)}
|
||||
</div>
|
||||
) : null}
|
||||
{optionsDisplay != null ? (
|
||||
<div class="equal-spaced">
|
||||
{render(optionsDisplay)}
|
||||
</div>
|
||||
) : null}
|
||||
</span>);
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
@ -1,46 +1,32 @@
|
|||
import { computed } from "@vue/reactivity";
|
||||
import { isArray } from "@vue/shared";
|
||||
import Select from "components/fields/Select.vue";
|
||||
import AchievementComponent from "features/achievements/Achievement.vue";
|
||||
import { GenericDecorator } from "features/decorators/common";
|
||||
import {
|
||||
CoercableComponent,
|
||||
Component,
|
||||
GatherProps,
|
||||
GenericComponent,
|
||||
OptionsFunc,
|
||||
Replace,
|
||||
StyleValue,
|
||||
Visibility,
|
||||
getUniqueID,
|
||||
jsx,
|
||||
setDefault
|
||||
} from "features/feature";
|
||||
import { Visibility } 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 type {
|
||||
Computable,
|
||||
GetComputableType,
|
||||
GetComputableTypeWithDefault,
|
||||
ProcessedComputable
|
||||
} from "util/computed";
|
||||
import { processComputable } from "util/computed";
|
||||
import { processGetter } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import { coerceComponent, isCoercableComponent } from "util/vue";
|
||||
import { unref, watchEffect } from "vue";
|
||||
import {
|
||||
isJSXElement,
|
||||
render,
|
||||
Renderable,
|
||||
VueFeature,
|
||||
vueFeatureMixin,
|
||||
VueFeatureOptions
|
||||
} from "util/vue";
|
||||
import { computed, isRef, MaybeRef, MaybeRefOrGetter, unref, watchEffect } from "vue";
|
||||
import { useToast } from "vue-toastification";
|
||||
import Achievement from "./Achievement.vue";
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
|
@ -59,235 +45,171 @@ export enum AchievementDisplay {
|
|||
/**
|
||||
* An object that configures an {@link Achievement}.
|
||||
*/
|
||||
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}. */
|
||||
export interface AchievementOptions extends VueFeatureOptions {
|
||||
/** The requirement(s) to earn this achievement. Can be left null if using {@link Achievement.complete}. */
|
||||
requirements?: Requirements;
|
||||
/** The display to use for this achievement. */
|
||||
display?: Computable<
|
||||
| CoercableComponent
|
||||
display?:
|
||||
| MaybeRefOrGetter<Renderable>
|
||||
| {
|
||||
/** Description of the requirement(s) for this achievement. If unspecified then the requirements will be displayed automatically based on {@link requirements}. */
|
||||
requirement?: CoercableComponent;
|
||||
requirement?: MaybeRefOrGetter<Renderable>;
|
||||
/** Description of what will change (if anything) for achieving this. */
|
||||
effectDisplay?: CoercableComponent;
|
||||
effectDisplay?: MaybeRefOrGetter<Renderable>;
|
||||
/** Any additional things to display on this achievement, such as a toggle for it's effect. */
|
||||
optionsDisplay?: CoercableComponent;
|
||||
}
|
||||
>;
|
||||
/** Shows a marker on the corner of the feature. */
|
||||
mark?: Computable<boolean | string>;
|
||||
optionsDisplay?: MaybeRefOrGetter<Renderable>;
|
||||
};
|
||||
/** Toggles a smaller design for the feature. */
|
||||
small?: Computable<boolean>;
|
||||
small?: MaybeRefOrGetter<boolean>;
|
||||
/** An image to display as the background for this achievement. */
|
||||
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>>;
|
||||
image?: MaybeRefOrGetter<string>;
|
||||
/** Whether or not to display a notification popup when this achievement is earned. */
|
||||
showPopups?: Computable<boolean>;
|
||||
showPopups?: MaybeRefOrGetter<boolean>;
|
||||
/** A function that is called when the achievement is completed. */
|
||||
onComplete?: VoidFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* The properties that are added onto a processed {@link AchievementOptions} to create an {@link Achievement}.
|
||||
*/
|
||||
export interface BaseAchievement {
|
||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
||||
id: string;
|
||||
/** 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?:
|
||||
| MaybeRef<Renderable>
|
||||
| {
|
||||
requirement?: MaybeRef<Renderable>;
|
||||
effectDisplay?: MaybeRef<Renderable>;
|
||||
optionsDisplay?: MaybeRef<Renderable>;
|
||||
};
|
||||
/** Toggles a smaller design for the feature. */
|
||||
small?: MaybeRef<boolean>;
|
||||
/** An image to display as the background for this achievement. */
|
||||
image?: MaybeRef<string>;
|
||||
/** Whether or not to display a notification popup when this achievement is earned. */
|
||||
showPopups: MaybeRef<boolean>;
|
||||
/** Whether or not this achievement has been earned. */
|
||||
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>;
|
||||
}
|
||||
|
||||
/** An object that represents a feature with requirements that is passively earned upon meeting certain requirements. */
|
||||
export type Achievement<T extends AchievementOptions> = Replace<
|
||||
T & BaseAchievement,
|
||||
{
|
||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||
display: GetComputableType<T["display"]>;
|
||||
mark: GetComputableType<T["mark"]>;
|
||||
image: GetComputableType<T["image"]>;
|
||||
style: GetComputableType<T["style"]>;
|
||||
classes: GetComputableType<T["classes"]>;
|
||||
showPopups: GetComputableTypeWithDefault<T["showPopups"], true>;
|
||||
}
|
||||
>;
|
||||
|
||||
/** A type that matches any valid {@link Achievement} object. */
|
||||
export type GenericAchievement = Replace<
|
||||
Achievement<AchievementOptions>,
|
||||
{
|
||||
visibility: ProcessedComputable<Visibility | boolean>;
|
||||
showPopups: ProcessedComputable<boolean>;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* Lazily creates an achievement with the given options.
|
||||
* @param optionsFunc Achievement options.
|
||||
*/
|
||||
export function createAchievement<T extends AchievementOptions>(
|
||||
optionsFunc?: OptionsFunc<T, BaseAchievement, GenericAchievement>,
|
||||
...decorators: GenericDecorator[]
|
||||
): Achievement<T> {
|
||||
export function createAchievement<T extends AchievementOptions>(optionsFunc?: () => T) {
|
||||
const earned = persistent<boolean>(false, false);
|
||||
const decoratedData = decorators.reduce(
|
||||
(current, next) => Object.assign(current, next.getPersistentData?.()),
|
||||
{}
|
||||
);
|
||||
return createLazyProxy(feature => {
|
||||
const achievement =
|
||||
optionsFunc?.call(feature, feature) ??
|
||||
({} as ReturnType<NonNullable<typeof optionsFunc>>);
|
||||
achievement.id = getUniqueID("achievement-");
|
||||
achievement.type = AchievementType;
|
||||
achievement[Component] = AchievementComponent as GenericComponent;
|
||||
return createLazyProxy(() => {
|
||||
const options = optionsFunc?.() ?? ({} as T);
|
||||
const { requirements, display, small, image, showPopups, onComplete, ...props } = options;
|
||||
|
||||
for (const decorator of decorators) {
|
||||
decorator.preConstruct?.(achievement);
|
||||
}
|
||||
const vueFeature = vueFeatureMixin("achievement", options, () => (
|
||||
<Achievement
|
||||
display={achievement.display}
|
||||
earned={achievement.earned}
|
||||
requirements={achievement.requirements}
|
||||
image={achievement.image}
|
||||
small={achievement.small}
|
||||
/>
|
||||
));
|
||||
|
||||
achievement.earned = earned;
|
||||
achievement.complete = function () {
|
||||
if (earned.value) {
|
||||
return;
|
||||
}
|
||||
earned.value = true;
|
||||
const genericAchievement = achievement as GenericAchievement;
|
||||
genericAchievement.onComplete?.();
|
||||
if (
|
||||
genericAchievement.display != null &&
|
||||
unref(genericAchievement.showPopups) === true
|
||||
) {
|
||||
const display = unref(genericAchievement.display);
|
||||
let Display;
|
||||
if (isCoercableComponent(display)) {
|
||||
Display = coerceComponent(display);
|
||||
} else if (display.requirement != null) {
|
||||
Display = coerceComponent(display.requirement);
|
||||
} else {
|
||||
Display = displayRequirements(genericAchievement.requirements ?? []);
|
||||
const achievement = {
|
||||
type: AchievementType,
|
||||
...(props as Omit<typeof props, keyof VueFeature | keyof AchievementOptions>),
|
||||
...vueFeature,
|
||||
visibility: computed(() => {
|
||||
const display = unref((achievement as Achievement).display);
|
||||
switch (settings.msDisplay) {
|
||||
default:
|
||||
case AchievementDisplay.All:
|
||||
return unref(vueFeature.visibility) ?? true;
|
||||
case AchievementDisplay.Configurable:
|
||||
if (
|
||||
unref(earned) &&
|
||||
!(
|
||||
display != null &&
|
||||
typeof display === "object" &&
|
||||
"optionsDisplay" in 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;
|
||||
}
|
||||
toast.info(
|
||||
<div>
|
||||
<h3>Achievement earned!</h3>
|
||||
}),
|
||||
earned,
|
||||
onComplete,
|
||||
small: processGetter(small),
|
||||
image: processGetter(image),
|
||||
showPopups: processGetter(showPopups) ?? true,
|
||||
display:
|
||||
display == null
|
||||
? undefined
|
||||
: isRef(display) ||
|
||||
typeof display === "string" ||
|
||||
typeof display === "function" ||
|
||||
isJSXElement(display)
|
||||
? processGetter(display)
|
||||
: {
|
||||
requirement: processGetter(display.requirement),
|
||||
effectDisplay: processGetter(display.effectDisplay),
|
||||
optionsDisplay: processGetter(display.optionsDisplay)
|
||||
},
|
||||
requirements:
|
||||
requirements == null
|
||||
? undefined
|
||||
: [
|
||||
createVisibilityRequirement(vueFeature.visibility ?? true),
|
||||
createBooleanRequirement(() => !earned.value),
|
||||
...(Array.isArray(requirements) ? requirements : [requirements])
|
||||
],
|
||||
complete() {
|
||||
if (earned.value) {
|
||||
return;
|
||||
}
|
||||
earned.value = true;
|
||||
achievement.onComplete?.();
|
||||
if (achievement.display != null && unref(achievement.showPopups) === true) {
|
||||
const display = achievement.display;
|
||||
let Display;
|
||||
if (isRef(display) || typeof display === "string" || isJSXElement(display)) {
|
||||
Display = () => render(display);
|
||||
} else if (display.requirement != null) {
|
||||
Display = () => render(display.requirement!);
|
||||
} else {
|
||||
Display = () => displayRequirements(achievement.requirements ?? []);
|
||||
}
|
||||
toast.info(
|
||||
<div>
|
||||
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
|
||||
{/* @ts-ignore */}
|
||||
<Display />
|
||||
<h3>Achievement earned!</h3>
|
||||
<div>{Display()}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
} satisfies Achievement;
|
||||
|
||||
Object.assign(achievement, decoratedData);
|
||||
|
||||
processComputable(achievement as T, "visibility");
|
||||
setDefault(achievement, "visibility", Visibility.Visible);
|
||||
const visibility = achievement.visibility as ProcessedComputable<Visibility | boolean>;
|
||||
achievement.visibility = computed(() => {
|
||||
const display = unref((achievement as GenericAchievement).display);
|
||||
switch (settings.msDisplay) {
|
||||
default:
|
||||
case AchievementDisplay.All:
|
||||
return unref(visibility);
|
||||
case AchievementDisplay.Configurable:
|
||||
if (
|
||||
unref(achievement.earned) &&
|
||||
!(
|
||||
display != null &&
|
||||
typeof display == "object" &&
|
||||
"optionsDisplay" in (display as Record<string, unknown>)
|
||||
)
|
||||
) {
|
||||
return Visibility.None;
|
||||
}
|
||||
return unref(visibility);
|
||||
case AchievementDisplay.Incomplete:
|
||||
if (unref(achievement.earned)) {
|
||||
return Visibility.None;
|
||||
}
|
||||
return unref(visibility);
|
||||
case AchievementDisplay.None:
|
||||
return Visibility.None;
|
||||
}
|
||||
});
|
||||
|
||||
processComputable(achievement as T, "display");
|
||||
processComputable(achievement as T, "mark");
|
||||
processComputable(achievement as T, "small");
|
||||
processComputable(achievement as T, "image");
|
||||
processComputable(achievement as T, "style");
|
||||
processComputable(achievement as T, "classes");
|
||||
processComputable(achievement as T, "showPopups");
|
||||
setDefault(achievement, "showPopups", true);
|
||||
|
||||
for (const decorator of decorators) {
|
||||
decorator.postConstruct?.(achievement);
|
||||
}
|
||||
|
||||
const decoratedProps = decorators.reduce(
|
||||
(current, next) => Object.assign(current, next.getGatheredProps?.(achievement)),
|
||||
{}
|
||||
);
|
||||
achievement[GatherProps] = function (this: GenericAchievement) {
|
||||
const {
|
||||
visibility,
|
||||
display,
|
||||
requirements,
|
||||
earned,
|
||||
image,
|
||||
style,
|
||||
classes,
|
||||
mark,
|
||||
small,
|
||||
id
|
||||
} = this;
|
||||
return {
|
||||
visibility,
|
||||
display,
|
||||
requirements,
|
||||
earned,
|
||||
image,
|
||||
style: unref(style),
|
||||
classes,
|
||||
mark,
|
||||
small,
|
||||
id,
|
||||
...decoratedProps
|
||||
};
|
||||
};
|
||||
|
||||
if (achievement.requirements) {
|
||||
const genericAchievement = achievement as GenericAchievement;
|
||||
const requirements = [
|
||||
createVisibilityRequirement(genericAchievement),
|
||||
createBooleanRequirement(() => !genericAchievement.earned.value),
|
||||
...(isArray(achievement.requirements)
|
||||
? achievement.requirements
|
||||
: [achievement.requirements])
|
||||
];
|
||||
if (achievement.requirements != null) {
|
||||
watchEffect(() => {
|
||||
if (settings.active !== player.id) return;
|
||||
if (requirementsMet(requirements)) {
|
||||
genericAchievement.complete();
|
||||
if (requirementsMet(achievement.requirements ?? [])) {
|
||||
achievement.complete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return achievement as unknown as Achievement<T>;
|
||||
return achievement;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -298,7 +220,7 @@ declare module "game/settings" {
|
|||
}
|
||||
|
||||
globalBus.on("loadSettings", settings => {
|
||||
setDefault(settings, "msDisplay", AchievementDisplay.All);
|
||||
settings.msDisplay ??= AchievementDisplay.All;
|
||||
});
|
||||
|
||||
const msDisplayOptions = Object.values(AchievementDisplay).map(option => ({
|
||||
|
@ -306,15 +228,15 @@ const msDisplayOptions = Object.values(AchievementDisplay).map(option => ({
|
|||
value: option
|
||||
}));
|
||||
|
||||
registerSettingField(
|
||||
jsx(() => (
|
||||
globalBus.on("setupVue", () =>
|
||||
registerSettingField(() => (
|
||||
<Select
|
||||
title={jsx(() => (
|
||||
title={
|
||||
<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}
|
||||
|
|
|
@ -1,293 +0,0 @@
|
|||
import { isArray } from "@vue/shared";
|
||||
import ClickableComponent from "features/clickables/Clickable.vue";
|
||||
import {
|
||||
Component,
|
||||
findFeatures,
|
||||
GatherProps,
|
||||
GenericComponent,
|
||||
getUniqueID,
|
||||
jsx,
|
||||
JSXFunction,
|
||||
OptionsFunc,
|
||||
Replace,
|
||||
setDefault,
|
||||
StyleValue,
|
||||
Visibility
|
||||
} from "features/feature";
|
||||
import { globalBus } from "game/events";
|
||||
import { persistent } from "game/persistence";
|
||||
import Decimal, { DecimalSource } from "lib/break_eternity";
|
||||
import { Unsubscribe } from "nanoevents";
|
||||
import { Direction } from "util/common";
|
||||
import type {
|
||||
Computable,
|
||||
GetComputableType,
|
||||
GetComputableTypeWithDefault,
|
||||
ProcessedComputable
|
||||
} from "util/computed";
|
||||
import { processComputable } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import { coerceComponent, isCoercableComponent, render } from "util/vue";
|
||||
import { computed, Ref, ref, unref } from "vue";
|
||||
import { BarOptions, createBar, GenericBar } from "./bars/bar";
|
||||
import { ClickableOptions } from "./clickables/clickable";
|
||||
import { Decorator, GenericDecorator } from "./decorators/common";
|
||||
|
||||
/** A symbol used to identify {@link Action} features. */
|
||||
export const ActionType = Symbol("Action");
|
||||
|
||||
/**
|
||||
* An object that configures an {@link Action}.
|
||||
*/
|
||||
export interface ActionOptions extends Omit<ClickableOptions, "onClick" | "onHold"> {
|
||||
/** The cooldown during which the action cannot be performed again, in seconds. */
|
||||
duration: Computable<DecimalSource>;
|
||||
/** Whether or not the action should perform automatically when the cooldown is finished. */
|
||||
autoStart?: Computable<boolean>;
|
||||
/** A function that is called when the action is clicked. */
|
||||
onClick: (amount: DecimalSource) => void;
|
||||
/** A pass-through to the {@link Bar} used to display the cooldown progress for the action. */
|
||||
barOptions?: Partial<BarOptions>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The properties that are added onto a processed {@link ActionOptions} to create an {@link Action}.
|
||||
*/
|
||||
export interface BaseAction {
|
||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
||||
id: string;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof ActionType;
|
||||
/** Whether or not the player is holding down the action. Actions will be considered clicked as soon as the cooldown completes when being held down. */
|
||||
isHolding: Ref<boolean>;
|
||||
/** The current amount of progress through the cooldown. */
|
||||
progress: Ref<DecimalSource>;
|
||||
/** The bar used to display the current cooldown progress. */
|
||||
progressBar: GenericBar;
|
||||
/** Update the cooldown the specified number of seconds */
|
||||
update: (diff: number) => void;
|
||||
/** The Vue component used to render this feature. */
|
||||
[Component]: GenericComponent;
|
||||
/** A function to gather the props the vue component requires for this feature. */
|
||||
[GatherProps]: () => Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** An object that represents a feature that can be clicked upon, and then has a cooldown before it can be clicked again. */
|
||||
export type Action<T extends ActionOptions> = Replace<
|
||||
T & BaseAction,
|
||||
{
|
||||
duration: GetComputableType<T["duration"]>;
|
||||
autoStart: GetComputableTypeWithDefault<T["autoStart"], false>;
|
||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||
canClick: GetComputableTypeWithDefault<T["canClick"], true>;
|
||||
classes: GetComputableType<T["classes"]>;
|
||||
style: GetComputableType<T["style"]>;
|
||||
mark: GetComputableType<T["mark"]>;
|
||||
display: JSXFunction;
|
||||
onClick: VoidFunction;
|
||||
}
|
||||
>;
|
||||
|
||||
/** A type that matches any valid {@link Action} object. */
|
||||
export type GenericAction = Replace<
|
||||
Action<ActionOptions>,
|
||||
{
|
||||
autoStart: ProcessedComputable<boolean>;
|
||||
visibility: ProcessedComputable<Visibility | boolean>;
|
||||
canClick: ProcessedComputable<boolean>;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* Lazily creates an action with the given options.
|
||||
* @param optionsFunc Action options.
|
||||
*/
|
||||
export function createAction<T extends ActionOptions>(
|
||||
optionsFunc?: OptionsFunc<T, BaseAction, GenericAction>,
|
||||
...decorators: GenericDecorator[]
|
||||
): Action<T> {
|
||||
const progress = persistent<DecimalSource>(0);
|
||||
const decoratedData = decorators.reduce(
|
||||
(current, next) => Object.assign(current, next.getPersistentData?.()),
|
||||
{}
|
||||
);
|
||||
return createLazyProxy(feature => {
|
||||
const action =
|
||||
optionsFunc?.call(feature, feature) ??
|
||||
({} as ReturnType<NonNullable<typeof optionsFunc>>);
|
||||
action.id = getUniqueID("action-");
|
||||
action.type = ActionType;
|
||||
action[Component] = ClickableComponent as GenericComponent;
|
||||
|
||||
// Required because of display changing types
|
||||
const genericAction = action as unknown as GenericAction;
|
||||
|
||||
for (const decorator of decorators) {
|
||||
decorator.preConstruct?.(action);
|
||||
}
|
||||
|
||||
action.isHolding = ref(false);
|
||||
action.progress = progress;
|
||||
Object.assign(action, decoratedData);
|
||||
|
||||
processComputable(action as T, "visibility");
|
||||
setDefault(action, "visibility", Visibility.Visible);
|
||||
processComputable(action as T, "duration");
|
||||
processComputable(action as T, "autoStart");
|
||||
setDefault(action, "autoStart", false);
|
||||
processComputable(action as T, "canClick");
|
||||
setDefault(action, "canClick", true);
|
||||
processComputable(action as T, "classes");
|
||||
processComputable(action as T, "style");
|
||||
processComputable(action as T, "mark");
|
||||
processComputable(action as T, "display");
|
||||
|
||||
const style = action.style as ProcessedComputable<StyleValue | undefined>;
|
||||
action.style = computed(() => {
|
||||
const currStyle: StyleValue[] = [
|
||||
{
|
||||
cursor: Decimal.gte(
|
||||
progress.value,
|
||||
unref(action.duration as ProcessedComputable<DecimalSource>)
|
||||
)
|
||||
? "pointer"
|
||||
: "progress",
|
||||
display: "flex",
|
||||
flexDirection: "column"
|
||||
}
|
||||
];
|
||||
const originalStyle = unref(style);
|
||||
if (isArray(originalStyle)) {
|
||||
currStyle.push(...originalStyle);
|
||||
} else if (originalStyle != null) {
|
||||
currStyle.push(originalStyle);
|
||||
}
|
||||
return currStyle as StyleValue;
|
||||
});
|
||||
|
||||
action.progressBar = createBar(() => ({
|
||||
direction: Direction.Right,
|
||||
width: 100,
|
||||
height: 10,
|
||||
borderStyle: "border-color: black",
|
||||
baseStyle: "margin-top: -1px",
|
||||
progress: () => Decimal.div(progress.value, unref(genericAction.duration)),
|
||||
...action.barOptions
|
||||
}));
|
||||
|
||||
const canClick = action.canClick as ProcessedComputable<boolean>;
|
||||
action.canClick = computed(
|
||||
() =>
|
||||
unref(canClick) &&
|
||||
Decimal.gte(
|
||||
progress.value,
|
||||
unref(action.duration as ProcessedComputable<DecimalSource>)
|
||||
)
|
||||
);
|
||||
|
||||
const display = action.display as GetComputableType<ClickableOptions["display"]>;
|
||||
action.display = jsx(() => {
|
||||
const currDisplay = unref(display);
|
||||
let Comp: GenericComponent | undefined;
|
||||
if (isCoercableComponent(currDisplay)) {
|
||||
Comp = coerceComponent(currDisplay);
|
||||
} else if (currDisplay != null) {
|
||||
const Title = coerceComponent(currDisplay.title ?? "", "h3");
|
||||
const Description = coerceComponent(currDisplay.description, "div");
|
||||
Comp = coerceComponent(
|
||||
jsx(() => (
|
||||
<span>
|
||||
{currDisplay.title != null ? (
|
||||
<div>
|
||||
<Title />
|
||||
</div>
|
||||
) : null}
|
||||
<Description />
|
||||
</span>
|
||||
))
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div style="flex-grow: 1" />
|
||||
{Comp == null ? null : <Comp />}
|
||||
<div style="flex-grow: 1" />
|
||||
{render(genericAction.progressBar)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const onClick = action.onClick.bind(action);
|
||||
action.onClick = function () {
|
||||
if (unref(action.canClick) === false) {
|
||||
return;
|
||||
}
|
||||
const amount = Decimal.div(progress.value, unref(genericAction.duration));
|
||||
onClick?.(amount);
|
||||
progress.value = 0;
|
||||
};
|
||||
|
||||
action.update = function (diff) {
|
||||
const duration = unref(genericAction.duration);
|
||||
if (Decimal.gte(progress.value, duration)) {
|
||||
progress.value = duration;
|
||||
} else {
|
||||
progress.value = Decimal.add(progress.value, diff);
|
||||
if (genericAction.isHolding.value || unref(genericAction.autoStart)) {
|
||||
genericAction.onClick();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const decorator of decorators) {
|
||||
decorator.postConstruct?.(action);
|
||||
}
|
||||
|
||||
const decoratedProps = decorators.reduce(
|
||||
(current, next) => Object.assign(current, next.getGatheredProps?.(action)),
|
||||
{}
|
||||
);
|
||||
action[GatherProps] = function (this: GenericAction) {
|
||||
const {
|
||||
display,
|
||||
visibility,
|
||||
style,
|
||||
classes,
|
||||
onClick,
|
||||
isHolding,
|
||||
canClick,
|
||||
small,
|
||||
mark,
|
||||
id
|
||||
} = this;
|
||||
return {
|
||||
display,
|
||||
visibility,
|
||||
style: unref(style),
|
||||
classes,
|
||||
onClick,
|
||||
isHolding,
|
||||
canClick,
|
||||
small,
|
||||
mark,
|
||||
id,
|
||||
...decoratedProps
|
||||
};
|
||||
};
|
||||
|
||||
return action as unknown as Action<T>;
|
||||
});
|
||||
}
|
||||
|
||||
const listeners: Record<string, Unsubscribe | undefined> = {};
|
||||
globalBus.on("addLayer", layer => {
|
||||
const actions: GenericAction[] = findFeatures(layer, ActionType) as GenericAction[];
|
||||
listeners[layer.id] = layer.on("postUpdate", diff => {
|
||||
actions.forEach(action => action.update(diff));
|
||||
});
|
||||
});
|
||||
globalBus.on("removeLayer", layer => {
|
||||
// unsubscribe from postUpdate
|
||||
listeners[layer.id]?.();
|
||||
listeners[layer.id] = undefined;
|
||||
});
|
|
@ -1,18 +1,10 @@
|
|||
<template>
|
||||
<div
|
||||
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)
|
||||
:style="{
|
||||
width: unref(width) + 'px',
|
||||
height: unref(height) + 'px',
|
||||
}"
|
||||
class="bar"
|
||||
>
|
||||
<div
|
||||
class="overlayTextContainer border"
|
||||
|
@ -21,127 +13,79 @@
|
|||
unref(borderStyle) ?? {}
|
||||
]"
|
||||
>
|
||||
<span v-if="component" class="overlayText" :style="unref(textStyle)">
|
||||
<component :is="component" />
|
||||
<span v-if="display" class="overlayText" :style="unref(textStyle)">
|
||||
<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(style) ?? {}, unref(fillStyle) ?? {}]" />
|
||||
<div class="fill" :style="[barStyle, unref(fillStyle) ?? {}]" />
|
||||
</div>
|
||||
<MarkNode :mark="unref(mark)" />
|
||||
<Node :id="id" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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";
|
||||
<script setup lang="ts">
|
||||
import Decimal from "util/bignum";
|
||||
import { Direction } from "util/common";
|
||||
import { computeOptionalComponent, processedPropType, unwrapRef } from "util/vue";
|
||||
import type { CSSProperties, StyleValue } from "vue";
|
||||
import { computed, defineComponent, toRefs, unref } from "vue";
|
||||
import { render } from "util/vue";
|
||||
import type { CSSProperties } from "vue";
|
||||
import { computed, unref } from "vue";
|
||||
import { Bar } from "./bar";
|
||||
|
||||
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 props = defineProps<{
|
||||
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"];
|
||||
}>();
|
||||
|
||||
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: unwrapRef(width) + 0.5 + "px",
|
||||
height: unwrapRef(height) + 0.5 + "px"
|
||||
};
|
||||
switch (unref(direction)) {
|
||||
case Direction.Up:
|
||||
barStyle.clipPath = `inset(${normalizedProgress.value}% 0% 0% 0%)`;
|
||||
barStyle.width = unwrapRef(width) + 1 + "px";
|
||||
break;
|
||||
case Direction.Down:
|
||||
barStyle.clipPath = `inset(0% 0% ${normalizedProgress.value}% 0%)`;
|
||||
barStyle.width = unwrapRef(width) + 1 + "px";
|
||||
break;
|
||||
case Direction.Right:
|
||||
barStyle.clipPath = `inset(0% ${normalizedProgress.value}% 0% 0%)`;
|
||||
break;
|
||||
case Direction.Left:
|
||||
barStyle.clipPath = `inset(0% 0% 0% ${normalizedProgress.value}%)`;
|
||||
break;
|
||||
case Direction.Default:
|
||||
barStyle.clipPath = "inset(0% 50% 0% 0%)";
|
||||
break;
|
||||
}
|
||||
return barStyle;
|
||||
});
|
||||
|
||||
const component = computeOptionalComponent(display);
|
||||
|
||||
return {
|
||||
normalizedProgress,
|
||||
barStyle,
|
||||
component,
|
||||
unref,
|
||||
Visibility,
|
||||
isVisible,
|
||||
isHidden
|
||||
};
|
||||
}
|
||||
const 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 barStyle = computed(() => {
|
||||
const barStyle: Partial<CSSProperties> = {
|
||||
width: unref(props.width) + 0.5 + "px",
|
||||
height: unref(props.height) + 0.5 + "px"
|
||||
};
|
||||
switch (props.direction) {
|
||||
case Direction.Up:
|
||||
barStyle.clipPath = `inset(${normalizedProgress.value}% 0% 0% 0%)`;
|
||||
barStyle.width = unref(props.width) + 1 + "px";
|
||||
break;
|
||||
case Direction.Down:
|
||||
barStyle.clipPath = `inset(0% 0% ${normalizedProgress.value}% 0%)`;
|
||||
barStyle.width = unref(props.width) + 1 + "px";
|
||||
break;
|
||||
case Direction.Right:
|
||||
barStyle.clipPath = `inset(0% ${normalizedProgress.value}% 0% 0%)`;
|
||||
break;
|
||||
case Direction.Left:
|
||||
barStyle.clipPath = `inset(0% 0% 0% ${normalizedProgress.value}%)`;
|
||||
break;
|
||||
case Direction.Default:
|
||||
barStyle.clipPath = "inset(0% 50% 0% 0%)";
|
||||
break;
|
||||
}
|
||||
return barStyle;
|
||||
});
|
||||
|
||||
const Component = () => props.display ? render(props.display) : null;
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
@ -1,185 +0,0 @@
|
|||
import BarComponent from "features/bars/Bar.vue";
|
||||
import { GenericDecorator } from "features/decorators/common";
|
||||
import type {
|
||||
CoercableComponent,
|
||||
GenericComponent,
|
||||
OptionsFunc,
|
||||
Replace,
|
||||
StyleValue
|
||||
} from "features/feature";
|
||||
import { Component, GatherProps, Visibility, getUniqueID, setDefault } from "features/feature";
|
||||
import type { DecimalSource } from "util/bignum";
|
||||
import { Direction } from "util/common";
|
||||
import type {
|
||||
Computable,
|
||||
GetComputableType,
|
||||
GetComputableTypeWithDefault,
|
||||
ProcessedComputable
|
||||
} from "util/computed";
|
||||
import { processComputable } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import { unref } from "vue";
|
||||
|
||||
/** A symbol used to identify {@link Bar} features. */
|
||||
export const BarType = Symbol("Bar");
|
||||
|
||||
/**
|
||||
* An object that configures a {@link Bar}.
|
||||
*/
|
||||
export interface BarOptions {
|
||||
/** Whether this bar should be visible. */
|
||||
visibility?: Computable<Visibility | boolean>;
|
||||
/** The width of the bar. */
|
||||
width: Computable<number>;
|
||||
/** The height of the bar. */
|
||||
height: Computable<number>;
|
||||
/** The direction in which the bar progresses. */
|
||||
direction: Computable<Direction>;
|
||||
/** CSS to apply to this feature. */
|
||||
style?: Computable<StyleValue>;
|
||||
/** Dictionary of CSS classes to apply to this feature. */
|
||||
classes?: Computable<Record<string, boolean>>;
|
||||
/** CSS to apply to the bar's border. */
|
||||
borderStyle?: Computable<StyleValue>;
|
||||
/** CSS to apply to the bar's base. */
|
||||
baseStyle?: Computable<StyleValue>;
|
||||
/** CSS to apply to the bar's text. */
|
||||
textStyle?: Computable<StyleValue>;
|
||||
/** CSS to apply to the bar's fill. */
|
||||
fillStyle?: Computable<StyleValue>;
|
||||
/** The progress value of the bar, from 0 to 1. */
|
||||
progress: Computable<DecimalSource>;
|
||||
/** The display to use for this bar. */
|
||||
display?: Computable<CoercableComponent>;
|
||||
/** Shows a marker on the corner of the feature. */
|
||||
mark?: Computable<boolean | string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The properties that are added onto a processed {@link BarOptions} to create a {@link Bar}.
|
||||
*/
|
||||
export interface BaseBar {
|
||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
||||
id: string;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof BarType;
|
||||
/** The Vue component used to render this feature. */
|
||||
[Component]: GenericComponent;
|
||||
/** A function to gather the props the vue component requires for this feature. */
|
||||
[GatherProps]: () => Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** An object that represents a feature that displays some sort of progress or completion or resource with a cap. */
|
||||
export type Bar<T extends BarOptions> = Replace<
|
||||
T & BaseBar,
|
||||
{
|
||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||
width: GetComputableType<T["width"]>;
|
||||
height: GetComputableType<T["height"]>;
|
||||
direction: GetComputableType<T["direction"]>;
|
||||
style: GetComputableType<T["style"]>;
|
||||
classes: GetComputableType<T["classes"]>;
|
||||
borderStyle: GetComputableType<T["borderStyle"]>;
|
||||
baseStyle: GetComputableType<T["baseStyle"]>;
|
||||
textStyle: GetComputableType<T["textStyle"]>;
|
||||
fillStyle: GetComputableType<T["fillStyle"]>;
|
||||
progress: GetComputableType<T["progress"]>;
|
||||
display: GetComputableType<T["display"]>;
|
||||
mark: GetComputableType<T["mark"]>;
|
||||
}
|
||||
>;
|
||||
|
||||
/** A type that matches any valid {@link Bar} object. */
|
||||
export type GenericBar = Replace<
|
||||
Bar<BarOptions>,
|
||||
{
|
||||
visibility: ProcessedComputable<Visibility | boolean>;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* Lazily creates a bar with the given options.
|
||||
* @param optionsFunc Bar options.
|
||||
*/
|
||||
export function createBar<T extends BarOptions>(
|
||||
optionsFunc: OptionsFunc<T, BaseBar, GenericBar>,
|
||||
...decorators: GenericDecorator[]
|
||||
): Bar<T> {
|
||||
const decoratedData = decorators.reduce(
|
||||
(current, next) => Object.assign(current, next.getPersistentData?.()),
|
||||
{}
|
||||
);
|
||||
return createLazyProxy(feature => {
|
||||
const bar = optionsFunc.call(feature, feature);
|
||||
bar.id = getUniqueID("bar-");
|
||||
bar.type = BarType;
|
||||
bar[Component] = BarComponent as GenericComponent;
|
||||
|
||||
for (const decorator of decorators) {
|
||||
decorator.preConstruct?.(bar);
|
||||
}
|
||||
|
||||
Object.assign(bar, decoratedData);
|
||||
|
||||
processComputable(bar as T, "visibility");
|
||||
setDefault(bar, "visibility", Visibility.Visible);
|
||||
processComputable(bar as T, "width");
|
||||
processComputable(bar as T, "height");
|
||||
processComputable(bar as T, "direction");
|
||||
processComputable(bar as T, "style");
|
||||
processComputable(bar as T, "classes");
|
||||
processComputable(bar as T, "borderStyle");
|
||||
processComputable(bar as T, "baseStyle");
|
||||
processComputable(bar as T, "textStyle");
|
||||
processComputable(bar as T, "fillStyle");
|
||||
processComputable(bar as T, "progress");
|
||||
processComputable(bar as T, "display");
|
||||
processComputable(bar as T, "mark");
|
||||
|
||||
for (const decorator of decorators) {
|
||||
decorator.postConstruct?.(bar);
|
||||
}
|
||||
|
||||
const decoratedProps = decorators.reduce(
|
||||
(current, next) => Object.assign(current, next.getGatheredProps?.(bar)),
|
||||
{}
|
||||
);
|
||||
bar[GatherProps] = function (this: GenericBar) {
|
||||
const {
|
||||
progress,
|
||||
width,
|
||||
height,
|
||||
direction,
|
||||
display,
|
||||
visibility,
|
||||
style,
|
||||
classes,
|
||||
borderStyle,
|
||||
textStyle,
|
||||
baseStyle,
|
||||
fillStyle,
|
||||
mark,
|
||||
id
|
||||
} = this;
|
||||
return {
|
||||
progress,
|
||||
width,
|
||||
height,
|
||||
direction,
|
||||
display,
|
||||
visibility,
|
||||
style: unref(style),
|
||||
classes,
|
||||
borderStyle,
|
||||
textStyle,
|
||||
baseStyle,
|
||||
fillStyle,
|
||||
mark,
|
||||
id,
|
||||
...decoratedProps
|
||||
};
|
||||
};
|
||||
|
||||
return bar as unknown as Bar<T>;
|
||||
});
|
||||
}
|
109
src/features/bars/bar.tsx
Normal file
109
src/features/bars/bar.tsx
Normal file
|
@ -0,0 +1,109 @@
|
|||
import Bar from "features/bars/Bar.vue";
|
||||
import type { DecimalSource } from "util/bignum";
|
||||
import { Direction } from "util/common";
|
||||
import { processGetter } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import { Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
|
||||
import { CSSProperties, MaybeRef, MaybeRefOrGetter } from "vue";
|
||||
|
||||
/** A symbol used to identify {@link Bar} features. */
|
||||
export const BarType = Symbol("Bar");
|
||||
|
||||
/**
|
||||
* An object that configures a {@link Bar}.
|
||||
*/
|
||||
export interface BarOptions extends VueFeatureOptions {
|
||||
/** The width of the bar. */
|
||||
width: MaybeRefOrGetter<number>;
|
||||
/** The height of the bar. */
|
||||
height: MaybeRefOrGetter<number>;
|
||||
/** The direction in which the bar progresses. */
|
||||
direction: MaybeRefOrGetter<Direction>;
|
||||
/** CSS to apply to the bar's border. */
|
||||
borderStyle?: MaybeRefOrGetter<CSSProperties>;
|
||||
/** CSS to apply to the bar's base. */
|
||||
baseStyle?: MaybeRefOrGetter<CSSProperties>;
|
||||
/** CSS to apply to the bar's text. */
|
||||
textStyle?: MaybeRefOrGetter<CSSProperties>;
|
||||
/** CSS to apply to the bar's fill. */
|
||||
fillStyle?: MaybeRefOrGetter<CSSProperties>;
|
||||
/** The progress value of the bar, from 0 to 1. */
|
||||
progress: MaybeRefOrGetter<DecimalSource>;
|
||||
/** The display to use for this bar. */
|
||||
display?: MaybeRefOrGetter<Renderable>;
|
||||
}
|
||||
|
||||
/** An object that represents a feature that displays some sort of progress or completion or resource with a cap. */
|
||||
export interface Bar extends VueFeature {
|
||||
/** The width of the bar. */
|
||||
width: MaybeRef<number>;
|
||||
/** The height of the bar. */
|
||||
height: MaybeRef<number>;
|
||||
/** The direction in which the bar progresses. */
|
||||
direction: MaybeRef<Direction>;
|
||||
/** CSS to apply to the bar's border. */
|
||||
borderStyle?: MaybeRef<CSSProperties>;
|
||||
/** CSS to apply to the bar's base. */
|
||||
baseStyle?: MaybeRef<CSSProperties>;
|
||||
/** CSS to apply to the bar's text. */
|
||||
textStyle?: MaybeRef<CSSProperties>;
|
||||
/** CSS to apply to the bar's fill. */
|
||||
fillStyle?: MaybeRef<CSSProperties>;
|
||||
/** The progress value of the bar, from 0 to 1. */
|
||||
progress: MaybeRef<DecimalSource>;
|
||||
/** The display to use for this bar. */
|
||||
display?: MaybeRef<Renderable>;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof BarType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily creates a bar with the given options.
|
||||
* @param optionsFunc Bar options.
|
||||
*/
|
||||
export function createBar<T extends BarOptions>(optionsFunc: () => T) {
|
||||
return createLazyProxy(() => {
|
||||
const options = optionsFunc?.();
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
direction,
|
||||
borderStyle,
|
||||
baseStyle,
|
||||
textStyle,
|
||||
fillStyle,
|
||||
progress,
|
||||
display,
|
||||
...props
|
||||
} = options;
|
||||
|
||||
const bar = {
|
||||
type: BarType,
|
||||
...(props as Omit<typeof props, keyof VueFeature | keyof BarOptions>),
|
||||
...vueFeatureMixin("bar", options, () => (
|
||||
<Bar
|
||||
width={bar.width}
|
||||
height={bar.height}
|
||||
direction={bar.direction}
|
||||
borderStyle={bar.borderStyle}
|
||||
baseStyle={bar.baseStyle}
|
||||
textStyle={bar.textStyle}
|
||||
fillStyle={bar.fillStyle}
|
||||
progress={bar.progress}
|
||||
display={bar.display}
|
||||
/>
|
||||
)),
|
||||
width: processGetter(width),
|
||||
height: processGetter(height),
|
||||
direction: processGetter(direction),
|
||||
borderStyle: processGetter(borderStyle),
|
||||
baseStyle: processGetter(baseStyle),
|
||||
textStyle: processGetter(textStyle),
|
||||
fillStyle: processGetter(fillStyle),
|
||||
progress: processGetter(progress),
|
||||
display: processGetter(display)
|
||||
} satisfies Bar;
|
||||
|
||||
return bar;
|
||||
});
|
||||
}
|
|
@ -1,294 +0,0 @@
|
|||
<template>
|
||||
<panZoom
|
||||
v-if="isVisible(visibility)"
|
||||
:style="[
|
||||
{
|
||||
width,
|
||||
height
|
||||
},
|
||||
style
|
||||
]"
|
||||
:class="classes"
|
||||
selector=".g1"
|
||||
:options="{ initialZoom: 1, minZoom: 0.1, maxZoom: 10, zoomDoubleClickSpeed: 1 }"
|
||||
ref="stage"
|
||||
@init="onInit"
|
||||
@mousemove="drag"
|
||||
@touchmove="drag"
|
||||
@mousedown="(e: MouseEvent) => mouseDown(e)"
|
||||
@touchstart="(e: TouchEvent) => mouseDown(e)"
|
||||
@mouseup="() => endDragging(unref(draggingNode))"
|
||||
@touchend.passive="() => endDragging(unref(draggingNode))"
|
||||
@mouseleave="() => endDragging(unref(draggingNode), true)"
|
||||
>
|
||||
<svg class="stage" width="100%" height="100%">
|
||||
<g class="g1">
|
||||
<transition-group name="link" appear>
|
||||
<g
|
||||
v-for="link in unref(links) || []"
|
||||
:key="`${link.startNode.id}-${link.endNode.id}`"
|
||||
>
|
||||
<BoardLinkVue
|
||||
:link="link"
|
||||
:dragging="unref(draggingNode)"
|
||||
:dragged="
|
||||
link.startNode === unref(draggingNode) ||
|
||||
link.endNode === unref(draggingNode)
|
||||
? dragged
|
||||
: undefined
|
||||
"
|
||||
/>
|
||||
</g>
|
||||
</transition-group>
|
||||
<transition-group name="grow" :duration="500" appear>
|
||||
<g v-for="node in sortedNodes" :key="node.id" style="transition-duration: 0s">
|
||||
<BoardNodeVue
|
||||
:node="node"
|
||||
:nodeType="types[node.type]"
|
||||
:dragging="unref(draggingNode)"
|
||||
:dragged="unref(draggingNode) === node ? dragged : undefined"
|
||||
:hasDragged="unref(draggingNode) == null ? false : hasDragged"
|
||||
:receivingNode="unref(receivingNode) === node"
|
||||
:isSelected="unref(selectedNode) === node"
|
||||
:selectedAction="
|
||||
unref(selectedNode) === node ? unref(selectedAction) : null
|
||||
"
|
||||
@mouseDown="mouseDown"
|
||||
@endDragging="endDragging"
|
||||
@clickAction="(actionId: string) => clickAction(node, actionId)"
|
||||
/>
|
||||
</g>
|
||||
</transition-group>
|
||||
</g>
|
||||
</svg>
|
||||
</panZoom>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
BoardData,
|
||||
BoardNode,
|
||||
BoardNodeLink,
|
||||
GenericBoardNodeAction,
|
||||
GenericNodeType
|
||||
} from "features/boards/board";
|
||||
import { getNodeProperty } from "features/boards/board";
|
||||
import type { StyleValue } from "features/feature";
|
||||
import { Visibility, isVisible } from "features/feature";
|
||||
import type { ProcessedComputable } from "util/computed";
|
||||
import { Ref, computed, ref, toRefs, unref, watchEffect } from "vue";
|
||||
import BoardLinkVue from "./BoardLink.vue";
|
||||
import BoardNodeVue from "./BoardNode.vue";
|
||||
|
||||
const _props = defineProps<{
|
||||
nodes: Ref<BoardNode[]>;
|
||||
types: Record<string, GenericNodeType>;
|
||||
state: Ref<BoardData>;
|
||||
visibility: ProcessedComputable<Visibility | boolean>;
|
||||
width?: ProcessedComputable<string>;
|
||||
height?: ProcessedComputable<string>;
|
||||
style?: ProcessedComputable<StyleValue>;
|
||||
classes?: ProcessedComputable<Record<string, boolean>>;
|
||||
links: Ref<BoardNodeLink[] | null>;
|
||||
selectedAction: Ref<GenericBoardNodeAction | null>;
|
||||
selectedNode: Ref<BoardNode | null>;
|
||||
draggingNode: Ref<BoardNode | null>;
|
||||
receivingNode: Ref<BoardNode | null>;
|
||||
mousePosition: Ref<{ x: number; y: number } | null>;
|
||||
setReceivingNode: (node: BoardNode | null) => void;
|
||||
setDraggingNode: (node: BoardNode | null) => void;
|
||||
}>();
|
||||
const props = toRefs(_props);
|
||||
|
||||
const lastMousePosition = ref({ x: 0, y: 0 });
|
||||
const dragged = ref({ x: 0, y: 0 });
|
||||
const hasDragged = ref(false);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const stage = ref<any>(null);
|
||||
|
||||
const sortedNodes = computed(() => {
|
||||
const nodes = props.nodes.value.slice();
|
||||
if (props.selectedNode.value) {
|
||||
const node = nodes.splice(nodes.indexOf(props.selectedNode.value), 1)[0];
|
||||
nodes.push(node);
|
||||
}
|
||||
if (props.draggingNode.value) {
|
||||
const node = nodes.splice(nodes.indexOf(props.draggingNode.value), 1)[0];
|
||||
nodes.push(node);
|
||||
}
|
||||
return nodes;
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
const node = props.draggingNode.value;
|
||||
if (node == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const position = {
|
||||
x: node.position.x + dragged.value.x,
|
||||
y: node.position.y + dragged.value.y
|
||||
};
|
||||
let smallestDistance = Number.MAX_VALUE;
|
||||
|
||||
props.setReceivingNode.value(
|
||||
props.nodes.value.reduce((smallest: BoardNode | null, curr: BoardNode) => {
|
||||
if (curr.id === node.id) {
|
||||
return smallest;
|
||||
}
|
||||
const nodeType = props.types.value[curr.type];
|
||||
const canAccept = getNodeProperty(nodeType.canAccept, curr, node);
|
||||
if (!canAccept) {
|
||||
return smallest;
|
||||
}
|
||||
|
||||
const distanceSquared =
|
||||
Math.pow(position.x - curr.position.x, 2) +
|
||||
Math.pow(position.y - curr.position.y, 2);
|
||||
let size = getNodeProperty(nodeType.size, curr);
|
||||
if (distanceSquared > smallestDistance || distanceSquared > size * size) {
|
||||
return smallest;
|
||||
}
|
||||
|
||||
smallestDistance = distanceSquared;
|
||||
return curr;
|
||||
}, null)
|
||||
);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function onInit(panzoomInstance: any) {
|
||||
panzoomInstance.setTransformOrigin(null);
|
||||
panzoomInstance.moveTo(stage.value.$el.clientWidth / 2, stage.value.$el.clientHeight / 2);
|
||||
}
|
||||
|
||||
function mouseDown(e: MouseEvent | TouchEvent, node: BoardNode | null = null, draggable = false) {
|
||||
if (props.draggingNode.value == null) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
let clientX, clientY;
|
||||
if ("touches" in e) {
|
||||
if (e.touches.length === 1) {
|
||||
clientX = e.touches[0].clientX;
|
||||
clientY = e.touches[0].clientY;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
clientX = e.clientX;
|
||||
clientY = e.clientY;
|
||||
}
|
||||
lastMousePosition.value = {
|
||||
x: clientX,
|
||||
y: clientY
|
||||
};
|
||||
dragged.value = { x: 0, y: 0 };
|
||||
hasDragged.value = false;
|
||||
|
||||
if (draggable) {
|
||||
props.setDraggingNode.value(node);
|
||||
}
|
||||
}
|
||||
if (node != null) {
|
||||
props.state.value.selectedNode = null;
|
||||
props.state.value.selectedAction = null;
|
||||
}
|
||||
}
|
||||
|
||||
function drag(e: MouseEvent | TouchEvent) {
|
||||
const { x, y, scale } = stage.value.panZoomInstance.getTransform();
|
||||
|
||||
let clientX, clientY;
|
||||
if ("touches" in e) {
|
||||
if (e.touches.length === 1) {
|
||||
clientX = e.touches[0].clientX;
|
||||
clientY = e.touches[0].clientY;
|
||||
} else {
|
||||
endDragging(props.draggingNode.value);
|
||||
props.mousePosition.value = null;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
clientX = e.clientX;
|
||||
clientY = e.clientY;
|
||||
}
|
||||
|
||||
props.mousePosition.value = {
|
||||
x: (clientX - x) / scale,
|
||||
y: (clientY - y) / scale
|
||||
};
|
||||
|
||||
dragged.value = {
|
||||
x: dragged.value.x + (clientX - lastMousePosition.value.x) / scale,
|
||||
y: dragged.value.y + (clientY - lastMousePosition.value.y) / scale
|
||||
};
|
||||
lastMousePosition.value = {
|
||||
x: clientX,
|
||||
y: clientY
|
||||
};
|
||||
|
||||
if (Math.abs(dragged.value.x) > 10 || Math.abs(dragged.value.y) > 10) {
|
||||
hasDragged.value = true;
|
||||
}
|
||||
|
||||
if (props.draggingNode.value != null) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
function endDragging(node: BoardNode | null, mouseLeave = false) {
|
||||
if (props.draggingNode.value != null && props.draggingNode.value === node) {
|
||||
if (props.receivingNode.value == null) {
|
||||
props.draggingNode.value.position.x += Math.round(dragged.value.x / 25) * 25;
|
||||
props.draggingNode.value.position.y += Math.round(dragged.value.y / 25) * 25;
|
||||
}
|
||||
|
||||
const nodes = props.nodes.value;
|
||||
nodes.push(nodes.splice(nodes.indexOf(props.draggingNode.value), 1)[0]);
|
||||
|
||||
if (props.receivingNode.value) {
|
||||
props.types.value[props.receivingNode.value.type].onDrop?.(
|
||||
props.receivingNode.value,
|
||||
props.draggingNode.value
|
||||
);
|
||||
}
|
||||
|
||||
props.setDraggingNode.value(null);
|
||||
} else if (!hasDragged.value && !mouseLeave) {
|
||||
props.state.value.selectedNode = null;
|
||||
props.state.value.selectedAction = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clickAction(node: BoardNode, actionId: string) {
|
||||
if (props.state.value.selectedAction === actionId) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
unref(props.selectedAction)!.onClick(unref(props.selectedNode)!);
|
||||
} else {
|
||||
props.state.value = { ...props.state.value, selectedAction: actionId };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.vue-pan-zoom-scene {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.vue-pan-zoom-scene:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.g1 {
|
||||
transition-duration: 0s;
|
||||
}
|
||||
|
||||
.link-enter-from,
|
||||
.link-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
|
@ -1,80 +0,0 @@
|
|||
<template>
|
||||
<line
|
||||
class="link"
|
||||
v-bind="linkProps"
|
||||
:class="{ pulsing: link.pulsing }"
|
||||
:x1="startPosition.x"
|
||||
:y1="startPosition.y"
|
||||
:x2="endPosition.x"
|
||||
:y2="endPosition.y"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { BoardNode, BoardNodeLink } from "features/boards/board";
|
||||
import { kebabifyObject } from "util/vue";
|
||||
import { computed, toRefs, unref } from "vue";
|
||||
|
||||
const _props = defineProps<{
|
||||
link: BoardNodeLink;
|
||||
dragging: BoardNode | null;
|
||||
dragged?: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
}>();
|
||||
const props = toRefs(_props);
|
||||
|
||||
const startPosition = computed(() => {
|
||||
const position = { ...props.link.value.startNode.position };
|
||||
if (props.link.value.offsetStart) {
|
||||
position.x += unref(props.link.value.offsetStart).x;
|
||||
position.y += unref(props.link.value.offsetStart).y;
|
||||
}
|
||||
if (props.dragging?.value === props.link.value.startNode) {
|
||||
position.x += props.dragged?.value?.x ?? 0;
|
||||
position.y += props.dragged?.value?.y ?? 0;
|
||||
}
|
||||
return position;
|
||||
});
|
||||
|
||||
const endPosition = computed(() => {
|
||||
const position = { ...props.link.value.endNode.position };
|
||||
if (props.link.value.offsetEnd) {
|
||||
position.x += unref(props.link.value.offsetEnd).x;
|
||||
position.y += unref(props.link.value.offsetEnd).y;
|
||||
}
|
||||
if (props.dragging?.value === props.link.value.endNode) {
|
||||
position.x += props.dragged?.value?.x ?? 0;
|
||||
position.y += props.dragged?.value?.y ?? 0;
|
||||
}
|
||||
return position;
|
||||
});
|
||||
|
||||
const linkProps = computed(() => kebabifyObject(_props.link as unknown as Record<string, unknown>));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.link {
|
||||
transition-duration: 0s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.link.pulsing {
|
||||
animation: pulsing 2s ease-in infinite;
|
||||
}
|
||||
|
||||
@keyframes pulsing {
|
||||
0% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,339 +0,0 @@
|
|||
<template>
|
||||
<!-- Ugly casting to prevent TS compiler error about style because vue doesn't think it supports arrays when it does -->
|
||||
<g
|
||||
class="boardnode"
|
||||
:class="{ [node.type]: true, isSelected, isDraggable, ...classes }"
|
||||
:style="[{ opacity: dragging?.id === node.id && hasDragged ? 0.5 : 1 }, style ?? []] as unknown as (string | CSSProperties)"
|
||||
:transform="`translate(${position.x},${position.y})${isSelected ? ' scale(1.2)' : ''}`"
|
||||
>
|
||||
<BoardNodeAction
|
||||
:actions="actions ?? []"
|
||||
:is-selected="isSelected"
|
||||
:node="node"
|
||||
:node-type="nodeType"
|
||||
:selected-action="selectedAction"
|
||||
@click-action="(actionId: string) => emit('clickAction', actionId)"
|
||||
/>
|
||||
|
||||
<g
|
||||
class="node-container"
|
||||
@mousedown="mouseDown"
|
||||
@touchstart.passive="mouseDown"
|
||||
@mouseup="mouseUp"
|
||||
@touchend.passive="mouseUp"
|
||||
>
|
||||
<g v-if="shape === Shape.Circle">
|
||||
<circle
|
||||
v-if="canAccept"
|
||||
class="receiver"
|
||||
:r="size + 8"
|
||||
:fill="backgroundColor"
|
||||
:stroke="receivingNode ? '#0F0' : '#0F03'"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
|
||||
<circle
|
||||
class="body"
|
||||
:r="size"
|
||||
:fill="fillColor"
|
||||
:stroke="outlineColor"
|
||||
:stroke-width="4"
|
||||
/>
|
||||
|
||||
<circle
|
||||
class="progress progressFill"
|
||||
v-if="progressDisplay === ProgressDisplay.Fill"
|
||||
:r="Math.max(size * progress - 2, 0)"
|
||||
:fill="progressColor"
|
||||
/>
|
||||
<circle
|
||||
v-else
|
||||
:r="size + 4.5"
|
||||
class="progress progressRing"
|
||||
fill="transparent"
|
||||
:stroke-dasharray="(size + 4.5) * 2 * Math.PI"
|
||||
:stroke-width="5"
|
||||
:stroke-dashoffset="
|
||||
(size + 4.5) * 2 * Math.PI - progress * (size + 4.5) * 2 * Math.PI
|
||||
"
|
||||
:stroke="progressColor"
|
||||
/>
|
||||
</g>
|
||||
<g v-else-if="shape === Shape.Diamond" transform="rotate(45, 0, 0)">
|
||||
<rect
|
||||
v-if="canAccept"
|
||||
class="receiver"
|
||||
:width="size * sqrtTwo + 16"
|
||||
:height="size * sqrtTwo + 16"
|
||||
:transform="`translate(${-(size * sqrtTwo + 16) / 2}, ${
|
||||
-(size * sqrtTwo + 16) / 2
|
||||
})`"
|
||||
:fill="backgroundColor"
|
||||
:stroke="receivingNode ? '#0F0' : '#0F03'"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
|
||||
<rect
|
||||
class="body"
|
||||
:width="size * sqrtTwo"
|
||||
:height="size * sqrtTwo"
|
||||
:transform="`translate(${(-size * sqrtTwo) / 2}, ${(-size * sqrtTwo) / 2})`"
|
||||
:fill="fillColor"
|
||||
:stroke="outlineColor"
|
||||
:stroke-width="4"
|
||||
/>
|
||||
|
||||
<rect
|
||||
v-if="progressDisplay === ProgressDisplay.Fill"
|
||||
class="progress progressFill"
|
||||
:width="Math.max(size * sqrtTwo * progress - 2, 0)"
|
||||
:height="Math.max(size * sqrtTwo * progress - 2, 0)"
|
||||
:transform="`translate(${-Math.max(size * sqrtTwo * progress - 2, 0) / 2}, ${
|
||||
-Math.max(size * sqrtTwo * progress - 2, 0) / 2
|
||||
})`"
|
||||
:fill="progressColor"
|
||||
/>
|
||||
<rect
|
||||
v-else
|
||||
class="progress progressDiamond"
|
||||
:width="size * sqrtTwo + 9"
|
||||
:height="size * sqrtTwo + 9"
|
||||
:transform="`translate(${-(size * sqrtTwo + 9) / 2}, ${
|
||||
-(size * sqrtTwo + 9) / 2
|
||||
})`"
|
||||
fill="transparent"
|
||||
:stroke-dasharray="(size * sqrtTwo + 9) * 4"
|
||||
:stroke-width="5"
|
||||
:stroke-dashoffset="
|
||||
(size * sqrtTwo + 9) * 4 - progress * (size * sqrtTwo + 9) * 4
|
||||
"
|
||||
:stroke="progressColor"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<text :fill="titleColor" class="node-title">{{ title }}</text>
|
||||
</g>
|
||||
|
||||
<transition name="fade" appear>
|
||||
<g v-if="label">
|
||||
<text
|
||||
:fill="label.color ?? titleColor"
|
||||
class="node-title"
|
||||
:class="{ pulsing: label.pulsing }"
|
||||
:y="-size - 20"
|
||||
>{{ label.text }}
|
||||
</text>
|
||||
</g>
|
||||
</transition>
|
||||
|
||||
<transition name="fade" appear>
|
||||
<text
|
||||
v-if="isSelected && selectedAction"
|
||||
:fill="confirmationLabel.color ?? titleColor"
|
||||
class="node-title"
|
||||
:class="{ pulsing: confirmationLabel.pulsing }"
|
||||
:y="size + 75"
|
||||
>{{ confirmationLabel.text }}</text
|
||||
>
|
||||
</transition>
|
||||
</g>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import themes from "data/themes";
|
||||
import type { BoardNode, GenericBoardNodeAction, GenericNodeType } from "features/boards/board";
|
||||
import { ProgressDisplay, Shape, getNodeProperty } from "features/boards/board";
|
||||
import { isVisible } from "features/feature";
|
||||
import settings from "game/settings";
|
||||
import { CSSProperties, computed, toRefs, unref, watch } from "vue";
|
||||
import BoardNodeAction from "./BoardNodeAction.vue";
|
||||
|
||||
const sqrtTwo = Math.sqrt(2);
|
||||
|
||||
const _props = defineProps<{
|
||||
node: BoardNode;
|
||||
nodeType: GenericNodeType;
|
||||
dragging: BoardNode | null;
|
||||
dragged?: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
hasDragged?: boolean;
|
||||
receivingNode?: boolean;
|
||||
isSelected: boolean;
|
||||
selectedAction: GenericBoardNodeAction | null;
|
||||
}>();
|
||||
const props = toRefs(_props);
|
||||
const emit = defineEmits<{
|
||||
(e: "mouseDown", event: MouseEvent | TouchEvent, node: BoardNode, isDraggable: boolean): void;
|
||||
(e: "endDragging", node: BoardNode): void;
|
||||
(e: "clickAction", actionId: string): void;
|
||||
}>();
|
||||
|
||||
const isDraggable = computed(() =>
|
||||
getNodeProperty(props.nodeType.value.draggable, unref(props.node))
|
||||
);
|
||||
|
||||
watch(isDraggable, value => {
|
||||
const node = unref(props.node);
|
||||
if (unref(props.dragging) === node && !value) {
|
||||
emit("endDragging", node);
|
||||
}
|
||||
});
|
||||
|
||||
const actions = computed(() => {
|
||||
const node = unref(props.node);
|
||||
return getNodeProperty(props.nodeType.value.actions, node)?.filter(action =>
|
||||
isVisible(getNodeProperty(action.visibility, node))
|
||||
);
|
||||
});
|
||||
|
||||
const position = computed(() => {
|
||||
const node = unref(props.node);
|
||||
|
||||
if (
|
||||
getNodeProperty(props.nodeType.value.draggable, node) &&
|
||||
unref(props.dragging)?.id === node.id &&
|
||||
unref(props.dragged) != null
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const { x, y } = unref(props.dragged)!;
|
||||
return {
|
||||
x: node.position.x + Math.round(x / 25) * 25,
|
||||
y: node.position.y + Math.round(y / 25) * 25
|
||||
};
|
||||
}
|
||||
return node.position;
|
||||
});
|
||||
|
||||
const shape = computed(() => getNodeProperty(props.nodeType.value.shape, unref(props.node)));
|
||||
const title = computed(() => getNodeProperty(props.nodeType.value.title, unref(props.node)));
|
||||
const label = computed(
|
||||
() =>
|
||||
(props.isSelected.value
|
||||
? unref(props.selectedAction) &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getNodeProperty(unref(props.selectedAction)!.tooltip, unref(props.node))
|
||||
: null) ?? getNodeProperty(props.nodeType.value.label, unref(props.node))
|
||||
);
|
||||
const confirmationLabel = computed(() =>
|
||||
getNodeProperty(
|
||||
unref(props.selectedAction)?.confirmationLabel ?? {
|
||||
text: "Tap again to confirm"
|
||||
},
|
||||
unref(props.node)
|
||||
)
|
||||
);
|
||||
const size = computed(() => getNodeProperty(props.nodeType.value.size, unref(props.node)));
|
||||
const progress = computed(
|
||||
() => getNodeProperty(props.nodeType.value.progress, unref(props.node)) ?? 0
|
||||
);
|
||||
const backgroundColor = computed(() => themes[settings.theme].variables["--background"]);
|
||||
const outlineColor = computed(
|
||||
() =>
|
||||
getNodeProperty(props.nodeType.value.outlineColor, unref(props.node)) ??
|
||||
themes[settings.theme].variables["--outline"]
|
||||
);
|
||||
const fillColor = computed(
|
||||
() =>
|
||||
getNodeProperty(props.nodeType.value.fillColor, unref(props.node)) ??
|
||||
themes[settings.theme].variables["--raised-background"]
|
||||
);
|
||||
const progressColor = computed(() =>
|
||||
getNodeProperty(props.nodeType.value.progressColor, unref(props.node))
|
||||
);
|
||||
const titleColor = computed(
|
||||
() =>
|
||||
getNodeProperty(props.nodeType.value.titleColor, unref(props.node)) ??
|
||||
themes[settings.theme].variables["--foreground"]
|
||||
);
|
||||
const progressDisplay = computed(() =>
|
||||
getNodeProperty(props.nodeType.value.progressDisplay, unref(props.node))
|
||||
);
|
||||
const canAccept = computed(
|
||||
() =>
|
||||
props.dragging.value != null &&
|
||||
unref(props.hasDragged) &&
|
||||
getNodeProperty(props.nodeType.value.canAccept, unref(props.node), props.dragging.value)
|
||||
);
|
||||
const style = computed(() => getNodeProperty(props.nodeType.value.style, unref(props.node)));
|
||||
const classes = computed(() => getNodeProperty(props.nodeType.value.classes, unref(props.node)));
|
||||
|
||||
function mouseDown(e: MouseEvent | TouchEvent) {
|
||||
emit("mouseDown", e, props.node.value, isDraggable.value);
|
||||
}
|
||||
|
||||
function mouseUp(e: MouseEvent | TouchEvent) {
|
||||
if (!props.hasDragged?.value) {
|
||||
emit("endDragging", props.node.value);
|
||||
props.nodeType.value.onClick?.(props.node.value);
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.boardnode {
|
||||
cursor: pointer;
|
||||
transition-duration: 0s;
|
||||
}
|
||||
|
||||
.boardnode:hover .body {
|
||||
fill: var(--highlighted);
|
||||
}
|
||||
|
||||
.boardnode.isSelected .body {
|
||||
fill: var(--accent1) !important;
|
||||
}
|
||||
|
||||
.boardnode:not(.isDraggable) .body {
|
||||
fill: var(--locked);
|
||||
}
|
||||
|
||||
.node-title {
|
||||
text-anchor: middle;
|
||||
dominant-baseline: middle;
|
||||
font-family: monospace;
|
||||
font-size: 200%;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(3px 3px 2px var(--tooltip-background));
|
||||
}
|
||||
|
||||
.progress {
|
||||
transition-duration: 0.05s;
|
||||
}
|
||||
|
||||
.progressRing {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.pulsing {
|
||||
animation: pulsing 2s ease-in infinite;
|
||||
}
|
||||
|
||||
@keyframes pulsing {
|
||||
0% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.grow-enter-from .node-container,
|
||||
.grow-leave-to .node-container {
|
||||
transform: scale(0);
|
||||
}
|
||||
</style>
|
|
@ -1,109 +0,0 @@
|
|||
<template>
|
||||
<transition name="actions" appear>
|
||||
<g v-if="isSelected && actions">
|
||||
<g
|
||||
v-for="(action, index) in actions"
|
||||
:key="action.id"
|
||||
class="action"
|
||||
:class="{ selected: selectedAction?.id === action.id }"
|
||||
:transform="`translate(
|
||||
${
|
||||
(-size - 30) *
|
||||
Math.sin(((actions.length - 1) / 2 - index) * actionDistance)
|
||||
},
|
||||
${
|
||||
(size + 30) *
|
||||
Math.cos(((actions.length - 1) / 2 - index) * actionDistance)
|
||||
}
|
||||
)`"
|
||||
@mousedown="e => performAction(e, action)"
|
||||
@touchstart="e => performAction(e, action)"
|
||||
@mouseup="e => actionMouseUp(e, action)"
|
||||
@touchend.stop="e => actionMouseUp(e, action)"
|
||||
>
|
||||
<circle
|
||||
:fill="getNodeProperty(action.fillColor, node)"
|
||||
r="20"
|
||||
:stroke-width="selectedAction?.id === action.id ? 4 : 0"
|
||||
:stroke="outlineColor"
|
||||
/>
|
||||
<text :fill="titleColor" class="material-icons">{{
|
||||
getNodeProperty(action.icon, node)
|
||||
}}</text>
|
||||
</g>
|
||||
</g>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import themes from "data/themes";
|
||||
import type { BoardNode, GenericBoardNodeAction, GenericNodeType } from "features/boards/board";
|
||||
import { getNodeProperty } from "features/boards/board";
|
||||
import settings from "game/settings";
|
||||
import { computed, toRefs, unref } from "vue";
|
||||
|
||||
const _props = defineProps<{
|
||||
node: BoardNode;
|
||||
nodeType: GenericNodeType;
|
||||
actions?: GenericBoardNodeAction[];
|
||||
isSelected: boolean;
|
||||
selectedAction: GenericBoardNodeAction | null;
|
||||
}>();
|
||||
const props = toRefs(_props);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "clickAction", actionId: string): void;
|
||||
}>();
|
||||
|
||||
const size = computed(() => getNodeProperty(props.nodeType.value.size, unref(props.node)));
|
||||
const outlineColor = computed(
|
||||
() =>
|
||||
getNodeProperty(props.nodeType.value.outlineColor, unref(props.node)) ??
|
||||
themes[settings.theme].variables["--outline"]
|
||||
);
|
||||
const titleColor = computed(
|
||||
() =>
|
||||
getNodeProperty(props.nodeType.value.titleColor, unref(props.node)) ??
|
||||
themes[settings.theme].variables["--foreground"]
|
||||
);
|
||||
const actionDistance = computed(() =>
|
||||
getNodeProperty(props.nodeType.value.actionDistance, unref(props.node))
|
||||
);
|
||||
|
||||
function performAction(e: MouseEvent | TouchEvent, action: GenericBoardNodeAction) {
|
||||
emit("clickAction", action.id);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
function actionMouseUp(e: MouseEvent | TouchEvent, action: GenericBoardNodeAction) {
|
||||
if (unref(props.selectedAction)?.id === action.id) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.action:not(.boardnode):hover circle,
|
||||
.action:not(.boardnode).selected circle {
|
||||
r: 25;
|
||||
}
|
||||
|
||||
.action:not(.boardnode):hover text,
|
||||
.action:not(.boardnode).selected text {
|
||||
font-size: 187.5%; /* 150% * 1.25 */
|
||||
}
|
||||
|
||||
.action:not(.boardnode) text {
|
||||
text-anchor: middle;
|
||||
dominant-baseline: central;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.actions-enter-from .action,
|
||||
.actions-leave-to .action {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
</style>
|
|
@ -1,631 +0,0 @@
|
|||
import BoardComponent from "features/boards/Board.vue";
|
||||
import type { GenericComponent, OptionsFunc, Replace, StyleValue } from "features/feature";
|
||||
import {
|
||||
Component,
|
||||
findFeatures,
|
||||
GatherProps,
|
||||
getUniqueID,
|
||||
setDefault,
|
||||
Visibility
|
||||
} from "features/feature";
|
||||
import { globalBus } from "game/events";
|
||||
import { DefaultValue, deletePersistent, Persistent, State } from "game/persistence";
|
||||
import { persistent } from "game/persistence";
|
||||
import type { Unsubscribe } from "nanoevents";
|
||||
import { Direction, isFunction } from "util/common";
|
||||
import type {
|
||||
Computable,
|
||||
GetComputableType,
|
||||
GetComputableTypeWithDefault,
|
||||
ProcessedComputable
|
||||
} from "util/computed";
|
||||
import { processComputable } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import { computed, isRef, ref, Ref, unref } from "vue";
|
||||
import panZoom from "vue-panzoom";
|
||||
import type { Link } from "../links/links";
|
||||
|
||||
globalBus.on("setupVue", app => panZoom.install(app));
|
||||
|
||||
/** A symbol used to identify {@link Board} features. */
|
||||
export const BoardType = Symbol("Board");
|
||||
|
||||
/**
|
||||
* A type representing a computable value for a node on the board. Used for node types to return different values based on the given node and the state of the board.
|
||||
*/
|
||||
export type NodeComputable<T, S extends unknown[] = []> =
|
||||
| Computable<T>
|
||||
| ((node: BoardNode, ...args: S) => T);
|
||||
|
||||
/** Ways to display progress of an action with a duration. */
|
||||
export enum ProgressDisplay {
|
||||
Outline = "Outline",
|
||||
Fill = "Fill"
|
||||
}
|
||||
|
||||
/** Node shapes. */
|
||||
export enum Shape {
|
||||
Circle = "Circle",
|
||||
Diamond = "Triangle"
|
||||
}
|
||||
|
||||
/** An object representing a node on the board. */
|
||||
export interface BoardNode {
|
||||
id: number;
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
type: string;
|
||||
state?: State;
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
/** An object representing a link between two nodes on the board. */
|
||||
export interface BoardNodeLink extends Omit<Link, "startNode" | "endNode"> {
|
||||
startNode: BoardNode;
|
||||
endNode: BoardNode;
|
||||
stroke: string;
|
||||
strokeWidth: number;
|
||||
pulsing?: boolean;
|
||||
}
|
||||
|
||||
/** An object representing a label for a node. */
|
||||
export interface NodeLabel {
|
||||
text: string;
|
||||
color?: string;
|
||||
pulsing?: boolean;
|
||||
}
|
||||
|
||||
/** The persistent data for a board. */
|
||||
export type BoardData = {
|
||||
nodes: BoardNode[];
|
||||
selectedNode: number | null;
|
||||
selectedAction: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* An object that configures a {@link NodeType}.
|
||||
*/
|
||||
export interface NodeTypeOptions {
|
||||
/** The title to display for the node. */
|
||||
title: NodeComputable<string>;
|
||||
/** An optional label for the node. */
|
||||
label?: NodeComputable<NodeLabel | null>;
|
||||
/** The size of the node - diameter for circles, width and height for squares. */
|
||||
size: NodeComputable<number>;
|
||||
/** CSS to apply to this node. */
|
||||
style?: NodeComputable<StyleValue>;
|
||||
/** Dictionary of CSS classes to apply to this node. */
|
||||
classes?: NodeComputable<Record<string, boolean>>;
|
||||
/** Whether the node is draggable or not. */
|
||||
draggable?: NodeComputable<boolean>;
|
||||
/** The shape of the node. */
|
||||
shape: NodeComputable<Shape>;
|
||||
/** Whether the node can accept another node being dropped upon it. */
|
||||
canAccept?: NodeComputable<boolean, [BoardNode]>;
|
||||
/** The progress value of the node, from 0 to 1. */
|
||||
progress?: NodeComputable<number>;
|
||||
/** How the progress should be displayed on the node. */
|
||||
progressDisplay?: NodeComputable<ProgressDisplay>;
|
||||
/** The color of the progress indicator. */
|
||||
progressColor?: NodeComputable<string>;
|
||||
/** The fill color of the node. */
|
||||
fillColor?: NodeComputable<string>;
|
||||
/** The outline color of the node. */
|
||||
outlineColor?: NodeComputable<string>;
|
||||
/** The color of the title text. */
|
||||
titleColor?: NodeComputable<string>;
|
||||
/** The list of action options for the node. */
|
||||
actions?: BoardNodeActionOptions[];
|
||||
/** The arc between each action, in radians. */
|
||||
actionDistance?: NodeComputable<number>;
|
||||
/** A function that is called when the node is clicked. */
|
||||
onClick?: (node: BoardNode) => void;
|
||||
/** A function that is called when a node is dropped onto this node. */
|
||||
onDrop?: (node: BoardNode, otherNode: BoardNode) => void;
|
||||
/** A function that is called for each node of this type every tick. */
|
||||
update?: (node: BoardNode, diff: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The properties that are added onto a processed {@link NodeTypeOptions} to create a {@link NodeType}.
|
||||
*/
|
||||
export interface BaseNodeType {
|
||||
/** The nodes currently on the board of this type. */
|
||||
nodes: Ref<BoardNode[]>;
|
||||
}
|
||||
|
||||
/** An object that represents a type of node that can appear on a board. It will handle getting properties and callbacks for every node of that type. */
|
||||
export type NodeType<T extends NodeTypeOptions> = Replace<
|
||||
T & BaseNodeType,
|
||||
{
|
||||
title: GetComputableType<T["title"]>;
|
||||
label: GetComputableType<T["label"]>;
|
||||
size: GetComputableTypeWithDefault<T["size"], 50>;
|
||||
style: GetComputableType<T["style"]>;
|
||||
classes: GetComputableType<T["classes"]>;
|
||||
draggable: GetComputableTypeWithDefault<T["draggable"], false>;
|
||||
shape: GetComputableTypeWithDefault<T["shape"], Shape.Circle>;
|
||||
canAccept: GetComputableTypeWithDefault<T["canAccept"], false>;
|
||||
progress: GetComputableType<T["progress"]>;
|
||||
progressDisplay: GetComputableTypeWithDefault<T["progressDisplay"], ProgressDisplay.Fill>;
|
||||
progressColor: GetComputableTypeWithDefault<T["progressColor"], "none">;
|
||||
fillColor: GetComputableType<T["fillColor"]>;
|
||||
outlineColor: GetComputableType<T["outlineColor"]>;
|
||||
titleColor: GetComputableType<T["titleColor"]>;
|
||||
actions?: GenericBoardNodeAction[];
|
||||
actionDistance: GetComputableTypeWithDefault<T["actionDistance"], number>;
|
||||
}
|
||||
>;
|
||||
|
||||
/** A type that matches any valid {@link NodeType} object. */
|
||||
export type GenericNodeType = Replace<
|
||||
NodeType<NodeTypeOptions>,
|
||||
{
|
||||
size: NodeComputable<number>;
|
||||
draggable: NodeComputable<boolean>;
|
||||
shape: NodeComputable<Shape>;
|
||||
canAccept: NodeComputable<boolean, [BoardNode]>;
|
||||
progressDisplay: NodeComputable<ProgressDisplay>;
|
||||
progressColor: NodeComputable<string>;
|
||||
actionDistance: NodeComputable<number>;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* An object that configures a {@link BoardNodeAction}.
|
||||
*/
|
||||
export interface BoardNodeActionOptions {
|
||||
/** A unique identifier for the action. */
|
||||
id: string;
|
||||
/** Whether this action should be visible. */
|
||||
visibility?: NodeComputable<Visibility | boolean>;
|
||||
/** The icon to display for the action. */
|
||||
icon: NodeComputable<string>;
|
||||
/** The fill color of the action. */
|
||||
fillColor?: NodeComputable<string>;
|
||||
/** The tooltip text to display for the action. */
|
||||
tooltip: NodeComputable<NodeLabel>;
|
||||
/** The confirmation label that appears under the action. */
|
||||
confirmationLabel?: NodeComputable<NodeLabel>;
|
||||
/** An array of board node links associated with the action. They appear when the action is focused. */
|
||||
links?: NodeComputable<BoardNodeLink[]>;
|
||||
/** A function that is called when the action is clicked. */
|
||||
onClick: (node: BoardNode) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The properties that are added onto a processed {@link BoardNodeActionOptions} to create an {@link BoardNodeAction}.
|
||||
*/
|
||||
export interface BaseBoardNodeAction {
|
||||
links?: Ref<BoardNodeLink[]>;
|
||||
}
|
||||
|
||||
/** An object that represents an action that can be taken upon a node. */
|
||||
export type BoardNodeAction<T extends BoardNodeActionOptions> = Replace<
|
||||
T & BaseBoardNodeAction,
|
||||
{
|
||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||
icon: GetComputableType<T["icon"]>;
|
||||
fillColor: GetComputableType<T["fillColor"]>;
|
||||
tooltip: GetComputableType<T["tooltip"]>;
|
||||
confirmationLabel: GetComputableTypeWithDefault<T["confirmationLabel"], NodeLabel>;
|
||||
links: GetComputableType<T["links"]>;
|
||||
}
|
||||
>;
|
||||
|
||||
/** A type that matches any valid {@link BoardNodeAction} object. */
|
||||
export type GenericBoardNodeAction = Replace<
|
||||
BoardNodeAction<BoardNodeActionOptions>,
|
||||
{
|
||||
visibility: NodeComputable<Visibility | boolean>;
|
||||
confirmationLabel: NodeComputable<NodeLabel>;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* An object that configures a {@link Board}.
|
||||
*/
|
||||
export interface BoardOptions {
|
||||
/** Whether this board should be visible. */
|
||||
visibility?: Computable<Visibility | boolean>;
|
||||
/** The height of the board. Defaults to 100% */
|
||||
height?: Computable<string>;
|
||||
/** The width of the board. Defaults to 100% */
|
||||
width?: Computable<string>;
|
||||
/** Dictionary of CSS classes to apply to this feature. */
|
||||
classes?: Computable<Record<string, boolean>>;
|
||||
/** CSS to apply to this feature. */
|
||||
style?: Computable<StyleValue>;
|
||||
/** A function that returns an array of initial board nodes, without IDs. */
|
||||
startNodes: () => Omit<BoardNode, "id">[];
|
||||
/** A dictionary of node types that can appear on the board. */
|
||||
types: Record<string, NodeTypeOptions>;
|
||||
/** The persistent state of the board. */
|
||||
state?: Computable<BoardData>;
|
||||
/** An array of board node links to display. */
|
||||
links?: Computable<BoardNodeLink[] | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The properties that are added onto a processed {@link BoardOptions} to create a {@link Board}.
|
||||
*/
|
||||
export interface BaseBoard {
|
||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
||||
id: string;
|
||||
/** All the nodes currently on the board. */
|
||||
nodes: Ref<BoardNode[]>;
|
||||
/** The currently selected node, if any. */
|
||||
selectedNode: Ref<BoardNode | null>;
|
||||
/** The currently selected action, if any. */
|
||||
selectedAction: Ref<GenericBoardNodeAction | null>;
|
||||
/** The currently being dragged node, if any. */
|
||||
draggingNode: Ref<BoardNode | null>;
|
||||
/** If dragging a node, the node it's currently being hovered over, if any. */
|
||||
receivingNode: Ref<BoardNode | null>;
|
||||
/** The current mouse position, if over the board. */
|
||||
mousePosition: Ref<{ x: number; y: number } | null>;
|
||||
/** Places a node in the nearest empty space in the given direction with the specified space around it. */
|
||||
placeInAvailableSpace: (node: BoardNode, radius?: number, direction?: Direction) => void;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof BoardType;
|
||||
/** The Vue component used to render this feature. */
|
||||
[Component]: GenericComponent;
|
||||
/** A function to gather the props the vue component requires for this feature. */
|
||||
[GatherProps]: () => Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** An object that represents a feature that is a zoomable, pannable board with various nodes upon it. */
|
||||
export type Board<T extends BoardOptions> = Replace<
|
||||
T & BaseBoard,
|
||||
{
|
||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||
types: Record<string, GenericNodeType>;
|
||||
height: GetComputableType<T["height"]>;
|
||||
width: GetComputableType<T["width"]>;
|
||||
classes: GetComputableType<T["classes"]>;
|
||||
style: GetComputableType<T["style"]>;
|
||||
state: GetComputableTypeWithDefault<T["state"], Persistent<BoardData>>;
|
||||
links: GetComputableTypeWithDefault<T["links"], Ref<BoardNodeLink[] | null>>;
|
||||
}
|
||||
>;
|
||||
|
||||
/** A type that matches any valid {@link Board} object. */
|
||||
export type GenericBoard = Replace<
|
||||
Board<BoardOptions>,
|
||||
{
|
||||
visibility: ProcessedComputable<Visibility | boolean>;
|
||||
state: ProcessedComputable<BoardData>;
|
||||
links: ProcessedComputable<BoardNodeLink[] | null>;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* Lazily creates a board with the given options.
|
||||
* @param optionsFunc Board options.
|
||||
*/
|
||||
export function createBoard<T extends BoardOptions>(
|
||||
optionsFunc: OptionsFunc<T, BaseBoard, GenericBoard>
|
||||
): Board<T> {
|
||||
const state = persistent<BoardData>(
|
||||
{
|
||||
nodes: [],
|
||||
selectedNode: null,
|
||||
selectedAction: null
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
return createLazyProxy(feature => {
|
||||
const board = optionsFunc.call(feature, feature);
|
||||
board.id = getUniqueID("board-");
|
||||
board.type = BoardType;
|
||||
board[Component] = BoardComponent as GenericComponent;
|
||||
|
||||
if (board.state) {
|
||||
deletePersistent(state);
|
||||
processComputable(board as T, "state");
|
||||
} else {
|
||||
state[DefaultValue] = {
|
||||
nodes: board.startNodes().map((n, i) => {
|
||||
(n as BoardNode).id = i;
|
||||
return n as BoardNode;
|
||||
}),
|
||||
selectedNode: null,
|
||||
selectedAction: null
|
||||
};
|
||||
board.state = state;
|
||||
}
|
||||
|
||||
board.nodes = computed(() => unref(processedBoard.state).nodes);
|
||||
board.selectedNode = computed({
|
||||
get() {
|
||||
return (
|
||||
processedBoard.nodes.value.find(
|
||||
node => node.id === unref(processedBoard.state).selectedNode
|
||||
) || null
|
||||
);
|
||||
},
|
||||
set(node) {
|
||||
if (isRef(processedBoard.state)) {
|
||||
processedBoard.state.value = {
|
||||
...processedBoard.state.value,
|
||||
selectedNode: node?.id ?? null
|
||||
};
|
||||
} else {
|
||||
processedBoard.state.selectedNode = node?.id ?? null;
|
||||
}
|
||||
}
|
||||
});
|
||||
board.selectedAction = computed({
|
||||
get() {
|
||||
const selectedNode = processedBoard.selectedNode.value;
|
||||
if (selectedNode == null) {
|
||||
return null;
|
||||
}
|
||||
const type = processedBoard.types[selectedNode.type];
|
||||
if (type.actions == null) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
type.actions.find(
|
||||
action => action.id === unref(processedBoard.state).selectedAction
|
||||
) || null
|
||||
);
|
||||
},
|
||||
set(action) {
|
||||
if (isRef(processedBoard.state)) {
|
||||
processedBoard.state.value = {
|
||||
...processedBoard.state.value,
|
||||
selectedAction: action?.id ?? null
|
||||
};
|
||||
} else {
|
||||
processedBoard.state.selectedAction = action?.id ?? null;
|
||||
}
|
||||
}
|
||||
});
|
||||
board.mousePosition = ref(null);
|
||||
if (board.links) {
|
||||
processComputable(board as T, "links");
|
||||
} else {
|
||||
board.links = computed(() => {
|
||||
if (processedBoard.selectedAction.value == null) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
processedBoard.selectedAction.value.links &&
|
||||
processedBoard.selectedNode.value
|
||||
) {
|
||||
return getNodeProperty(
|
||||
processedBoard.selectedAction.value.links,
|
||||
processedBoard.selectedNode.value
|
||||
);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
board.draggingNode = ref(null);
|
||||
board.receivingNode = ref(null);
|
||||
processComputable(board as T, "visibility");
|
||||
setDefault(board, "visibility", Visibility.Visible);
|
||||
processComputable(board as T, "width");
|
||||
setDefault(board, "width", "100%");
|
||||
processComputable(board as T, "height");
|
||||
setDefault(board, "height", "100%");
|
||||
processComputable(board as T, "classes");
|
||||
processComputable(board as T, "style");
|
||||
|
||||
for (const type in board.types) {
|
||||
const nodeType: NodeTypeOptions & Partial<BaseNodeType> = board.types[type];
|
||||
|
||||
processComputable(nodeType as NodeTypeOptions, "title");
|
||||
processComputable(nodeType as NodeTypeOptions, "label");
|
||||
processComputable(nodeType as NodeTypeOptions, "size");
|
||||
setDefault(nodeType, "size", 50);
|
||||
processComputable(nodeType as NodeTypeOptions, "style");
|
||||
processComputable(nodeType as NodeTypeOptions, "classes");
|
||||
processComputable(nodeType as NodeTypeOptions, "draggable");
|
||||
setDefault(nodeType, "draggable", false);
|
||||
processComputable(nodeType as NodeTypeOptions, "shape");
|
||||
setDefault(nodeType, "shape", Shape.Circle);
|
||||
processComputable(nodeType as NodeTypeOptions, "canAccept");
|
||||
setDefault(nodeType, "canAccept", false);
|
||||
processComputable(nodeType as NodeTypeOptions, "progress");
|
||||
processComputable(nodeType as NodeTypeOptions, "progressDisplay");
|
||||
setDefault(nodeType, "progressDisplay", ProgressDisplay.Fill);
|
||||
processComputable(nodeType as NodeTypeOptions, "progressColor");
|
||||
setDefault(nodeType, "progressColor", "none");
|
||||
processComputable(nodeType as NodeTypeOptions, "fillColor");
|
||||
processComputable(nodeType as NodeTypeOptions, "outlineColor");
|
||||
processComputable(nodeType as NodeTypeOptions, "titleColor");
|
||||
processComputable(nodeType as NodeTypeOptions, "actionDistance");
|
||||
setDefault(nodeType, "actionDistance", Math.PI / 6);
|
||||
nodeType.nodes = computed(() =>
|
||||
unref(processedBoard.state).nodes.filter(node => node.type === type)
|
||||
);
|
||||
setDefault(nodeType, "onClick", function (node: BoardNode) {
|
||||
unref(processedBoard.state).selectedNode = node.id;
|
||||
});
|
||||
|
||||
if (nodeType.actions) {
|
||||
for (const action of nodeType.actions) {
|
||||
processComputable(action, "visibility");
|
||||
setDefault(action, "visibility", Visibility.Visible);
|
||||
processComputable(action, "icon");
|
||||
processComputable(action, "fillColor");
|
||||
processComputable(action, "tooltip");
|
||||
processComputable(action, "confirmationLabel");
|
||||
setDefault(action, "confirmationLabel", { text: "Tap again to confirm" });
|
||||
processComputable(action, "links");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setDraggingNode(node: BoardNode | null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
board.draggingNode!.value = node;
|
||||
}
|
||||
function setReceivingNode(node: BoardNode | null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
board.receivingNode!.value = node;
|
||||
}
|
||||
|
||||
board.placeInAvailableSpace = function (
|
||||
node: BoardNode,
|
||||
radius = 100,
|
||||
direction = Direction.Right
|
||||
) {
|
||||
const nodes = processedBoard.nodes.value
|
||||
.slice()
|
||||
.filter(n => {
|
||||
// Exclude self
|
||||
if (n === node) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exclude nodes that aren't within the corridor we'll be moving within
|
||||
if (
|
||||
(direction === Direction.Down || direction === Direction.Up) &&
|
||||
Math.abs(n.position.x - node.position.x) > radius
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
(direction === Direction.Left || direction === Direction.Right) &&
|
||||
Math.abs(n.position.y - node.position.y) > radius
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exclude nodes in the wrong direction
|
||||
return !(
|
||||
(direction === Direction.Right &&
|
||||
n.position.x < node.position.x - radius) ||
|
||||
(direction === Direction.Left && n.position.x > node.position.x + radius) ||
|
||||
(direction === Direction.Up && n.position.y > node.position.y + radius) ||
|
||||
(direction === Direction.Down && n.position.y < node.position.y - radius)
|
||||
);
|
||||
})
|
||||
.sort(
|
||||
direction === Direction.Right
|
||||
? (a, b) => a.position.x - b.position.x
|
||||
: direction === Direction.Left
|
||||
? (a, b) => b.position.x - a.position.x
|
||||
: direction === Direction.Up
|
||||
? (a, b) => b.position.y - a.position.y
|
||||
: (a, b) => a.position.y - b.position.y
|
||||
);
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const nodeToCheck = nodes[i];
|
||||
const distance =
|
||||
direction === Direction.Right || direction === Direction.Left
|
||||
? Math.abs(node.position.x - nodeToCheck.position.x)
|
||||
: Math.abs(node.position.y - nodeToCheck.position.y);
|
||||
|
||||
// If we're too close to this node, move further
|
||||
if (distance < radius) {
|
||||
if (direction === Direction.Right) {
|
||||
node.position.x = nodeToCheck.position.x + radius;
|
||||
} else if (direction === Direction.Left) {
|
||||
node.position.x = nodeToCheck.position.x - radius;
|
||||
} else if (direction === Direction.Up) {
|
||||
node.position.y = nodeToCheck.position.y - radius;
|
||||
} else if (direction === Direction.Down) {
|
||||
node.position.y = nodeToCheck.position.y + radius;
|
||||
}
|
||||
} else if (i > 0 && distance > radius) {
|
||||
// If we're further from this node than the radius, then the nodes are past us and we can early exit
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
board[GatherProps] = function (this: GenericBoard) {
|
||||
const {
|
||||
nodes,
|
||||
types,
|
||||
state,
|
||||
visibility,
|
||||
width,
|
||||
height,
|
||||
style,
|
||||
classes,
|
||||
links,
|
||||
selectedAction,
|
||||
selectedNode,
|
||||
mousePosition,
|
||||
draggingNode,
|
||||
receivingNode
|
||||
} = this;
|
||||
return {
|
||||
nodes,
|
||||
types,
|
||||
state,
|
||||
visibility,
|
||||
width,
|
||||
height,
|
||||
style: unref(style),
|
||||
classes,
|
||||
links,
|
||||
selectedAction,
|
||||
selectedNode,
|
||||
mousePosition,
|
||||
draggingNode,
|
||||
receivingNode,
|
||||
setDraggingNode,
|
||||
setReceivingNode
|
||||
};
|
||||
};
|
||||
|
||||
// This is necessary because board.types is different from T and Board
|
||||
const processedBoard = board as unknown as Board<T>;
|
||||
return processedBoard;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the value of a property for a specified node.
|
||||
* @param property The property to find the value of
|
||||
* @param node The node to get the property of
|
||||
*/
|
||||
export function getNodeProperty<T, S extends unknown[]>(
|
||||
property: NodeComputable<T, S>,
|
||||
node: BoardNode,
|
||||
...args: S
|
||||
): T {
|
||||
return isFunction<T, [BoardNode, ...S], Computable<T>>(property)
|
||||
? property(node, ...args)
|
||||
: unref(property);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to get an ID for a node that is guaranteed unique.
|
||||
* @param board The board feature to generate an ID for
|
||||
*/
|
||||
export function getUniqueNodeID(board: GenericBoard): number {
|
||||
let id = 0;
|
||||
board.nodes.value.forEach(node => {
|
||||
if (node.id >= id) {
|
||||
id = node.id + 1;
|
||||
}
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
const listeners: Record<string, Unsubscribe | undefined> = {};
|
||||
globalBus.on("addLayer", layer => {
|
||||
const boards: GenericBoard[] = findFeatures(layer, BoardType) as GenericBoard[];
|
||||
listeners[layer.id] = layer.on("postUpdate", diff => {
|
||||
boards.forEach(board => {
|
||||
Object.values(board.types).forEach(type =>
|
||||
type.nodes.value.forEach(node => type.update?.(node, diff))
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
globalBus.on("removeLayer", layer => {
|
||||
// unsubscribe from postUpdate
|
||||
listeners[layer.id]?.();
|
||||
listeners[layer.id] = undefined;
|
||||
});
|
|
@ -1,20 +1,11 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="isVisible(visibility)"
|
||||
:style="[
|
||||
{
|
||||
visibility: isHidden(visibility) ? 'hidden' : undefined
|
||||
},
|
||||
notifyStyle,
|
||||
unref(style) ?? {}
|
||||
]"
|
||||
:style="notifyStyle"
|
||||
:class="{
|
||||
feature: true,
|
||||
challenge: true,
|
||||
done: unref(completed),
|
||||
canStart: unref(canStart) && !unref(maxed),
|
||||
maxed: unref(maxed),
|
||||
...unref(classes)
|
||||
maxed: unref(maxed)
|
||||
}"
|
||||
>
|
||||
<button
|
||||
|
@ -24,155 +15,80 @@
|
|||
>
|
||||
{{ buttonText }}
|
||||
</button>
|
||||
<component v-if="unref(comp)" :is="unref(comp)" />
|
||||
<MarkNode :mark="unref(mark)" />
|
||||
<Node :id="id" />
|
||||
<Component v-if="props.display" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="tsx">
|
||||
<script setup 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 { 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";
|
||||
import { displayRequirements } from "game/requirements";
|
||||
import { render } from "util/vue";
|
||||
import type { Component } from "vue";
|
||||
import { computed, unref } from "vue";
|
||||
import { Challenge } from "./challenge";
|
||||
|
||||
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
|
||||
}
|
||||
},
|
||||
components: {
|
||||
MarkNode,
|
||||
Node
|
||||
},
|
||||
setup(props) {
|
||||
const { active, maxed, canComplete, display, requirements } = toRefs(props);
|
||||
const props = defineProps<{
|
||||
active: Challenge["active"];
|
||||
maxed: Challenge["maxed"];
|
||||
canComplete: Challenge["canComplete"];
|
||||
display: Challenge["display"];
|
||||
requirements: Challenge["requirements"];
|
||||
completed: Challenge["completed"];
|
||||
canStart: Challenge["canStart"];
|
||||
toggle: Challenge["toggle"];
|
||||
}>();
|
||||
|
||||
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
|
||||
};
|
||||
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();
|
||||
}
|
||||
return getNotifyStyle();
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const Component = () => {
|
||||
if (props.display == null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof props.display === "object" && "description" in props.display) {
|
||||
const { title, description, goal, reward, effectDisplay } = props.display;
|
||||
return <span>
|
||||
{title != null ? (<div>{render(title, el => <h3>{el}</h3>)}</div>) : null}
|
||||
{render(description, el => <div>{el}</div>)}
|
||||
<div>
|
||||
<br />
|
||||
Goal: {goal == null ? displayRequirements(props.requirements) : render(goal, el => <h3>{el}</h3>)}
|
||||
</div>
|
||||
{reward != null ? (
|
||||
<div>
|
||||
<br />
|
||||
Reward: {render(reward)}
|
||||
</div>
|
||||
) : null}
|
||||
{effectDisplay != null ? (
|
||||
<div>
|
||||
Currently: {render(effectDisplay)}
|
||||
</div>
|
||||
) : null}
|
||||
</span>;
|
||||
}
|
||||
return render(props.display);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
@ -1,24 +1,6 @@
|
|||
import { isArray } from "@vue/shared";
|
||||
import Toggle from "components/fields/Toggle.vue";
|
||||
import ChallengeComponent from "features/challenges/Challenge.vue";
|
||||
import { GenericDecorator } from "features/decorators/common";
|
||||
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 { isVisible } from "features/feature";
|
||||
import type { Reset } from "features/reset";
|
||||
import { globalBus } from "game/events";
|
||||
import type { Persistent } from "game/persistence";
|
||||
import { persistent } from "game/persistence";
|
||||
|
@ -26,16 +8,12 @@ import { Requirements, maxRequirementsMet } from "game/requirements";
|
|||
import settings, { registerSettingField } from "game/settings";
|
||||
import type { DecimalSource } from "util/bignum";
|
||||
import Decimal from "util/bignum";
|
||||
import type {
|
||||
Computable,
|
||||
GetComputableType,
|
||||
GetComputableTypeWithDefault,
|
||||
ProcessedComputable
|
||||
} from "util/computed";
|
||||
import { processComputable } from "util/computed";
|
||||
import { processGetter } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import type { Ref, WatchStopHandle } from "vue";
|
||||
import { Renderable, VueFeature, VueFeatureOptions, vueFeatureMixin } from "util/vue";
|
||||
import type { MaybeRef, MaybeRefOrGetter, Ref, WatchStopHandle } from "vue";
|
||||
import { computed, unref, watch } from "vue";
|
||||
import Challenge from "./Challenge.vue";
|
||||
|
||||
/** A symbol used to identify {@link Challenge} features. */
|
||||
export const ChallengeType = Symbol("Challenge");
|
||||
|
@ -43,39 +21,30 @@ export const ChallengeType = Symbol("Challenge");
|
|||
/**
|
||||
* An object that configures a {@link Challenge}.
|
||||
*/
|
||||
export interface ChallengeOptions {
|
||||
/** Whether this challenge should be visible. */
|
||||
visibility?: Computable<Visibility | boolean>;
|
||||
export interface ChallengeOptions extends VueFeatureOptions {
|
||||
/** Whether this challenge can be started. */
|
||||
canStart?: Computable<boolean>;
|
||||
canStart?: MaybeRefOrGetter<boolean>;
|
||||
/** The reset function for this challenge. */
|
||||
reset?: GenericReset;
|
||||
reset?: Reset;
|
||||
/** The requirement(s) to complete this challenge. */
|
||||
requirements: Requirements;
|
||||
/** The maximum number of times the challenge can be completed. */
|
||||
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>;
|
||||
completionLimit?: MaybeRefOrGetter<DecimalSource>;
|
||||
/** The display to use for this challenge. */
|
||||
display?: Computable<
|
||||
| CoercableComponent
|
||||
display?:
|
||||
| MaybeRefOrGetter<Renderable>
|
||||
| {
|
||||
/** A header to appear at the top of the display. */
|
||||
title?: CoercableComponent;
|
||||
title?: MaybeRefOrGetter<Renderable>;
|
||||
/** The main text that appears in the display. */
|
||||
description: CoercableComponent;
|
||||
description: MaybeRefOrGetter<Renderable>;
|
||||
/** A description of the current goal for this challenge. If unspecified then the requirements will be displayed automatically based on {@link requirements}. */
|
||||
goal?: CoercableComponent;
|
||||
goal?: MaybeRefOrGetter<Renderable>;
|
||||
/** A description of what will change upon completing this challenge. */
|
||||
reward?: CoercableComponent;
|
||||
reward?: MaybeRefOrGetter<Renderable>;
|
||||
/** A description of the current effect of this challenge. */
|
||||
effectDisplay?: CoercableComponent;
|
||||
}
|
||||
>;
|
||||
effectDisplay?: MaybeRefOrGetter<Renderable>;
|
||||
};
|
||||
/** A function that is called when the challenge is completed. */
|
||||
onComplete?: VoidFunction;
|
||||
/** A function that is called when the challenge is exited. */
|
||||
|
@ -84,12 +53,37 @@ export interface ChallengeOptions {
|
|||
onEnter?: VoidFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
/** 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?:
|
||||
| MaybeRef<Renderable>
|
||||
| {
|
||||
/** A header to appear at the top of the display. */
|
||||
title?: MaybeRef<Renderable>;
|
||||
/** The main text that appears in the display. */
|
||||
description: MaybeRef<Renderable>;
|
||||
/** A description of the current goal for this challenge. If unspecified then the requirements will be displayed automatically based on {@link requirements}. */
|
||||
goal?: MaybeRef<Renderable>;
|
||||
/** A description of what will change upon completing this challenge. */
|
||||
reward?: MaybeRef<Renderable>;
|
||||
/** A description of the current effect of this challenge. */
|
||||
effectDisplay?: MaybeRef<Renderable>;
|
||||
};
|
||||
/** The current amount of times this challenge can be completed. */
|
||||
canComplete: Ref<DecimalSource>;
|
||||
/** The current number of times this challenge has been completed. */
|
||||
|
@ -109,203 +103,128 @@ export interface BaseChallenge {
|
|||
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"]>;
|
||||
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: 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);
|
||||
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,
|
||||
reset,
|
||||
onComplete,
|
||||
onEnter,
|
||||
onExit,
|
||||
...props
|
||||
} = options;
|
||||
|
||||
challenge.id = getUniqueID("challenge-");
|
||||
challenge.type = ChallengeType;
|
||||
challenge[Component] = ChallengeComponent as GenericComponent;
|
||||
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}
|
||||
toggle={challenge.toggle}
|
||||
/>
|
||||
));
|
||||
|
||||
for (const decorator of decorators) {
|
||||
decorator.preConstruct?.(challenge);
|
||||
}
|
||||
|
||||
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 (
|
||||
Decimal.gt(unref(genericChallenge.canComplete), 0) &&
|
||||
!genericChallenge.maxed.value
|
||||
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:
|
||||
display == null
|
||||
? undefined
|
||||
: typeof display === "object" && "description" in display
|
||||
? {
|
||||
title: processGetter(display.title),
|
||||
description: processGetter(display.description),
|
||||
goal: processGetter(display.goal),
|
||||
reward: processGetter(display.reward),
|
||||
effectDisplay: processGetter(display.effectDisplay)
|
||||
}
|
||||
: processGetter(display),
|
||||
toggle: function () {
|
||||
if (active.value) {
|
||||
if (
|
||||
Decimal.gt(unref(challenge.canComplete), 0) &&
|
||||
!unref<boolean>(challenge.maxed)
|
||||
) {
|
||||
const newCompletions = unref(challenge.canComplete);
|
||||
completions.value = Decimal.min(
|
||||
Decimal.add(challenge.completions.value, newCompletions),
|
||||
unref(challenge.completionLimit)
|
||||
);
|
||||
onComplete?.();
|
||||
}
|
||||
active.value = false;
|
||||
onExit?.();
|
||||
reset?.reset();
|
||||
} else if (
|
||||
unref<boolean>(challenge.canStart) &&
|
||||
isVisible(unref(challenge.visibility) ?? true) &&
|
||||
!unref<boolean>(challenge.maxed)
|
||||
) {
|
||||
const completions = unref(genericChallenge.canComplete);
|
||||
genericChallenge.completions.value = Decimal.min(
|
||||
Decimal.add(genericChallenge.completions.value, completions),
|
||||
unref(genericChallenge.completionLimit)
|
||||
challenge.reset?.reset();
|
||||
active.value = true;
|
||||
onEnter?.();
|
||||
}
|
||||
},
|
||||
complete: function (remainInChallenge?: boolean) {
|
||||
const newCompletions = unref(challenge.canComplete);
|
||||
if (
|
||||
active.value &&
|
||||
Decimal.gt(newCompletions, 0) &&
|
||||
!unref<boolean>(challenge.maxed)
|
||||
) {
|
||||
completions.value = Decimal.min(
|
||||
Decimal.add(challenge.completions.value, newCompletions),
|
||||
unref(challenge.completionLimit)
|
||||
);
|
||||
genericChallenge.onComplete?.();
|
||||
}
|
||||
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(() =>
|
||||
maxRequirementsMet((challenge as GenericChallenge).requirements)
|
||||
);
|
||||
challenge.complete = function (remainInChallenge?: boolean) {
|
||||
const genericChallenge = challenge as GenericChallenge;
|
||||
const completions = unref(genericChallenge.canComplete);
|
||||
if (
|
||||
genericChallenge.active.value &&
|
||||
Decimal.gt(completions, 0) &&
|
||||
!genericChallenge.maxed.value
|
||||
) {
|
||||
genericChallenge.completions.value = Decimal.min(
|
||||
Decimal.add(genericChallenge.completions.value, completions),
|
||||
unref(genericChallenge.completionLimit)
|
||||
);
|
||||
genericChallenge.onComplete?.();
|
||||
if (remainInChallenge !== true) {
|
||||
genericChallenge.active.value = false;
|
||||
genericChallenge.onExit?.();
|
||||
genericChallenge.reset?.reset();
|
||||
onComplete?.();
|
||||
if (remainInChallenge !== true) {
|
||||
active.value = false;
|
||||
onExit?.();
|
||||
reset?.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
processComputable(challenge as T, "visibility");
|
||||
setDefault(challenge, "visibility", Visibility.Visible);
|
||||
const visibility = challenge.visibility as ProcessedComputable<Visibility | boolean>;
|
||||
challenge.visibility = computed(() => {
|
||||
if (settings.hideChallenges === true && unref(challenge.maxed)) {
|
||||
return Visibility.None;
|
||||
}
|
||||
return unref(visibility);
|
||||
});
|
||||
if (challenge.mark == null) {
|
||||
challenge.mark = computed(
|
||||
() =>
|
||||
Decimal.gt(unref((challenge as GenericChallenge).completionLimit), 1) &&
|
||||
!!unref(challenge.maxed)
|
||||
);
|
||||
}
|
||||
|
||||
processComputable(challenge as T, "canStart");
|
||||
setDefault(challenge, "canStart", true);
|
||||
processComputable(challenge as T, "completionLimit");
|
||||
setDefault(challenge, "completionLimit", 1);
|
||||
processComputable(challenge as T, "mark");
|
||||
processComputable(challenge as T, "classes");
|
||||
processComputable(challenge as T, "style");
|
||||
processComputable(challenge as T, "display");
|
||||
} satisfies Challenge;
|
||||
|
||||
if (challenge.reset != null) {
|
||||
globalBus.on("reset", currentReset => {
|
||||
if (currentReset === challenge.reset && (challenge.active as Ref<boolean>).value) {
|
||||
(challenge.toggle as VoidFunction)();
|
||||
if (currentReset === challenge.reset && active.value) {
|
||||
challenge.toggle();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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>;
|
||||
return challenge;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -316,8 +235,8 @@ export function createChallenge<T extends ChallengeOptions>(
|
|||
* @param exitOnComplete Whether or not to exit the challenge after auto-completion
|
||||
*/
|
||||
export function setupAutoComplete(
|
||||
challenge: GenericChallenge,
|
||||
autoActive: Computable<boolean> = true,
|
||||
challenge: Challenge,
|
||||
autoActive: MaybeRefOrGetter<boolean> = true,
|
||||
exitOnComplete = true
|
||||
): WatchStopHandle {
|
||||
const isActive = typeof autoActive === "function" ? computed(autoActive) : autoActive;
|
||||
|
@ -335,9 +254,7 @@ export function setupAutoComplete(
|
|||
* Utility for taking an array of challenges where only one may be active at a time, and giving a ref to the one currently active (or null if none are active)
|
||||
* @param challenges The list of challenges that are mutually exclusive
|
||||
*/
|
||||
export function createActiveChallenge(
|
||||
challenges: GenericChallenge[]
|
||||
): Ref<GenericChallenge | null> {
|
||||
export function createActiveChallenge(challenges: Challenge[]): Ref<Challenge | null> {
|
||||
return computed(() => challenges.find(challenge => challenge.active.value) ?? null);
|
||||
}
|
||||
|
||||
|
@ -346,12 +263,12 @@ export function createActiveChallenge(
|
|||
* @param challenges List of challenges that are mutually exclusive
|
||||
*/
|
||||
export function isAnyChallengeActive(
|
||||
challenges: GenericChallenge[] | Ref<GenericChallenge | null>
|
||||
challenges: Challenge[] | Ref<Challenge | null>
|
||||
): Ref<boolean> {
|
||||
if (isArray(challenges)) {
|
||||
if (Array.isArray(challenges)) {
|
||||
challenges = createActiveChallenge(challenges);
|
||||
}
|
||||
return computed(() => (challenges as Ref<GenericChallenge | null>).value != null);
|
||||
return computed(() => (challenges as Ref<Challenge | null>).value != null);
|
||||
}
|
||||
|
||||
declare module "game/settings" {
|
||||
|
@ -361,18 +278,18 @@ declare module "game/settings" {
|
|||
}
|
||||
|
||||
globalBus.on("loadSettings", settings => {
|
||||
setDefault(settings, "hideChallenges", false);
|
||||
settings.hideChallenges ??= false;
|
||||
});
|
||||
|
||||
registerSettingField(
|
||||
jsx(() => (
|
||||
globalBus.on("setupVue", () =>
|
||||
registerSettingField(() => (
|
||||
<Toggle
|
||||
title={jsx(() => (
|
||||
title={
|
||||
<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,10 +1,5 @@
|
|||
<template>
|
||||
<button
|
||||
v-if="isVisible(visibility)"
|
||||
:style="[
|
||||
{ visibility: isHidden(visibility) ? 'hidden' : undefined },
|
||||
unref(style) ?? []
|
||||
]"
|
||||
@click="onClick"
|
||||
@mousedown="start"
|
||||
@mouseleave="stop"
|
||||
|
@ -13,114 +8,36 @@
|
|||
@touchend.passive="stop"
|
||||
@touchcancel.passive="stop"
|
||||
:class="{
|
||||
feature: true,
|
||||
clickable: true,
|
||||
can: unref(canClick),
|
||||
locked: !unref(canClick),
|
||||
small,
|
||||
...unref(classes)
|
||||
locked: !unref(canClick)
|
||||
}"
|
||||
:disabled="!unref(canClick)"
|
||||
>
|
||||
<component v-if="unref(comp)" :is="unref(comp)" />
|
||||
<MarkNode :mark="unref(mark)" />
|
||||
<Node :id="id" />
|
||||
<Component />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="tsx">
|
||||
<script setup lang="tsx">
|
||||
import "components/common/features.css";
|
||||
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 type { Clickable } from "features/clickables/clickable";
|
||||
import {
|
||||
coerceComponent,
|
||||
isCoercableComponent,
|
||||
processedPropType,
|
||||
setupHoldToClick,
|
||||
unwrapRef
|
||||
render,
|
||||
setupHoldToClick
|
||||
} from "util/vue";
|
||||
import type { Component, PropType, UnwrapRef } from "vue";
|
||||
import { defineComponent, shallowRef, toRefs, unref, watchEffect } from "vue";
|
||||
import type { Component } from "vue";
|
||||
import { toRef, unref } from "vue";
|
||||
|
||||
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 props = defineProps<{
|
||||
canClick: Clickable["canClick"];
|
||||
onClick: Clickable["onClick"];
|
||||
onHold?: Clickable["onHold"];
|
||||
display: Clickable["display"];
|
||||
}>();
|
||||
|
||||
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(onClick, onHold);
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
comp,
|
||||
Visibility,
|
||||
isVisible,
|
||||
isHidden,
|
||||
unref
|
||||
};
|
||||
}
|
||||
});
|
||||
const { start, stop } = setupHoldToClick(toRef(props, "onClick"), toRef(props, "onHold"));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
@ -130,10 +47,6 @@ export default defineComponent({
|
|||
font-size: 10px;
|
||||
}
|
||||
|
||||
.clickable.small {
|
||||
min-height: unset;
|
||||
}
|
||||
|
||||
.clickable > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
|
183
src/features/clickables/action.tsx
Normal file
183
src/features/clickables/action.tsx
Normal file
|
@ -0,0 +1,183 @@
|
|||
import ClickableVue from "features/clickables/Clickable.vue";
|
||||
import { findFeatures } from "features/feature";
|
||||
import { globalBus } from "game/events";
|
||||
import { persistent } from "game/persistence";
|
||||
import Decimal, { DecimalSource } from "lib/break_eternity";
|
||||
import { Unsubscribe } from "nanoevents";
|
||||
import { Direction } from "util/common";
|
||||
import { processGetter } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import { render, Renderable, VueFeature, vueFeatureMixin } from "util/vue";
|
||||
import { computed, MaybeRef, MaybeRefOrGetter, Ref, ref, unref } from "vue";
|
||||
import { JSX } from "vue/jsx-runtime";
|
||||
import { Bar, BarOptions, createBar } from "../bars/bar";
|
||||
import { type Clickable, ClickableOptions } from "./clickable";
|
||||
|
||||
/** A symbol used to identify {@link Action} features. */
|
||||
export const ActionType = Symbol("Action");
|
||||
|
||||
/**
|
||||
* An object that configures an {@link Action}.
|
||||
*/
|
||||
export interface ActionOptions extends Omit<ClickableOptions, "onClick" | "onHold"> {
|
||||
/** The cooldown during which the action cannot be performed again, in seconds. */
|
||||
duration: MaybeRefOrGetter<DecimalSource>;
|
||||
/** Whether or not the action should perform automatically when the cooldown is finished. */
|
||||
autoStart?: MaybeRefOrGetter<boolean>;
|
||||
/** A function that is called when the action is clicked. */
|
||||
onClick: (amount: DecimalSource) => void;
|
||||
/** A pass-through to the {@link Bar} used to display the cooldown progress for the action. */
|
||||
barOptions?: Partial<BarOptions>;
|
||||
}
|
||||
|
||||
/** An object that represents a feature that can be clicked upon, and then has a cooldown before it can be clicked again. */
|
||||
export interface Action extends VueFeature {
|
||||
/** The cooldown during which the action cannot be performed again, in seconds. */
|
||||
duration: MaybeRef<DecimalSource>;
|
||||
/** Whether or not the action should perform automatically when the cooldown is finished. */
|
||||
autoStart: MaybeRef<boolean>;
|
||||
/** Whether or not the action may be performed. */
|
||||
canClick: MaybeRef<boolean>;
|
||||
/** The display to use for this action. */
|
||||
display?: MaybeRef<Renderable>;
|
||||
/** A function that is called when the action is clicked. */
|
||||
onClick: (amount: DecimalSource) => void;
|
||||
/** Whether or not the player is holding down the action. Actions will be considered clicked as soon as the cooldown completes when being held down. */
|
||||
isHolding: Ref<boolean>;
|
||||
/** The current amount of progress through the cooldown. */
|
||||
progress: Ref<DecimalSource>;
|
||||
/** The bar used to display the current cooldown progress. */
|
||||
progressBar: Bar;
|
||||
/** Update the cooldown the specified number of seconds */
|
||||
update: (diff: number) => void;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof ActionType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily creates an action with the given options.
|
||||
* @param optionsFunc Action options.
|
||||
*/
|
||||
export function createAction<T extends ActionOptions>(optionsFunc?: () => T) {
|
||||
const progress = persistent<DecimalSource>(0);
|
||||
return createLazyProxy(() => {
|
||||
const options = optionsFunc?.() ?? ({} as T);
|
||||
const { style, duration, canClick, autoStart, display, barOptions, onClick, ...props } =
|
||||
options;
|
||||
|
||||
const processedCanClick = processGetter(canClick) ?? true;
|
||||
const processedStyle = processGetter(style);
|
||||
|
||||
const progressBar = createBar(() => ({
|
||||
direction: Direction.Right,
|
||||
width: 100,
|
||||
height: 10,
|
||||
borderStyle: { borderColor: "black" },
|
||||
baseStyle: { marginTop: "-1px" },
|
||||
progress: (): DecimalSource => Decimal.div(progress.value, unref(action.duration)),
|
||||
...(barOptions as Omit<typeof barOptions, keyof VueFeature>)
|
||||
}));
|
||||
|
||||
let Component: () => JSX.Element;
|
||||
if (typeof display === "object" && "description" in display) {
|
||||
const title = processGetter(display.title);
|
||||
const description = processGetter(display.description);
|
||||
|
||||
const Title = () => (title == null ? <></> : render(title, el => <h3>{el}</h3>));
|
||||
const Description = () => render(description, el => <div>{el}</div>);
|
||||
|
||||
Component = () => {
|
||||
return (
|
||||
<span>
|
||||
{title != null ? (
|
||||
<div>
|
||||
<Title />
|
||||
</div>
|
||||
) : null}
|
||||
<Description />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
} else if (display != null) {
|
||||
const processedDisplay = processGetter(display);
|
||||
Component = () => render(processedDisplay);
|
||||
}
|
||||
|
||||
const action = {
|
||||
type: ActionType,
|
||||
...(props as Omit<typeof props, keyof VueFeature | keyof ActionOptions>),
|
||||
...vueFeatureMixin(
|
||||
"action",
|
||||
{
|
||||
...options,
|
||||
style: () => ({
|
||||
cursor: Decimal.gte(progress.value, unref(action.duration))
|
||||
? "pointer"
|
||||
: "progress",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
...unref(processedStyle)
|
||||
})
|
||||
},
|
||||
() => (
|
||||
<ClickableVue
|
||||
canClick={action.canClick}
|
||||
onClick={action.onClick}
|
||||
display={action.display}
|
||||
/>
|
||||
)
|
||||
),
|
||||
progress,
|
||||
isHolding: ref(false),
|
||||
duration: processGetter(duration),
|
||||
canClick: computed(
|
||||
(): boolean =>
|
||||
unref(processedCanClick) && Decimal.gte(progress.value, unref(action.duration))
|
||||
),
|
||||
autoStart: processGetter(autoStart) ?? false,
|
||||
display: computed(() => (
|
||||
<>
|
||||
<div style="flex-grow: 1" />
|
||||
{display == null ? null : <Component />}
|
||||
<div style="flex-grow: 1" />
|
||||
{render(progressBar)}
|
||||
</>
|
||||
)),
|
||||
progressBar,
|
||||
onClick: function () {
|
||||
if (unref(action.canClick) === false) {
|
||||
return;
|
||||
}
|
||||
const amount = Decimal.div(progress.value, unref(action.duration));
|
||||
onClick?.call(action, amount);
|
||||
progress.value = 0;
|
||||
},
|
||||
update: function (diff) {
|
||||
const duration = unref(action.duration);
|
||||
if (Decimal.gte(progress.value, duration)) {
|
||||
progress.value = duration;
|
||||
} else {
|
||||
progress.value = Decimal.add(progress.value, diff);
|
||||
if (action.isHolding.value || unref<boolean>(action.autoStart)) {
|
||||
action.onClick();
|
||||
}
|
||||
}
|
||||
}
|
||||
} satisfies Action satisfies Omit<Clickable, "type"> & { type: typeof ActionType };
|
||||
|
||||
return action;
|
||||
});
|
||||
}
|
||||
|
||||
const listeners: Record<string, Unsubscribe | undefined> = {};
|
||||
globalBus.on("addLayer", layer => {
|
||||
const actions: Action[] = findFeatures(layer, ActionType) as Action[];
|
||||
listeners[layer.id] = layer.on("postUpdate", (diff: number) => {
|
||||
actions.forEach(action => action.update(diff));
|
||||
});
|
||||
});
|
||||
globalBus.on("removeLayer", layer => {
|
||||
// unsubscribe from postUpdate
|
||||
listeners[layer.id]?.();
|
||||
listeners[layer.id] = undefined;
|
||||
});
|
|
@ -1,204 +0,0 @@
|
|||
import ClickableComponent from "features/clickables/Clickable.vue";
|
||||
import { GenericDecorator } from "features/decorators/common";
|
||||
import type {
|
||||
CoercableComponent,
|
||||
GenericComponent,
|
||||
OptionsFunc,
|
||||
Replace,
|
||||
StyleValue
|
||||
} from "features/feature";
|
||||
import { Component, GatherProps, Visibility, getUniqueID, setDefault } from "features/feature";
|
||||
import type { BaseLayer } from "game/layers";
|
||||
import type { Unsubscribe } from "nanoevents";
|
||||
import type {
|
||||
Computable,
|
||||
GetComputableType,
|
||||
GetComputableTypeWithDefault,
|
||||
ProcessedComputable
|
||||
} from "util/computed";
|
||||
import { processComputable } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import { computed, unref } from "vue";
|
||||
|
||||
/** A symbol used to identify {@link Clickable} features. */
|
||||
export const ClickableType = Symbol("Clickable");
|
||||
|
||||
/**
|
||||
* An object that configures a {@link Clickable}.
|
||||
*/
|
||||
export interface ClickableOptions {
|
||||
/** Whether this clickable should be visible. */
|
||||
visibility?: Computable<Visibility | boolean>;
|
||||
/** Whether or not the clickable may be clicked. */
|
||||
canClick?: Computable<boolean>;
|
||||
/** Dictionary of CSS classes to apply to this feature. */
|
||||
classes?: Computable<Record<string, boolean>>;
|
||||
/** CSS to apply to this feature. */
|
||||
style?: Computable<StyleValue>;
|
||||
/** Shows a marker on the corner of the feature. */
|
||||
mark?: Computable<boolean | string>;
|
||||
/** The display to use for this clickable. */
|
||||
display?: Computable<
|
||||
| CoercableComponent
|
||||
| {
|
||||
/** A header to appear at the top of the display. */
|
||||
title?: CoercableComponent;
|
||||
/** The main text that appears in the display. */
|
||||
description: CoercableComponent;
|
||||
}
|
||||
>;
|
||||
/** Toggles a smaller design for the feature. */
|
||||
small?: boolean;
|
||||
/** A function that is called when the clickable is clicked. */
|
||||
onClick?: (e?: MouseEvent | TouchEvent) => void;
|
||||
/** A function that is called when the clickable is held down. */
|
||||
onHold?: VoidFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* The properties that are added onto a processed {@link ClickableOptions} to create an {@link Clickable}.
|
||||
*/
|
||||
export interface BaseClickable {
|
||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
||||
id: string;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof ClickableType;
|
||||
/** The Vue component used to render this feature. */
|
||||
[Component]: GenericComponent;
|
||||
/** A function to gather the props the vue component requires for this feature. */
|
||||
[GatherProps]: () => Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** An object that represents a feature that can be clicked or held down. */
|
||||
export type Clickable<T extends ClickableOptions> = Replace<
|
||||
T & BaseClickable,
|
||||
{
|
||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||
canClick: GetComputableTypeWithDefault<T["canClick"], true>;
|
||||
classes: GetComputableType<T["classes"]>;
|
||||
style: GetComputableType<T["style"]>;
|
||||
mark: GetComputableType<T["mark"]>;
|
||||
display: GetComputableType<T["display"]>;
|
||||
}
|
||||
>;
|
||||
|
||||
/** A type that matches any valid {@link Clickable} object. */
|
||||
export type GenericClickable = Replace<
|
||||
Clickable<ClickableOptions>,
|
||||
{
|
||||
visibility: ProcessedComputable<Visibility | boolean>;
|
||||
canClick: ProcessedComputable<boolean>;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* Lazily creates a clickable with the given options.
|
||||
* @param optionsFunc Clickable options.
|
||||
*/
|
||||
export function createClickable<T extends ClickableOptions>(
|
||||
optionsFunc?: OptionsFunc<T, BaseClickable, GenericClickable>,
|
||||
...decorators: GenericDecorator[]
|
||||
): Clickable<T> {
|
||||
const decoratedData = decorators.reduce(
|
||||
(current, next) => Object.assign(current, next.getPersistentData?.()),
|
||||
{}
|
||||
);
|
||||
return createLazyProxy(feature => {
|
||||
const clickable =
|
||||
optionsFunc?.call(feature, feature) ??
|
||||
({} as ReturnType<NonNullable<typeof optionsFunc>>);
|
||||
clickable.id = getUniqueID("clickable-");
|
||||
clickable.type = ClickableType;
|
||||
clickable[Component] = ClickableComponent as GenericComponent;
|
||||
|
||||
for (const decorator of decorators) {
|
||||
decorator.preConstruct?.(clickable);
|
||||
}
|
||||
|
||||
Object.assign(clickable, decoratedData);
|
||||
|
||||
processComputable(clickable as T, "visibility");
|
||||
setDefault(clickable, "visibility", Visibility.Visible);
|
||||
processComputable(clickable as T, "canClick");
|
||||
setDefault(clickable, "canClick", true);
|
||||
processComputable(clickable as T, "classes");
|
||||
processComputable(clickable as T, "style");
|
||||
processComputable(clickable as T, "mark");
|
||||
processComputable(clickable as T, "display");
|
||||
|
||||
if (clickable.onClick) {
|
||||
const onClick = clickable.onClick.bind(clickable);
|
||||
clickable.onClick = function (e) {
|
||||
if (unref(clickable.canClick) !== false) {
|
||||
onClick(e);
|
||||
}
|
||||
};
|
||||
}
|
||||
if (clickable.onHold) {
|
||||
const onHold = clickable.onHold.bind(clickable);
|
||||
clickable.onHold = function () {
|
||||
if (unref(clickable.canClick) !== false) {
|
||||
onHold();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
for (const decorator of decorators) {
|
||||
decorator.postConstruct?.(clickable);
|
||||
}
|
||||
|
||||
const decoratedProps = decorators.reduce(
|
||||
(current, next) => Object.assign(current, next.getGatheredProps?.(clickable)),
|
||||
{}
|
||||
);
|
||||
clickable[GatherProps] = function (this: GenericClickable) {
|
||||
const {
|
||||
display,
|
||||
visibility,
|
||||
style,
|
||||
classes,
|
||||
onClick,
|
||||
onHold,
|
||||
canClick,
|
||||
small,
|
||||
mark,
|
||||
id
|
||||
} = this;
|
||||
return {
|
||||
display,
|
||||
visibility,
|
||||
style: unref(style),
|
||||
classes,
|
||||
onClick,
|
||||
onHold,
|
||||
canClick,
|
||||
small,
|
||||
mark,
|
||||
id,
|
||||
...decoratedProps
|
||||
};
|
||||
};
|
||||
|
||||
return clickable as unknown as Clickable<T>;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to auto click a clickable whenever it can be.
|
||||
* @param layer The layer the clickable is apart of
|
||||
* @param clickable The clicker to click automatically
|
||||
* @param autoActive Whether or not the clickable should currently be auto-clicking
|
||||
*/
|
||||
export function setupAutoClick(
|
||||
layer: BaseLayer,
|
||||
clickable: GenericClickable,
|
||||
autoActive: Computable<boolean> = true
|
||||
): Unsubscribe {
|
||||
const isActive: ProcessedComputable<boolean> =
|
||||
typeof autoActive === "function" ? computed(autoActive) : autoActive;
|
||||
return layer.on("update", () => {
|
||||
if (unref(isActive) && unref(clickable.canClick)) {
|
||||
clickable.onClick?.();
|
||||
}
|
||||
});
|
||||
}
|
130
src/features/clickables/clickable.tsx
Normal file
130
src/features/clickables/clickable.tsx
Normal file
|
@ -0,0 +1,130 @@
|
|||
import Clickable from "features/clickables/Clickable.vue";
|
||||
import type { BaseLayer } from "game/layers";
|
||||
import type { Unsubscribe } from "nanoevents";
|
||||
import { processGetter } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import { render, Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
|
||||
import { computed, MaybeRef, MaybeRefOrGetter, unref } from "vue";
|
||||
|
||||
/** A symbol used to identify {@link Clickable} features. */
|
||||
export const ClickableType = Symbol("Clickable");
|
||||
|
||||
/**
|
||||
* An object that configures a {@link Clickable}.
|
||||
*/
|
||||
export interface ClickableOptions extends VueFeatureOptions {
|
||||
/** Whether or not the clickable may be clicked. */
|
||||
canClick?: MaybeRefOrGetter<boolean>;
|
||||
/** The display to use for this clickable. */
|
||||
display?:
|
||||
| MaybeRefOrGetter<Renderable>
|
||||
| {
|
||||
/** A header to appear at the top of the display. */
|
||||
title?: MaybeRefOrGetter<Renderable>;
|
||||
/** The main text that appears in the display. */
|
||||
description: MaybeRefOrGetter<Renderable>;
|
||||
};
|
||||
/** A function that is called when the clickable is clicked. */
|
||||
onClick?: (e?: MouseEvent | TouchEvent) => void;
|
||||
/** A function that is called when the clickable is held down. */
|
||||
onHold?: VoidFunction;
|
||||
}
|
||||
|
||||
/** An object that represents a feature that can be clicked or held down. */
|
||||
export interface Clickable extends VueFeature {
|
||||
/** A function that is called when the clickable is clicked. */
|
||||
onClick?: (e?: MouseEvent | TouchEvent) => void;
|
||||
/** A function that is called when the clickable is held down. */
|
||||
onHold?: VoidFunction;
|
||||
/** Whether or not the clickable may be clicked. */
|
||||
canClick: MaybeRef<boolean>;
|
||||
/** The display to use for this clickable. */
|
||||
display?: MaybeRef<Renderable>;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof ClickableType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily creates a clickable with the given options.
|
||||
* @param optionsFunc Clickable options.
|
||||
*/
|
||||
export function createClickable<T extends ClickableOptions>(optionsFunc?: () => T) {
|
||||
return createLazyProxy(() => {
|
||||
const options = optionsFunc?.() ?? ({} as T);
|
||||
const { canClick, display: _display, onClick: onClick, onHold: onHold, ...props } = options;
|
||||
|
||||
let display: MaybeRef<Renderable> | undefined = undefined;
|
||||
if (typeof _display === "object" && "description" in _display) {
|
||||
const title = processGetter(_display.title);
|
||||
const description = processGetter(_display.description);
|
||||
|
||||
const Title = () => (title == null ? <></> : render(title, el => <h3>{el}</h3>));
|
||||
const Description = () => render(description, el => <div>{el}</div>);
|
||||
|
||||
display = computed(() => (
|
||||
<span>
|
||||
{title != null ? (
|
||||
<div>
|
||||
<Title />
|
||||
</div>
|
||||
) : null}
|
||||
<Description />
|
||||
</span>
|
||||
));
|
||||
} else if (_display != null) {
|
||||
display = processGetter(_display);
|
||||
}
|
||||
|
||||
const clickable = {
|
||||
type: ClickableType,
|
||||
...(props as Omit<typeof props, keyof VueFeature | keyof ClickableOptions>),
|
||||
...vueFeatureMixin("clickable", options, () => (
|
||||
<Clickable
|
||||
canClick={clickable.canClick}
|
||||
onClick={clickable.onClick}
|
||||
display={clickable.display}
|
||||
/>
|
||||
)),
|
||||
canClick: processGetter(canClick) ?? true,
|
||||
display,
|
||||
onClick:
|
||||
onClick == null
|
||||
? undefined
|
||||
: function (e) {
|
||||
if (unref(clickable.canClick) !== false) {
|
||||
onClick.call(clickable, e);
|
||||
}
|
||||
},
|
||||
onHold:
|
||||
onHold == null
|
||||
? undefined
|
||||
: function () {
|
||||
if (unref(clickable.canClick) !== false) {
|
||||
onHold.call(clickable);
|
||||
}
|
||||
}
|
||||
} satisfies Clickable & { onClick: T["onClick"] };
|
||||
|
||||
return clickable;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to auto click a clickable whenever it can be.
|
||||
* @param layer The layer the clickable is apart of
|
||||
* @param clickable The clicker to click automatically
|
||||
* @param autoActive Whether or not the clickable should currently be auto-clicking
|
||||
*/
|
||||
export function setupAutoClick(
|
||||
layer: BaseLayer,
|
||||
clickable: Clickable,
|
||||
autoActive: MaybeRefOrGetter<boolean> = true
|
||||
): Unsubscribe {
|
||||
const isActive: MaybeRef<boolean> =
|
||||
typeof autoActive === "function" ? computed(autoActive) : autoActive;
|
||||
return layer.on("update", () => {
|
||||
if (unref(isActive) && unref<boolean>(clickable.canClick)) {
|
||||
clickable.onClick?.();
|
||||
}
|
||||
});
|
||||
}
|
205
src/features/clickables/repeatable.tsx
Normal file
205
src/features/clickables/repeatable.tsx
Normal file
|
@ -0,0 +1,205 @@
|
|||
import Clickable from "features/clickables/Clickable.vue";
|
||||
import { Visibility } from "features/feature";
|
||||
import { DefaultValue, Persistent, persistent } from "game/persistence";
|
||||
import {
|
||||
createVisibilityRequirement,
|
||||
displayRequirements,
|
||||
maxRequirementsMet,
|
||||
payRequirements,
|
||||
Requirements,
|
||||
requirementsMet
|
||||
} from "game/requirements";
|
||||
import type { DecimalSource } from "util/bignum";
|
||||
import Decimal, { formatWhole } from "util/bignum";
|
||||
import { processGetter } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import { isJSXElement, render, Renderable, VueFeature, vueFeatureMixin } from "util/vue";
|
||||
import type { MaybeRef, MaybeRefOrGetter, Ref } from "vue";
|
||||
import { computed, isRef, unref } from "vue";
|
||||
import { ClickableOptions } from "./clickable";
|
||||
|
||||
/** A symbol used to identify {@link Repeatable} features. */
|
||||
export const RepeatableType = Symbol("Repeatable");
|
||||
|
||||
/** An object that configures a {@link Repeatable}. */
|
||||
export interface RepeatableOptions extends ClickableOptions {
|
||||
/** The requirement(s) to increase this repeatable. */
|
||||
requirements: Requirements;
|
||||
/** The maximum amount obtainable for this repeatable. */
|
||||
limit?: MaybeRefOrGetter<DecimalSource>;
|
||||
/** The initial amount this repeatable has on a new save / after reset. */
|
||||
initialAmount?: DecimalSource;
|
||||
/** The display to use for this repeatable. */
|
||||
display?:
|
||||
| MaybeRefOrGetter<Renderable>
|
||||
| {
|
||||
/** A header to appear at the top of the display. */
|
||||
title?: MaybeRefOrGetter<Renderable>;
|
||||
/** The main text that appears in the display. */
|
||||
description: MaybeRefOrGetter<Renderable>;
|
||||
/** A description of the current effect of this repeatable, based off its amount. */
|
||||
effectDisplay?: MaybeRefOrGetter<Renderable>;
|
||||
/** Whether or not to show the current amount of this repeatable at the bottom of the display. */
|
||||
showAmount?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/** An object that represents a feature with multiple "levels" with scaling requirements. */
|
||||
export interface Repeatable extends VueFeature {
|
||||
/** The requirement(s) to increase this repeatable. */
|
||||
requirements: Requirements;
|
||||
/** The maximum amount obtainable for this repeatable. */
|
||||
limit: MaybeRef<DecimalSource>;
|
||||
/** The initial amount this repeatable has on a new save / after reset. */
|
||||
initialAmount?: DecimalSource;
|
||||
/** The display to use for this repeatable. */
|
||||
display?: MaybeRef<Renderable>;
|
||||
/** Whether or not the repeatable may be clicked. */
|
||||
canClick: Ref<boolean>;
|
||||
/** A function that is called when the repeatable is clicked. */
|
||||
onClick: (event?: MouseEvent | TouchEvent) => void;
|
||||
/** The current amount this repeatable has. */
|
||||
amount: Persistent<DecimalSource>;
|
||||
/** Whether or not this repeatable's amount is at it's limit. */
|
||||
maxed: Ref<boolean>;
|
||||
/** How much amount can be increased by, or 1 if unclickable. **/
|
||||
amountToIncrease: Ref<DecimalSource>;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof RepeatableType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily creates a repeatable with the given options.
|
||||
* @param optionsFunc Repeatable options.
|
||||
*/
|
||||
export function createRepeatable<T extends RepeatableOptions>(optionsFunc: () => T) {
|
||||
const amount = persistent<DecimalSource>(0);
|
||||
return createLazyProxy(() => {
|
||||
const options = optionsFunc();
|
||||
const {
|
||||
requirements: _requirements,
|
||||
display: _display,
|
||||
limit,
|
||||
onClick,
|
||||
initialAmount,
|
||||
...props
|
||||
} = options;
|
||||
|
||||
if (options.classes == null) {
|
||||
options.classes = computed(() => ({ bought: unref(repeatable.maxed) }));
|
||||
} else {
|
||||
const classes = processGetter(options.classes);
|
||||
options.classes = computed(() => ({
|
||||
...unref(classes),
|
||||
bought: unref(repeatable.maxed)
|
||||
}));
|
||||
}
|
||||
const vueFeature = vueFeatureMixin("repeatable", options, () => (
|
||||
<Clickable
|
||||
canClick={repeatable.canClick}
|
||||
onClick={repeatable.onClick}
|
||||
display={repeatable.display}
|
||||
/>
|
||||
));
|
||||
|
||||
const limitRequirement = {
|
||||
requirementMet: computed(
|
||||
(): DecimalSource => Decimal.sub(unref(repeatable.limit), unref(amount))
|
||||
),
|
||||
requiresPay: false,
|
||||
visibility: Visibility.None,
|
||||
canMaximize: true
|
||||
} as const;
|
||||
const requirements: Requirements = [
|
||||
...(Array.isArray(_requirements) ? _requirements : [_requirements]),
|
||||
limitRequirement
|
||||
];
|
||||
if (vueFeature.visibility != null) {
|
||||
requirements.push(createVisibilityRequirement(vueFeature.visibility));
|
||||
}
|
||||
|
||||
let display;
|
||||
if (typeof _display === "object" && !isRef(_display) && !isJSXElement(_display)) {
|
||||
const title = processGetter(_display.title);
|
||||
const description = processGetter(_display.description);
|
||||
const effectDisplay = processGetter(_display.effectDisplay);
|
||||
const showAmount = processGetter(_display.showAmount);
|
||||
|
||||
const Title = title == null ? null : () => render(title, el => <h3>{el}</h3>);
|
||||
const Description = () => render(description, el => <>{el}</>);
|
||||
const EffectDisplay =
|
||||
effectDisplay == null ? null : () => render(effectDisplay, el => <>{el}</>);
|
||||
|
||||
display = computed(() => (
|
||||
<span>
|
||||
{Title == null ? null : (
|
||||
<div>
|
||||
<Title />
|
||||
</div>
|
||||
)}
|
||||
<Description />
|
||||
{showAmount === false ? null : (
|
||||
<div>
|
||||
<br />
|
||||
<>Amount: {formatWhole(unref(amount))}</>
|
||||
{Decimal.isFinite(unref(repeatable.limit)) ? (
|
||||
<> / {formatWhole(unref(repeatable.limit))}</>
|
||||
) : undefined}
|
||||
</div>
|
||||
)}
|
||||
{EffectDisplay == null ? null : (
|
||||
<div>
|
||||
<br />
|
||||
Currently: <EffectDisplay />
|
||||
</div>
|
||||
)}
|
||||
{unref(repeatable.maxed) ? null : (
|
||||
<div>
|
||||
<br />
|
||||
{displayRequirements(requirements, unref(repeatable.amountToIncrease))}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
));
|
||||
} else if (_display != null) {
|
||||
const processedDisplay = processGetter(_display);
|
||||
display = computed(() => render(processedDisplay));
|
||||
} else {
|
||||
display = undefined;
|
||||
}
|
||||
|
||||
amount[DefaultValue] = initialAmount ?? 0;
|
||||
|
||||
const repeatable = {
|
||||
type: RepeatableType,
|
||||
...(props as Omit<typeof props, keyof VueFeature | keyof RepeatableOptions>),
|
||||
...vueFeature,
|
||||
amount,
|
||||
requirements,
|
||||
initialAmount,
|
||||
limit: processGetter(limit) ?? Decimal.dInf,
|
||||
classes: computed(() => {
|
||||
const currClasses = unref(vueFeature.classes) || {};
|
||||
if (unref(repeatable.maxed)) {
|
||||
currClasses.bought = true;
|
||||
}
|
||||
return currClasses;
|
||||
}),
|
||||
maxed: computed((): boolean => Decimal.gte(unref(amount), unref(repeatable.limit))),
|
||||
canClick: computed(() => requirementsMet(requirements)),
|
||||
amountToIncrease: computed(() => Decimal.clampMin(maxRequirementsMet(requirements), 1)),
|
||||
onClick(event?: MouseEvent | TouchEvent) {
|
||||
if (!unref(repeatable.canClick)) {
|
||||
return;
|
||||
}
|
||||
const purchaseAmount = unref(repeatable.amountToIncrease) ?? 1;
|
||||
payRequirements(requirements, purchaseAmount);
|
||||
amount.value = Decimal.add(unref(amount), purchaseAmount);
|
||||
onClick?.(event);
|
||||
},
|
||||
display
|
||||
} satisfies Repeatable;
|
||||
|
||||
return repeatable;
|
||||
});
|
||||
}
|
174
src/features/clickables/upgrade.tsx
Normal file
174
src/features/clickables/upgrade.tsx
Normal file
|
@ -0,0 +1,174 @@
|
|||
import { findFeatures } from "features/feature";
|
||||
import { Layer } from "game/layers";
|
||||
import type { Persistent } from "game/persistence";
|
||||
import { persistent } from "game/persistence";
|
||||
import {
|
||||
Requirements,
|
||||
createVisibilityRequirement,
|
||||
displayRequirements,
|
||||
payRequirements,
|
||||
requirementsMet
|
||||
} from "game/requirements";
|
||||
import { isFunction } from "util/common";
|
||||
import { processGetter } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import { Renderable, VueFeature, VueFeatureOptions, render, vueFeatureMixin } from "util/vue";
|
||||
import type { MaybeRef, MaybeRefOrGetter, Ref } from "vue";
|
||||
import { computed, unref } from "vue";
|
||||
import Clickable from "./Clickable.vue";
|
||||
import { ClickableOptions } from "./clickable";
|
||||
|
||||
/** A symbol used to identify {@link Upgrade} features. */
|
||||
export const UpgradeType = Symbol("Upgrade");
|
||||
|
||||
/**
|
||||
* An object that configures a {@link Upgrade}.
|
||||
*/
|
||||
export interface UpgradeOptions extends VueFeatureOptions, ClickableOptions {
|
||||
/** The display to use for this upgrade. */
|
||||
display?:
|
||||
| MaybeRefOrGetter<Renderable>
|
||||
| {
|
||||
/** A header to appear at the top of the display. */
|
||||
title?: MaybeRefOrGetter<Renderable>;
|
||||
/** The main text that appears in the display. */
|
||||
description: MaybeRefOrGetter<Renderable>;
|
||||
/** A description of the current effect of the achievement. Useful when the effect changes dynamically. */
|
||||
effectDisplay?: MaybeRefOrGetter<Renderable>;
|
||||
};
|
||||
/** The requirements to purchase this upgrade. */
|
||||
requirements: Requirements;
|
||||
/** A function that is called when the upgrade is purchased. */
|
||||
onPurchase?: VoidFunction;
|
||||
}
|
||||
|
||||
/** An object that represents a feature that can be purchased a single time. */
|
||||
export interface Upgrade extends VueFeature {
|
||||
/** The requirements to purchase this upgrade. */
|
||||
requirements: Requirements;
|
||||
/** The display to use for this upgrade. */
|
||||
display?: MaybeRef<Renderable>;
|
||||
/** Whether or not this upgrade has been purchased. */
|
||||
bought: Persistent<boolean>;
|
||||
/** Whether or not the upgrade can currently be purchased. */
|
||||
canPurchase: Ref<boolean>;
|
||||
/** A function that is called when the upgrade is purchased. */
|
||||
onPurchase?: VoidFunction;
|
||||
/** Purchase the upgrade */
|
||||
purchase: VoidFunction;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof UpgradeType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily creates an upgrade with the given options.
|
||||
* @param optionsFunc Upgrade options.
|
||||
*/
|
||||
export function createUpgrade<T extends UpgradeOptions>(optionsFunc: () => T) {
|
||||
const bought = persistent<boolean>(false, false);
|
||||
return createLazyProxy(() => {
|
||||
const options = optionsFunc();
|
||||
const { requirements: _requirements, display: _display, onHold, ...props } = options;
|
||||
|
||||
if (options.classes == null) {
|
||||
options.classes = computed(() => ({ bought: unref(upgrade.bought) }));
|
||||
} else {
|
||||
const classes = processGetter(options.classes);
|
||||
options.classes = computed(() => ({
|
||||
...unref(classes),
|
||||
bought: unref(upgrade.bought)
|
||||
}));
|
||||
}
|
||||
const vueFeature = vueFeatureMixin("upgrade", options, () => (
|
||||
<Clickable
|
||||
onClick={upgrade.purchase}
|
||||
onHold={upgrade.onHold}
|
||||
canClick={upgrade.canPurchase}
|
||||
display={upgrade.display}
|
||||
/>
|
||||
));
|
||||
const requirements = Array.isArray(_requirements) ? _requirements : [_requirements];
|
||||
if (vueFeature.visibility != null) {
|
||||
requirements.push(createVisibilityRequirement(vueFeature.visibility));
|
||||
}
|
||||
|
||||
let display: MaybeRef<Renderable> | undefined = undefined;
|
||||
if (typeof _display === "object" && "description" in _display) {
|
||||
const title = processGetter(_display.title);
|
||||
const description = processGetter(_display.description);
|
||||
const effectDisplay = processGetter(_display.effectDisplay);
|
||||
|
||||
const Title = () => (title == null ? <></> : render(title, el => <h3>{el}</h3>));
|
||||
const Description = () => render(description, el => <div>{el}</div>);
|
||||
const EffectDisplay = () =>
|
||||
effectDisplay == null ? <></> : render(effectDisplay, el => <>{el}</>);
|
||||
|
||||
display = computed(() => (
|
||||
<span>
|
||||
{title != null ? (
|
||||
<div>
|
||||
<Title />
|
||||
</div>
|
||||
) : null}
|
||||
<Description />
|
||||
{effectDisplay != null ? (
|
||||
<div>
|
||||
Currently: <EffectDisplay />
|
||||
</div>
|
||||
) : null}
|
||||
{bought.value ? null : (
|
||||
<>
|
||||
<br />
|
||||
{displayRequirements(requirements)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
));
|
||||
} else if (_display != null) {
|
||||
display = processGetter(_display);
|
||||
}
|
||||
|
||||
const upgrade = {
|
||||
type: UpgradeType,
|
||||
...(props as Omit<typeof props, keyof VueFeature | keyof UpgradeOptions>),
|
||||
...vueFeature,
|
||||
bought,
|
||||
canPurchase: computed(() => !bought.value && requirementsMet(requirements)),
|
||||
requirements,
|
||||
display,
|
||||
onHold,
|
||||
purchase() {
|
||||
if (!unref(upgrade.canPurchase)) {
|
||||
return;
|
||||
}
|
||||
payRequirements(requirements);
|
||||
bought.value = true;
|
||||
options.onPurchase?.();
|
||||
}
|
||||
} satisfies Upgrade;
|
||||
|
||||
return upgrade;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to auto purchase a list of upgrades whenever they're affordable.
|
||||
* @param layer The layer the upgrades are apart of
|
||||
* @param autoActive Whether or not the upgrades should currently be auto-purchasing
|
||||
* @param upgrades The specific upgrades to upgrade. If unspecified, uses all upgrades on the layer.
|
||||
*/
|
||||
export function setupAutoPurchase(
|
||||
layer: Layer,
|
||||
autoActive: MaybeRefOrGetter<boolean>,
|
||||
upgrades: Upgrade[] = []
|
||||
): void {
|
||||
upgrades = upgrades.length === 0 ? (findFeatures(layer, UpgradeType) as Upgrade[]) : upgrades;
|
||||
const isAutoActive: MaybeRef<boolean> = isFunction(autoActive)
|
||||
? computed(autoActive)
|
||||
: autoActive;
|
||||
layer.on("update", () => {
|
||||
if (unref(isAutoActive)) {
|
||||
upgrades.forEach(upgrade => upgrade.purchase());
|
||||
}
|
||||
});
|
||||
}
|
|
@ -1,18 +1,17 @@
|
|||
import type { CoercableComponent, OptionsFunc, Replace } from "features/feature";
|
||||
import { setDefault } from "features/feature";
|
||||
import type { Resource } from "features/resources/resource";
|
||||
import 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 type { Computable, GetComputableTypeWithDefault, ProcessedComputable } from "util/computed";
|
||||
import { convertComputable, processComputable } from "util/computed";
|
||||
import { processGetter } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import type { Ref } from "vue";
|
||||
import { computed, unref } from "vue";
|
||||
import { GenericDecorator } from "./decorators/common";
|
||||
import { createBooleanRequirement } from "game/requirements";
|
||||
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");
|
||||
|
||||
/** An object that configures a {@link Conversion}. */
|
||||
export interface ConversionOptions {
|
||||
|
@ -25,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?: Computable<DecimalSource>;
|
||||
currentGain?: MaybeRefOrGetter<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?: Computable<DecimalSource>;
|
||||
actualGain?: MaybeRefOrGetter<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?: Computable<DecimalSource>;
|
||||
currentAt?: MaybeRefOrGetter<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?: Computable<DecimalSource>;
|
||||
nextAt?: MaybeRefOrGetter<DecimalSource>;
|
||||
/**
|
||||
* The input {@link features/resources/resource.Resource} for this conversion.
|
||||
*/
|
||||
|
@ -55,7 +54,7 @@ export interface ConversionOptions {
|
|||
* Whether or not to cap the amount of the output resource gained by converting at 1.
|
||||
* Defaults to true.
|
||||
*/
|
||||
buyMax?: Computable<boolean>;
|
||||
buyMax?: MaybeRefOrGetter<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.
|
||||
|
@ -77,40 +76,64 @@ export interface ConversionOptions {
|
|||
/**
|
||||
* The properties that are added onto a processed {@link ConversionOptions} to create a {@link Conversion}.
|
||||
*/
|
||||
export interface BaseConversion {
|
||||
export interface Conversion {
|
||||
/**
|
||||
* The function that performs the actual conversion.
|
||||
* The formula used to determine how much {@link gainResource} should be earned by this converting.
|
||||
*/
|
||||
formula: InvertibleFormula;
|
||||
/**
|
||||
* How much of the output resource the conversion can currently convert for.
|
||||
* Typically this will be set for you in a conversion constructor.
|
||||
*/
|
||||
currentGain: MaybeRef<DecimalSource>;
|
||||
/**
|
||||
* The absolute amount the output resource will be changed by.
|
||||
* Typically this will be set for you in a conversion constructor.
|
||||
* This will differ from {@link currentGain} in the cases where the conversion isn't just adding the converted amount to the output resource.
|
||||
*/
|
||||
actualGain: MaybeRef<DecimalSource>;
|
||||
/**
|
||||
* The amount of the input resource currently being required in order to produce the {@link currentGain}.
|
||||
* That is, if it went below this value then {@link currentGain} would decrease.
|
||||
* Typically this will be set for you in a conversion constructor.
|
||||
*/
|
||||
currentAt: MaybeRef<DecimalSource>;
|
||||
/**
|
||||
* The amount of the input resource required to make {@link currentGain} increase.
|
||||
* Typically this will be set for you in a conversion constructor.
|
||||
*/
|
||||
nextAt: MaybeRef<DecimalSource>;
|
||||
/**
|
||||
* The input {@link features/resources/resource.Resource} for this conversion.
|
||||
*/
|
||||
baseResource: Resource;
|
||||
/**
|
||||
* The output {@link features/resources/resource.Resource} for this conversion. i.e. the resource being generated.
|
||||
*/
|
||||
gainResource: Resource;
|
||||
/**
|
||||
* Whether or not to cap the amount of the output resource gained by converting at 1.
|
||||
* Defaults to true.
|
||||
*/
|
||||
buyMax: MaybeRef<boolean>;
|
||||
/**
|
||||
* The function that performs the actual conversion from {@link baseResource} to {@link gainResource}.
|
||||
* Typically this will be set for you in a conversion constructor.
|
||||
*/
|
||||
convert: VoidFunction;
|
||||
/**
|
||||
* 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.
|
||||
|
@ -118,81 +141,77 @@ export type GenericConversion = Replace<
|
|||
* @see {@link createCumulativeConversion}.
|
||||
* @see {@link createIndependentConversion}.
|
||||
*/
|
||||
export function createConversion<T extends ConversionOptions>(
|
||||
optionsFunc: OptionsFunc<T, BaseConversion, GenericConversion>,
|
||||
...decorators: GenericDecorator[]
|
||||
): Conversion<T> {
|
||||
return createLazyProxy(feature => {
|
||||
const conversion = optionsFunc.call(feature, feature);
|
||||
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;
|
||||
|
||||
for (const decorator of decorators) {
|
||||
decorator.preConstruct?.(conversion);
|
||||
}
|
||||
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);
|
||||
|
||||
(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)
|
||||
);
|
||||
});
|
||||
}
|
||||
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;
|
||||
|
||||
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>;
|
||||
return conversion;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -202,9 +221,7 @@ export function createConversion<T extends ConversionOptions>(
|
|||
* This is equivalent to just calling createConversion directly.
|
||||
* @param optionsFunc Conversion options.
|
||||
*/
|
||||
export function createCumulativeConversion<S extends ConversionOptions>(
|
||||
optionsFunc: OptionsFunc<S, BaseConversion, GenericConversion>
|
||||
): Conversion<S> {
|
||||
export function createCumulativeConversion<T extends ConversionOptions>(optionsFunc: () => T) {
|
||||
return createConversion(optionsFunc);
|
||||
}
|
||||
|
||||
|
@ -213,55 +230,46 @@ export function createCumulativeConversion<S extends ConversionOptions>(
|
|||
* This is similar to the behavior of "static" layers in The Modding Tree.
|
||||
* @param optionsFunc Converison options.
|
||||
*/
|
||||
export function createIndependentConversion<S extends ConversionOptions>(
|
||||
optionsFunc: OptionsFunc<S, BaseConversion, GenericConversion>
|
||||
): Conversion<S> {
|
||||
return createConversion(feature => {
|
||||
const conversion: S = optionsFunc.call(feature, feature);
|
||||
export function createIndependentConversion<T extends ConversionOptions>(optionsFunc: () => T) {
|
||||
const conversion = createConversion(() => {
|
||||
const options = optionsFunc();
|
||||
|
||||
setDefault(conversion, "buyMax", false);
|
||||
options.buyMax ??= false;
|
||||
|
||||
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
|
||||
options.currentGain ??= computed(() => {
|
||||
let gain = Decimal.floor(conversion.formula.evaluate(options.baseResource.value)).max(
|
||||
options.gainResource.value
|
||||
);
|
||||
(conversion as unknown as GenericConversion).spend(amountGained);
|
||||
conversion.onConvert?.(amountGained);
|
||||
if (unref(options.buyMax as MaybeRef<boolean>) === false) {
|
||||
gain = gain.min(Decimal.add(options.gainResource.value, 1));
|
||||
}
|
||||
return gain;
|
||||
});
|
||||
|
||||
return conversion;
|
||||
}) as Conversion<S>;
|
||||
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.onConvert?.(amountGained);
|
||||
};
|
||||
|
||||
return options;
|
||||
});
|
||||
return conversion;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -275,12 +283,12 @@ export function createIndependentConversion<S extends ConversionOptions>(
|
|||
*/
|
||||
export function setupPassiveGeneration(
|
||||
layer: BaseLayer,
|
||||
conversion: GenericConversion,
|
||||
rate: Computable<DecimalSource> = 1,
|
||||
cap: Computable<DecimalSource> = Decimal.dInf
|
||||
conversion: Conversion,
|
||||
rate: MaybeRefOrGetter<DecimalSource> = 1,
|
||||
cap: MaybeRefOrGetter<DecimalSource> = Decimal.dInf
|
||||
): void {
|
||||
const processedRate = convertComputable(rate);
|
||||
const processedCap = convertComputable(cap);
|
||||
const processedRate = processGetter(rate);
|
||||
const processedCap = processGetter(cap);
|
||||
layer.on("preUpdate", diff => {
|
||||
const currRate = unref(processedRate);
|
||||
if (Decimal.neq(currRate, 0)) {
|
||||
|
@ -300,11 +308,11 @@ export function setupPassiveGeneration(
|
|||
* @param minGainAmount The minimum gain amount that must be met for the requirement to be met
|
||||
*/
|
||||
export function createCanConvertRequirement(
|
||||
conversion: GenericConversion,
|
||||
minGainAmount: Computable<DecimalSource> = 1,
|
||||
display?: CoercableComponent
|
||||
conversion: Conversion,
|
||||
minGainAmount: MaybeRefOrGetter<DecimalSource> = 1,
|
||||
display?: MaybeRefOrGetter<Renderable>
|
||||
) {
|
||||
const computedMinGainAmount = convertComputable(minGainAmount);
|
||||
const computedMinGainAmount = processGetter(minGainAmount);
|
||||
return createBooleanRequirement(
|
||||
() => Decimal.gte(unref(conversion.actualGain), unref(computedMinGainAmount)),
|
||||
display
|
||||
|
|
|
@ -1,117 +0,0 @@
|
|||
import { Replace } from "features/feature";
|
||||
import Decimal, { DecimalSource } from "util/bignum";
|
||||
import {
|
||||
Computable,
|
||||
GetComputableType,
|
||||
ProcessedComputable,
|
||||
processComputable
|
||||
} from "util/computed";
|
||||
import { Ref, computed, unref } from "vue";
|
||||
import { Decorator } from "./common";
|
||||
|
||||
export interface BonusAmountFeatureOptions {
|
||||
bonusAmount: Computable<DecimalSource>;
|
||||
totalAmount?: Computable<DecimalSource>;
|
||||
}
|
||||
export interface BonusCompletionsFeatureOptions {
|
||||
bonusCompletions: Computable<DecimalSource>;
|
||||
totalCompletions?: Computable<DecimalSource>;
|
||||
}
|
||||
|
||||
export interface BaseBonusAmountFeature {
|
||||
amount: Ref<DecimalSource>;
|
||||
bonusAmount: ProcessedComputable<DecimalSource>;
|
||||
totalAmount?: Ref<DecimalSource>;
|
||||
}
|
||||
export interface BaseBonusCompletionsFeature {
|
||||
completions: Ref<DecimalSource>;
|
||||
bonusCompletions: ProcessedComputable<DecimalSource>;
|
||||
totalCompletions?: Ref<DecimalSource>;
|
||||
}
|
||||
|
||||
export type BonusAmountFeature<T extends BonusAmountFeatureOptions> = Replace<
|
||||
T,
|
||||
{ bonusAmount: GetComputableType<T["bonusAmount"]> }
|
||||
>;
|
||||
export type BonusCompletionsFeature<T extends BonusCompletionsFeatureOptions> = Replace<
|
||||
T,
|
||||
{ bonusAmount: GetComputableType<T["bonusCompletions"]> }
|
||||
>;
|
||||
|
||||
export type GenericBonusAmountFeature = Replace<
|
||||
BonusAmountFeature<BonusAmountFeatureOptions>,
|
||||
{
|
||||
bonusAmount: ProcessedComputable<DecimalSource>;
|
||||
totalAmount: ProcessedComputable<DecimalSource>;
|
||||
}
|
||||
>;
|
||||
export type GenericBonusCompletionsFeature = Replace<
|
||||
BonusCompletionsFeature<BonusCompletionsFeatureOptions>,
|
||||
{
|
||||
bonusCompletions: ProcessedComputable<DecimalSource>;
|
||||
totalCompletions: ProcessedComputable<DecimalSource>;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* Allows the addition of "bonus levels" to the decorated feature, with an accompanying "total amount".
|
||||
* To function properly, the `createFeature()` function must have its generic type extended by {@linkcode BonusAmountFeatureOptions}.
|
||||
* Additionally, the base feature must have an `amount` property.
|
||||
* To allow access to the decorated values outside the `createFeature()` function, the output type must be extended by {@linkcode GenericBonusAmountFeature}.
|
||||
* @example ```ts
|
||||
* createRepeatable<RepeatableOptions & BonusAmountFeatureOptions>(() => ({
|
||||
* bonusAmount: noPersist(otherRepeatable.amount),
|
||||
* ...
|
||||
* }), bonusAmountDecorator) as GenericRepeatable & GenericBonusAmountFeature
|
||||
*/
|
||||
export const bonusAmountDecorator: Decorator<
|
||||
BonusAmountFeatureOptions,
|
||||
BaseBonusAmountFeature,
|
||||
GenericBonusAmountFeature
|
||||
> = {
|
||||
postConstruct(feature) {
|
||||
if (feature.amount === undefined) {
|
||||
console.error(
|
||||
`Decorated feature ${feature.id} does not contain the required 'amount' property"`
|
||||
);
|
||||
}
|
||||
processComputable(feature, "bonusAmount");
|
||||
if (feature.totalAmount === undefined) {
|
||||
feature.totalAmount = computed(() =>
|
||||
Decimal.add(
|
||||
unref(feature.amount ?? 0),
|
||||
unref(feature.bonusAmount as ProcessedComputable<DecimalSource>)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Allows the addition of "bonus levels" to the decorated feature, with an accompanying "total amount".
|
||||
* To function properly, the `createFeature()` function must have its generic type extended by {@linkcode BonusCompletionFeatureOptions}.
|
||||
* To allow access to the decorated values outside the `createFeature()` function, the output type must be extended by {@linkcode GenericBonusCompletionFeature}.
|
||||
* @example ```ts
|
||||
* createChallenge<ChallengeOptions & BonusCompletionFeatureOptions>(() => ({
|
||||
* bonusCompletions: noPersist(otherChallenge.completions),
|
||||
* ...
|
||||
* }), bonusCompletionDecorator) as GenericChallenge & GenericBonusCompletionFeature
|
||||
* ```
|
||||
*/
|
||||
export const bonusCompletionsDecorator: Decorator<
|
||||
BonusCompletionsFeatureOptions,
|
||||
BaseBonusCompletionsFeature,
|
||||
GenericBonusCompletionsFeature
|
||||
> = {
|
||||
postConstruct(feature) {
|
||||
processComputable(feature, "bonusCompletions");
|
||||
if (feature.totalCompletions === undefined) {
|
||||
feature.totalCompletions = computed(() =>
|
||||
Decimal.add(
|
||||
unref(feature.completions ?? 0),
|
||||
unref(feature.bonusCompletions as ProcessedComputable<DecimalSource>)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
|
@ -1,59 +0,0 @@
|
|||
import { Replace, OptionsObject } from "../feature";
|
||||
import {
|
||||
Computable,
|
||||
GetComputableType,
|
||||
processComputable,
|
||||
ProcessedComputable
|
||||
} from "util/computed";
|
||||
import { Persistent, State } from "game/persistence";
|
||||
|
||||
export type Decorator<
|
||||
FeatureOptions,
|
||||
BaseFeature = object,
|
||||
GenericFeature = BaseFeature,
|
||||
S extends State = State
|
||||
> = {
|
||||
getPersistentData?(): Record<string, Persistent<S>>;
|
||||
preConstruct?(
|
||||
feature: OptionsObject<FeatureOptions, BaseFeature & { id: string }, GenericFeature>
|
||||
): void;
|
||||
postConstruct?(
|
||||
feature: OptionsObject<FeatureOptions, BaseFeature & { id: string }, GenericFeature>
|
||||
): void;
|
||||
getGatheredProps?(
|
||||
feature: OptionsObject<FeatureOptions, BaseFeature & { id: string }, GenericFeature>
|
||||
): Partial<OptionsObject<FeatureOptions, BaseFeature & { id: string }, GenericFeature>>;
|
||||
};
|
||||
|
||||
export type GenericDecorator = Decorator<unknown>;
|
||||
|
||||
export interface EffectFeatureOptions<T = unknown> {
|
||||
effect: Computable<T>;
|
||||
}
|
||||
|
||||
export type EffectFeature<T extends EffectFeatureOptions> = Replace<
|
||||
T,
|
||||
{ effect: GetComputableType<T["effect"]> }
|
||||
>;
|
||||
|
||||
export type GenericEffectFeature<T = unknown> = Replace<
|
||||
EffectFeature<EffectFeatureOptions>,
|
||||
{ effect: ProcessedComputable<T> }
|
||||
>;
|
||||
|
||||
/**
|
||||
* Allows the usage of an `effect` field in the decorated feature.
|
||||
* To function properly, the `createFeature()` function must have its generic type extended by {@linkcode EffectFeatureOptions}.
|
||||
* To allow access to the decorated values outside the `createFeature()` function, the output type must be extended by {@linkcode GenericEffectFeature}.
|
||||
* @example ```ts
|
||||
* createRepeatable<RepeatableOptions & EffectFeatureOptions>(() => ({
|
||||
* effect() { return Decimal.pow(2, this.amount); },
|
||||
* ...
|
||||
* }), effectDecorator) as GenericUpgrade & GenericEffectFeature;
|
||||
* ```
|
||||
*/
|
||||
export const effectDecorator: Decorator<EffectFeatureOptions, unknown, GenericEffectFeature> = {
|
||||
postConstruct(feature) {
|
||||
processComputable(feature, "effect");
|
||||
}
|
||||
};
|
|
@ -1,50 +1,6 @@
|
|||
import Decimal from "util/bignum";
|
||||
import { 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, S = R> = (obj: R) => OptionsObject<T, R, S>;
|
||||
|
||||
export type OptionsObject<T, R = unknown, S = R> = T & Partial<R> & ThisType<T & S>;
|
||||
import { Renderable, renderCol, VueFeature } from "util/vue";
|
||||
import { computed, isRef, MaybeRef, Ref, unref } from "vue";
|
||||
|
||||
let id = 0;
|
||||
/**
|
||||
|
@ -67,34 +23,18 @@ export enum Visibility {
|
|||
None
|
||||
}
|
||||
|
||||
export function isVisible(visibility: ProcessedComputable<Visibility | boolean>) {
|
||||
export function isVisible(visibility: MaybeRef<Visibility | boolean>) {
|
||||
const currVisibility = unref(visibility);
|
||||
return currVisibility !== Visibility.None && currVisibility !== false;
|
||||
}
|
||||
|
||||
export function isHidden(visibility: ProcessedComputable<Visibility | boolean>) {
|
||||
export function isHidden(visibility: MaybeRef<Visibility | boolean>) {
|
||||
const currVisibility = unref(visibility);
|
||||
return currVisibility === Visibility.Hidden;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -102,11 +42,11 @@ export function setDefault<T, K extends keyof T>(
|
|||
* @param obj The object to traverse
|
||||
* @param types The feature types that will be searched for
|
||||
*/
|
||||
export function findFeatures(obj: Record<string, unknown>, ...types: symbol[]): unknown[] {
|
||||
export function findFeatures(obj: object, ...types: symbol[]): unknown[] {
|
||||
const objects: unknown[] = [];
|
||||
const handleObject = (obj: Record<string, unknown>) => {
|
||||
const handleObject = (obj: object) => {
|
||||
Object.keys(obj).forEach(key => {
|
||||
const value = obj[key];
|
||||
const value: unknown = obj[key as keyof typeof obj];
|
||||
if (value != null && typeof value === "object") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (types.includes((value as Record<string, any>).type)) {
|
||||
|
@ -121,6 +61,24 @@ export function findFeatures(obj: Record<string, unknown>, ...types: symbol[]):
|
|||
return objects;
|
||||
}
|
||||
|
||||
export function getFirstFeature<T extends VueFeature>(
|
||||
features: T[],
|
||||
filter: (feature: T) => boolean
|
||||
): {
|
||||
firstFeature: Ref<T | undefined>;
|
||||
collapsedContent: MaybeRef<Renderable>;
|
||||
hasCollapsedContent: Ref<boolean>;
|
||||
} {
|
||||
const filteredFeatures = computed(() =>
|
||||
features.filter(feature => isVisible(feature.visibility ?? true) && filter(feature))
|
||||
);
|
||||
return {
|
||||
firstFeature: computed(() => filteredFeatures.value[0]),
|
||||
collapsedContent: computed(() => renderCol(...filteredFeatures.value.slice(1))),
|
||||
hasCollapsedContent: computed(() => filteredFeatures.value.length > 1)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverses an object and returns all features that are _not_ any of the given types.
|
||||
* Features are any object with a "type" property that has a symbol value.
|
||||
|
@ -135,7 +93,7 @@ export function excludeFeatures(obj: Record<string, unknown>, ...types: symbol[]
|
|||
if (value != null && typeof value === "object") {
|
||||
if (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
typeof (value as Record<string, any>).type == "symbol" &&
|
||||
typeof (value as Record<string, any>).type === "symbol" &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
!types.includes((value as Record<string, any>).type)
|
||||
) {
|
||||
|
|
|
@ -1,60 +0,0 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="isVisible(visibility)"
|
||||
:style="{
|
||||
visibility: isHidden(visibility) ? 'hidden' : undefined
|
||||
}"
|
||||
class="table-grid"
|
||||
>
|
||||
<div v-for="row in unref(rows)" class="row-grid" :class="{ mergeAdjacent }" :key="row">
|
||||
<GridCell
|
||||
v-for="col in unref(cols)"
|
||||
:key="col"
|
||||
v-bind="gatherCellProps(unref(cells)[row * 100 + col])"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import "components/common/table.css";
|
||||
import themes from "data/themes";
|
||||
import { isHidden, isVisible, Visibility } from "features/feature";
|
||||
import type { GridCell } from "features/grids/grid";
|
||||
import settings from "game/settings";
|
||||
import { processedPropType } from "util/vue";
|
||||
import { computed, defineComponent, unref } from "vue";
|
||||
import GridCellVue from "./GridCell.vue";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
visibility: {
|
||||
type: processedPropType<Visibility | boolean>(Number, Boolean),
|
||||
required: true
|
||||
},
|
||||
rows: {
|
||||
type: processedPropType<number>(Number),
|
||||
required: true
|
||||
},
|
||||
cols: {
|
||||
type: processedPropType<number>(Number),
|
||||
required: true
|
||||
},
|
||||
cells: {
|
||||
type: processedPropType<Record<string, GridCell>>(Object),
|
||||
required: true
|
||||
}
|
||||
},
|
||||
components: { GridCell: GridCellVue },
|
||||
setup() {
|
||||
const mergeAdjacent = computed(() => themes[settings.theme].mergeAdjacent);
|
||||
|
||||
function gatherCellProps(cell: GridCell) {
|
||||
const { visibility, onClick, onHold, display, title, style, canClick, id } = cell;
|
||||
return { visibility, onClick, onHold, display, title, style, canClick, id };
|
||||
}
|
||||
|
||||
return { unref, gatherCellProps, Visibility, mergeAdjacent, isVisible, isHidden };
|
||||
}
|
||||
});
|
||||
</script>
|
|
@ -1,98 +0,0 @@
|
|||
<template>
|
||||
<button
|
||||
v-if="isVisible(visibility)"
|
||||
:class="{ feature: true, tile: true, can: unref(canClick), locked: !unref(canClick) }"
|
||||
:style="[
|
||||
{
|
||||
visibility: isHidden(visibility) ? 'hidden' : undefined
|
||||
},
|
||||
unref(style) ?? {}
|
||||
]"
|
||||
@click="onClick"
|
||||
@mousedown="start"
|
||||
@mouseleave="stop"
|
||||
@mouseup="stop"
|
||||
@touchstart.passive="start"
|
||||
@touchend.passive="stop"
|
||||
@touchcancel.passive="stop"
|
||||
>
|
||||
<div v-if="title"><component :is="titleComponent" /></div>
|
||||
<component :is="component" style="white-space: pre-line" />
|
||||
<Node :id="id" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import "components/common/features.css";
|
||||
import Node from "components/Node.vue";
|
||||
import type { CoercableComponent, StyleValue } from "features/feature";
|
||||
import { isHidden, isVisible, Visibility } from "features/feature";
|
||||
import {
|
||||
computeComponent,
|
||||
computeOptionalComponent,
|
||||
processedPropType,
|
||||
setupHoldToClick
|
||||
} from "util/vue";
|
||||
import type { PropType } from "vue";
|
||||
import { defineComponent, toRefs, unref } from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
visibility: {
|
||||
type: processedPropType<Visibility | boolean>(Number, Boolean),
|
||||
required: true
|
||||
},
|
||||
onClick: Function as PropType<(e?: MouseEvent | TouchEvent) => void>,
|
||||
onHold: Function as PropType<VoidFunction>,
|
||||
display: {
|
||||
type: processedPropType<CoercableComponent>(Object, String, Function),
|
||||
required: true
|
||||
},
|
||||
title: processedPropType<CoercableComponent>(Object, String, Function),
|
||||
style: processedPropType<StyleValue>(String, Object, Array),
|
||||
canClick: {
|
||||
type: processedPropType<boolean>(Boolean),
|
||||
required: true
|
||||
},
|
||||
id: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
components: {
|
||||
Node
|
||||
},
|
||||
setup(props) {
|
||||
const { onClick, onHold, title, display } = toRefs(props);
|
||||
|
||||
const { start, stop } = setupHoldToClick(onClick, onHold);
|
||||
|
||||
const titleComponent = computeOptionalComponent(title);
|
||||
const component = computeComponent(display);
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
titleComponent,
|
||||
component,
|
||||
Visibility,
|
||||
unref,
|
||||
isVisible,
|
||||
isHidden
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tile {
|
||||
min-height: 80px;
|
||||
width: 80px;
|
||||
font-size: 10px;
|
||||
background-color: var(--layer-color);
|
||||
}
|
||||
|
||||
.tile > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
|
@ -1,370 +0,0 @@
|
|||
import type {
|
||||
CoercableComponent,
|
||||
GenericComponent,
|
||||
OptionsFunc,
|
||||
Replace,
|
||||
StyleValue
|
||||
} from "features/feature";
|
||||
import { Component, GatherProps, getUniqueID, setDefault, Visibility } from "features/feature";
|
||||
import GridComponent from "features/grids/Grid.vue";
|
||||
import type { Persistent, State } from "game/persistence";
|
||||
import { persistent } from "game/persistence";
|
||||
import { isFunction } from "util/common";
|
||||
import type {
|
||||
Computable,
|
||||
GetComputableType,
|
||||
GetComputableTypeWithDefault,
|
||||
ProcessedComputable
|
||||
} from "util/computed";
|
||||
import { processComputable } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import type { Ref } from "vue";
|
||||
import { computed, unref } from "vue";
|
||||
|
||||
/** A symbol used to identify {@link Grid} features. */
|
||||
export const GridType = Symbol("Grid");
|
||||
|
||||
/** A type representing a computable value for a cell in the grid. */
|
||||
export type CellComputable<T> = Computable<T> | ((id: string | number, state: State) => T);
|
||||
|
||||
/** Create proxy to more easily get the properties of cells on a grid. */
|
||||
function createGridProxy(grid: GenericGrid): Record<string | number, GridCell> {
|
||||
return new Proxy({}, getGridHandler(grid)) as Record<string | number, GridCell>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns traps for a proxy that will give cell proxies when accessing any numerical key.
|
||||
* @param grid The grid to get the cells from.
|
||||
* @see {@link createGridProxy}
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function getGridHandler(grid: GenericGrid): ProxyHandler<Record<string | number, GridCell>> {
|
||||
const keys = computed(() => {
|
||||
const keys = [];
|
||||
for (let row = 1; row <= unref(grid.rows); row++) {
|
||||
for (let col = 1; col <= unref(grid.cols); col++) {
|
||||
keys.push((row * 100 + col).toString());
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
});
|
||||
return {
|
||||
get(target: Record<string | number, GridCell>, key: PropertyKey) {
|
||||
if (key === "isProxy") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof key === "symbol") {
|
||||
return (grid as never)[key];
|
||||
}
|
||||
|
||||
if (!keys.value.includes(key.toString())) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (target[key] == null) {
|
||||
target[key] = new Proxy(
|
||||
grid,
|
||||
getCellHandler(key.toString())
|
||||
) as unknown as GridCell;
|
||||
}
|
||||
|
||||
return target[key];
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
set(target: Record<string | number, GridCell>, key: PropertyKey, value: any) {
|
||||
console.warn("Cannot set grid cells", target, key, value);
|
||||
return false;
|
||||
},
|
||||
ownKeys() {
|
||||
return keys.value;
|
||||
},
|
||||
has(target: Record<string | number, GridCell>, key: PropertyKey) {
|
||||
return keys.value.includes(key.toString());
|
||||
},
|
||||
getOwnPropertyDescriptor(target: Record<string | number, GridCell>, key: PropertyKey) {
|
||||
if (keys.value.includes(key.toString())) {
|
||||
return {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: false
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns traps for a proxy that will get the properties for the specified cell
|
||||
* @param id The grid cell ID to get properties from.
|
||||
* @see {@link getGridHandler}
|
||||
* @see {@link createGridProxy}
|
||||
*/
|
||||
function getCellHandler(id: string): ProxyHandler<GenericGrid> {
|
||||
const keys = [
|
||||
"id",
|
||||
"visibility",
|
||||
"canClick",
|
||||
"startState",
|
||||
"state",
|
||||
"style",
|
||||
"classes",
|
||||
"title",
|
||||
"display",
|
||||
"onClick",
|
||||
"onHold"
|
||||
];
|
||||
const cache: Record<string, Ref<unknown>> = {};
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
get(target, key, receiver): any {
|
||||
if (key === "isProxy") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let prop = (target as any)[key];
|
||||
|
||||
if (isFunction(prop)) {
|
||||
return () => prop.call(receiver, id, target.getState(id));
|
||||
}
|
||||
if (prop != undefined || typeof key === "symbol") {
|
||||
return prop;
|
||||
}
|
||||
|
||||
key = key.slice(0, 1).toUpperCase() + key.slice(1);
|
||||
|
||||
if (key === "startState") {
|
||||
return prop.call(receiver, id);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
prop = (target as any)[`get${key}`];
|
||||
if (isFunction(prop)) {
|
||||
if (!(key in cache)) {
|
||||
cache[key] = computed(() => prop.call(receiver, id, target.getState(id)));
|
||||
}
|
||||
return cache[key].value;
|
||||
} else if (prop != undefined) {
|
||||
return unref(prop);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
prop = (target as any)[`on${key}`];
|
||||
if (isFunction(prop)) {
|
||||
return () => prop.call(receiver, id, target.getState(id));
|
||||
} else if (prop != undefined) {
|
||||
return prop;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (target as any)[key];
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
set(target: Record<string, any>, key: string, value: any, receiver: typeof Proxy): boolean {
|
||||
key = `set${key.slice(0, 1).toUpperCase() + key.slice(1)}`;
|
||||
if (key in target && isFunction(target[key]) && target[key].length < 3) {
|
||||
target[key].call(receiver, id, value);
|
||||
return true;
|
||||
} else {
|
||||
console.warn(`No setter for "${key}".`, target);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
ownKeys() {
|
||||
return keys;
|
||||
},
|
||||
has(target, key) {
|
||||
return keys.includes(key.toString());
|
||||
},
|
||||
getOwnPropertyDescriptor(target, key) {
|
||||
if (keys.includes(key.toString())) {
|
||||
return {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: false
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a cell within a grid. These properties will typically be accessed via a cell proxy that calls functions on the grid to get the properties for a specific cell.
|
||||
* @see {@link createGridProxy}
|
||||
*/
|
||||
export interface GridCell {
|
||||
/** A unique identifier for the grid cell. */
|
||||
id: string;
|
||||
/** Whether this cell should be visible. */
|
||||
visibility: Visibility | boolean;
|
||||
/** Whether this cell can be clicked. */
|
||||
canClick: boolean;
|
||||
/** The initial persistent state of this cell. */
|
||||
startState: State;
|
||||
/** The persistent state of this cell. */
|
||||
state: State;
|
||||
/** CSS to apply to this feature. */
|
||||
style?: StyleValue;
|
||||
/** Dictionary of CSS classes to apply to this feature. */
|
||||
classes?: Record<string, boolean>;
|
||||
/** A header to appear at the top of the display. */
|
||||
title?: CoercableComponent;
|
||||
/** The main text that appears in the display. */
|
||||
display: CoercableComponent;
|
||||
/** A function that is called when the cell is clicked. */
|
||||
onClick?: (e?: MouseEvent | TouchEvent) => void;
|
||||
/** A function that is called when the cell is held down. */
|
||||
onHold?: VoidFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* An object that configures a {@link Grid}.
|
||||
*/
|
||||
export interface GridOptions {
|
||||
/** Whether this grid should be visible. */
|
||||
visibility?: Computable<Visibility | boolean>;
|
||||
/** The number of rows in the grid. */
|
||||
rows: Computable<number>;
|
||||
/** The number of columns in the grid. */
|
||||
cols: Computable<number>;
|
||||
/** A computable to determine the visibility of a cell. */
|
||||
getVisibility?: CellComputable<Visibility | boolean>;
|
||||
/** A computable to determine if a cell can be clicked. */
|
||||
getCanClick?: CellComputable<boolean>;
|
||||
/** A computable to get the initial persistent state of a cell. */
|
||||
getStartState: Computable<State> | ((id: string | number) => State);
|
||||
/** A computable to get the CSS styles for a cell. */
|
||||
getStyle?: CellComputable<StyleValue>;
|
||||
/** A computable to get the CSS classes for a cell. */
|
||||
getClasses?: CellComputable<Record<string, boolean>>;
|
||||
/** A computable to get the title component for a cell. */
|
||||
getTitle?: CellComputable<CoercableComponent>;
|
||||
/** A computable to get the display component for a cell. */
|
||||
getDisplay: CellComputable<CoercableComponent>;
|
||||
/** A function that is called when a cell is clicked. */
|
||||
onClick?: (id: string | number, state: State, e?: MouseEvent | TouchEvent) => void;
|
||||
/** A function that is called when a cell is held down. */
|
||||
onHold?: (id: string | number, state: State) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The properties that are added onto a processed {@link BoardOptions} to create a {@link Board}.
|
||||
*/
|
||||
export interface BaseGrid {
|
||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
||||
id: string;
|
||||
/** Get the auto-generated ID for identifying a specific cell of this grid that appears in the DOM. Will not persist between refreshes or updates. */
|
||||
getID: (id: string | number, state: State) => string;
|
||||
/** Get the persistent state of the given cell. */
|
||||
getState: (id: string | number) => State;
|
||||
/** Set the persistent state of the given cell. */
|
||||
setState: (id: string | number, state: State) => void;
|
||||
/** A dictionary of cells within this grid. */
|
||||
cells: Record<string | number, GridCell>;
|
||||
/** The persistent state of this grid, which is a dictionary of cell states. */
|
||||
cellState: Persistent<Record<string | number, State>>;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof GridType;
|
||||
/** The Vue component used to render this feature. */
|
||||
[Component]: GenericComponent;
|
||||
/** A function to gather the props the vue component requires for this feature. */
|
||||
[GatherProps]: () => Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** An object that represents a feature that is a grid of cells that all behave according to the same rules. */
|
||||
export type Grid<T extends GridOptions> = Replace<
|
||||
T & BaseGrid,
|
||||
{
|
||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||
rows: GetComputableType<T["rows"]>;
|
||||
cols: GetComputableType<T["cols"]>;
|
||||
getVisibility: GetComputableTypeWithDefault<T["getVisibility"], Visibility.Visible>;
|
||||
getCanClick: GetComputableTypeWithDefault<T["getCanClick"], true>;
|
||||
getStartState: GetComputableType<T["getStartState"]>;
|
||||
getStyle: GetComputableType<T["getStyle"]>;
|
||||
getClasses: GetComputableType<T["getClasses"]>;
|
||||
getTitle: GetComputableType<T["getTitle"]>;
|
||||
getDisplay: GetComputableType<T["getDisplay"]>;
|
||||
}
|
||||
>;
|
||||
|
||||
/** A type that matches any valid {@link Grid} object. */
|
||||
export type GenericGrid = Replace<
|
||||
Grid<GridOptions>,
|
||||
{
|
||||
visibility: ProcessedComputable<Visibility | boolean>;
|
||||
getVisibility: ProcessedComputable<Visibility | boolean>;
|
||||
getCanClick: ProcessedComputable<boolean>;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* Lazily creates a grid with the given options.
|
||||
* @param optionsFunc Grid options.
|
||||
*/
|
||||
export function createGrid<T extends GridOptions>(
|
||||
optionsFunc: OptionsFunc<T, BaseGrid, GenericGrid>
|
||||
): Grid<T> {
|
||||
const cellState = persistent<Record<string | number, State>>({}, false);
|
||||
return createLazyProxy(feature => {
|
||||
const grid = optionsFunc.call(feature, feature);
|
||||
grid.id = getUniqueID("grid-");
|
||||
grid[Component] = GridComponent as GenericComponent;
|
||||
|
||||
grid.cellState = cellState;
|
||||
|
||||
grid.getID = function (this: GenericGrid, cell: string | number) {
|
||||
return grid.id + "-" + cell;
|
||||
};
|
||||
grid.getState = function (this: GenericGrid, cell: string | number) {
|
||||
if (this.cellState.value[cell] != undefined) {
|
||||
return cellState.value[cell];
|
||||
}
|
||||
return this.cells[cell].startState;
|
||||
};
|
||||
grid.setState = function (this: GenericGrid, cell: string | number, state: State) {
|
||||
cellState.value[cell] = state;
|
||||
};
|
||||
|
||||
grid.cells = createGridProxy(grid as GenericGrid);
|
||||
|
||||
processComputable(grid as T, "visibility");
|
||||
setDefault(grid, "visibility", Visibility.Visible);
|
||||
processComputable(grid as T, "rows");
|
||||
processComputable(grid as T, "cols");
|
||||
processComputable(grid as T, "getVisibility");
|
||||
setDefault(grid, "getVisibility", Visibility.Visible);
|
||||
processComputable(grid as T, "getCanClick");
|
||||
setDefault(grid, "getCanClick", true);
|
||||
processComputable(grid as T, "getStartState");
|
||||
processComputable(grid as T, "getStyle");
|
||||
processComputable(grid as T, "getClasses");
|
||||
processComputable(grid as T, "getTitle");
|
||||
processComputable(grid as T, "getDisplay");
|
||||
|
||||
if (grid.onClick) {
|
||||
const onClick = grid.onClick.bind(grid);
|
||||
grid.onClick = function (id, state, e) {
|
||||
if (unref((grid as GenericGrid).cells[id].canClick)) {
|
||||
onClick(id, state, e);
|
||||
}
|
||||
};
|
||||
}
|
||||
if (grid.onHold) {
|
||||
const onHold = grid.onHold.bind(grid);
|
||||
grid.onHold = function (id, state) {
|
||||
if (unref((grid as GenericGrid).cells[id].canClick)) {
|
||||
onHold(id, state);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
grid[GatherProps] = function (this: GenericGrid) {
|
||||
const { visibility, rows, cols, cells, id } = this;
|
||||
return { visibility, rows, cols, cells, id };
|
||||
};
|
||||
|
||||
return grid as unknown as Grid<T>;
|
||||
});
|
||||
}
|
464
src/features/grids/grid.tsx
Normal file
464
src/features/grids/grid.tsx
Normal file
|
@ -0,0 +1,464 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { getUniqueID, Visibility } from "features/feature";
|
||||
import type { Persistent, State } from "game/persistence";
|
||||
import { persistent } from "game/persistence";
|
||||
import { isFunction } from "util/common";
|
||||
import { processGetter } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import { isJSXElement, render, Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
|
||||
import type { CSSProperties, MaybeRef, MaybeRefOrGetter, Ref } from "vue";
|
||||
import { computed, isRef, unref } from "vue";
|
||||
import Column from "components/layout/Column.vue";
|
||||
import Row from "components/layout/Row.vue";
|
||||
import Clickable from "features/clickables/Clickable.vue";
|
||||
|
||||
/** A symbol used to identify {@link Grid} features. */
|
||||
export const GridType = Symbol("Grid");
|
||||
|
||||
/** A type representing a MaybeRefOrGetter value for a cell in the grid. */
|
||||
export type CellMaybeRefOrGetter<T> =
|
||||
| MaybeRefOrGetter<T>
|
||||
| ((row: number, col: number, state: State) => T);
|
||||
export type ProcessedCellRefOrGetter<T> =
|
||||
| MaybeRef<T>
|
||||
| ((row: number, col: number, state: State) => T);
|
||||
|
||||
/**
|
||||
* Represents a cell within a grid. These properties will typically be accessed via a cell proxy that calls functions on the grid to get the properties for a specific cell.
|
||||
* @see {@link createGridProxy}
|
||||
*/
|
||||
export interface GridCell extends VueFeature {
|
||||
/** Which roe in the grid this cell is from. */
|
||||
row: number;
|
||||
/** Which col in the grid this cell is from. */
|
||||
col: number;
|
||||
/** Whether this cell can be clicked. */
|
||||
canClick: boolean;
|
||||
/** The initial persistent state of this cell. */
|
||||
startState: State;
|
||||
/** The persistent state of this cell. */
|
||||
state: State;
|
||||
/** The main text that appears in the display. */
|
||||
display: MaybeRef<Renderable>;
|
||||
/** A function that is called when the cell is clicked. */
|
||||
onClick?: (e?: MouseEvent | TouchEvent) => void;
|
||||
/** A function that is called when the cell is held down. */
|
||||
onHold?: VoidFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* An object that configures a {@link Grid}.
|
||||
*/
|
||||
export interface GridOptions extends VueFeatureOptions {
|
||||
/** The number of rows in the grid. */
|
||||
rows: MaybeRefOrGetter<number>;
|
||||
/** The number of columns in the grid. */
|
||||
cols: MaybeRefOrGetter<number>;
|
||||
/** A getter for the visibility of a cell. */
|
||||
getVisibility?: CellMaybeRefOrGetter<Visibility | boolean>;
|
||||
/** A getter for if a cell can be clicked. */
|
||||
getCanClick?: CellMaybeRefOrGetter<boolean>;
|
||||
/** A getter for the initial persistent state of a cell. */
|
||||
getStartState: MaybeRefOrGetter<State> | ((row: number, col: number) => State);
|
||||
/** A getter for the CSS styles for a cell. */
|
||||
getStyle?: CellMaybeRefOrGetter<CSSProperties>;
|
||||
/** A getter for the CSS classes for a cell. */
|
||||
getClasses?: CellMaybeRefOrGetter<Record<string, boolean>>;
|
||||
/** A getter for the display component for a cell. */
|
||||
getDisplay: CellMaybeRefOrGetter<Renderable> | {
|
||||
getTitle?: CellMaybeRefOrGetter<Renderable>;
|
||||
getDescription: CellMaybeRefOrGetter<Renderable>
|
||||
};
|
||||
/** A function that is called when a cell is clicked. */
|
||||
onClick?: (row: number, col: number, state: State, e?: MouseEvent | TouchEvent) => void;
|
||||
/** A function that is called when a cell is held down. */
|
||||
onHold?: (row: number, col: number, state: State) => void;
|
||||
}
|
||||
|
||||
/** An object that represents a feature that is a grid of cells that all behave according to the same rules. */
|
||||
export interface Grid extends VueFeature {
|
||||
/** A function that is called when a cell is clicked. */
|
||||
onClick?: (row: number, col: number, state: State, e?: MouseEvent | TouchEvent) => void;
|
||||
/** A function that is called when a cell is held down. */
|
||||
onHold?: (row: number, col: number, state: State) => void;
|
||||
/** A getter for determine the visibility of a cell. */
|
||||
getVisibility?: ProcessedCellRefOrGetter<Visibility | boolean>;
|
||||
/** A getter for determine if a cell can be clicked. */
|
||||
getCanClick?: ProcessedCellRefOrGetter<boolean>;
|
||||
/** The number of rows in the grid. */
|
||||
rows: MaybeRef<number>;
|
||||
/** The number of columns in the grid. */
|
||||
cols: MaybeRef<number>;
|
||||
/** A getter for the initial persistent state of a cell. */
|
||||
getStartState: MaybeRef<State> | ((row: number, col: number) => State);
|
||||
/** A getter for the CSS styles for a cell. */
|
||||
getStyle?: ProcessedCellRefOrGetter<CSSProperties>;
|
||||
/** A getter for the CSS classes for a cell. */
|
||||
getClasses?: ProcessedCellRefOrGetter<Record<string, boolean>>;
|
||||
/** A getter for the display component for a cell. */
|
||||
getDisplay: ProcessedCellRefOrGetter<Renderable>;
|
||||
/** Get the auto-generated ID for identifying a specific cell of this grid that appears in the DOM. Will not persist between refreshes or updates. */
|
||||
getID: (row: number, col: number, state: State) => string;
|
||||
/** Get the persistent state of the given cell. */
|
||||
getState: (row: number, col: number) => State;
|
||||
/** Set the persistent state of the given cell. */
|
||||
setState: (row: number, col: number, state: State) => void;
|
||||
/** A dictionary of cells within this grid. */
|
||||
cells: GridCell[][];
|
||||
/** The persistent state of this grid, which is a dictionary of cell states. */
|
||||
cellState: Persistent<Record<number, Record<number, State>>>;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof GridType;
|
||||
}
|
||||
|
||||
function getCellRowHandler(grid: Grid, row: number) {
|
||||
return new Proxy({} as GridCell[], {
|
||||
get(target, key) {
|
||||
if (key === "length") {
|
||||
return unref(grid.cols);
|
||||
}
|
||||
if (typeof key !== "number" && typeof key !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
const keyNum = typeof key === "number" ? key : parseInt(key);
|
||||
if (Number.isFinite(keyNum) && keyNum < unref(grid.cols)) {
|
||||
if (keyNum in target) {
|
||||
return target[keyNum];
|
||||
}
|
||||
return (target[keyNum] = getCellHandler(grid, row, keyNum));
|
||||
}
|
||||
},
|
||||
set(target, key, value) {
|
||||
console.warn("Cannot set grid cells", target, key, value);
|
||||
return false;
|
||||
},
|
||||
ownKeys() {
|
||||
return [...new Array(unref(grid.cols)).fill(0).map((_, i) => "" + i), "length"];
|
||||
},
|
||||
has(target, key) {
|
||||
if (key === "length") {
|
||||
return true;
|
||||
}
|
||||
if (typeof key !== "number" && typeof key !== "string") {
|
||||
return false;
|
||||
}
|
||||
const keyNum = typeof key === "number" ? key : parseInt(key);
|
||||
if (!Number.isFinite(keyNum) || keyNum >= unref(grid.cols)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
getOwnPropertyDescriptor(target, key) {
|
||||
if (typeof key !== "number" && typeof key !== "string") {
|
||||
return;
|
||||
}
|
||||
const keyNum = typeof key === "number" ? key : parseInt(key);
|
||||
if (key !== "length" && (!Number.isFinite(keyNum) || keyNum >= unref(grid.cols))) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: false
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns traps for a proxy that will get the properties for the specified cell
|
||||
* @param id The grid cell ID to get properties from.
|
||||
* @see {@link getGridHandler}
|
||||
* @see {@link createGridProxy}
|
||||
*/
|
||||
function getCellHandler(grid: Grid, row: number, col: number): GridCell {
|
||||
const keys = [
|
||||
"id",
|
||||
"visibility",
|
||||
"classes",
|
||||
"style",
|
||||
"components",
|
||||
"wrappers",
|
||||
VueFeature,
|
||||
"row",
|
||||
"col",
|
||||
"canClick",
|
||||
"startState",
|
||||
"state",
|
||||
"title",
|
||||
"display",
|
||||
"onClick",
|
||||
"onHold"
|
||||
] as const;
|
||||
const cache: Record<string, Ref<unknown>> = {};
|
||||
return new Proxy({} as GridCell, {
|
||||
// The typing in this function is absolutely atrocious in order to support custom properties
|
||||
get(target, key, receiver) {
|
||||
switch (key) {
|
||||
case "wrappers":
|
||||
return [];
|
||||
case VueFeature:
|
||||
return true;
|
||||
case "row":
|
||||
return row;
|
||||
case "col":
|
||||
return col;
|
||||
case "startState": {
|
||||
if (typeof grid.getStartState === "function") {
|
||||
return grid.getStartState(row, col);
|
||||
}
|
||||
return unref(grid.getStartState);
|
||||
}
|
||||
case "state": {
|
||||
return grid.getState(row, col);
|
||||
}
|
||||
case "id":
|
||||
return target.id = target.id ?? getUniqueID("gridcell");
|
||||
case "components":
|
||||
return [
|
||||
computed(() => (
|
||||
<Clickable
|
||||
onClick={receiver.onClick}
|
||||
onHold={receiver.onHold}
|
||||
display={receiver.display}
|
||||
canClick={receiver.canClick}
|
||||
/>
|
||||
))
|
||||
];
|
||||
}
|
||||
|
||||
if (typeof key === "symbol") {
|
||||
return (grid as any)[key];
|
||||
}
|
||||
|
||||
key = key.slice(0, 1).toUpperCase() + key.slice(1);
|
||||
|
||||
let prop = (grid as any)[`get${key}`];
|
||||
if (isFunction(prop)) {
|
||||
if (!(key in cache)) {
|
||||
cache[key] = computed(() =>
|
||||
prop.call(receiver, row, col, grid.getState(row, col))
|
||||
);
|
||||
}
|
||||
return cache[key].value;
|
||||
} else if (prop != null) {
|
||||
return unref(prop);
|
||||
}
|
||||
|
||||
prop = (grid as any)[`on${key}`];
|
||||
if (isFunction(prop)) {
|
||||
return () => prop.call(receiver, row, col, grid.getState(row, col));
|
||||
} else if (prop != null) {
|
||||
return prop;
|
||||
}
|
||||
|
||||
// Revert key change
|
||||
key = key.slice(0, 1).toLowerCase() + key.slice(1);
|
||||
prop = (grid as any)[key];
|
||||
|
||||
if (isFunction(prop)) {
|
||||
return () => prop.call(receiver, row, col, grid.getState(row, col));
|
||||
}
|
||||
|
||||
return (grid as any)[key];
|
||||
},
|
||||
set(target, key, value) {
|
||||
console.log("!!?", key, value)
|
||||
if (typeof key !== "string") {
|
||||
return false;
|
||||
}
|
||||
key = `set${key.slice(0, 1).toUpperCase() + key.slice(1)}`;
|
||||
console.log(key, grid[key])
|
||||
if (key in grid && isFunction((grid as any)[key]) && (grid as any)[key].length <= 3) {
|
||||
(grid as any)[key].call(grid, row, col, value);
|
||||
return true;
|
||||
} else {
|
||||
console.warn(`No setter for "${key}".`, target);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
ownKeys() {
|
||||
return keys;
|
||||
},
|
||||
has(target, key) {
|
||||
return (keys as readonly (string | symbol)[]).includes(key);
|
||||
},
|
||||
getOwnPropertyDescriptor(target, key) {
|
||||
if ((keys as readonly (string | symbol)[]).includes(key)) {
|
||||
return {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: false
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function convertCellMaybeRefOrGetter<T>(
|
||||
value: NonNullable<CellMaybeRefOrGetter<T>>
|
||||
): ProcessedCellRefOrGetter<T>;
|
||||
function convertCellMaybeRefOrGetter<T>(
|
||||
value: CellMaybeRefOrGetter<T> | undefined
|
||||
): ProcessedCellRefOrGetter<T> | undefined;
|
||||
function convertCellMaybeRefOrGetter<T>(
|
||||
value: CellMaybeRefOrGetter<T>
|
||||
): ProcessedCellRefOrGetter<T> {
|
||||
if (typeof value === "function" && value.length > 0) {
|
||||
return value;
|
||||
}
|
||||
return processGetter(value) as MaybeRef<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily creates a grid with the given options.
|
||||
* @param optionsFunc Grid options.
|
||||
*/
|
||||
export function createGrid<T extends GridOptions>(optionsFunc: () => T) {
|
||||
const cellState = persistent<Record<number, Record<number, State>>>({}, false);
|
||||
return createLazyProxy(() => {
|
||||
const options = optionsFunc();
|
||||
const {
|
||||
rows,
|
||||
cols,
|
||||
getVisibility,
|
||||
getCanClick,
|
||||
getStartState,
|
||||
getStyle,
|
||||
getClasses,
|
||||
getDisplay: _getDisplay,
|
||||
onClick,
|
||||
onHold,
|
||||
...props
|
||||
} = options;
|
||||
|
||||
let getDisplay;
|
||||
if (typeof _getDisplay === "object" && !isRef(_getDisplay) && !isJSXElement(_getDisplay)) {
|
||||
const { getTitle, getDescription } = _getDisplay;
|
||||
const getProcessedTitle = convertCellMaybeRefOrGetter(getTitle);
|
||||
const getProcessedDescription = convertCellMaybeRefOrGetter(getDescription);
|
||||
getDisplay = function(row: number, col: number, state: State) {
|
||||
const title = typeof getProcessedTitle === "function" ? getProcessedTitle(row, col, state) : unref(getProcessedTitle);
|
||||
const description = typeof getProcessedDescription === "function" ? getProcessedDescription(row, col, state) : unref(getProcessedDescription);
|
||||
return <>
|
||||
{title}
|
||||
{description}
|
||||
</>;
|
||||
}
|
||||
} else {
|
||||
getDisplay = convertCellMaybeRefOrGetter(_getDisplay);
|
||||
}
|
||||
|
||||
const grid = {
|
||||
type: GridType,
|
||||
...(props as Omit<typeof props, keyof VueFeature | keyof GridOptions>),
|
||||
...vueFeatureMixin("grid", options, () => (
|
||||
<Column>
|
||||
{new Array(unref(grid.rows)).fill(0).map((_, row) => (
|
||||
<Row>
|
||||
{new Array(unref(grid.cols)).fill(0).map((_, col) =>
|
||||
render(grid.cells[row][col]))}
|
||||
</Row>))}
|
||||
</Column>)),
|
||||
cellState,
|
||||
cells: new Proxy({} as GridCell[][], {
|
||||
get(target, key: PropertyKey) {
|
||||
if (key === "length") {
|
||||
return unref(grid.rows);
|
||||
}
|
||||
|
||||
if (typeof key !== "number" && typeof key !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
const keyNum = typeof key === "number" ? key : parseInt(key);
|
||||
if (Number.isFinite(keyNum) && keyNum < unref(grid.rows)) {
|
||||
if (!(keyNum in target)) {
|
||||
target[keyNum] = getCellRowHandler(grid, keyNum);
|
||||
}
|
||||
return target[keyNum];
|
||||
}
|
||||
},
|
||||
set(target, key, value) {
|
||||
console.warn("Cannot set grid cells", target, key, value);
|
||||
return false;
|
||||
},
|
||||
ownKeys(): string[] {
|
||||
return [...new Array(unref(grid.rows)).fill(0).map((_, i) => "" + i), "length"];
|
||||
},
|
||||
has(target, key) {
|
||||
if (key === "length") {
|
||||
return true;
|
||||
}
|
||||
if (typeof key !== "number" && typeof key !== "string") {
|
||||
return false;
|
||||
}
|
||||
const keyNum = typeof key === "number" ? key : parseInt(key);
|
||||
if (!Number.isFinite(keyNum) || keyNum >= unref(grid.rows)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
getOwnPropertyDescriptor(target, key) {
|
||||
if (typeof key !== "number" && typeof key !== "string") {
|
||||
return;
|
||||
}
|
||||
const keyNum = typeof key === "number" ? key : parseInt(key);
|
||||
if (
|
||||
key !== "length" &&
|
||||
(!Number.isFinite(keyNum) || keyNum >= unref(grid.rows))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: false
|
||||
};
|
||||
}
|
||||
}),
|
||||
rows: processGetter(rows),
|
||||
cols: processGetter(cols),
|
||||
getVisibility: convertCellMaybeRefOrGetter(getVisibility ?? true),
|
||||
getCanClick: convertCellMaybeRefOrGetter(getCanClick ?? true),
|
||||
getStartState: typeof getStartState === "function" && getStartState.length > 0 ?
|
||||
getStartState : processGetter(getStartState),
|
||||
getStyle: convertCellMaybeRefOrGetter(getStyle),
|
||||
getClasses: convertCellMaybeRefOrGetter(getClasses),
|
||||
getDisplay,
|
||||
getID: function (row: number, col: number): string {
|
||||
return grid.id + "-" + row + "-" + col;
|
||||
},
|
||||
getState: function (row: number, col: number): State {
|
||||
cellState.value[row] ??= {};
|
||||
if (cellState.value[row][col] != null) {
|
||||
return cellState.value[row][col];
|
||||
}
|
||||
return grid.cells[row][col].startState;
|
||||
},
|
||||
setState: function (row: number, col: number, state: State) {
|
||||
cellState.value[row] ??= {};
|
||||
cellState.value[row][col] = state;
|
||||
},
|
||||
onClick:
|
||||
onClick == null
|
||||
? undefined
|
||||
: function (row, col, state, e) {
|
||||
if (grid.cells[row][col].canClick !== false) {
|
||||
onClick.call(grid, row, col, state, e);
|
||||
}
|
||||
},
|
||||
onHold:
|
||||
onHold == null
|
||||
? undefined
|
||||
: function (row, col, state) {
|
||||
if (grid.cells[row][col].canClick !== false) {
|
||||
onHold.call(grid, row, col, state);
|
||||
}
|
||||
}
|
||||
} satisfies Grid;
|
||||
|
||||
return grid;
|
||||
});
|
||||
}
|
|
@ -1,22 +1,15 @@
|
|||
import Hotkey from "components/Hotkey.vue";
|
||||
import { hasWon } from "data/projEntry";
|
||||
import type { OptionsFunc, Replace } from "features/feature";
|
||||
import { findFeatures, jsx, setDefault } from "features/feature";
|
||||
import { findFeatures } from "features/feature";
|
||||
import { globalBus } from "game/events";
|
||||
import player from "game/player";
|
||||
import { registerInfoComponent } from "game/settings";
|
||||
import type {
|
||||
Computable,
|
||||
GetComputableType,
|
||||
GetComputableTypeWithDefault,
|
||||
ProcessedComputable
|
||||
} from "util/computed";
|
||||
import { processComputable } from "util/computed";
|
||||
import { processGetter } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import { shallowReactive, unref } from "vue";
|
||||
import Hotkey from "components/Hotkey.vue";
|
||||
import { MaybeRef, MaybeRefOrGetter, shallowReactive, unref } from "vue";
|
||||
|
||||
/** A dictionary of all hotkeys. */
|
||||
export const hotkeys: Record<string, GenericHotkey | undefined> = shallowReactive({});
|
||||
export const hotkeys: Record<string, Hotkey | undefined> = shallowReactive({});
|
||||
/** A symbol used to identify {@link Hotkey} features. */
|
||||
export const HotkeyType = Symbol("Hotkey");
|
||||
|
||||
|
@ -25,39 +18,28 @@ export const HotkeyType = Symbol("Hotkey");
|
|||
*/
|
||||
export interface HotkeyOptions {
|
||||
/** Whether or not this hotkey is currently enabled. */
|
||||
enabled?: Computable<boolean>;
|
||||
enabled?: MaybeRefOrGetter<boolean>;
|
||||
/** The key tied to this hotkey */
|
||||
key: string;
|
||||
/** The description of this hotkey, to display in the settings. */
|
||||
description: Computable<string>;
|
||||
description: MaybeRefOrGetter<string>;
|
||||
/** What to do upon pressing the key. */
|
||||
onPress: VoidFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
onPress: (e?: MouseEvent | TouchEvent) => void;
|
||||
}
|
||||
|
||||
/** 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>;
|
||||
}
|
||||
>;
|
||||
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;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof HotkeyType;
|
||||
}
|
||||
|
||||
const uppercaseNumbers = [")", "!", "@", "#", "$", "%", "^", "&", "*", "("];
|
||||
|
||||
|
@ -65,29 +47,32 @@ const uppercaseNumbers = [")", "!", "@", "#", "$", "%", "^", "&", "*", "("];
|
|||
* Lazily creates a hotkey with the given options.
|
||||
* @param optionsFunc Hotkey 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;
|
||||
export function createHotkey<T extends HotkeyOptions>(optionsFunc: () => T) {
|
||||
return createLazyProxy(() => {
|
||||
const options = optionsFunc();
|
||||
const { enabled, description, key, onPress, ...props } = options;
|
||||
|
||||
processComputable(hotkey as T, "enabled");
|
||||
setDefault(hotkey, "enabled", true);
|
||||
processComputable(hotkey as T, "description");
|
||||
const hotkey = {
|
||||
type: HotkeyType,
|
||||
...(props as Omit<typeof props, keyof HotkeyOptions>),
|
||||
enabled: processGetter(enabled) ?? true,
|
||||
description: processGetter(description),
|
||||
key,
|
||||
onPress
|
||||
} satisfies Hotkey;
|
||||
|
||||
return hotkey as unknown as Hotkey<T>;
|
||||
return hotkey;
|
||||
});
|
||||
}
|
||||
|
||||
globalBus.on("addLayer", layer => {
|
||||
(findFeatures(layer, HotkeyType) as GenericHotkey[]).forEach(hotkey => {
|
||||
(findFeatures(layer, HotkeyType) as Hotkey[]).forEach(hotkey => {
|
||||
hotkeys[hotkey.key] = hotkey;
|
||||
});
|
||||
});
|
||||
|
||||
globalBus.on("removeLayer", layer => {
|
||||
(findFeatures(layer, HotkeyType) as GenericHotkey[]).forEach(hotkey => {
|
||||
(findFeatures(layer, HotkeyType) as Hotkey[]).forEach(hotkey => {
|
||||
hotkeys[hotkey.key] = undefined;
|
||||
});
|
||||
});
|
||||
|
@ -99,24 +84,38 @@ document.onkeydown = function (e) {
|
|||
if (hasWon.value && !player.keepGoing) {
|
||||
return;
|
||||
}
|
||||
let key = e.key;
|
||||
if (uppercaseNumbers.includes(key)) {
|
||||
key = "shift+" + uppercaseNumbers.indexOf(key);
|
||||
const keysToCheck: string[] = [e.key];
|
||||
if (e.shiftKey && e.ctrlKey) {
|
||||
keysToCheck.splice(0, 1);
|
||||
keysToCheck.push("ctrl+shift+" + e.key.toUpperCase());
|
||||
keysToCheck.push("shift+ctrl+" + e.key.toUpperCase());
|
||||
if (uppercaseNumbers.includes(e.key)) {
|
||||
keysToCheck.push("ctrl+shift+" + uppercaseNumbers.indexOf(e.key));
|
||||
keysToCheck.push("shift+ctrl+" + uppercaseNumbers.indexOf(e.key));
|
||||
} else {
|
||||
keysToCheck.push("ctrl+shift+" + e.key.toLowerCase());
|
||||
keysToCheck.push("shift+ctrl+" + e.key.toLowerCase());
|
||||
}
|
||||
} else if (uppercaseNumbers.includes(e.key)) {
|
||||
keysToCheck.push("shift+" + e.key);
|
||||
keysToCheck.push("shift+" + uppercaseNumbers.indexOf(e.key));
|
||||
} else if (e.shiftKey) {
|
||||
key = "shift+" + key;
|
||||
keysToCheck.push("shift+" + e.key.toUpperCase());
|
||||
keysToCheck.push("shift+" + e.key.toLowerCase());
|
||||
} else if (e.ctrlKey) {
|
||||
// remove e.key since the key doesn't change based on ctrl being held or not
|
||||
keysToCheck.splice(0, 1);
|
||||
keysToCheck.push("ctrl+" + e.key);
|
||||
}
|
||||
if (e.ctrlKey) {
|
||||
key = "ctrl+" + key;
|
||||
}
|
||||
const hotkey = hotkeys[key];
|
||||
if (hotkey && unref(hotkey.enabled)) {
|
||||
const hotkey = hotkeys[keysToCheck.find(key => key in hotkeys) ?? ""];
|
||||
if (hotkey != null && unref(hotkey.enabled) !== false) {
|
||||
e.preventDefault();
|
||||
hotkey.onPress();
|
||||
}
|
||||
};
|
||||
|
||||
registerInfoComponent(
|
||||
jsx(() => {
|
||||
globalBus.on("setupVue", () =>
|
||||
registerInfoComponent(() => {
|
||||
const keys = Object.values(hotkeys).filter(hotkey => unref(hotkey?.enabled));
|
||||
if (keys.length === 0) {
|
||||
return "";
|
||||
|
@ -128,7 +127,7 @@ registerInfoComponent(
|
|||
<div style="column-count: 2">
|
||||
{keys.map(hotkey => (
|
||||
<div>
|
||||
<Hotkey hotkey={hotkey as GenericHotkey} /> {hotkey?.description}
|
||||
<Hotkey hotkey={hotkey as Hotkey} /> {unref(hotkey?.description)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
@ -1,15 +1,10 @@
|
|||
<template>
|
||||
<div
|
||||
class="infobox"
|
||||
v-if="isVisible(visibility)"
|
||||
:style="[
|
||||
{
|
||||
:style="{
|
||||
borderColor: unref(color),
|
||||
visibility: isHidden(visibility) ? 'hidden' : undefined
|
||||
},
|
||||
unref(style) ?? {}
|
||||
]"
|
||||
:class="{ collapsed: unref(collapsed), stacked, ...unref(classes) }"
|
||||
}"
|
||||
:class="{ collapsed: unref(collapsed), stacked }"
|
||||
>
|
||||
<button
|
||||
class="title"
|
||||
|
@ -17,78 +12,37 @@
|
|||
@click="collapsed.value = !unref(collapsed)"
|
||||
>
|
||||
<span class="toggle">▼</span>
|
||||
<component :is="titleComponent" />
|
||||
<Title />
|
||||
</button>
|
||||
<CollapseTransition>
|
||||
<div v-if="!unref(collapsed)" class="body" :style="{ backgroundColor: unref(color) }">
|
||||
<component :is="bodyComponent" :style="unref(bodyStyle)" />
|
||||
<div v-if="!unref(collapsed)" class="body" :style="unref(bodyStyle)">
|
||||
<Body />
|
||||
</div>
|
||||
</CollapseTransition>
|
||||
<Node :id="id" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup 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 { computeComponent, processedPropType } from "util/vue";
|
||||
import type { PropType, Ref, StyleValue } from "vue";
|
||||
import { computed, defineComponent, toRefs, unref } from "vue";
|
||||
import { render } from "util/vue";
|
||||
import { computed, unref } from "vue";
|
||||
import { Infobox } from "./infobox";
|
||||
|
||||
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 props = defineProps<{
|
||||
color: Infobox["color"];
|
||||
titleStyle: Infobox["titleStyle"];
|
||||
bodyStyle: Infobox["bodyStyle"];
|
||||
collapsed: Infobox["collapsed"];
|
||||
display: Infobox["display"];
|
||||
title: Infobox["title"];
|
||||
}>();
|
||||
|
||||
const titleComponent = computeComponent(title);
|
||||
const bodyComponent = computeComponent(display);
|
||||
const stacked = computed(() => themes[settings.theme].mergeAdjacent);
|
||||
const Title = () => render(props.title);
|
||||
const Body = () => render(props.display);
|
||||
|
||||
return {
|
||||
titleComponent,
|
||||
bodyComponent,
|
||||
stacked,
|
||||
unref,
|
||||
Visibility,
|
||||
isVisible,
|
||||
isHidden
|
||||
};
|
||||
}
|
||||
});
|
||||
const stacked = computed(() => themes[settings.theme].mergeAdjacent);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
@ -125,6 +79,8 @@ export default defineComponent({
|
|||
width: auto;
|
||||
text-align: left;
|
||||
padding-left: 30px;
|
||||
border-radius: 0;
|
||||
margin: 00;
|
||||
}
|
||||
|
||||
.infobox:not(.stacked) .title {
|
||||
|
@ -163,21 +119,15 @@ export default defineComponent({
|
|||
|
||||
.body {
|
||||
transition-duration: 0.5s;
|
||||
border-radius: 5px;
|
||||
border-top-left-radius: 0;
|
||||
padding: 8px;
|
||||
width: 100%;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--background);
|
||||
border-radius: 0 0 var(--feature-margin) var(--feature-margin);
|
||||
}
|
||||
|
||||
.infobox:not(.stacked) .body {
|
||||
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>
|
||||
|
|
|
@ -1,141 +0,0 @@
|
|||
import type {
|
||||
CoercableComponent,
|
||||
GenericComponent,
|
||||
OptionsFunc,
|
||||
Replace,
|
||||
StyleValue
|
||||
} from "features/feature";
|
||||
import { Component, GatherProps, getUniqueID, setDefault, Visibility } from "features/feature";
|
||||
import InfoboxComponent from "features/infoboxes/Infobox.vue";
|
||||
import type { Persistent } from "game/persistence";
|
||||
import { persistent } from "game/persistence";
|
||||
import type {
|
||||
Computable,
|
||||
GetComputableType,
|
||||
GetComputableTypeWithDefault,
|
||||
ProcessedComputable
|
||||
} from "util/computed";
|
||||
import { processComputable } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import { unref } from "vue";
|
||||
|
||||
/** A symbol used to identify {@link Infobox} features. */
|
||||
export const InfoboxType = Symbol("Infobox");
|
||||
|
||||
/**
|
||||
* An object that configures an {@link Infobox}.
|
||||
*/
|
||||
export interface InfoboxOptions {
|
||||
/** Whether this clickable should be visible. */
|
||||
visibility?: Computable<Visibility | boolean>;
|
||||
/** The background color of the Infobox. */
|
||||
color?: Computable<string>;
|
||||
/** CSS to apply to this feature. */
|
||||
style?: Computable<StyleValue>;
|
||||
/** CSS to apply to the title of the infobox. */
|
||||
titleStyle?: Computable<StyleValue>;
|
||||
/** CSS to apply to the body of the infobox. */
|
||||
bodyStyle?: Computable<StyleValue>;
|
||||
/** Dictionary of CSS classes to apply to this feature. */
|
||||
classes?: Computable<Record<string, boolean>>;
|
||||
/** A header to appear at the top of the display. */
|
||||
title: Computable<CoercableComponent>;
|
||||
/** The main text that appears in the display. */
|
||||
display: Computable<CoercableComponent>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The properties that are added onto a processed {@link InfoboxOptions} to create an {@link Infobox}.
|
||||
*/
|
||||
export interface BaseInfobox {
|
||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
||||
id: string;
|
||||
/** Whether or not this infobox is collapsed. */
|
||||
collapsed: Persistent<boolean>;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof InfoboxType;
|
||||
/** The Vue component used to render this feature. */
|
||||
[Component]: GenericComponent;
|
||||
/** A function to gather the props the vue component requires for this feature. */
|
||||
[GatherProps]: () => Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** An object that represents a feature that displays information in a collapsible way. */
|
||||
export type Infobox<T extends InfoboxOptions> = Replace<
|
||||
T & BaseInfobox,
|
||||
{
|
||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||
color: GetComputableType<T["color"]>;
|
||||
style: GetComputableType<T["style"]>;
|
||||
titleStyle: GetComputableType<T["titleStyle"]>;
|
||||
bodyStyle: GetComputableType<T["bodyStyle"]>;
|
||||
classes: GetComputableType<T["classes"]>;
|
||||
title: GetComputableType<T["title"]>;
|
||||
display: GetComputableType<T["display"]>;
|
||||
}
|
||||
>;
|
||||
|
||||
/** A type that matches any valid {@link Infobox} object. */
|
||||
export type GenericInfobox = Replace<
|
||||
Infobox<InfoboxOptions>,
|
||||
{
|
||||
visibility: ProcessedComputable<Visibility | boolean>;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* Lazily creates an infobox with the given options.
|
||||
* @param optionsFunc Infobox options.
|
||||
*/
|
||||
export function createInfobox<T extends InfoboxOptions>(
|
||||
optionsFunc: OptionsFunc<T, BaseInfobox, GenericInfobox>
|
||||
): Infobox<T> {
|
||||
const collapsed = persistent<boolean>(false, false);
|
||||
return createLazyProxy(feature => {
|
||||
const infobox = optionsFunc.call(feature, feature);
|
||||
infobox.id = getUniqueID("infobox-");
|
||||
infobox.type = InfoboxType;
|
||||
infobox[Component] = InfoboxComponent as GenericComponent;
|
||||
|
||||
infobox.collapsed = collapsed;
|
||||
|
||||
processComputable(infobox as T, "visibility");
|
||||
setDefault(infobox, "visibility", Visibility.Visible);
|
||||
processComputable(infobox as T, "color");
|
||||
processComputable(infobox as T, "style");
|
||||
processComputable(infobox as T, "titleStyle");
|
||||
processComputable(infobox as T, "bodyStyle");
|
||||
processComputable(infobox as T, "classes");
|
||||
processComputable(infobox as T, "title");
|
||||
processComputable(infobox as T, "display");
|
||||
|
||||
infobox[GatherProps] = function (this: GenericInfobox) {
|
||||
const {
|
||||
visibility,
|
||||
display,
|
||||
title,
|
||||
color,
|
||||
collapsed,
|
||||
style,
|
||||
titleStyle,
|
||||
bodyStyle,
|
||||
classes,
|
||||
id
|
||||
} = this;
|
||||
return {
|
||||
visibility,
|
||||
display,
|
||||
title,
|
||||
color,
|
||||
collapsed,
|
||||
style: unref(style),
|
||||
titleStyle,
|
||||
bodyStyle,
|
||||
classes,
|
||||
id
|
||||
};
|
||||
};
|
||||
|
||||
return infobox as unknown as Infobox<T>;
|
||||
});
|
||||
}
|
79
src/features/infoboxes/infobox.tsx
Normal file
79
src/features/infoboxes/infobox.tsx
Normal file
|
@ -0,0 +1,79 @@
|
|||
import Infobox from "features/infoboxes/Infobox.vue";
|
||||
import type { Persistent } from "game/persistence";
|
||||
import { persistent } from "game/persistence";
|
||||
import { processGetter } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import { Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
|
||||
import { CSSProperties, MaybeRef, MaybeRefOrGetter } from "vue";
|
||||
|
||||
/** A symbol used to identify {@link Infobox} features. */
|
||||
export const InfoboxType = Symbol("Infobox");
|
||||
|
||||
/**
|
||||
* An object that configures an {@link Infobox}.
|
||||
*/
|
||||
export interface InfoboxOptions extends VueFeatureOptions {
|
||||
/** The background color of the Infobox. Defaults to the layer color. */
|
||||
color?: MaybeRefOrGetter<string>;
|
||||
/** CSS to apply to the title of the infobox. */
|
||||
titleStyle?: MaybeRefOrGetter<CSSProperties>;
|
||||
/** CSS to apply to the body of the infobox. */
|
||||
bodyStyle?: MaybeRefOrGetter<CSSProperties>;
|
||||
/** A header to appear at the top of the display. */
|
||||
title: MaybeRefOrGetter<Renderable>;
|
||||
/** The main text that appears in the display. */
|
||||
display: MaybeRefOrGetter<Renderable>;
|
||||
}
|
||||
|
||||
/** An object that represents a feature that displays information in a collapsible way. */
|
||||
export interface Infobox extends VueFeature {
|
||||
/** The background color of the Infobox. */
|
||||
color?: MaybeRef<string>;
|
||||
/** CSS to apply to the title of the infobox. */
|
||||
titleStyle?: MaybeRef<CSSProperties>;
|
||||
/** CSS to apply to the body of the infobox. */
|
||||
bodyStyle?: MaybeRef<CSSProperties>;
|
||||
/** A header to appear at the top of the display. */
|
||||
title: MaybeRef<Renderable>;
|
||||
/** The main text that appears in the display. */
|
||||
display: MaybeRef<Renderable>;
|
||||
/** Whether or not this infobox is collapsed. */
|
||||
collapsed: Persistent<boolean>;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof InfoboxType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily creates an infobox with the given options.
|
||||
* @param optionsFunc Infobox options.
|
||||
*/
|
||||
export function createInfobox<T extends InfoboxOptions>(optionsFunc: () => T) {
|
||||
const collapsed = persistent<boolean>(false, false);
|
||||
return createLazyProxy(() => {
|
||||
const options = optionsFunc();
|
||||
const { color, titleStyle, bodyStyle, title, display, ...props } = options;
|
||||
|
||||
const infobox = {
|
||||
type: InfoboxType,
|
||||
...(props as Omit<typeof props, keyof VueFeature | keyof InfoboxOptions>),
|
||||
...vueFeatureMixin("infobox", options, () => (
|
||||
<Infobox
|
||||
color={infobox.color}
|
||||
titleStyle={infobox.titleStyle}
|
||||
bodyStyle={infobox.bodyStyle}
|
||||
collapsed={infobox.collapsed}
|
||||
title={infobox.title}
|
||||
display={infobox.display}
|
||||
/>
|
||||
)),
|
||||
collapsed,
|
||||
color: processGetter(color) ?? "--layer-color",
|
||||
titleStyle: processGetter(titleStyle),
|
||||
bodyStyle: processGetter(bodyStyle),
|
||||
title: processGetter(title),
|
||||
display: processGetter(display)
|
||||
} satisfies Infobox;
|
||||
|
||||
return infobox;
|
||||
});
|
||||
}
|
|
@ -14,47 +14,46 @@
|
|||
import type { Link } from "features/links/links";
|
||||
import type { FeatureNode } from "game/layers";
|
||||
import { kebabifyObject } from "util/vue";
|
||||
import { computed, toRefs } from "vue";
|
||||
import { computed } 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.value.rect;
|
||||
const boundingRect = props.boundingRect.value;
|
||||
const rect = props.startNode.rect;
|
||||
const boundingRect = props.boundingRect;
|
||||
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.value.offsetStart) {
|
||||
position.x += props.link.value.offsetStart.x;
|
||||
position.y += props.link.value.offsetStart.y;
|
||||
if (props.link.offsetStart) {
|
||||
position.x += props.link.offsetStart.x;
|
||||
position.y += props.link.offsetStart.y;
|
||||
}
|
||||
return position;
|
||||
});
|
||||
|
||||
const endPosition = computed(() => {
|
||||
const rect = props.endNode.value.rect;
|
||||
const boundingRect = props.boundingRect.value;
|
||||
const rect = props.endNode.rect;
|
||||
const boundingRect = props.boundingRect;
|
||||
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.value.offsetEnd) {
|
||||
position.x += props.link.value.offsetEnd.x;
|
||||
position.y += props.link.value.offsetEnd.y;
|
||||
if (props.link.offsetEnd) {
|
||||
position.x += props.link.offsetEnd.x;
|
||||
position.y += props.link.offsetEnd.y;
|
||||
}
|
||||
return position;
|
||||
});
|
||||
|
||||
const linkProps = computed(() => kebabifyObject(_props.link as unknown as Record<string, unknown>));
|
||||
const linkProps = computed(() => kebabifyObject(props.link as unknown as Record<string, unknown>));
|
||||
</script>
|
||||
|
|
|
@ -13,52 +13,51 @@
|
|||
</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, onMounted, ref, toRef, watch } from "vue";
|
||||
import { computed, inject, onMounted, ref, shallowRef, unref, watch } from "vue";
|
||||
import LinkVue from "./Link.vue";
|
||||
import { Links } from "./links";
|
||||
|
||||
const _props = defineProps<{ links?: Link[] }>();
|
||||
const links = toRef(_props, "links");
|
||||
const props = defineProps<{ links: Links["links"] }>();
|
||||
|
||||
const resizeListener = ref<Element | null>(null);
|
||||
function updateBounds() {
|
||||
boundingRect.value = resizeListener.value?.getBoundingClientRect();
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateBounds);
|
||||
const resizeListener = shallowRef<HTMLElement | null>(null);
|
||||
|
||||
const nodes = inject(NodesInjectionKey, ref<Record<string, FeatureNode | undefined>>({}));
|
||||
const outerBoundingRect = inject(BoundsInjectionKey, ref<DOMRect | undefined>(undefined));
|
||||
const boundingRect = ref<DOMRect | undefined>(resizeListener.value?.getBoundingClientRect());
|
||||
watch(
|
||||
outerBoundingRect,
|
||||
() => (boundingRect.value = resizeListener.value?.getBoundingClientRect())
|
||||
);
|
||||
onMounted(() => (boundingRect.value = resizeListener.value?.getBoundingClientRect()));
|
||||
watch(outerBoundingRect, updateBounds);
|
||||
onMounted(() => {
|
||||
const resListener = resizeListener.value;
|
||||
if (resListener != null) {
|
||||
resizeObserver.observe(resListener);
|
||||
}
|
||||
updateBounds();
|
||||
});
|
||||
|
||||
const validLinks = computed(() => {
|
||||
const n = nodes.value;
|
||||
return (
|
||||
links.value?.filter(link => n[link.startNode.id]?.rect && n[link.startNode.id]?.rect) ?? []
|
||||
unref(props.links)?.filter(link =>
|
||||
n[link.startNode.id]?.rect && n[link.endNode.id]?.rect) ?? []
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.resize-listener {
|
||||
.resize-listener, svg {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: -4px;
|
||||
bottom: 5px;
|
||||
z-index: -10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
z-index: -10;
|
||||
pointer-events: none;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,78 +0,0 @@
|
|||
import type { GenericComponent, OptionsFunc, Replace } from "features/feature";
|
||||
import { GatherProps, Component } from "features/feature";
|
||||
import type { Position } from "game/layers";
|
||||
import type { Computable, GetComputableType, ProcessedComputable } from "util/computed";
|
||||
import { processComputable } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import type { SVGAttributes } from "vue";
|
||||
import LinksComponent from "./Links.vue";
|
||||
|
||||
/** A symbol used to identify {@link Links} features. */
|
||||
export const LinksType = Symbol("Links");
|
||||
|
||||
/** Represents a link between two nodes. It will be displayed as an SVG line, and can take any appropriate properties for an SVG line element. */
|
||||
export interface Link extends SVGAttributes {
|
||||
startNode: { id: string };
|
||||
endNode: { id: string };
|
||||
offsetStart?: Position;
|
||||
offsetEnd?: Position;
|
||||
}
|
||||
|
||||
/** An object that configures a {@link Links}. */
|
||||
export interface LinksOptions {
|
||||
/** The list of links to display. */
|
||||
links: Computable<Link[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The properties that are added onto a processed {@link LinksOptions} to create an {@link Links}.
|
||||
*/
|
||||
export interface BaseLinks {
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof LinksType;
|
||||
/** The Vue component used to render this feature. */
|
||||
[Component]: GenericComponent;
|
||||
/** A function to gather the props the vue component requires for this feature. */
|
||||
[GatherProps]: () => Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** An object that represents a list of links between nodes, which are the elements in the DOM for any renderable feature. */
|
||||
export type Links<T extends LinksOptions> = Replace<
|
||||
T & BaseLinks,
|
||||
{
|
||||
links: GetComputableType<T["links"]>;
|
||||
}
|
||||
>;
|
||||
|
||||
/** A type that matches any valid {@link Links} object. */
|
||||
export type GenericLinks = Replace<
|
||||
Links<LinksOptions>,
|
||||
{
|
||||
links: ProcessedComputable<Link[]>;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* Lazily creates links with the given options.
|
||||
* @param optionsFunc Links options.
|
||||
*/
|
||||
export function createLinks<T extends LinksOptions>(
|
||||
optionsFunc: OptionsFunc<T, BaseLinks, GenericLinks>
|
||||
): Links<T> {
|
||||
return createLazyProxy(feature => {
|
||||
const links = optionsFunc.call(feature, feature);
|
||||
links.type = LinksType;
|
||||
links[Component] = LinksComponent as GenericComponent;
|
||||
|
||||
processComputable(links as T, "links");
|
||||
|
||||
links[GatherProps] = function (this: GenericLinks) {
|
||||
const { links } = this;
|
||||
return {
|
||||
links
|
||||
};
|
||||
};
|
||||
|
||||
return links as unknown as Links<T>;
|
||||
});
|
||||
}
|
54
src/features/links/links.tsx
Normal file
54
src/features/links/links.tsx
Normal file
|
@ -0,0 +1,54 @@
|
|||
import type { Position } from "game/layers";
|
||||
import { processGetter } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import { VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
|
||||
import { unref, type MaybeRef, type MaybeRefOrGetter, type SVGAttributes } from "vue";
|
||||
import Links from "./Links.vue";
|
||||
|
||||
/** A symbol used to identify {@link Links} features. */
|
||||
export const LinksType = Symbol("Links");
|
||||
|
||||
/** Represents a link between two nodes. It will be displayed as an SVG line, and can take any appropriate properties for an SVG line element. */
|
||||
export interface Link extends /* @vue-ignore */ SVGAttributes {
|
||||
startNode: { id: string };
|
||||
endNode: { id: string };
|
||||
offsetStart?: Position;
|
||||
offsetEnd?: Position;
|
||||
}
|
||||
|
||||
/** An object that configures a {@link Links}. */
|
||||
export interface LinksOptions extends VueFeatureOptions {
|
||||
/** The list of links to display. */
|
||||
links: MaybeRefOrGetter<Link[]>;
|
||||
}
|
||||
|
||||
/** An object that represents a list of links between nodes, which are the elements in the DOM for any renderable feature. */
|
||||
export interface Links extends VueFeature {
|
||||
/** The list of links to display. */
|
||||
links: MaybeRef<Link[]>;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof LinksType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily creates links with the given options.
|
||||
* @param optionsFunc Links options.
|
||||
*/
|
||||
export function createLinks<T extends LinksOptions>(optionsFunc: () => T) {
|
||||
return createLazyProxy(() => {
|
||||
const options = optionsFunc?.();
|
||||
const { links, style: _style, ...props } = options;
|
||||
|
||||
const style = processGetter(_style);
|
||||
options.style = () => ({ position: "static", ...(unref(style) ?? {}) });
|
||||
|
||||
const retLinks = {
|
||||
type: LinksType,
|
||||
...(props as Omit<typeof props, keyof VueFeature | keyof LinksOptions>),
|
||||
...vueFeatureMixin("links", options, () => <Links links={retLinks.links} />),
|
||||
links: processGetter(links)
|
||||
} satisfies Links;
|
||||
|
||||
return retLinks;
|
||||
});
|
||||
}
|
|
@ -2,83 +2,60 @@
|
|||
<div
|
||||
ref="resizeListener"
|
||||
class="resize-listener"
|
||||
:style="unref(style)"
|
||||
:class="unref(classes)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="tsx">
|
||||
<script setup lang="tsx">
|
||||
import { Application } from "@pixi/app";
|
||||
import type { StyleValue } from "features/feature";
|
||||
import { globalBus } from "game/events";
|
||||
import "lib/pixi";
|
||||
import { processedPropType } from "util/vue";
|
||||
import type { PropType } from "vue";
|
||||
import { defineComponent, nextTick, onBeforeUnmount, onMounted, shallowRef, unref } from "vue";
|
||||
import { nextTick, onBeforeUnmount, onMounted, shallowRef, unref } from "vue";
|
||||
import type { Particles } from "./particles";
|
||||
|
||||
// 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 props = defineProps<{
|
||||
onContainerResized: Particles["onContainerResized"];
|
||||
onHotReload: Particles["onHotReload"];
|
||||
onInit: (app: Application) => void;
|
||||
}>();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateBounds);
|
||||
const resizeListener = shallowRef<HTMLElement | null>(null);
|
||||
const app = shallowRef<null | Application>(null);
|
||||
|
||||
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);
|
||||
}
|
||||
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
|
||||
});
|
||||
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
|
||||
};
|
||||
resizeListener.value?.appendChild(app.value.view);
|
||||
props.onInit(app.value);
|
||||
}
|
||||
updateBounds();
|
||||
if (props.onHotReload) {
|
||||
nextTick(props.onHotReload);
|
||||
}
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
app.value?.destroy();
|
||||
});
|
||||
|
||||
let isDirty = true;
|
||||
function updateBounds() {
|
||||
if (isDirty) {
|
||||
isDirty = false;
|
||||
nextTick(() => {
|
||||
if (resizeListener.value != null) {
|
||||
props.onContainerResized?.(resizeListener.value.getBoundingClientRect());
|
||||
}
|
||||
isDirty = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
globalBus.on("fontsLoaded", updateBounds);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
@ -1,12 +1,10 @@
|
|||
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 { Ref, shallowRef, unref } from "vue";
|
||||
import { VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
|
||||
import { Ref, shallowRef } from "vue";
|
||||
import Particles from "./Particles.vue";
|
||||
|
||||
/** A symbol used to identify {@link Particles} features. */
|
||||
export const ParticlesType = Symbol("Particles");
|
||||
|
@ -14,11 +12,7 @@ export const ParticlesType = Symbol("Particles");
|
|||
/**
|
||||
* An object that configures {@link Particles}.
|
||||
*/
|
||||
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>;
|
||||
export interface ParticlesOptions extends VueFeatureOptions {
|
||||
/** 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. */
|
||||
|
@ -26,11 +20,14 @@ export interface ParticlesOptions {
|
|||
}
|
||||
|
||||
/**
|
||||
* The properties that are added onto a processed {@link ParticlesOptions} to create an {@link Particles}.
|
||||
* An object that represents a feature that display particle effects on the screen.
|
||||
* The config should typically be gotten by designing the effect using the [online particle effect editor](https://pixijs.io/pixi-particles-editor/) and passing it into the {@link upgradeConfig} from @pixi/particle-emitter.
|
||||
*/
|
||||
export interface BaseParticles {
|
||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
||||
id: string;
|
||||
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;
|
||||
/** The Pixi.JS Application powering this particles canvas. */
|
||||
app: Ref<null | Application>;
|
||||
/**
|
||||
|
@ -41,52 +38,16 @@ export interface BaseParticles {
|
|||
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?: 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;
|
||||
|
||||
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 });
|
||||
});
|
||||
};
|
||||
export function createParticles<T extends ParticlesOptions>(optionsFunc?: () => T) {
|
||||
return createLazyProxy(() => {
|
||||
const options = optionsFunc?.() ?? ({} as T);
|
||||
const { onContainerResized, onHotReload, ...props } = options;
|
||||
|
||||
let emittersToAdd: {
|
||||
resolve: (value: Emitter | PromiseLike<Emitter>) => void;
|
||||
|
@ -94,27 +55,34 @@ export function createParticles<T extends ParticlesOptions>(
|
|||
}[] = [];
|
||||
|
||||
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.onContainerResized = particles.onContainerResized?.bind(particles);
|
||||
const particles = {
|
||||
type: ParticlesType,
|
||||
...(props as Omit<typeof props, keyof VueFeature | keyof ParticlesOptions>),
|
||||
...vueFeatureMixin("particles", options, () => (
|
||||
<Particles
|
||||
onInit={onInit}
|
||||
onContainerResized={particles.onContainerResized}
|
||||
onHotReload={particles.onHotReload}
|
||||
/>
|
||||
)),
|
||||
app: shallowRef<null | Application>(null),
|
||||
onContainerResized,
|
||||
onHotReload,
|
||||
addEmitter: (config: EmitterConfigV3): Promise<Emitter> => {
|
||||
if (particles.app.value != null) {
|
||||
return Promise.resolve(new Emitter(particles.app.value.stage, config));
|
||||
}
|
||||
return new Promise<Emitter>(resolve => {
|
||||
emittersToAdd.push({ resolve, config });
|
||||
});
|
||||
}
|
||||
} satisfies Particles;
|
||||
|
||||
particles[GatherProps] = function (this: GenericParticles) {
|
||||
const { id, style, classes, onContainerResized, onHotReload } = this;
|
||||
return {
|
||||
id,
|
||||
style: unref(style),
|
||||
classes,
|
||||
onContainerResized,
|
||||
onHotReload,
|
||||
onInit
|
||||
};
|
||||
};
|
||||
|
||||
return particles as unknown as Particles<T>;
|
||||
return particles;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -1,299 +0,0 @@
|
|||
import { isArray } from "@vue/shared";
|
||||
import ClickableComponent from "features/clickables/Clickable.vue";
|
||||
import type {
|
||||
CoercableComponent,
|
||||
GenericComponent,
|
||||
OptionsFunc,
|
||||
Replace,
|
||||
StyleValue
|
||||
} from "features/feature";
|
||||
import { Component, GatherProps, Visibility, getUniqueID, jsx, setDefault } from "features/feature";
|
||||
import { DefaultValue, Persistent, persistent } from "game/persistence";
|
||||
import {
|
||||
Requirements,
|
||||
createVisibilityRequirement,
|
||||
displayRequirements,
|
||||
maxRequirementsMet,
|
||||
payRequirements,
|
||||
requirementsMet
|
||||
} from "game/requirements";
|
||||
import type { DecimalSource } from "util/bignum";
|
||||
import Decimal, { formatWhole } from "util/bignum";
|
||||
import type {
|
||||
Computable,
|
||||
GetComputableType,
|
||||
GetComputableTypeWithDefault,
|
||||
ProcessedComputable
|
||||
} from "util/computed";
|
||||
import { processComputable } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import { coerceComponent, isCoercableComponent } from "util/vue";
|
||||
import type { Ref } from "vue";
|
||||
import { computed, unref } from "vue";
|
||||
import { GenericDecorator } from "./decorators/common";
|
||||
|
||||
/** A symbol used to identify {@link Repeatable} features. */
|
||||
export const RepeatableType = Symbol("Repeatable");
|
||||
|
||||
/** A type that can be used to customize the {@link Repeatable} display. */
|
||||
export type RepeatableDisplay =
|
||||
| CoercableComponent
|
||||
| {
|
||||
/** A header to appear at the top of the display. */
|
||||
title?: CoercableComponent;
|
||||
/** The main text that appears in the display. */
|
||||
description?: CoercableComponent;
|
||||
/** A description of the current effect of this repeatable, based off its amount. */
|
||||
effectDisplay?: CoercableComponent;
|
||||
/** Whether or not to show the current amount of this repeatable at the bottom of the display. */
|
||||
showAmount?: boolean;
|
||||
};
|
||||
|
||||
/** An object that configures a {@link Repeatable}. */
|
||||
export interface RepeatableOptions {
|
||||
/** Whether this repeatable should be visible. */
|
||||
visibility?: Computable<Visibility | boolean>;
|
||||
/** The requirement(s) to increase this repeatable. */
|
||||
requirements: Requirements;
|
||||
/** The maximum amount obtainable for this repeatable. */
|
||||
limit?: Computable<DecimalSource>;
|
||||
/** The initial amount this repeatable has on a new save / after reset. */
|
||||
initialAmount?: DecimalSource;
|
||||
/** Dictionary of CSS classes to apply to this feature. */
|
||||
classes?: Computable<Record<string, boolean>>;
|
||||
/** CSS to apply to this feature. */
|
||||
style?: Computable<StyleValue>;
|
||||
/** Shows a marker on the corner of the feature. */
|
||||
mark?: Computable<boolean | string>;
|
||||
/** Toggles a smaller design for the feature. */
|
||||
small?: Computable<boolean>;
|
||||
/** The display to use for this repeatable. */
|
||||
display?: Computable<RepeatableDisplay>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The properties that are added onto a processed {@link RepeatableOptions} to create a {@link Repeatable}.
|
||||
*/
|
||||
export interface BaseRepeatable {
|
||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
||||
id: string;
|
||||
/** The current amount this repeatable has. */
|
||||
amount: Persistent<DecimalSource>;
|
||||
/** Whether or not this repeatable's amount is at it's limit. */
|
||||
maxed: Ref<boolean>;
|
||||
/** Whether or not this repeatable can be clicked. */
|
||||
canClick: ProcessedComputable<boolean>;
|
||||
/**
|
||||
* How much amount can be increased by, or 1 if unclickable.
|
||||
**/
|
||||
amountToIncrease: Ref<DecimalSource>;
|
||||
/** A function that gets called when this repeatable is clicked. */
|
||||
onClick: (event?: MouseEvent | TouchEvent) => void;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof RepeatableType;
|
||||
/** The Vue component used to render this feature. */
|
||||
[Component]: GenericComponent;
|
||||
/** A function to gather the props the vue component requires for this feature. */
|
||||
[GatherProps]: () => Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** An object that represents a feature with multiple "levels" with scaling requirements. */
|
||||
export type Repeatable<T extends RepeatableOptions> = Replace<
|
||||
T & BaseRepeatable,
|
||||
{
|
||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||
requirements: GetComputableType<T["requirements"]>;
|
||||
limit: GetComputableTypeWithDefault<T["limit"], Decimal>;
|
||||
classes: GetComputableType<T["classes"]>;
|
||||
style: GetComputableType<T["style"]>;
|
||||
mark: GetComputableType<T["mark"]>;
|
||||
small: GetComputableType<T["small"]>;
|
||||
display: Ref<CoercableComponent>;
|
||||
}
|
||||
>;
|
||||
|
||||
/** A type that matches any valid {@link Repeatable} object. */
|
||||
export type GenericRepeatable = Replace<
|
||||
Repeatable<RepeatableOptions>,
|
||||
{
|
||||
visibility: ProcessedComputable<Visibility | boolean>;
|
||||
limit: ProcessedComputable<DecimalSource>;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* Lazily creates a repeatable with the given options.
|
||||
* @param optionsFunc Repeatable options.
|
||||
*/
|
||||
export function createRepeatable<T extends RepeatableOptions>(
|
||||
optionsFunc: OptionsFunc<T, BaseRepeatable, GenericRepeatable>,
|
||||
...decorators: GenericDecorator[]
|
||||
): Repeatable<T> {
|
||||
const amount = persistent<DecimalSource>(0);
|
||||
const decoratedData = decorators.reduce(
|
||||
(current, next) => Object.assign(current, next.getPersistentData?.()),
|
||||
{}
|
||||
);
|
||||
return createLazyProxy<Repeatable<T>, Repeatable<T>>(feature => {
|
||||
const repeatable = optionsFunc.call(feature, feature);
|
||||
|
||||
repeatable.id = getUniqueID("repeatable-");
|
||||
repeatable.type = RepeatableType;
|
||||
repeatable[Component] = ClickableComponent as GenericComponent;
|
||||
|
||||
for (const decorator of decorators) {
|
||||
decorator.preConstruct?.(repeatable);
|
||||
}
|
||||
|
||||
repeatable.amount = amount;
|
||||
repeatable.amount[DefaultValue] = repeatable.initialAmount ?? 0;
|
||||
|
||||
Object.assign(repeatable, decoratedData);
|
||||
|
||||
const limitRequirement = {
|
||||
requirementMet: computed(() =>
|
||||
Decimal.sub(
|
||||
unref((repeatable as GenericRepeatable).limit),
|
||||
(repeatable as GenericRepeatable).amount.value
|
||||
)
|
||||
),
|
||||
requiresPay: false,
|
||||
visibility: Visibility.None,
|
||||
canMaximize: true
|
||||
} as const;
|
||||
const visibilityRequirement = createVisibilityRequirement(repeatable as GenericRepeatable);
|
||||
if (isArray(repeatable.requirements)) {
|
||||
repeatable.requirements.unshift(visibilityRequirement);
|
||||
repeatable.requirements.push(limitRequirement);
|
||||
} else {
|
||||
repeatable.requirements = [
|
||||
visibilityRequirement,
|
||||
repeatable.requirements,
|
||||
limitRequirement
|
||||
];
|
||||
}
|
||||
|
||||
repeatable.maxed = computed(() =>
|
||||
Decimal.gte(
|
||||
(repeatable as GenericRepeatable).amount.value,
|
||||
unref((repeatable as GenericRepeatable).limit)
|
||||
)
|
||||
);
|
||||
processComputable(repeatable as T, "classes");
|
||||
const classes = repeatable.classes as
|
||||
| ProcessedComputable<Record<string, boolean>>
|
||||
| undefined;
|
||||
repeatable.classes = computed(() => {
|
||||
const currClasses = unref(classes) || {};
|
||||
if ((repeatable as GenericRepeatable).maxed.value) {
|
||||
currClasses.bought = true;
|
||||
}
|
||||
return currClasses;
|
||||
});
|
||||
repeatable.amountToIncrease = computed(() =>
|
||||
Decimal.clampMin(maxRequirementsMet(repeatable.requirements), 1)
|
||||
);
|
||||
repeatable.canClick = computed(() => requirementsMet(repeatable.requirements));
|
||||
const onClick = repeatable.onClick;
|
||||
repeatable.onClick = function (this: GenericRepeatable, event?: MouseEvent | TouchEvent) {
|
||||
const genericRepeatable = repeatable as GenericRepeatable;
|
||||
if (!unref(genericRepeatable.canClick)) {
|
||||
return;
|
||||
}
|
||||
const amountToIncrease = unref(repeatable.amountToIncrease) ?? 1;
|
||||
payRequirements(repeatable.requirements, amountToIncrease);
|
||||
genericRepeatable.amount.value = Decimal.add(
|
||||
genericRepeatable.amount.value,
|
||||
amountToIncrease
|
||||
);
|
||||
onClick?.(event);
|
||||
};
|
||||
processComputable(repeatable as T, "display");
|
||||
const display = repeatable.display;
|
||||
repeatable.display = jsx(() => {
|
||||
// TODO once processComputable types correctly, remove this "as X"
|
||||
const currDisplay = unref(display) as RepeatableDisplay;
|
||||
if (isCoercableComponent(currDisplay)) {
|
||||
const CurrDisplay = coerceComponent(currDisplay);
|
||||
return <CurrDisplay />;
|
||||
}
|
||||
if (currDisplay != null) {
|
||||
const genericRepeatable = repeatable as GenericRepeatable;
|
||||
const Title = coerceComponent(currDisplay.title ?? "", "h3");
|
||||
const Description = coerceComponent(currDisplay.description ?? "");
|
||||
const EffectDisplay = coerceComponent(currDisplay.effectDisplay ?? "");
|
||||
|
||||
return (
|
||||
<span>
|
||||
{currDisplay.title == null ? null : (
|
||||
<div>
|
||||
<Title />
|
||||
</div>
|
||||
)}
|
||||
{currDisplay.description == null ? null : <Description />}
|
||||
{currDisplay.showAmount === false ? null : (
|
||||
<div>
|
||||
<br />
|
||||
<>Amount: {formatWhole(genericRepeatable.amount.value)}</>
|
||||
{Decimal.isFinite(unref(genericRepeatable.limit)) ? (
|
||||
<> / {formatWhole(unref(genericRepeatable.limit))}</>
|
||||
) : undefined}
|
||||
</div>
|
||||
)}
|
||||
{currDisplay.effectDisplay == null ? null : (
|
||||
<div>
|
||||
<br />
|
||||
Currently: <EffectDisplay />
|
||||
</div>
|
||||
)}
|
||||
{genericRepeatable.maxed.value ? null : (
|
||||
<div>
|
||||
<br />
|
||||
{displayRequirements(
|
||||
genericRepeatable.requirements,
|
||||
unref(repeatable.amountToIncrease)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
processComputable(repeatable as T, "visibility");
|
||||
setDefault(repeatable, "visibility", Visibility.Visible);
|
||||
processComputable(repeatable as T, "limit");
|
||||
setDefault(repeatable, "limit", Decimal.dInf);
|
||||
processComputable(repeatable as T, "style");
|
||||
processComputable(repeatable as T, "mark");
|
||||
processComputable(repeatable as T, "small");
|
||||
|
||||
for (const decorator of decorators) {
|
||||
decorator.postConstruct?.(repeatable);
|
||||
}
|
||||
|
||||
const decoratedProps = decorators.reduce(
|
||||
(current, next) => Object.assign(current, next.getGatheredProps?.(repeatable)),
|
||||
{}
|
||||
);
|
||||
repeatable[GatherProps] = function (this: GenericRepeatable) {
|
||||
const { display, visibility, style, classes, onClick, canClick, small, mark, id } =
|
||||
this;
|
||||
return {
|
||||
display,
|
||||
visibility,
|
||||
style: unref(style),
|
||||
classes,
|
||||
onClick,
|
||||
canClick,
|
||||
small,
|
||||
mark,
|
||||
id,
|
||||
...decoratedProps
|
||||
};
|
||||
};
|
||||
|
||||
return repeatable as unknown as Repeatable<T>;
|
||||
});
|
||||
}
|
|
@ -1,16 +1,18 @@
|
|||
import type { OptionsFunc, Replace } from "features/feature";
|
||||
import { getUniqueID } from "features/feature";
|
||||
import { globalBus } from "game/events";
|
||||
import Formula from "game/formulas/formulas";
|
||||
import type { BaseLayer } from "game/layers";
|
||||
import { NonPersistent, Persistent, SkipPersistence } from "game/persistence";
|
||||
import { DefaultValue, persistent } from "game/persistence";
|
||||
import {
|
||||
DefaultValue,
|
||||
NonPersistent,
|
||||
Persistent,
|
||||
persistent,
|
||||
SkipPersistence
|
||||
} from "game/persistence";
|
||||
import type { Unsubscribe } from "nanoevents";
|
||||
import Decimal from "util/bignum";
|
||||
import type { Computable, GetComputableType } from "util/computed";
|
||||
import { processComputable } from "util/computed";
|
||||
import { processGetter } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import { isRef, unref } from "vue";
|
||||
import { isRef, MaybeRef, MaybeRefOrGetter, unref } from "vue";
|
||||
|
||||
/** A symbol used to identify {@link Reset} features. */
|
||||
export const ResetType = Symbol("Reset");
|
||||
|
@ -20,75 +22,65 @@ export const ResetType = Symbol("Reset");
|
|||
*/
|
||||
export interface ResetOptions {
|
||||
/** List of things to reset. Can include objects which will be recursed over for persistent values. */
|
||||
thingsToReset: Computable<unknown[]>;
|
||||
thingsToReset: MaybeRefOrGetter<unknown[]>;
|
||||
/** A function that is called when the reset is performed. */
|
||||
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;
|
||||
/** 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;
|
||||
/** 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: OptionsFunc<T, BaseReset, GenericReset>
|
||||
): Reset<T> {
|
||||
return createLazyProxy(feature => {
|
||||
const reset = optionsFunc.call(feature, feature);
|
||||
reset.id = getUniqueID("reset-");
|
||||
reset.type = ResetType;
|
||||
export function createReset<T extends ResetOptions>(optionsFunc: () => T) {
|
||||
return createLazyProxy(() => {
|
||||
const options = optionsFunc();
|
||||
const { thingsToReset, onReset, ...props } = options;
|
||||
|
||||
reset.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;
|
||||
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>)
|
||||
);
|
||||
}
|
||||
}
|
||||
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 as GenericReset).thingsToReset).forEach(handleObject);
|
||||
globalBus.emit("reset", reset as GenericReset);
|
||||
reset.onReset?.();
|
||||
};
|
||||
};
|
||||
unref(reset.thingsToReset).forEach(handleObject);
|
||||
globalBus.emit("reset", reset);
|
||||
onReset?.();
|
||||
}
|
||||
} satisfies Reset;
|
||||
|
||||
processComputable(reset as T, "thingsToReset");
|
||||
|
||||
return reset as unknown as Reset<T>;
|
||||
return reset;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -98,7 +90,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: GenericReset): Persistent<Decimal> {
|
||||
export function trackResetTime(layer: BaseLayer, reset: Reset): Persistent<Decimal> {
|
||||
const resetTime = persistent<Decimal>(new Decimal(0));
|
||||
globalBus.on("addLayer", layerBeingAdded => {
|
||||
if (layer.id === layerBeingAdded.id) {
|
||||
|
@ -123,6 +115,6 @@ globalBus.on("removeLayer", layer => {
|
|||
|
||||
declare module "game/events" {
|
||||
interface GlobalEvents {
|
||||
reset: (reset: GenericReset) => void;
|
||||
reset: (reset: Reset) => void;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,16 +3,12 @@
|
|||
<div
|
||||
class="main-display-container"
|
||||
:class="classes ?? {}"
|
||||
:style="[{ height: `${(effectRef?.$el.clientHeight ?? 0) + 50}px` }, style ?? {}]"
|
||||
>
|
||||
<div class="main-display">
|
||||
:style="[{ height: `${(displayRef?.clientHeight ?? 0) + 20}px` }, style ?? {}]">
|
||||
<div class="main-display" ref="displayRef">
|
||||
<span v-if="showPrefix">You have </span>
|
||||
<ResourceVue :resource="resource" :color="color || 'white'" />
|
||||
{{ resource.displayName
|
||||
}}<!-- remove whitespace -->
|
||||
<span v-if="effectComponent"
|
||||
>, <component :is="effectComponent" ref="effectRef"
|
||||
/></span>
|
||||
{{ resource.displayName }}<!-- remove whitespace -->
|
||||
<span v-if="effectDisplay">, <Effect /></span>
|
||||
</div>
|
||||
</div>
|
||||
</Sticky>
|
||||
|
@ -20,28 +16,23 @@
|
|||
|
||||
<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 { computeOptionalComponent } from "util/vue";
|
||||
import { ComponentPublicInstance, ref, Ref, StyleValue } from "vue";
|
||||
import { computed, toRefs } from "vue";
|
||||
import { Renderable } from "util/vue";
|
||||
import { computed, MaybeRefOrGetter, ref, StyleValue, toValue } from "vue";
|
||||
|
||||
const _props = defineProps<{
|
||||
const props = defineProps<{
|
||||
resource: Resource;
|
||||
color?: string;
|
||||
classes?: Record<string, boolean>;
|
||||
style?: StyleValue;
|
||||
effectDisplay?: CoercableComponent;
|
||||
effectDisplay?: MaybeRefOrGetter<Renderable>;
|
||||
}>();
|
||||
const props = toRefs(_props);
|
||||
|
||||
const effectRef = ref<ComponentPublicInstance | null>(null);
|
||||
const displayRef = ref<Element | null>(null);
|
||||
|
||||
const effectComponent = computeOptionalComponent(
|
||||
props.effectDisplay as Ref<CoercableComponent | undefined>
|
||||
);
|
||||
const Effect = () => toValue(props.effectDisplay);
|
||||
|
||||
const showPrefix = computed(() => {
|
||||
return Decimal.lt(props.resource.value, "1e1000");
|
||||
|
|
|
@ -3,9 +3,8 @@ import type { Persistent, State } from "game/persistence";
|
|||
import { NonPersistent, persistent } from "game/persistence";
|
||||
import 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, Ref } from "vue";
|
||||
import type { ComputedRef, MaybeRef, Ref } from "vue";
|
||||
import { computed, isRef, ref, unref, watch } from "vue";
|
||||
|
||||
/** An object that represents a named and quantifiable resource in the game. */
|
||||
|
@ -159,7 +158,7 @@ export function displayResource(resource: Resource, overrideAmount?: DecimalSour
|
|||
}
|
||||
|
||||
/** Utility for unwrapping a resource that may or may not be inside a ref. */
|
||||
export function unwrapResource(resource: ProcessedComputable<Resource>): Resource {
|
||||
export function unwrapResource(resource: MaybeRef<Resource>): Resource {
|
||||
if ("displayName" in resource) {
|
||||
return resource;
|
||||
}
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
<template>
|
||||
<component :is="component" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CoercableComponent } from "features/feature";
|
||||
import { computeComponent } from "util/vue";
|
||||
import { toRefs } from "vue";
|
||||
|
||||
const _props = defineProps<{ display: CoercableComponent }>();
|
||||
const { display } = toRefs(_props);
|
||||
const component = computeComponent(display);
|
||||
</script>
|
|
@ -1,79 +1,47 @@
|
|||
<template>
|
||||
<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 @click="selectTab" class="tabButton" :style="glowColorStyle" :class="{ active }">
|
||||
<Component />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { CoercableComponent, StyleValue } from "features/feature";
|
||||
import { isHidden, isVisible, Visibility } from "features/feature";
|
||||
<script setup lang="ts">
|
||||
import { getNotifyStyle } from "game/notifications";
|
||||
import { computeComponent, processedPropType, unwrapRef } from "util/vue";
|
||||
import { computed, defineComponent, toRefs, unref } from "vue";
|
||||
import { render } from "util/vue";
|
||||
import { computed, unref } from "vue";
|
||||
import { TabButton } from "./tabFamily";
|
||||
import themes from "data/themes";
|
||||
import settings from "game/settings";
|
||||
|
||||
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 props = defineProps<{
|
||||
display: TabButton["display"];
|
||||
glowColor: TabButton["glowColor"];
|
||||
active?: boolean;
|
||||
}>();
|
||||
|
||||
const component = computeComponent(display);
|
||||
const emit = defineEmits<{
|
||||
selectTab: [];
|
||||
}>();
|
||||
|
||||
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 Component = () => render(props.display);
|
||||
|
||||
function selectTab() {
|
||||
emit("selectTab");
|
||||
}
|
||||
|
||||
return {
|
||||
selectTab,
|
||||
component,
|
||||
glowColorStyle,
|
||||
unref,
|
||||
Visibility,
|
||||
isVisible,
|
||||
isHidden
|
||||
};
|
||||
const glowColorStyle = computed(() => {
|
||||
const color = unref(props.glowColor);
|
||||
if (color == null || color === "") {
|
||||
return {};
|
||||
}
|
||||
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,132 +1,61 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="isVisible(visibility)"
|
||||
class="tab-family-container"
|
||||
:class="{ ...unref(classes), ...tabClasses }"
|
||||
:style="[
|
||||
{
|
||||
visibility: isHidden(visibility) ? 'hidden' : undefined
|
||||
},
|
||||
unref(style) ?? [],
|
||||
tabStyle ?? []
|
||||
]"
|
||||
>
|
||||
<div class="tab-family-container" :class="tabClasses" :style="tabStyle">
|
||||
<Sticky
|
||||
class="tab-buttons-container"
|
||||
:class="unref(buttonContainerClasses)"
|
||||
:style="unref(buttonContainerStyle)"
|
||||
>
|
||||
<div class="tab-buttons" :class="{ floating }">
|
||||
<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)"
|
||||
/>
|
||||
<TabButtons />
|
||||
</div>
|
||||
</Sticky>
|
||||
<template v-if="unref(activeTab)">
|
||||
<component :is="unref(component)" />
|
||||
</template>
|
||||
<Component v-if="unref(activeTab) != null" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import Sticky from "components/layout/Sticky.vue";
|
||||
import { isType } from "features/feature";
|
||||
import { render } from "util/vue";
|
||||
import type { Component } from "vue";
|
||||
import { computed, unref } from "vue";
|
||||
import { TabType } from "./tab";
|
||||
import { TabFamily } from "./tabFamily";
|
||||
import themes from "data/themes";
|
||||
import 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 { coerceComponent, isCoercableComponent, processedPropType, unwrapRef } from "util/vue";
|
||||
import type { Component, PropType, Ref } from "vue";
|
||||
import { computed, defineComponent, shallowRef, toRefs, unref, watchEffect } from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
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 props = defineProps<{
|
||||
activeTab: TabFamily["activeTab"];
|
||||
tabs: TabFamily["tabs"];
|
||||
buttonContainerClasses: TabFamily["buttonContainerClasses"];
|
||||
buttonContainerStyle: TabFamily["buttonContainerStyle"];
|
||||
}>();
|
||||
|
||||
const floating = computed(() => {
|
||||
return themes[settings.theme].floatingTabs;
|
||||
});
|
||||
const Component = () => {
|
||||
const activeTab = unref(props.activeTab);
|
||||
if (activeTab == null) {
|
||||
return;
|
||||
}
|
||||
return render(activeTab);
|
||||
};
|
||||
|
||||
const component = shallowRef<Component | string>("");
|
||||
const floating = computed(() => {
|
||||
return themes[settings.theme].floatingTabs;
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
const currActiveTab = unwrapRef(activeTab);
|
||||
if (currActiveTab == null) {
|
||||
component.value = "";
|
||||
return;
|
||||
}
|
||||
if (isCoercableComponent(currActiveTab)) {
|
||||
component.value = coerceComponent(currActiveTab);
|
||||
return;
|
||||
}
|
||||
component.value = coerceComponent(unref(currActiveTab.display));
|
||||
});
|
||||
const TabButtons = () => Object.values(props.tabs).map(tab => render(tab));
|
||||
|
||||
const tabClasses = computed(() => {
|
||||
const currActiveTab = unwrapRef(activeTab);
|
||||
const tabClasses =
|
||||
isCoercableComponent(currActiveTab) || !currActiveTab
|
||||
? undefined
|
||||
: unref(currActiveTab.classes);
|
||||
return tabClasses;
|
||||
});
|
||||
const tabClasses = computed(() => {
|
||||
const activeTab = unref(props.activeTab);
|
||||
if (isType(activeTab, TabType)) {
|
||||
return unref(activeTab.classes);
|
||||
}
|
||||
});
|
||||
|
||||
const tabStyle = computed(() => {
|
||||
const currActiveTab = unwrapRef(activeTab);
|
||||
return isCoercableComponent(currActiveTab) || !currActiveTab
|
||||
? undefined
|
||||
: unref(currActiveTab.style);
|
||||
});
|
||||
|
||||
function gatherButtonProps(button: GenericTabButton) {
|
||||
const { display, style, classes, glowColor, visibility } = button;
|
||||
return { display, style: unref(style), classes, glowColor, visibility };
|
||||
}
|
||||
|
||||
return {
|
||||
floating,
|
||||
tabClasses,
|
||||
tabStyle,
|
||||
Visibility,
|
||||
component,
|
||||
gatherButtonProps,
|
||||
unref,
|
||||
isVisible,
|
||||
isHidden
|
||||
};
|
||||
const tabStyle = computed(() => {
|
||||
const activeTab = unref(props.activeTab);
|
||||
if (isType(activeTab, TabType)) {
|
||||
return unref(activeTab.style);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
@ -199,6 +128,10 @@ export default defineComponent({
|
|||
z-index: 4;
|
||||
}
|
||||
|
||||
.tab-buttons > * {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.layer-tab
|
||||
> .tab-family-container:first-child:nth-last-child(3)
|
||||
> .tab-buttons-container
|
||||
|
|
|
@ -1,14 +1,8 @@
|
|||
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 { processGetter } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import { render, Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
|
||||
import { MaybeRef, MaybeRefOrGetter } from "vue";
|
||||
import { JSX } from "vue/jsx-runtime";
|
||||
|
||||
/** A symbol used to identify {@link Tab} features. */
|
||||
export const TabType = Symbol("Tab");
|
||||
|
@ -16,63 +10,38 @@ export const TabType = Symbol("Tab");
|
|||
/**
|
||||
* An object that configures a {@link Tab}.
|
||||
*/
|
||||
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>;
|
||||
export interface TabOptions extends VueFeatureOptions {
|
||||
/** The display to use for this tab. */
|
||||
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>;
|
||||
display: MaybeRefOrGetter<Renderable>;
|
||||
}
|
||||
|
||||
/**
|
||||
* An object representing a tab of content in a tabbed interface.
|
||||
* @see {@link TabFamily}
|
||||
*/
|
||||
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>;
|
||||
export interface Tab extends VueFeature {
|
||||
/** The display to use for this tab. */
|
||||
display: MaybeRef<Renderable>;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof TabType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily creates a tab with the given options.
|
||||
* @param optionsFunc Tab 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;
|
||||
export function createTab<T extends TabOptions>(optionsFunc: () => T) {
|
||||
return createLazyProxy(() => {
|
||||
const options = optionsFunc?.() ?? ({} as T);
|
||||
const { display, ...props } = options;
|
||||
|
||||
tab[GatherProps] = function (this: GenericTab) {
|
||||
const { display } = this;
|
||||
return { display };
|
||||
};
|
||||
const tab = {
|
||||
type: TabType,
|
||||
...(props as Omit<typeof props, keyof VueFeature | keyof TabOptions>),
|
||||
...vueFeatureMixin("tab", options, (): JSX.Element => render(tab.display)),
|
||||
display: processGetter(display)
|
||||
} satisfies Tab;
|
||||
|
||||
return tab as unknown as Tab<T>;
|
||||
return tab;
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,232 +0,0 @@
|
|||
import type {
|
||||
CoercableComponent,
|
||||
GenericComponent,
|
||||
OptionsFunc,
|
||||
Replace,
|
||||
StyleValue
|
||||
} from "features/feature";
|
||||
import {
|
||||
Component,
|
||||
GatherProps,
|
||||
getUniqueID,
|
||||
isVisible,
|
||||
setDefault,
|
||||
Visibility
|
||||
} from "features/feature";
|
||||
import TabButtonComponent from "features/tabs/TabButton.vue";
|
||||
import TabFamilyComponent from "features/tabs/TabFamily.vue";
|
||||
import type { Persistent } from "game/persistence";
|
||||
import { persistent } from "game/persistence";
|
||||
import type {
|
||||
Computable,
|
||||
GetComputableType,
|
||||
GetComputableTypeWithDefault,
|
||||
ProcessedComputable
|
||||
} from "util/computed";
|
||||
import { processComputable } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import type { Ref } from "vue";
|
||||
import { computed, unref } from "vue";
|
||||
import type { GenericTab } from "./tab";
|
||||
|
||||
/** A symbol used to identify {@link TabButton} features. */
|
||||
export const TabButtonType = Symbol("TabButton");
|
||||
/** A symbol used to identify {@link TabFamily} features. */
|
||||
export const TabFamilyType = Symbol("TabFamily");
|
||||
|
||||
/**
|
||||
* An object that configures a {@link TabButton}.
|
||||
*/
|
||||
export interface TabButtonOptions {
|
||||
/** Whether this tab button should be visible. */
|
||||
visibility?: Computable<Visibility | boolean>;
|
||||
/** The tab to display when this button is clicked. */
|
||||
tab: Computable<GenericTab | CoercableComponent>;
|
||||
/** The label on this button. */
|
||||
display: Computable<CoercableComponent>;
|
||||
/** Dictionary of CSS classes to apply to this feature. */
|
||||
classes?: Computable<Record<string, boolean>>;
|
||||
/** CSS to apply to this feature. */
|
||||
style?: Computable<StyleValue>;
|
||||
/** The color of the glow effect to display when this button is active. */
|
||||
glowColor?: Computable<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The properties that are added onto a processed {@link TabButtonOptions} to create an {@link TabButton}.
|
||||
*/
|
||||
export interface BaseTabButton {
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof TabButtonType;
|
||||
/** The Vue component used to render this feature. */
|
||||
[Component]: GenericComponent;
|
||||
}
|
||||
|
||||
/**
|
||||
* An object that represents a button that can be clicked to change tabs in a tabbed interface.
|
||||
* @see {@link TabFamily}
|
||||
*/
|
||||
export type TabButton<T extends TabButtonOptions> = Replace<
|
||||
T & BaseTabButton,
|
||||
{
|
||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||
tab: GetComputableType<T["tab"]>;
|
||||
display: GetComputableType<T["display"]>;
|
||||
classes: GetComputableType<T["classes"]>;
|
||||
style: GetComputableType<T["style"]>;
|
||||
glowColor: GetComputableType<T["glowColor"]>;
|
||||
}
|
||||
>;
|
||||
|
||||
/** A type that matches any valid {@link TabButton} object. */
|
||||
export type GenericTabButton = Replace<
|
||||
TabButton<TabButtonOptions>,
|
||||
{
|
||||
visibility: ProcessedComputable<Visibility | boolean>;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* An object that configures a {@link TabFamily}.
|
||||
*/
|
||||
export interface TabFamilyOptions {
|
||||
/** Whether this tab button should be visible. */
|
||||
visibility?: Computable<Visibility | boolean>;
|
||||
/** Dictionary of CSS classes to apply to this feature. */
|
||||
classes?: Computable<Record<string, boolean>>;
|
||||
/** CSS to apply to this feature. */
|
||||
style?: Computable<StyleValue>;
|
||||
/** A dictionary of CSS classes to apply to the list of buttons for changing tabs. */
|
||||
buttonContainerClasses?: Computable<Record<string, boolean>>;
|
||||
/** CSS to apply to the list of buttons for changing tabs. */
|
||||
buttonContainerStyle?: Computable<StyleValue>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The properties that are added onto a processed {@link TabFamilyOptions} to create an {@link TabFamily}.
|
||||
*/
|
||||
export interface BaseTabFamily {
|
||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
||||
id: string;
|
||||
/** All the tabs within this family. */
|
||||
tabs: Record<string, TabButtonOptions>;
|
||||
/** The currently active tab, if any. */
|
||||
activeTab: Ref<GenericTab | CoercableComponent | null>;
|
||||
/** The name of the tab that is currently active. */
|
||||
selected: Persistent<string>;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof TabFamilyType;
|
||||
/** The Vue component used to render this feature. */
|
||||
[Component]: GenericComponent;
|
||||
/** A function to gather the props the vue component requires for this feature. */
|
||||
[GatherProps]: () => Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* An object that represents a tabbed interface.
|
||||
* @see {@link TabFamily}
|
||||
*/
|
||||
export type TabFamily<T extends TabFamilyOptions> = Replace<
|
||||
T & BaseTabFamily,
|
||||
{
|
||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||
tabs: Record<string, GenericTabButton>;
|
||||
}
|
||||
>;
|
||||
|
||||
/** A type that matches any valid {@link TabFamily} object. */
|
||||
export type GenericTabFamily = Replace<
|
||||
TabFamily<TabFamilyOptions>,
|
||||
{
|
||||
visibility: ProcessedComputable<Visibility | boolean>;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* Lazily creates a tab family with the given options.
|
||||
* @param optionsFunc Tab family options.
|
||||
*/
|
||||
export function createTabFamily<T extends TabFamilyOptions>(
|
||||
tabs: Record<string, () => TabButtonOptions>,
|
||||
optionsFunc?: OptionsFunc<T, BaseTabFamily, GenericTabFamily>
|
||||
): TabFamily<T> {
|
||||
if (Object.keys(tabs).length === 0) {
|
||||
console.error("Cannot create tab family with 0 tabs");
|
||||
}
|
||||
|
||||
const selected = persistent(Object.keys(tabs)[0], false);
|
||||
return createLazyProxy(feature => {
|
||||
const tabFamily =
|
||||
optionsFunc?.call(feature, feature) ??
|
||||
({} as ReturnType<NonNullable<typeof optionsFunc>>);
|
||||
|
||||
tabFamily.id = getUniqueID("tabFamily-");
|
||||
tabFamily.type = TabFamilyType;
|
||||
tabFamily[Component] = TabFamilyComponent as GenericComponent;
|
||||
|
||||
tabFamily.tabs = Object.keys(tabs).reduce<Record<string, GenericTabButton>>(
|
||||
(parsedTabs, tab) => {
|
||||
const tabButton: TabButtonOptions & Partial<BaseTabButton> = tabs[tab]();
|
||||
tabButton.type = TabButtonType;
|
||||
tabButton[Component] = TabButtonComponent as GenericComponent;
|
||||
|
||||
processComputable(tabButton as TabButtonOptions, "visibility");
|
||||
setDefault(tabButton, "visibility", Visibility.Visible);
|
||||
processComputable(tabButton as TabButtonOptions, "tab");
|
||||
processComputable(tabButton as TabButtonOptions, "display");
|
||||
processComputable(tabButton as TabButtonOptions, "classes");
|
||||
processComputable(tabButton as TabButtonOptions, "style");
|
||||
processComputable(tabButton as TabButtonOptions, "glowColor");
|
||||
parsedTabs[tab] = tabButton as GenericTabButton;
|
||||
return parsedTabs;
|
||||
},
|
||||
{}
|
||||
);
|
||||
tabFamily.selected = selected;
|
||||
tabFamily.activeTab = computed(() => {
|
||||
const tabs = unref(processedTabFamily.tabs);
|
||||
if (selected.value in tabs && isVisible(tabs[selected.value].visibility)) {
|
||||
return unref(tabs[selected.value].tab);
|
||||
}
|
||||
const firstTab = Object.values(tabs).find(tab => isVisible(tab.visibility));
|
||||
if (firstTab) {
|
||||
return unref(firstTab.tab);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
processComputable(tabFamily as T, "visibility");
|
||||
setDefault(tabFamily, "visibility", Visibility.Visible);
|
||||
processComputable(tabFamily as T, "classes");
|
||||
processComputable(tabFamily as T, "style");
|
||||
processComputable(tabFamily as T, "buttonContainerClasses");
|
||||
processComputable(tabFamily as T, "buttonContainerStyle");
|
||||
|
||||
tabFamily[GatherProps] = function (this: GenericTabFamily) {
|
||||
const {
|
||||
visibility,
|
||||
activeTab,
|
||||
selected,
|
||||
tabs,
|
||||
style,
|
||||
classes,
|
||||
buttonContainerClasses,
|
||||
buttonContainerStyle
|
||||
} = this;
|
||||
return {
|
||||
visibility,
|
||||
activeTab,
|
||||
selected,
|
||||
tabs,
|
||||
style: unref(style),
|
||||
classes,
|
||||
buttonContainerClasses,
|
||||
buttonContainerStyle
|
||||
};
|
||||
};
|
||||
|
||||
// This is necessary because board.types is different from T and TabFamily
|
||||
const processedTabFamily = tabFamily as unknown as TabFamily<T>;
|
||||
return processedTabFamily;
|
||||
});
|
||||
}
|
146
src/features/tabs/tabFamily.tsx
Normal file
146
src/features/tabs/tabFamily.tsx
Normal file
|
@ -0,0 +1,146 @@
|
|||
import { isVisible } from "features/feature";
|
||||
import { Tab } from "features/tabs/tab";
|
||||
import TabButton from "features/tabs/TabButton.vue";
|
||||
import TabFamily from "features/tabs/TabFamily.vue";
|
||||
import type { Persistent } from "game/persistence";
|
||||
import { persistent } from "game/persistence";
|
||||
import { processGetter } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import { Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
|
||||
import type { CSSProperties, MaybeRef, MaybeRefOrGetter, Ref } from "vue";
|
||||
import { computed, unref } from "vue";
|
||||
|
||||
/** A symbol used to identify {@link TabButton} features. */
|
||||
export const TabButtonType = Symbol("TabButton");
|
||||
/** A symbol used to identify {@link TabFamily} features. */
|
||||
export const TabFamilyType = Symbol("TabFamily");
|
||||
|
||||
/**
|
||||
* An object that configures a {@link TabButton}.
|
||||
*/
|
||||
export interface TabButtonOptions extends VueFeatureOptions {
|
||||
/** The tab to display when this button is clicked. */
|
||||
tab: Tab | MaybeRefOrGetter<Renderable>;
|
||||
/** The label on this button. */
|
||||
display: MaybeRefOrGetter<Renderable>;
|
||||
/** The color of the glow effect to display when this button is active. */
|
||||
glowColor?: MaybeRefOrGetter<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* An object that represents a button that can be clicked to change tabs in a tabbed interface.
|
||||
* @see {@link TabFamily}
|
||||
*/
|
||||
export interface TabButton extends VueFeature {
|
||||
/** The tab to display when this button is clicked. */
|
||||
tab: Tab | MaybeRef<Renderable>;
|
||||
/** The label on this button. */
|
||||
display: MaybeRef<Renderable>;
|
||||
/** The color of the glow effect to display when this button is active. */
|
||||
glowColor?: MaybeRef<string>;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof TabButtonType;
|
||||
}
|
||||
|
||||
/**
|
||||
* An object that configures a {@link TabFamily}.
|
||||
*/
|
||||
export interface TabFamilyOptions extends VueFeatureOptions {
|
||||
/** A dictionary of CSS classes to apply to the list of buttons for changing tabs. */
|
||||
buttonContainerClasses?: MaybeRefOrGetter<Record<string, boolean>>;
|
||||
/** CSS to apply to the list of buttons for changing tabs. */
|
||||
buttonContainerStyle?: MaybeRefOrGetter<CSSProperties>;
|
||||
}
|
||||
|
||||
/**
|
||||
* An object that represents a tabbed interface.
|
||||
* @see {@link TabFamily}
|
||||
*/
|
||||
export interface TabFamily extends VueFeature {
|
||||
/** A dictionary of CSS classes to apply to the list of buttons for changing tabs. */
|
||||
buttonContainerClasses?: MaybeRef<Record<string, boolean>>;
|
||||
/** CSS to apply to the list of buttons for changing tabs. */
|
||||
buttonContainerStyle?: MaybeRef<CSSProperties>;
|
||||
/** All the tabs within this family. */
|
||||
tabs: Record<string, TabButton>;
|
||||
/** The currently active tab, if any. */
|
||||
activeTab: Ref<Tab | MaybeRef<Renderable> | null>;
|
||||
/** The name of the tab that is currently active. */
|
||||
selected: Persistent<string>;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof TabFamilyType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily creates a tab family with the given options.
|
||||
* @param optionsFunc Tab family options.
|
||||
*/
|
||||
export function createTabFamily<T extends TabFamilyOptions>(
|
||||
tabs: Record<string, () => TabButtonOptions>,
|
||||
optionsFunc?: () => T
|
||||
) {
|
||||
if (Object.keys(tabs).length === 0) {
|
||||
console.error("Cannot create tab family with 0 tabs");
|
||||
}
|
||||
|
||||
const selected = persistent(Object.keys(tabs)[0], false);
|
||||
return createLazyProxy(() => {
|
||||
const options = optionsFunc?.() ?? ({} as T);
|
||||
const { buttonContainerClasses, buttonContainerStyle, ...props } = options;
|
||||
|
||||
const tabFamily = {
|
||||
type: TabFamilyType,
|
||||
...(props as Omit<typeof props, keyof VueFeature | keyof TabFamilyOptions>),
|
||||
...vueFeatureMixin("tabFamily", options, () => (
|
||||
<TabFamily
|
||||
activeTab={tabFamily.activeTab}
|
||||
tabs={tabFamily.tabs}
|
||||
buttonContainerClasses={tabFamily.buttonContainerClasses}
|
||||
buttonContainerStyle={tabFamily.buttonContainerStyle}
|
||||
/>
|
||||
)),
|
||||
tabs: Object.keys(tabs).reduce<Record<string, TabButton>>((parsedTabs, tab) => {
|
||||
const options = tabs[tab]();
|
||||
const { tab: buttonTab, glowColor, display, ...props } = options;
|
||||
|
||||
const tabButton = {
|
||||
type: TabButtonType,
|
||||
...(props as Omit<typeof props, keyof VueFeature | keyof TabButtonOptions>),
|
||||
...vueFeatureMixin("tabButton", options, () =>
|
||||
<TabButton
|
||||
display={tabButton.display}
|
||||
glowColor={tabButton.glowColor}
|
||||
active={unref(tabButton.tab) === unref(tabFamily.activeTab)}
|
||||
onSelectTab={() => tabFamily.selected.value = tab}
|
||||
/>),
|
||||
tab: processGetter(buttonTab),
|
||||
glowColor: processGetter(glowColor),
|
||||
display: processGetter(display)
|
||||
} satisfies TabButton;
|
||||
|
||||
parsedTabs[tab] = tabButton;
|
||||
return parsedTabs;
|
||||
}, {}),
|
||||
buttonContainerClasses: processGetter(buttonContainerClasses),
|
||||
buttonContainerStyle: processGetter(buttonContainerStyle),
|
||||
selected,
|
||||
activeTab: computed((): Tab | MaybeRef<Renderable> | null => {
|
||||
if (
|
||||
selected.value in tabFamily.tabs &&
|
||||
isVisible(tabFamily.tabs[selected.value].visibility ?? true)
|
||||
) {
|
||||
return unref(tabFamily.tabs[selected.value].tab);
|
||||
}
|
||||
const firstTab = Object.values(tabFamily.tabs).find(tab =>
|
||||
isVisible(tab.visibility ?? true)
|
||||
);
|
||||
if (firstTab != null) {
|
||||
return unref(firstTab.tab);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
} satisfies TabFamily;
|
||||
|
||||
return tabFamily;
|
||||
});
|
||||
}
|
|
@ -1,120 +0,0 @@
|
|||
import type { CoercableComponent, GenericComponent, Replace, StyleValue } from "features/feature";
|
||||
import { Component, GatherProps, setDefault } from "features/feature";
|
||||
import { deletePersistent, Persistent, persistent } from "game/persistence";
|
||||
import { Direction } from "util/common";
|
||||
import type {
|
||||
Computable,
|
||||
GetComputableType,
|
||||
GetComputableTypeWithDefault,
|
||||
ProcessedComputable
|
||||
} from "util/computed";
|
||||
import { processComputable } from "util/computed";
|
||||
import type { VueFeature } from "util/vue";
|
||||
import type { Ref } from "vue";
|
||||
import { nextTick, unref } from "vue";
|
||||
import TooltipComponent from "./Tooltip.vue";
|
||||
|
||||
declare module "@vue/runtime-dom" {
|
||||
interface CSSProperties {
|
||||
"--xoffset"?: string;
|
||||
"--yoffset"?: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An object that configures a {@link Tooltip}.
|
||||
*/
|
||||
export interface TooltipOptions {
|
||||
/** Whether or not this tooltip can be pinned, meaning it'll stay visible even when not hovered. */
|
||||
pinnable?: boolean;
|
||||
/** The text to display inside the tooltip. */
|
||||
display: Computable<CoercableComponent>;
|
||||
/** Dictionary of CSS classes to apply to this feature. */
|
||||
classes?: Computable<Record<string, boolean>>;
|
||||
/** CSS to apply to this feature. */
|
||||
style?: Computable<StyleValue>;
|
||||
/** The direction in which to display the tooltip */
|
||||
direction?: Computable<Direction>;
|
||||
/** The x offset of the tooltip, in px. */
|
||||
xoffset?: Computable<string>;
|
||||
/** The y offset of the tooltip, in px. */
|
||||
yoffset?: Computable<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The properties that are added onto a processed {@link TooltipOptions} to create an {@link Tooltip}.
|
||||
*/
|
||||
export interface BaseTooltip {
|
||||
pinned?: Ref<boolean>;
|
||||
}
|
||||
|
||||
/** An object that represents a tooltip that appears when hovering over an element. */
|
||||
export type Tooltip<T extends TooltipOptions> = Replace<
|
||||
T & BaseTooltip,
|
||||
{
|
||||
pinnable: undefined extends T["pinnable"] ? false : T["pinnable"];
|
||||
pinned: T["pinnable"] extends true ? Ref<boolean> : undefined;
|
||||
display: GetComputableType<T["display"]>;
|
||||
classes: GetComputableType<T["classes"]>;
|
||||
style: GetComputableType<T["style"]>;
|
||||
direction: GetComputableTypeWithDefault<T["direction"], Direction.Up>;
|
||||
xoffset: GetComputableType<T["xoffset"]>;
|
||||
yoffset: GetComputableType<T["yoffset"]>;
|
||||
}
|
||||
>;
|
||||
|
||||
/** A type that matches any valid {@link Tooltip} object. */
|
||||
export type GenericTooltip = Replace<
|
||||
Tooltip<TooltipOptions>,
|
||||
{
|
||||
pinnable: boolean;
|
||||
pinned: Ref<boolean> | undefined;
|
||||
direction: ProcessedComputable<Direction>;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* Creates a tooltip on the given element with the given options.
|
||||
* @param element The renderable feature to display the tooltip on.
|
||||
* @param options Tooltip options.
|
||||
*/
|
||||
export function addTooltip<T extends TooltipOptions>(
|
||||
element: VueFeature,
|
||||
options: T & ThisType<Tooltip<T>> & Partial<BaseTooltip>
|
||||
): Tooltip<T> {
|
||||
processComputable(options as T, "display");
|
||||
processComputable(options as T, "classes");
|
||||
processComputable(options as T, "style");
|
||||
processComputable(options as T, "direction");
|
||||
setDefault(options, "direction", Direction.Up);
|
||||
processComputable(options as T, "xoffset");
|
||||
processComputable(options as T, "yoffset");
|
||||
|
||||
if (options.pinnable) {
|
||||
options.pinned = persistent<boolean>(false, false);
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
const elementComponent = element[Component];
|
||||
element[Component] = TooltipComponent as GenericComponent;
|
||||
const elementGatherProps = element[GatherProps].bind(element);
|
||||
element[GatherProps] = function gatherTooltipProps(this: GenericTooltip) {
|
||||
const { display, classes, style, direction, xoffset, yoffset, pinned } = this;
|
||||
return {
|
||||
element: {
|
||||
[Component]: elementComponent,
|
||||
[GatherProps]: elementGatherProps
|
||||
},
|
||||
display,
|
||||
classes,
|
||||
style: unref(style),
|
||||
direction,
|
||||
xoffset,
|
||||
yoffset,
|
||||
pinned
|
||||
};
|
||||
}.bind(options as GenericTooltip);
|
||||
});
|
||||
|
||||
return options as unknown as Tooltip<T>;
|
||||
}
|
|
@ -1,79 +1,38 @@
|
|||
<template>
|
||||
<component :is="nodesComp" />
|
||||
<component v-if="leftNodesComp" :is="leftNodesComp" />
|
||||
<component v-if="rightNodesComp" :is="rightNodesComp" />
|
||||
<Nodes />
|
||||
<LeftNodes v-if="leftSideNodes" />
|
||||
<RightNodes v-if="rightSideNodes" />
|
||||
<Links v-if="branches" :links="unref(branches)" />
|
||||
</template>
|
||||
|
||||
<script lang="tsx">
|
||||
<script setup lang="tsx">
|
||||
import "components/common/table.css";
|
||||
import { jsx } from "features/feature";
|
||||
import Links from "features/links/Links.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";
|
||||
import type { Tree } from "features/trees/tree";
|
||||
import { render } from "util/vue";
|
||||
import { unref } from "vue";
|
||||
|
||||
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 props = defineProps<{
|
||||
nodes: Tree["nodes"];
|
||||
leftSideNodes: Tree["leftSideNodes"];
|
||||
rightSideNodes: Tree["rightSideNodes"];
|
||||
branches: Tree["branches"];
|
||||
}>();
|
||||
|
||||
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 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 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
|
||||
};
|
||||
}
|
||||
});
|
||||
const RightNodes = () => props.rightSideNodes == null ? <></> :
|
||||
<span class="side-nodes small">
|
||||
{unref(props.rightSideNodes).map(node => render(node))}
|
||||
</span>;
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="isVisible(visibility)"
|
||||
:style="{ visibility: isHidden(visibility) ? 'hidden' : undefined }"
|
||||
<button
|
||||
:style="{
|
||||
backgroundColor: unref(color),
|
||||
boxShadow: `-4px -4px 4px rgba(0, 0, 0, 0.25) inset, 0 0 20px ${unref(
|
||||
glowColor
|
||||
)}`
|
||||
}"
|
||||
:class="{
|
||||
treeNode: true,
|
||||
can: unref(canClick),
|
||||
...unref(classes)
|
||||
can: unref(canClick)
|
||||
}"
|
||||
@click="onClick"
|
||||
@mousedown="start"
|
||||
|
@ -15,108 +18,45 @@
|
|||
@touchend.passive="stop"
|
||||
@touchcancel.passive="stop"
|
||||
>
|
||||
<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>
|
||||
<Component />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<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";
|
||||
<script setup lang="tsx">
|
||||
import { render, setupHoldToClick } from "util/vue";
|
||||
import { toRef, unref } from "vue";
|
||||
import { TreeNode } from "./tree";
|
||||
|
||||
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 props = defineProps<{
|
||||
canClick: TreeNode["canClick"];
|
||||
display: TreeNode["display"];
|
||||
onClick: TreeNode["onClick"];
|
||||
onHold: TreeNode["onHold"];
|
||||
color: TreeNode["color"];
|
||||
glowColor: TreeNode["glowColor"];
|
||||
}>();
|
||||
|
||||
const comp = computeOptionalComponent(display);
|
||||
const Component = () => props.display == null ? <></> :
|
||||
render(props.display, el => <div>{el}</div>);
|
||||
|
||||
const { start, stop } = setupHoldToClick(onClick, onHold);
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
comp,
|
||||
unref,
|
||||
Visibility,
|
||||
isCoercableComponent,
|
||||
isVisible,
|
||||
isHidden
|
||||
};
|
||||
}
|
||||
});
|
||||
const { start, stop } = setupHoldToClick(toRef(props, "onClick"), toRef(props, "onHold"));
|
||||
</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 > *:first-child > * {
|
||||
.treeNode > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,383 +0,0 @@
|
|||
import { Decorator, GenericDecorator } from "features/decorators/common";
|
||||
import type {
|
||||
CoercableComponent,
|
||||
GenericComponent,
|
||||
OptionsFunc,
|
||||
Replace,
|
||||
StyleValue
|
||||
} from "features/feature";
|
||||
import { Component, GatherProps, getUniqueID, setDefault, Visibility } from "features/feature";
|
||||
import type { Link } from "features/links/links";
|
||||
import type { GenericReset } from "features/reset";
|
||||
import type { Resource } from "features/resources/resource";
|
||||
import { displayResource } from "features/resources/resource";
|
||||
import TreeComponent from "features/trees/Tree.vue";
|
||||
import TreeNodeComponent from "features/trees/TreeNode.vue";
|
||||
import type { DecimalSource } from "util/bignum";
|
||||
import Decimal, { format, formatWhole } from "util/bignum";
|
||||
import type {
|
||||
Computable,
|
||||
GetComputableType,
|
||||
GetComputableTypeWithDefault,
|
||||
ProcessedComputable
|
||||
} from "util/computed";
|
||||
import { convertComputable, processComputable } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import type { Ref } from "vue";
|
||||
import { computed, ref, shallowRef, unref } from "vue";
|
||||
|
||||
/** A symbol used to identify {@link TreeNode} features. */
|
||||
export const TreeNodeType = Symbol("TreeNode");
|
||||
/** A symbol used to identify {@link Tree} features. */
|
||||
export const TreeType = Symbol("Tree");
|
||||
|
||||
/**
|
||||
* An object that configures a {@link TreeNode}.
|
||||
*/
|
||||
export interface TreeNodeOptions {
|
||||
/** Whether this tree node should be visible. */
|
||||
visibility?: Computable<Visibility | boolean>;
|
||||
/** Whether or not this tree node can be clicked. */
|
||||
canClick?: Computable<boolean>;
|
||||
/** The background color for this node. */
|
||||
color?: Computable<string>;
|
||||
/** The label to display on this tree node. */
|
||||
display?: Computable<CoercableComponent>;
|
||||
/** The color of the glow effect shown to notify the user there's something to do with this node. */
|
||||
glowColor?: Computable<string>;
|
||||
/** Dictionary of CSS classes to apply to this feature. */
|
||||
classes?: Computable<Record<string, boolean>>;
|
||||
/** CSS to apply to this feature. */
|
||||
style?: Computable<StyleValue>;
|
||||
/** Shows a marker on the corner of the feature. */
|
||||
mark?: Computable<boolean | string>;
|
||||
/** A reset object attached to this node, used for propagating resets through the tree. */
|
||||
reset?: GenericReset;
|
||||
/** A function that is called when the tree node is clicked. */
|
||||
onClick?: (e?: MouseEvent | TouchEvent) => void;
|
||||
/** A function that is called when the tree node is held down. */
|
||||
onHold?: VoidFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* The properties that are added onto a processed {@link TreeNodeOptions} to create an {@link TreeNode}.
|
||||
*/
|
||||
export interface BaseTreeNode {
|
||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
||||
id: string;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof TreeNodeType;
|
||||
/** The Vue component used to render this feature. */
|
||||
[Component]: GenericComponent;
|
||||
/** A function to gather the props the vue component requires for this feature. */
|
||||
[GatherProps]: () => Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** An object that represents a node on a tree. */
|
||||
export type TreeNode<T extends TreeNodeOptions> = Replace<
|
||||
T & BaseTreeNode,
|
||||
{
|
||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||
canClick: GetComputableTypeWithDefault<T["canClick"], true>;
|
||||
color: GetComputableType<T["color"]>;
|
||||
display: GetComputableType<T["display"]>;
|
||||
glowColor: GetComputableType<T["glowColor"]>;
|
||||
classes: GetComputableType<T["classes"]>;
|
||||
style: GetComputableType<T["style"]>;
|
||||
mark: GetComputableType<T["mark"]>;
|
||||
}
|
||||
>;
|
||||
|
||||
/** A type that matches any valid {@link TreeNode} object. */
|
||||
export type GenericTreeNode = Replace<
|
||||
TreeNode<TreeNodeOptions>,
|
||||
{
|
||||
visibility: ProcessedComputable<Visibility | boolean>;
|
||||
canClick: ProcessedComputable<boolean>;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* Lazily creates a tree node with the given options.
|
||||
* @param optionsFunc Tree Node options.
|
||||
*/
|
||||
export function createTreeNode<T extends TreeNodeOptions>(
|
||||
optionsFunc?: OptionsFunc<T, BaseTreeNode, GenericTreeNode>,
|
||||
...decorators: GenericDecorator[]
|
||||
): TreeNode<T> {
|
||||
const decoratedData = decorators.reduce(
|
||||
(current, next) => Object.assign(current, next.getPersistentData?.()),
|
||||
{}
|
||||
);
|
||||
return createLazyProxy(feature => {
|
||||
const treeNode =
|
||||
optionsFunc?.call(feature, feature) ??
|
||||
({} as ReturnType<NonNullable<typeof optionsFunc>>);
|
||||
treeNode.id = getUniqueID("treeNode-");
|
||||
treeNode.type = TreeNodeType;
|
||||
treeNode[Component] = TreeNodeComponent as GenericComponent;
|
||||
|
||||
for (const decorator of decorators) {
|
||||
decorator.preConstruct?.(treeNode);
|
||||
}
|
||||
|
||||
Object.assign(decoratedData);
|
||||
|
||||
processComputable(treeNode as T, "visibility");
|
||||
setDefault(treeNode, "visibility", Visibility.Visible);
|
||||
processComputable(treeNode as T, "canClick");
|
||||
setDefault(treeNode, "canClick", true);
|
||||
processComputable(treeNode as T, "color");
|
||||
processComputable(treeNode as T, "display");
|
||||
processComputable(treeNode as T, "glowColor");
|
||||
processComputable(treeNode as T, "classes");
|
||||
processComputable(treeNode as T, "style");
|
||||
processComputable(treeNode as T, "mark");
|
||||
|
||||
for (const decorator of decorators) {
|
||||
decorator.postConstruct?.(treeNode);
|
||||
}
|
||||
|
||||
if (treeNode.onClick) {
|
||||
const onClick = treeNode.onClick.bind(treeNode);
|
||||
treeNode.onClick = function (e) {
|
||||
if (unref(treeNode.canClick) !== false) {
|
||||
onClick(e);
|
||||
}
|
||||
};
|
||||
}
|
||||
if (treeNode.onHold) {
|
||||
const onHold = treeNode.onHold.bind(treeNode);
|
||||
treeNode.onHold = function () {
|
||||
if (unref(treeNode.canClick) !== false) {
|
||||
onHold();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const decoratedProps = decorators.reduce(
|
||||
(current, next) => Object.assign(current, next.getGatheredProps?.(treeNode)),
|
||||
{}
|
||||
);
|
||||
treeNode[GatherProps] = function (this: GenericTreeNode) {
|
||||
const {
|
||||
display,
|
||||
visibility,
|
||||
style,
|
||||
classes,
|
||||
onClick,
|
||||
onHold,
|
||||
color,
|
||||
glowColor,
|
||||
canClick,
|
||||
mark,
|
||||
id
|
||||
} = this;
|
||||
return {
|
||||
display,
|
||||
visibility,
|
||||
style,
|
||||
classes,
|
||||
onClick,
|
||||
onHold,
|
||||
color,
|
||||
glowColor,
|
||||
canClick,
|
||||
mark,
|
||||
id,
|
||||
...decoratedProps
|
||||
};
|
||||
};
|
||||
|
||||
return treeNode as unknown as TreeNode<T>;
|
||||
});
|
||||
}
|
||||
|
||||
/** Represents a branch between two nodes in a tree. */
|
||||
export interface TreeBranch extends Omit<Link, "startNode" | "endNode"> {
|
||||
startNode: GenericTreeNode;
|
||||
endNode: GenericTreeNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* An object that configures a {@link Tree}.
|
||||
*/
|
||||
export interface TreeOptions {
|
||||
/** Whether this clickable should be visible. */
|
||||
visibility?: Computable<Visibility | boolean>;
|
||||
/** The nodes within the tree, in a 2D array. */
|
||||
nodes: Computable<GenericTreeNode[][]>;
|
||||
/** Nodes to show on the left side of the tree. */
|
||||
leftSideNodes?: Computable<GenericTreeNode[]>;
|
||||
/** Nodes to show on the right side of the tree. */
|
||||
rightSideNodes?: Computable<GenericTreeNode[]>;
|
||||
/** The branches between nodes within this tree. */
|
||||
branches?: Computable<TreeBranch[]>;
|
||||
/** How to propagate resets through the tree. */
|
||||
resetPropagation?: ResetPropagation;
|
||||
/** A function that is called when a node within the tree is reset. */
|
||||
onReset?: (node: GenericTreeNode) => void;
|
||||
}
|
||||
|
||||
export interface BaseTree {
|
||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
||||
id: string;
|
||||
/** The link objects for each of the branches of the tree. */
|
||||
links: Ref<Link[]>;
|
||||
/** Cause a reset on this node and propagate it through the tree according to {@link resetPropagation}. */
|
||||
reset: (node: GenericTreeNode) => void;
|
||||
/** A flag that is true while the reset is still propagating through the tree. */
|
||||
isResetting: Ref<boolean>;
|
||||
/** A reference to the node that caused the currently propagating reset. */
|
||||
resettingNode: Ref<GenericTreeNode | null>;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof TreeType;
|
||||
/** The Vue component used to render this feature. */
|
||||
[Component]: GenericComponent;
|
||||
/** A function to gather the props the vue component requires for this feature. */
|
||||
[GatherProps]: () => Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** An object that represents a feature that is a tree of nodes with branches between them. Contains support for reset mechanics that can propagate through the tree. */
|
||||
export type Tree<T extends TreeOptions> = Replace<
|
||||
T & BaseTree,
|
||||
{
|
||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||
nodes: GetComputableType<T["nodes"]>;
|
||||
leftSideNodes: GetComputableType<T["leftSideNodes"]>;
|
||||
rightSideNodes: GetComputableType<T["rightSideNodes"]>;
|
||||
branches: GetComputableType<T["branches"]>;
|
||||
}
|
||||
>;
|
||||
|
||||
/** A type that matches any valid {@link Tree} object. */
|
||||
export type GenericTree = Replace<
|
||||
Tree<TreeOptions>,
|
||||
{
|
||||
visibility: ProcessedComputable<Visibility | boolean>;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* Lazily creates a tree with the given options.
|
||||
* @param optionsFunc Tree options.
|
||||
*/
|
||||
export function createTree<T extends TreeOptions>(
|
||||
optionsFunc: OptionsFunc<T, BaseTree, GenericTree>
|
||||
): Tree<T> {
|
||||
return createLazyProxy(feature => {
|
||||
const tree = optionsFunc.call(feature, feature);
|
||||
tree.id = getUniqueID("tree-");
|
||||
tree.type = TreeType;
|
||||
tree[Component] = TreeComponent as GenericComponent;
|
||||
|
||||
tree.isResetting = ref(false);
|
||||
tree.resettingNode = shallowRef(null);
|
||||
|
||||
tree.reset = function (node) {
|
||||
const genericTree = tree as GenericTree;
|
||||
genericTree.isResetting.value = true;
|
||||
genericTree.resettingNode.value = node;
|
||||
genericTree.resetPropagation?.(genericTree, node);
|
||||
genericTree.onReset?.(node);
|
||||
genericTree.isResetting.value = false;
|
||||
genericTree.resettingNode.value = null;
|
||||
};
|
||||
tree.links = computed(() => {
|
||||
const genericTree = tree as GenericTree;
|
||||
return unref(genericTree.branches) ?? [];
|
||||
});
|
||||
|
||||
processComputable(tree as T, "visibility");
|
||||
setDefault(tree, "visibility", Visibility.Visible);
|
||||
processComputable(tree as T, "nodes");
|
||||
processComputable(tree as T, "leftSideNodes");
|
||||
processComputable(tree as T, "rightSideNodes");
|
||||
processComputable(tree as T, "branches");
|
||||
|
||||
tree[GatherProps] = function (this: GenericTree) {
|
||||
const { nodes, leftSideNodes, rightSideNodes, branches } = this;
|
||||
return { nodes, leftSideNodes, rightSideNodes, branches };
|
||||
};
|
||||
|
||||
return tree as unknown as Tree<T>;
|
||||
});
|
||||
}
|
||||
|
||||
/** A function that is used to propagate resets through a tree. */
|
||||
export type ResetPropagation = {
|
||||
(tree: GenericTree, resettingNode: GenericTreeNode): void;
|
||||
};
|
||||
|
||||
/** Propagate resets down the tree by resetting every node in a lower row. */
|
||||
export const defaultResetPropagation = function (
|
||||
tree: GenericTree,
|
||||
resettingNode: GenericTreeNode
|
||||
): void {
|
||||
const nodes = unref(tree.nodes);
|
||||
const row = nodes.findIndex(nodes => nodes.includes(resettingNode)) - 1;
|
||||
for (let x = row; x >= 0; x--) {
|
||||
nodes[x].forEach(node => node.reset?.reset());
|
||||
}
|
||||
};
|
||||
|
||||
/** Propagate resets down the tree by resetting every node in a lower row. */
|
||||
export const invertedResetPropagation = function (
|
||||
tree: GenericTree,
|
||||
resettingNode: GenericTreeNode
|
||||
): void {
|
||||
const nodes = unref(tree.nodes);
|
||||
const row = nodes.findIndex(nodes => nodes.includes(resettingNode)) + 1;
|
||||
for (let x = row; x < nodes.length; x++) {
|
||||
nodes[x].forEach(node => node.reset?.reset());
|
||||
}
|
||||
};
|
||||
|
||||
/** Propagate resets down the branches of the tree. */
|
||||
export const branchedResetPropagation = function (
|
||||
tree: GenericTree,
|
||||
resettingNode: GenericTreeNode
|
||||
): void {
|
||||
const links = unref(tree.branches);
|
||||
if (links == null) return;
|
||||
const reset: GenericTreeNode[] = [];
|
||||
let current = [resettingNode];
|
||||
while (current.length != 0) {
|
||||
const next: GenericTreeNode[] = [];
|
||||
for (const node of current) {
|
||||
for (const link of links.filter(link => link.startNode === node)) {
|
||||
if ([...reset, ...current].includes(link.endNode)) continue
|
||||
next.push(link.endNode);
|
||||
link.endNode.reset?.reset();
|
||||
}
|
||||
};
|
||||
reset.push(...current);
|
||||
current = next;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility for creating a tooltip for a tree node that displays a resource-based unlock requirement, and after unlock shows the amount of another resource.
|
||||
* It sounds oddly specific, but comes up a lot.
|
||||
*/
|
||||
export function createResourceTooltip(
|
||||
resource: Resource,
|
||||
requiredResource: Resource | null = null,
|
||||
requirement: Computable<DecimalSource> = 0
|
||||
): Ref<string> {
|
||||
const req = convertComputable(requirement);
|
||||
return computed(() => {
|
||||
if (requiredResource == null || Decimal.gte(resource.value, unref(req))) {
|
||||
return displayResource(resource) + " " + resource.displayName;
|
||||
}
|
||||
return `Reach ${
|
||||
Decimal.eq(requiredResource.precision, 0)
|
||||
? formatWhole(unref(req))
|
||||
: format(unref(req), requiredResource.precision)
|
||||
} ${requiredResource.displayName} to unlock (You have ${
|
||||
Decimal.eq(requiredResource.precision, 0)
|
||||
? formatWhole(requiredResource.value)
|
||||
: format(requiredResource.value, requiredResource.precision)
|
||||
})`;
|
||||
});
|
||||
}
|
284
src/features/trees/tree.tsx
Normal file
284
src/features/trees/tree.tsx
Normal file
|
@ -0,0 +1,284 @@
|
|||
import { Link } from "features/links/links";
|
||||
import type { Reset } from "features/reset";
|
||||
import type { Resource } from "features/resources/resource";
|
||||
import { displayResource } from "features/resources/resource";
|
||||
import Tree from "features/trees/Tree.vue";
|
||||
import TreeNode from "features/trees/TreeNode.vue";
|
||||
import { noPersist } from "game/persistence";
|
||||
import type { DecimalSource } from "util/bignum";
|
||||
import Decimal, { format, formatWhole } from "util/bignum";
|
||||
import { processGetter } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import { Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
|
||||
import type { MaybeRef, MaybeRefOrGetter, Ref } from "vue";
|
||||
import { computed, ref, shallowRef, unref } from "vue";
|
||||
|
||||
/** A symbol used to identify {@link TreeNode} features. */
|
||||
export const TreeNodeType = Symbol("TreeNode");
|
||||
/** A symbol used to identify {@link Tree} features. */
|
||||
export const TreeType = Symbol("Tree");
|
||||
|
||||
/**
|
||||
* An object that configures a {@link TreeNode}.
|
||||
*/
|
||||
export interface TreeNodeOptions extends VueFeatureOptions {
|
||||
/** Whether or not this tree node can be clicked. */
|
||||
canClick?: MaybeRefOrGetter<boolean>;
|
||||
/** The background color for this node. */
|
||||
color?: MaybeRefOrGetter<string>;
|
||||
/** The label to display on this tree node. */
|
||||
display?: MaybeRefOrGetter<Renderable>;
|
||||
/** The color of the glow effect shown to notify the user there's something to do with this node. */
|
||||
glowColor?: MaybeRefOrGetter<string>;
|
||||
/** A reset object attached to this node, used for propagating resets through the tree. */
|
||||
reset?: Reset;
|
||||
/** A function that is called when the tree node is clicked. */
|
||||
onClick?: (e?: MouseEvent | TouchEvent) => void;
|
||||
/** A function that is called when the tree node is held down. */
|
||||
onHold?: VoidFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* The properties that are added onto a processed {@link TreeNodeOptions} to create an {@link TreeNode}.
|
||||
*/
|
||||
export interface TreeNode extends VueFeature {
|
||||
/** Whether or not this tree node can be clicked. */
|
||||
canClick?: MaybeRef<boolean>;
|
||||
/** The background color for this node. */
|
||||
color?: MaybeRef<string>;
|
||||
/** The label to display on this tree node. */
|
||||
display?: MaybeRef<Renderable>;
|
||||
/** The color of the glow effect shown to notify the user there's something to do with this node. */
|
||||
glowColor?: MaybeRef<string>;
|
||||
/** A reset object attached to this node, used for propagating resets through the tree. */
|
||||
reset?: Reset;
|
||||
/** A function that is called when the tree node is clicked. */
|
||||
onClick?: (e?: MouseEvent | TouchEvent) => void;
|
||||
/** A function that is called when the tree node is held down. */
|
||||
onHold?: VoidFunction;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof TreeNodeType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily creates a tree node with the given options.
|
||||
* @param optionsFunc Tree Node options.
|
||||
*/
|
||||
export function createTreeNode<T extends TreeNodeOptions>(optionsFunc?: () => T) {
|
||||
return createLazyProxy(() => {
|
||||
const options = optionsFunc?.() ?? ({} as T);
|
||||
const { canClick, color, display, glowColor, onClick, onHold, ...props } = options;
|
||||
|
||||
const treeNode = {
|
||||
type: TreeNodeType,
|
||||
...(props as Omit<typeof props, keyof VueFeature | keyof TreeNodeOptions>),
|
||||
...vueFeatureMixin("treeNode", options, () => (
|
||||
<TreeNode
|
||||
canClick={treeNode.canClick}
|
||||
display={treeNode.display}
|
||||
onClick={treeNode.onClick}
|
||||
onHold={treeNode.onHold}
|
||||
color={treeNode.color}
|
||||
glowColor={treeNode.glowColor}
|
||||
/>
|
||||
)),
|
||||
canClick: processGetter(canClick) ?? true,
|
||||
color: processGetter(color),
|
||||
display: processGetter(display),
|
||||
glowColor: processGetter(glowColor),
|
||||
onClick:
|
||||
onClick == null
|
||||
? undefined
|
||||
: function (e) {
|
||||
if (unref(treeNode.canClick) !== false) {
|
||||
onClick.call(treeNode, e);
|
||||
}
|
||||
},
|
||||
onHold:
|
||||
onHold == null
|
||||
? undefined
|
||||
: function () {
|
||||
if (unref(treeNode.canClick) !== false) {
|
||||
onHold.call(treeNode);
|
||||
}
|
||||
}
|
||||
} satisfies TreeNode;
|
||||
|
||||
return treeNode;
|
||||
});
|
||||
}
|
||||
|
||||
/** Represents a branch between two nodes in a tree. */
|
||||
export interface TreeBranch extends Omit<Link, "startNode" | "endNode"> {
|
||||
startNode: TreeNode;
|
||||
endNode: TreeNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* An object that configures a {@link Tree}.
|
||||
*/
|
||||
export interface TreeOptions extends VueFeatureOptions {
|
||||
/** The nodes within the tree, in a 2D array. */
|
||||
nodes: MaybeRefOrGetter<TreeNode[][]>;
|
||||
/** Nodes to show on the left side of the tree. */
|
||||
leftSideNodes?: MaybeRefOrGetter<TreeNode[]>;
|
||||
/** Nodes to show on the right side of the tree. */
|
||||
rightSideNodes?: MaybeRefOrGetter<TreeNode[]>;
|
||||
/** The branches between nodes within this tree. */
|
||||
branches?: MaybeRefOrGetter<TreeBranch[]>;
|
||||
/** How to propagate resets through the tree. */
|
||||
resetPropagation?: ResetPropagation;
|
||||
/** A function that is called when a node within the tree is reset. */
|
||||
onReset?: (node: TreeNode) => void;
|
||||
}
|
||||
|
||||
export interface Tree extends VueFeature {
|
||||
/** The nodes within the tree, in a 2D array. */
|
||||
nodes: MaybeRef<TreeNode[][]>;
|
||||
/** Nodes to show on the left side of the tree. */
|
||||
leftSideNodes?: MaybeRef<TreeNode[]>;
|
||||
/** Nodes to show on the right side of the tree. */
|
||||
rightSideNodes?: MaybeRef<TreeNode[]>;
|
||||
/** The branches between nodes within this tree. */
|
||||
branches?: MaybeRef<TreeBranch[]>;
|
||||
/** How to propagate resets through the tree. */
|
||||
resetPropagation?: ResetPropagation;
|
||||
/** A function that is called when a node within the tree is reset. */
|
||||
onReset?: (node: TreeNode) => void;
|
||||
/** The link objects for each of the branches of the tree. */
|
||||
links: MaybeRef<Link[]>;
|
||||
/** Cause a reset on this node and propagate it through the tree according to {@link TreeOptions.resetPropagation}. */
|
||||
reset: (node: TreeNode) => void;
|
||||
/** A flag that is true while the reset is still propagating through the tree. */
|
||||
isResetting: Ref<boolean>;
|
||||
/** A reference to the node that caused the currently propagating reset. */
|
||||
resettingNode: Ref<TreeNode | null>;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof TreeType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily creates a tree with the given options.
|
||||
* @param optionsFunc Tree options.
|
||||
*/
|
||||
export function createTree<T extends TreeOptions>(optionsFunc: () => T) {
|
||||
return createLazyProxy(() => {
|
||||
const options = optionsFunc();
|
||||
const {
|
||||
branches: _branches,
|
||||
nodes,
|
||||
leftSideNodes,
|
||||
rightSideNodes,
|
||||
resetPropagation,
|
||||
onReset,
|
||||
style: _style,
|
||||
...props
|
||||
} = options;
|
||||
|
||||
const style = processGetter(_style);
|
||||
options.style = () => ({ position: "static", ...(unref(style) ?? {}) });
|
||||
|
||||
const branches = _branches == null ? undefined : processGetter(_branches);
|
||||
|
||||
const tree = {
|
||||
type: TreeType,
|
||||
...(props as Omit<typeof props, keyof VueFeature | keyof TreeOptions>),
|
||||
...vueFeatureMixin("tree", options, () => (
|
||||
<Tree
|
||||
nodes={tree.nodes}
|
||||
leftSideNodes={tree.leftSideNodes}
|
||||
rightSideNodes={tree.rightSideNodes}
|
||||
branches={tree.branches}
|
||||
/>
|
||||
)),
|
||||
branches,
|
||||
isResetting: ref(false),
|
||||
resettingNode: shallowRef<TreeNode | null>(null),
|
||||
nodes: processGetter(nodes),
|
||||
leftSideNodes: processGetter(leftSideNodes),
|
||||
rightSideNodes: processGetter(rightSideNodes),
|
||||
links: branches == null ? [] : noPersist(branches),
|
||||
resetPropagation,
|
||||
onReset,
|
||||
reset: function (node: TreeNode) {
|
||||
tree.isResetting.value = true;
|
||||
tree.resettingNode.value = node;
|
||||
tree.resetPropagation?.(tree, node);
|
||||
tree.onReset?.(node);
|
||||
tree.isResetting.value = false;
|
||||
tree.resettingNode.value = null;
|
||||
}
|
||||
} satisfies Tree;
|
||||
|
||||
return tree;
|
||||
});
|
||||
}
|
||||
|
||||
/** A function that is used to propagate resets through a tree. */
|
||||
export type ResetPropagation = {
|
||||
(tree: Tree, resettingNode: TreeNode): void;
|
||||
};
|
||||
|
||||
/** Propagate resets down the tree by resetting every node in a lower row. */
|
||||
export const defaultResetPropagation = function (tree: Tree, resettingNode: TreeNode): void {
|
||||
const nodes = unref(tree.nodes);
|
||||
const row = nodes.findIndex(nodes => nodes.includes(resettingNode)) - 1;
|
||||
for (let x = row; x >= 0; x--) {
|
||||
nodes[x].forEach(node => node.reset?.reset());
|
||||
}
|
||||
};
|
||||
|
||||
/** Propagate resets down the tree by resetting every node in a lower row. */
|
||||
export const invertedResetPropagation = function (tree: Tree, resettingNode: TreeNode): void {
|
||||
const nodes = unref(tree.nodes);
|
||||
const row = nodes.findIndex(nodes => nodes.includes(resettingNode)) + 1;
|
||||
for (let x = row; x < nodes.length; x++) {
|
||||
nodes[x].forEach(node => node.reset?.reset());
|
||||
}
|
||||
};
|
||||
|
||||
/** Propagate resets down the branches of the tree. */
|
||||
export const branchedResetPropagation = function (tree: Tree, resettingNode: TreeNode): void {
|
||||
const links = unref(tree.branches);
|
||||
if (links == null) return;
|
||||
const reset: TreeNode[] = [];
|
||||
let current = [resettingNode];
|
||||
while (current.length !== 0) {
|
||||
const next: TreeNode[] = [];
|
||||
for (const node of current) {
|
||||
for (const link of links.filter(link => link.startNode === node)) {
|
||||
if ([...reset, ...current].includes(link.endNode)) continue;
|
||||
next.push(link.endNode);
|
||||
link.endNode.reset?.reset();
|
||||
}
|
||||
}
|
||||
reset.push(...current);
|
||||
current = next;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility for creating a tooltip for a tree node that displays a resource-based unlock requirement, and after unlock shows the amount of another resource.
|
||||
* It sounds oddly specific, but comes up a lot.
|
||||
*/
|
||||
export function createResourceTooltip(
|
||||
resource: Resource,
|
||||
requiredResource: Resource | null = null,
|
||||
requirement: MaybeRefOrGetter<DecimalSource> = 0
|
||||
): Ref<string> {
|
||||
const req = processGetter(requirement);
|
||||
return computed(() => {
|
||||
if (requiredResource == null || Decimal.gte(resource.value, unref(req))) {
|
||||
return displayResource(resource) + " " + resource.displayName;
|
||||
}
|
||||
return `Reach ${
|
||||
Decimal.eq(requiredResource.precision, 0)
|
||||
? formatWhole(unref(req))
|
||||
: format(unref(req), requiredResource.precision)
|
||||
} ${requiredResource.displayName} to unlock (You have ${
|
||||
Decimal.eq(requiredResource.precision, 0)
|
||||
? formatWhole(requiredResource.value)
|
||||
: format(requiredResource.value, requiredResource.precision)
|
||||
})`;
|
||||
});
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue