mirror of
https://github.com/thepaperpilot/Super-Auto-Coots.git
synced 2024-11-24 01:11:47 +00:00
Initial commit
This commit is contained in:
commit
a6beb1261f
146 changed files with 35687 additions and 0 deletions
36
.eslintrc.js
Normal file
36
.eslintrc.js
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
require("@rushstack/eslint-patch/modern-module-resolution");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: {
|
||||||
|
node: true
|
||||||
|
},
|
||||||
|
extends: [
|
||||||
|
"plugin:vue/vue3-essential",
|
||||||
|
"@vue/eslint-config-typescript/recommended",
|
||||||
|
"@vue/eslint-config-prettier"
|
||||||
|
],
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
project: "tsconfig.json"
|
||||||
|
},
|
||||||
|
ignorePatterns: ["src/lib"],
|
||||||
|
rules: {
|
||||||
|
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||||
|
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||||
|
"vue/script-setup-uses-vars": "warn",
|
||||||
|
"vue/no-mutating-props": "off",
|
||||||
|
"vue/multi-word-component-names": "off",
|
||||||
|
"@typescript-eslint/strict-boolean-expressions": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
allowNullableObject: true,
|
||||||
|
allowNullableBoolean: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
defineProps: "readonly",
|
||||||
|
defineEmits: "readonly"
|
||||||
|
}
|
||||||
|
};
|
25
.github/workflows/deploy.yml
vendored
Normal file
25
.github/workflows/deploy.yml
vendored
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
name: Build and Deploy
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'main'
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
if: github.repository != 'profectus-engine/Profectus' # Don't build placeholder mod on main repo
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout 🛎️
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Install and Build 🔧 # This example project is built using npm and outputs the result to the 'build' folder. Replace with the commands required to build your project, or remove this step entirely if your site is pre-built.
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Deploy 🚀
|
||||||
|
uses: JamesIves/github-pages-deploy-action@v4.2.5
|
||||||
|
with:
|
||||||
|
branch: gh-pages # The branch the action should deploy to.
|
||||||
|
folder: dist # The folder the action should deploy.
|
21
.github/workflows/test.yml
vendored
Normal file
21
.github/workflows/test.yml
vendored
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
name: Build and Deploy
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
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
|
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
/dist
|
||||||
|
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
7
.prettierrc.json
Normal file
7
.prettierrc.json
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"endOfLine": "auto",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"trailingComma": "none"
|
||||||
|
}
|
13
.replit
Normal file
13
.replit
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
run = "npm install; npm run dev"
|
||||||
|
|
||||||
|
[packager]
|
||||||
|
language = "nodejs"
|
||||||
|
[packager.features]
|
||||||
|
packageSearch = true
|
||||||
|
guessImports = false
|
||||||
|
|
||||||
|
[languages.javascript]
|
||||||
|
pattern = "**/{*.js,*.jsx,*.ts,*.tsx}"
|
||||||
|
|
||||||
|
[languages.javascript.languageServer]
|
||||||
|
start = [ "typescript-language-server", "--stdio" ]
|
23
.vscode/launch.json
vendored
Normal file
23
.vscode/launch.json
vendored
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"type": "pwa-node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Debug Current Test File",
|
||||||
|
"autoAttachChildProcesses": true,
|
||||||
|
"skipFiles": [
|
||||||
|
"<node_internals>/**",
|
||||||
|
"**/node_modules/**"
|
||||||
|
],
|
||||||
|
"program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"${relativeFile}"
|
||||||
|
],
|
||||||
|
"smartStep": true,
|
||||||
|
"console": "integratedTerminal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
12
.vscode/settings.json
vendored
Normal file
12
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"vitest.commandLine": "npx vitest",
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": true
|
||||||
|
},
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"git.ignoreLimitWarning": true,
|
||||||
|
"[typescriptreact]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib"
|
||||||
|
}
|
322
CHANGELOG.md
Normal file
322
CHANGELOG.md
Normal file
|
@ -0,0 +1,322 @@
|
||||||
|
# Changelog
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.5.2] - 2022-08-22
|
||||||
|
### Added
|
||||||
|
- onLoad event
|
||||||
|
- fontsLoaded event
|
||||||
|
- Dismissable notification you can add to VueFeatures when they're interactable
|
||||||
|
- Option on exponential modifiers to better support numbers less than 1
|
||||||
|
- Utility function to track if a VueFeature is being hovered over
|
||||||
|
- Utility to unwrap Resources that may be in refs
|
||||||
|
- Utility to join JSX elements together with a joiner
|
||||||
|
- Type for converting readonly string arrays into a union of string values
|
||||||
|
### Changed
|
||||||
|
- The main and prestige layers no longer use arrow functions for their options functions
|
||||||
|
- Modifiers are now lazily loaded
|
||||||
|
- Collapsible modifier sections are now lazily loaded
|
||||||
|
- Converted several refs into shallow refs for improved performance
|
||||||
|
- Roboto Mono and Material Icons fonts are now bundled instead of downloaded from the web, so they work with PWAs
|
||||||
|
- Node bounds are now updated whenever that context has a node removed or added, fixing many issues with incorrect bounds
|
||||||
|
### Fixed
|
||||||
|
- trackResetTime not updating
|
||||||
|
- colorText prepending $s
|
||||||
|
- Default .replit config was broken
|
||||||
|
- Pixi.js canvases no longer rendering
|
||||||
|
- Node positions being shifted on initial page load due to fonts loading on firefox
|
||||||
|
- Modifier sections looked wrong if the topmost section wasn't visible
|
||||||
|
|
||||||
|
## [0.5.1] - 2022-07-17
|
||||||
|
### Added
|
||||||
|
- Notif component that displays a jumping exclamation point
|
||||||
|
- showAmount boolean to buyable displays
|
||||||
|
- Tab families now take option to style the tab buttons container
|
||||||
|
- Utility for creating text of a certain color
|
||||||
|
### Changed
|
||||||
|
- Improved typing of player.layers
|
||||||
|
- Improved typing of createCollapsibleModifierSections's parameters
|
||||||
|
- Made Particles vue component typed as GenericComponent due to issues generating documentation
|
||||||
|
- Minimized how much of pixi.js is included in the built site
|
||||||
|
- Split bundles into smaller bundles for faster loading
|
||||||
|
- Updated TypeScript
|
||||||
|
- Descriptions on buyables are now optional
|
||||||
|
- Improved tooltips performance
|
||||||
|
- Improved how MainDisplay displays effect strings
|
||||||
|
- MainDisplays are now sticky
|
||||||
|
- processComputable now binds uncached functions as well
|
||||||
|
### Fixed
|
||||||
|
- trackResetTime stopped working once its layer was removed and re-added
|
||||||
|
- Runtime compilation was disabled in vite config
|
||||||
|
- Websites had to be hosted on root directory to have assets load correctly
|
||||||
|
- Tooltips' persistent ref was lazily created
|
||||||
|
- In some situations Links would not update its bounding rect
|
||||||
|
- Achievements' and milestones' onComplete callbacks were firing on load
|
||||||
|
- Processed JSXFunctions were not considered coercable components by isCoercableComponent
|
||||||
|
- Error from passing in overlay text to bar component
|
||||||
|
### Removed
|
||||||
|
- lodash.cloneDeep dependency, which hasn't been used in awhile
|
||||||
|
- Some unused configs from vue-cli-service
|
||||||
|
### Documented
|
||||||
|
- Update vitepress, and updated the content of many pages
|
||||||
|
- Rest of /game
|
||||||
|
- Rest of /data
|
||||||
|
- layers.tsx
|
||||||
|
- Any type augmentations to Window object
|
||||||
|
- Various cleanup of docs comments
|
||||||
|
- Fixed doc generation being broken from switch to vite
|
||||||
|
### Tests
|
||||||
|
- Switched from jest to vitest
|
||||||
|
|
||||||
|
## [0.5.0] - 2022-06-27
|
||||||
|
### Added
|
||||||
|
- Projects now cache for offline play, and show notification when an update is available
|
||||||
|
- Projects can now be "installed" as a Progressive Web App
|
||||||
|
- Conversions can now be given a custom spend function, which defaults to setting the base resource amount to 0
|
||||||
|
- Components for displaying Floor and Square Root symbols
|
||||||
|
### Changed
|
||||||
|
- **BREAKING** Several projInfo properties now default to empty strings, to prevent things like reusing project IDs
|
||||||
|
- **BREAKING** Replaced vue-cli-service with vite (should not break most projects)
|
||||||
|
- Updated dependencies
|
||||||
|
- Made all type-only imports explicit
|
||||||
|
- setupPassiveGeneration now works properly on independent conversions
|
||||||
|
- setupPassiveGeneration now takes an option cap it can't go over
|
||||||
|
- Improved typing for PlayerData.layers
|
||||||
|
- Options Functions have an improved `this` type - it now includes the options themselves
|
||||||
|
- Removed v-show being used in data/common.tsx
|
||||||
|
### Tests
|
||||||
|
- Implement Jest, and running tests automatically on push
|
||||||
|
- Tests written for utils/common.ts
|
||||||
|
|
||||||
|
## [0.4.2] - 2022-05-23
|
||||||
|
### Added
|
||||||
|
- costModifier to conversions
|
||||||
|
- onConvert(amountGained) to conversions
|
||||||
|
### Changed
|
||||||
|
- **BREAKING** getFirstFeature has a new signature, that will lead to improved performance
|
||||||
|
- trackResetTime is now intended to be used with a reset button
|
||||||
|
- regularFormat handles small numbers better
|
||||||
|
- Slider tooltips now appear below the slider, not above
|
||||||
|
- Node's mutation observers now ignore attributes. This shouldn't have issues with links/particle effect positions, but prevents a _lot_ of unnecessary node updates
|
||||||
|
- OptionsFunc no longer takes its S type parameter, as it was unnecessary. Layer options functions now have proper `this` typing
|
||||||
|
- Several functions have been updated to take BaseLayer instead of GenericLayer, to allow them to work with `this` inside layer options functions
|
||||||
|
### Fixed
|
||||||
|
- Particle effects and links would not always appear on reload or when switching layers
|
||||||
|
- Particle effects and links no longer appear in wrong spot after nodes are added or removed
|
||||||
|
- Collapsibles having wrong widths on the button and collapsed content sections
|
||||||
|
- Additive modifiers with negative values appeared like "+-" instead of "-"
|
||||||
|
- Buyables' onPurchase was not being called
|
||||||
|
- Reset button would display "Next:" if the buyMax property is a ref
|
||||||
|
|
||||||
|
## [0.4.1] - 2022-05-10
|
||||||
|
### Added
|
||||||
|
- findFeatures can now accept multiple feature types
|
||||||
|
- excludeFeatures can now be used to find features with a feature type _blacklist_
|
||||||
|
- All the icons in the saves manager now have tooltips
|
||||||
|
### Changed
|
||||||
|
- All touch events that can be passive now are
|
||||||
|
- Layers' style and classes attributes are now applied to the tab element rather than the layer-tab
|
||||||
|
- Saving now always uses lz-string, and saveEncoding has been renamed to exportEncoding
|
||||||
|
- The property will now only affect exports, and defaults to base64 so exports can be shared in more places without issues
|
||||||
|
- Buyables can now have their onClick/purchase function overwritten
|
||||||
|
### Fixed
|
||||||
|
- Arrays in player were not being wrapped in proxies for things like NaN detection
|
||||||
|
- Error when switching between saves with different layers
|
||||||
|
- Links would sometimes error from trying to use nodes that were removed earlier that frame
|
||||||
|
- createModifierSection would require modifiers to have revert and enabled properties despite not using them
|
||||||
|
- Tab buttons would not use the style property if it was a ref
|
||||||
|
- Typings on the Board vue component were incorrect
|
||||||
|
- Offline time would always show, if offlineLimit is set to 0
|
||||||
|
- Buyables will now call onPurchase() when cost and/or resource were not set
|
||||||
|
- Presets dropdown wouldn't deselect the option after creating the save
|
||||||
|
### Documented
|
||||||
|
- feature.ts
|
||||||
|
|
||||||
|
## [0.4.0] - 2022-05-01
|
||||||
|
### Added
|
||||||
|
- Saves can now be encoded in two new options: plaintext and lz compressed, determined by a new "saveEncoding" property in projInfo
|
||||||
|
- Saves will be loaded in whatever format is detected. The setting only applies when writing saves
|
||||||
|
- createModifierSection has new parameter to override the label used for the base value
|
||||||
|
- createCollapsibleModifierSections utility function to display `createModifierSection`s in collapsible forms
|
||||||
|
### Fixed
|
||||||
|
- Saves manager would not clear the current save from its cache when switching saves, leading to progress loss if flipping between saves
|
||||||
|
- Layer.minWidth being ignored
|
||||||
|
- Separators between tabs (player.tabs) would not extend to the bottom of the screen when scrolling
|
||||||
|
- Tree nodes not being clicked on their edges
|
||||||
|
### Changed
|
||||||
|
- **BREAKING** No features extend persistent anymore
|
||||||
|
- This will break ALL existing saves that aren't manually dealt with in fixOldSave
|
||||||
|
- Affected features: Achievement, Buyable, Grid, Infobox, Milestone, TabFamily, and Upgrade
|
||||||
|
- Affected features will now have a property within them where the persistent ref is stored. This means new persistent refs can now be safely added to these features
|
||||||
|
- Features with option functions with 0 required properties now don't require passing in an options function
|
||||||
|
- Improved the look of the goBack and minimize buttons (and made them more consistent with each other)
|
||||||
|
- Newly created saves are immediately switched to
|
||||||
|
- TooltipDirection and Direction have been merged into one enum
|
||||||
|
- Made layers shallow reactive, so it works better with dynamic layers
|
||||||
|
- Modifier functions all have more explicit types now
|
||||||
|
- Scaling functions take computables instead of processed computables
|
||||||
|
### Removed
|
||||||
|
- Unused tsParticles.d.ts file
|
||||||
|
### Documented
|
||||||
|
- modifiers.ts
|
||||||
|
- conversions.ts
|
||||||
|
|
||||||
|
## [0.3.3] - 2022-04-24
|
||||||
|
### Fixed
|
||||||
|
- Spacing between rows in Tree components
|
||||||
|
- Computed style attributes on tooltips were ignored
|
||||||
|
- Tooltips could cause infinite loops due to cyclical dependencies
|
||||||
|
|
||||||
|
## [0.3.2] - 2022-04-23
|
||||||
|
### Fixed
|
||||||
|
- Clickables and several other elements would not register clicks sometimes, if the display is updating rapidly
|
||||||
|
- createLayerTreeNode wasn't using display option correctly
|
||||||
|
|
||||||
|
## [0.3.1] - 2022-04-23
|
||||||
|
### Added
|
||||||
|
- Render utility methods that always return JSX Elements
|
||||||
|
### Changed
|
||||||
|
- **BREAKING** Tooltips overhaul
|
||||||
|
- Tree Nodes no longer have tooltips related properties
|
||||||
|
- Tooltips can now be added to any feature with a Vue component using the `addTooltip` function
|
||||||
|
- Any tooltip can be made pinnable by setting pinnable to true in the addTooltip options, or by passing a `Ref<boolean>` to a Tooltip component
|
||||||
|
- Pinned tooltips have an icon to represent that. It can be disabled by setting the theme's `showPin` property to false
|
||||||
|
- Modifiers are now their own features rather than a part of conversions
|
||||||
|
- Including utilities to display the current state of all the modifiers
|
||||||
|
- TabFamilies' options function is now optional
|
||||||
|
- Layer.minWidth can take string values
|
||||||
|
- If parseable into a number, it'll have "px" appended. Otherwise it'll be un-processed
|
||||||
|
- TreeNodes now have Vue components attached to them
|
||||||
|
- `createResourceTooltip` now shows the resource name
|
||||||
|
- Made classic and aqua theme's `feature-foreground` color dark rather than light
|
||||||
|
|
||||||
|
## [0.3.0] - 2022-04-10
|
||||||
|
### Added
|
||||||
|
- conversion.currentAt [#4](https://github.com/profectus-engine/Profectus/pull/4)
|
||||||
|
- OptionsFunc utility type, improving type inferencing in feature types
|
||||||
|
- minimumGain property to ResetButton, defaulting to 1
|
||||||
|
### Changed
|
||||||
|
- **BREAKING** Major persistence rework
|
||||||
|
- Removed makePersistent
|
||||||
|
- Removed old Persistent, and renamed PersistentRef to Persistent
|
||||||
|
- createLazyProxy now takes optional base object (replacing use cases for makePersistent)
|
||||||
|
- Added warnings when creating refs outside a layer
|
||||||
|
- Added warnings when persistent refs aren't included in their layer object
|
||||||
|
- **BREAKING** createLayer now takes id as the first param, rather than inside the option function
|
||||||
|
- resetButton now shows "Req:" instead of "Next:" when conversion.buyMax is false
|
||||||
|
- Conversion nextAt and currentAt now cap at 0 after reverting modifier
|
||||||
|
### Fixed
|
||||||
|
- Independent conversion gain calculation [#4](https://github.com/profectus-engine/Profectus/pull/4)
|
||||||
|
- Persistence issue when loading layer dynamically
|
||||||
|
- resetButton's gain and requirement display being incorrect when conversion.buyMax is false
|
||||||
|
- Independent conversions with buyMax false capping incorrectly
|
||||||
|
|
||||||
|
## [0.2.2] - 2022-04-01
|
||||||
|
Unironically posting an update on April Fool's Day ;)
|
||||||
|
### Changed
|
||||||
|
- **BREAKING** Replaced tsparticles with pixi-emitter. Different options, and behaves differently.
|
||||||
|
- Print key and value in lazy proxy's setter message
|
||||||
|
- Update bounding boxes after web fonts load in
|
||||||
|
### Removed
|
||||||
|
- safff.txt
|
||||||
|
|
||||||
|
## [0.2.1] - 2022-03-29
|
||||||
|
### Changed
|
||||||
|
- **BREAKING** Reworked conversion.modifyGainAmount into conversion.gainModifier, with several utility functions. This makes nextAt accurate with modified gain
|
||||||
|
### Fixed
|
||||||
|
- Made overlay nav not overlap leftmost layer
|
||||||
|
|
||||||
|
## [0.2.0] - 2022-03-27
|
||||||
|
### Added
|
||||||
|
- Particles feature
|
||||||
|
- Collapsible layout component
|
||||||
|
- Utility function for splitting off the first from the list of features that meets a given filter
|
||||||
|
### Changed
|
||||||
|
- **BREAKING** Reworked most of the code from Links into a generic Context component that manages the positions of features in the DOM
|
||||||
|
- Updated vue-cli and TS dependencies
|
||||||
|
- Challenges cannot be started when maxed, and `canStart` now defaults to `true`
|
||||||
|
- onClick listeners on various features now get passed a MouseEvent or TouchEvent when possible
|
||||||
|
- Minor style changes to Milestones, most notably removing min-height
|
||||||
|
### Fixed
|
||||||
|
- Buyables didn't support CoercableComponents for displays
|
||||||
|
- TreeNodes would have a double glow effect on hover
|
||||||
|
### Removed
|
||||||
|
- Unused mousemove listener attached to App.vue
|
||||||
|
|
||||||
|
## [0.1.4] - 2022-03-13
|
||||||
|
### Added
|
||||||
|
- You can now access this.on() from within a createLayer function (and other BaseLayer properties)
|
||||||
|
- Support for passing non-persistent refs to createResource
|
||||||
|
- dontMerge class to allow features to ignore mergeAdjacent
|
||||||
|
### Fixed
|
||||||
|
- Clickables would not merge adjacent
|
||||||
|
- onClick and onHold functions would not be bound to their object when being called
|
||||||
|
- Refs passed to a components style prop would be ignored
|
||||||
|
- Fixed z-index issue when stopping hovering over features with .can class
|
||||||
|
|
||||||
|
## [0.1.3] - 2022-03-11
|
||||||
|
### Added
|
||||||
|
- Milestone.complete
|
||||||
|
- Challenge.complete
|
||||||
|
- setupAutoClick function to run a clickable's onClick every tick
|
||||||
|
- setupAutoComplete function to attempt to complete a challenge every tick
|
||||||
|
- isAnyChallengeActive function to query if any challenge from a given list is active
|
||||||
|
- Hotkeys now appear in info modal, if any exist
|
||||||
|
- projInfo.json now includes a "enablePausing" option that can be used to prevent the player from pausing the game
|
||||||
|
- Added a "gameWon" global event
|
||||||
|
### Changed
|
||||||
|
- **BREAKING** Buyables now default to an infinite purchase limit
|
||||||
|
- **BREAKING** devSpeed, playedTime, offlineTime, and diff now use numbers instead of Decimals
|
||||||
|
- **BREAKING** Achievements and milestones now use watchEffect to check for completion, instead of polling each tick. shouldEarn properties now only accept functions
|
||||||
|
- Cached more decimal values for optimization
|
||||||
|
### Fixed
|
||||||
|
- Many types not being exported
|
||||||
|
- setupHoldToClick wouldn't stop clicking after a component is unmounted
|
||||||
|
- Header's banner would not have correct width
|
||||||
|
### Removed
|
||||||
|
- **BREAKING** Removed setupAutoReset
|
||||||
|
### Documentation
|
||||||
|
- Support for documentation generation using typedoc
|
||||||
|
- Hide main layer from docs
|
||||||
|
- Hide prestige layer from docs
|
||||||
|
- Use stub declaration files for libs that don't provide types (vue-panzoom and vue-textarea-autosize)
|
||||||
|
|
||||||
|
## [0.1.2] - 2022-03-05
|
||||||
|
### Changed
|
||||||
|
- **BREAKING** Removed "@" path alias, and used baseUrl instead
|
||||||
|
- **BREAKING** Renamed createExponentialScaling to createPolynomialScaling and removed coefficient parameter
|
||||||
|
- Changed options passed into createLayerTreeNode; now allows overriding display
|
||||||
|
- App component is no longer cloned before being passed to `createApp`
|
||||||
|
- Changed TS version from ^4.5.4 to ~4.5.5
|
||||||
|
### Fixed
|
||||||
|
- Document title is set as soon as possible now
|
||||||
|
|
||||||
|
## [0.1.1] - 2022-03-02
|
||||||
|
### Added
|
||||||
|
- Configuration for Glitch projects
|
||||||
|
- Configuration for Replit projects
|
||||||
|
- Hide versionTitle if blank
|
||||||
|
### Changed
|
||||||
|
- **BREAKING** Renamed modInfo.json -> projInfo.json
|
||||||
|
- **BREAKING** Renamed mod.tsx -> projEntry.tsx
|
||||||
|
- Improved performance of branch drawing code
|
||||||
|
- Improved performance of formatting numbers
|
||||||
|
- Changed some projInfo default values to empty strings
|
||||||
|
- Renamed projInfo.allowSmall -> projInfo.defaultShowSmall
|
||||||
|
### Fixed
|
||||||
|
- Spacing on discord logo in NaN screen
|
||||||
|
- Some files accessing old location for persistence code
|
||||||
|
- Fixed lint-staged not being listed in devDependencies
|
||||||
|
- Branch locations were not accurate after scrolling
|
||||||
|
- Saves Manager displayed "default body" while closing
|
||||||
|
- Reset buttons activating when held down when canClick is false
|
||||||
|
- Lifting up on auto clickable elements not stopping the auto clicker
|
||||||
|
### Removed
|
||||||
|
- Removed Theme.stackedInfoboxes
|
||||||
|
- Removed Theme.showSingleTab
|
||||||
|
|
||||||
|
## [0.1.0] - Initial Release
|
21
LICENSE
Normal file
21
LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2021 Anthony Lawn
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
37
README.md
Normal file
37
README.md
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
# Profectus
|
||||||
|
|
||||||
|
A game engine that grows with you
|
||||||
|
|
||||||
|
[![Run on Repl.it](https://repl.it/badge/github/profectus-engine/Profectus)](https://repl.it/github/profectus-engine/Profectus)
|
||||||
|
|
||||||
|
[Read the docs](https://moddingtree.com)
|
||||||
|
|
||||||
|
## Project setup
|
||||||
|
```
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hosts dev server and hot-reloads modules as they're changed
|
||||||
|
```
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compiles and minifies for production
|
||||||
|
```
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hosts the production build
|
||||||
|
```
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lints and fixes files
|
||||||
|
```
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Runs the tests using vite-jest
|
||||||
|
```
|
||||||
|
npm run test
|
||||||
|
```
|
24
index.html
Normal file
24
index.html
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<meta name="theme-color" content="#2E3440">
|
||||||
|
|
||||||
|
<title>Profectus</title>
|
||||||
|
<meta name="description" content="A project made in Profectus"/>
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<strong>We're sorry but Profectus doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||||
|
</noscript>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="./src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
13799
package-lock.json
generated
Normal file
13799
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
57
package.json
Normal file
57
package.json
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
{
|
||||||
|
"name": "profectus",
|
||||||
|
"version": "0.5.2",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"start": "vite",
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"testw": "vitest",
|
||||||
|
"serve": "vite preview --host"
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"@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",
|
||||||
|
"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",
|
||||||
|
"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",
|
||||||
|
"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": "^4.7.4",
|
||||||
|
"vitest": "^0.28.5",
|
||||||
|
"vue-tsc": "^0.38.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "16.x"
|
||||||
|
}
|
||||||
|
}
|
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.5 KiB |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
24
public/favicon.svg
Normal file
24
public/favicon.svg
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
<svg width="656" height="649" viewBox="0 0 656 649" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g filter="url(#filter0_d_65_3)">
|
||||||
|
<rect x="26" y="21" width="600" height="600" rx="30" fill="#2E3440"/>
|
||||||
|
</g>
|
||||||
|
<path d="M313 572.877C295.453 567.525 262.205 562.614 233 572.877C243.737 355.447 238.724 271.878 233 107.877C233 107.877 279.614 70.7611 313 65.8775C346.386 60.9938 399.025 76.3369 433 115.877C438 145.877 428.647 222.534 389 271.878C354.851 314.378 259 310.358 259 364.877C259 466.877 313 572.877 313 572.877Z" stroke="#A3BE8C" stroke-width="10" stroke-miterlimit="16" stroke-linecap="round"/>
|
||||||
|
<path d="M433 115.878C421.023 186.453 397.39 226.835 370.997 251.5C305.783 312.444 223.719 277.436 259 364.877" stroke="#A3BE8C" stroke-width="5" stroke-miterlimit="16" stroke-linecap="round"/>
|
||||||
|
<path d="M406.5 248C395.275 252.447 387.434 253.134 370.997 251.5" stroke="#A3BE8C" stroke-width="5" stroke-miterlimit="16" stroke-linecap="round"/>
|
||||||
|
<path d="M285.5 306.5C323.145 305.626 339.011 298.775 368.5 288" stroke="#A3BE8C" stroke-width="5" stroke-miterlimit="16" stroke-linecap="round"/>
|
||||||
|
<path d="M433 115.877C381.5 77.8262 298.094 104.947 259 167C254.257 174.528 251.896 182.373 250.674 191C247.146 215.91 253.117 247.34 238.673 296.5" stroke="#A3BE8C" stroke-width="5" stroke-miterlimit="16" stroke-linecap="round"/>
|
||||||
|
<path d="M233 107.878L250.674 191" stroke="#A3BE8C" stroke-width="5" stroke-miterlimit="16" stroke-linecap="round"/>
|
||||||
|
<path d="M215 386.5C228.178 408.708 231.486 429.334 236.7 467.5" stroke="#A3BE8C" stroke-width="5" stroke-miterlimit="16" stroke-linecap="round"/>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_d_65_3" x="22" y="21" width="608" height="608" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset dy="4"/>
|
||||||
|
<feGaussianBlur stdDeviation="2"/>
|
||||||
|
<feComposite in2="hardAlpha" operator="out"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
|
||||||
|
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_65_3"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_65_3" result="shape"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/pwa-192x192.png
Normal file
BIN
public/pwa-192x192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 10 KiB |
BIN
public/pwa-512x512.png
Normal file
BIN
public/pwa-512x512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 38 KiB |
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
7
replit.nix
Normal file
7
replit.nix
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{ pkgs }: {
|
||||||
|
deps = [
|
||||||
|
pkgs.nodejs-16_x
|
||||||
|
pkgs.nodePackages.typescript-language-server
|
||||||
|
pkgs.nodePackages.npm
|
||||||
|
];
|
||||||
|
}
|
0
saves/.placehold
Normal file
0
saves/.placehold
Normal file
53
src/App.vue
Normal file
53
src/App.vue
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
<template>
|
||||||
|
<div id="modal-root" :style="theme" />
|
||||||
|
<div class="app" :style="theme" :class="{ useHeader }">
|
||||||
|
<Nav v-if="useHeader" />
|
||||||
|
<Game />
|
||||||
|
<TPS v-if="unref(showTPS)" />
|
||||||
|
<GameOverScreen />
|
||||||
|
<NaNScreen />
|
||||||
|
<component :is="gameComponent" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="tsx">
|
||||||
|
import { jsx } from "features/feature";
|
||||||
|
import { coerceComponent, render } from "util/vue";
|
||||||
|
import { computed, toRef, unref } from "vue";
|
||||||
|
import Game from "./components/Game.vue";
|
||||||
|
import GameOverScreen from "./components/GameOverScreen.vue";
|
||||||
|
import NaNScreen from "./components/NaNScreen.vue";
|
||||||
|
import Nav from "./components/Nav.vue";
|
||||||
|
import TPS from "./components/TPS.vue";
|
||||||
|
import projInfo from "./data/projInfo.json";
|
||||||
|
import themes from "./data/themes";
|
||||||
|
import settings, { gameComponents } from "./game/settings";
|
||||||
|
import "./main.css";
|
||||||
|
import "@fontsource/roboto-mono";
|
||||||
|
import type { CSSProperties } from "vue";
|
||||||
|
|
||||||
|
const useHeader = projInfo.useHeader;
|
||||||
|
const theme = computed(() => themes[settings.theme].variables as CSSProperties);
|
||||||
|
const showTPS = toRef(settings, "showTPS");
|
||||||
|
|
||||||
|
const gameComponent = computed(() => {
|
||||||
|
return coerceComponent(jsx(() => (<>{gameComponents.map(render)}</>)));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app {
|
||||||
|
background-color: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
min-height: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#modal-root {
|
||||||
|
position: absolute;
|
||||||
|
min-height: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
89
src/components/Context.vue
Normal file
89
src/components/Context.vue
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
<template>
|
||||||
|
<slot />
|
||||||
|
<div ref="resizeListener" class="resize-listener" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
RegisterNodeInjectionKey,
|
||||||
|
UnregisterNodeInjectionKey,
|
||||||
|
NodesInjectionKey,
|
||||||
|
BoundsInjectionKey
|
||||||
|
} from "game/layers";
|
||||||
|
import type { FeatureNode } from "game/layers";
|
||||||
|
import { nextTick, onMounted, provide, ref } from "vue";
|
||||||
|
import { globalBus } from "game/events";
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "updateNodes", nodes: Record<string, FeatureNode | undefined>): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const nodes = ref<Record<string, FeatureNode | undefined>>({});
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(updateBounds);
|
||||||
|
const resizeListener = ref<Element | null>(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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let isDirty = true;
|
||||||
|
let boundingRect = ref(resizeListener.value?.getBoundingClientRect());
|
||||||
|
function updateBounds() {
|
||||||
|
if (isDirty) {
|
||||||
|
isDirty = false;
|
||||||
|
nextTick(() => {
|
||||||
|
boundingRect.value = resizeListener.value?.getBoundingClientRect();
|
||||||
|
(Object.values(nodes.value) as FeatureNode[])
|
||||||
|
.filter(n => n) // Sometimes the values become undefined
|
||||||
|
.forEach(node => (node.rect = node.element.getBoundingClientRect()));
|
||||||
|
emit("updateNodes", nodes.value);
|
||||||
|
isDirty = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalBus.on("fontsLoaded", updateBounds);
|
||||||
|
|
||||||
|
const observerOptions = {
|
||||||
|
attributes: false,
|
||||||
|
childList: true,
|
||||||
|
subtree: false
|
||||||
|
};
|
||||||
|
|
||||||
|
provide(RegisterNodeInjectionKey, (id, element) => {
|
||||||
|
const observer = new MutationObserver(() => updateNode(id));
|
||||||
|
observer.observe(element, observerOptions);
|
||||||
|
nodes.value[id] = { element, observer, rect: element.getBoundingClientRect() };
|
||||||
|
updateBounds();
|
||||||
|
});
|
||||||
|
provide(UnregisterNodeInjectionKey, id => {
|
||||||
|
nodes.value[id]?.observer.disconnect();
|
||||||
|
nodes.value[id] = undefined;
|
||||||
|
updateBounds();
|
||||||
|
});
|
||||||
|
provide(NodesInjectionKey, nodes);
|
||||||
|
provide(BoundsInjectionKey, boundingRect);
|
||||||
|
|
||||||
|
function updateNode(id: string) {
|
||||||
|
const node = nodes.value[id];
|
||||||
|
if (node == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
node.rect = node.element.getBoundingClientRect();
|
||||||
|
emit("updateNodes", nodes.value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.resize-listener {
|
||||||
|
position: absolute;
|
||||||
|
top: 0px;
|
||||||
|
left: 0;
|
||||||
|
right: -4px;
|
||||||
|
bottom: 5px;
|
||||||
|
z-index: -10;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
91
src/components/Game.vue
Normal file
91
src/components/Game.vue
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
<template>
|
||||||
|
<div class="tabs-container" :class="{ useHeader }">
|
||||||
|
<div
|
||||||
|
v-for="(tab, index) in tabs"
|
||||||
|
:key="index"
|
||||||
|
class="tab"
|
||||||
|
:style="unref(layers[tab]?.style)"
|
||||||
|
:class="unref(layers[tab]?.classes)"
|
||||||
|
>
|
||||||
|
<Nav v-if="index === 0 && !useHeader" />
|
||||||
|
<div class="inner-tab">
|
||||||
|
<Layer
|
||||||
|
v-if="layerKeys.includes(tab)"
|
||||||
|
v-bind="gatherLayerProps(layers[tab]!)"
|
||||||
|
:index="index"
|
||||||
|
@set-minimized="value => (layers[tab]!.minimized.value = value)"
|
||||||
|
/>
|
||||||
|
<component :is="tab" :index="index" v-else />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import projInfo from "data/projInfo.json";
|
||||||
|
import type { GenericLayer } from "game/layers";
|
||||||
|
import { layers } from "game/layers";
|
||||||
|
import player from "game/player";
|
||||||
|
import { computed, toRef, unref } from "vue";
|
||||||
|
import Layer from "./Layer.vue";
|
||||||
|
import Nav from "./Nav.vue";
|
||||||
|
|
||||||
|
const tabs = toRef(player, "tabs");
|
||||||
|
const layerKeys = computed(() => Object.keys(layers));
|
||||||
|
const useHeader = projInfo.useHeader;
|
||||||
|
|
||||||
|
function gatherLayerProps(layer: GenericLayer) {
|
||||||
|
const { display, minimized, name, color, minimizable, nodes, minimizedDisplay } = layer;
|
||||||
|
return { display, minimized, name, color, minimizable, nodes, minimizedDisplay };
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tabs-container {
|
||||||
|
width: 100vw;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-container:not(.useHeader) {
|
||||||
|
width: calc(100vw - 50px);
|
||||||
|
margin-left: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
flex-grow: 1;
|
||||||
|
transition-duration: 0s;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inner-tab {
|
||||||
|
padding: 50px 10px;
|
||||||
|
min-height: calc(100% - 100px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab + .tab > .inner-tab {
|
||||||
|
border-left: solid 4px var(--outline);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tab hr {
|
||||||
|
height: 4px;
|
||||||
|
border: none;
|
||||||
|
background: var(--outline);
|
||||||
|
margin: var(--feature-margin) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab .modal-body hr {
|
||||||
|
margin: 7px 0;
|
||||||
|
}
|
||||||
|
</style>
|
107
src/components/GameOverScreen.vue
Normal file
107
src/components/GameOverScreen.vue
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
<template>
|
||||||
|
<Modal :model-value="isOpen">
|
||||||
|
<template v-slot:header>
|
||||||
|
<div class="game-over-modal-header">
|
||||||
|
<img class="game-over-modal-logo" v-if="logo" :src="logo" :alt="title" />
|
||||||
|
<div class="game-over-modal-title">
|
||||||
|
<h2>Congratulations!</h2>
|
||||||
|
<h4>You've beaten {{ title }} v{{ versionNumber }}: {{ versionTitle }}</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body="{ shown }">
|
||||||
|
<div v-if="shown">
|
||||||
|
<div>It took you {{ timePlayed }} to beat the game.</div>
|
||||||
|
<br />
|
||||||
|
<div>
|
||||||
|
Please check the Discord to discuss the game or to check for new content
|
||||||
|
updates!
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<div>
|
||||||
|
<a :href="discordLink" class="game-over-modal-discord-link">
|
||||||
|
<span class="material-icons game-over-modal-discord">discord</span>
|
||||||
|
{{ discordName }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<Toggle title="Autosave" v-model="autosave" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:footer>
|
||||||
|
<div class="game-over-footer">
|
||||||
|
<button @click="keepGoing" class="button">Keep Going</button>
|
||||||
|
<button @click="playAgain" class="button danger">Play Again</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</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";
|
||||||
|
|
||||||
|
const { title, logo, discordName, discordLink, versionNumber, versionTitle } = projInfo;
|
||||||
|
|
||||||
|
const timePlayed = computed(() => formatTime(player.timePlayed));
|
||||||
|
const isOpen = computed(() => hasWon.value && !player.keepGoing);
|
||||||
|
const autosave = toRef(player, "autosave");
|
||||||
|
|
||||||
|
function keepGoing() {
|
||||||
|
player.keepGoing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function playAgain() {
|
||||||
|
loadSave(newSave());
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.game-over-modal-header {
|
||||||
|
display: flex;
|
||||||
|
margin: -20px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
background: var(--raised-background);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-over-modal-header * {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-over-modal-logo {
|
||||||
|
height: 4em;
|
||||||
|
width: 4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-over-modal-title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 10px 0;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-over-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-over-footer button {
|
||||||
|
margin: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-over-modal-discord-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-over-modal-discord {
|
||||||
|
margin: 0;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
70
src/components/Hotkey.vue
Normal file
70
src/components/Hotkey.vue
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
<!-- Make eslint not whine about putting spaces before the +'s -->
|
||||||
|
<!-- eslint-disable prettier/prettier -->
|
||||||
|
<template>
|
||||||
|
<template v-if="isCtrl"
|
||||||
|
><div class="key">Ctrl</div
|
||||||
|
>+</template
|
||||||
|
><template v-if="isShift"
|
||||||
|
><div class="key">Shift</div
|
||||||
|
>+</template
|
||||||
|
>
|
||||||
|
<div class="key">{{ key }}</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { GenericHotkey } from "features/hotkey";
|
||||||
|
import { watchEffect } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
hotkey: GenericHotkey;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let key = "";
|
||||||
|
let isCtrl = false;
|
||||||
|
let isShift = false;
|
||||||
|
let isAlpha = false;
|
||||||
|
watchEffect(() => {
|
||||||
|
key = props.hotkey.key;
|
||||||
|
|
||||||
|
isCtrl = key.startsWith("ctrl+");
|
||||||
|
if (isCtrl) {
|
||||||
|
key = key.slice(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
isShift = key.startsWith("shift+");
|
||||||
|
if (isShift) {
|
||||||
|
key = key.slice(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
isAlpha = key.length == 1 && key.toLowerCase() != key.toUpperCase();
|
||||||
|
if (isAlpha) {
|
||||||
|
key = key.toUpperCase();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.key {
|
||||||
|
display: inline-block;
|
||||||
|
height: 1.4em;
|
||||||
|
min-width: 1em;
|
||||||
|
margin-block: 0.1em;
|
||||||
|
padding-inline: 0.2em;
|
||||||
|
vertical-align: 0.1em;
|
||||||
|
|
||||||
|
background: var(--foreground);
|
||||||
|
color: var(--feature-foreground);
|
||||||
|
border: 1px solid #0007;
|
||||||
|
border-radius: 0.3em;
|
||||||
|
box-shadow: 0 0.1em #0007, 0 0.1em var(--foreground);
|
||||||
|
|
||||||
|
font-size: smaller;
|
||||||
|
text-align: center;
|
||||||
|
user-select: none;
|
||||||
|
transition: transform 0s, box-shadow 0s;
|
||||||
|
}
|
||||||
|
.key:active {
|
||||||
|
transform: translateY(0.1em);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
</style>
|
131
src/components/Info.vue
Normal file
131
src/components/Info.vue
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
<template>
|
||||||
|
<Modal v-model="isOpen">
|
||||||
|
<template v-slot:header>
|
||||||
|
<div class="info-modal-header">
|
||||||
|
<img class="info-modal-logo" v-if="logo" :src="logo" :alt="title" />
|
||||||
|
<div class="info-modal-title">
|
||||||
|
<h2>{{ title }}</h2>
|
||||||
|
<h4>
|
||||||
|
v{{ versionNumber }}<span v-if="versionTitle">: {{ versionTitle }}</span>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body="{ shown }">
|
||||||
|
<div v-if="shown">
|
||||||
|
<div v-if="author">By {{ author }}</div>
|
||||||
|
<div>
|
||||||
|
Made in Profectus, by thepaperpilot with inspiration from Acameada and Jacorb
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<div class="link" @click="openChangelog">Changelog</div>
|
||||||
|
<br />
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
:href="discordLink"
|
||||||
|
v-if="discordLink"
|
||||||
|
class="info-modal-discord-link"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<span class="material-icons info-modal-discord">discord</span>
|
||||||
|
{{ discordName }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href="https://discord.gg/WzejVAx"
|
||||||
|
class="info-modal-discord-link"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<span class="material-icons info-modal-discord">discord</span>
|
||||||
|
The Paper Pilot Community
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href="https://discord.gg/F3xveHV"
|
||||||
|
class="info-modal-discord-link"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<span class="material-icons info-modal-discord">discord</span>
|
||||||
|
The Modding Tree
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<div>Time Played: {{ timePlayed }}</div>
|
||||||
|
<component :is="infoComponent" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="tsx">
|
||||||
|
import Modal from "components/Modal.vue";
|
||||||
|
import type Changelog from "data/Changelog.vue";
|
||||||
|
import projInfo from "data/projInfo.json";
|
||||||
|
import { jsx } from "features/feature";
|
||||||
|
import player from "game/player";
|
||||||
|
import { infoComponents } from "game/settings";
|
||||||
|
import { formatTime } from "util/bignum";
|
||||||
|
import { coerceComponent, render } from "util/vue";
|
||||||
|
import { computed, ref, toRefs, unref } from "vue";
|
||||||
|
|
||||||
|
const { title, logo, author, discordName, discordLink, versionNumber, versionTitle } = projInfo;
|
||||||
|
|
||||||
|
const _props = defineProps<{ changelog: typeof Changelog | null }>();
|
||||||
|
const props = toRefs(_props);
|
||||||
|
|
||||||
|
const isOpen = ref(false);
|
||||||
|
|
||||||
|
const timePlayed = computed(() => formatTime(player.timePlayed));
|
||||||
|
|
||||||
|
const infoComponent = computed(() => {
|
||||||
|
return coerceComponent(jsx(() => (<>{infoComponents.map(render)}</>)));
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
open() {
|
||||||
|
isOpen.value = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function openChangelog() {
|
||||||
|
unref(props.changelog)?.open();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.info-modal-header {
|
||||||
|
display: flex;
|
||||||
|
margin: -20px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
background: var(--raised-background);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-modal-header * {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-modal-logo {
|
||||||
|
height: 4em;
|
||||||
|
width: 4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-modal-title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 10px 0;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-modal-discord-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-modal-discord {
|
||||||
|
margin: 0;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
209
src/components/Layer.vue
Normal file
209
src/components/Layer.vue
Normal file
|
@ -0,0 +1,209 @@
|
||||||
|
<template>
|
||||||
|
<div class="layer-container" :style="{ '--layer-color': unref(color) }">
|
||||||
|
<button v-if="showGoBack" class="goBack" @click="goBack">❌</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="layer-tab minimized"
|
||||||
|
v-if="unref(minimized)"
|
||||||
|
@click="$emit('setMinimized', false)"
|
||||||
|
>
|
||||||
|
<component v-if="minimizedComponent" :is="minimizedComponent" />
|
||||||
|
<div v-else>{{ unref(name) }}</div>
|
||||||
|
</button>
|
||||||
|
<div class="layer-tab" :class="{ showGoBack }" v-else>
|
||||||
|
<Context @update-nodes="updateNodes">
|
||||||
|
<component :is="component" />
|
||||||
|
</Context>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button v-if="unref(minimizable)" class="minimize" @click="$emit('setMinimized', true)">
|
||||||
|
▼
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import projInfo from "data/projInfo.json";
|
||||||
|
import type { CoercableComponent } from "features/feature";
|
||||||
|
import type { FeatureNode } from "game/layers";
|
||||||
|
import player from "game/player";
|
||||||
|
import { computeComponent, computeOptionalComponent, processedPropType, unwrapRef } from "util/vue";
|
||||||
|
import type { PropType, Ref } from "vue";
|
||||||
|
import { computed, defineComponent, toRefs, unref } from "vue";
|
||||||
|
import Context from "./Context.vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
components: { Context },
|
||||||
|
props: {
|
||||||
|
index: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
display: {
|
||||||
|
type: processedPropType<CoercableComponent>(Object, String, Function),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
minimizedDisplay: processedPropType<CoercableComponent>(Object, String, Function),
|
||||||
|
minimized: {
|
||||||
|
type: Object as PropType<Ref<boolean>>,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: processedPropType<string>(String),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
color: processedPropType<string>(String),
|
||||||
|
minimizable: processedPropType<boolean>(Boolean),
|
||||||
|
nodes: {
|
||||||
|
type: Object as PropType<Ref<Record<string, FeatureNode | undefined>>>,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: ["setMinimized"],
|
||||||
|
setup(props) {
|
||||||
|
const { display, index, minimized, minimizedDisplay } = toRefs(props);
|
||||||
|
|
||||||
|
const component = computeComponent(display);
|
||||||
|
const minimizedComponent = computeOptionalComponent(minimizedDisplay);
|
||||||
|
const showGoBack = computed(
|
||||||
|
() => projInfo.allowGoBack && index.value > 0 && !unwrapRef(minimized)
|
||||||
|
);
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
player.tabs.splice(unref(props.index), Infinity);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMinimized(min: boolean) {
|
||||||
|
minimized.value = min;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateNodes(nodes: Record<string, FeatureNode | undefined>) {
|
||||||
|
props.nodes.value = nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
component,
|
||||||
|
minimizedComponent,
|
||||||
|
showGoBack,
|
||||||
|
updateNodes,
|
||||||
|
unref,
|
||||||
|
goBack
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.layer-container {
|
||||||
|
min-width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-tab:not(.minimized) {
|
||||||
|
padding-top: 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
min-height: 100%;
|
||||||
|
flex-grow: 1;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inner-tab > .layer-container > .layer-tab:not(.minimized) {
|
||||||
|
padding-top: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-tab.minimized {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
padding: 0;
|
||||||
|
padding-top: 55px;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 40px;
|
||||||
|
color: var(--foreground);
|
||||||
|
border: none;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-tab.minimized > * {
|
||||||
|
margin: 0;
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
text-align: left;
|
||||||
|
padding-left: 10px;
|
||||||
|
width: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inner-tab > .layer-container > .layer-tab:not(.minimized) {
|
||||||
|
margin: -50px -10px;
|
||||||
|
padding: 50px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body .layer-tab {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body .layer-tab:not(.hasSubtabs) {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimize {
|
||||||
|
position: sticky;
|
||||||
|
top: 6px;
|
||||||
|
right: 9px;
|
||||||
|
z-index: 7;
|
||||||
|
line-height: 30px;
|
||||||
|
border: none;
|
||||||
|
background: var(--background);
|
||||||
|
box-shadow: var(--background) 0 2px 3px 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 40px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: -44px;
|
||||||
|
margin-right: -30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimized + .minimize {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
top: 10px;
|
||||||
|
right: 18px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goBack {
|
||||||
|
position: sticky;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
line-height: 30px;
|
||||||
|
margin-top: -50px;
|
||||||
|
margin-left: -35px;
|
||||||
|
border: none;
|
||||||
|
background: var(--background);
|
||||||
|
box-shadow: var(--background) 0 2px 3px 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 30px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goBack:hover {
|
||||||
|
transform: scale(1.1, 1.1);
|
||||||
|
text-shadow: 0 0 7px var(--foreground);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.layer-tab.minimized > * > .desc {
|
||||||
|
color: var(--accent1);
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
</style>
|
61
src/components/MarkNode.vue
Normal file
61
src/components/MarkNode.vue
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="mark">
|
||||||
|
<div v-if="mark === true" class="mark star"></div>
|
||||||
|
<img v-else class="mark" :src="mark" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ mark?: boolean | string }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mark {
|
||||||
|
position: absolute;
|
||||||
|
left: -25px;
|
||||||
|
top: -10px;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
margin-left: 0.9em;
|
||||||
|
margin-right: 0.9em;
|
||||||
|
margin-bottom: 1.2em;
|
||||||
|
border-right: 0.3em solid transparent;
|
||||||
|
border-bottom: 0.7em solid transparent;
|
||||||
|
border-left: 0.3em solid transparent;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star {
|
||||||
|
left: -10px;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
margin-left: 0.9em;
|
||||||
|
margin-right: 0.9em;
|
||||||
|
margin-bottom: 1.2em;
|
||||||
|
border-right: 0.3em solid transparent;
|
||||||
|
border-bottom: 0.7em solid #ffcc00;
|
||||||
|
border-left: 0.3em solid transparent;
|
||||||
|
font-size: 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star::before,
|
||||||
|
.star::after {
|
||||||
|
content: "";
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 0.6em;
|
||||||
|
left: -1em;
|
||||||
|
border-right: 1em solid transparent;
|
||||||
|
border-bottom: 0.7em solid #ffcc00;
|
||||||
|
border-left: 1em solid transparent;
|
||||||
|
transform: rotate(-35deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.star::after {
|
||||||
|
transform: rotate(35deg);
|
||||||
|
}
|
||||||
|
</style>
|
139
src/components/Modal.vue
Normal file
139
src/components/Modal.vue
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
<template>
|
||||||
|
<teleport to="#modal-root">
|
||||||
|
<transition
|
||||||
|
name="modal"
|
||||||
|
@before-enter="isAnimating = true"
|
||||||
|
@after-leave="isAnimating = false"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="modal-mask"
|
||||||
|
v-show="modelValue"
|
||||||
|
v-on:pointerdown.self="close"
|
||||||
|
v-bind="$attrs"
|
||||||
|
>
|
||||||
|
<div class="modal-wrapper">
|
||||||
|
<div class="modal-container">
|
||||||
|
<div class="modal-header">
|
||||||
|
<slot name="header" :shown="isOpen"> default header </slot>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<Context ref="contextRef">
|
||||||
|
<slot name="body" :shown="isOpen"> default body </slot>
|
||||||
|
</Context>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<slot name="footer" :shown="isOpen">
|
||||||
|
<div class="modal-default-footer">
|
||||||
|
<div class="modal-default-flex-grow"></div>
|
||||||
|
<button class="button modal-default-button" @click="close">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { FeatureNode } from "game/layers";
|
||||||
|
import { computed, ref, toRefs, unref } from "vue";
|
||||||
|
import Context from "./Context.vue";
|
||||||
|
|
||||||
|
const _props = defineProps<{
|
||||||
|
modelValue: boolean;
|
||||||
|
}>();
|
||||||
|
const props = toRefs(_props);
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", value: boolean): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isOpen = computed(() => unref(props.modelValue) || isAnimating.value);
|
||||||
|
function close() {
|
||||||
|
emit("update:modelValue", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAnimating = ref(false);
|
||||||
|
|
||||||
|
const contextRef = ref<typeof Context | null>(null);
|
||||||
|
const nodes = computed<Record<string, FeatureNode | undefined> | null>(
|
||||||
|
() => contextRef.value?.nodes ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
defineExpose({ isOpen, nodes });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-mask {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9998;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-container {
|
||||||
|
width: 640px;
|
||||||
|
max-width: 95vw;
|
||||||
|
max-height: 95vh;
|
||||||
|
background-color: var(--background);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-align: left;
|
||||||
|
border: var(--modal-border);
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
margin: 20px 0;
|
||||||
|
width: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-default-footer {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-default-flex-grow {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-leave-active {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-from .modal-container,
|
||||||
|
.modal-leave-active .modal-container {
|
||||||
|
-webkit-transform: scale(1.1);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
</style>
|
130
src/components/NaNScreen.vue
Normal file
130
src/components/NaNScreen.vue
Normal file
|
@ -0,0 +1,130 @@
|
||||||
|
<template>
|
||||||
|
<Modal v-model="hasNaN" v-bind="$attrs">
|
||||||
|
<template v-slot:header>
|
||||||
|
<div class="nan-modal-header">
|
||||||
|
<h2>NaN value detected!</h2>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body>
|
||||||
|
<div>
|
||||||
|
Attempted to assign "{{ path }}" to NaN<span v-if="previous">
|
||||||
|
{{ " " }}(previously {{ format(previous) }})</span
|
||||||
|
>. Auto-saving has been {{ autosave ? "enabled" : "disabled" }}. Check the console
|
||||||
|
for more details, and consider sharing it with the developers on discord.
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
:href="discordLink || 'https://discord.gg/WzejVAx'"
|
||||||
|
class="nan-modal-discord-link"
|
||||||
|
>
|
||||||
|
<span class="material-icons nan-modal-discord">discord</span>
|
||||||
|
{{ discordName || "The Paper Pilot Community" }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<Toggle title="Autosave" v-model="autosave" />
|
||||||
|
<Toggle v-if="projInfo.enablePausing" title="Pause game" v-model="isPaused" />
|
||||||
|
</template>
|
||||||
|
<template v-slot:footer>
|
||||||
|
<div class="nan-footer">
|
||||||
|
<button @click="savesManager?.open()" class="button">Open Saves Manager</button>
|
||||||
|
<button @click="setZero" class="button">Set to 0</button>
|
||||||
|
<button @click="setOne" class="button">Set to 1</button>
|
||||||
|
<button
|
||||||
|
@click="hasNaN = false"
|
||||||
|
class="button"
|
||||||
|
v-if="previous && Decimal.neq(previous, 0) && Decimal.neq(previous, 1)"
|
||||||
|
>
|
||||||
|
Set to previous
|
||||||
|
</button>
|
||||||
|
<button @click="ignore" class="button danger">Ignore</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
<SavesManager ref="savesManager" />
|
||||||
|
</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";
|
||||||
|
import type { DecimalSource } from "util/bignum";
|
||||||
|
import Decimal, { format } from "util/bignum";
|
||||||
|
import type { ComponentPublicInstance } from "vue";
|
||||||
|
import { computed, ref, toRef, watch } from "vue";
|
||||||
|
import Toggle from "./fields/Toggle.vue";
|
||||||
|
import SavesManager from "./SavesManager.vue";
|
||||||
|
|
||||||
|
const { discordName, discordLink } = projInfo;
|
||||||
|
const autosave = ref(true);
|
||||||
|
const isPaused = ref(true);
|
||||||
|
const hasNaN = toRef(state, "hasNaN");
|
||||||
|
const savesManager = ref<ComponentPublicInstance<typeof SavesManager> | null>(null);
|
||||||
|
|
||||||
|
watch(hasNaN, hasNaN => {
|
||||||
|
if (hasNaN) {
|
||||||
|
autosave.value = player.autosave;
|
||||||
|
isPaused.value = player.devSpeed === 0;
|
||||||
|
} else {
|
||||||
|
player.autosave = autosave.value;
|
||||||
|
player.devSpeed = isPaused.value ? 0 : null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const path = computed(() => state.NaNPath?.join("."));
|
||||||
|
const previous = computed<DecimalSource | null>(() => {
|
||||||
|
if (state.NaNPersistent != null) {
|
||||||
|
return state.NaNPersistent.value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function setZero() {
|
||||||
|
if (state.NaNPersistent != null) {
|
||||||
|
state.NaNPersistent.value = new Decimal(0);
|
||||||
|
state.hasNaN = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOne() {
|
||||||
|
if (state.NaNPersistent) {
|
||||||
|
state.NaNPersistent.value = new Decimal(1);
|
||||||
|
state.hasNaN = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ignore() {
|
||||||
|
if (state.NaNPersistent) {
|
||||||
|
state.NaNPersistent.value = new Decimal(NaN);
|
||||||
|
state.hasNaN = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.nan-modal-header {
|
||||||
|
padding: 10px 0;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nan-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nan-footer button {
|
||||||
|
margin: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nan-modal-discord-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nan-modal-discord {
|
||||||
|
margin: 0;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
271
src/components/Nav.vue
Normal file
271
src/components/Nav.vue
Normal file
|
@ -0,0 +1,271 @@
|
||||||
|
<template>
|
||||||
|
<div class="nav" v-if="useHeader" v-bind="$attrs">
|
||||||
|
<img v-if="banner" :src="banner" class="banner" :alt="title" />
|
||||||
|
<div v-else class="title">{{ title }}</div>
|
||||||
|
<div @click="changelog?.open()" class="version-container">
|
||||||
|
<Tooltip display="Changelog" :direction="Direction.Down" class="version"
|
||||||
|
><span>v{{ versionNumber }}</span></Tooltip
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div style="flex-grow: 1; cursor: unset"></div>
|
||||||
|
<div class="discord">
|
||||||
|
<span @click="openDiscord" class="material-icons">discord</span>
|
||||||
|
<ul class="discord-links">
|
||||||
|
<li v-if="discordLink">
|
||||||
|
<a :href="discordLink" target="_blank">{{ discordName }}</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://discord.gg/WzejVAx" target="_blank"
|
||||||
|
>The Paper Pilot Community</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://discord.gg/F3xveHV" target="_blank">The Modding Tree</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="https://forums.moddingtree.com/" target="_blank">
|
||||||
|
<Tooltip display="Forums" :direction="Direction.Down" yoffset="5px">
|
||||||
|
<span class="material-icons">forum</span>
|
||||||
|
</Tooltip>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div @click="info?.open()">
|
||||||
|
<Tooltip display="Info" :direction="Direction.Down" class="info">
|
||||||
|
<span class="material-icons">info</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div @click="savesManager?.open()">
|
||||||
|
<Tooltip display="Saves" :direction="Direction.Down" xoffset="-20px">
|
||||||
|
<span class="material-icons">library_books</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div @click="options?.open()">
|
||||||
|
<Tooltip display="Settings" :direction="Direction.Down" xoffset="-66px">
|
||||||
|
<span class="material-icons">settings</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="overlay-nav" v-bind="$attrs">
|
||||||
|
<div @click="changelog?.open()" class="version-container">
|
||||||
|
<Tooltip display="Changelog" :direction="Direction.Right" xoffset="25%" class="version">
|
||||||
|
<span>v{{ versionNumber }}</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div @click="savesManager?.open()">
|
||||||
|
<Tooltip display="Saves" :direction="Direction.Right">
|
||||||
|
<span class="material-icons">library_books</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div @click="options?.open()">
|
||||||
|
<Tooltip display="Settings" :direction="Direction.Right">
|
||||||
|
<span class="material-icons">settings</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div @click="info?.open()">
|
||||||
|
<Tooltip display="Info" :direction="Direction.Right">
|
||||||
|
<span class="material-icons">info</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="https://forums.moddingtree.com/" target="_blank">
|
||||||
|
<Tooltip display="Forums" :direction="Direction.Right" xoffset="7px">
|
||||||
|
<span class="material-icons">forum</span>
|
||||||
|
</Tooltip>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="discord">
|
||||||
|
<span @click="openDiscord" class="material-icons">discord</span>
|
||||||
|
<ul class="discord-links">
|
||||||
|
<li v-if="discordLink">
|
||||||
|
<a :href="discordLink" target="_blank">{{ discordName }}</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://discord.gg/WzejVAx" target="_blank"
|
||||||
|
>The Paper Pilot Community</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://discord.gg/F3xveHV" target="_blank">The Modding Tree</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Info ref="info" :changelog="changelog" />
|
||||||
|
<SavesManager ref="savesManager" />
|
||||||
|
<Options ref="options" />
|
||||||
|
<Changelog ref="changelog" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Changelog from "data/Changelog.vue";
|
||||||
|
import projInfo from "data/projInfo.json";
|
||||||
|
import Tooltip from "features/tooltips/Tooltip.vue";
|
||||||
|
import { Direction } from "util/common";
|
||||||
|
import type { ComponentPublicInstance } from "vue";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import Info from "./Info.vue";
|
||||||
|
import Options from "./Options.vue";
|
||||||
|
import SavesManager from "./SavesManager.vue";
|
||||||
|
|
||||||
|
const info = ref<ComponentPublicInstance<typeof Info> | null>(null);
|
||||||
|
const savesManager = ref<ComponentPublicInstance<typeof SavesManager> | null>(null);
|
||||||
|
const options = ref<ComponentPublicInstance<typeof Options> | null>(null);
|
||||||
|
// For some reason Info won't accept the changelog unless I do this:
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const changelog = ref<ComponentPublicInstance<any> | null>(null);
|
||||||
|
|
||||||
|
const { useHeader, banner, title, discordName, discordLink, versionNumber } = projInfo;
|
||||||
|
|
||||||
|
function openDiscord() {
|
||||||
|
window.open(discordLink, "mywindow");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.nav {
|
||||||
|
background-color: var(--raised-background);
|
||||||
|
display: flex;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 46px;
|
||||||
|
width: 100%;
|
||||||
|
border-bottom: 4px solid var(--outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav > * {
|
||||||
|
height: 46px;
|
||||||
|
width: 46px;
|
||||||
|
display: flex;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav > .banner {
|
||||||
|
height: 100%;
|
||||||
|
width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-nav > * {
|
||||||
|
height: 50px;
|
||||||
|
width: 50px;
|
||||||
|
display: flex;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 36px;
|
||||||
|
text-align: left;
|
||||||
|
margin-left: 12px;
|
||||||
|
cursor: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav > .title {
|
||||||
|
width: unset;
|
||||||
|
flex-shrink: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav .saves,
|
||||||
|
.nav .info {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-nav .discord {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-links {
|
||||||
|
position: fixed;
|
||||||
|
top: 45px;
|
||||||
|
padding: 20px;
|
||||||
|
right: -280px;
|
||||||
|
width: 200px;
|
||||||
|
transition: right 0.25s ease;
|
||||||
|
background: var(--raised-background);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-nav .discord-links {
|
||||||
|
position: absolute;
|
||||||
|
left: -280px;
|
||||||
|
right: unset;
|
||||||
|
transition: left 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-nav .discord:hover .discord-links {
|
||||||
|
left: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-links li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-links li:first-child {
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
*:not(.overlay-nav) .discord:hover .discord-links {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-icons {
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-icons:hover {
|
||||||
|
text-shadow: 5px 0 10px var(--link), -3px 0 12px var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav .version-container {
|
||||||
|
display: flex;
|
||||||
|
height: 25px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-nav .version-container {
|
||||||
|
width: unset;
|
||||||
|
height: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version {
|
||||||
|
color: var(--points);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version:hover span {
|
||||||
|
text-shadow: 5px 0 10px var(--points), -3px 0 12px var(--points);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav > div > a,
|
||||||
|
.overlay-nav > div > a {
|
||||||
|
color: var(--foreground);
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
</style>
|
41
src/components/Node.vue
Normal file
41
src/components/Node.vue
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
<template>
|
||||||
|
<div class="node" ref="node"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { RegisterNodeInjectionKey, UnregisterNodeInjectionKey } from "game/layers";
|
||||||
|
import { computed, inject, onUnmounted, shallowRef, toRefs, unref, watch } from "vue";
|
||||||
|
|
||||||
|
const _props = defineProps<{ id: string }>();
|
||||||
|
const props = toRefs(_props);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
const register = inject(RegisterNodeInjectionKey, () => {});
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
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]) => {
|
||||||
|
if (prevNode) {
|
||||||
|
unregister(unref(prevID));
|
||||||
|
}
|
||||||
|
if (newNode) {
|
||||||
|
register(newID, newNode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => unregister(unref(props.id)));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.node {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
43
src/components/Notif.vue
Normal file
43
src/components/Notif.vue
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
<template>
|
||||||
|
<div class="notif">!</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts"></script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notif {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 5px;
|
||||||
|
z-index: 10;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
color: var(--accent3);
|
||||||
|
font-size: x-large;
|
||||||
|
animation: 1s linear infinite bounce;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background: var(--locked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
0% {
|
||||||
|
animation-timing-function: cubic-bezier(0.1361, 0.2514, 0.2175, 0.8786);
|
||||||
|
transform: translate(0, 0px) scaleY(1);
|
||||||
|
}
|
||||||
|
37% {
|
||||||
|
animation-timing-function: cubic-bezier(0.7674, 0.1844, 0.8382, 0.7157);
|
||||||
|
transform: translate(0, -20px) scaleY(1);
|
||||||
|
}
|
||||||
|
72% {
|
||||||
|
animation-timing-function: cubic-bezier(0.1118, 0.2149, 0.2172, 0.941);
|
||||||
|
transform: translate(0, 0px) scaleY(1);
|
||||||
|
}
|
||||||
|
87% {
|
||||||
|
animation-timing-function: cubic-bezier(0.7494, 0.2259, 0.8209, 0.6963);
|
||||||
|
transform: translate(0, 10px) scaleY(0.602);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(0, 0px) scaleY(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
167
src/components/Options.vue
Normal file
167
src/components/Options.vue
Normal file
|
@ -0,0 +1,167 @@
|
||||||
|
<template>
|
||||||
|
<Modal v-model="isOpen">
|
||||||
|
<template v-slot:header>
|
||||||
|
<div class="header">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<div class="option-tabs">
|
||||||
|
<button :class="{selected: isTab('behaviour')}" @click="setTab('behaviour')">Behaviour</button>
|
||||||
|
<button :class="{selected: isTab('appearance')}" @click="setTab('appearance')">Appearance</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body>
|
||||||
|
<div v-if="isTab('behaviour')">
|
||||||
|
<Toggle :title="unthrottledTitle" v-model="unthrottled" />
|
||||||
|
<Toggle v-if="projInfo.enablePausing" :title="isPausedTitle" v-model="isPaused" />
|
||||||
|
<Toggle :title="offlineProdTitle" v-model="offlineProd" />
|
||||||
|
<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" />
|
||||||
|
<Toggle :title="showTPSTitle" v-model="showTPS" />
|
||||||
|
<Toggle :title="alignModifierUnitsTitle" v-model="alignUnits" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</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 { computed, ref, toRefs } from "vue";
|
||||||
|
import Select from "./fields/Select.vue";
|
||||||
|
import Toggle from "./fields/Toggle.vue";
|
||||||
|
import FeedbackButton from "./fields/FeedbackButton.vue";
|
||||||
|
|
||||||
|
const isOpen = ref(false);
|
||||||
|
const currentTab = ref("behaviour");
|
||||||
|
|
||||||
|
function isTab(tab: string): boolean {
|
||||||
|
return tab == currentTab.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTab(tab: string) {
|
||||||
|
currentTab.value = tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
isTab,
|
||||||
|
setTab,
|
||||||
|
save,
|
||||||
|
open() {
|
||||||
|
isOpen.value = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const themes = Object.keys(rawThemes).map(theme => ({
|
||||||
|
label: camelToTitle(theme),
|
||||||
|
value: theme
|
||||||
|
}));
|
||||||
|
|
||||||
|
const settingFieldsComponent = computed(() => {
|
||||||
|
return coerceComponent(jsx(() => (<>{settingFields.map(render)}</>)));
|
||||||
|
});
|
||||||
|
|
||||||
|
const { showTPS, theme, unthrottled, alignUnits } = toRefs(settings);
|
||||||
|
const { autosave, offlineProd } = toRefs(player);
|
||||||
|
const isPaused = computed({
|
||||||
|
get() {
|
||||||
|
return player.devSpeed === 0;
|
||||||
|
},
|
||||||
|
set(value: boolean) {
|
||||||
|
player.devSpeed = value ? 0 : null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const unthrottledTitle = jsx(() => (
|
||||||
|
<span class="option-title">
|
||||||
|
Unthrottled
|
||||||
|
<desc>Allow the game to run as fast as possible. Not battery friendly.</desc>
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
const offlineProdTitle = jsx(() => (
|
||||||
|
<span class="option-title">
|
||||||
|
Offline Production<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip>
|
||||||
|
<desc>Simulate production that occurs while the game is closed.</desc>
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
const autosaveTitle = jsx(() => (
|
||||||
|
<span class="option-title">
|
||||||
|
Autosave<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip>
|
||||||
|
<desc>Automatically save the game every second or when the game is closed.</desc>
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
const isPausedTitle = jsx(() => (
|
||||||
|
<span class="option-title">
|
||||||
|
Pause game<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip>
|
||||||
|
<desc>Stop everything from moving.</desc>
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
const themeTitle = jsx(() => (
|
||||||
|
<span class="option-title">
|
||||||
|
Theme
|
||||||
|
<desc>How the game looks.</desc>
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
const showTPSTitle = jsx(() => (
|
||||||
|
<span class="option-title">
|
||||||
|
Show TPS
|
||||||
|
<desc>Show TPS meter at the bottom-left corner of the page.</desc>
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
const alignModifierUnitsTitle = jsx(() => (
|
||||||
|
<span class="option-title">
|
||||||
|
Align modifier units
|
||||||
|
<desc>Align numbers to the beginning of the unit in modifier view.</desc>
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.option-tabs {
|
||||||
|
border-bottom: 2px solid var(--outline);
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-tabs button {
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--foreground);
|
||||||
|
margin-bottom: -2px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px 20px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-tabs button:not(.selected) {
|
||||||
|
border-bottom-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-title .tooltip-container {
|
||||||
|
display: inline;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
.option-title desc {
|
||||||
|
display: block;
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: small;
|
||||||
|
width: 300px;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-button {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
</style>
|
195
src/components/Profectus.vue
Normal file
195
src/components/Profectus.vue
Normal file
|
@ -0,0 +1,195 @@
|
||||||
|
<template>
|
||||||
|
<transition appear>
|
||||||
|
<svg
|
||||||
|
id="eaRe02fYmMp1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
viewBox="0 0 228 521"
|
||||||
|
shape-rendering="geometricPrecision"
|
||||||
|
text-rendering="geometricPrecision"
|
||||||
|
>
|
||||||
|
<g id="P">
|
||||||
|
<path
|
||||||
|
d="m 101,512.877 c -17.547386,-5.3519 -50.794681,-10.26296 -80,0 10.737201,-217.43031 5.7244,-300.999 0,-464.9995 0,0 46.6144,-37.1164 80,-42.00002 33.386,-4.883633 86.025,10.45942 120,50.00002 5,30 -4.353,106.6565 -44,156.0005 -34.149,42.5 -130,38.48 -130,92.999 0,102 54,208 54,208 z"
|
||||||
|
style="
|
||||||
|
display: inline;
|
||||||
|
fill: none;
|
||||||
|
stroke: rgb(163, 190, 140);
|
||||||
|
stroke-width: 10;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-miterlimit: 16;
|
||||||
|
"
|
||||||
|
id="trunk"
|
||||||
|
class="svg-elem-1"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M 221,55.8775 C 209.023,126.453 185.39,166.835 158.997,191.5 93.783098,252.444 11.718998,217.436 46.999998,304.877"
|
||||||
|
style="
|
||||||
|
display: inline;
|
||||||
|
fill: none;
|
||||||
|
stroke: rgb(163, 190, 140);
|
||||||
|
stroke-width: 5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-miterlimit: 16;
|
||||||
|
"
|
||||||
|
id="vine2"
|
||||||
|
class="svg-elem-2"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="m 194.5,188 c -11.225,4.447 -19.066,5.134 -35.503,3.5"
|
||||||
|
style="
|
||||||
|
display: inline;
|
||||||
|
fill: none;
|
||||||
|
stroke: rgb(163, 190, 140);
|
||||||
|
stroke-width: 5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-miterlimit: 16;
|
||||||
|
"
|
||||||
|
id="short_vine4"
|
||||||
|
class="svg-elem-3"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M 73.499996,246.5 C 111.145,245.626 127.011,238.775 156.5,228"
|
||||||
|
style="
|
||||||
|
display: inline;
|
||||||
|
fill: none;
|
||||||
|
stroke: rgb(163, 190, 140);
|
||||||
|
stroke-width: 5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-miterlimit: 16;
|
||||||
|
"
|
||||||
|
id="short_vine3"
|
||||||
|
class="svg-elem-4"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M 221,55.8775 C 169.5,17.8262 86.0943,44.9468 47,107 c -4.743,7.528 -7.1041,15.373 -8.326,24 -3.5282,24.91 2.4426,56.34 -12.0011,105.5"
|
||||||
|
style="
|
||||||
|
display: inline;
|
||||||
|
fill: none;
|
||||||
|
stroke: rgb(163, 190, 140);
|
||||||
|
stroke-width: 5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-miterlimit: 16;
|
||||||
|
"
|
||||||
|
id="vine1"
|
||||||
|
class="svg-elem-5"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M 21,47.8775 38.674,131"
|
||||||
|
style="
|
||||||
|
display: inline;
|
||||||
|
fill: none;
|
||||||
|
stroke: rgb(163, 190, 140);
|
||||||
|
stroke-width: 5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-miterlimit: 16;
|
||||||
|
"
|
||||||
|
id="short_vine2"
|
||||||
|
class="svg-elem-6"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="m 3,326.5 c 13.1783,22.208 16.4863,42.834 21.6997,81"
|
||||||
|
style="
|
||||||
|
display: inline;
|
||||||
|
fill: none;
|
||||||
|
stroke: rgb(163, 190, 140);
|
||||||
|
stroke-width: 5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-miterlimit: 16;
|
||||||
|
"
|
||||||
|
id="short_vine1"
|
||||||
|
class="svg-elem-7"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
svg {
|
||||||
|
background: #2e3440;
|
||||||
|
}
|
||||||
|
|
||||||
|
/***************************************************
|
||||||
|
* Generated by SVG Artista on 1/7/2022, 4:39:47 PM
|
||||||
|
* MIT license (https://opensource.org/licenses/MIT)
|
||||||
|
* W. https://svgartista.net
|
||||||
|
**************************************************/
|
||||||
|
|
||||||
|
svg .svg-elem-1 {
|
||||||
|
stroke-dashoffset: 2648.758056640625px;
|
||||||
|
stroke-dasharray: 1324.3790283203125px;
|
||||||
|
transition: stroke-dashoffset 1s cubic-bezier(0.47, 0, 0.745, 0.715) 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.v-enter-from .svg-elem-1,
|
||||||
|
svg.v-leave-to .svg-elem-1 {
|
||||||
|
stroke-dashoffset: 1324.3790283203125px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg .svg-elem-2 {
|
||||||
|
stroke-dashoffset: 680.4000854492188px;
|
||||||
|
stroke-dasharray: 340.2000427246094px;
|
||||||
|
transition: stroke-dashoffset 1s ease-out 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.v-enter-from .svg-elem-2,
|
||||||
|
svg.v-leave-to .svg-elem-2 {
|
||||||
|
stroke-dashoffset: 340.2000427246094px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg .svg-elem-3 {
|
||||||
|
stroke-dashoffset: 76.21031951904297px;
|
||||||
|
stroke-dasharray: 38.105159759521484px;
|
||||||
|
transition: stroke-dashoffset 1s ease-out 0.8s;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.v-enter-from .svg-elem-3,
|
||||||
|
svg.v-leave-to .svg-elem-3 {
|
||||||
|
stroke-dashoffset: 38.105159759521484px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg .svg-elem-4 {
|
||||||
|
stroke-dashoffset: 175.18072509765625px;
|
||||||
|
stroke-dasharray: 87.59036254882812px;
|
||||||
|
transition: stroke-dashoffset 1s cubic-bezier(0.47, 0, 0.745, 0.715) 0.36s;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.v-enter-from .svg-elem-4,
|
||||||
|
svg.v-leave-to .svg-elem-4 {
|
||||||
|
stroke-dashoffset: 87.59036254882812px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg .svg-elem-5 {
|
||||||
|
stroke-dashoffset: 671.9447021484375px;
|
||||||
|
stroke-dasharray: 335.97235107421875px;
|
||||||
|
transition: stroke-dashoffset 1s ease-out 0.8s;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.v-enter-from .svg-elem-5,
|
||||||
|
svg.v-leave-to .svg-elem-5 {
|
||||||
|
stroke-dashoffset: 335.97235107421875px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg .svg-elem-6 {
|
||||||
|
stroke-dashoffset: 173.96141052246094px;
|
||||||
|
stroke-dasharray: 86.98070526123047px;
|
||||||
|
transition: stroke-dashoffset 1s ease-out 1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.v-enter-from .svg-elem-6,
|
||||||
|
svg.v-leave-to .svg-elem-6 {
|
||||||
|
stroke-dashoffset: 86.98070526123047px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg .svg-elem-7 {
|
||||||
|
stroke-dashoffset: 172.99151611328125px;
|
||||||
|
stroke-dasharray: 86.49575805664062px;
|
||||||
|
transition: stroke-dashoffset 1s ease-out 0.85s;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.v-enter-from .svg-elem-7,
|
||||||
|
svg.v-leave-to .svg-elem-7 {
|
||||||
|
stroke-dashoffset: 86.49575805664062px;
|
||||||
|
}
|
||||||
|
</style>
|
204
src/components/Save.vue
Normal file
204
src/components/Save.vue
Normal file
|
@ -0,0 +1,204 @@
|
||||||
|
<template>
|
||||||
|
<div class="save" :class="{ active: isActive }">
|
||||||
|
<div class="handle material-icons">drag_handle</div>
|
||||||
|
<div class="actions" v-if="!isEditing">
|
||||||
|
<FeedbackButton
|
||||||
|
@click="emit('export')"
|
||||||
|
class="button"
|
||||||
|
left
|
||||||
|
v-if="save.error == undefined && !isConfirming"
|
||||||
|
>
|
||||||
|
<Tooltip display="Export" :direction="Direction.Left" class="info">
|
||||||
|
<span class="material-icons">content_paste</span>
|
||||||
|
</Tooltip>
|
||||||
|
</FeedbackButton>
|
||||||
|
<button
|
||||||
|
@click="emit('duplicate')"
|
||||||
|
class="button"
|
||||||
|
v-if="save.error == undefined && !isConfirming"
|
||||||
|
>
|
||||||
|
<Tooltip display="Duplicate" :direction="Direction.Left" class="info">
|
||||||
|
<span class="material-icons">content_copy</span>
|
||||||
|
</Tooltip>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="isEditing = !isEditing"
|
||||||
|
class="button"
|
||||||
|
v-if="save.error == undefined && !isConfirming"
|
||||||
|
>
|
||||||
|
<Tooltip display="Edit Name" :direction="Direction.Left" class="info">
|
||||||
|
<span class="material-icons">edit</span>
|
||||||
|
</Tooltip>
|
||||||
|
</button>
|
||||||
|
<DangerButton
|
||||||
|
:disabled="isActive"
|
||||||
|
@click="emit('delete')"
|
||||||
|
@confirmingChanged="value => (isConfirming = value)"
|
||||||
|
>
|
||||||
|
<Tooltip display="Delete" :direction="Direction.Left" class="info">
|
||||||
|
<span class="material-icons" style="margin: -2px">delete</span>
|
||||||
|
</Tooltip>
|
||||||
|
</DangerButton>
|
||||||
|
</div>
|
||||||
|
<div class="actions" v-else>
|
||||||
|
<button @click="changeName" class="button">
|
||||||
|
<Tooltip display="Save" :direction="Direction.Left" class="info">
|
||||||
|
<span class="material-icons">check</span>
|
||||||
|
</Tooltip>
|
||||||
|
</button>
|
||||||
|
<button @click="isEditing = !isEditing" class="button">
|
||||||
|
<Tooltip display="Cancel" :direction="Direction.Left" class="info">
|
||||||
|
<span class="material-icons">close</span>
|
||||||
|
</Tooltip>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="details" v-if="save.error == undefined && !isEditing">
|
||||||
|
<button class="button open" @click="emit('open')">
|
||||||
|
<h3>{{ save.name }}</h3>
|
||||||
|
</button>
|
||||||
|
<span class="save-version">v{{ save.modVersion }}</span
|
||||||
|
><br />
|
||||||
|
<div v-if="currentTime">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" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="details error">
|
||||||
|
Error: Failed to load save with id {{ save.id }}<br />{{ save.error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Tooltip from "features/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 type { LoadablePlayerData } from "./SavesManager.vue";
|
||||||
|
|
||||||
|
const _props = defineProps<{
|
||||||
|
save: LoadablePlayerData;
|
||||||
|
}>();
|
||||||
|
const { save } = toRefs(_props);
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "export"): void;
|
||||||
|
(e: "open"): void;
|
||||||
|
(e: "duplicate"): void;
|
||||||
|
(e: "delete"): void;
|
||||||
|
(e: "editName", name: string): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const dateFormat = new Intl.DateTimeFormat("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "numeric",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "numeric",
|
||||||
|
second: "numeric"
|
||||||
|
});
|
||||||
|
|
||||||
|
const isEditing = ref(false);
|
||||||
|
const isConfirming = ref(false);
|
||||||
|
const newName = ref("");
|
||||||
|
|
||||||
|
watch(isEditing, () => (newName.value = save.value.name ?? ""));
|
||||||
|
|
||||||
|
const isActive = computed(() => save.value != null && save.value.id === player.id);
|
||||||
|
const currentTime = computed(() =>
|
||||||
|
isActive.value ? player.time : (save.value != null && save.value.time) ?? 0
|
||||||
|
);
|
||||||
|
|
||||||
|
function changeName() {
|
||||||
|
emit("editName", newName.value);
|
||||||
|
isEditing.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.save {
|
||||||
|
position: relative;
|
||||||
|
border: solid 4px var(--outline);
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--raised-background);
|
||||||
|
margin: var(--feature-margin);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save.active {
|
||||||
|
border-color: var(--bought);
|
||||||
|
}
|
||||||
|
|
||||||
|
.open {
|
||||||
|
display: inline;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle {
|
||||||
|
flex-grow: 0;
|
||||||
|
margin-right: 8px;
|
||||||
|
margin-left: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
margin: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
margin-right: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-version {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 0.7em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 4px;
|
||||||
|
display: flex;
|
||||||
|
padding: 4px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editname {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.save button {
|
||||||
|
transition-duration: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save .actions button {
|
||||||
|
display: flex;
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save .actions button .material-icons {
|
||||||
|
font-size: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save .button.danger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save .field {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
334
src/components/SavesManager.vue
Normal file
334
src/components/SavesManager.vue
Normal file
|
@ -0,0 +1,334 @@
|
||||||
|
<template>
|
||||||
|
<Modal v-model="isOpen" ref="modal">
|
||||||
|
<template v-slot:header>
|
||||||
|
<h2>Saves Manager</h2>
|
||||||
|
</template>
|
||||||
|
<template #body="{ shown }">
|
||||||
|
<Draggable
|
||||||
|
:list="settings.saves"
|
||||||
|
handle=".handle"
|
||||||
|
v-if="shown"
|
||||||
|
:itemKey="(save: string) => save"
|
||||||
|
>
|
||||||
|
<template #item="{ element }">
|
||||||
|
<Save
|
||||||
|
:save="saves[element]"
|
||||||
|
@open="openSave(element)"
|
||||||
|
@export="exportSave(element)"
|
||||||
|
@editName="name => editSave(element, name)"
|
||||||
|
@duplicate="duplicateSave(element)"
|
||||||
|
@delete="deleteSave(element)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Draggable>
|
||||||
|
</template>
|
||||||
|
<template v-slot:footer>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<Text
|
||||||
|
v-model="saveToImport"
|
||||||
|
title="Import Save"
|
||||||
|
placeholder="Paste your save here!"
|
||||||
|
:class="{ importingFailed }"
|
||||||
|
/>
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-title">Create Save</span>
|
||||||
|
<div class="field-buttons">
|
||||||
|
<button class="button" @click="openSave(newSave().id)">New Game</button>
|
||||||
|
<Select
|
||||||
|
v-if="Object.keys(bank).length > 0"
|
||||||
|
:options="bank"
|
||||||
|
:modelValue="selectedPreset"
|
||||||
|
@update:modelValue="preset => newFromPreset(preset as string)"
|
||||||
|
closeOnSelect
|
||||||
|
placeholder="Select preset"
|
||||||
|
class="presets"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<div style="flex-grow: 1"></div>
|
||||||
|
<button class="button modal-default-button" @click="isOpen = false">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</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 type { ComponentPublicInstance } from "vue";
|
||||||
|
import { computed, nextTick, ref, shallowReactive, watch } from "vue";
|
||||||
|
import Draggable from "vuedraggable";
|
||||||
|
import Select from "./fields/Select.vue";
|
||||||
|
import Text from "./fields/Text.vue";
|
||||||
|
import Save from "./Save.vue";
|
||||||
|
|
||||||
|
export type LoadablePlayerData = Omit<Partial<Player>, "id"> & { id: string; error?: unknown };
|
||||||
|
|
||||||
|
const isOpen = ref(false);
|
||||||
|
const modal = ref<ComponentPublicInstance<typeof Modal> | null>(null);
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
open() {
|
||||||
|
isOpen.value = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const importingFailed = ref(false);
|
||||||
|
const saveToImport = ref("");
|
||||||
|
const selectedPreset = ref<string | null>(null);
|
||||||
|
|
||||||
|
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 {
|
||||||
|
console.warn("Unable to determine preset encoding", importedSave);
|
||||||
|
importingFailed.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const playerData = JSON.parse(importedSave);
|
||||||
|
if (typeof playerData !== "object") {
|
||||||
|
importingFailed.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = getUniqueID();
|
||||||
|
playerData.id = id;
|
||||||
|
save(playerData);
|
||||||
|
saveToImport.value = "";
|
||||||
|
importingFailed.value = false;
|
||||||
|
|
||||||
|
settings.saves.push(id);
|
||||||
|
} catch (e) {
|
||||||
|
importingFailed.value = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
importingFailed.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let bankContext = import.meta.globEager("./../../saves/*.txt", { as: "raw" });
|
||||||
|
let bank = ref(
|
||||||
|
Object.keys(bankContext).reduce((acc: Array<{ label: string; value: string }>, curr) => {
|
||||||
|
acc.push({
|
||||||
|
// .slice(2, -4) strips the leading ./ and the trailing .txt
|
||||||
|
label: curr.split("/").slice(-1)[0].slice(0, -4),
|
||||||
|
// Have to perform this unholy cast because globEager's typing doesn't appear to know
|
||||||
|
// adding { as: "raw" } will make the object contain strings rather than modules
|
||||||
|
value: bankContext[curr] as unknown as string
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
const cachedSaves = shallowReactive<Record<string, LoadablePlayerData | undefined>>({});
|
||||||
|
function getCachedSave(id: string) {
|
||||||
|
if (cachedSaves[id] == null) {
|
||||||
|
let save = localStorage.getItem(id);
|
||||||
|
if (save == null) {
|
||||||
|
cachedSaves[id] = { error: `Save doesn't exist in localStorage`, id };
|
||||||
|
} else if (save === "dW5kZWZpbmVk") {
|
||||||
|
cachedSaves[id] = { error: `Save is undefined`, id };
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
if (save[0] === "{") {
|
||||||
|
// plaintext. No processing needed
|
||||||
|
} else if (save[0] === "e") {
|
||||||
|
// Assumed to be base64, which starts with e
|
||||||
|
save = decodeURIComponent(escape(atob(save)));
|
||||||
|
} else if (save[0] === "ᯡ") {
|
||||||
|
// Assumed to be lz, which starts with ᯡ
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
save = LZString.decompressFromUTF16(save)!;
|
||||||
|
} else {
|
||||||
|
console.warn("Unable to determine preset encoding", save);
|
||||||
|
importingFailed.value = true;
|
||||||
|
cachedSaves[id] = { error: "Unable to determine preset encoding", id };
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
return cachedSaves[id]!;
|
||||||
|
}
|
||||||
|
cachedSaves[id] = { ...JSON.parse(save), id };
|
||||||
|
} catch (error) {
|
||||||
|
cachedSaves[id] = { error, id };
|
||||||
|
console.warn(
|
||||||
|
`SavesManager: Failed to load info about save with id ${id}:\n${error}\n${save}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
return cachedSaves[id]!;
|
||||||
|
}
|
||||||
|
// Wipe cache whenever the modal is opened
|
||||||
|
watch(isOpen, isOpen => {
|
||||||
|
if (isOpen) {
|
||||||
|
Object.keys(cachedSaves).forEach(key => delete cachedSaves[key]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const saves = computed(() =>
|
||||||
|
settings.saves.reduce((acc: Record<string, LoadablePlayerData>, curr: string) => {
|
||||||
|
acc[curr] = getCachedSave(curr);
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
);
|
||||||
|
|
||||||
|
function exportSave(id: string) {
|
||||||
|
let saveToExport;
|
||||||
|
if (player.id === id) {
|
||||||
|
saveToExport = stringifySave(player);
|
||||||
|
} else {
|
||||||
|
saveToExport = JSON.stringify(saves.value[id]);
|
||||||
|
}
|
||||||
|
switch (projInfo.exportEncoding) {
|
||||||
|
default:
|
||||||
|
console.warn(`Unknown save encoding: ${projInfo.exportEncoding}. Defaulting to lz`);
|
||||||
|
case "lz":
|
||||||
|
saveToExport = LZString.compressToUTF16(saveToExport);
|
||||||
|
break;
|
||||||
|
case "base64":
|
||||||
|
saveToExport = btoa(unescape(encodeURIComponent(saveToExport)));
|
||||||
|
break;
|
||||||
|
case "plain":
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put on clipboard. Using the clipboard API asks for permissions and stuff
|
||||||
|
const el = document.createElement("textarea");
|
||||||
|
el.value = saveToExport;
|
||||||
|
document.body.appendChild(el);
|
||||||
|
el.select();
|
||||||
|
el.setSelectionRange(0, 99999);
|
||||||
|
document.execCommand("copy");
|
||||||
|
document.body.removeChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateSave(id: string) {
|
||||||
|
if (player.id === id) {
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerData = { ...saves.value[id], id: getUniqueID() };
|
||||||
|
save(playerData as Player);
|
||||||
|
|
||||||
|
settings.saves.push(playerData.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSave(id: string) {
|
||||||
|
settings.saves = settings.saves.filter((save: string) => save !== id);
|
||||||
|
localStorage.removeItem(id);
|
||||||
|
cachedSaves[id] = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSave(id: string) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
saves.value[player.id]!.time = player.time;
|
||||||
|
save();
|
||||||
|
cachedSaves[player.id] = undefined;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
loadSave(saves.value[id]!);
|
||||||
|
// Delete cached version in case of opening it again
|
||||||
|
cachedSaves[id] = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function newFromPreset(preset: string) {
|
||||||
|
// Reset preset dropdown
|
||||||
|
selectedPreset.value = preset;
|
||||||
|
nextTick(() => {
|
||||||
|
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 {
|
||||||
|
console.warn("Unable to determine preset encoding", preset);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const playerData = JSON.parse(preset);
|
||||||
|
playerData.id = getUniqueID();
|
||||||
|
save(playerData as Player);
|
||||||
|
|
||||||
|
settings.saves.push(playerData.id);
|
||||||
|
|
||||||
|
openSave(playerData.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function editSave(id: string, newName: string) {
|
||||||
|
const currSave = saves.value[id];
|
||||||
|
if (currSave != null) {
|
||||||
|
currSave.name = newName;
|
||||||
|
if (player.id === id) {
|
||||||
|
player.name = newName;
|
||||||
|
save();
|
||||||
|
} else {
|
||||||
|
save(currSave as Player);
|
||||||
|
cachedSaves[id] = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.field form,
|
||||||
|
.field .field-title,
|
||||||
|
.field .field-buttons {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-buttons {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-buttons .field {
|
||||||
|
margin: 0;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
margin-top: -20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.importingFailed input {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-buttons .v-select {
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presets .vue-select[aria-expanded="true"] vue-dropdown {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
53
src/components/TPS.vue
Normal file
53
src/components/TPS.vue
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
<template>
|
||||||
|
<div class="tpsDisplay" v-if="!tps.isNan()">
|
||||||
|
TPS: {{ formatWhole(tps) }}
|
||||||
|
<transition name="fade"
|
||||||
|
><span v-if="showLow" class="low">{{ formatWhole(low) }}</span></transition
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import state from "game/state";
|
||||||
|
import type { DecimalSource } from "util/bignum";
|
||||||
|
import Decimal, { formatWhole } from "util/bignum";
|
||||||
|
import { computed, ref, watchEffect } from "vue";
|
||||||
|
|
||||||
|
const tps = computed(() =>
|
||||||
|
Decimal.div(
|
||||||
|
state.lastTenTicks.length,
|
||||||
|
state.lastTenTicks.reduce((acc, curr) => acc + curr, 0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const lastTenFPS = ref<number[]>([]);
|
||||||
|
watchEffect(() => {
|
||||||
|
lastTenFPS.value.push(Math.round(tps.value.toNumber()));
|
||||||
|
if (lastTenFPS.value.length > 10) {
|
||||||
|
lastTenFPS.value = lastTenFPS.value.slice(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const low = computed(() =>
|
||||||
|
lastTenFPS.value.reduce<DecimalSource>((acc, curr) => Decimal.max(acc, curr), 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const showLow = computed(() => Decimal.sub(tps.value, low.value).gt(1));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tpsDisplay {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.low {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
38
src/components/common/features.css
Normal file
38
src/components/common/features.css
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
.feature:not(li),
|
||||||
|
.feature:not(li) button {
|
||||||
|
position: relative;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.125);
|
||||||
|
margin: var(--feature-margin);
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: var(--feature-foreground);
|
||||||
|
z-index: 0;
|
||||||
|
transition: all 0.5s, z-index 0s 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.can,
|
||||||
|
.can button {
|
||||||
|
background-color: var(--layer-color);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.can:hover,
|
||||||
|
.can:hover button {
|
||||||
|
transform: scale(1.15, 1.15);
|
||||||
|
box-shadow: 0 0 20px var(--points);
|
||||||
|
z-index: 1;
|
||||||
|
transition: all 0.5s, z-index 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locked,
|
||||||
|
.locked button {
|
||||||
|
background-color: var(--locked);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bought,
|
||||||
|
.bought button {
|
||||||
|
background-color: var(--bought);
|
||||||
|
cursor: default;
|
||||||
|
}
|
13
src/components/common/fields.css
Normal file
13
src/components/common/fields.css
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
min-height: 2em;
|
||||||
|
margin: 10px 0;
|
||||||
|
user-select: none;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field > * {
|
||||||
|
margin: 0;
|
||||||
|
}
|
21
src/components/common/modifiers.css
Normal file
21
src/components/common/modifiers.css
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
.modifier-container {
|
||||||
|
display: flex;
|
||||||
|
padding: 1px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modifier-container:nth-child(2n) {
|
||||||
|
background: var(--raised-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modifier-amount {
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
:not(:first-of-type, :last-of-type) > .modifier-amount::after {
|
||||||
|
content: var(--unit);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modifier-description {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
128
src/components/common/table.css
Normal file
128
src/components/common/table.css
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
.table {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column wrap;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table + .table {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row wrap;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: stretch;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row > :not(.feature) {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column wrap;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.mergeAdjacent > .feature:not(.dontMerge),
|
||||||
|
.row.mergeAdjacent > .tooltip-container > .feature:not(.dontMerge) {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.mergeAdjacent > .feature:not(.dontMerge):first-child,
|
||||||
|
.row.mergeAdjacent > .tooltip-container:first-child > .feature:not(.dontMerge) {
|
||||||
|
border-radius: var(--border-radius) 0 0 var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.mergeAdjacent > .feature:not(.dontMerge):last-child,
|
||||||
|
.row.mergeAdjacent > .tooltip-container:last-child > .feature:not(.dontMerge) {
|
||||||
|
border-radius: 0 var(--border-radius) var(--border-radius) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.mergeAdjacent > .feature:not(.dontMerge):first-child:last-child,
|
||||||
|
.row.mergeAdjacent > .tooltip-container:first-child:last-child > .feature:not(.dontMerge) {
|
||||||
|
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;
|
||||||
|
margin-top: 0;
|
||||||
|
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 {
|
||||||
|
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col.mergeAdjacent .feature:not(.dontMerge):last-child {
|
||||||
|
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col.mergeAdjacent .feature:not(.dontMerge):first-child:last-child {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
TODO how to implement mergeAdjacent for grids?
|
||||||
|
.col.mergeAdjacent + .col.mergeAdjacent > .feature:not(.dontMerge) {
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
}
|
||||||
|
*/
|
78
src/components/fields/DangerButton.vue
Normal file
78
src/components/fields/DangerButton.vue
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
<template>
|
||||||
|
<span class="container" :class="{ confirming: isConfirming }">
|
||||||
|
<span v-if="isConfirming">Are you sure?</span>
|
||||||
|
<button @click.stop="click" class="button danger" :disabled="disabled">
|
||||||
|
<span v-if="isConfirming">Yes</span>
|
||||||
|
<slot v-else />
|
||||||
|
</button>
|
||||||
|
<button v-if="isConfirming" class="button" @click.stop="cancel">No</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, toRefs, unref, watch } from "vue";
|
||||||
|
|
||||||
|
const _props = defineProps<{
|
||||||
|
disabled?: boolean;
|
||||||
|
skipConfirm?: boolean;
|
||||||
|
}>();
|
||||||
|
const props = toRefs(_props);
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "click"): void;
|
||||||
|
(e: "confirmingChanged", value: boolean): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isConfirming = ref(false);
|
||||||
|
|
||||||
|
watch(isConfirming, isConfirming => {
|
||||||
|
emit("confirmingChanged", isConfirming);
|
||||||
|
});
|
||||||
|
|
||||||
|
function click() {
|
||||||
|
if (unref(props.skipConfirm)) {
|
||||||
|
emit("click");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isConfirming.value) {
|
||||||
|
emit("click");
|
||||||
|
}
|
||||||
|
isConfirming.value = !isConfirming.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
isConfirming.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--raised-background);
|
||||||
|
box-shadow: var(--raised-background) 0 2px 3px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container.confirming button {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container > * {
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.danger,
|
||||||
|
.button.danger {
|
||||||
|
position: relative;
|
||||||
|
border: solid 2px var(--danger);
|
||||||
|
border-right-width: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger::after {
|
||||||
|
position: absolute;
|
||||||
|
content: "!";
|
||||||
|
color: white;
|
||||||
|
right: -13px;
|
||||||
|
}
|
||||||
|
</style>
|
74
src/components/fields/FeedbackButton.vue
Normal file
74
src/components/fields/FeedbackButton.vue
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
<template>
|
||||||
|
<button @click.stop="click" class="feedback" :class="{ activated, left }">
|
||||||
|
<slot />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, ref } from "vue";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
left?: boolean;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "click"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const activated = ref(false);
|
||||||
|
const activatedTimeout = ref<NodeJS.Timer | null>(null);
|
||||||
|
|
||||||
|
function click() {
|
||||||
|
emit("click");
|
||||||
|
|
||||||
|
// Give feedback to user
|
||||||
|
if (activatedTimeout.value) {
|
||||||
|
clearTimeout(activatedTimeout.value);
|
||||||
|
}
|
||||||
|
activated.value = false;
|
||||||
|
nextTick(() => {
|
||||||
|
activated.value = true;
|
||||||
|
activatedTimeout.value = setTimeout(() => (activated.value = false), 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.feedback {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback::after {
|
||||||
|
position: absolute;
|
||||||
|
left: calc(100% + 5px);
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
content: "✔";
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: inset 0 0 0 35px rgba(111, 148, 182, 0);
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback.left::after {
|
||||||
|
left: unset;
|
||||||
|
right: calc(100% + 5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback.activated::after {
|
||||||
|
animation: feedback 0.5s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes feedback {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale3d(0.4, 0.4, 1), translateY(-50%);
|
||||||
|
}
|
||||||
|
80% {
|
||||||
|
opacity: 0.1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale3d(1.2, 1.2, 1), translateY(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
97
src/components/fields/Select.vue
Normal file
97
src/components/fields/Select.vue
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
<template>
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-title" v-if="titleComponent"><component :is="titleComponent" /></span>
|
||||||
|
<VueNextSelect
|
||||||
|
:options="options"
|
||||||
|
v-model="value"
|
||||||
|
@update:model-value="onUpdate"
|
||||||
|
:min="1"
|
||||||
|
label-by="label"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:close-on-select="closeOnSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import "components/common/fields.css";
|
||||||
|
import type { CoercableComponent } from "features/feature";
|
||||||
|
import { computeOptionalComponent, unwrapRef } from "util/vue";
|
||||||
|
import { ref, toRef, watch } from "vue";
|
||||||
|
import VueNextSelect from "vue-next-select";
|
||||||
|
import "vue-next-select/dist/index.css";
|
||||||
|
|
||||||
|
export type SelectOption = { label: string; value: unknown };
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
title?: CoercableComponent;
|
||||||
|
modelValue?: unknown;
|
||||||
|
options: SelectOption[];
|
||||||
|
placeholder?: string;
|
||||||
|
closeOnSelect?: boolean;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", value: unknown): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const titleComponent = computeOptionalComponent(toRef(props, "title"), "span");
|
||||||
|
|
||||||
|
const value = ref<SelectOption | null>(
|
||||||
|
props.options.find(option => option.value === props.modelValue) ?? null
|
||||||
|
);
|
||||||
|
watch(toRef(props, "modelValue"), modelValue => {
|
||||||
|
if (unwrapRef(value) !== modelValue) {
|
||||||
|
value.value = props.options.find(option => option.value === modelValue) ?? null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function onUpdate(value: SelectOption) {
|
||||||
|
emit("update:modelValue", value.value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.vue-select {
|
||||||
|
width: 50%;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-buttons .vue-select {
|
||||||
|
width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vue-select,
|
||||||
|
.vue-dropdown {
|
||||||
|
border-color: var(--outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vue-dropdown {
|
||||||
|
background: var(--raised-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vue-dropdown-item {
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vue-dropdown-item,
|
||||||
|
.vue-dropdown-item * {
|
||||||
|
transition-duration: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vue-dropdown-item.highlighted {
|
||||||
|
background-color: var(--highlighted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vue-dropdown-item.selected,
|
||||||
|
.vue-dropdown-item.highlighted.selected {
|
||||||
|
background-color: var(--bought);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vue-input input {
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vue-input input::placeholder {
|
||||||
|
color: var(--link);
|
||||||
|
}
|
||||||
|
</style>
|
41
src/components/fields/Slider.vue
Normal file
41
src/components/fields/Slider.vue
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
<template>
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-title" v-if="title">{{ title }}</span>
|
||||||
|
<Tooltip :display="`${value}`" :class="{ fullWidth: !title }" :direction="Direction.Down">
|
||||||
|
<input type="range" v-model="value" :min="min" :max="max" />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import "components/common/fields.css";
|
||||||
|
import Tooltip from "features/tooltips/Tooltip.vue";
|
||||||
|
import { Direction } from "util/common";
|
||||||
|
import { computed, toRefs, unref } from "vue";
|
||||||
|
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
set(value: string) {
|
||||||
|
emit("update:modelValue", Number(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fullWidth {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
99
src/components/fields/Text.vue
Normal file
99
src/components/fields/Text.vue
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
<template>
|
||||||
|
<form @submit.prevent="submit">
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-title" v-if="titleComponent"
|
||||||
|
><component :is="titleComponent"
|
||||||
|
/></span>
|
||||||
|
<VueTextareaAutosize
|
||||||
|
v-if="textArea"
|
||||||
|
v-model="value"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:maxHeight="maxHeight"
|
||||||
|
@blur="blur"
|
||||||
|
ref="field"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
type="text"
|
||||||
|
v-model="value"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:class="{ fullWidth: !title }"
|
||||||
|
@blur="blur"
|
||||||
|
ref="field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
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 VueTextareaAutosize from "vue-textarea-autosize";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
title?: CoercableComponent;
|
||||||
|
modelValue?: string;
|
||||||
|
textArea?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
maxHeight?: number;
|
||||||
|
submitOnBlur?: boolean;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", value: string): void;
|
||||||
|
(e: "submit"): void;
|
||||||
|
(e: "cancel"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const titleComponent = computeOptionalComponent(toRef(props, "title"), "span");
|
||||||
|
|
||||||
|
const field = shallowRef<HTMLElement | null>(null);
|
||||||
|
onMounted(() => {
|
||||||
|
field.value?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
const value = computed({
|
||||||
|
get() {
|
||||||
|
return unref(props.modelValue) ?? "";
|
||||||
|
},
|
||||||
|
set(value: string) {
|
||||||
|
emit("update:modelValue", value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
emit("submit");
|
||||||
|
}
|
||||||
|
|
||||||
|
function blur() {
|
||||||
|
if (props.submitOnBlur !== false) {
|
||||||
|
emit("submit");
|
||||||
|
} else {
|
||||||
|
emit("cancel");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
form {
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field > * {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 50%;
|
||||||
|
outline: none;
|
||||||
|
border: solid 1px var(--outline);
|
||||||
|
background-color: unset;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullWidth {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
117
src/components/fields/Toggle.vue
Normal file
117
src/components/fields/Toggle.vue
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
<template>
|
||||||
|
<label class="field">
|
||||||
|
<input type="checkbox" class="toggle" v-model="value" />
|
||||||
|
<component :is="component" />
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import "components/common/fields.css";
|
||||||
|
import type { CoercableComponent } from "features/feature";
|
||||||
|
import { coerceComponent } from "util/vue";
|
||||||
|
import { computed, unref } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
title?: CoercableComponent;
|
||||||
|
modelValue?: boolean;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", value: boolean): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const component = computed(() => coerceComponent(unref(props.title) ?? "<span></span>", "span"));
|
||||||
|
|
||||||
|
const value = computed({
|
||||||
|
get() {
|
||||||
|
return !!props.modelValue;
|
||||||
|
},
|
||||||
|
set(value: boolean) {
|
||||||
|
emit("update:modelValue", value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.field {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
appearance: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
width: 100%;
|
||||||
|
padding-right: 41px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* track */
|
||||||
|
input + span::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: calc(50% - 7px);
|
||||||
|
right: 0px;
|
||||||
|
border-radius: 7px;
|
||||||
|
width: 36px;
|
||||||
|
height: 14px;
|
||||||
|
background-color: var(--outline);
|
||||||
|
opacity: 0.38;
|
||||||
|
vertical-align: top;
|
||||||
|
transition: background-color 0.2s, opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* thumb */
|
||||||
|
input + span::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: calc(50% - 10px);
|
||||||
|
right: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background-color: var(--locked);
|
||||||
|
box-shadow: 0 3px 1px -2px rgba(0, 0, 0, 0.2), 0 2px 2px 0 rgba(0, 0, 0, 0.14),
|
||||||
|
0 1px 5px 0 rgba(0, 0, 0, 0.12);
|
||||||
|
transition: background-color 0.2s, transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + span::before {
|
||||||
|
background-color: var(--link);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + span::after {
|
||||||
|
background-color: var(--link);
|
||||||
|
transform: translateX(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* active */
|
||||||
|
input:active + span::before {
|
||||||
|
background-color: var(--link);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked:active + span::before {
|
||||||
|
background-color: var(--outline);
|
||||||
|
opacity: 0.38;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* disabled */
|
||||||
|
input:disabled + span {
|
||||||
|
color: black;
|
||||||
|
opacity: 0.38;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:disabled + span::before {
|
||||||
|
background-color: var(--outline);
|
||||||
|
opacity: 0.38;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked:disabled + span::before {
|
||||||
|
background-color: var(--link);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
</style>
|
70
src/components/layout/Collapsible.vue
Normal file
70
src/components/layout/Collapsible.vue
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
<template>
|
||||||
|
<Col class="collapsible-container">
|
||||||
|
<button @click="collapsed.value = !collapsed.value" class="feature collapsible-toggle">
|
||||||
|
<component :is="displayComponent" />
|
||||||
|
</button>
|
||||||
|
<component v-if="!collapsed.value" :is="contentComponent" />
|
||||||
|
</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 Col from "./Column.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
collapsed: Ref<boolean>;
|
||||||
|
display: CoercableComponent;
|
||||||
|
content: CoercableComponent;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const displayComponent = computeComponent(toRef(props, "display"));
|
||||||
|
const contentComponent = computeComponent(toRef(props, "content"));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.collapsible-container {
|
||||||
|
width: calc(100% - 10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-toggle {
|
||||||
|
max-width: unset;
|
||||||
|
width: calc(100% + 0px);
|
||||||
|
margin: 0;
|
||||||
|
margin-left: -5px;
|
||||||
|
background: var(--raised-background);
|
||||||
|
padding: var(--feature-margin);
|
||||||
|
color: var(--foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
transition-duration: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-toggle:last-child {
|
||||||
|
margin-left: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.collapsible-toggle + .table) {
|
||||||
|
max-width: unset;
|
||||||
|
width: calc(100% + 10px);
|
||||||
|
margin-left: -5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.col) {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mergeAdjacent .collapsible-toggle {
|
||||||
|
border: 0;
|
||||||
|
border-top-left-radius: 0 !important;
|
||||||
|
border-top-right-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.mergeAdjacent .feature:not(.dontMerge):first-child) {
|
||||||
|
border-top-left-radius: 0 !important;
|
||||||
|
border-top-right-radius: 0 !important;
|
||||||
|
}
|
||||||
|
</style>
|
16
src/components/layout/Column.vue
Normal file
16
src/components/layout/Column.vue
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<template>
|
||||||
|
<div class="table">
|
||||||
|
<div class="col" :class="{ mergeAdjacent }">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import "components/common/table.css";
|
||||||
|
import themes from "data/themes";
|
||||||
|
import settings from "game/settings";
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const mergeAdjacent = computed(() => themes[settings.theme].mergeAdjacent);
|
||||||
|
</script>
|
16
src/components/layout/Row.vue
Normal file
16
src/components/layout/Row.vue
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<template>
|
||||||
|
<div class="table">
|
||||||
|
<div class="row" :class="{ mergeAdjacent }">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import "components/common/table.css";
|
||||||
|
import themes from "data/themes";
|
||||||
|
import settings from "game/settings";
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const mergeAdjacent = computed(() => themes[settings.theme].mergeAdjacent);
|
||||||
|
</script>
|
16
src/components/layout/Spacer.vue
Normal file
16
src/components/layout/Spacer.vue
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<template>
|
||||||
|
<div :style="{ width, height }"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
width?: string;
|
||||||
|
height?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
width: "8px",
|
||||||
|
height: "17px"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
49
src/components/layout/Sticky.vue
Normal file
49
src/components/layout/Sticky.vue
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
<template>
|
||||||
|
<div class="sticky" :style="{ top }" ref="element" data-v-sticky>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, onMounted, ref, shallowRef } from "vue";
|
||||||
|
|
||||||
|
const top = ref("0");
|
||||||
|
const observer = new ResizeObserver(updateTop);
|
||||||
|
const element = shallowRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
function updateTop() {
|
||||||
|
let el = element.value;
|
||||||
|
if (el == undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let newTop = 0;
|
||||||
|
while (el.previousSibling) {
|
||||||
|
const sibling = el.previousSibling as HTMLElement;
|
||||||
|
if (sibling.dataset && "vSticky" in sibling.dataset) {
|
||||||
|
newTop += sibling.offsetHeight;
|
||||||
|
}
|
||||||
|
el = sibling;
|
||||||
|
}
|
||||||
|
top.value = newTop + "px";
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTick(updateTop);
|
||||||
|
document.fonts.ready.then(updateTop);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const el = element.value?.parentElement;
|
||||||
|
if (el) {
|
||||||
|
observer.observe(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sticky {
|
||||||
|
position: sticky;
|
||||||
|
background: var(--background);
|
||||||
|
width: calc(100% - 2px);
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
</style>
|
18
src/components/layout/VerticalRule.vue
Normal file
18
src/components/layout/VerticalRule.vue
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
<template>
|
||||||
|
<div class="vr" :style="{ height }"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
height?: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.vr {
|
||||||
|
width: 4px;
|
||||||
|
background: var(--outline);
|
||||||
|
height: 100%;
|
||||||
|
margin: auto var(--feature-margin);
|
||||||
|
}
|
||||||
|
</style>
|
5
src/components/math/Floor.vue
Normal file
5
src/components/math/Floor.vue
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<template>
|
||||||
|
<span style="white-space: nowrap; font-size: larger; font-family: initial">
|
||||||
|
⌊ <slot /> ⌋
|
||||||
|
</span>
|
||||||
|
</template>
|
1
src/components/math/Fraction.vue
Normal file
1
src/components/math/Fraction.vue
Normal file
|
@ -0,0 +1 @@
|
||||||
|
|
8
src/components/math/Sqrt.vue
Normal file
8
src/components/math/Sqrt.vue
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
<template>
|
||||||
|
<span style="white-space: nowrap">
|
||||||
|
<span style="font-size: larger; font-family: initial">√</span>
|
||||||
|
<div style="display: inline-block; border-top: 1px solid; padding-left: 0.2em">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</template>
|
83
src/data/Changelog.vue
Normal file
83
src/data/Changelog.vue
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
<template>
|
||||||
|
<Modal v-model="isOpen">
|
||||||
|
<template v-slot:header>
|
||||||
|
<h2>Changelog</h2>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body>
|
||||||
|
<details open>
|
||||||
|
<summary>v0.0 Initial Commit - <time>2021-09-04</time></summary>
|
||||||
|
This is the first release :D
|
||||||
|
<ul>
|
||||||
|
<li class="feature">Did everything</li>
|
||||||
|
<li class="fix">Had some fun</li>
|
||||||
|
<li class="breaking">Removed everything</li>
|
||||||
|
<li class="balancing">Created some bugs to fix later</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Modal from "components/Modal.vue";
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
const isOpen = ref(false);
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
open() {
|
||||||
|
isOpen.value = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
details {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
margin-left: -18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: var(--feature-margin) 0;
|
||||||
|
background: var(--raised-background);
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.125);
|
||||||
|
padding: 5px 5px 5px 15px;
|
||||||
|
list-style: inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li::before {
|
||||||
|
padding: 2px 8px;
|
||||||
|
margin-right: 8px;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature::before {
|
||||||
|
content: "Feature";
|
||||||
|
background: var(--accent1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fix::before {
|
||||||
|
content: "Fix";
|
||||||
|
background: var(--accent2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balancing::before {
|
||||||
|
content: "Balancing";
|
||||||
|
background: var(--accent3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breaking::before {
|
||||||
|
content: "Breaking";
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
</style>
|
9
src/data/common.css
Normal file
9
src/data/common.css
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
.modifier-toggle {
|
||||||
|
padding-right: 10px;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modifier-toggle.collapsed {
|
||||||
|
transform: translate(-5px, -5px) rotate(-90deg);
|
||||||
|
}
|
492
src/data/common.tsx
Normal file
492
src/data/common.tsx
Normal file
|
@ -0,0 +1,492 @@
|
||||||
|
import Collapsible from "components/layout/Collapsible.vue";
|
||||||
|
import type { Clickable, ClickableOptions, GenericClickable } 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 { GenericMilestone } from "features/milestones/milestone";
|
||||||
|
import { displayResource, Resource } from "features/resources/resource";
|
||||||
|
import type { GenericTree, GenericTreeNode, TreeNode, TreeNodeOptions } from "features/trees/tree";
|
||||||
|
import { createTreeNode } from "features/trees/tree";
|
||||||
|
import { GenericFormula } from "game/formulas";
|
||||||
|
import type { Modifier } from "game/modifiers";
|
||||||
|
import type { Persistent } from "game/persistence";
|
||||||
|
import { DefaultValue, persistent } from "game/persistence";
|
||||||
|
import player from "game/player";
|
||||||
|
import settings from "game/settings";
|
||||||
|
import type { DecimalSource } from "util/bignum";
|
||||||
|
import Decimal, { format, formatSmall, formatTime } from "util/bignum";
|
||||||
|
import type { WithRequired } from "util/common";
|
||||||
|
import type {
|
||||||
|
Computable,
|
||||||
|
GetComputableType,
|
||||||
|
GetComputableTypeWithDefault,
|
||||||
|
ProcessedComputable
|
||||||
|
} from "util/computed";
|
||||||
|
import { convertComputable, processComputable } from "util/computed";
|
||||||
|
import { getFirstFeature, renderColJSX, renderJSX } from "util/vue";
|
||||||
|
import type { ComputedRef, Ref } from "vue";
|
||||||
|
import { computed, unref } from "vue";
|
||||||
|
import "./common.css";
|
||||||
|
|
||||||
|
/** An object that configures a {@link ResetButton} */
|
||||||
|
export interface ResetButtonOptions extends ClickableOptions {
|
||||||
|
/** The conversion the button uses to calculate how much resources will be gained on click */
|
||||||
|
conversion: GenericConversion;
|
||||||
|
/** The tree this reset button is apart of */
|
||||||
|
tree: GenericTree;
|
||||||
|
/** The specific tree node associated with this reset button */
|
||||||
|
treeNode: GenericTreeNode;
|
||||||
|
/**
|
||||||
|
* Text to display on low conversion amounts, describing what "resetting" is in this context.
|
||||||
|
* Defaults to "Reset for ".
|
||||||
|
*/
|
||||||
|
resetDescription?: Computable<string>;
|
||||||
|
/** Whether or not to show how much currency would be required to make the gain amount increase. */
|
||||||
|
showNextAt?: Computable<boolean>;
|
||||||
|
/**
|
||||||
|
* The content to display on the button.
|
||||||
|
* By default, this includes the reset description, and amount of currency to be gained.
|
||||||
|
*/
|
||||||
|
display?: Computable<CoercableComponent>;
|
||||||
|
/**
|
||||||
|
* Whether or not this button can currently be clicked.
|
||||||
|
* Defaults to checking the current gain amount is greater than {@link minimumGain}
|
||||||
|
*/
|
||||||
|
canClick?: Computable<boolean>;
|
||||||
|
/**
|
||||||
|
* When {@link canClick} is left to its default, minimumGain is used to only enable the reset button when a sufficient amount of currency to gain is available.
|
||||||
|
*/
|
||||||
|
minimumGain?: Computable<DecimalSource>;
|
||||||
|
/** A persistent ref to track how much time has passed since the last time this tree node was reset. */
|
||||||
|
resetTime?: Persistent<DecimalSource>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A button that is used to control a conversion.
|
||||||
|
* 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>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(() => {
|
||||||
|
const resetButton = optionsFunc();
|
||||||
|
|
||||||
|
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.floor(unref(resetButton.conversion.actualGain)).neq(1)
|
||||||
|
? unref(resetButton.conversion.nextAt)
|
||||||
|
: unref(resetButton.conversion.currentAt)
|
||||||
|
)}{" "}
|
||||||
|
{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;
|
||||||
|
}
|
||||||
|
resetButton.conversion.convert();
|
||||||
|
resetButton.tree.reset(resetButton.treeNode);
|
||||||
|
if (resetButton.resetTime) {
|
||||||
|
resetButton.resetTime.value = resetButton.resetTime[DefaultValue];
|
||||||
|
}
|
||||||
|
onClick?.(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
return resetButton;
|
||||||
|
}) as unknown as ResetButton<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An object that configures a {@link LayerTreeNode} */
|
||||||
|
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
|
||||||
|
/**
|
||||||
|
* The content to display in the tree node.
|
||||||
|
* Defaults to the layer's ID
|
||||||
|
*/
|
||||||
|
display?: Computable<CoercableComponent>;
|
||||||
|
/** 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>;
|
||||||
|
}
|
||||||
|
/** 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>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(() => {
|
||||||
|
const options = optionsFunc();
|
||||||
|
processComputable(options as T, "display");
|
||||||
|
setDefault(options, "display", options.layerID);
|
||||||
|
processComputable(options as T, "append");
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
display: options.display,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}) as unknown as LayerTreeNode<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An option object for a modifier display as a single section. **/
|
||||||
|
export interface Section {
|
||||||
|
/** The header for this modifier. **/
|
||||||
|
title: Computable<string>;
|
||||||
|
/** A subtitle for this modifier, e.g. to explain the context for the modifier. **/
|
||||||
|
subtitle?: Computable<string>;
|
||||||
|
/** The modifier to be displaying in this section. **/
|
||||||
|
modifier: WithRequired<Modifier, "description">;
|
||||||
|
/** The base value being modified. **/
|
||||||
|
base?: Computable<DecimalSource>;
|
||||||
|
/** The unit of measurement for the base. **/
|
||||||
|
unit?: string;
|
||||||
|
/** The label to call the base amount. Defaults to "Base". **/
|
||||||
|
baseText?: Computable<CoercableComponent>;
|
||||||
|
/** Whether or not this section should be currently visible to the player. **/
|
||||||
|
visible?: Computable<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes an array of modifier "sections", and creates a JSXFunction that can render all those sections, and allow each section to be collapsed.
|
||||||
|
* Also returns a list of persistent refs that are used to control which sections are currently collapsed.
|
||||||
|
* @param sectionsFunc A function that returns the sections to display.
|
||||||
|
* @param smallerIsBetter Determines whether numbers larger or smaller than the base should be displayed as red.
|
||||||
|
*/
|
||||||
|
export function createCollapsibleModifierSections(
|
||||||
|
sectionsFunc: () => Section[],
|
||||||
|
smallerIsBetter = false
|
||||||
|
): [JSXFunction, 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>[];
|
||||||
|
}
|
||||||
|
| 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));
|
||||||
|
calculated = true;
|
||||||
|
}
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collapsed = persistent<Record<number, boolean>>({});
|
||||||
|
const jsxFunc = jsx(() => {
|
||||||
|
const sections = calculateSections();
|
||||||
|
|
||||||
|
let firstVisibleSection = true;
|
||||||
|
const sectionJSX = sections.map((s, i) => {
|
||||||
|
if (unref(processed.visible[i]) === false) return null;
|
||||||
|
const header = (
|
||||||
|
<h3
|
||||||
|
onClick={() => (collapsed.value[i] = !collapsed.value[i])}
|
||||||
|
style="cursor: pointer"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class={"modifier-toggle" + (unref(collapsed.value[i]) ? " collapsed" : "")}
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</span>
|
||||||
|
{unref(processed.title[i])}
|
||||||
|
{unref(processed.subtitle[i]) != null ? (
|
||||||
|
<span class="subtitle"> ({unref(processed.subtitle[i])})</span>
|
||||||
|
) : null}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
|
||||||
|
const modifiers = unref(collapsed.value[i]) ? null : (
|
||||||
|
<>
|
||||||
|
<div class="modifier-container">
|
||||||
|
<span class="modifier-description">
|
||||||
|
{renderJSX(unref(processed.baseText[i]) ?? "Base")}
|
||||||
|
</span>
|
||||||
|
<span class="modifier-amount">
|
||||||
|
{format(unref(processed.base[i]) ?? 1)}
|
||||||
|
{s.unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{renderJSX(unref(s.modifier.description))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasPreviousSection = !firstVisibleSection;
|
||||||
|
firstVisibleSection = false;
|
||||||
|
|
||||||
|
const base = unref(processed.base[i]) ?? 1;
|
||||||
|
const total = s.modifier.apply(base);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{hasPreviousSection ? <br /> : null}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
"--unit":
|
||||||
|
settings.alignUnits && s.unit != null ? "'" + s.unit + "'" : ""
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{header}
|
||||||
|
<br />
|
||||||
|
{modifiers}
|
||||||
|
<hr />
|
||||||
|
<div class="modifier-container">
|
||||||
|
<span class="modifier-description">Total</span>
|
||||||
|
<span
|
||||||
|
class="modifier-amount"
|
||||||
|
style={
|
||||||
|
(
|
||||||
|
smallerIsBetter === true
|
||||||
|
? Decimal.gt(total, base ?? 1)
|
||||||
|
: Decimal.lt(total, base ?? 1)
|
||||||
|
)
|
||||||
|
? "color: var(--danger)"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formatSmall(total)}
|
||||||
|
{s.unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return <>{sectionJSX}</>;
|
||||||
|
});
|
||||||
|
return [jsxFunc, collapsed];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an HTML string for a span that writes some given text in a given color.
|
||||||
|
* @param textToColor The content to change the color of
|
||||||
|
* @param color The color to change the content to look like. Defaults to the current theme's accent 2 variable.
|
||||||
|
*/
|
||||||
|
export function colorText(textToColor: string, color = "var(--accent2)"): JSX.Element {
|
||||||
|
return <span style={{ color }}>{textToColor}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a collapsible display of a list of milestones
|
||||||
|
* @param milestones A dictionary of the milestones to display, inserted in the order from easiest to hardest
|
||||||
|
*/
|
||||||
|
export function createCollapsibleMilestones(milestones: Record<string, GenericMilestone>) {
|
||||||
|
// Milestones are typically defined from easiest to hardest, and we want to show hardest first
|
||||||
|
const orderedMilestones = Object.values(milestones).reverse();
|
||||||
|
const collapseMilestones = persistent<boolean>(true);
|
||||||
|
const lockedMilestones = computed(() =>
|
||||||
|
orderedMilestones.filter(m => m.earned.value === false)
|
||||||
|
);
|
||||||
|
const { firstFeature, collapsedContent, hasCollapsedContent } = getFirstFeature(
|
||||||
|
orderedMilestones,
|
||||||
|
m => m.earned.value
|
||||||
|
);
|
||||||
|
const display = jsx(() => {
|
||||||
|
const milestonesToDisplay = [...lockedMilestones.value];
|
||||||
|
if (firstFeature.value) {
|
||||||
|
milestonesToDisplay.push(firstFeature.value);
|
||||||
|
}
|
||||||
|
return renderColJSX(
|
||||||
|
...milestonesToDisplay,
|
||||||
|
jsx(() => (
|
||||||
|
<Collapsible
|
||||||
|
collapsed={collapseMilestones}
|
||||||
|
content={collapsedContent}
|
||||||
|
display={
|
||||||
|
collapseMilestones.value
|
||||||
|
? "Show other completed milestones"
|
||||||
|
: "Hide other completed milestones"
|
||||||
|
}
|
||||||
|
v-show={unref(hasCollapsedContent)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
collapseMilestones,
|
||||||
|
display
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function for getting an ETA for when a target will be reached by a resource with a known (and assumed consistent) gain.
|
||||||
|
* @param resource The resource that will be increasing over time.
|
||||||
|
* @param rate The rate at which the resource is increasing.
|
||||||
|
* @param target The target amount of the resource to estimate the duration until.
|
||||||
|
*/
|
||||||
|
export function estimateTime(
|
||||||
|
resource: Resource,
|
||||||
|
rate: Computable<DecimalSource>,
|
||||||
|
target: Computable<DecimalSource>
|
||||||
|
) {
|
||||||
|
const processedRate = convertComputable(rate);
|
||||||
|
const processedTarget = convertComputable(target);
|
||||||
|
return computed(() => {
|
||||||
|
const currRate = unref(processedRate);
|
||||||
|
const currTarget = unref(processedTarget);
|
||||||
|
if (Decimal.gte(resource.value, currTarget)) {
|
||||||
|
return "Now";
|
||||||
|
} else if (Decimal.lt(currRate, 0)) {
|
||||||
|
return "Never";
|
||||||
|
}
|
||||||
|
return formatTime(Decimal.sub(currTarget, resource.value).div(currRate));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function for displaying the result of a formula such that it will, when told to, preview how the formula's result will change.
|
||||||
|
* Requires a formula with a single variable inside.
|
||||||
|
* @param formula The formula to display the result of.
|
||||||
|
* @param showPreview Whether or not to preview how the formula's result will change.
|
||||||
|
* @param previewAmount The amount to _add_ to the current formula's variable amount to preview the change in result.
|
||||||
|
*/
|
||||||
|
export function createFormulaPreview(
|
||||||
|
formula: GenericFormula,
|
||||||
|
showPreview: Computable<boolean>,
|
||||||
|
previewAmount: Computable<DecimalSource> = 1
|
||||||
|
): ComputedRef<CoercableComponent> {
|
||||||
|
const processedShowPreview = convertComputable(showPreview);
|
||||||
|
const processedPreviewAmount = convertComputable(previewAmount);
|
||||||
|
if (!formula.hasVariable()) {
|
||||||
|
throw "Cannot create formula preview if the formula does not have a variable";
|
||||||
|
}
|
||||||
|
return computed(() => {
|
||||||
|
if (unref(processedShowPreview)) {
|
||||||
|
const curr = formatSmall(formula.evaluate());
|
||||||
|
const preview = formatSmall(
|
||||||
|
formula.evaluate(
|
||||||
|
Decimal.add(
|
||||||
|
unref(formula.innermostVariable ?? 0),
|
||||||
|
unref(processedPreviewAmount)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return jsx(() => (
|
||||||
|
<>
|
||||||
|
<b>
|
||||||
|
<i>
|
||||||
|
{curr}→{preview}
|
||||||
|
</i>
|
||||||
|
</b>
|
||||||
|
</>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return formatSmall(formula.evaluate());
|
||||||
|
});
|
||||||
|
}
|
73
src/data/layers/prestige.tsx
Normal file
73
src/data/layers/prestige.tsx
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
/**
|
||||||
|
* @module
|
||||||
|
* @hidden
|
||||||
|
*/
|
||||||
|
import { main } from "data/projEntry";
|
||||||
|
import { createCumulativeConversion, createPolynomialScaling } from "features/conversion";
|
||||||
|
import { jsx } from "features/feature";
|
||||||
|
import { createHotkey } from "features/hotkey";
|
||||||
|
import { createReset } from "features/reset";
|
||||||
|
import MainDisplay from "features/resources/MainDisplay.vue";
|
||||||
|
import { createResource } from "features/resources/resource";
|
||||||
|
import { addTooltip } from "features/tooltips/tooltip";
|
||||||
|
import { createResourceTooltip } from "features/trees/tree";
|
||||||
|
import { BaseLayer, createLayer } from "game/layers";
|
||||||
|
import type { DecimalSource } from "util/bignum";
|
||||||
|
import { render } from "util/vue";
|
||||||
|
import { createLayerTreeNode, createResetButton } from "../common";
|
||||||
|
|
||||||
|
const id = "p";
|
||||||
|
const layer = createLayer(id, function (this: BaseLayer) {
|
||||||
|
const name = "Prestige";
|
||||||
|
const color = "#4BDC13";
|
||||||
|
const points = createResource<DecimalSource>(0, "prestige points");
|
||||||
|
|
||||||
|
const conversion = createCumulativeConversion(() => ({
|
||||||
|
scaling: createPolynomialScaling(10, 0.5),
|
||||||
|
baseResource: main.points,
|
||||||
|
gainResource: points,
|
||||||
|
roundUpCost: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
const reset = createReset(() => ({
|
||||||
|
thingsToReset: (): Record<string, unknown>[] => [layer]
|
||||||
|
}));
|
||||||
|
|
||||||
|
const treeNode = createLayerTreeNode(() => ({
|
||||||
|
layerID: id,
|
||||||
|
color,
|
||||||
|
reset
|
||||||
|
}));
|
||||||
|
addTooltip(treeNode, {
|
||||||
|
display: createResourceTooltip(points),
|
||||||
|
pinnable: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetButton = createResetButton(() => ({
|
||||||
|
conversion,
|
||||||
|
tree: main.tree,
|
||||||
|
treeNode
|
||||||
|
}));
|
||||||
|
|
||||||
|
const hotkey = createHotkey(() => ({
|
||||||
|
description: "Reset for prestige points",
|
||||||
|
key: "p",
|
||||||
|
onPress: resetButton.onClick
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
points,
|
||||||
|
display: jsx(() => (
|
||||||
|
<>
|
||||||
|
<MainDisplay resource={points} color={color} />
|
||||||
|
{render(resetButton)}
|
||||||
|
</>
|
||||||
|
)),
|
||||||
|
treeNode,
|
||||||
|
hotkey
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export default layer;
|
103
src/data/projEntry.tsx
Normal file
103
src/data/projEntry.tsx
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
import Spacer from "components/layout/Spacer.vue";
|
||||||
|
import { jsx } from "features/feature";
|
||||||
|
import { createResource, trackBest, trackOOMPS, trackTotal } from "features/resources/resource";
|
||||||
|
import type { GenericTree } from "features/trees/tree";
|
||||||
|
import { branchedResetPropagation, createTree } from "features/trees/tree";
|
||||||
|
import { globalBus } from "game/events";
|
||||||
|
import type { BaseLayer, GenericLayer } from "game/layers";
|
||||||
|
import { createLayer } from "game/layers";
|
||||||
|
import type { Player } from "game/player";
|
||||||
|
import player from "game/player";
|
||||||
|
import type { DecimalSource } from "util/bignum";
|
||||||
|
import Decimal, { format, formatTime } from "util/bignum";
|
||||||
|
import { render } from "util/vue";
|
||||||
|
import { computed, toRaw } from "vue";
|
||||||
|
import prestige from "./layers/prestige";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @hidden
|
||||||
|
*/
|
||||||
|
export const main = createLayer("main", function (this: BaseLayer) {
|
||||||
|
const points = createResource<DecimalSource>(10);
|
||||||
|
const best = trackBest(points);
|
||||||
|
const total = trackTotal(points);
|
||||||
|
|
||||||
|
const pointGain = computed(() => {
|
||||||
|
// eslint-disable-next-line prefer-const
|
||||||
|
let gain = new Decimal(1);
|
||||||
|
return gain;
|
||||||
|
});
|
||||||
|
globalBus.on("update", diff => {
|
||||||
|
points.value = Decimal.add(points.value, Decimal.times(pointGain.value, diff));
|
||||||
|
});
|
||||||
|
const oomps = trackOOMPS(points, pointGain);
|
||||||
|
|
||||||
|
const tree = createTree(() => ({
|
||||||
|
nodes: [[prestige.treeNode]],
|
||||||
|
branches: [],
|
||||||
|
onReset() {
|
||||||
|
points.value = toRaw(this.resettingNode.value) === toRaw(prestige.treeNode) ? 0 : 10;
|
||||||
|
best.value = points.value;
|
||||||
|
total.value = points.value;
|
||||||
|
},
|
||||||
|
resetPropagation: branchedResetPropagation
|
||||||
|
})) as GenericTree;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "Tree",
|
||||||
|
links: tree.links,
|
||||||
|
display: jsx(() => (
|
||||||
|
<>
|
||||||
|
{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>
|
||||||
|
) : null}
|
||||||
|
{player.offlineTime != null && player.offlineTime !== 0 ? (
|
||||||
|
<div>Offline Time: {formatTime(player.offlineTime)}</div>
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
{Decimal.lt(points.value, "1e1000") ? <span>You have </span> : null}
|
||||||
|
<h2>{format(points.value)}</h2>
|
||||||
|
{Decimal.lt(points.value, "1e1e6") ? <span> points</span> : null}
|
||||||
|
</div>
|
||||||
|
{Decimal.gt(pointGain.value, 0) ? <div>({oomps.value})</div> : null}
|
||||||
|
<Spacer />
|
||||||
|
{render(tree)}
|
||||||
|
</>
|
||||||
|
)),
|
||||||
|
points,
|
||||||
|
best,
|
||||||
|
total,
|
||||||
|
oomps,
|
||||||
|
tree
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a player save data object being loaded, return a list of layers that should currently be enabled.
|
||||||
|
* If your project does not use dynamic layers, this should just return all layers.
|
||||||
|
*/
|
||||||
|
export const getInitialLayers = (
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
|
||||||
|
player: Partial<Player>
|
||||||
|
): Array<GenericLayer> => [main, prestige];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A computed ref whose value is true whenever the game is over.
|
||||||
|
*/
|
||||||
|
export const hasWon = computed(() => {
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a player save data object being loaded with a different version, update the save data object to match the structure of the current version.
|
||||||
|
* @param oldVersion The version of the save being loaded in
|
||||||
|
* @param player The save data being loaded in
|
||||||
|
*/
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
export function fixOldSave(
|
||||||
|
oldVersion: string | undefined,
|
||||||
|
player: Partial<Player>
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
): void {}
|
||||||
|
/* eslint-enable @typescript-eslint/no-unused-vars */
|
93
src/data/projInfo-schema.json
Normal file
93
src/data/projInfo-schema.json
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The name of the project, which will appear in the info tab and the header, if enabled. The page title will also be set to this value."
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "A description of the project, which will be used when the project is installed as a Progressive Web Application."
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "This is a unique ID used when saving player data. Changing this will effectively erase all save data for all players. This ID MUST be unique to your project, and should not be left as the default value. Otherwise, your project may use the save data from another project and cause issues for both projects.",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"author": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The author of the project, which will appear in the info tab."
|
||||||
|
},
|
||||||
|
"discordName": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The text to display for the discord server to point users to. This will appear when hovering over the discord icon, inside the info tab, the game over screen, as well as the NaN detected screen."
|
||||||
|
},
|
||||||
|
"discordLink": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The link for the discord server to point users to."
|
||||||
|
},
|
||||||
|
"versionNumber": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The current version of the project loaded. If the player data was last saved in a different version of the project, fixOldSave will be run, so you can perform any save migrations necessary. This will also appear in the nav, the info tab, and the game over screen.",
|
||||||
|
"markdownDescription": "The current version of the project loaded. If the player data was last saved in a different version of the project, [fixOldSave](https://moddingtree.com/guide/creating-your-project/project-entry.html#fixoldsave) will be run, so you can perform any save migrations necessary. This will also appear in the nav, the info tab, and the game over screen."
|
||||||
|
},
|
||||||
|
"versionTitle": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The display name for the current version of the project loaded. This will also appear in the nav, the info tab, and the game over screen unless set to an empty string."
|
||||||
|
},
|
||||||
|
"allowGoBack": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether or not to allow tabs (besides the first) to display a \"back\" button to close them (and any other tabs to the right of them)."
|
||||||
|
},
|
||||||
|
"defaultShowSmall": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether or not to allow resources to display small values (<.001). If false they'll just display as 0. Individual resources can also be configured to override this value."
|
||||||
|
},
|
||||||
|
"defaultDecimalsShown": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Default precision to display numbers at when passed into format. Individual format calls can override this value, and resources can be configured with a custom precision as well.",
|
||||||
|
"markdownDescription": "Default precision to display numbers at when passed into format. Individual format calls can override this value, and resources can be configured with a custom precision as well."
|
||||||
|
},
|
||||||
|
"useHeader": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether or not to display the nav as a header at the top of the screen. If disabled, the nav will appear on the left side of the screen laid over the first tab."
|
||||||
|
},
|
||||||
|
"banner": {
|
||||||
|
"type": ["boolean", "null"],
|
||||||
|
"description": "A path to an image file to display as the logo of the app. If null, the title will be shown instead. This will appear in the nav when useHeader is true.",
|
||||||
|
"markdownDescription": "A path to an image file to display as the logo of the app. If null, the title will be shown instead. This will appear in the nav when `useHeader` is true."
|
||||||
|
},
|
||||||
|
"logo": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "A path to an image file to display as the logo of the app within the info tab. If left blank no logo will be shown."
|
||||||
|
},
|
||||||
|
"initialTabs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"minItems": 1,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"description": "The list of initial tabs to display on new saves. This value must have at least one element. Each element should be the ID of the layer to display in that tab."
|
||||||
|
},
|
||||||
|
"maxTickLength": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The longest duration a single tick can be, in seconds. When calculating things like offline time, a single tick will be forced to be this amount or lower. This will make calculating offline time spread out across many ticks as necessary. The default value is 1 hour."
|
||||||
|
},
|
||||||
|
"offlineLimit": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The max amount of time that can be stored as offline time, in hours."
|
||||||
|
},
|
||||||
|
"enablePausing": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether or not to allow the player to pause the game. Turning this off disables the toggle from the options menu as well as the NaN screen. Developers can still manually pause by just setting player.devSpeed to 0 in console (or 1 to resume).",
|
||||||
|
"markdownDescription": "Whether or not to allow the player to pause the game. Turning this off disables the toggle from the options menu as well as the NaN screen. Developers can still manually pause by just running `player.devSpeed = 0` in console (or `= 1` to resume)."
|
||||||
|
},
|
||||||
|
"exportEncoding": {
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
26
src/data/projInfo.json
Normal file
26
src/data/projInfo.json
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"$schema": "./projInfo-schema.json",
|
||||||
|
|
||||||
|
"title": "Profectus",
|
||||||
|
"description": "A project made in Profectus",
|
||||||
|
"id": "",
|
||||||
|
"author": "",
|
||||||
|
"discordName": "",
|
||||||
|
"discordLink": "",
|
||||||
|
|
||||||
|
"versionNumber": "0.0",
|
||||||
|
"versionTitle": "Initial Commit",
|
||||||
|
|
||||||
|
"allowGoBack": true,
|
||||||
|
"defaultShowSmall": false,
|
||||||
|
"defaultDecimalsShown": 2,
|
||||||
|
"useHeader": true,
|
||||||
|
"banner": null,
|
||||||
|
"logo": "",
|
||||||
|
"initialTabs": [ "main" ],
|
||||||
|
|
||||||
|
"maxTickLength": 3600,
|
||||||
|
"offlineLimit": 1,
|
||||||
|
"enablePausing": true,
|
||||||
|
"exportEncoding": "base64"
|
||||||
|
}
|
137
src/data/themes.ts
Normal file
137
src/data/themes.ts
Normal file
|
@ -0,0 +1,137 @@
|
||||||
|
/** A object of all CSS variables determined by the current theme. */
|
||||||
|
export interface ThemeVars {
|
||||||
|
"--foreground": string;
|
||||||
|
"--background": string;
|
||||||
|
"--feature-foreground": string;
|
||||||
|
"--tooltip-background": string;
|
||||||
|
"--raised-background": string;
|
||||||
|
"--points": string;
|
||||||
|
"--locked": string;
|
||||||
|
"--highlighted": string;
|
||||||
|
"--bought": string;
|
||||||
|
"--danger": string;
|
||||||
|
"--link": string;
|
||||||
|
"--outline": string;
|
||||||
|
"--accent1": string;
|
||||||
|
"--accent2": string;
|
||||||
|
"--accent3": string;
|
||||||
|
"--border-radius": string;
|
||||||
|
"--modal-border": string;
|
||||||
|
"--feature-margin": string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An object representing a theme the player can use to change the look of the game. */
|
||||||
|
export interface Theme {
|
||||||
|
/** The values of the theme's CSS variables. */
|
||||||
|
variables: ThemeVars;
|
||||||
|
/** Whether or not tabs should "float" in the center of their container. */
|
||||||
|
floatingTabs: boolean;
|
||||||
|
/** Whether or not adjacent features should merge together - removing the margin between them, and only applying the border radius to the first and last elements in the row or column. */
|
||||||
|
mergeAdjacent: boolean;
|
||||||
|
/** Whether or not to show a pin icon on pinned tooltips. */
|
||||||
|
showPin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@vue/runtime-dom" {
|
||||||
|
/** Make CSS properties accept any CSS variables usually controlled by a theme. */
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||||
|
interface CSSProperties extends Partial<ThemeVars> {}
|
||||||
|
|
||||||
|
interface HTMLAttributes {
|
||||||
|
style?: StyleValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultTheme: Theme = {
|
||||||
|
variables: {
|
||||||
|
"--foreground": "#dfdfdf",
|
||||||
|
"--background": "#0f0f0f",
|
||||||
|
"--feature-foreground": "#0f0f0f",
|
||||||
|
"--tooltip-background": "rgba(0, 0, 0, 0.75)",
|
||||||
|
"--raised-background": "#0f0f0f",
|
||||||
|
"--points": "#ffffff",
|
||||||
|
"--locked": "#bf8f8f",
|
||||||
|
"--highlighted": "#333",
|
||||||
|
"--bought": "#77bf5f",
|
||||||
|
"--danger": "rgb(220, 53, 69)",
|
||||||
|
"--link": "#02f2f2",
|
||||||
|
"--outline": "#dfdfdf",
|
||||||
|
"--accent1": "#627a82",
|
||||||
|
"--accent2": "#658262",
|
||||||
|
"--accent3": "#7c6282",
|
||||||
|
|
||||||
|
"--border-radius": "15px",
|
||||||
|
"--modal-border": "solid 2px var(--color)",
|
||||||
|
"--feature-margin": "0px"
|
||||||
|
},
|
||||||
|
floatingTabs: true,
|
||||||
|
mergeAdjacent: true,
|
||||||
|
showPin: true
|
||||||
|
};
|
||||||
|
|
||||||
|
/** An enum of all available themes and their internal IDs. The keys are their display names. */
|
||||||
|
export enum Themes {
|
||||||
|
Classic = "classic",
|
||||||
|
Paper = "paper",
|
||||||
|
Nordic = "nordic",
|
||||||
|
Aqua = "aqua"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A dictionary of all available themes. */
|
||||||
|
export default {
|
||||||
|
classic: defaultTheme,
|
||||||
|
paper: {
|
||||||
|
...defaultTheme,
|
||||||
|
variables: {
|
||||||
|
...defaultTheme.variables,
|
||||||
|
"--background": "#2a323d",
|
||||||
|
"--feature-foreground": "#000",
|
||||||
|
"--raised-background": "#333c4a",
|
||||||
|
"--locked": "#3a3e45",
|
||||||
|
"--bought": "#5C8A58",
|
||||||
|
"--outline": "#333c4a",
|
||||||
|
"--border-radius": "4px",
|
||||||
|
"--modal-border": "",
|
||||||
|
"--feature-margin": "5px"
|
||||||
|
},
|
||||||
|
floatingTabs: false
|
||||||
|
} as Theme,
|
||||||
|
// Based on https://www.nordtheme.com
|
||||||
|
nordic: {
|
||||||
|
...defaultTheme,
|
||||||
|
variables: {
|
||||||
|
...defaultTheme.variables,
|
||||||
|
"--foreground": "#D8DEE9",
|
||||||
|
"--background": "#2E3440",
|
||||||
|
"--feature-foreground": "#000",
|
||||||
|
"--raised-background": "#3B4252",
|
||||||
|
"--points": "#E5E9F0",
|
||||||
|
"--locked": "#4c566a",
|
||||||
|
"--highlighted": "#434c5e",
|
||||||
|
"--bought": "#8FBCBB",
|
||||||
|
"--danger": "#D08770",
|
||||||
|
"--link": "#88C0D0",
|
||||||
|
"--outline": "#3B4252",
|
||||||
|
"--accent1": "#B48EAD",
|
||||||
|
"--accent2": "#A3BE8C",
|
||||||
|
"--accent3": "#EBCB8B",
|
||||||
|
"--border-radius": "4px",
|
||||||
|
"--modal-border": "solid 2px #3B4252",
|
||||||
|
"--feature-margin": "5px"
|
||||||
|
},
|
||||||
|
floatingTabs: false
|
||||||
|
} as Theme,
|
||||||
|
aqua: {
|
||||||
|
...defaultTheme,
|
||||||
|
variables: {
|
||||||
|
...defaultTheme.variables,
|
||||||
|
"--foreground": "#bfdfff",
|
||||||
|
"--background": "#001f3f",
|
||||||
|
"--tooltip-background": "rgba(0, 15, 31, 0.75)",
|
||||||
|
"--raised-background": "#001f3f",
|
||||||
|
"--points": "#dfefff",
|
||||||
|
"--locked": "#c4a7b3",
|
||||||
|
"--outline": "#bfdfff"
|
||||||
|
}
|
||||||
|
} as Theme
|
||||||
|
} as Record<Themes, Theme>;
|
81
src/features/achievements/Achievement.vue
Normal file
81
src/features/achievements/Achievement.vue
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="isVisible(visibility)"
|
||||||
|
:style="[
|
||||||
|
{
|
||||||
|
visibility: isHidden(visibility) ? 'hidden' : undefined,
|
||||||
|
backgroundImage: (earned && image && `url(${image})`) || ''
|
||||||
|
},
|
||||||
|
unref(style) ?? []
|
||||||
|
]"
|
||||||
|
:class="{
|
||||||
|
feature: true,
|
||||||
|
achievement: true,
|
||||||
|
locked: !unref(earned),
|
||||||
|
bought: unref(earned),
|
||||||
|
...unref(classes)
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<component v-if="component" :is="component" />
|
||||||
|
<MarkNode :mark="unref(mark)" />
|
||||||
|
<Node :id="id" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import "components/common/features.css";
|
||||||
|
import MarkNode from "components/MarkNode.vue";
|
||||||
|
import Node from "components/Node.vue";
|
||||||
|
import type { CoercableComponent } from "features/feature";
|
||||||
|
import { Visibility, isHidden, isVisible } from "features/feature";
|
||||||
|
import { computeOptionalComponent, processedPropType } from "util/vue";
|
||||||
|
import type { StyleValue } from "vue";
|
||||||
|
import { defineComponent, toRefs, unref } from "vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
visibility: {
|
||||||
|
type: processedPropType<Visibility | boolean>(Number, Boolean),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
display: processedPropType<CoercableComponent>(Object, String, Function),
|
||||||
|
earned: {
|
||||||
|
type: processedPropType<boolean>(Boolean),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
image: processedPropType<string>(String),
|
||||||
|
style: processedPropType<StyleValue>(String, Object, Array),
|
||||||
|
classes: processedPropType<Record<string, boolean>>(Object),
|
||||||
|
mark: processedPropType<boolean | string>(Boolean, String),
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Node,
|
||||||
|
MarkNode
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const { display } = toRefs(props);
|
||||||
|
|
||||||
|
return {
|
||||||
|
component: computeOptionalComponent(display),
|
||||||
|
unref,
|
||||||
|
Visibility,
|
||||||
|
isVisible,
|
||||||
|
isHidden
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.achievement {
|
||||||
|
height: 90px;
|
||||||
|
width: 90px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: white;
|
||||||
|
text-shadow: 0 0 2px #000000;
|
||||||
|
}
|
||||||
|
</style>
|
132
src/features/achievements/achievement.tsx
Normal file
132
src/features/achievements/achievement.tsx
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
import AchievementComponent from "features/achievements/Achievement.vue";
|
||||||
|
import {
|
||||||
|
CoercableComponent,
|
||||||
|
Component,
|
||||||
|
GatherProps,
|
||||||
|
getUniqueID,
|
||||||
|
isVisible,
|
||||||
|
OptionsFunc,
|
||||||
|
Replace,
|
||||||
|
setDefault,
|
||||||
|
StyleValue,
|
||||||
|
Visibility
|
||||||
|
} from "features/feature";
|
||||||
|
import "game/notifications";
|
||||||
|
import type { Persistent } from "game/persistence";
|
||||||
|
import { persistent } from "game/persistence";
|
||||||
|
import player from "game/player";
|
||||||
|
import settings from "game/settings";
|
||||||
|
import type {
|
||||||
|
Computable,
|
||||||
|
GetComputableType,
|
||||||
|
GetComputableTypeWithDefault,
|
||||||
|
ProcessedComputable
|
||||||
|
} from "util/computed";
|
||||||
|
import { processComputable } from "util/computed";
|
||||||
|
import { createLazyProxy } from "util/proxies";
|
||||||
|
import { coerceComponent } from "util/vue";
|
||||||
|
import { unref, watchEffect } from "vue";
|
||||||
|
import { useToast } from "vue-toastification";
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
export const AchievementType = Symbol("Achievement");
|
||||||
|
|
||||||
|
export interface AchievementOptions {
|
||||||
|
visibility?: Computable<Visibility | boolean>;
|
||||||
|
shouldEarn?: () => boolean;
|
||||||
|
display?: Computable<CoercableComponent>;
|
||||||
|
mark?: Computable<boolean | string>;
|
||||||
|
image?: Computable<string>;
|
||||||
|
style?: Computable<StyleValue>;
|
||||||
|
classes?: Computable<Record<string, boolean>>;
|
||||||
|
onComplete?: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseAchievement {
|
||||||
|
id: string;
|
||||||
|
earned: Persistent<boolean>;
|
||||||
|
complete: VoidFunction;
|
||||||
|
type: typeof AchievementType;
|
||||||
|
[Component]: typeof AchievementComponent;
|
||||||
|
[GatherProps]: () => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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"]>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericAchievement = Replace<
|
||||||
|
Achievement<AchievementOptions>,
|
||||||
|
{
|
||||||
|
visibility: ProcessedComputable<Visibility | boolean>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createAchievement<T extends AchievementOptions>(
|
||||||
|
optionsFunc?: OptionsFunc<T, BaseAchievement, GenericAchievement>
|
||||||
|
): Achievement<T> {
|
||||||
|
const earned = persistent<boolean>(false);
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const achievement = optionsFunc?.() ?? ({} as ReturnType<NonNullable<typeof optionsFunc>>);
|
||||||
|
achievement.id = getUniqueID("achievement-");
|
||||||
|
achievement.type = AchievementType;
|
||||||
|
achievement[Component] = AchievementComponent;
|
||||||
|
|
||||||
|
achievement.earned = earned;
|
||||||
|
achievement.complete = function () {
|
||||||
|
earned.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
processComputable(achievement as T, "visibility");
|
||||||
|
setDefault(achievement, "visibility", Visibility.Visible);
|
||||||
|
processComputable(achievement as T, "display");
|
||||||
|
processComputable(achievement as T, "mark");
|
||||||
|
processComputable(achievement as T, "image");
|
||||||
|
processComputable(achievement as T, "style");
|
||||||
|
processComputable(achievement as T, "classes");
|
||||||
|
|
||||||
|
achievement[GatherProps] = function (this: GenericAchievement) {
|
||||||
|
const { visibility, display, earned, image, style, classes, mark, id } = this;
|
||||||
|
return { visibility, display, earned, image, style: unref(style), classes, mark, id };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (achievement.shouldEarn) {
|
||||||
|
const genericAchievement = achievement as GenericAchievement;
|
||||||
|
watchEffect(() => {
|
||||||
|
if (settings.active !== player.id) return;
|
||||||
|
if (
|
||||||
|
!genericAchievement.earned.value &&
|
||||||
|
isVisible(genericAchievement.visibility) &&
|
||||||
|
genericAchievement.shouldEarn?.()
|
||||||
|
) {
|
||||||
|
genericAchievement.earned.value = true;
|
||||||
|
genericAchievement.onComplete?.();
|
||||||
|
if (genericAchievement.display != null) {
|
||||||
|
const Display = coerceComponent(unref(genericAchievement.display));
|
||||||
|
toast.info(
|
||||||
|
<div>
|
||||||
|
<h3>Achievement earned!</h3>
|
||||||
|
<div>
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
<Display />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return achievement as unknown as Achievement<T>;
|
||||||
|
});
|
||||||
|
}
|
247
src/features/action.tsx
Normal file
247
src/features/action.tsx
Normal file
|
@ -0,0 +1,247 @@
|
||||||
|
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";
|
||||||
|
|
||||||
|
export const ActionType = Symbol("Action");
|
||||||
|
|
||||||
|
export interface ActionOptions extends Omit<ClickableOptions, "onClick" | "onHold"> {
|
||||||
|
duration: Computable<DecimalSource>;
|
||||||
|
autoStart?: Computable<boolean>;
|
||||||
|
onClick: (amount: DecimalSource) => void;
|
||||||
|
barOptions?: Partial<BarOptions>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseAction {
|
||||||
|
id: string;
|
||||||
|
type: typeof ActionType;
|
||||||
|
isHolding: Ref<boolean>;
|
||||||
|
progress: Ref<DecimalSource>;
|
||||||
|
progressBar: GenericBar;
|
||||||
|
update: (diff: number) => void;
|
||||||
|
[Component]: GenericComponent;
|
||||||
|
[GatherProps]: () => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericAction = Replace<
|
||||||
|
Action<ActionOptions>,
|
||||||
|
{
|
||||||
|
autoStart: ProcessedComputable<boolean>;
|
||||||
|
visibility: ProcessedComputable<Visibility | boolean>;
|
||||||
|
canClick: ProcessedComputable<boolean>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createAction<T extends ActionOptions>(
|
||||||
|
optionsFunc?: OptionsFunc<T, BaseAction, GenericAction>
|
||||||
|
): Action<T> {
|
||||||
|
const progress = persistent<DecimalSource>(0);
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const action = optionsFunc?.() ?? ({} 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;
|
||||||
|
|
||||||
|
action.isHolding = ref(false);
|
||||||
|
action.progress = progress;
|
||||||
|
|
||||||
|
processComputable(action as T, "visibility");
|
||||||
|
setDefault(action, "visibility", Visibility.Visible);
|
||||||
|
processComputable(action as T, "duration");
|
||||||
|
processComputable(action as T, "autoStart");
|
||||||
|
setDefault(action, "autoStart", false);
|
||||||
|
processComputable(action as T, "canClick");
|
||||||
|
setDefault(action, "canClick", true);
|
||||||
|
processComputable(action as T, "classes");
|
||||||
|
processComputable(action as T, "style");
|
||||||
|
processComputable(action as T, "mark");
|
||||||
|
processComputable(action as T, "display");
|
||||||
|
|
||||||
|
const style = action.style as ProcessedComputable<StyleValue | undefined>;
|
||||||
|
action.style = computed(() => {
|
||||||
|
const currStyle: StyleValue[] = [
|
||||||
|
{
|
||||||
|
cursor: Decimal.gte(
|
||||||
|
progress.value,
|
||||||
|
unref(action.duration as ProcessedComputable<DecimalSource>)
|
||||||
|
)
|
||||||
|
? "pointer"
|
||||||
|
: "progress",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const originalStyle = unref(style);
|
||||||
|
if (isArray(originalStyle)) {
|
||||||
|
currStyle.push(...originalStyle);
|
||||||
|
} else if (originalStyle != null) {
|
||||||
|
currStyle.push(originalStyle);
|
||||||
|
}
|
||||||
|
return currStyle as StyleValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
action.progressBar = createBar(() => ({
|
||||||
|
direction: Direction.Right,
|
||||||
|
width: 100,
|
||||||
|
height: 10,
|
||||||
|
style: "margin-top: 8px",
|
||||||
|
borderStyle: "border-color: black",
|
||||||
|
baseStyle: "margin-top: -1px",
|
||||||
|
progress: () => Decimal.div(progress.value, unref(genericAction.duration)),
|
||||||
|
...action.barOptions
|
||||||
|
}));
|
||||||
|
|
||||||
|
const canClick = action.canClick as ProcessedComputable<boolean>;
|
||||||
|
action.canClick = computed(
|
||||||
|
() =>
|
||||||
|
unref(canClick) &&
|
||||||
|
Decimal.gte(
|
||||||
|
progress.value,
|
||||||
|
unref(action.duration as ProcessedComputable<DecimalSource>)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const display = action.display as GetComputableType<ClickableOptions["display"]>;
|
||||||
|
action.display = jsx(() => {
|
||||||
|
const currDisplay = unref(display);
|
||||||
|
let Comp: GenericComponent | undefined;
|
||||||
|
if (isCoercableComponent(currDisplay)) {
|
||||||
|
Comp = coerceComponent(currDisplay);
|
||||||
|
} else if (currDisplay != null) {
|
||||||
|
const Title = coerceComponent(currDisplay.title ?? "", "h3");
|
||||||
|
const Description = coerceComponent(currDisplay.description, "div");
|
||||||
|
Comp = coerceComponent(
|
||||||
|
jsx(() => (
|
||||||
|
<span>
|
||||||
|
{currDisplay.title != null ? (
|
||||||
|
<div>
|
||||||
|
<Title />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Description />
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style="flex-grow: 1" />
|
||||||
|
{Comp == null ? null : <Comp />}
|
||||||
|
<div style="flex-grow: 1" />
|
||||||
|
{render(genericAction.progressBar)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const onClick = action.onClick.bind(action);
|
||||||
|
action.onClick = function () {
|
||||||
|
if (unref(action.canClick) === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const amount = Decimal.div(progress.value, unref(genericAction.duration));
|
||||||
|
onClick?.(amount);
|
||||||
|
progress.value = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
action.update = function (diff) {
|
||||||
|
const duration = unref(genericAction.duration);
|
||||||
|
if (Decimal.gte(progress.value, duration)) {
|
||||||
|
progress.value = duration;
|
||||||
|
} else {
|
||||||
|
progress.value = Decimal.add(progress.value, diff);
|
||||||
|
if (genericAction.isHolding.value || unref(genericAction.autoStart)) {
|
||||||
|
genericAction.onClick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
183
src/features/bars/Bar.vue
Normal file
183
src/features/bars/Bar.vue
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
<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)
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="overlayTextContainer border"
|
||||||
|
:style="[
|
||||||
|
{ width: unref(width) + 'px', height: unref(height) + 'px' },
|
||||||
|
unref(borderStyle) ?? {}
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span v-if="component" class="overlayText" :style="unref(textStyle)">
|
||||||
|
<component :is="component" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="border"
|
||||||
|
:style="[
|
||||||
|
{ width: unref(width) + 'px', height: unref(height) + 'px' },
|
||||||
|
unref(style) ?? {},
|
||||||
|
unref(baseStyle) ?? {},
|
||||||
|
unref(borderStyle) ?? {}
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="fill" :style="[barStyle, unref(style) ?? {}, 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";
|
||||||
|
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";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
progress: {
|
||||||
|
type: processedPropType<DecimalSource>(String, Object, Number),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
type: processedPropType<number>(Number),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: processedPropType<number>(Number),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
direction: {
|
||||||
|
type: processedPropType<Direction>(String),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
display: processedPropType<CoercableComponent>(Object, String, Function),
|
||||||
|
visibility: {
|
||||||
|
type: processedPropType<Visibility | boolean>(Number, Boolean),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
style: processedPropType<StyleValue>(Object, String, Array),
|
||||||
|
classes: processedPropType<Record<string, boolean>>(Object),
|
||||||
|
borderStyle: processedPropType<StyleValue>(Object, String, Array),
|
||||||
|
textStyle: processedPropType<StyleValue>(Object, String, Array),
|
||||||
|
baseStyle: processedPropType<StyleValue>(Object, String, Array),
|
||||||
|
fillStyle: processedPropType<StyleValue>(Object, String, Array),
|
||||||
|
mark: processedPropType<boolean | string>(Boolean, String),
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MarkNode,
|
||||||
|
Node
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const { progress, width, height, direction, display } = toRefs(props);
|
||||||
|
|
||||||
|
const normalizedProgress = computed(() => {
|
||||||
|
let progressNumber =
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bar {
|
||||||
|
position: relative;
|
||||||
|
display: table;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlayTextContainer {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 10px;
|
||||||
|
vertical-align: middle;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlayText {
|
||||||
|
z-index: 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border {
|
||||||
|
border: 2px solid;
|
||||||
|
border-radius: 10px;
|
||||||
|
border-color: var(--foreground);
|
||||||
|
overflow: hidden;
|
||||||
|
mask-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAA5JREFUeNpiYGBgAAgwAAAEAAGbA+oJAAAAAElFTkSuQmCC);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill {
|
||||||
|
position: absolute;
|
||||||
|
background-color: var(--foreground);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-left: -0.5px;
|
||||||
|
transition-duration: 0.2s;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
</style>
|
134
src/features/bars/bar.ts
Normal file
134
src/features/bars/bar.ts
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
import BarComponent from "features/bars/Bar.vue";
|
||||||
|
import type {
|
||||||
|
CoercableComponent,
|
||||||
|
GenericComponent,
|
||||||
|
OptionsFunc,
|
||||||
|
Replace,
|
||||||
|
StyleValue
|
||||||
|
} from "features/feature";
|
||||||
|
import { Component, GatherProps, getUniqueID, setDefault, Visibility } 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";
|
||||||
|
|
||||||
|
export const BarType = Symbol("Bar");
|
||||||
|
|
||||||
|
export interface BarOptions {
|
||||||
|
visibility?: Computable<Visibility | boolean>;
|
||||||
|
width: Computable<number>;
|
||||||
|
height: Computable<number>;
|
||||||
|
direction: Computable<Direction>;
|
||||||
|
style?: Computable<StyleValue>;
|
||||||
|
classes?: Computable<Record<string, boolean>>;
|
||||||
|
borderStyle?: Computable<StyleValue>;
|
||||||
|
baseStyle?: Computable<StyleValue>;
|
||||||
|
textStyle?: Computable<StyleValue>;
|
||||||
|
fillStyle?: Computable<StyleValue>;
|
||||||
|
progress: Computable<DecimalSource>;
|
||||||
|
display?: Computable<CoercableComponent>;
|
||||||
|
mark?: Computable<boolean | string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseBar {
|
||||||
|
id: string;
|
||||||
|
type: typeof BarType;
|
||||||
|
[Component]: typeof BarComponent;
|
||||||
|
[GatherProps]: () => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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"]>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericBar = Replace<
|
||||||
|
Bar<BarOptions>,
|
||||||
|
{
|
||||||
|
visibility: ProcessedComputable<Visibility | boolean>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createBar<T extends BarOptions>(
|
||||||
|
optionsFunc: OptionsFunc<T, BaseBar, GenericBar>
|
||||||
|
): Bar<T> {
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const bar = optionsFunc();
|
||||||
|
bar.id = getUniqueID("bar-");
|
||||||
|
bar.type = BarType;
|
||||||
|
bar[Component] = BarComponent;
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return bar as unknown as Bar<T>;
|
||||||
|
});
|
||||||
|
}
|
262
src/features/boards/Board.vue
Normal file
262
src/features/boards/Board.vue
Normal file
|
@ -0,0 +1,262 @@
|
||||||
|
<template>
|
||||||
|
<panZoom
|
||||||
|
v-if="isVisible(visibility)"
|
||||||
|
v-show="isHidden(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(dragging)"
|
||||||
|
@touchend.passive="() => endDragging(dragging)"
|
||||||
|
@mouseleave="() => endDragging(dragging)"
|
||||||
|
>
|
||||||
|
<svg class="stage" width="100%" height="100%">
|
||||||
|
<g class="g1">
|
||||||
|
<transition-group name="link" appear>
|
||||||
|
<g v-for="(link, i) in unref(links) || []" :key="i">
|
||||||
|
<BoardLinkVue :link="link" />
|
||||||
|
</g>
|
||||||
|
</transition-group>
|
||||||
|
<transition-group name="grow" :duration="500" appear>
|
||||||
|
<g v-for="node in sortedNodes" :key="node.id" style="transition-duration: 0s">
|
||||||
|
<BoardNodeVue
|
||||||
|
:node="node"
|
||||||
|
:nodeType="types[node.type]"
|
||||||
|
:dragging="draggingNode"
|
||||||
|
:dragged="dragged"
|
||||||
|
:hasDragged="hasDragged"
|
||||||
|
:receivingNode="receivingNode?.id === node.id"
|
||||||
|
:selectedNode="unref(selectedNode)"
|
||||||
|
:selectedAction="unref(selectedAction)"
|
||||||
|
@mouseDown="mouseDown"
|
||||||
|
@endDragging="endDragging"
|
||||||
|
/>
|
||||||
|
</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 { isHidden, isVisible, Visibility } from "features/feature";
|
||||||
|
import type { ProcessedComputable } from "util/computed";
|
||||||
|
import { computed, ref, Ref, toRefs, unref } 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>;
|
||||||
|
mousePosition: Ref<{ x: number; y: number } | null>;
|
||||||
|
}>();
|
||||||
|
const props = toRefs(_props);
|
||||||
|
|
||||||
|
const lastMousePosition = ref({ x: 0, y: 0 });
|
||||||
|
const dragged = ref({ x: 0, y: 0 });
|
||||||
|
const dragging = ref<number | null>(null);
|
||||||
|
const hasDragged = ref(false);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const stage = ref<any>(null);
|
||||||
|
|
||||||
|
const draggingNode = computed(() =>
|
||||||
|
dragging.value == null ? undefined : props.nodes.value.find(node => node.id === dragging.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortedNodes = computed(() => {
|
||||||
|
const nodes = props.nodes.value.slice();
|
||||||
|
if (draggingNode.value) {
|
||||||
|
const node = nodes.splice(nodes.indexOf(draggingNode.value), 1)[0];
|
||||||
|
nodes.push(node);
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
});
|
||||||
|
|
||||||
|
const receivingNode = computed(() => {
|
||||||
|
const node = 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;
|
||||||
|
return 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);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mouseDown(e: MouseEvent | TouchEvent, nodeID: number | null = null, draggable = false) {
|
||||||
|
if (dragging.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) {
|
||||||
|
dragging.value = nodeID;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nodeID != 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(dragging.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 (dragging.value != null) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDragging(nodeID: number | null) {
|
||||||
|
if (dragging.value != null && dragging.value === nodeID && draggingNode.value != null) {
|
||||||
|
draggingNode.value.position.x += Math.round(dragged.value.x / 25) * 25;
|
||||||
|
draggingNode.value.position.y += Math.round(dragged.value.y / 25) * 25;
|
||||||
|
|
||||||
|
const nodes = props.nodes.value;
|
||||||
|
nodes.splice(nodes.indexOf(draggingNode.value), 1);
|
||||||
|
nodes.push(draggingNode.value);
|
||||||
|
|
||||||
|
if (receivingNode.value) {
|
||||||
|
props.types.value[receivingNode.value.type].onDrop?.(
|
||||||
|
receivingNode.value,
|
||||||
|
draggingNode.value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
dragging.value = null;
|
||||||
|
} else if (!hasDragged.value) {
|
||||||
|
props.state.value.selectedNode = null;
|
||||||
|
props.state.value.selectedAction = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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>
|
59
src/features/boards/BoardLink.vue
Normal file
59
src/features/boards/BoardLink.vue
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
<template>
|
||||||
|
<line
|
||||||
|
class="link"
|
||||||
|
v-bind="link"
|
||||||
|
:class="{ pulsing: link.pulsing }"
|
||||||
|
:x1="startPosition.x"
|
||||||
|
:y1="startPosition.y"
|
||||||
|
:x2="endPosition.x"
|
||||||
|
:y2="endPosition.y"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { BoardNodeLink } from "features/boards/board";
|
||||||
|
import { computed, toRefs, unref } from "vue";
|
||||||
|
|
||||||
|
const _props = defineProps<{
|
||||||
|
link: BoardNodeLink;
|
||||||
|
}>();
|
||||||
|
const props = toRefs(_props);
|
||||||
|
|
||||||
|
const startPosition = computed(() => {
|
||||||
|
const position = props.link.value.startNode.position;
|
||||||
|
if (props.link.value.offsetStart) {
|
||||||
|
position.x += unref(props.link.value.offsetStart).x;
|
||||||
|
position.y += unref(props.link.value.offsetStart).y;
|
||||||
|
}
|
||||||
|
return position;
|
||||||
|
});
|
||||||
|
|
||||||
|
const endPosition = computed(() => {
|
||||||
|
const position = props.link.value.endNode.position;
|
||||||
|
if (props.link.value.offsetEnd) {
|
||||||
|
position.x += unref(props.link.value.offsetEnd).x;
|
||||||
|
position.y += unref(props.link.value.offsetEnd).y;
|
||||||
|
}
|
||||||
|
return position;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.link.pulsing {
|
||||||
|
animation: pulsing 2s ease-in infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulsing {
|
||||||
|
0% {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
365
src/features/boards/BoardNode.vue
Normal file
365
src/features/boards/BoardNode.vue
Normal file
|
@ -0,0 +1,365 @@
|
||||||
|
<template>
|
||||||
|
<g
|
||||||
|
class="boardnode"
|
||||||
|
:class="node.type"
|
||||||
|
:style="{ opacity: dragging?.id === node.id && hasDragged ? 0.5 : 1 }"
|
||||||
|
:transform="`translate(${position.x},${position.y})`"
|
||||||
|
>
|
||||||
|
<transition name="actions" appear>
|
||||||
|
<g v-if="isSelected && actions">
|
||||||
|
<!-- TODO move to separate file -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<g
|
||||||
|
class="node-container"
|
||||||
|
@mouseenter="isHovering = true"
|
||||||
|
@mouseleave="isHovering = false"
|
||||||
|
@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="progressFill"
|
||||||
|
v-if="progressDisplay === ProgressDisplay.Fill"
|
||||||
|
:r="Math.max(size * progress - 2, 0)"
|
||||||
|
:fill="progressColor"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
v-else
|
||||||
|
:r="size + 4.5"
|
||||||
|
class="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="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="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="titleColor"
|
||||||
|
class="node-title"
|
||||||
|
:y="size + 75"
|
||||||
|
>Tap again to confirm</text
|
||||||
|
>
|
||||||
|
</transition>
|
||||||
|
</g>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import themes from "data/themes";
|
||||||
|
import type { BoardNode, GenericBoardNodeAction, GenericNodeType } from "features/boards/board";
|
||||||
|
import { ProgressDisplay, getNodeProperty, Shape } from "features/boards/board";
|
||||||
|
import { Visibility } from "features/feature";
|
||||||
|
import settings from "game/settings";
|
||||||
|
import { computed, ref, toRefs, unref, watch } from "vue";
|
||||||
|
|
||||||
|
const sqrtTwo = Math.sqrt(2);
|
||||||
|
|
||||||
|
const _props = defineProps<{
|
||||||
|
node: BoardNode;
|
||||||
|
nodeType: GenericNodeType;
|
||||||
|
dragging?: BoardNode;
|
||||||
|
dragged?: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
hasDragged?: boolean;
|
||||||
|
receivingNode?: boolean;
|
||||||
|
selectedNode?: BoardNode | null;
|
||||||
|
selectedAction?: GenericBoardNodeAction | null;
|
||||||
|
}>();
|
||||||
|
const props = toRefs(_props);
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "mouseDown", event: MouseEvent | TouchEvent, node: number, isDraggable: boolean): void;
|
||||||
|
(e: "endDragging", node: number): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isHovering = ref(false);
|
||||||
|
const isSelected = computed(() => unref(props.selectedNode) === unref(props.node));
|
||||||
|
const isDraggable = computed(() =>
|
||||||
|
getNodeProperty(props.nodeType.value.draggable, unref(props.node))
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(isDraggable, value => {
|
||||||
|
const node = unref(props.node);
|
||||||
|
if (unref(props.dragging) === node && !value) {
|
||||||
|
emit("endDragging", node.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const actions = computed(() => {
|
||||||
|
const node = unref(props.node);
|
||||||
|
return getNodeProperty(props.nodeType.value.actions, node)?.filter(
|
||||||
|
action => getNodeProperty(action.visibility, node) !== Visibility.None
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const position = computed(() => {
|
||||||
|
const node = unref(props.node);
|
||||||
|
const dragged = unref(props.dragged);
|
||||||
|
|
||||||
|
return getNodeProperty(props.nodeType.value.draggable, node) &&
|
||||||
|
unref(props.dragging)?.id === node.id &&
|
||||||
|
dragged
|
||||||
|
? {
|
||||||
|
x: node.position.x + Math.round(dragged.x / 25) * 25,
|
||||||
|
y: node.position.y + Math.round(dragged.y / 25) * 25
|
||||||
|
}
|
||||||
|
: 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(() => getNodeProperty(props.nodeType.value.label, 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(
|
||||||
|
() =>
|
||||||
|
unref(props.dragging) != null &&
|
||||||
|
unref(props.hasDragged) &&
|
||||||
|
getNodeProperty(props.nodeType.value.canAccept, unref(props.node))
|
||||||
|
);
|
||||||
|
const actionDistance = computed(() =>
|
||||||
|
getNodeProperty(props.nodeType.value.actionDistance, unref(props.node))
|
||||||
|
);
|
||||||
|
|
||||||
|
function mouseDown(e: MouseEvent | TouchEvent) {
|
||||||
|
emit("mouseDown", e, props.node.value.id, isDraggable.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mouseUp() {
|
||||||
|
if (!props.hasDragged?.value) {
|
||||||
|
props.nodeType.value.onClick?.(props.node.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function performAction(e: MouseEvent | TouchEvent, action: GenericBoardNodeAction) {
|
||||||
|
// If the onClick function made this action selected,
|
||||||
|
// don't propagate the event (which will deselect everything)
|
||||||
|
if (action.onClick(unref(props.node)) || unref(props.selectedAction)?.id === 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>
|
||||||
|
.boardnode {
|
||||||
|
cursor: pointer;
|
||||||
|
transition-duration: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-title {
|
||||||
|
text-anchor: middle;
|
||||||
|
dominant-baseline: middle;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 200%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressRing {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
|
.actions-enter-from .action,
|
||||||
|
.actions-leave-to .action {
|
||||||
|
transform: translate(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow-enter-from .node-container,
|
||||||
|
.grow-leave-to .node-container {
|
||||||
|
transform: scale(0);
|
||||||
|
}
|
||||||
|
</style>
|
397
src/features/boards/board.ts
Normal file
397
src/features/boards/board.ts
Normal file
|
@ -0,0 +1,397 @@
|
||||||
|
import BoardComponent from "features/boards/Board.vue";
|
||||||
|
import type { 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 { 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, ref, Ref, unref } from "vue";
|
||||||
|
import panZoom from "vue-panzoom";
|
||||||
|
import type { Link } from "../links/links";
|
||||||
|
|
||||||
|
globalBus.on("setupVue", app => panZoom.install(app));
|
||||||
|
|
||||||
|
export const BoardType = Symbol("Board");
|
||||||
|
|
||||||
|
export type NodeComputable<T> = Computable<T> | ((node: BoardNode) => T);
|
||||||
|
|
||||||
|
export enum ProgressDisplay {
|
||||||
|
Outline = "Outline",
|
||||||
|
Fill = "Fill"
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum Shape {
|
||||||
|
Circle = "Circle",
|
||||||
|
Diamond = "Triangle"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardNode {
|
||||||
|
id: number;
|
||||||
|
position: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
type: string;
|
||||||
|
state?: State;
|
||||||
|
pinned?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardNodeLink extends Omit<Link, "startNode" | "endNode"> {
|
||||||
|
startNode: BoardNode;
|
||||||
|
endNode: BoardNode;
|
||||||
|
pulsing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeLabel {
|
||||||
|
text: string;
|
||||||
|
color?: string;
|
||||||
|
pulsing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BoardData = {
|
||||||
|
nodes: BoardNode[];
|
||||||
|
selectedNode: number | null;
|
||||||
|
selectedAction: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface NodeTypeOptions {
|
||||||
|
title: NodeComputable<string>;
|
||||||
|
label?: NodeComputable<NodeLabel | null>;
|
||||||
|
size: NodeComputable<number>;
|
||||||
|
draggable?: NodeComputable<boolean>;
|
||||||
|
shape: NodeComputable<Shape>;
|
||||||
|
canAccept?: boolean | Ref<boolean> | ((node: BoardNode, otherNode: BoardNode) => boolean);
|
||||||
|
progress?: NodeComputable<number>;
|
||||||
|
progressDisplay?: NodeComputable<ProgressDisplay>;
|
||||||
|
progressColor?: NodeComputable<string>;
|
||||||
|
fillColor?: NodeComputable<string>;
|
||||||
|
outlineColor?: NodeComputable<string>;
|
||||||
|
titleColor?: NodeComputable<string>;
|
||||||
|
actions?: BoardNodeActionOptions[];
|
||||||
|
actionDistance?: NodeComputable<number>;
|
||||||
|
onClick?: (node: BoardNode) => void;
|
||||||
|
onDrop?: (node: BoardNode, otherNode: BoardNode) => void;
|
||||||
|
update?: (node: BoardNode, diff: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseNodeType {
|
||||||
|
nodes: Ref<BoardNode[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NodeType<T extends NodeTypeOptions> = Replace<
|
||||||
|
T & BaseNodeType,
|
||||||
|
{
|
||||||
|
title: GetComputableType<T["title"]>;
|
||||||
|
label: GetComputableType<T["label"]>;
|
||||||
|
size: GetComputableTypeWithDefault<T["size"], 50>;
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericNodeType = Replace<
|
||||||
|
NodeType<NodeTypeOptions>,
|
||||||
|
{
|
||||||
|
size: NodeComputable<number>;
|
||||||
|
draggable: NodeComputable<boolean>;
|
||||||
|
shape: NodeComputable<Shape>;
|
||||||
|
canAccept: NodeComputable<boolean>;
|
||||||
|
progressDisplay: NodeComputable<ProgressDisplay>;
|
||||||
|
progressColor: NodeComputable<string>;
|
||||||
|
actionDistance: NodeComputable<number>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface BoardNodeActionOptions {
|
||||||
|
id: string;
|
||||||
|
visibility?: NodeComputable<Visibility | boolean>;
|
||||||
|
icon: NodeComputable<string>;
|
||||||
|
fillColor?: NodeComputable<string>;
|
||||||
|
tooltip: NodeComputable<string>;
|
||||||
|
links?: NodeComputable<BoardNodeLink[]>;
|
||||||
|
onClick: (node: BoardNode) => boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseBoardNodeAction {
|
||||||
|
links?: Ref<BoardNodeLink[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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"]>;
|
||||||
|
links: GetComputableType<T["links"]>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericBoardNodeAction = Replace<
|
||||||
|
BoardNodeAction<BoardNodeActionOptions>,
|
||||||
|
{
|
||||||
|
visibility: NodeComputable<Visibility | boolean>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface BoardOptions {
|
||||||
|
visibility?: Computable<Visibility | boolean>;
|
||||||
|
height?: Computable<string>;
|
||||||
|
width?: Computable<string>;
|
||||||
|
classes?: Computable<Record<string, boolean>>;
|
||||||
|
style?: Computable<StyleValue>;
|
||||||
|
startNodes: () => Omit<BoardNode, "id">[];
|
||||||
|
types: Record<string, NodeTypeOptions>;
|
||||||
|
state?: Computable<BoardData>;
|
||||||
|
links?: Computable<BoardNodeLink[] | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseBoard {
|
||||||
|
id: string;
|
||||||
|
nodes: Ref<BoardNode[]>;
|
||||||
|
selectedNode: Ref<BoardNode | null>;
|
||||||
|
selectedAction: Ref<GenericBoardNodeAction | null>;
|
||||||
|
mousePosition: Ref<{ x: number; y: number } | null>;
|
||||||
|
type: typeof BoardType;
|
||||||
|
[Component]: typeof BoardComponent;
|
||||||
|
[GatherProps]: () => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericBoard = Replace<
|
||||||
|
Board<BoardOptions>,
|
||||||
|
{
|
||||||
|
visibility: ProcessedComputable<Visibility | boolean>;
|
||||||
|
state: ProcessedComputable<BoardData>;
|
||||||
|
links: ProcessedComputable<BoardNodeLink[] | null>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createBoard<T extends BoardOptions>(
|
||||||
|
optionsFunc: OptionsFunc<T, BaseBoard, GenericBoard>
|
||||||
|
): Board<T> {
|
||||||
|
const state = persistent<BoardData>({
|
||||||
|
nodes: [],
|
||||||
|
selectedNode: null,
|
||||||
|
selectedAction: null
|
||||||
|
});
|
||||||
|
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const board = optionsFunc();
|
||||||
|
board.id = getUniqueID("board-");
|
||||||
|
board.type = BoardType;
|
||||||
|
board[Component] = BoardComponent;
|
||||||
|
|
||||||
|
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(
|
||||||
|
() =>
|
||||||
|
processedBoard.nodes.value.find(
|
||||||
|
node => node.id === unref(processedBoard.state).selectedNode
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
board.selectedAction = computed(() => {
|
||||||
|
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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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", "400px");
|
||||||
|
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, "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, "links");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
board[GatherProps] = function (this: GenericBoard) {
|
||||||
|
const {
|
||||||
|
nodes,
|
||||||
|
types,
|
||||||
|
state,
|
||||||
|
visibility,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
style,
|
||||||
|
classes,
|
||||||
|
links,
|
||||||
|
selectedAction,
|
||||||
|
selectedNode,
|
||||||
|
mousePosition
|
||||||
|
} = this;
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
types,
|
||||||
|
state,
|
||||||
|
visibility,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
style: unref(style),
|
||||||
|
classes,
|
||||||
|
links,
|
||||||
|
selectedAction,
|
||||||
|
selectedNode,
|
||||||
|
mousePosition
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// This is necessary because board.types is different from T and Board
|
||||||
|
const processedBoard = board as unknown as Board<T>;
|
||||||
|
return processedBoard;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodeProperty<T>(property: NodeComputable<T>, node: BoardNode): T {
|
||||||
|
return isFunction<T, [BoardNode], Computable<T>>(property) ? property(node) : unref(property);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
206
src/features/challenges/Challenge.vue
Normal file
206
src/features/challenges/Challenge.vue
Normal file
|
@ -0,0 +1,206 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="isVisible(visibility)"
|
||||||
|
:style="[
|
||||||
|
{
|
||||||
|
visibility: isHidden(visibility) ? 'hidden' : undefined
|
||||||
|
},
|
||||||
|
notifyStyle,
|
||||||
|
unref(style) ?? {}
|
||||||
|
]"
|
||||||
|
:class="{
|
||||||
|
feature: true,
|
||||||
|
challenge: true,
|
||||||
|
done: unref(completed),
|
||||||
|
canStart: unref(canStart) && !unref(maxed),
|
||||||
|
maxed: unref(maxed),
|
||||||
|
...unref(classes)
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="toggleChallenge"
|
||||||
|
@click="toggle"
|
||||||
|
:disabled="!unref(canStart) || unref(maxed)"
|
||||||
|
>
|
||||||
|
{{ buttonText }}
|
||||||
|
</button>
|
||||||
|
<component v-if="unref(comp)" :is="unref(comp)" />
|
||||||
|
<MarkNode :mark="unref(mark)" />
|
||||||
|
<Node :id="id" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="tsx">
|
||||||
|
import "components/common/features.css";
|
||||||
|
import MarkNode from "components/MarkNode.vue";
|
||||||
|
import Node from "components/Node.vue";
|
||||||
|
import type { GenericChallenge } from "features/challenges/challenge";
|
||||||
|
import type { StyleValue } from "features/feature";
|
||||||
|
import { isHidden, isVisible, jsx, Visibility } from "features/feature";
|
||||||
|
import { getHighNotifyStyle, getNotifyStyle } from "game/notifications";
|
||||||
|
import { coerceComponent, isCoercableComponent, processedPropType, unwrapRef } from "util/vue";
|
||||||
|
import type { Component, PropType, UnwrapRef } from "vue";
|
||||||
|
import { computed, defineComponent, shallowRef, toRefs, unref, watchEffect } from "vue";
|
||||||
|
|
||||||
|
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
|
||||||
|
),
|
||||||
|
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 } = toRefs(props);
|
||||||
|
|
||||||
|
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 || "");
|
||||||
|
const Reward = coerceComponent(currDisplay.reward || "");
|
||||||
|
const EffectDisplay = coerceComponent(currDisplay.effectDisplay || "");
|
||||||
|
comp.value = coerceComponent(
|
||||||
|
jsx(() => (
|
||||||
|
<span>
|
||||||
|
{currDisplay.title != null ? (
|
||||||
|
<div>
|
||||||
|
<Title />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Description />
|
||||||
|
{currDisplay.goal != null ? (
|
||||||
|
<div>
|
||||||
|
<br />
|
||||||
|
Goal: <Goal />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{currDisplay.reward != null ? (
|
||||||
|
<div>
|
||||||
|
<br />
|
||||||
|
Reward: <Reward />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{currDisplay.effectDisplay != null ? (
|
||||||
|
<div>
|
||||||
|
Currently: <EffectDisplay />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
buttonText,
|
||||||
|
notifyStyle,
|
||||||
|
comp,
|
||||||
|
Visibility,
|
||||||
|
isVisible,
|
||||||
|
isHidden,
|
||||||
|
unref
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.challenge {
|
||||||
|
background-color: var(--locked);
|
||||||
|
width: 300px;
|
||||||
|
min-height: 300px;
|
||||||
|
color: black;
|
||||||
|
font-size: 15px;
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge.done {
|
||||||
|
background-color: var(--bought);
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge button {
|
||||||
|
min-height: 50px;
|
||||||
|
width: 120px;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: none !important;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge.canStart button {
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--layer-color);
|
||||||
|
}
|
||||||
|
</style>
|
326
src/features/challenges/challenge.tsx
Normal file
326
src/features/challenges/challenge.tsx
Normal file
|
@ -0,0 +1,326 @@
|
||||||
|
import { isArray } from "@vue/shared";
|
||||||
|
import Toggle from "components/fields/Toggle.vue";
|
||||||
|
import ChallengeComponent from "features/challenges/Challenge.vue";
|
||||||
|
import type { CoercableComponent, OptionsFunc, Replace, StyleValue } from "features/feature";
|
||||||
|
import {
|
||||||
|
Component,
|
||||||
|
GatherProps,
|
||||||
|
getUniqueID,
|
||||||
|
isVisible,
|
||||||
|
jsx,
|
||||||
|
setDefault,
|
||||||
|
Visibility
|
||||||
|
} from "features/feature";
|
||||||
|
import type { GenericReset } from "features/reset";
|
||||||
|
import type { Resource } from "features/resources/resource";
|
||||||
|
import { globalBus } from "game/events";
|
||||||
|
import type { Persistent } from "game/persistence";
|
||||||
|
import { persistent } from "game/persistence";
|
||||||
|
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 { createLazyProxy } from "util/proxies";
|
||||||
|
import type { Ref, WatchStopHandle } from "vue";
|
||||||
|
import { computed, unref, watch } from "vue";
|
||||||
|
|
||||||
|
export const ChallengeType = Symbol("ChallengeType");
|
||||||
|
|
||||||
|
export interface ChallengeOptions {
|
||||||
|
visibility?: Computable<Visibility | boolean>;
|
||||||
|
canStart?: Computable<boolean>;
|
||||||
|
reset?: GenericReset;
|
||||||
|
canComplete?: Computable<boolean | DecimalSource>;
|
||||||
|
completionLimit?: Computable<DecimalSource>;
|
||||||
|
mark?: Computable<boolean | string>;
|
||||||
|
resource?: Resource;
|
||||||
|
goal?: Computable<DecimalSource>;
|
||||||
|
classes?: Computable<Record<string, boolean>>;
|
||||||
|
style?: Computable<StyleValue>;
|
||||||
|
display?: Computable<
|
||||||
|
| CoercableComponent
|
||||||
|
| {
|
||||||
|
title?: CoercableComponent;
|
||||||
|
description: CoercableComponent;
|
||||||
|
goal?: CoercableComponent;
|
||||||
|
reward?: CoercableComponent;
|
||||||
|
effectDisplay?: CoercableComponent;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
onComplete?: VoidFunction;
|
||||||
|
onExit?: VoidFunction;
|
||||||
|
onEnter?: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseChallenge {
|
||||||
|
id: string;
|
||||||
|
completions: Persistent<DecimalSource>;
|
||||||
|
completed: Ref<boolean>;
|
||||||
|
maxed: Ref<boolean>;
|
||||||
|
active: Persistent<boolean>;
|
||||||
|
toggle: VoidFunction;
|
||||||
|
complete: (remainInChallenge?: boolean) => void;
|
||||||
|
type: typeof ChallengeType;
|
||||||
|
[Component]: typeof ChallengeComponent;
|
||||||
|
[GatherProps]: () => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Challenge<T extends ChallengeOptions> = Replace<
|
||||||
|
T & BaseChallenge,
|
||||||
|
{
|
||||||
|
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||||
|
canStart: GetComputableTypeWithDefault<T["canStart"], true>;
|
||||||
|
canComplete: GetComputableTypeWithDefault<T["canComplete"], Ref<boolean>>;
|
||||||
|
completionLimit: GetComputableTypeWithDefault<T["completionLimit"], 1>;
|
||||||
|
mark: GetComputableTypeWithDefault<T["mark"], Ref<boolean>>;
|
||||||
|
goal: GetComputableType<T["goal"]>;
|
||||||
|
classes: GetComputableType<T["classes"]>;
|
||||||
|
style: GetComputableType<T["style"]>;
|
||||||
|
display: GetComputableType<T["display"]>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericChallenge = Replace<
|
||||||
|
Challenge<ChallengeOptions>,
|
||||||
|
{
|
||||||
|
visibility: ProcessedComputable<Visibility | boolean>;
|
||||||
|
canStart: ProcessedComputable<boolean>;
|
||||||
|
canComplete: ProcessedComputable<boolean | DecimalSource>;
|
||||||
|
completionLimit: ProcessedComputable<DecimalSource>;
|
||||||
|
mark: ProcessedComputable<boolean>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createChallenge<T extends ChallengeOptions>(
|
||||||
|
optionsFunc: OptionsFunc<T, BaseChallenge, GenericChallenge>
|
||||||
|
): Challenge<T> {
|
||||||
|
const completions = persistent(0);
|
||||||
|
const active = persistent(false);
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const challenge = optionsFunc();
|
||||||
|
|
||||||
|
if (
|
||||||
|
challenge.canComplete == null &&
|
||||||
|
(challenge.resource == null || challenge.goal == null)
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
"Cannot create challenge without a canComplete property or a resource and goal property",
|
||||||
|
challenge
|
||||||
|
);
|
||||||
|
throw "Cannot create challenge without a canComplete property or a resource and goal property";
|
||||||
|
}
|
||||||
|
|
||||||
|
challenge.id = getUniqueID("challenge-");
|
||||||
|
challenge.type = ChallengeType;
|
||||||
|
challenge[Component] = ChallengeComponent;
|
||||||
|
|
||||||
|
challenge.completions = completions;
|
||||||
|
challenge.active = active;
|
||||||
|
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 (
|
||||||
|
unref(genericChallenge.canComplete) !== false &&
|
||||||
|
!genericChallenge.maxed.value
|
||||||
|
) {
|
||||||
|
let completions: boolean | DecimalSource = unref(genericChallenge.canComplete);
|
||||||
|
if (typeof completions === "boolean") {
|
||||||
|
completions = 1;
|
||||||
|
}
|
||||||
|
genericChallenge.completions.value = Decimal.min(
|
||||||
|
Decimal.add(genericChallenge.completions.value, completions),
|
||||||
|
unref(genericChallenge.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.complete = function (remainInChallenge?: boolean) {
|
||||||
|
const genericChallenge = challenge as GenericChallenge;
|
||||||
|
let completions: boolean | DecimalSource = unref(genericChallenge.canComplete);
|
||||||
|
if (
|
||||||
|
genericChallenge.active.value &&
|
||||||
|
completions !== false &&
|
||||||
|
(completions === true || Decimal.neq(0, completions)) &&
|
||||||
|
!genericChallenge.maxed.value
|
||||||
|
) {
|
||||||
|
if (typeof completions === "boolean") {
|
||||||
|
completions = 1;
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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.canComplete == null) {
|
||||||
|
challenge.canComplete = computed(() => {
|
||||||
|
const genericChallenge = challenge as GenericChallenge;
|
||||||
|
if (
|
||||||
|
!genericChallenge.active.value ||
|
||||||
|
genericChallenge.resource == null ||
|
||||||
|
genericChallenge.goal == null
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Decimal.gte(genericChallenge.resource.value, unref(genericChallenge.goal));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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, "canComplete");
|
||||||
|
processComputable(challenge as T, "completionLimit");
|
||||||
|
setDefault(challenge, "completionLimit", 1);
|
||||||
|
processComputable(challenge as T, "mark");
|
||||||
|
processComputable(challenge as T, "goal");
|
||||||
|
processComputable(challenge as T, "classes");
|
||||||
|
processComputable(challenge as T, "style");
|
||||||
|
processComputable(challenge as T, "display");
|
||||||
|
|
||||||
|
if (challenge.reset != null) {
|
||||||
|
globalBus.on("reset", currentReset => {
|
||||||
|
if (currentReset === challenge.reset && (challenge.active as Ref<boolean>).value) {
|
||||||
|
(challenge.toggle as VoidFunction)();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
challenge[GatherProps] = function (this: GenericChallenge) {
|
||||||
|
const {
|
||||||
|
active,
|
||||||
|
maxed,
|
||||||
|
canComplete,
|
||||||
|
display,
|
||||||
|
visibility,
|
||||||
|
style,
|
||||||
|
classes,
|
||||||
|
completed,
|
||||||
|
canStart,
|
||||||
|
mark,
|
||||||
|
id,
|
||||||
|
toggle
|
||||||
|
} = this;
|
||||||
|
return {
|
||||||
|
active,
|
||||||
|
maxed,
|
||||||
|
canComplete,
|
||||||
|
display,
|
||||||
|
visibility,
|
||||||
|
style: unref(style),
|
||||||
|
classes,
|
||||||
|
completed,
|
||||||
|
canStart,
|
||||||
|
mark,
|
||||||
|
id,
|
||||||
|
toggle
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return challenge as unknown as Challenge<T>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupAutoComplete(
|
||||||
|
challenge: GenericChallenge,
|
||||||
|
autoActive: Computable<boolean> = true,
|
||||||
|
exitOnComplete = true
|
||||||
|
): WatchStopHandle {
|
||||||
|
const isActive = typeof autoActive === "function" ? computed(autoActive) : autoActive;
|
||||||
|
return watch(
|
||||||
|
[challenge.canComplete as Ref<boolean>, isActive as Ref<boolean>],
|
||||||
|
([canComplete, isActive]) => {
|
||||||
|
if (canComplete && isActive) {
|
||||||
|
challenge.complete(!exitOnComplete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createActiveChallenge(
|
||||||
|
challenges: GenericChallenge[]
|
||||||
|
): Ref<GenericChallenge | undefined> {
|
||||||
|
return computed(() => challenges.find(challenge => challenge.active.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAnyChallengeActive(
|
||||||
|
challenges: GenericChallenge[] | Ref<GenericChallenge | undefined>
|
||||||
|
): Ref<boolean> {
|
||||||
|
if (isArray(challenges)) {
|
||||||
|
challenges = createActiveChallenge(challenges);
|
||||||
|
}
|
||||||
|
return computed(() => (challenges as Ref<GenericChallenge | undefined>).value != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "game/settings" {
|
||||||
|
interface Settings {
|
||||||
|
hideChallenges: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
globalBus.on("loadSettings", settings => {
|
||||||
|
setDefault(settings, "hideChallenges", false);
|
||||||
|
});
|
||||||
|
|
||||||
|
registerSettingField(
|
||||||
|
jsx(() => (
|
||||||
|
<Toggle
|
||||||
|
title={jsx(() => (
|
||||||
|
<span class="option-title">
|
||||||
|
Hide maxed challenges
|
||||||
|
<desc>Hide challenges that have been fully completed.</desc>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
onUpdate:modelValue={value => (settings.hideChallenges = value)}
|
||||||
|
modelValue={settings.hideChallenges}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
);
|
140
src/features/clickables/Clickable.vue
Normal file
140
src/features/clickables/Clickable.vue
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
v-if="isVisible(visibility)"
|
||||||
|
: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"
|
||||||
|
:class="{
|
||||||
|
feature: true,
|
||||||
|
clickable: true,
|
||||||
|
can: unref(canClick),
|
||||||
|
locked: !unref(canClick),
|
||||||
|
small,
|
||||||
|
...unref(classes)
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<component v-if="unref(comp)" :is="unref(comp)" />
|
||||||
|
<MarkNode :mark="unref(mark)" />
|
||||||
|
<Node :id="id" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script 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 {
|
||||||
|
coerceComponent,
|
||||||
|
isCoercableComponent,
|
||||||
|
processedPropType,
|
||||||
|
setupHoldToClick,
|
||||||
|
unwrapRef
|
||||||
|
} from "util/vue";
|
||||||
|
import type { Component, PropType, UnwrapRef } from "vue";
|
||||||
|
import { defineComponent, shallowRef, toRefs, unref, watchEffect } 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 comp = shallowRef<Component | string>("");
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.clickable {
|
||||||
|
min-height: 120px;
|
||||||
|
width: 120px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable.small {
|
||||||
|
min-height: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable > * {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
147
src/features/clickables/clickable.ts
Normal file
147
src/features/clickables/clickable.ts
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
import ClickableComponent from "features/clickables/Clickable.vue";
|
||||||
|
import type {
|
||||||
|
CoercableComponent,
|
||||||
|
GenericComponent,
|
||||||
|
OptionsFunc,
|
||||||
|
Replace,
|
||||||
|
StyleValue
|
||||||
|
} from "features/feature";
|
||||||
|
import { Component, GatherProps, getUniqueID, setDefault, Visibility } 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";
|
||||||
|
|
||||||
|
export const ClickableType = Symbol("Clickable");
|
||||||
|
|
||||||
|
export interface ClickableOptions {
|
||||||
|
visibility?: Computable<Visibility | boolean>;
|
||||||
|
canClick?: Computable<boolean>;
|
||||||
|
classes?: Computable<Record<string, boolean>>;
|
||||||
|
style?: Computable<StyleValue>;
|
||||||
|
mark?: Computable<boolean | string>;
|
||||||
|
display?: Computable<
|
||||||
|
| CoercableComponent
|
||||||
|
| {
|
||||||
|
title?: CoercableComponent;
|
||||||
|
description: CoercableComponent;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
small?: boolean;
|
||||||
|
onClick?: (e?: MouseEvent | TouchEvent) => void;
|
||||||
|
onHold?: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseClickable {
|
||||||
|
id: string;
|
||||||
|
type: typeof ClickableType;
|
||||||
|
[Component]: typeof ClickableComponent;
|
||||||
|
[GatherProps]: () => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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"]>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericClickable = Replace<
|
||||||
|
Clickable<ClickableOptions>,
|
||||||
|
{
|
||||||
|
visibility: ProcessedComputable<Visibility | boolean>;
|
||||||
|
canClick: ProcessedComputable<boolean>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createClickable<T extends ClickableOptions>(
|
||||||
|
optionsFunc?: OptionsFunc<T, BaseClickable, GenericClickable>
|
||||||
|
): Clickable<T> {
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const clickable = optionsFunc?.() ?? ({} as ReturnType<NonNullable<typeof optionsFunc>>);
|
||||||
|
clickable.id = getUniqueID("clickable-");
|
||||||
|
clickable.type = ClickableType;
|
||||||
|
clickable[Component] = ClickableComponent;
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return clickable as unknown as Clickable<T>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
536
src/features/conversion.ts
Normal file
536
src/features/conversion.ts
Normal file
|
@ -0,0 +1,536 @@
|
||||||
|
import type { OptionsFunc, Replace } from "features/feature";
|
||||||
|
import { setDefault } from "features/feature";
|
||||||
|
import type { Resource } from "features/resources/resource";
|
||||||
|
import type { BaseLayer } from "game/layers";
|
||||||
|
import type { Modifier } from "game/modifiers";
|
||||||
|
import type { DecimalSource } from "util/bignum";
|
||||||
|
import Decimal from "util/bignum";
|
||||||
|
import type { WithRequired } from "util/common";
|
||||||
|
import type { Computable, GetComputableTypeWithDefault, ProcessedComputable } from "util/computed";
|
||||||
|
import { convertComputable, processComputable } from "util/computed";
|
||||||
|
import { createLazyProxy } from "util/proxies";
|
||||||
|
import type { Ref } from "vue";
|
||||||
|
import { computed, unref } from "vue";
|
||||||
|
|
||||||
|
/** An object that configures a {@link Conversion}. */
|
||||||
|
export interface ConversionOptions {
|
||||||
|
/**
|
||||||
|
* The scaling function that is used to determine the rate of conversion from one {@link features/resources/resource.Resource} to the other.
|
||||||
|
*/
|
||||||
|
scaling: ScalingFunction;
|
||||||
|
/**
|
||||||
|
* 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>;
|
||||||
|
/**
|
||||||
|
* 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>;
|
||||||
|
/**
|
||||||
|
* 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>;
|
||||||
|
/**
|
||||||
|
* 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>;
|
||||||
|
/**
|
||||||
|
* 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?: Computable<boolean>;
|
||||||
|
/**
|
||||||
|
* Whether or not to round up the cost to generate a given amount of the output resource.
|
||||||
|
*/
|
||||||
|
roundUpCost?: Computable<boolean>;
|
||||||
|
/**
|
||||||
|
* The function that performs the actual conversion from {@link baseResource} to {@link gainResource}.
|
||||||
|
* Typically this will be set for you in a conversion constructor.
|
||||||
|
*/
|
||||||
|
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 additional modifier that will be applied to the gain amounts.
|
||||||
|
* Must be reversible in order to correctly calculate {@link nextAt}.
|
||||||
|
* @see {@link game/modifiers.createSequentialModifier} if you want to apply multiple modifiers.
|
||||||
|
*/
|
||||||
|
gainModifier?: WithRequired<Modifier, "revert">;
|
||||||
|
/**
|
||||||
|
* A modifier that will be applied to the cost amounts.
|
||||||
|
* That is to say, this modifier will be applied to the amount of baseResource before going into the scaling function.
|
||||||
|
* A cost modifier of x0.5 would give gain amounts equal to the player having half the baseResource they actually have.
|
||||||
|
* Must be reversible in order to correctly calculate {@link nextAt}.
|
||||||
|
* @see {@link game/modifiers.createSequentialModifier} if you want to apply multiple modifiers.
|
||||||
|
*/
|
||||||
|
costModifier?: WithRequired<Modifier, "revert">;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The properties that are added onto a processed {@link ConversionOptions} to create a {@link Conversion}.
|
||||||
|
*/
|
||||||
|
export interface BaseConversion {
|
||||||
|
/**
|
||||||
|
* The function that performs the actual conversion.
|
||||||
|
*/
|
||||||
|
convert: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
{
|
||||||
|
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"];
|
||||||
|
roundUpCost: GetComputableTypeWithDefault<T["roundUpCost"], true>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
roundUpCost: ProcessedComputable<boolean>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
* @param optionsFunc Conversion options.
|
||||||
|
* @see {@link createCumulativeConversion}.
|
||||||
|
* @see {@link createIndependentConversion}.
|
||||||
|
*/
|
||||||
|
export function createConversion<T extends ConversionOptions>(
|
||||||
|
optionsFunc: OptionsFunc<T, BaseConversion, GenericConversion>
|
||||||
|
): Conversion<T> {
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const conversion = optionsFunc();
|
||||||
|
|
||||||
|
if (conversion.currentGain == null) {
|
||||||
|
conversion.currentGain = computed(() => {
|
||||||
|
let gain = conversion.gainModifier
|
||||||
|
? conversion.gainModifier.apply(
|
||||||
|
conversion.scaling.currentGain(conversion as GenericConversion)
|
||||||
|
)
|
||||||
|
: conversion.scaling.currentGain(conversion as GenericConversion);
|
||||||
|
gain = Decimal.floor(gain).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(() => {
|
||||||
|
let current = conversion.scaling.currentAt(conversion as GenericConversion);
|
||||||
|
if (unref((conversion as GenericConversion).roundUpCost))
|
||||||
|
current = Decimal.ceil(current);
|
||||||
|
return current;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (conversion.nextAt == null) {
|
||||||
|
conversion.nextAt = computed(() => {
|
||||||
|
let next = conversion.scaling.nextAt(conversion as GenericConversion);
|
||||||
|
if (unref((conversion as GenericConversion).roundUpCost)) next = Decimal.ceil(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
processComputable(conversion as T, "roundUpCost");
|
||||||
|
setDefault(conversion, "roundUpCost", true);
|
||||||
|
|
||||||
|
return conversion as unknown as Conversion<T>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A collection of functions that allow a conversion to scale the amount of resources gained based on the input resource.
|
||||||
|
* This typically shouldn't be created directly. Instead use one of the scaling function constructors.
|
||||||
|
* @see {@link createLinearScaling}.
|
||||||
|
* @see {@link createPolynomialScaling}.
|
||||||
|
*/
|
||||||
|
export interface ScalingFunction {
|
||||||
|
/**
|
||||||
|
* Calculates the amount of the output resource a conversion should be able to currently produce.
|
||||||
|
* This should be based off of `conversion.baseResource.value`.
|
||||||
|
* The conversion is responsible for applying the gainModifier, so this function should be un-modified.
|
||||||
|
* It does not need to be clamped or rounded.
|
||||||
|
*/
|
||||||
|
currentGain: (conversion: GenericConversion) => DecimalSource;
|
||||||
|
/**
|
||||||
|
* Calculates the amount of the input resource that is required for the current value of `conversion.currentGain`.
|
||||||
|
* Note that `conversion.currentGain` has been modified by `conversion.gainModifier`, so you will need to revert that as appropriate.
|
||||||
|
* The conversion is responsible for rounding up the amount as appropriate.
|
||||||
|
* The returned value should not be below 0.
|
||||||
|
*/
|
||||||
|
currentAt: (conversion: GenericConversion) => DecimalSource;
|
||||||
|
/**
|
||||||
|
* Calculates the amount of the input resource that would be required for the current value of `conversion.currentGain` to increase.
|
||||||
|
* Note that `conversion.currentGain` has been modified by `conversion.gainModifier`, so you will need to revert that as appropriate.
|
||||||
|
* The conversion is responsible for rounding up the amount as appropriate.
|
||||||
|
* The returned value should not be below 0.
|
||||||
|
*/
|
||||||
|
nextAt: (conversion: GenericConversion) => DecimalSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a scaling function based off the formula `(baseResource - base) * coefficient`.
|
||||||
|
* If the baseResource value is less than base then the currentGain will be 0.
|
||||||
|
* @param base The base variable in the scaling formula.
|
||||||
|
* @param coefficient The coefficient variable in the scaling formula.
|
||||||
|
* @example
|
||||||
|
* A scaling function created via `createLinearScaling(10, 0.5)` would produce the following values:
|
||||||
|
* | Base Resource | Current Gain |
|
||||||
|
* | ------------- | ------------ |
|
||||||
|
* | 10 | 1 |
|
||||||
|
* | 12 | 2 |
|
||||||
|
* | 20 | 6 |
|
||||||
|
*/
|
||||||
|
export function createLinearScaling(
|
||||||
|
base: Computable<DecimalSource>,
|
||||||
|
coefficient: Computable<DecimalSource>
|
||||||
|
): ScalingFunction {
|
||||||
|
const processedBase = convertComputable(base);
|
||||||
|
const processedCoefficient = convertComputable(coefficient);
|
||||||
|
return {
|
||||||
|
currentGain(conversion) {
|
||||||
|
let baseAmount: DecimalSource = unref(conversion.baseResource.value);
|
||||||
|
if (conversion.costModifier) {
|
||||||
|
baseAmount = conversion.costModifier.apply(baseAmount);
|
||||||
|
}
|
||||||
|
if (Decimal.lt(baseAmount, unref(processedBase))) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Decimal.sub(baseAmount, unref(processedBase))
|
||||||
|
.sub(1)
|
||||||
|
.times(unref(processedCoefficient))
|
||||||
|
.add(1);
|
||||||
|
},
|
||||||
|
currentAt(conversion) {
|
||||||
|
let current: DecimalSource = unref(conversion.currentGain);
|
||||||
|
if (conversion.gainModifier) {
|
||||||
|
current = conversion.gainModifier.revert(current);
|
||||||
|
}
|
||||||
|
current = Decimal.max(0, current)
|
||||||
|
.sub(1)
|
||||||
|
.div(unref(processedCoefficient))
|
||||||
|
.add(unref(processedBase));
|
||||||
|
if (conversion.costModifier) {
|
||||||
|
current = conversion.costModifier.revert(current);
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
},
|
||||||
|
nextAt(conversion) {
|
||||||
|
let next: DecimalSource = Decimal.add(unref(conversion.currentGain), 1).floor();
|
||||||
|
if (conversion.gainModifier) {
|
||||||
|
next = conversion.gainModifier.revert(next);
|
||||||
|
}
|
||||||
|
next = Decimal.max(0, next)
|
||||||
|
.sub(1)
|
||||||
|
.div(unref(processedCoefficient))
|
||||||
|
.add(unref(processedBase))
|
||||||
|
.max(unref(processedBase));
|
||||||
|
if (conversion.costModifier) {
|
||||||
|
next = conversion.costModifier.revert(next);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a scaling function based off the formula `(baseResource / base) ^ exponent`.
|
||||||
|
* If the baseResource value is less than base then the currentGain will be 0.
|
||||||
|
* @param base The base variable in the scaling formula.
|
||||||
|
* @param exponent The exponent variable in the scaling formula.
|
||||||
|
* @example
|
||||||
|
* A scaling function created via `createPolynomialScaling(10, 0.5)` would produce the following values:
|
||||||
|
* | Base Resource | Current Gain |
|
||||||
|
* | ------------- | ------------ |
|
||||||
|
* | 10 | 1 |
|
||||||
|
* | 40 | 2 |
|
||||||
|
* | 250 | 5 |
|
||||||
|
*/
|
||||||
|
export function createPolynomialScaling(
|
||||||
|
base: Computable<DecimalSource>,
|
||||||
|
exponent: Computable<DecimalSource>
|
||||||
|
): ScalingFunction {
|
||||||
|
const processedBase = convertComputable(base);
|
||||||
|
const processedExponent = convertComputable(exponent);
|
||||||
|
return {
|
||||||
|
currentGain(conversion) {
|
||||||
|
let baseAmount: DecimalSource = unref(conversion.baseResource.value);
|
||||||
|
if (conversion.costModifier) {
|
||||||
|
baseAmount = conversion.costModifier.apply(baseAmount);
|
||||||
|
}
|
||||||
|
if (Decimal.lt(baseAmount, unref(processedBase))) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gain = Decimal.div(baseAmount, unref(processedBase)).pow(
|
||||||
|
unref(processedExponent)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (gain.isNan()) {
|
||||||
|
return new Decimal(0);
|
||||||
|
}
|
||||||
|
return gain;
|
||||||
|
},
|
||||||
|
currentAt(conversion) {
|
||||||
|
let current: DecimalSource = unref(conversion.currentGain);
|
||||||
|
if (conversion.gainModifier) {
|
||||||
|
current = conversion.gainModifier.revert(current);
|
||||||
|
}
|
||||||
|
current = Decimal.max(0, current)
|
||||||
|
.root(unref(processedExponent))
|
||||||
|
.times(unref(processedBase));
|
||||||
|
if (conversion.costModifier) {
|
||||||
|
current = conversion.costModifier.revert(current);
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
},
|
||||||
|
nextAt(conversion) {
|
||||||
|
let next: DecimalSource = Decimal.add(unref(conversion.currentGain), 1).floor();
|
||||||
|
if (conversion.gainModifier) {
|
||||||
|
next = conversion.gainModifier.revert(next);
|
||||||
|
}
|
||||||
|
next = Decimal.max(0, next)
|
||||||
|
.root(unref(processedExponent))
|
||||||
|
.times(unref(processedBase))
|
||||||
|
.max(unref(processedBase));
|
||||||
|
if (conversion.costModifier) {
|
||||||
|
next = conversion.costModifier.revert(next);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a conversion that simply adds to the gainResource amount upon converting.
|
||||||
|
* This is similar to the behavior of "normal" layers in The Modding Tree.
|
||||||
|
* 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> {
|
||||||
|
return createConversion(optionsFunc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a conversion that will replace the gainResource amount with the new amount upon converting.
|
||||||
|
* 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(() => {
|
||||||
|
const conversion: S = optionsFunc();
|
||||||
|
|
||||||
|
setDefault(conversion, "buyMax", false);
|
||||||
|
|
||||||
|
if (conversion.currentGain == null) {
|
||||||
|
conversion.currentGain = computed(() => {
|
||||||
|
let gain = conversion.gainModifier
|
||||||
|
? conversion.gainModifier.apply(
|
||||||
|
conversion.scaling.currentGain(conversion as GenericConversion)
|
||||||
|
)
|
||||||
|
: conversion.scaling.currentGain(conversion as GenericConversion);
|
||||||
|
gain = Decimal.floor(gain).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(
|
||||||
|
Decimal.floor(conversion.scaling.currentGain(conversion as GenericConversion)),
|
||||||
|
conversion.gainResource.value
|
||||||
|
).max(0);
|
||||||
|
|
||||||
|
if (unref(conversion.buyMax) === false) {
|
||||||
|
gain = gain.min(1);
|
||||||
|
}
|
||||||
|
return gain;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setDefault(conversion, "convert", function () {
|
||||||
|
const amountGained = unref((conversion as GenericConversion).actualGain);
|
||||||
|
conversion.gainResource.value = conversion.gainModifier
|
||||||
|
? conversion.gainModifier.apply(
|
||||||
|
unref((conversion as GenericConversion).currentGain)
|
||||||
|
)
|
||||||
|
: unref((conversion as GenericConversion).currentGain);
|
||||||
|
(conversion as GenericConversion).spend(amountGained);
|
||||||
|
conversion.onConvert?.(amountGained);
|
||||||
|
});
|
||||||
|
|
||||||
|
return conversion;
|
||||||
|
}) as Conversion<S>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This will automatically increase the value of conversion.gainResource without lowering the value of the input resource.
|
||||||
|
* It will by default perform 100% of a conversion's currentGain per second.
|
||||||
|
* If you use a ref for the rate you can set it's value to 0 when passive generation should be disabled.
|
||||||
|
* @param layer The layer this passive generation will be associated with. Typically `this` when calling this function from inside a layer's options function.
|
||||||
|
* @param conversion The conversion that will determine how much generation there is.
|
||||||
|
* @param rate A multiplier to multiply against the conversion's currentGain.
|
||||||
|
* @param cap A value that should not be passed via passive generation. If null, no cap is applied.
|
||||||
|
*/
|
||||||
|
export function setupPassiveGeneration(
|
||||||
|
layer: BaseLayer,
|
||||||
|
conversion: GenericConversion,
|
||||||
|
rate: Computable<DecimalSource> = 1,
|
||||||
|
cap: Computable<DecimalSource | null> = null
|
||||||
|
): void {
|
||||||
|
const processedRate = convertComputable(rate);
|
||||||
|
const processedCap = convertComputable(cap);
|
||||||
|
layer.on("preUpdate", diff => {
|
||||||
|
const currRate = unref(processedRate);
|
||||||
|
if (Decimal.neq(currRate, 0)) {
|
||||||
|
conversion.gainResource.value = Decimal.add(
|
||||||
|
conversion.gainResource.value,
|
||||||
|
Decimal.times(currRate, diff).times(Decimal.ceil(unref(conversion.actualGain)))
|
||||||
|
)
|
||||||
|
.min(unref(processedCap) ?? Decimal.dInf)
|
||||||
|
.max(conversion.gainResource.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a value, this function finds the amount above a certain value and raises it to a power.
|
||||||
|
* If the power is <1, this will effectively make the value scale slower after the cap.
|
||||||
|
* @param value The raw value.
|
||||||
|
* @param cap The value after which the softcap should be applied.
|
||||||
|
* @param power The power to raise value above the cap to.
|
||||||
|
* @example
|
||||||
|
* A softcap added via `addSoftcap(scaling, 100, 0.5)` would produce the following values:
|
||||||
|
* | Raw Value | Softcapped Value |
|
||||||
|
* | --------- | ---------------- |
|
||||||
|
* | 1 | 1 |
|
||||||
|
* | 100 | 100 |
|
||||||
|
* | 125 | 105 |
|
||||||
|
* | 200 | 110 |
|
||||||
|
*/
|
||||||
|
export function softcap(
|
||||||
|
value: DecimalSource,
|
||||||
|
cap: DecimalSource,
|
||||||
|
power: DecimalSource = 0.5
|
||||||
|
): DecimalSource {
|
||||||
|
if (Decimal.lte(value, cap)) {
|
||||||
|
return value;
|
||||||
|
} else {
|
||||||
|
return Decimal.pow(value, power).times(Decimal.pow(cap, Decimal.sub(1, power)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a scaling function based off an existing scaling function, with a softcap applied to it.
|
||||||
|
* The softcap will take any value above a certain value and raise it to a power.
|
||||||
|
* If the power is <1, this will effectively make the value scale slower after the cap.
|
||||||
|
* @param scaling The raw scaling function.
|
||||||
|
* @param cap The value after which the softcap should be applied.
|
||||||
|
* @param power The power to raise value about the cap to.
|
||||||
|
* @see {@link softcap}.
|
||||||
|
*/
|
||||||
|
export function addSoftcap(
|
||||||
|
scaling: ScalingFunction,
|
||||||
|
cap: ProcessedComputable<DecimalSource>,
|
||||||
|
power: ProcessedComputable<DecimalSource> = 0.5
|
||||||
|
): ScalingFunction {
|
||||||
|
return {
|
||||||
|
...scaling,
|
||||||
|
currentAt: conversion =>
|
||||||
|
softcap(scaling.currentAt(conversion), unref(cap), Decimal.recip(unref(power))),
|
||||||
|
nextAt: conversion =>
|
||||||
|
softcap(scaling.nextAt(conversion), unref(cap), Decimal.recip(unref(power))),
|
||||||
|
currentGain: conversion =>
|
||||||
|
softcap(scaling.currentGain(conversion), unref(cap), unref(power))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a scaling function off an existing function, with a hardcap applied to it.
|
||||||
|
* The harcap will ensure that the currentGain will stop at a given cap.
|
||||||
|
* @param scaling The raw scaling function.
|
||||||
|
* @param cap The maximum value the scaling function can output.
|
||||||
|
*/
|
||||||
|
export function addHardcap(
|
||||||
|
scaling: ScalingFunction,
|
||||||
|
cap: ProcessedComputable<DecimalSource>
|
||||||
|
): ScalingFunction {
|
||||||
|
return {
|
||||||
|
...scaling,
|
||||||
|
currentGain: conversion => Decimal.min(scaling.currentGain(conversion), unref(cap))
|
||||||
|
};
|
||||||
|
}
|
151
src/features/feature.ts
Normal file
151
src/features/feature.ts
Normal file
|
@ -0,0 +1,151 @@
|
||||||
|
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 = Record<string, unknown>, S = R> = () => T &
|
||||||
|
Partial<R> &
|
||||||
|
ThisType<T & S>;
|
||||||
|
|
||||||
|
let id = 0;
|
||||||
|
/**
|
||||||
|
* Gets a unique ID to give to each feature, used for any sort of system that needs to identify
|
||||||
|
* elements in the DOM rather than references to the feature itself. (For example, branches)
|
||||||
|
* IDs are guaranteed unique, but _NOT_ persistent - they likely will change between updates.
|
||||||
|
* @param prefix A string to prepend to the id to make it more readable in the inspector tools
|
||||||
|
*/
|
||||||
|
export function getUniqueID(prefix = "feature-"): string {
|
||||||
|
return prefix + id++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enum for what the visibility of a feature or component should be */
|
||||||
|
export enum Visibility {
|
||||||
|
/** The feature or component should be visible */
|
||||||
|
Visible,
|
||||||
|
/** The feature or component should not appear but still take up space */
|
||||||
|
Hidden,
|
||||||
|
/** The feature or component should not appear not take up space */
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVisible(visibility: ProcessedComputable<Visibility | boolean>) {
|
||||||
|
const currVisibility = unref(visibility);
|
||||||
|
return currVisibility !== Visibility.None && currVisibility !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHidden(visibility: ProcessedComputable<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traverses an object and returns all features of the given type(s)
|
||||||
|
* @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[] {
|
||||||
|
const objects: unknown[] = [];
|
||||||
|
const handleObject = (obj: Record<string, unknown>) => {
|
||||||
|
Object.keys(obj).forEach(key => {
|
||||||
|
const value = obj[key];
|
||||||
|
if (value != null && typeof value === "object") {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
if (types.includes((value as Record<string, any>).type)) {
|
||||||
|
objects.push(value);
|
||||||
|
} else if (!(value instanceof Decimal) && !isRef(value)) {
|
||||||
|
handleObject(value as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
handleObject(obj);
|
||||||
|
return objects;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
* @param obj The object to traverse
|
||||||
|
* @param types The feature types that will be skipped over
|
||||||
|
*/
|
||||||
|
export function excludeFeatures(obj: Record<string, unknown>, ...types: symbol[]): unknown[] {
|
||||||
|
const objects: unknown[] = [];
|
||||||
|
const handleObject = (obj: Record<string, unknown>) => {
|
||||||
|
Object.keys(obj).forEach(key => {
|
||||||
|
const value = obj[key];
|
||||||
|
if (value != null && typeof value === "object") {
|
||||||
|
if (
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
typeof (value as Record<string, any>).type == "symbol" &&
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
!types.includes((value as Record<string, any>).type)
|
||||||
|
) {
|
||||||
|
objects.push(value);
|
||||||
|
} else if (!(value instanceof Decimal) && !isRef(value)) {
|
||||||
|
handleObject(value as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
handleObject(obj);
|
||||||
|
return objects;
|
||||||
|
}
|
60
src/features/grids/Grid.vue
Normal file
60
src/features/grids/Grid.vue
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="isVisible(visibility)"
|
||||||
|
:style="{
|
||||||
|
visibility: isHidden(visibility) ? 'hidden' : undefined
|
||||||
|
}"
|
||||||
|
class="table-grid"
|
||||||
|
>
|
||||||
|
<div v-for="row in unref(rows)" class="row-grid" :class="{ mergeAdjacent }" :key="row">
|
||||||
|
<GridCell
|
||||||
|
v-for="col in unref(cols)"
|
||||||
|
:key="col"
|
||||||
|
v-bind="gatherCellProps(unref(cells)[row * 100 + col])"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import "components/common/table.css";
|
||||||
|
import themes from "data/themes";
|
||||||
|
import { isHidden, isVisible, Visibility } from "features/feature";
|
||||||
|
import type { GridCell } from "features/grids/grid";
|
||||||
|
import settings from "game/settings";
|
||||||
|
import { processedPropType } from "util/vue";
|
||||||
|
import { computed, defineComponent, unref } from "vue";
|
||||||
|
import GridCellVue from "./GridCell.vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
visibility: {
|
||||||
|
type: processedPropType<Visibility | boolean>(Number, Boolean),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
rows: {
|
||||||
|
type: processedPropType<number>(Number),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
cols: {
|
||||||
|
type: processedPropType<number>(Number),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
cells: {
|
||||||
|
type: processedPropType<Record<string, GridCell>>(Object),
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: { GridCell: GridCellVue },
|
||||||
|
setup() {
|
||||||
|
const mergeAdjacent = computed(() => themes[settings.theme].mergeAdjacent);
|
||||||
|
|
||||||
|
function gatherCellProps(cell: GridCell) {
|
||||||
|
const { visibility, onClick, onHold, display, title, style, canClick, id } = cell;
|
||||||
|
return { visibility, onClick, onHold, display, title, style, canClick, id };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { unref, gatherCellProps, Visibility, mergeAdjacent, isVisible, isHidden };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
98
src/features/grids/GridCell.vue
Normal file
98
src/features/grids/GridCell.vue
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
v-if="isVisible(visibility)"
|
||||||
|
:class="{ feature: true, tile: true, can: unref(canClick), locked: !unref(canClick) }"
|
||||||
|
:style="[
|
||||||
|
{
|
||||||
|
visibility: isHidden(visibility) ? 'hidden' : undefined
|
||||||
|
},
|
||||||
|
unref(style) ?? {}
|
||||||
|
]"
|
||||||
|
@click="onClick"
|
||||||
|
@mousedown="start"
|
||||||
|
@mouseleave="stop"
|
||||||
|
@mouseup="stop"
|
||||||
|
@touchstart.passive="start"
|
||||||
|
@touchend.passive="stop"
|
||||||
|
@touchcancel.passive="stop"
|
||||||
|
>
|
||||||
|
<div v-if="title"><component :is="titleComponent" /></div>
|
||||||
|
<component :is="component" style="white-space: pre-line" />
|
||||||
|
<Node :id="id" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import "components/common/features.css";
|
||||||
|
import Node from "components/Node.vue";
|
||||||
|
import type { CoercableComponent, StyleValue } from "features/feature";
|
||||||
|
import { isHidden, isVisible, Visibility } from "features/feature";
|
||||||
|
import {
|
||||||
|
computeComponent,
|
||||||
|
computeOptionalComponent,
|
||||||
|
processedPropType,
|
||||||
|
setupHoldToClick
|
||||||
|
} from "util/vue";
|
||||||
|
import type { PropType } from "vue";
|
||||||
|
import { defineComponent, toRefs, unref } from "vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
visibility: {
|
||||||
|
type: processedPropType<Visibility | boolean>(Number, Boolean),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
onClick: Function as PropType<(e?: MouseEvent | TouchEvent) => void>,
|
||||||
|
onHold: Function as PropType<VoidFunction>,
|
||||||
|
display: {
|
||||||
|
type: processedPropType<CoercableComponent>(Object, String, Function),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
title: processedPropType<CoercableComponent>(Object, String, Function),
|
||||||
|
style: processedPropType<StyleValue>(String, Object, Array),
|
||||||
|
canClick: {
|
||||||
|
type: processedPropType<boolean>(Boolean),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Node
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const { onClick, onHold, title, display } = toRefs(props);
|
||||||
|
|
||||||
|
const { start, stop } = setupHoldToClick(onClick, onHold);
|
||||||
|
|
||||||
|
const titleComponent = computeOptionalComponent(title);
|
||||||
|
const component = computeComponent(display);
|
||||||
|
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
titleComponent,
|
||||||
|
component,
|
||||||
|
Visibility,
|
||||||
|
unref,
|
||||||
|
isVisible,
|
||||||
|
isHidden
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tile {
|
||||||
|
min-height: 80px;
|
||||||
|
width: 80px;
|
||||||
|
font-size: 10px;
|
||||||
|
background-color: var(--layer-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile > * {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
302
src/features/grids/grid.ts
Normal file
302
src/features/grids/grid.ts
Normal file
|
@ -0,0 +1,302 @@
|
||||||
|
import type { CoercableComponent, 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";
|
||||||
|
|
||||||
|
export const GridType = Symbol("Grid");
|
||||||
|
|
||||||
|
export type CellComputable<T> = Computable<T> | ((id: string | number, state: State) => T);
|
||||||
|
|
||||||
|
function createGridProxy(grid: GenericGrid): Record<string | number, GridCell> {
|
||||||
|
return new Proxy({}, getGridHandler(grid)) as Record<string | number, GridCell>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridCell {
|
||||||
|
id: string;
|
||||||
|
visibility: Visibility | boolean;
|
||||||
|
canClick: boolean;
|
||||||
|
startState: State;
|
||||||
|
state: State;
|
||||||
|
style?: StyleValue;
|
||||||
|
classes?: Record<string, boolean>;
|
||||||
|
title?: CoercableComponent;
|
||||||
|
display: CoercableComponent;
|
||||||
|
onClick?: (e?: MouseEvent | TouchEvent) => void;
|
||||||
|
onHold?: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridOptions {
|
||||||
|
visibility?: Computable<Visibility | boolean>;
|
||||||
|
rows: Computable<number>;
|
||||||
|
cols: Computable<number>;
|
||||||
|
getVisibility?: CellComputable<Visibility | boolean>;
|
||||||
|
getCanClick?: CellComputable<boolean>;
|
||||||
|
getStartState: Computable<State> | ((id: string | number) => State);
|
||||||
|
getStyle?: CellComputable<StyleValue>;
|
||||||
|
getClasses?: CellComputable<Record<string, boolean>>;
|
||||||
|
getTitle?: CellComputable<CoercableComponent>;
|
||||||
|
getDisplay: CellComputable<CoercableComponent>;
|
||||||
|
onClick?: (id: string | number, state: State, e?: MouseEvent | TouchEvent) => void;
|
||||||
|
onHold?: (id: string | number, state: State) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseGrid {
|
||||||
|
id: string;
|
||||||
|
getID: (id: string | number, state: State) => string;
|
||||||
|
getState: (id: string | number) => State;
|
||||||
|
setState: (id: string | number, state: State) => void;
|
||||||
|
cells: Record<string | number, GridCell>;
|
||||||
|
cellState: Persistent<Record<string | number, State>>;
|
||||||
|
type: typeof GridType;
|
||||||
|
[Component]: typeof GridComponent;
|
||||||
|
[GatherProps]: () => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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"]>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericGrid = Replace<
|
||||||
|
Grid<GridOptions>,
|
||||||
|
{
|
||||||
|
visibility: ProcessedComputable<Visibility | boolean>;
|
||||||
|
getVisibility: ProcessedComputable<Visibility | boolean>;
|
||||||
|
getCanClick: ProcessedComputable<boolean>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createGrid<T extends GridOptions>(
|
||||||
|
optionsFunc: OptionsFunc<T, BaseGrid, GenericGrid>
|
||||||
|
): Grid<T> {
|
||||||
|
const cellState = persistent<Record<string | number, State>>({});
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const grid = optionsFunc();
|
||||||
|
grid.id = getUniqueID("grid-");
|
||||||
|
grid[Component] = GridComponent;
|
||||||
|
|
||||||
|
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>;
|
||||||
|
});
|
||||||
|
}
|
119
src/features/hotkey.tsx
Normal file
119
src/features/hotkey.tsx
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
import { hasWon } from "data/projEntry";
|
||||||
|
import type { OptionsFunc, Replace } from "features/feature";
|
||||||
|
import { findFeatures, jsx, setDefault } from "features/feature";
|
||||||
|
import { globalBus } from "game/events";
|
||||||
|
import player from "game/player";
|
||||||
|
import { registerInfoComponent } from "game/settings";
|
||||||
|
import type {
|
||||||
|
Computable,
|
||||||
|
GetComputableType,
|
||||||
|
GetComputableTypeWithDefault,
|
||||||
|
ProcessedComputable
|
||||||
|
} from "util/computed";
|
||||||
|
import { processComputable } from "util/computed";
|
||||||
|
import { createLazyProxy } from "util/proxies";
|
||||||
|
import { shallowReactive, unref } from "vue";
|
||||||
|
import Hotkey from "components/Hotkey.vue";
|
||||||
|
|
||||||
|
export const hotkeys: Record<string, GenericHotkey | undefined> = shallowReactive({});
|
||||||
|
export const HotkeyType = Symbol("Hotkey");
|
||||||
|
|
||||||
|
export interface HotkeyOptions {
|
||||||
|
enabled?: Computable<boolean>;
|
||||||
|
key: string;
|
||||||
|
description: Computable<string>;
|
||||||
|
onPress: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseHotkey {
|
||||||
|
type: typeof HotkeyType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Hotkey<T extends HotkeyOptions> = Replace<
|
||||||
|
T & BaseHotkey,
|
||||||
|
{
|
||||||
|
enabled: GetComputableTypeWithDefault<T["enabled"], true>;
|
||||||
|
description: GetComputableType<T["description"]>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericHotkey = Replace<
|
||||||
|
Hotkey<HotkeyOptions>,
|
||||||
|
{
|
||||||
|
enabled: ProcessedComputable<boolean>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
const uppercaseNumbers = [")", "!", "@", "#", "$", "%", "^", "&", "*", "("];
|
||||||
|
|
||||||
|
export function createHotkey<T extends HotkeyOptions>(
|
||||||
|
optionsFunc: OptionsFunc<T, BaseHotkey, GenericHotkey>
|
||||||
|
): Hotkey<T> {
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const hotkey = optionsFunc();
|
||||||
|
hotkey.type = HotkeyType;
|
||||||
|
|
||||||
|
processComputable(hotkey as T, "enabled");
|
||||||
|
setDefault(hotkey, "enabled", true);
|
||||||
|
processComputable(hotkey as T, "description");
|
||||||
|
|
||||||
|
return hotkey as unknown as Hotkey<T>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
globalBus.on("addLayer", layer => {
|
||||||
|
(findFeatures(layer, HotkeyType) as GenericHotkey[]).forEach(hotkey => {
|
||||||
|
hotkeys[hotkey.key] = hotkey;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
globalBus.on("removeLayer", layer => {
|
||||||
|
(findFeatures(layer, HotkeyType) as GenericHotkey[]).forEach(hotkey => {
|
||||||
|
hotkeys[hotkey.key] = undefined;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.onkeydown = function (e) {
|
||||||
|
if ((e.target as HTMLElement | null)?.tagName === "INPUT") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasWon.value && !player.keepGoing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let key = e.key;
|
||||||
|
if (uppercaseNumbers.includes(key)) {
|
||||||
|
key = "shift+" + uppercaseNumbers.indexOf(key);
|
||||||
|
} else if (e.shiftKey) {
|
||||||
|
key = "shift+" + key;
|
||||||
|
}
|
||||||
|
if (e.ctrlKey) {
|
||||||
|
key = "ctrl+" + key;
|
||||||
|
}
|
||||||
|
const hotkey = hotkeys[key];
|
||||||
|
if (hotkey && unref(hotkey.enabled)) {
|
||||||
|
e.preventDefault();
|
||||||
|
hotkey.onPress();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
registerInfoComponent(
|
||||||
|
jsx(() => {
|
||||||
|
const keys = Object.values(hotkeys).filter(hotkey => unref(hotkey?.enabled));
|
||||||
|
if (keys.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<br />
|
||||||
|
<h4>Hotkeys</h4>
|
||||||
|
<div style="column-count: 2">
|
||||||
|
{keys.map(hotkey => (
|
||||||
|
<div>
|
||||||
|
<Hotkey hotkey={hotkey as GenericHotkey} /> {hotkey?.description}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
183
src/features/infoboxes/Infobox.vue
Normal file
183
src/features/infoboxes/Infobox.vue
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="infobox"
|
||||||
|
v-if="isVisible(visibility)"
|
||||||
|
:style="[
|
||||||
|
{
|
||||||
|
borderColor: unref(color),
|
||||||
|
visibility: isHidden(visibility) ? 'hidden' : undefined
|
||||||
|
},
|
||||||
|
unref(style) ?? {}
|
||||||
|
]"
|
||||||
|
:class="{ collapsed: unref(collapsed), stacked, ...unref(classes) }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="title"
|
||||||
|
:style="[{ backgroundColor: unref(color) }, unref(titleStyle) || []]"
|
||||||
|
@click="collapsed.value = !unref(collapsed)"
|
||||||
|
>
|
||||||
|
<span class="toggle">▼</span>
|
||||||
|
<component :is="titleComponent" />
|
||||||
|
</button>
|
||||||
|
<CollapseTransition>
|
||||||
|
<div v-if="!unref(collapsed)" class="body" :style="{ backgroundColor: unref(color) }">
|
||||||
|
<component :is="bodyComponent" :style="unref(bodyStyle)" />
|
||||||
|
</div>
|
||||||
|
</CollapseTransition>
|
||||||
|
<Node :id="id" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script 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";
|
||||||
|
|
||||||
|
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 titleComponent = computeComponent(title);
|
||||||
|
const bodyComponent = computeComponent(display);
|
||||||
|
const stacked = computed(() => themes[settings.theme].mergeAdjacent);
|
||||||
|
|
||||||
|
return {
|
||||||
|
titleComponent,
|
||||||
|
bodyComponent,
|
||||||
|
stacked,
|
||||||
|
unref,
|
||||||
|
Visibility,
|
||||||
|
isVisible,
|
||||||
|
isHidden
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.infobox {
|
||||||
|
position: relative;
|
||||||
|
width: 600px;
|
||||||
|
max-width: 95%;
|
||||||
|
margin-top: 0;
|
||||||
|
text-align: left;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 0px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infobox.stacked {
|
||||||
|
border-width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infobox:not(.stacked) + .infobox:not(.stacked) {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infobox + :not(.infobox) {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 24px;
|
||||||
|
color: black;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
width: auto;
|
||||||
|
text-align: left;
|
||||||
|
padding-left: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infobox:not(.stacked) .title {
|
||||||
|
border-top-left-radius: 5px;
|
||||||
|
border-top-right-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infobox.stacked + .infobox.stacked {
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
margin-top: -5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked .title {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsed:not(.stacked) .title::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 4px;
|
||||||
|
background-color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsed .toggle {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
transition-duration: 0.5s;
|
||||||
|
border-radius: 5px;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infobox:not(.stacked) .body {
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body > * {
|
||||||
|
padding: 8px;
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 5px;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
background-color: var(--background);
|
||||||
|
}
|
||||||
|
</style>
|
109
src/features/infoboxes/infobox.ts
Normal file
109
src/features/infoboxes/infobox.ts
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
import type { CoercableComponent, 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";
|
||||||
|
|
||||||
|
export const InfoboxType = Symbol("Infobox");
|
||||||
|
|
||||||
|
export interface InfoboxOptions {
|
||||||
|
visibility?: Computable<Visibility | boolean>;
|
||||||
|
color?: Computable<string>;
|
||||||
|
style?: Computable<StyleValue>;
|
||||||
|
titleStyle?: Computable<StyleValue>;
|
||||||
|
bodyStyle?: Computable<StyleValue>;
|
||||||
|
classes?: Computable<Record<string, boolean>>;
|
||||||
|
title: Computable<CoercableComponent>;
|
||||||
|
display: Computable<CoercableComponent>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseInfobox {
|
||||||
|
id: string;
|
||||||
|
collapsed: Persistent<boolean>;
|
||||||
|
type: typeof InfoboxType;
|
||||||
|
[Component]: typeof InfoboxComponent;
|
||||||
|
[GatherProps]: () => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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"]>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericInfobox = Replace<
|
||||||
|
Infobox<InfoboxOptions>,
|
||||||
|
{
|
||||||
|
visibility: ProcessedComputable<Visibility | boolean>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createInfobox<T extends InfoboxOptions>(
|
||||||
|
optionsFunc: OptionsFunc<T, BaseInfobox, GenericInfobox>
|
||||||
|
): Infobox<T> {
|
||||||
|
const collapsed = persistent<boolean>(false);
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const infobox = optionsFunc();
|
||||||
|
infobox.id = getUniqueID("infobox-");
|
||||||
|
infobox.type = InfoboxType;
|
||||||
|
infobox[Component] = InfoboxComponent;
|
||||||
|
|
||||||
|
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>;
|
||||||
|
});
|
||||||
|
}
|
57
src/features/links/Link.vue
Normal file
57
src/features/links/Link.vue
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
<template>
|
||||||
|
<line
|
||||||
|
stroke-width="15px"
|
||||||
|
stroke="white"
|
||||||
|
v-bind="link"
|
||||||
|
:x1="startPosition.x"
|
||||||
|
:y1="startPosition.y"
|
||||||
|
:x2="endPosition.x"
|
||||||
|
:y2="endPosition.y"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Link } from "features/links/links";
|
||||||
|
import type { FeatureNode } from "game/layers";
|
||||||
|
import { computed, toRefs } from "vue";
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
return position;
|
||||||
|
});
|
||||||
|
|
||||||
|
const endPosition = computed(() => {
|
||||||
|
const rect = props.endNode.value.rect;
|
||||||
|
const boundingRect = props.boundingRect.value;
|
||||||
|
const position = boundingRect
|
||||||
|
? {
|
||||||
|
x: rect.x + rect.width / 2 - boundingRect.x,
|
||||||
|
y: rect.y + rect.height / 2 - boundingRect.y
|
||||||
|
}
|
||||||
|
: { x: 0, y: 0 };
|
||||||
|
if (props.link.value.offsetEnd) {
|
||||||
|
position.x += props.link.value.offsetEnd.x;
|
||||||
|
position.y += props.link.value.offsetEnd.y;
|
||||||
|
}
|
||||||
|
return position;
|
||||||
|
});
|
||||||
|
</script>
|
64
src/features/links/Links.vue
Normal file
64
src/features/links/Links.vue
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
<template>
|
||||||
|
<svg v-if="validLinks" v-bind="$attrs">
|
||||||
|
<LinkVue
|
||||||
|
v-for="(link, index) in validLinks"
|
||||||
|
:key="index"
|
||||||
|
:link="link"
|
||||||
|
:boundingRect="boundingRect"
|
||||||
|
:startNode="nodes[link.startNode.id]!"
|
||||||
|
:endNode="nodes[link.endNode.id]!"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div ref="resizeListener" class="resize-listener" />
|
||||||
|
</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 LinkVue from "./Link.vue";
|
||||||
|
|
||||||
|
const _props = defineProps<{ links?: Link[] }>();
|
||||||
|
const links = toRef(_props, "links");
|
||||||
|
|
||||||
|
const resizeListener = ref<Element | null>(null);
|
||||||
|
|
||||||
|
const nodes = inject(NodesInjectionKey, ref<Record<string, FeatureNode | undefined>>({}));
|
||||||
|
const outerBoundingRect = inject(BoundsInjectionKey, ref<DOMRect | undefined>(undefined));
|
||||||
|
const boundingRect = ref<DOMRect | undefined>(resizeListener.value?.getBoundingClientRect());
|
||||||
|
watch(
|
||||||
|
outerBoundingRect,
|
||||||
|
() => (boundingRect.value = resizeListener.value?.getBoundingClientRect())
|
||||||
|
);
|
||||||
|
onMounted(() => (boundingRect.value = resizeListener.value?.getBoundingClientRect()));
|
||||||
|
|
||||||
|
const validLinks = computed(() => {
|
||||||
|
const n = nodes.value;
|
||||||
|
return (
|
||||||
|
links.value?.filter(link => n[link.startNode.id]?.rect && n[link.startNode.id]?.rect) ?? []
|
||||||
|
);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.resize-listener {
|
||||||
|
position: absolute;
|
||||||
|
top: 0px;
|
||||||
|
left: 0;
|
||||||
|
right: -4px;
|
||||||
|
bottom: 5px;
|
||||||
|
z-index: -10;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: -10;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
62
src/features/links/links.ts
Normal file
62
src/features/links/links.ts
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import type { 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";
|
||||||
|
|
||||||
|
export const LinksType = Symbol("Links");
|
||||||
|
|
||||||
|
export interface Link extends SVGAttributes {
|
||||||
|
startNode: { id: string };
|
||||||
|
endNode: { id: string };
|
||||||
|
offsetStart?: Position;
|
||||||
|
offsetEnd?: Position;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LinksOptions {
|
||||||
|
links: Computable<Link[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseLinks {
|
||||||
|
type: typeof LinksType;
|
||||||
|
[Component]: typeof LinksComponent;
|
||||||
|
[GatherProps]: () => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Links<T extends LinksOptions> = Replace<
|
||||||
|
T & BaseLinks,
|
||||||
|
{
|
||||||
|
links: GetComputableType<T["links"]>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericLinks = Replace<
|
||||||
|
Links<LinksOptions>,
|
||||||
|
{
|
||||||
|
links: ProcessedComputable<Link[]>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createLinks<T extends LinksOptions>(
|
||||||
|
optionsFunc: OptionsFunc<T, BaseLinks, GenericLinks>
|
||||||
|
): Links<T> {
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const links = optionsFunc();
|
||||||
|
links.type = LinksType;
|
||||||
|
links[Component] = LinksComponent;
|
||||||
|
|
||||||
|
processComputable(links as T, "links");
|
||||||
|
|
||||||
|
links[GatherProps] = function (this: GenericLinks) {
|
||||||
|
const { links } = this;
|
||||||
|
return {
|
||||||
|
links
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return links as unknown as Links<T>;
|
||||||
|
});
|
||||||
|
}
|
128
src/features/milestones/Milestone.vue
Normal file
128
src/features/milestones/Milestone.vue
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="isVisible(visibility)"
|
||||||
|
:style="[
|
||||||
|
{
|
||||||
|
visibility: isHidden(visibility) ? 'hidden' : undefined
|
||||||
|
},
|
||||||
|
unref(style) ?? {}
|
||||||
|
]"
|
||||||
|
:class="{ feature: true, milestone: true, done: unref(earned), ...unref(classes) }"
|
||||||
|
>
|
||||||
|
<component :is="unref(comp)" />
|
||||||
|
<Node :id="id" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="tsx">
|
||||||
|
import "components/common/features.css";
|
||||||
|
import Node from "components/Node.vue";
|
||||||
|
import type { StyleValue } from "features/feature";
|
||||||
|
import { isHidden, isVisible, jsx, Visibility } from "features/feature";
|
||||||
|
import type { GenericMilestone } from "features/milestones/milestone";
|
||||||
|
import { coerceComponent, isCoercableComponent, processedPropType, unwrapRef } from "util/vue";
|
||||||
|
import type { Component, UnwrapRef } from "vue";
|
||||||
|
import { defineComponent, shallowRef, toRefs, unref, watchEffect } from "vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
visibility: {
|
||||||
|
type: processedPropType<Visibility | boolean>(Number, Boolean),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
display: {
|
||||||
|
type: processedPropType<UnwrapRef<GenericMilestone["display"]>>(
|
||||||
|
String,
|
||||||
|
Object,
|
||||||
|
Function
|
||||||
|
),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
style: processedPropType<StyleValue>(String, Object, Array),
|
||||||
|
classes: processedPropType<Record<string, boolean>>(Object),
|
||||||
|
earned: {
|
||||||
|
type: processedPropType<boolean>(Boolean),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Node
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const { display } = toRefs(props);
|
||||||
|
|
||||||
|
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, "h3");
|
||||||
|
const EffectDisplay = coerceComponent(currDisplay.effectDisplay || "", "b");
|
||||||
|
const OptionsDisplay = coerceComponent(currDisplay.optionsDisplay || "", "span");
|
||||||
|
comp.value = coerceComponent(
|
||||||
|
jsx(() => (
|
||||||
|
<span>
|
||||||
|
<Requirement />
|
||||||
|
{currDisplay.effectDisplay != null ? (
|
||||||
|
<div>
|
||||||
|
<EffectDisplay />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{currDisplay.optionsDisplay != null ? (
|
||||||
|
<div class="equal-spaced">
|
||||||
|
<OptionsDisplay />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
comp,
|
||||||
|
unref,
|
||||||
|
Visibility,
|
||||||
|
isVisible,
|
||||||
|
isHidden
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.milestone {
|
||||||
|
width: calc(100% - 10px);
|
||||||
|
min-width: 120px;
|
||||||
|
padding-left: 5px;
|
||||||
|
padding-right: 5px;
|
||||||
|
background-color: var(--locked);
|
||||||
|
border-width: 4px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone.done {
|
||||||
|
background-color: var(--bought);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone :deep(.equal-spaced) {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone :deep(.equal-spaced > *) {
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
</style>
|
235
src/features/milestones/milestone.tsx
Normal file
235
src/features/milestones/milestone.tsx
Normal file
|
@ -0,0 +1,235 @@
|
||||||
|
import Select from "components/fields/Select.vue";
|
||||||
|
import type {
|
||||||
|
CoercableComponent,
|
||||||
|
GenericComponent,
|
||||||
|
OptionsFunc,
|
||||||
|
Replace,
|
||||||
|
StyleValue
|
||||||
|
} from "features/feature";
|
||||||
|
import {
|
||||||
|
Component,
|
||||||
|
GatherProps,
|
||||||
|
getUniqueID,
|
||||||
|
isVisible,
|
||||||
|
jsx,
|
||||||
|
setDefault,
|
||||||
|
Visibility
|
||||||
|
} from "features/feature";
|
||||||
|
import MilestoneComponent from "features/milestones/Milestone.vue";
|
||||||
|
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 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 { createLazyProxy } from "util/proxies";
|
||||||
|
import { coerceComponent, isCoercableComponent } from "util/vue";
|
||||||
|
import { computed, unref, watchEffect } from "vue";
|
||||||
|
import { useToast } from "vue-toastification";
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
export const MilestoneType = Symbol("Milestone");
|
||||||
|
|
||||||
|
export enum MilestoneDisplay {
|
||||||
|
All = "all",
|
||||||
|
//Last = "last",
|
||||||
|
Configurable = "configurable",
|
||||||
|
Incomplete = "incomplete",
|
||||||
|
None = "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MilestoneOptions {
|
||||||
|
visibility?: Computable<Visibility | boolean>;
|
||||||
|
shouldEarn?: () => boolean;
|
||||||
|
style?: Computable<StyleValue>;
|
||||||
|
classes?: Computable<Record<string, boolean>>;
|
||||||
|
display?: Computable<
|
||||||
|
| CoercableComponent
|
||||||
|
| {
|
||||||
|
requirement: CoercableComponent;
|
||||||
|
effectDisplay?: CoercableComponent;
|
||||||
|
optionsDisplay?: CoercableComponent;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
showPopups?: Computable<boolean>;
|
||||||
|
onComplete?: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseMilestone {
|
||||||
|
id: string;
|
||||||
|
earned: Persistent<boolean>;
|
||||||
|
complete: VoidFunction;
|
||||||
|
type: typeof MilestoneType;
|
||||||
|
[Component]: typeof MilestoneComponent;
|
||||||
|
[GatherProps]: () => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Milestone<T extends MilestoneOptions> = Replace<
|
||||||
|
T & BaseMilestone,
|
||||||
|
{
|
||||||
|
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||||
|
style: GetComputableType<T["style"]>;
|
||||||
|
classes: GetComputableType<T["classes"]>;
|
||||||
|
display: GetComputableType<T["display"]>;
|
||||||
|
showPopups: GetComputableType<T["showPopups"]>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericMilestone = Replace<
|
||||||
|
Milestone<MilestoneOptions>,
|
||||||
|
{
|
||||||
|
visibility: ProcessedComputable<Visibility | boolean>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createMilestone<T extends MilestoneOptions>(
|
||||||
|
optionsFunc?: OptionsFunc<T, BaseMilestone, GenericMilestone>
|
||||||
|
): Milestone<T> {
|
||||||
|
const earned = persistent<boolean>(false);
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const milestone = optionsFunc?.() ?? ({} as ReturnType<NonNullable<typeof optionsFunc>>);
|
||||||
|
milestone.id = getUniqueID("milestone-");
|
||||||
|
milestone.type = MilestoneType;
|
||||||
|
milestone[Component] = MilestoneComponent;
|
||||||
|
|
||||||
|
milestone.earned = earned;
|
||||||
|
milestone.complete = function () {
|
||||||
|
const genericMilestone = milestone as GenericMilestone;
|
||||||
|
earned.value = true;
|
||||||
|
genericMilestone.onComplete?.();
|
||||||
|
if (genericMilestone.display != null && unref(genericMilestone.showPopups) === true) {
|
||||||
|
const display = unref(genericMilestone.display);
|
||||||
|
const Display = coerceComponent(
|
||||||
|
isCoercableComponent(display) ? display : display.requirement
|
||||||
|
);
|
||||||
|
toast(
|
||||||
|
<>
|
||||||
|
<h3>Milestone earned!</h3>
|
||||||
|
<div>
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
<Display />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
processComputable(milestone as T, "visibility");
|
||||||
|
setDefault(milestone, "visibility", Visibility.Visible);
|
||||||
|
const visibility = milestone.visibility as ProcessedComputable<Visibility | boolean>;
|
||||||
|
milestone.visibility = computed(() => {
|
||||||
|
const display = unref((milestone as GenericMilestone).display);
|
||||||
|
switch (settings.msDisplay) {
|
||||||
|
default:
|
||||||
|
case MilestoneDisplay.All:
|
||||||
|
return unref(visibility);
|
||||||
|
case MilestoneDisplay.Configurable:
|
||||||
|
if (
|
||||||
|
unref(milestone.earned) &&
|
||||||
|
!(
|
||||||
|
display != null &&
|
||||||
|
typeof display == "object" &&
|
||||||
|
"optionsDisplay" in (display as Record<string, unknown>)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return Visibility.None;
|
||||||
|
}
|
||||||
|
return unref(visibility);
|
||||||
|
case MilestoneDisplay.Incomplete:
|
||||||
|
if (unref(milestone.earned)) {
|
||||||
|
return Visibility.None;
|
||||||
|
}
|
||||||
|
return unref(visibility);
|
||||||
|
case MilestoneDisplay.None:
|
||||||
|
return Visibility.None;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
processComputable(milestone as T, "style");
|
||||||
|
processComputable(milestone as T, "classes");
|
||||||
|
processComputable(milestone as T, "display");
|
||||||
|
processComputable(milestone as T, "showPopups");
|
||||||
|
|
||||||
|
milestone[GatherProps] = function (this: GenericMilestone) {
|
||||||
|
const { visibility, display, style, classes, earned, id } = this;
|
||||||
|
return { visibility, display, style: unref(style), classes, earned, id };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (milestone.shouldEarn) {
|
||||||
|
const genericMilestone = milestone as GenericMilestone;
|
||||||
|
watchEffect(() => {
|
||||||
|
if (settings.active !== player.id) return;
|
||||||
|
if (
|
||||||
|
!genericMilestone.earned.value &&
|
||||||
|
isVisible(genericMilestone.visibility) &&
|
||||||
|
genericMilestone.shouldEarn?.()
|
||||||
|
) {
|
||||||
|
genericMilestone.earned.value = true;
|
||||||
|
genericMilestone.onComplete?.();
|
||||||
|
if (
|
||||||
|
genericMilestone.display != null &&
|
||||||
|
unref(genericMilestone.showPopups) === true
|
||||||
|
) {
|
||||||
|
const display = unref(genericMilestone.display);
|
||||||
|
const Display = coerceComponent(
|
||||||
|
isCoercableComponent(display) ? display : display.requirement
|
||||||
|
);
|
||||||
|
toast(
|
||||||
|
<>
|
||||||
|
<h3>Milestone earned!</h3>
|
||||||
|
<div>
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
<Display />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return milestone as unknown as Milestone<T>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "game/settings" {
|
||||||
|
interface Settings {
|
||||||
|
msDisplay: MilestoneDisplay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
globalBus.on("loadSettings", settings => {
|
||||||
|
setDefault(settings, "msDisplay", MilestoneDisplay.All);
|
||||||
|
});
|
||||||
|
|
||||||
|
const msDisplayOptions = Object.values(MilestoneDisplay).map(option => ({
|
||||||
|
label: camelToTitle(option),
|
||||||
|
value: option
|
||||||
|
}));
|
||||||
|
|
||||||
|
registerSettingField(
|
||||||
|
jsx(() => (
|
||||||
|
<Select
|
||||||
|
title={jsx(() => (
|
||||||
|
<span class="option-title">
|
||||||
|
Show milestones
|
||||||
|
<desc>Select which milestones to display based on criterias.</desc>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
options={msDisplayOptions}
|
||||||
|
onUpdate:modelValue={value => (settings.msDisplay = value as MilestoneDisplay)}
|
||||||
|
modelValue={settings.msDisplay}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
);
|
94
src/features/particles/Particles.vue
Normal file
94
src/features/particles/Particles.vue
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="resizeListener"
|
||||||
|
class="resize-listener"
|
||||||
|
:style="unref(style)"
|
||||||
|
:class="unref(classes)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script 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, ref, shallowRef, unref } from "vue";
|
||||||
|
|
||||||
|
// 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 resizeObserver = new ResizeObserver(updateBounds);
|
||||||
|
const resizeListener = shallowRef<HTMLElement | null>(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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.not-fullscreen,
|
||||||
|
.resize-listener {
|
||||||
|
position: absolute;
|
||||||
|
top: 0px;
|
||||||
|
left: 0;
|
||||||
|
right: -4px;
|
||||||
|
bottom: 5px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
94
src/features/particles/particles.tsx
Normal file
94
src/features/particles/particles.tsx
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
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";
|
||||||
|
|
||||||
|
export const ParticlesType = Symbol("Particles");
|
||||||
|
|
||||||
|
export interface ParticlesOptions {
|
||||||
|
classes?: Computable<Record<string, boolean>>;
|
||||||
|
style?: Computable<StyleValue>;
|
||||||
|
onContainerResized?: (boundingRect: DOMRect) => void;
|
||||||
|
onHotReload?: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseParticles {
|
||||||
|
id: string;
|
||||||
|
app: Ref<null | Application>;
|
||||||
|
addEmitter: (config: EmitterConfigV3) => Promise<Emitter>;
|
||||||
|
type: typeof ParticlesType;
|
||||||
|
[Component]: GenericComponent;
|
||||||
|
[GatherProps]: () => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Particles<T extends ParticlesOptions> = Replace<
|
||||||
|
T & BaseParticles,
|
||||||
|
{
|
||||||
|
classes: GetComputableType<T["classes"]>;
|
||||||
|
style: GetComputableType<T["style"]>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericParticles = Particles<ParticlesOptions>;
|
||||||
|
|
||||||
|
export function createParticles<T extends ParticlesOptions>(
|
||||||
|
optionsFunc?: OptionsFunc<T, BaseParticles, GenericParticles>
|
||||||
|
): Particles<T> {
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const particles = optionsFunc?.() ?? ({} as ReturnType<NonNullable<typeof optionsFunc>>);
|
||||||
|
particles.id = getUniqueID("particles-");
|
||||||
|
particles.type = ParticlesType;
|
||||||
|
particles[Component] = ParticlesComponent;
|
||||||
|
|
||||||
|
particles.app = shallowRef(null);
|
||||||
|
particles.addEmitter = (config: EmitterConfigV3): Promise<Emitter> => {
|
||||||
|
const genericParticles = particles as GenericParticles;
|
||||||
|
if (genericParticles.app.value) {
|
||||||
|
return Promise.resolve(new Emitter(genericParticles.app.value.stage, config));
|
||||||
|
}
|
||||||
|
return new Promise<Emitter>(resolve => {
|
||||||
|
emittersToAdd.push({ resolve, config });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let emittersToAdd: {
|
||||||
|
resolve: (value: Emitter | PromiseLike<Emitter>) => void;
|
||||||
|
config: EmitterConfigV3;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
upgradeConfig: typeof upgradeConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.upgradeConfig = upgradeConfig;
|
275
src/features/repeatable.tsx
Normal file
275
src/features/repeatable.tsx
Normal file
|
@ -0,0 +1,275 @@
|
||||||
|
import { isArray } from "@vue/shared";
|
||||||
|
import ClickableComponent from "features/clickables/Clickable.vue";
|
||||||
|
import type { CoercableComponent, OptionsFunc, Replace, StyleValue } from "features/feature";
|
||||||
|
import { Component, GatherProps, getUniqueID, jsx, setDefault, 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 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";
|
||||||
|
|
||||||
|
/** 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, bsed off its amount. */
|
||||||
|
effectDisplay?: CoercableComponent;
|
||||||
|
/** Whether or not to show the current amount of this repeatable at the bottom of the display. */
|
||||||
|
showAmount?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** An object that configures a {@link Repeatable}. */
|
||||||
|
export interface RepeatableOptions {
|
||||||
|
/** Whether this repeatable should be visible. */
|
||||||
|
visibility?: Computable<Visibility | boolean>;
|
||||||
|
/** The requirement(s) to increase this repeatable. */
|
||||||
|
requirements: Requirements;
|
||||||
|
/** The maximum amount obtainable for this repeatable. */
|
||||||
|
limit?: Computable<DecimalSource>;
|
||||||
|
/** The initial amount this repeatable has on a new save / after reset. */
|
||||||
|
initialAmount?: DecimalSource;
|
||||||
|
/** Dictionary of CSS classes to apply to this feature. */
|
||||||
|
classes?: Computable<Record<string, boolean>>;
|
||||||
|
/** CSS to apply to this feature. */
|
||||||
|
style?: Computable<StyleValue>;
|
||||||
|
/** Shows a marker on the corner of the feature. */
|
||||||
|
mark?: Computable<boolean | string>;
|
||||||
|
/** Toggles a smaller design for the feature. */
|
||||||
|
small?: Computable<boolean>;
|
||||||
|
/** Whether or not clicking this repeatable should attempt to maximize amount based on the requirements met. Requires {@link requirements} to be a requirement or array of requirements with {@link Requirement.canMaximize} true. */
|
||||||
|
maximize?: Computable<boolean>;
|
||||||
|
/** The display to use for this repeatable. */
|
||||||
|
display?: Computable<RepeatableDisplay>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The properties that are added onto a processed {@link RepeatableOptions} to create a {@link Repeatable}.
|
||||||
|
*/
|
||||||
|
export interface BaseRepeatable {
|
||||||
|
/** An auto-generated ID for identifying features that appear in the DOM. Will not persistent 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>;
|
||||||
|
/** 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]: typeof ClickableComponent;
|
||||||
|
/** A function to gather the props the vue component requires for this feature. */
|
||||||
|
[GatherProps]: () => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An object that represents a feature with multiple "levels" with scaling requirements. */
|
||||||
|
export type Repeatable<T extends RepeatableOptions> = Replace<
|
||||||
|
T & BaseRepeatable,
|
||||||
|
{
|
||||||
|
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||||
|
requirements: GetComputableType<T["requirements"]>;
|
||||||
|
limit: GetComputableTypeWithDefault<T["limit"], Decimal>;
|
||||||
|
classes: GetComputableType<T["classes"]>;
|
||||||
|
style: GetComputableType<T["style"]>;
|
||||||
|
mark: GetComputableType<T["mark"]>;
|
||||||
|
small: GetComputableType<T["small"]>;
|
||||||
|
maximize: GetComputableType<T["maximize"]>;
|
||||||
|
display: Ref<CoercableComponent>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** A type that matches any valid {@link Repeatable} object. */
|
||||||
|
export type GenericRepeatable = Replace<
|
||||||
|
Repeatable<RepeatableOptions>,
|
||||||
|
{
|
||||||
|
visibility: ProcessedComputable<Visibility | boolean>;
|
||||||
|
limit: ProcessedComputable<DecimalSource>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily creates a repeatable with the given options.
|
||||||
|
* @param optionsFunc Repeatable options.
|
||||||
|
*/
|
||||||
|
export function createRepeatable<T extends RepeatableOptions>(
|
||||||
|
optionsFunc: OptionsFunc<T, BaseRepeatable, GenericRepeatable>
|
||||||
|
): Repeatable<T> {
|
||||||
|
const amount = persistent<DecimalSource>(0);
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const repeatable = optionsFunc();
|
||||||
|
|
||||||
|
repeatable.id = getUniqueID("repeatable-");
|
||||||
|
repeatable.type = RepeatableType;
|
||||||
|
repeatable[Component] = ClickableComponent;
|
||||||
|
|
||||||
|
repeatable.amount = amount;
|
||||||
|
repeatable.amount[DefaultValue] = repeatable.initialAmount ?? 0;
|
||||||
|
|
||||||
|
const limitRequirement = {
|
||||||
|
requirementMet: computed(() =>
|
||||||
|
Decimal.sub(
|
||||||
|
unref((repeatable as GenericRepeatable).limit),
|
||||||
|
(repeatable as GenericRepeatable).amount.value
|
||||||
|
)
|
||||||
|
),
|
||||||
|
requiresPay: false,
|
||||||
|
visibility: Visibility.None
|
||||||
|
} as const;
|
||||||
|
const visibilityRequirement = createVisibilityRequirement(repeatable as GenericRepeatable);
|
||||||
|
if (isArray(repeatable.requirements)) {
|
||||||
|
repeatable.requirements.unshift(visibilityRequirement);
|
||||||
|
repeatable.requirements.push(limitRequirement);
|
||||||
|
} else {
|
||||||
|
repeatable.requirements = [
|
||||||
|
visibilityRequirement,
|
||||||
|
repeatable.requirements,
|
||||||
|
limitRequirement
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
repeatable.maxed = computed(() =>
|
||||||
|
Decimal.gte(
|
||||||
|
(repeatable as GenericRepeatable).amount.value,
|
||||||
|
unref((repeatable as GenericRepeatable).limit)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
processComputable(repeatable as T, "classes");
|
||||||
|
const classes = repeatable.classes as
|
||||||
|
| ProcessedComputable<Record<string, boolean>>
|
||||||
|
| undefined;
|
||||||
|
repeatable.classes = computed(() => {
|
||||||
|
const currClasses = unref(classes) || {};
|
||||||
|
if ((repeatable as GenericRepeatable).maxed.value) {
|
||||||
|
currClasses.bought = true;
|
||||||
|
}
|
||||||
|
return currClasses;
|
||||||
|
});
|
||||||
|
repeatable.canClick = computed(() => requirementsMet(repeatable.requirements));
|
||||||
|
const onClick = repeatable.onClick;
|
||||||
|
repeatable.onClick = function (this: GenericRepeatable, event?: MouseEvent | TouchEvent) {
|
||||||
|
const genericRepeatable = repeatable as GenericRepeatable;
|
||||||
|
if (!unref(genericRepeatable.canClick)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
payRequirements(
|
||||||
|
repeatable.requirements,
|
||||||
|
unref(genericRepeatable.maximize)
|
||||||
|
? maxRequirementsMet(genericRepeatable.requirements)
|
||||||
|
: 1
|
||||||
|
);
|
||||||
|
genericRepeatable.amount.value = Decimal.add(genericRepeatable.amount.value, 1);
|
||||||
|
onClick?.(event);
|
||||||
|
};
|
||||||
|
processComputable(repeatable as T, "display");
|
||||||
|
const display = repeatable.display;
|
||||||
|
repeatable.display = jsx(() => {
|
||||||
|
// TODO once processComputable types correctly, remove this "as X"
|
||||||
|
const currDisplay = unref(display) as RepeatableDisplay;
|
||||||
|
if (isCoercableComponent(currDisplay)) {
|
||||||
|
const CurrDisplay = coerceComponent(currDisplay);
|
||||||
|
return <CurrDisplay />;
|
||||||
|
}
|
||||||
|
if (currDisplay != null) {
|
||||||
|
const genericRepeatable = repeatable as GenericRepeatable;
|
||||||
|
const Title = coerceComponent(currDisplay.title ?? "", "h3");
|
||||||
|
const Description = coerceComponent(currDisplay.description ?? "");
|
||||||
|
const EffectDisplay = coerceComponent(currDisplay.effectDisplay ?? "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{currDisplay.title == null ? null : (
|
||||||
|
<div>
|
||||||
|
<Title />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{currDisplay.description == null ? null : <Description />}
|
||||||
|
{currDisplay.showAmount === false ? null : (
|
||||||
|
<div>
|
||||||
|
<br />
|
||||||
|
{unref(genericRepeatable.limit) === Decimal.dInf ? (
|
||||||
|
<>Amount: {formatWhole(genericRepeatable.amount.value)}</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Amount: {formatWhole(genericRepeatable.amount.value)} /{" "}
|
||||||
|
{formatWhole(unref(genericRepeatable.limit))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{currDisplay.effectDisplay == null ? null : (
|
||||||
|
<div>
|
||||||
|
<br />
|
||||||
|
Currently: <EffectDisplay />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{genericRepeatable.maxed.value ? null : (
|
||||||
|
<div>
|
||||||
|
<br />
|
||||||
|
{displayRequirements(
|
||||||
|
genericRepeatable.requirements,
|
||||||
|
unref(genericRepeatable.maximize)
|
||||||
|
? maxRequirementsMet(genericRepeatable.requirements)
|
||||||
|
: 1
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
processComputable(repeatable as T, "visibility");
|
||||||
|
setDefault(repeatable, "visibility", Visibility.Visible);
|
||||||
|
processComputable(repeatable as T, "limit");
|
||||||
|
setDefault(repeatable, "limit", Decimal.dInf);
|
||||||
|
processComputable(repeatable as T, "style");
|
||||||
|
processComputable(repeatable as T, "mark");
|
||||||
|
processComputable(repeatable as T, "small");
|
||||||
|
processComputable(repeatable as T, "maximize");
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return repeatable as unknown as Repeatable<T>;
|
||||||
|
});
|
||||||
|
}
|
96
src/features/reset.ts
Normal file
96
src/features/reset.ts
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
import type { OptionsFunc, Replace } from "features/feature";
|
||||||
|
import { getUniqueID } from "features/feature";
|
||||||
|
import { globalBus } from "game/events";
|
||||||
|
import type { BaseLayer } from "game/layers";
|
||||||
|
import type { NonPersistent, Persistent } from "game/persistence";
|
||||||
|
import { DefaultValue, persistent } 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 { createLazyProxy } from "util/proxies";
|
||||||
|
import { isRef, unref } from "vue";
|
||||||
|
|
||||||
|
export const ResetType = Symbol("Reset");
|
||||||
|
|
||||||
|
export interface ResetOptions {
|
||||||
|
thingsToReset: Computable<Record<string, unknown>[]>;
|
||||||
|
onReset?: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseReset {
|
||||||
|
id: string;
|
||||||
|
reset: VoidFunction;
|
||||||
|
type: typeof ResetType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Reset<T extends ResetOptions> = Replace<
|
||||||
|
T & BaseReset,
|
||||||
|
{
|
||||||
|
thingsToReset: GetComputableType<T["thingsToReset"]>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericReset = Reset<ResetOptions>;
|
||||||
|
|
||||||
|
export function createReset<T extends ResetOptions>(
|
||||||
|
optionsFunc: OptionsFunc<T, BaseReset, GenericReset>
|
||||||
|
): Reset<T> {
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const reset = optionsFunc();
|
||||||
|
reset.id = getUniqueID("reset-");
|
||||||
|
reset.type = ResetType;
|
||||||
|
|
||||||
|
reset.reset = function () {
|
||||||
|
const handleObject = (obj: unknown) => {
|
||||||
|
if (obj != null && typeof obj === "object") {
|
||||||
|
if (DefaultValue in obj) {
|
||||||
|
const persistent = obj as NonPersistent;
|
||||||
|
persistent.value = persistent[DefaultValue];
|
||||||
|
} else if (!(obj instanceof Decimal) && !isRef(obj)) {
|
||||||
|
Object.values(obj).forEach(obj =>
|
||||||
|
handleObject(obj as Record<string, unknown>)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
unref((reset as GenericReset).thingsToReset).forEach(handleObject);
|
||||||
|
globalBus.emit("reset", reset as GenericReset);
|
||||||
|
reset.onReset?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
processComputable(reset as T, "thingsToReset");
|
||||||
|
|
||||||
|
return reset as unknown as Reset<T>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners: Record<string, Unsubscribe | undefined> = {};
|
||||||
|
export function trackResetTime(layer: BaseLayer, reset: GenericReset): Persistent<Decimal> {
|
||||||
|
const resetTime = persistent<Decimal>(new Decimal(0));
|
||||||
|
globalBus.on("addLayer", layerBeingAdded => {
|
||||||
|
if (layer.id === layerBeingAdded.id) {
|
||||||
|
listeners[layer.id]?.();
|
||||||
|
listeners[layer.id] = layer.on("preUpdate", diff => {
|
||||||
|
resetTime.value = Decimal.add(resetTime.value, diff);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
globalBus.on("reset", currentReset => {
|
||||||
|
if (currentReset === reset) {
|
||||||
|
resetTime.value = new Decimal(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return resetTime;
|
||||||
|
}
|
||||||
|
globalBus.on("removeLayer", layer => {
|
||||||
|
// unsubscribe from preUpdate
|
||||||
|
listeners[layer.id]?.();
|
||||||
|
listeners[layer.id] = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
declare module "game/events" {
|
||||||
|
interface GlobalEvents {
|
||||||
|
reset: (reset: GenericReset) => void;
|
||||||
|
}
|
||||||
|
}
|
58
src/features/resources/MainDisplay.vue
Normal file
58
src/features/resources/MainDisplay.vue
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
<template>
|
||||||
|
<Sticky>
|
||||||
|
<div
|
||||||
|
class="main-display-container"
|
||||||
|
:class="classes ?? {}"
|
||||||
|
:style="[{ height: `${(effectRef?.$el.clientHeight ?? 0) + 50}px` }, style ?? {}]"
|
||||||
|
>
|
||||||
|
<div class="main-display">
|
||||||
|
<span v-if="showPrefix">You have </span>
|
||||||
|
<ResourceVue :resource="resource" :color="color || 'white'" />
|
||||||
|
{{ resource.displayName
|
||||||
|
}}<!-- remove whitespace -->
|
||||||
|
<span v-if="effectComponent"
|
||||||
|
>, <component :is="effectComponent" ref="effectRef"
|
||||||
|
/></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Sticky>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<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";
|
||||||
|
|
||||||
|
const _props = defineProps<{
|
||||||
|
resource: Resource;
|
||||||
|
color?: string;
|
||||||
|
classes?: Record<string, boolean>;
|
||||||
|
style?: StyleValue;
|
||||||
|
effectDisplay?: CoercableComponent;
|
||||||
|
}>();
|
||||||
|
const props = toRefs(_props);
|
||||||
|
|
||||||
|
const effectRef = ref<ComponentPublicInstance | null>(null);
|
||||||
|
|
||||||
|
const effectComponent = computeOptionalComponent(
|
||||||
|
props.effectDisplay as Ref<CoercableComponent | undefined>
|
||||||
|
);
|
||||||
|
|
||||||
|
const showPrefix = computed(() => {
|
||||||
|
return Decimal.lt(props.resource.value, "1e1000");
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.main-display-container {
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
transition-duration: 0s;
|
||||||
|
}
|
||||||
|
</style>
|
18
src/features/resources/Resource.vue
Normal file
18
src/features/resources/Resource.vue
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
<template>
|
||||||
|
<h2 :style="{ color, 'text-shadow': '0px 0px 10px ' + color }">
|
||||||
|
{{ amount }}
|
||||||
|
</h2>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Resource } from "features/resources/resource";
|
||||||
|
import { displayResource } from "features/resources/resource";
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
resource: Resource;
|
||||||
|
color: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const amount = computed(() => displayResource(props.resource));
|
||||||
|
</script>
|
153
src/features/resources/resource.ts
Normal file
153
src/features/resources/resource.ts
Normal file
|
@ -0,0 +1,153 @@
|
||||||
|
import { globalBus } from "game/events";
|
||||||
|
import { NonPersistent, Persistent, State } from "game/persistence";
|
||||||
|
import { persistent } from "game/persistence";
|
||||||
|
import player from "game/player";
|
||||||
|
import settings from "game/settings";
|
||||||
|
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 { computed, isRef, ref, unref, watch } from "vue";
|
||||||
|
|
||||||
|
export interface Resource<T = DecimalSource> extends Ref<T> {
|
||||||
|
displayName: string;
|
||||||
|
precision: number;
|
||||||
|
small?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createResource<T extends State>(
|
||||||
|
defaultValue: T,
|
||||||
|
displayName?: string,
|
||||||
|
precision?: number,
|
||||||
|
small?: boolean | undefined
|
||||||
|
): Resource<T> & Persistent<T> & { [NonPersistent]: Resource<T> };
|
||||||
|
export function createResource<T extends State>(
|
||||||
|
defaultValue: Ref<T>,
|
||||||
|
displayName?: string,
|
||||||
|
precision?: number,
|
||||||
|
small?: boolean | undefined
|
||||||
|
): Resource<T>;
|
||||||
|
export function createResource<T extends State>(
|
||||||
|
defaultValue: T | Ref<T>,
|
||||||
|
displayName = "points",
|
||||||
|
precision = 0,
|
||||||
|
small: boolean | undefined = undefined
|
||||||
|
) {
|
||||||
|
const resource: Partial<Resource<T>> = isRef(defaultValue)
|
||||||
|
? defaultValue
|
||||||
|
: persistent(defaultValue);
|
||||||
|
resource.displayName = displayName;
|
||||||
|
resource.precision = precision;
|
||||||
|
resource.small = small;
|
||||||
|
if (!isRef(defaultValue)) {
|
||||||
|
const nonPersistentResource = (resource as Persistent<T>)[
|
||||||
|
NonPersistent
|
||||||
|
] as unknown as Resource<T>;
|
||||||
|
nonPersistentResource.displayName = displayName;
|
||||||
|
nonPersistentResource.precision = precision;
|
||||||
|
nonPersistentResource.small = small;
|
||||||
|
}
|
||||||
|
return resource as Resource<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackBest(resource: Resource): Ref<DecimalSource> {
|
||||||
|
const best = persistent(resource.value);
|
||||||
|
watch(resource, amount => {
|
||||||
|
if (loadingSave.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Decimal.gt(amount, best.value)) {
|
||||||
|
best.value = amount;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackTotal(resource: Resource): Ref<DecimalSource> {
|
||||||
|
const total = persistent(resource.value);
|
||||||
|
watch(resource, (amount, prevAmount) => {
|
||||||
|
if (loadingSave.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Decimal.gt(amount, prevAmount)) {
|
||||||
|
total.value = Decimal.add(total.value, Decimal.sub(amount, prevAmount));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tetra8 = new Decimal("10^^8");
|
||||||
|
const e100 = new Decimal("1e100");
|
||||||
|
export function trackOOMPS(
|
||||||
|
resource: Resource,
|
||||||
|
pointGain?: ComputedRef<DecimalSource>
|
||||||
|
): Ref<string> {
|
||||||
|
const oomps = ref<DecimalSource>(0);
|
||||||
|
const oompsMag = ref(0);
|
||||||
|
const lastPoints = ref<DecimalSource>(0);
|
||||||
|
|
||||||
|
globalBus.on("update", diff => {
|
||||||
|
oompsMag.value = 0;
|
||||||
|
if (Decimal.lte(resource.value, e100)) {
|
||||||
|
lastPoints.value = resource.value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let curr = resource.value;
|
||||||
|
let prev = lastPoints.value;
|
||||||
|
lastPoints.value = curr;
|
||||||
|
if (Decimal.gt(curr, prev)) {
|
||||||
|
if (Decimal.gte(curr, tetra8)) {
|
||||||
|
curr = Decimal.slog(curr, 1e10);
|
||||||
|
prev = Decimal.slog(prev, 1e10);
|
||||||
|
oomps.value = curr.sub(prev).div(diff);
|
||||||
|
oompsMag.value = -1;
|
||||||
|
} else {
|
||||||
|
while (
|
||||||
|
Decimal.div(curr, prev).log(10).div(diff).gte("100") &&
|
||||||
|
oompsMag.value <= 5 &&
|
||||||
|
Decimal.gt(prev, 0)
|
||||||
|
) {
|
||||||
|
curr = Decimal.log10(curr);
|
||||||
|
prev = Decimal.log10(prev);
|
||||||
|
oomps.value = curr.sub(prev).div(diff);
|
||||||
|
oompsMag.value++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const oompsString = computed(() => {
|
||||||
|
if (oompsMag.value === 0) {
|
||||||
|
return pointGain
|
||||||
|
? format(pointGain.value, resource.precision, resource.small) +
|
||||||
|
" " +
|
||||||
|
resource.displayName +
|
||||||
|
"/s"
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
format(oomps.value) +
|
||||||
|
" OOM" +
|
||||||
|
(oompsMag.value < 0 ? "^OOM" : "^" + oompsMag.value) +
|
||||||
|
"s/sec"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return oompsString;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayResource(resource: Resource, overrideAmount?: DecimalSource): string {
|
||||||
|
const amount = overrideAmount ?? resource.value;
|
||||||
|
if (Decimal.eq(resource.precision, 0)) {
|
||||||
|
return formatWhole(resource.small ? amount : Decimal.floor(amount));
|
||||||
|
}
|
||||||
|
return format(amount, resource.precision, resource.small);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unwrapResource(resource: ProcessedComputable<Resource>): Resource {
|
||||||
|
if ("displayName" in resource) {
|
||||||
|
return resource;
|
||||||
|
}
|
||||||
|
return unref(resource);
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue