Compare commits

...

No commits in common. "main" and "gh-pages" have entirely different histories.

173 changed files with 39 additions and 42743 deletions

View file

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

View file

@ -1,50 +0,0 @@
require("@rushstack/eslint-patch/modern-module-resolution");
module.exports = {
root: true,
env: {
node: true
},
parser: '@typescript-eslint/parser',
plugins: ["@typescript-eslint"],
overrides: [
{
files: ['*.ts', '*.tsx'],
extends: [
"plugin:vue/vue3-essential",
"@vue/eslint-config-typescript/recommended",
"@vue/eslint-config-prettier"
],
parserOptions: {
ecmaVersion: 2020,
project: "./tsconfig.json"
},
}
],
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
}
],
"eqeqeq": [
"error",
"always",
{
"null": "never"
}
]
},
globals: {
defineProps: "readonly",
defineEmits: "readonly"
}
};

View file

@ -1,33 +0,0 @@
name: Build and Deploy
on:
push:
branches:
- 'main'
workflow_dispatch:
jobs:
build-and-deploy:
if: github.repository != 'profectus-engine/Profectus' # Don't build placeholder mod on main repo
runs-on: docker
container:
image: node:21-bullseye
steps:
- name: Setup RSync
run: |
apt-get update
apt-get install -y rsync
- name: Checkout 🛎️
uses: actions/checkout@v2
with:
submodules: recursive
- name: Install and Build 🔧 # This example project is built using npm and outputs the result to the 'build' folder. Replace with the commands required to build your project, or remove this step entirely if your site is pre-built.
run: |
npm ci
npm run build
- name: Deploy 🚀
uses: https://github.com/JamesIves/github-pages-deploy-action@v4.2.5
with:
branch: pages # The branch the action should deploy to.
folder: dist # The folder the action should deploy.

View file

@ -1,20 +0,0 @@
name: Run Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: docker
container:
image: node:21-bullseye
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- run: npm ci
- run: npm run build --if-present
- run: npm test
- run: npm run lint

View file

@ -1,26 +0,0 @@
name: Build and Deploy
on:
push:
branches:
- 'main'
workflow_dispatch:
jobs:
build-and-deploy:
if: github.repository != 'profectus-engine/Profectus' # Don't build placeholder mod on main repo
runs-on: 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.

View file

@ -1,22 +0,0 @@
name: Run Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Use Node.js 21.x
uses: actions/setup-node@v3
with:
node-version: 21.x
- run: npm ci
- run: npm run build --if-present
- run: npm test
- run: npm run lint

23
.gitignore vendored
View file

@ -1,23 +0,0 @@
.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?

View file

@ -1,7 +0,0 @@
{
"arrowParens": "avoid",
"endOfLine": "auto",
"printWidth": 100,
"tabWidth": 4,
"trailingComma": "none"
}

13
.replit
View file

@ -1,13 +0,0 @@
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
View file

@ -1,23 +0,0 @@
{
// 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
View file

@ -1,12 +0,0 @@
{
"vitest.commandLine": "npx vitest",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"git.ignoreLimitWarning": true,
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"typescript.tsdk": "node_modules/typescript/lib"
}

View file

@ -1,495 +0,0 @@
# 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.7.0] - 2024-12-31
### Additions
- Added modal to take a mental health break (can be disabled via projInfo.json)
- Added `ConversionType` symbol
- Added `isType` function that uses a type symbol to check
- Added `MaybeGetter` utility type for something that may be a getter function or a static value (but not a ref)
### Changes
- **BREAKING** Replaced Board feature with generic Board system that works with SVG and DOM elements
- **BREAKING** Rewrote how features are written, simplifying them greatly
- **BREAKING** Replaced decorators with mixins and wrappers
- **BREAKING** Moved modals to `src/components/modals`
- **BREAKING** Updated a very large amount of dependencies, making any necessary adjustments
- **BREAKING** Removed Grid component
- **BREAKING** `dontMerge` is now a property on rows and columns rather than an undocumented css class you'd have to include on every feature within the row or column
- **BREAKING** Moved all features that use the clickable component into the clickable folder
- **BREAKING** Removed small property from clickable, since its a single css rule (min-height: unset)
- **BREAKING** Removed `setDefault`, just use `??=`
- **BREAKING** Made Achievement.vue use a Renderable for the display. The object of components can still be passed to createAchievement
- **BREAKING** Made Challenge.vue use a Renderable for the display. The object of components can still be passed to createChallenge
- Upgrades now use the clickable component
### Fixes
- Hotkey descriptions were not being wrapped in `unref`
- Links wouldn't check if the end node existed when determining valid links
- `forceHideGoBack` was not being respected
- Saves manager not being imported in addiction warning component
Contributors: thepaperpilot
## [0.6.2] - 2024-04-01
### Added
- Export save button in error boundaries
- isRendered utility function
- Automatic galaxy.click cloud saves support
- Support for null and undefined in persistent refs
### Changes
- round, floor, ceil, trunc, and add now invert as no-ops
- "The Paper Pilot Community" renamed to "Profectus & Friends"
- Updated CI etc. to work with Forgejo
- Improved modifier typing
- Rename `printFormula` to `Formula.stringify`
### Fixed
- Hotkeys not working correctly with most combinations of modifiers
- Reset button using `currentAt` when not gaining
- Formulas not using modifiers that are disabled initially
- branchedResetPropagation logic being incorrect
- Fixed default elementsd in the main layer not updating Context when being added or removed
- Board links props not working in camelCase
- Board links absorbing pointer events
- Thrown errors not appearing in console
- Disabled elements would eat mouse events
- Fixed cost requirement without formula counting as being able to afford infinite purchases rather than just one
- Pinnable tooltips causing innocuous console error
- Bars with direction as "Left" wouldn't appear correctly
### Documentation
- Clarified expected progress values for board nodes
- Added CONTRIBUTING.md and enforce eslint on all PRs
### Tests
- Update formula test cases
- Tree reset propagation
Contributors: thepaperpilot, escapee, nif
## [0.6.1] - 2023-05-17
### Added
- Error boundaries around each layer, and errors now display on the page when in development
- Utility for creating requirement based on whether a conversion has met a requirement
### Changed
- **BREAKING** Formulas/requirements refactor
- spendResources renamed to cumulativeCost
- summedPurchases renamed to directSum
- calculateMaxAffordable now takes optional 'maxBulkAmount' parameter
- cost requirements now pass cumulativeCost, maxBulkAmount, and directSum to calculateMaxAffordable
- Non-integrable and non-invertible formulas will now work in more situations
- Repeatable.maximize is removed
- Challenge.maximize is removed
- Formulas have better typing information now
- Integrate functions now log errors if the variable input is not integrable
- Cyclical proxies now throw errors
- createFormulaPreview is now a JSX function
- Tree nodes are not automatically capitalized anymore
- upgrade.canPurchase now returns false if the upgrade is already bought
- TPS display is simplified and more performant now
### Fixed
- Actions could not be constructed
- Progress bar on actions was misaligned
- Many different issues the Board features (and many changes/improvements)
- Calculating max affordable could sometimes infinite loop
- Non-integrable formulas could cause errors in cost requirements
- estimateTime would not show "never" when production is 0
- isInvertible and isIntegrable now properly handle nested formulas
- Repeatables' amount display would show the literal text "joinJSX"
- Repeatables would not buy max properly
- Reset buttons were showing wrong "currentAt" vs "nextAt"
- Step-wise formulas not updating their value correctly
- Bonus amount decorator now checks for `amount` property in the post construct callback
### Documentation
- Various typos fixed and a few sections made more thorough
## [0.6.0] - 2023-04-20
### Added
- **BREAKING** New requirements system
- Replaces many features' existing requirements with new generic form
- **BREAKING** Formulas, which can be used to calculate buy max for you
- Requirements can use them so repeatables and challenges can be "buy max" without any extra effort
- Conversions now use formulas instead of the old scaling functions system, allowing for arbitrary functions that are much easier to follow
- Modifiers have a new getFormula property
- Feature decorators, which simplify the process of adding extra values to features
- Action feature, which is a clickable with a cooldown
- ETA util (calculates time until a specific amount of a resource, based on its current gain rate)
- createCollapsibleAchievements util
- deleteLowerSaves util
- Minimized layers can now display a component
- submitOnBlur property to Text fields
- showPopups property to achievements
- Mouse/touch events to more onClick listeners
- Example hotkey to starting layer
- Schema for projInfo.json
### Changes
- **BREAKING** Buyables renamed to Repeatables
- Renamed purchaseLimit to limit
- Renamed buyMax to maximize
- Added initialAmount property
- **BREAKING** Persistent refs no longer have redundancies in save object
- Requires referencing persistent refs either through a proxy or by wrapping in `noPersist()`
- **BREAKING** Visibility properties can now take booleans
- Removed showIf util
- **BREAKING** Lazy proxies and options functions now pass the base object in as `this` as well as the first parameter.
- Tweaked settings display
- setupPassiveGeneration will no longer lower the resource
- displayResource now floors resource amounts
- Tweaked modifier displays, incl showing negative modifiers in red
- Hotkeys now appear on key graphic
- Mofifier sections now accept computable strings for title and subtitle
- Every VueFeature's `[Component]` property is now typed as GenericComponent
- Make errors throw objects instead of strings
- Updated b_e
### Fixed
- NaN detection stopped working
- Now specifically only checks persistent refs
- trackTotal would increase the total when loading the save
- PWAs wouldn't show updates
- Board feature no longer working at all
- Some discord links didn't open in new tab
- Adjacent grid cells wouldn't merge
- When fixing old saves, the modVersion would not be updated
- Default layer would display `Dev Speed: 0x` when paused
- Fixed hotkeys not working with shift + numbers
- Fixed console errors about deleted persistent refs not being included in the layer object
- Modifiers wouldn't display small numbers
- Conversions' addSoftcap wouldn't affect currentAt or nextAt
- MainDisplay not respecting style and classes props
- Tabs could sometimes not update correctly
- offlineTime not capping properly
- Tooltips being user-selectable
- Pinnable tooltips causing stack overflow
- Workflows not working with submodules
- Various minor typing issues
### Removed
- **BREAKING** Removed milestones (achievements now have small and large displays)
### Documented
- every single feature
- formulas
- requirements
### Tests
- conversions
- formulas
- modifiers
- requirements
Contributors: thepaperpilot, escapee, adsaf, ducdat
## [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

View file

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

21
LICENSE
View file

@ -1,21 +0,0 @@
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.

BIN
Logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -1,32 +0,0 @@
# 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
```
### Runs the tests using vite-jest
```
npm run test
```

View file

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View file

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

View file

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

1
css/app.ac3eef8b.css Normal file

File diff suppressed because one or more lines are too long

BIN
favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

BIN
favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 873 B

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -1,25 +1 @@
<!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">
<link rel="mask-icon" href="/favicon.svg" color="#2E3440">
<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>
<!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"><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=Roboto+Mono&family=Kalam&display=swap" rel="stylesheet"><link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"><link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png"><link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png"><link rel="manifest" href="/site.webmanifest"><title>profectus</title><script defer="defer" type="module" src="js/chunk-vendors.e1e0e06d.js"></script><script defer="defer" type="module" src="js/app.0ae1f71d.js"></script><link href="css/app.ac3eef8b.css" rel="stylesheet"><script defer="defer" src="js/chunk-vendors-legacy.fc4e402a.js" nomodule></script><script defer="defer" src="js/app-legacy.c9c8b381.js" nomodule></script></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></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
js/app.0ae1f71d.js Normal file

File diff suppressed because one or more lines are too long

1
js/app.0ae1f71d.js.map Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

7847
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,65 +0,0 @@
{
"name": "profectus",
"version": "0.7.0",
"private": true,
"type": "module",
"scripts": {
"start": "vite",
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vitest run",
"testw": "vitest",
"serve": "vite preview --host",
"lint": "eslint src --max-warnings 0",
"lint:fix": "eslint --fix --max-warnings 0 src"
},
"dependencies": {
"@fontsource/material-icons": "^5.1.0",
"@fontsource/roboto-mono": "^5.1.0",
"@pixi/app": "^6.5.10",
"@pixi/constants": "~6.5.10",
"@pixi/core": "^6.5.10",
"@pixi/display": "~6.5.10",
"@pixi/math": "~6.5.10",
"@pixi/particle-emitter": "^5.0.7",
"@pixi/sprite": "~6.5.10",
"@pixi/ticker": "~6.5.10",
"@vitejs/plugin-vue": "^5.1.4",
"@vitejs/plugin-vue-jsx": "^4.0.1",
"is-plain-object": "^5.0.0",
"lz-string": "^1.5.0",
"nanoevents": "^9.0.0",
"unofficial-galaxy-sdk": "git+https://code.incremental.social/thepaperpilot/unofficial-galaxy-sdk.git#1.0.1",
"vite": "^5.1.8",
"vite-plugin-pwa": "^0.20.5",
"vite-tsconfig-paths": "^4.3.0",
"vue": "^3.5.13",
"vue-next-select": "^2.10.5",
"vue-panzoom": "https://github.com/thepaperpilot/vue-panzoom.git",
"vue-textarea-autosize": "^1.1.1",
"vue-toastification": "^2.0.0-rc.5",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@ivanv/vue-collapse-transition": "^1.0.2",
"@rushstack/eslint-patch": "^1.7.2",
"@types/lz-string": "^1.5.0",
"@types/node": "^22.7.6",
"@typescript-eslint/parser": "^7.2.0",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^13.0.0",
"eslint": "^8.57.0",
"jsdom": "^24.0.0",
"prettier": "^3.2.5",
"typescript": "~5.5.4",
"vitest": "^1.4.0",
"vue-tsc": "^2.0.6"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "^4.24.0"
},
"engines": {
"node": "21.x"
}
}

View file

@ -1,24 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -1,2 +0,0 @@
User-agent: *
Allow: /

View file

@ -1,7 +0,0 @@
{ pkgs }: {
deps = [
pkgs.nodejs-16_x
pkgs.nodePackages.typescript-language-server
pkgs.nodePackages.npm
];
}

View file

1
site.webmanifest Normal file
View file

@ -0,0 +1 @@
{"name":"Profectus","short_name":"Profectus","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#2E3440","background_color":"#2E3440","display":"standalone"}

View file

@ -1,74 +0,0 @@
<template>
<div v-if="appErrors.length > 0" class="error-container" :style="theme">
<Error :errors="appErrors" />
</div>
<template v-else>
<div id="modal-root" :style="theme" />
<div class="app" :style="theme" :class="{ useHeader }">
<Nav v-if="useHeader" />
<Game />
<TPS v-if="unref(showTPS)" />
<AddictionWarning />
<GameOverScreen />
<NaNScreen />
<CloudSaveResolver />
<GameComponent />
</div>
</template>
</template>
<script setup lang="tsx">
import "@fontsource/roboto-mono";
import Error from "components/Error.vue";
import AddictionWarning from "components/modals/AddictionWarning.vue";
import CloudSaveResolver from "components/modals/CloudSaveResolver.vue";
import GameOverScreen from "components/modals/GameOverScreen.vue";
import NaNScreen from "components/modals/NaNScreen.vue";
import state from "game/state";
import { render } from "util/vue";
import type { CSSProperties } from "vue";
import { computed, toRef, unref } from "vue";
import Game from "./components/Game.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";
const useHeader = projInfo.useHeader;
const theme = computed(() => themes[settings.theme].variables as CSSProperties);
const showTPS = toRef(settings, "showTPS");
const appErrors = toRef(state, "errors");
const GameComponent = () => gameComponents.map(c => render(c));
</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%;
color: var(--foreground);
}
.error-container {
background: var(--background);
overflow: auto;
width: 100%;
height: 100%;
}
.error-container > .error {
position: static;
}
</style>

View file

@ -1,89 +0,0 @@
<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>

View file

@ -1,135 +0,0 @@
<template>
<div class="error">
<h1 class="error-title">{{ firstError.name }}: {{ firstError.message }}</h1>
<div class="error-details" style="margin-top: -10px">
<div v-if="firstError.cause">
<div v-for="row in causes[0]" :key="row">{{ row }}</div>
</div>
<div v-if="firstError.stack" :style="firstError.cause ? 'margin-top: 10px' : ''">
<div v-for="row in stacks[0]" :key="row">{{ row }}</div>
</div>
</div>
<div class="instructions">
Check the console for more details, and consider sharing it with the developers on
<a :href="projInfo.discordLink || 'https://discord.gg/yJ4fjnjU54'" class="discord-link"
>discord</a
>!
<FeedbackButton @click="exportSave" class="button" style="display: inline-flex"
><span class="material-icons" style="font-size: 16px">content_paste</span
><span style="margin-left: 8px; font-size: medium">Copy Save</span></FeedbackButton
><br />
<div v-if="errors.length > 1" style="margin-top: 20px"><h3>Other errors</h3></div>
<div v-for="(error, i) in errors.slice(1)" :key="i" style="margin-top: 20px">
<details class="error-details">
<summary>{{ error.name }}: {{ error.message }}</summary>
<div v-if="error.cause" style="margin-top: 10px">
<div v-for="row in causes[i + 1]" :key="row">{{ row }}</div>
</div>
<div v-if="error.stack" style="margin-top: 10px">
<div v-for="row in stacks[i + 1]" :key="row">{{ row }}</div>
</div>
</details>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import projInfo from "data/projInfo.json";
import player, { stringifySave } from "game/player";
import LZString from "lz-string";
import { computed, onMounted } from "vue";
import FeedbackButton from "./fields/FeedbackButton.vue";
const props = defineProps<{
errors: Error[];
}>();
const firstError = computed(() => props.errors[0]);
const stacks = computed(() =>
props.errors.map(error => (error.stack == null ? [] : error.stack.split("\n")))
);
const causes = computed(() =>
props.errors.map(error =>
error.cause == null
? []
: (typeof error.cause === "string" ? error.cause : JSON.stringify(error.cause)).split(
"\n"
)
)
);
function exportSave() {
let saveToExport = stringifySave(player);
switch (projInfo.exportEncoding) {
default:
console.warn(`Unknown save encoding: ${projInfo.exportEncoding}. Defaulting to lz`);
case "lz":
saveToExport = LZString.compressToUTF16(saveToExport);
break;
case "base64":
saveToExport = btoa(unescape(encodeURIComponent(saveToExport)));
break;
case "plain":
break;
}
console.log(saveToExport);
// Put on clipboard. Using the clipboard API asks for permissions and stuff
const el = document.createElement("textarea");
el.value = saveToExport;
document.body.appendChild(el);
el.select();
el.setSelectionRange(0, 99999);
document.execCommand("copy");
document.body.removeChild(el);
}
onMounted(() => {
player.autosave = false;
player.devSpeed = 0;
});
</script>
<style scoped>
.error {
border: solid 10px var(--danger);
position: absolute;
top: 0;
left: 0;
right: 0;
text-align: left;
min-height: calc(100% - 20px);
text-align: left;
color: var(--foreground);
}
.error-title {
background: var(--danger);
color: var(--feature-foreground);
display: block;
margin: -10px 0 10px 0;
position: sticky;
top: 0;
}
.error-details {
white-space: nowrap;
overflow: auto;
padding: 10px;
background-color: var(--raised-background);
}
.instructions {
padding: 10px;
}
.discord-link {
display: inline;
}
summary {
cursor: pointer;
user-select: none;
}
</style>

View file

@ -1,108 +0,0 @@
<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">
<LayerVue
v-if="layerKeys.includes(tab)"
v-bind="gatherLayerProps(layers[tab])"
:index="index"
@set-minimized="(value: boolean) => (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 Layer, layers } from "game/layers";
import player from "game/player";
import { computed, toRef, unref } from "vue";
import LayerVue from "./Layer.vue";
import Nav from "./Nav.vue";
const tabs = toRef(player, "tabs");
const layerKeys = computed(() => Object.keys(layers));
const useHeader = projInfo.useHeader;
function gatherLayerProps(layer: Layer) {
const {
display,
name,
color,
minimizable,
minimizedDisplay,
minimized,
nodes,
forceHideGoBack
} = layer;
return {
display,
name,
color,
minimizable,
minimizedDisplay,
minimized,
nodes,
forceHideGoBack
};
}
</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>

View file

@ -1,70 +0,0 @@
<!-- 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 { Hotkey } from "features/hotkey";
import { watchEffect } from "vue";
const props = defineProps<{
hotkey: Hotkey;
}>();
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>

View file

@ -1,185 +0,0 @@
<template>
<ErrorVue v-if="errors.length > 0" :errors="errors" />
<div class="layer-container" :style="{ '--layer-color': unref(color) }" v-bind="$attrs" v-else>
<button v-if="showGoBack" class="goBack" @click="goBack"></button>
<button
class="layer-tab minimized"
v-if="unref(minimized)"
@click="$emit('setMinimized', false)"
>
<MinimizedComponent v-if="minimizedDisplay" />
<div v-else>{{ unref(name) }}</div>
</button>
<div class="layer-tab" :class="{ showGoBack }" v-else>
<Context @update-nodes="updateNodes">
<Component />
</Context>
</div>
<button v-if="unref(minimizable)" class="minimize" @click="$emit('setMinimized', true)">
</button>
</div>
</template>
<script setup lang="ts">
import projInfo from "data/projInfo.json";
import { type FeatureNode } from "game/layers";
import player from "game/player";
import { MaybeGetter } from "util/computed";
import { render, Renderable } from "util/vue";
import { computed, MaybeRef, onErrorCaptured, Ref, ref, unref } from "vue";
import Context from "./Context.vue";
import ErrorVue from "./Error.vue";
const props = defineProps<{
display: MaybeGetter<Renderable>;
minimizedDisplay?: MaybeGetter<Renderable>;
minimized: Ref<boolean>;
name?: MaybeRef<string>;
color?: MaybeRef<string>;
minimizable?: MaybeRef<boolean>;
nodes: Ref<Record<string, FeatureNode | undefined>>;
forceHideGoBack?: MaybeRef<boolean>;
index: number;
}>();
const Component = () => render(props.display);
const MinimizedComponent = () => props.minimizedDisplay == null ? undefined : render(props.minimizedDisplay);
const showGoBack = computed(
() => projInfo.allowGoBack && !unref(props.forceHideGoBack) && props.index > 0 && !unref(props.minimized)
);
function goBack() {
player.tabs.splice(unref(props.index), Infinity);
}
function updateNodes(nodes: Record<string, FeatureNode | undefined>) {
props.nodes.value = nodes;
}
const errors = ref<Error[]>([]);
onErrorCaptured((err, instance, info) => {
console.warn(`Error caught in "${props.name}" layer`, err, instance, info);
errors.value.push(
err instanceof Error ? (err as Error) : new Error(JSON.stringify(err))
);
return false;
});
</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>

View file

@ -1,298 +0,0 @@
<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/yJ4fjnjU54" target="_blank">Profectus & Friends</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" :class="{ needsSync }">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" :class="{ needsSync }">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/yJ4fjnjU54" target="_blank">Profectus & Friends</a>
</li>
<li>
<a href="https://discord.gg/F3xveHV" target="_blank">The Modding Tree</a>
</li>
</ul>
</div>
</div>
<Info ref="info" @open-changelog="changelog?.open()" />
<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 settings from "game/settings";
import { Direction } from "util/common";
import { galaxy, syncedSaves } from "util/galaxy";
import { computed, ref } from "vue";
import Tooltip from "wrappers/tooltips/Tooltip.vue";
import Info from "./modals/Info.vue";
import Options from "./modals/Options.vue";
import SavesManager from "./modals/SavesManager.vue";
const info = ref<typeof Info | null>(null);
const savesManager = ref<typeof SavesManager | null>(null);
const options = ref<typeof Options | null>(null);
const changelog = ref<typeof Changelog | null>(null);
const { useHeader, banner, title, discordName, discordLink, versionNumber } = projInfo;
function openDiscord() {
window.open(discordLink, "mywindow");
}
const needsSync = computed(
() => galaxy.value?.loggedIn === true && !syncedSaves.value.includes(settings.active)
);
</script>
<style scoped>
.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;
}
.needsSync {
color: var(--danger);
animation: 4s wiggle ease infinite;
}
@keyframes wiggle {
0% {
transform: rotate(-3deg);
box-shadow: 0 2px 2px #0003;
}
5% {
transform: rotate(20deg);
}
10% {
transform: rotate(-15deg);
}
15% {
transform: rotate(5deg);
}
20% {
transform: rotate(-1deg);
}
25% {
transform: rotate(0);
box-shadow: 0 2px 2px #0003;
}
}
</style>

View file

@ -1,40 +0,0 @@
<template>
<div class="node" ref="node"></div>
</template>
<script setup lang="ts">
import { RegisterNodeInjectionKey, UnregisterNodeInjectionKey } from "game/layers";
import { computed, inject, onUnmounted, shallowRef, toRef, unref, watch } from "vue";
const props = defineProps<{ id: string }>();
// 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, toRef(props, "id")], ([newNode, newID], [prevNode, prevID]) => {
if (prevNode) {
unregister(unref(prevID));
}
if (newNode) {
register(newID, newNode);
}
});
onUnmounted(() => unregister(props.id));
</script>
<style scoped>
.node {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
</style>

View file

@ -1,43 +0,0 @@
<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>

View file

@ -1,195 +0,0 @@
<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>

View file

@ -1,33 +0,0 @@
<template>
<div class="tpsDisplay" v-if="!tps.isNan()">TPS: {{ formatWhole(tps) }}</div>
</template>
<script setup lang="ts">
import state from "game/state";
import Decimal, { formatWhole } from "util/bignum";
import { computed } from "vue";
const tps = computed(() =>
Decimal.div(
state.lastTenTicks.length,
state.lastTenTicks.reduce((acc, curr) => acc + curr, 0)
)
);
</script>
<style scoped>
.tpsDisplay {
position: absolute;
left: 10px;
bottom: 10px;
z-index: 100;
}
.low {
color: var(--danger);
}
.fade-leave-to {
opacity: 0;
}
</style>

View file

@ -1,45 +0,0 @@
.feature {
position: relative;
}
button.feature,
.feature button {
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;
}
.feature button {
position: relative;
}
button.can,
.can button {
background-color: var(--layer-color);
cursor: pointer;
}
button.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;
}
button.locked,
.locked button {
background-color: var(--locked);
cursor: not-allowed;
}
button.bought,
.bought button {
background-color: var(--bought);
cursor: default;
}

View file

@ -1,13 +0,0 @@
.field {
display: flex;
position: relative;
min-height: 2em;
margin: 10px 0;
user-select: none;
justify-content: space-between;
align-items: center;
}
.field > * {
margin: 0;
}

View file

@ -1,21 +0,0 @@
.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;
}

View file

@ -1,176 +0,0 @@
.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;
}
.col {
display: flex;
flex-flow: column wrap;
justify-content: center;
align-items: center;
height: 100%;
margin: 10px 0;
}
.row.mergeAdjacent *,
.row.mergeAdjacent button.feature,
.row.mergeAdjacent .feature button {
margin-left: 0;
margin-right: 0;
}
.row.mergeAdjacent button.feature,
.row.mergeAdjacent .feature button {
border-radius: 0;
}
.row.mergeAdjacent > button.feature:first-child,
.row.mergeAdjacent > .feature:first-child button,
.row.mergeAdjacent > :first-child button.feature,
.row.mergeAdjacent > :first-child .feature button {
border-radius: var(--border-radius) 0 0 var(--border-radius);
}
.row.mergeAdjacent > button.feature:last-child,
.row.mergeAdjacent > .feature:last-child button,
.row.mergeAdjacent > :last-child button.feature,
.row.mergeAdjacent > :last-child .feature button {
border-radius: 0 var(--border-radius) var(--border-radius) 0;
}
.row.mergeAdjacent > button.feature:first-child:last-child,
.row.mergeAdjacent > .feature:first-child:last-child button,
.row.mergeAdjacent > :first-child:last-child button.feature,
.row.mergeAdjacent > :first-child:last-child .feature button {
border-radius: var(--border-radius);
}
.col.mergeAdjacent *,
.col.mergeAdjacent button.feature,
.col.mergeAdjacent .feature button {
margin-top: 0;
margin-bottom: 0;
}
.col.mergeAdjacent button.feature,
.col.mergeAdjacent .feature button {
border-radius: 0;
}
.col.mergeAdjacent > button.feature:first-child,
.col.mergeAdjacent > .feature:first-child button,
.col.mergeAdjacent > :first-child button.feature,
.col.mergeAdjacent > :first-child .feature button {
border-radius: var(--border-radius) var(--border-radius) 0 0;
}
.col.mergeAdjacent > button.feature:last-child,
.col.mergeAdjacent > .feature:last-child button,
.col.mergeAdjacent > :last-child button.feature,
.col.mergeAdjacent > :last-child .feature button {
border-radius: 0 0 var(--border-radius) var(--border-radius);
}
.col.mergeAdjacent > button.feature:first-child:last-child,
.col.mergeAdjacent > .feature:first-child:last-child button,
.col.mergeAdjacent > :first-child:last-child button.feature,
.col.mergeAdjacent > :first-child:last-child .feature button {
border-radius: var(--border-radius);
}
.col.mergeAdjacent > .table > .row.mergeAdjacent:first-child > button.feature:not(:first-child):not(:last-child),
.col.mergeAdjacent > .table > .row.mergeAdjacent:first-child > .feature:not(:first-child):not(:last-child) button,
.col.mergeAdjacent > .table > .row.mergeAdjacent:first-child > :not(:first-child):not(:last-child) button.feature,
.col.mergeAdjacent > .table > .row.mergeAdjacent:first-child > :not(:first-child):not(:last-child) .feature button,
.col.mergeAdjacent > .table > .row.mergeAdjacent:last-child > button.feature:not(:first-child):not(:last-child),
.col.mergeAdjacent > .table > .row.mergeAdjacent:last-child > .feature:not(:first-child):not(:last-child) button,
.col.mergeAdjacent > .table > .row.mergeAdjacent:last-child > :not(:first-child):not(:last-child) button.feature,
.col.mergeAdjacent > .table > .row.mergeAdjacent:last-child > :not(:first-child):not(:last-child) .feature button
.col.mergeAdjacent > .table:not(:first-child):not(:last-child) > .row.mergeAdjacent > button.feature,
.col.mergeAdjacent > .table:not(:first-child):not(:last-child) > .row.mergeAdjacent > .feature button,
.col.mergeAdjacent > .table:not(:first-child):not(:last-child) > .row.mergeAdjacent > * button.feature,
.col.mergeAdjacent > .table:not(:first-child):not(:last-child) > .row.mergeAdjacent > * .feature button
.row.mergeAdjacent > .table > .col.mergeAdjacent:first-child > button.feature:not(:first-child):not(:last-child),
.row.mergeAdjacent > .table > .col.mergeAdjacent:first-child > .feature:not(:first-child):not(:last-child) button,
.row.mergeAdjacent > .table > .col.mergeAdjacent:first-child > :not(:first-child):not(:last-child) button.feature,
.row.mergeAdjacent > .table > .col.mergeAdjacent:first-child > :not(:first-child):not(:last-child) .feature button,
.row.mergeAdjacent > .table > .col.mergeAdjacent:last-child > button.feature:not(:first-child):not(:last-child),
.row.mergeAdjacent > .table > .col.mergeAdjacent:last-child > .feature:not(:first-child):not(:last-child) button,
.row.mergeAdjacent > .table > .col.mergeAdjacent:last-child > :not(:first-child):not(:last-child) button.feature,
.row.mergeAdjacent > .table > .col.mergeAdjacent:last-child > :not(:first-child):not(:last-child) .feature button
.row.mergeAdjacent > .table:not(:first-child):not(:last-child) > .col.mergeAdjacent > button.feature,
.row.mergeAdjacent > .table:not(:first-child):not(:last-child) > .col.mergeAdjacent > .feature button,
.row.mergeAdjacent > .table:not(:first-child):not(:last-child) > .col.mergeAdjacent > * button.feature,
.row.mergeAdjacent > .table:not(:first-child):not(:last-child) > .col.mergeAdjacent > * .feature button {
border-radius: 0;
}
.col.mergeAdjacent > .table:first-child > .row.mergeAdjacent > button.feature:first-child,
.col.mergeAdjacent > .table:first-child > .row.mergeAdjacent > .feature:first-child button,
.col.mergeAdjacent > .table:first-child > .row.mergeAdjacent > :first-child button.feature,
.col.mergeAdjacent > .table:first-child > .row.mergeAdjacent > :first-child .feature button,
.row.mergeAdjacent > .table:first-child > .col.mergeAdjacent > button.feature:first-child,
.row.mergeAdjacent > .table:first-child > .col.mergeAdjacent > .feature:first-child button,
.row.mergeAdjacent > .table:first-child > .col.mergeAdjacent > :first-child button.feature,
.row.mergeAdjacent > .table:first-child > .col.mergeAdjacent > :first-child .feature button {
border-radius: var(--border-radius) 0 0 0;
}
.col.mergeAdjacent > .table:first-child > .row.mergeAdjacent > button.feature:last-child,
.col.mergeAdjacent > .table:first-child > .row.mergeAdjacent > .feature:last-child button,
.col.mergeAdjacent > .table:first-child > .row.mergeAdjacent > :last-child button.feature,
.col.mergeAdjacent > .table:first-child > .row.mergeAdjacent > :last-child .feature button,
.row.mergeAdjacent > .table:first-child > .col.mergeAdjacent > button.feature:last-child,
.row.mergeAdjacent > .table:first-child > .col.mergeAdjacent > .feature:last-child button,
.row.mergeAdjacent > .table:first-child > .col.mergeAdjacent > :last-child button.feature,
.row.mergeAdjacent > .table:first-child > .col.mergeAdjacent > :last-child .feature button {
border-radius: 0 var(--border-radius) 0 0;
}
.col.mergeAdjacent > .table:last-child > .row.mergeAdjacent > button.feature:last-child,
.col.mergeAdjacent > .table:last-child > .row.mergeAdjacent > .feature:last-child button,
.col.mergeAdjacent > .table:last-child > .row.mergeAdjacent > :last-child button.feature,
.col.mergeAdjacent > .table:last-child > .row.mergeAdjacent > :last-child .feature button,
.row.mergeAdjacent > .table:last-child > .col.mergeAdjacent > button.feature:last-child,
.row.mergeAdjacent > .table:last-child > .col.mergeAdjacent > .feature:last-child button,
.row.mergeAdjacent > .table:last-child > .col.mergeAdjacent > :last-child button.feature,
.row.mergeAdjacent > .table:last-child > .col.mergeAdjacent > :last-child .feature button {
border-radius: 0 0 var(--border-radius) 0;
}
.col.mergeAdjacent > .table:last-child > .row.mergeAdjacent > button.feature:first-child,
.col.mergeAdjacent > .table:last-child > .row.mergeAdjacent > .feature:first-child button,
.col.mergeAdjacent > .table:last-child > .row.mergeAdjacent > :first-child button.feature,
.col.mergeAdjacent > .table:last-child > .row.mergeAdjacent > :first-child .feature button,
.row.mergeAdjacent > .table:last-child > .col.mergeAdjacent > button.feature:first-child,
.row.mergeAdjacent > .table:last-child > .col.mergeAdjacent > .feature:first-child button,
.row.mergeAdjacent > .table:last-child > .col.mergeAdjacent > :first-child button.feature,
.row.mergeAdjacent > .table:last-child > .col.mergeAdjacent > :first-child .feature button {
border-radius: 0 0 0 var(--border-radius);
}

View file

@ -1,78 +0,0 @@
<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, watch } from "vue";
const props = defineProps<{
disabled?: boolean;
skipConfirm?: boolean;
}>();
const emit = defineEmits<{
(e: "click"): void;
(e: "confirmingChanged", value: boolean): void;
}>();
const isConfirming = ref(false);
watch(isConfirming, isConfirming => {
emit("confirmingChanged", isConfirming);
});
function click() {
if (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>

View file

@ -1,74 +0,0 @@
<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.Timeout | null>(null);
function click() {
emit("click");
// Give feedback to user
if (activatedTimeout.value != null) {
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>

View file

@ -1,97 +0,0 @@
<template>
<div class="field">
<span class="field-title" v-if="title"><Title /></span>
<VueNextSelect
:options="options"
v-model="value"
:min="1"
:placeholder="placeholder"
:close-on-select="closeOnSelect"
@update:model-value="onUpdate"
label-by="label"
/>
</div>
</template>
<script setup lang="tsx">
import "components/common/fields.css";
import { MaybeGetter } from "util/computed";
import { render, Renderable } from "util/vue";
import { ref, toRef, unref, watch } from "vue";
import VueNextSelect from "vue-next-select";
import "vue-next-select/dist/index.css";
export type SelectOption = { label: string; value: unknown };
const props = defineProps<{
title?: MaybeGetter<Renderable>;
modelValue?: unknown;
options: SelectOption[];
placeholder?: string;
closeOnSelect?: boolean;
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: unknown): void;
}>();
const Title = () => props.title ? render(props.title, el => <span>{el}</span>) : <></>;
const value = ref<SelectOption | null>(
props.options.find(option => option.value === props.modelValue) ?? null
);
watch(toRef(props, "modelValue"), modelValue => {
if (unref(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>

View file

@ -1,41 +0,0 @@
<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 "wrappers/tooltips/Tooltip.vue";
import { Direction } from "util/common";
import { computed } from "vue";
const props = defineProps<{
title?: string;
modelValue?: number;
min?: number;
max?: number;
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: number): void;
}>();
const value = computed({
get() {
return String(props.modelValue ?? 0);
},
set(value: string) {
emit("update:modelValue", Number(value));
}
});
</script>
<style scoped>
.fullWidth {
width: 100%;
}
</style>

View file

@ -1,99 +0,0 @@
<template>
<form @submit.prevent="submit">
<div class="field">
<span class="field-title" v-if="title">
<Title />
</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="tsx">
import "components/common/fields.css";
import { MaybeGetter } from "util/computed";
import { render, Renderable } from "util/vue";
import { computed, onMounted, shallowRef, unref } from "vue";
import VueTextareaAutosize from "vue-textarea-autosize";
const props = defineProps<{
title?: MaybeGetter<Renderable>;
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 Title = () => props.title == null ? <></> : render(props.title, el => <span>{el}</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>

View file

@ -1,117 +0,0 @@
<template>
<label class="field">
<input type="checkbox" class="toggle" v-model="value" />
<Component />
</label>
</template>
<script setup lang="tsx">
import "components/common/fields.css";
import { MaybeGetter } from "util/computed";
import { render, Renderable } from "util/vue";
import { computed } from "vue";
const props = defineProps<{
title?: MaybeGetter<Renderable>;
modelValue?: boolean;
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: boolean): void;
}>();
const Component = () => render(props.title ?? "", el => <span>{el}</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>

View file

@ -1,69 +0,0 @@
<template>
<Col class="collapsible-container">
<button @click="collapsed.value = !collapsed.value" class="feature collapsible-toggle">
<Display />
</button>
<Content v-if="!collapsed.value" />
</Col>
</template>
<script setup lang="ts">
import { MaybeGetter } from "util/computed";
import { render, Renderable } from "util/vue";
import type { Ref } from "vue";
import Col from "./Column.vue";
const props = defineProps<{
collapsed: Ref<boolean>;
display: MaybeGetter<Renderable>;
content: MaybeGetter<Renderable>;
}>();
const Display = () => render(props.display);
const Content = () => render(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>

View file

@ -1,21 +0,0 @@
<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 props = defineProps<{
dontMerge?: boolean
}>();
const mergeAdjacent = computed(() =>
themes[settings.theme].mergeAdjacent && props.dontMerge !== true);
</script>

View file

@ -1,21 +0,0 @@
<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 props = defineProps<{
dontMerge?: boolean
}>();
const mergeAdjacent = computed(() =>
themes[settings.theme].mergeAdjacent && props.dontMerge !== true);
</script>

View file

@ -1,16 +0,0 @@
<template>
<div :style="{ width, height }"></div>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
width?: string;
height?: string;
}>(),
{
width: "8px",
height: "17px"
}
);
</script>

View file

@ -1,49 +0,0 @@
<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>

View file

@ -1,18 +0,0 @@
<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>

View file

@ -1,5 +0,0 @@
<template>
<span style="white-space: nowrap; font-size: larger; font-family: initial">
&nbsp;<slot />&nbsp;
</span>
</template>

View file

@ -1 +0,0 @@

View file

@ -1,8 +0,0 @@
<template>
<span style="white-space: nowrap">
<span style="font-size: larger; font-family: initial">&radic;</span>
<div style="display: inline-block; border-top: 1px solid; padding-left: 0.2em">
<slot />
</div>
</span>
</template>

View file

@ -1,84 +0,0 @@
<template>
<Modal v-model="isOpen" v-bind="$attrs">
<template v-slot:header>
<div class="vga-modal-header">
<h2>Kindly consider taking a break.</h2>
</div>
</template>
<template v-slot:body>
<p>
You've been actively enjoying this game for awhile recently - and it's great that
you've been having a good time! That said, there are dangers to games like these that you should be aware of:
</p>
<p>
While incremental games can be fun and even healthy in certain contexts, they can
exacerbate video game addiction even more than other genres. If you feel like
playing incremental games is taking priority over other things in your life, or
manipulating your sleep schedule, it may be prudent to seek help.
</p>
<h4>Resources:</h4>
<p>
<span>
<a style="display: inline" href="https://www.samhsa.gov/" target="_blank">
SAMHSA
</a>
(<a style="display: inline" href="tel:1-800-662-4357">1-800-662-HELP</a>)
</span>
<br />
<a href="https://www.reddit.com/r/StopGaming/">r/StopGaming</a>
</p>
</template>
<template v-slot:footer>
<div class="vga-footer">
<button @click="neverShow" class="button">Never show this again</button>
<button @click="isOpen = false" class="button">Close</button>
</div>
</template>
</Modal>
<SavesManager ref="savesManager" />
</template>
<script setup lang="ts">
import projInfo from "data/projInfo.json";
import settings from "game/settings";
import state from "game/state";
import { ref, watchEffect } from "vue";
import Modal from "./Modal.vue";
import SavesManager from "./SavesManager.vue";
const isOpen = ref(false);
watchEffect(() => {
if (
projInfo.disableHealthWarning === false &&
settings.showHealthWarning &&
state.mouseActivity.filter(i => i).length > 6
) {
isOpen.value = true;
}
});
function neverShow() {
settings.showHealthWarning = false;
isOpen.value = false;
}
</script>
<style scoped>
.vga-modal-header {
padding-top: 10px;
margin-left: 10px;
}
.vga-footer {
display: flex;
justify-content: flex-end;
}
.vga-footer button {
margin: 0 10px;
}
p {
margin-bottom: 10px;
}
</style>

View file

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

View file

@ -1,113 +0,0 @@
<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 v-if="discordLink && discordName">
<a :href="discordLink" class="game-over-modal-discord-link">
<span class="material-icons game-over-modal-discord">discord</span>
{{ discordName }}
</a>
</div>
<div v-else>
<a href="https://discord.gg/yJ4fjnjU54" class="game-over-modal-discord-link">
<span class="material-icons game-over-modal-discord">discord</span>
Profectus & Friends
</a>
</div>
<Toggle title="Autosave" v-model="autosave" />
</div>
</template>
<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 { hasWon } from "data/projEntry";
import projInfo from "data/projInfo.json";
import player from "game/player";
import { formatTime } from "util/bignum";
import { loadSave, newSave } from "util/save";
import { computed, toRef } from "vue";
import Toggle from "../fields/Toggle.vue";
import Modal from "./Modal.vue";
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>

View file

@ -1,124 +0,0 @@
<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="emits('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/yJ4fjnjU54"
class="info-modal-discord-link"
target="_blank"
>
<span class="material-icons info-modal-discord">discord</span>
Profectus & Friends
</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>
<InfoComponents />
</div>
</template>
</Modal>
</template>
<script setup lang="tsx">
import projInfo from "data/projInfo.json";
import player from "game/player";
import { infoComponents } from "game/settings";
import { formatTime } from "util/bignum";
import { render } from "util/vue";
import { computed, ref } from "vue";
import Modal from "./Modal.vue";
const { title, logo, author, discordName, discordLink, versionNumber, versionTitle } = projInfo;
const emits = defineEmits<{
(e: "openChangelog"): void;
}>();
const isOpen = ref(false);
const timePlayed = computed(() => formatTime(player.timePlayed));
const InfoComponents = () => infoComponents.map(f => render(f));
defineExpose({
open() {
isOpen.value = true;
}
});
</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>

View file

@ -1,156 +0,0 @@
<template>
<teleport to="#modal-root">
<transition
name="modal"
@before-enter="isAnimating = true"
@after-leave="isAnimating = false"
appear
>
<div
class="modal-mask"
v-show="modelValue"
v-on:pointerdown.self="close"
v-bind="$attrs"
>
<div class="modal-wrapper">
<div class="modal-container" :width="width">
<div class="modal-header">
<!--
@slot Modal Header
@binding {boolean} shown Whether the modal is currently open or animating
-->
<slot name="header" :shown="isOpen" />
</div>
<div class="modal-body">
<Context ref="contextRef">
<!--
@slot Modal Body
@binding {boolean} shown Whether the modal is currently open or animating
-->
<slot name="body" :shown="isOpen" />
</Context>
</div>
<div class="modal-footer">
<!--
@slot Modal Footer
@binding {boolean} shown Whether the modal is currently open or animating
-->
<slot name="footer" :shown="isOpen">
<div class="modal-default-footer">
<div class="modal-default-flex-grow"></div>
<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 } from "vue";
import Context from "../Context.vue";
const props = defineProps<{
modelValue: boolean;
preventClosing?: boolean;
width?: string;
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: boolean): void;
}>();
const isOpen = computed(() => props.modelValue || isAnimating.value);
function close() {
if (props.preventClosing !== true) {
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>

View file

@ -1,130 +0,0 @@
<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/yJ4fjnjU54'"
class="nan-modal-discord-link"
>
<span class="material-icons nan-modal-discord">discord</span>
{{ discordName || "Profectus & Friends" }}
</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 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 Modal from "./Modal.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>

View file

@ -1,155 +0,0 @@
<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="showHealthWarningTitle" v-model="showHealthWarning" v-if="!projInfo.disableHealthWarning" />
<Toggle :title="autosaveTitle" v-model="autosave" />
<FeedbackButton v-if="!autosave" class="button save-button" @click="save()">Manually save</FeedbackButton>
</div>
<div v-if="isTab('appearance')">
<Select :title="themeTitle" :options="themes" v-model="theme" />
<SettingFields />
<Toggle :title="showTPSTitle" v-model="showTPS" />
<Toggle :title="alignModifierUnitsTitle" v-model="alignUnits" />
</div>
</template>
</Modal>
</template>
<script setup lang="tsx">
import projInfo from "data/projInfo.json";
import rawThemes from "data/themes";
import player from "game/player";
import settings, { settingFields } from "game/settings";
import { camelToTitle, Direction } from "util/common";
import { save } from "util/save";
import { render } from "util/vue";
import { computed, ref, toRefs } from "vue";
import Tooltip from "wrappers/tooltips/Tooltip.vue";
import FeedbackButton from "../fields/FeedbackButton.vue";
import Select from "../fields/Select.vue";
import Toggle from "../fields/Toggle.vue";
import Modal from "./Modal.vue";
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 SettingFields = () => settingFields.map(f => render(f));
const { showTPS, theme, unthrottled, alignUnits, showHealthWarning } = 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 = <span class="option-title">
Unthrottled
<desc>Allow the game to run as fast as possible. Not battery friendly.</desc>
</span>;
const offlineProdTitle = <span class="option-title">
Offline production<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip>
<desc>Simulate production that occurs while the game is closed.</desc>
</span>;
const showHealthWarningTitle = <span class="option-title">
Show videogame addiction warning
<desc>Show a helpful warning after playing for a long time about video game addiction and encouraging you to take a break.</desc>
</span>;
const autosaveTitle = <span class="option-title">
Autosave<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip>
<desc>Automatically save the game every second or when the game is closed.</desc>
</span>;
const isPausedTitle = <span class="option-title">
Pause game<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip>
<desc>Stop everything from moving.</desc>
</span>;
const themeTitle = <span class="option-title">
Theme
<desc>How the game looks.</desc>
</span>;
const showTPSTitle = <span class="option-title">
Show TPS
<desc>Show TPS meter at the bottom-left corner of the page.</desc>
</span>;
const alignModifierUnitsTitle = <span class="option-title">
Align modifier units
<desc>Align numbers to the beginning of the unit in modifier view.</desc>
</span>;
</script>
<style>
.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>

View file

@ -1,245 +0,0 @@
<template>
<div class="save" :class="{ active: isActive, readonly }">
<div class="handle material-icons" v-if="readonly !== true">drag_handle</div>
<div class="actions" v-if="!isEditing && readonly !== true">
<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: boolean) => (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-if="readonly !== true">
<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">
<Tooltip display="Synced!" :direction="Direction.Right" v-if="synced"
><span class="material-icons synced">cloud</span></Tooltip
>
<button class="button open" @click="emit('open')" :disabled="readonly">
<h3>{{ save.name }}</h3>
</button>
<span class="save-version">v{{ save.modVersion }}</span
><br />
<div v-if="currentTime" class="time">
Last played {{ dateFormat.format(currentTime) }}
</div>
</div>
<div class="details" v-else-if="save.error == undefined && isEditing">
<Text v-model="newName" class="editname" @submit="changeName" />
</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 player from "game/player";
import { Direction } from "util/common";
import { galaxy, syncedSaves } from "util/galaxy";
import { LoadablePlayerData } from "util/save";
import { computed, ref, watch } from "vue";
import Tooltip from "wrappers/tooltips/Tooltip.vue";
import DangerButton from "../fields/DangerButton.vue";
import FeedbackButton from "../fields/FeedbackButton.vue";
import Text from "../fields/Text.vue";
const props = defineProps<{
save: LoadablePlayerData;
readonly?: boolean;
}>();
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 = props.save.name ?? ""));
const isActive = computed(
() => props.save != null && props.save.id === player.id && !props.readonly
);
const currentTime = computed(() =>
isActive.value ? player.time : (props.save != null && props.save.time) ?? 0
);
const synced = computed(
() =>
!props.readonly &&
galaxy.value?.loggedIn === true &&
syncedSaves.value.includes(props.save.id)
);
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;
}
.open:disabled {
cursor: inherit;
color: var(--foreground);
opacity: 1;
pointer-events: none;
}
.handle {
flex-grow: 0;
margin-right: 8px;
margin-left: 0;
cursor: pointer;
}
.details {
margin: 0;
flex-grow: 1;
margin-right: 80px;
}
.save.readonly .details {
margin-right: 0;
}
.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;
}
.time {
font-size: small;
}
.synced {
font-size: 100%;
margin-right: 0.5em;
vertical-align: middle;
cursor: default;
}
</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;
}
.details > .tooltip-container {
display: inline;
}
</style>

View file

@ -1,311 +0,0 @@
<template>
<Modal v-model="isOpen" ref="modal">
<template v-slot:header>
<h2>Saves Manager</h2>
</template>
<template #body="{ shown }">
<div v-if="showNotSyncedWarning" style="color: var(--danger)">
Not all saves are synced! You may need to delete stale saves.
</div>
<Draggable
:list="settings.saves"
handle=".handle"
v-if="shown"
:itemKey="(save: string) => save"
>
<template #item="{ element }">
<Save
:save="saves[element]"
@open="openSave(element)"
@export="exportSave(element)"
@editName="(name: string) => 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: unknown) => 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 projInfo from "data/projInfo.json";
import type { Player } from "game/player";
import player, { stringifySave } from "game/player";
import settings from "game/settings";
import LZString from "lz-string";
import { galaxy, syncedSaves } from "util/galaxy";
import {
clearCachedSave,
clearCachedSaves,
decodeSave,
getCachedSave,
getUniqueID,
LoadablePlayerData,
loadSave,
newSave,
save
} from "util/save";
import type { ComponentPublicInstance } from "vue";
import { computed, nextTick, ref, watch } from "vue";
import Draggable from "vuedraggable";
import Select from "../fields/Select.vue";
import Text from "../fields/Text.vue";
import Modal from "./Modal.vue";
import Save from "./Save.vue";
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 {
importedSave = decodeSave(importedSave) ?? "";
if (importedSave === "") {
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.glob("./../../../saves/*.txt", { query: "?raw", eager: true });
let bank = ref(
Object.keys(bankContext).reduce((acc: Array<{ label: string; value: string }>, curr) => {
acc.push({
// .slice(2, -4) strips the leading ./ and the trailing .txt
label: curr.split("/").slice(-1)[0].slice(0, -4),
value: bankContext[curr] as string
});
return acc;
}, [])
);
// Wipe cache whenever the modal is opened
watch(isOpen, isOpen => {
if (isOpen) {
clearCachedSaves();
}
});
const saves = computed(() =>
settings.saves.reduce((acc: Record<string, LoadablePlayerData>, curr: string) => {
acc[curr] = getCachedSave(curr);
return acc;
}, {})
);
const showNotSyncedWarning = computed(
() => galaxy.value?.loggedIn === true && settings.saves.length < syncedSaves.value.length
);
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) {
if (galaxy.value?.loggedIn === true) {
galaxy.value.getSaveList().then(list => {
const slot = Object.keys(list).find(slot => {
const content = list[parseInt(slot)].content;
try {
if (JSON.parse(content).id === id) {
return true;
}
} catch (e) {
return false;
}
});
if (slot != null) {
galaxy.value?.save(parseInt(slot), "", "").catch(console.error);
}
});
}
settings.saves = settings.saves.filter((save: string) => save !== id);
localStorage.removeItem(id);
clearCachedSave(id);
}
function openSave(id: string) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
saves.value[player.id]!.time = player.time;
save();
clearCachedSave(player.id);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
loadSave(saves.value[id]!);
// Delete cached version in case of opening it again
clearCachedSave(id);
}
function newFromPreset(preset: string) {
// Reset preset dropdown
selectedPreset.value = preset;
nextTick(() => {
selectedPreset.value = null;
});
preset = decodeSave(preset) ?? "";
if (preset === "") {
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);
clearCachedSave(id);
}
}
}
</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>

View file

@ -1,83 +0,0 @@
<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/modals/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>

View file

@ -1,18 +0,0 @@
.modifier-toggle {
padding-right: 10px;
transform: translateY(-1px);
display: inline-block;
}
.modifier-toggle.collapsed {
transform: translate(-5px, -5px) rotate(-90deg);
}
.node-text {
text-anchor: middle;
dominant-baseline: middle;
font-family: monospace;
font-size: 200%;
pointer-events: none;
filter: drop-shadow(3px 3px 2px var(--tooltip-background));
}

View file

@ -1,520 +0,0 @@
import Collapsible from "components/layout/Collapsible.vue";
import { Achievement } from "features/achievements/achievement";
import type { Clickable, ClickableOptions } from "features/clickables/clickable";
import { createClickable } from "features/clickables/clickable";
import { Conversion } from "features/conversion";
import { getFirstFeature } from "features/feature";
import { displayResource, Resource } from "features/resources/resource";
import type { Tree, TreeNode, TreeNodeOptions } from "features/trees/tree";
import { createTreeNode } from "features/trees/tree";
import type { GenericFormula } from "game/formulas/types";
import { BaseLayer } from "game/layers";
import { Modifier } from "game/modifiers";
import type { Persistent } from "game/persistence";
import { DefaultValue, persistent } from "game/persistence";
import player from "game/player";
import settings from "game/settings";
import type { DecimalSource } from "util/bignum";
import Decimal, { format, formatSmall, formatTime } from "util/bignum";
import { WithRequired } from "util/common";
import { MaybeGetter, processGetter } from "util/computed";
import { render, Renderable, renderCol } from "util/vue";
import type { ComputedRef, MaybeRef, MaybeRefOrGetter } from "vue";
import { computed, ref, unref } from "vue";
import { JSX } from "vue/jsx-runtime";
import "./common.css";
/** An object that configures a {@link ResetButton} */
export interface ResetButtonOptions extends ClickableOptions {
/** The conversion the button uses to calculate how much resources will be gained on click */
conversion: Conversion;
/** The tree this reset button is apart of */
tree: Tree;
/** The specific tree node associated with this reset button */
treeNode: TreeNode;
/**
* Text to display on low conversion amounts, describing what "resetting" is in this context.
* Defaults to "Reset for ".
*/
resetDescription?: MaybeRefOrGetter<string>;
/** Whether or not to show how much currency would be required to make the gain amount increase. */
showNextAt?: MaybeRefOrGetter<boolean>;
/**
* The content to display on the button.
* By default, this includes the reset description, and amount of currency to be gained.
*/
display?: MaybeGetter<Renderable>;
/**
* Whether or not this button can currently be clicked.
* Defaults to checking the current gain amount is greater than {@link minimumGain}
*/
canClick?: MaybeRefOrGetter<boolean>;
/**
* When {@link canClick} is left to its default, minimumGain is used to only enable the reset button when a sufficient amount of currency to gain is available.
*/
minimumGain?: MaybeRefOrGetter<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 interface ResetButton extends Clickable {
/** The conversion the button uses to calculate how much resources will be gained on click */
conversion: Conversion;
/** The tree this reset button is apart of */
tree: Tree;
/** The specific tree node associated with this reset button */
treeNode: TreeNode;
/**
* Text to display on low conversion amounts, describing what "resetting" is in this context.
* Defaults to "Reset for ".
*/
resetDescription?: MaybeRef<string>;
/** Whether or not to show how much currency would be required to make the gain amount increase. */
showNextAt?: MaybeRef<boolean>;
/**
* When {@link canClick} is left to its default, minimumGain is used to only enable the reset button when a sufficient amount of currency to gain is available.
*/
minimumGain?: MaybeRef<DecimalSource>;
/** A persistent ref to track how much time has passed since the last time this tree node was reset. */
resetTime?: Persistent<DecimalSource>;
}
/**
* Lazily creates a reset button with the given options.
* @param optionsFunc A function that returns the options object for this reset button.
*/
export function createResetButton<T extends ClickableOptions & ResetButtonOptions>(
optionsFunc: () => T
) {
const resetButton = createClickable(() => {
const options = optionsFunc();
const {
conversion,
tree,
treeNode,
resetTime,
resetDescription,
showNextAt,
minimumGain,
display,
canClick,
onClick,
...props
} = options;
return {
...(props as Omit<typeof props, keyof ResetButtonOptions>),
conversion,
tree,
treeNode,
resetTime,
resetDescription:
processGetter(resetDescription) ??
computed((): string =>
Decimal.lt(conversion.gainResource.value, 1e3) ? "Reset for " : ""
),
showNextAt: processGetter(showNextAt) ?? true,
minimumGain: processGetter(minimumGain) ?? 1,
canClick:
processGetter(canClick) ??
computed((): boolean =>
Decimal.gte(unref(conversion.actualGain), unref(resetButton.minimumGain))
),
display:
display ??
((): JSX.Element => (
<span>
{unref(resetButton.resetDescription)}
<b>
{displayResource(
conversion.gainResource,
Decimal.max(
unref(conversion.actualGain),
unref(resetButton.minimumGain)
)
)}
</b>{" "}
{conversion.gainResource.displayName}
{unref(resetButton.showNextAt) != null ? (
<div>
<br />
{unref(conversion.buyMax) ? "Next:" : "Req:"}{" "}
{displayResource(
conversion.baseResource,
!unref<boolean>(conversion.buyMax) &&
Decimal.gte(unref(conversion.actualGain), 1)
? unref(conversion.currentAt)
: unref(conversion.nextAt)
)}{" "}
{conversion.baseResource.displayName}
</div>
) : null}
</span>
)),
onClick: function (e?: MouseEvent | TouchEvent) {
if (unref(resetButton.canClick) === false) {
return;
}
conversion.convert();
tree.reset(treeNode);
if (resetTime) {
resetTime.value = resetTime[DefaultValue];
}
onClick?.call(resetButton, e);
}
};
}) satisfies ResetButton;
return resetButton;
}
/** 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: MaybeRefOrGetter<string>; // marking as required
/** Whether or not to append the layer to the tabs list.
* If set to false, then the tree node will instead always remove all tabs to its right and then add the layer tab.
* Defaults to true.
*/
append?: MaybeRefOrGetter<boolean>;
}
/** A tree node that is associated with a given layer, and which opens the layer when clicked. */
export interface LayerTreeNode extends TreeNode {
/** The ID of the layer this tree node is associated with */
layerID: string;
/** Whether or not to append the layer to the tabs list.
* If set to false, then the tree node will instead always remove all tabs to its right and then add the layer tab.
* Defaults to true.
*/
append?: MaybeRef<boolean>;
}
/**
* Lazily creates a tree node that's associated with a specific layer, with the given options.
* @param optionsFunc A function that returns the options object for this tree node.
*/
export function createLayerTreeNode<T extends LayerTreeNodeOptions>(optionsFunc: () => T) {
const layerTreeNode = createTreeNode(() => {
const options = optionsFunc();
const { display, append, layerID, ...props } = options;
return {
...(props as Omit<typeof props, keyof LayerTreeNodeOptions>),
layerID,
display: display ?? layerID,
append: processGetter(append) ?? true,
onClick() {
if (unref<boolean>(layerTreeNode.append)) {
if (player.tabs.includes(layerID)) {
const index = player.tabs.lastIndexOf(layerID);
player.tabs.splice(index, 1);
} else {
player.tabs.push(layerID);
}
} else {
player.tabs.splice(1, 1, layerID);
}
}
};
}) satisfies LayerTreeNode;
return layerTreeNode;
}
/** An option object for a modifier display as a single section. **/
export interface Section {
/** The header for this modifier. **/
title: MaybeRefOrGetter<string>;
/** A subtitle for this modifier, e.g. to explain the context for the modifier. **/
subtitle?: MaybeRefOrGetter<string>;
/** The modifier to be displaying in this section. **/
modifier: WithRequired<Modifier, "description">;
/** The base value being modified. **/
base?: MaybeRefOrGetter<DecimalSource>;
/** The unit of measurement for the base. **/
unit?: string;
/** The label to call the base amount. Defaults to "Base". **/
baseText?: MaybeGetter<Renderable>;
/** Whether or not this section should be currently visible to the player. **/
visible?: MaybeRefOrGetter<boolean>;
/** Determines if numbers larger or smaller than the base should be displayed as red. */
smallerIsBetter?: 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.
*/
export function createCollapsibleModifierSections(
sectionsFunc: () => Section[]
): [() => Renderable, Persistent<Record<number, boolean>>] {
const sections: Section[] = [];
const processed:
| {
base: MaybeRef<DecimalSource | undefined>[];
baseText: (MaybeGetter<Renderable> | undefined)[];
visible: MaybeRef<boolean | undefined>[];
title: MaybeRef<string | undefined>[];
subtitle: MaybeRef<string | undefined>[];
}
| Record<string, never> = {};
let calculated = false;
function calculateSections() {
if (!calculated) {
sections.push(...sectionsFunc());
processed.base = sections.map(s => processGetter(s.base));
processed.baseText = sections.map(s => s.baseText);
processed.visible = sections.map(s => processGetter(s.visible));
processed.title = sections.map(s => processGetter(s.title));
processed.subtitle = sections.map(s => processGetter(s.subtitle));
calculated = true;
}
return sections;
}
const collapsed = persistent<Record<number, boolean>>({}, false);
const jsxFunc = () => {
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">
{render(unref(processed.baseText[i]) ?? "Base")}
</span>
<span class="modifier-amount">
{format(unref(processed.base[i]) ?? 1)}
{s.unit}
</span>
</div>
{s.modifier.description == null ? null : render(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={
(
s.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 achievements
* @param achievements A dictionary of the achievements to display, inserted in the order from easiest to hardest
*/
export function createCollapsibleAchievements(achievements: Record<string, Achievement>) {
// Achievements are typically defined from easiest to hardest, and we want to show hardest first
const orderedAchievements = Object.values(achievements).reverse();
const collapseAchievements = persistent<boolean>(true, false);
const lockedAchievements = computed(() =>
orderedAchievements.filter(m => m.earned.value === false)
);
const { firstFeature, collapsedContent, hasCollapsedContent } = getFirstFeature(
orderedAchievements,
m => m.earned.value
);
const display = computed(() => {
const achievementsToDisplay = [...lockedAchievements.value];
if (firstFeature.value) {
achievementsToDisplay.push(firstFeature.value);
}
return renderCol(
...achievementsToDisplay,
<Collapsible
collapsed={collapseAchievements}
content={collapsedContent}
display={
collapseAchievements.value
? "Show other completed achievements"
: "Hide other completed achievements"
}
v-show={unref(hasCollapsedContent)}
/>
);
});
return {
collapseAchievements: collapseAchievements,
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: MaybeRefOrGetter<DecimalSource>,
target: MaybeRefOrGetter<DecimalSource>
) {
const processedRate = processGetter(rate);
const processedTarget = processGetter(target);
return computed(() => {
const currRate = unref(processedRate);
const currTarget = unref(processedTarget);
if (Decimal.gte(resource.value, currTarget)) {
return "Now";
} else if (Decimal.lte(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: MaybeRefOrGetter<boolean>,
previewAmount: MaybeRefOrGetter<DecimalSource> = 1
) {
const processedShowPreview = processGetter(showPreview);
const processedPreviewAmount = processGetter(previewAmount);
if (!formula.hasVariable()) {
console.error("Cannot create formula preview if the formula does not have a variable");
}
return computed(() => {
if (unref(processedShowPreview)) {
const curr = formatSmall(formula.evaluate());
const preview = formatSmall(
formula.evaluate(
Decimal.add(
unref(formula.innermostVariable ?? 0),
unref(processedPreviewAmount)
)
)
);
return (
<>
<b>
<i>
{curr} {preview}
</i>
</b>
</>
);
}
return <>{formatSmall(formula.evaluate())}</>;
});
}
/**
* Utility function for getting a computed boolean for whether or not a given feature is currently rendered in the DOM.
* Note it will have a true value even if the feature is off screen.
* @param layer The layer the feature appears within
* @param id The ID of the feature
*/
export function isRendered(layer: BaseLayer, id: string): ComputedRef<boolean>;
/**
* Utility function for getting a computed boolean for whether or not a given feature is currently rendered in the DOM.
* Note it will have a true value even if the feature is off screen.
* @param layer The layer the feature appears within
* @param feature The feature that may be rendered
*/
export function isRendered(layer: BaseLayer, feature: { id: string }): ComputedRef<boolean>;
export function isRendered(layer: BaseLayer, idOrFeature: string | { id: string }) {
const id = typeof idOrFeature === "string" ? idOrFeature : idOrFeature.id;
return computed(() => id in layer.nodes.value);
}
/**
* Utility function for setting up a system where one of many things can be selected.
* It's recommended to use an ID or index rather than the object itself, so that you can wrap the ref in a persistent without breaking anything.
* @returns The ref containing the selection, as well as a select and deselect function
*/
export function setupSelectable<T>() {
const selected = ref<T>();
return {
select: function (node: T) {
selected.value = node;
},
deselect: function () {
selected.value = undefined;
},
selected
};
}

View file

@ -1,72 +0,0 @@
/**
* @module
* @hidden
*/
import { main } from "data/projEntry";
import { createCumulativeConversion } from "features/conversion";
import { createHotkey } from "features/hotkey";
import { createReset } from "features/reset";
import MainDisplay from "features/resources/MainDisplay.vue";
import { createResource } from "features/resources/resource";
import { createResourceTooltip } from "features/trees/tree";
import { createLayer } from "game/layers";
import type { DecimalSource } from "util/bignum";
import { render } from "util/vue";
import { addTooltip } from "wrappers/tooltips/tooltip";
import { createLayerTreeNode, createResetButton } from "../common";
const id = "p";
const layer = createLayer(id, () => {
const name = "Prestige";
const color = "#4BDC13";
const points = createResource<DecimalSource>(0, "prestige points");
const conversion = createCumulativeConversion(() => ({
formula: x => x.div(10).sqrt(),
baseResource: main.points,
gainResource: points
}));
const reset = createReset(() => ({
thingsToReset: (): Record<string, unknown>[] => [layer]
}));
const treeNode = createLayerTreeNode(() => ({
layerID: id,
color,
reset
}));
const tooltip = 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,
tooltip,
display: () => (
<>
<MainDisplay resource={points} color={color} />
{render(resetButton)}
</>
),
treeNode,
hotkey
};
});
export default layer;

View file

@ -1,121 +0,0 @@
import Node from "components/Node.vue";
import Spacer from "components/layout/Spacer.vue";
import { createResource, trackBest, trackOOMPS, trackTotal } from "features/resources/resource";
import { branchedResetPropagation, createTree, Tree } from "features/trees/tree";
import type { Layer } from "game/layers";
import { createLayer } from "game/layers";
import { noPersist } from "game/persistence";
import player, { 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", layer => {
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;
});
layer.on("update", diff => {
points.value = Decimal.add(points.value, Decimal.times(pointGain.value, diff));
});
const oomps = trackOOMPS(points, pointGain);
// Note: Casting as generic tree to avoid recursive type definitions
const tree = createTree(() => ({
nodes: noPersist([[prestige.treeNode]]),
branches: [],
onReset() {
points.value = toRaw(tree.resettingNode.value) === toRaw(prestige.treeNode) ? 0 : 10;
best.value = points.value;
total.value = points.value;
},
resetPropagation: branchedResetPropagation
})) as Tree;
// Note: layers don't _need_ a reference to everything,
// but I'd recommend it over trying to remember what does and doesn't need to be included.
// Officially all you need are anything with persistency or that you want to access elsewhere
return {
name: "Tree",
links: tree.links,
display: () => (
<>
{player.devSpeed === 0 ? (
<div>
Game Paused
<Node id="paused" />
</div>
) : null}
{player.devSpeed != null && player.devSpeed !== 0 && player.devSpeed !== 1 ? (
<div>
Dev Speed: {format(player.devSpeed)}x
<Node id="devspeed" />
</div>
) : null}
{player.offlineTime != null && player.offlineTime !== 0 ? (
<div>
Offline Time: {formatTime(player.offlineTime)}
<Node id="offline" />
</div>
) : null}
<div>
{Decimal.lt(points.value, "1e1000") ? <span>You have </span> : null}
<h2>{format(points.value)}</h2>
{Decimal.lt(points.value, "1e1e6") ? <span> points</span> : null}
</div>
{Decimal.gt(pointGain.value, 0) ? (
<div>
({oomps.value})
<Node id="oomps" />
</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<Layer> => [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 */

View file

@ -1,97 +0,0 @@
{
"$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."
},
"disableHealthWarning": {
"type": "boolean",
"description": "Whether or not to disable the health warning that appears to the player after excessive playtime (activity during 6 of the last 8 hours). If left enabled, the player will still be able to individually turn off the health warning in settings or by clicking \"Do not show again\" in the warning itself."
}
}
}

View file

@ -1,27 +0,0 @@
{
"$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",
"disableHealthWarning": false
}

View file

@ -1,137 +0,0 @@
/** 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>;

View file

@ -1,41 +0,0 @@
<template>
<div v-if="isVisible(unref(visibility))"
:style="[
{
visibility: isHidden(unref(visibility)) ? 'hidden' : undefined
},
unref(style)
]"
:class="{ feature: true, ...unref(classes) }"
>
<Components />
<Node :id="id" />
</div>
</template>
<script setup lang="tsx">
import "components/common/features.css";
import Node from "components/Node.vue";
import type { Visibility } from "features/feature";
import { isHidden, isVisible } from "features/feature";
import { MaybeGetter } from "util/computed";
import { render, Renderable, Wrapper } from "util/vue";
import { MaybeRef, unref, type CSSProperties } from "vue";
const props = withDefaults(defineProps<{
id: string;
components: MaybeGetter<Renderable>[];
wrappers: Wrapper[];
visibility?: MaybeRef<Visibility | boolean>;
style?: MaybeRef<CSSProperties>;
classes?: MaybeRef<Record<string, boolean>>;
}>(), {
visibility: true,
style: () => ({}),
classes: () => ({})
});
const Components = () => props.wrappers.reduce<() => Renderable>(
(acc, curr) => (() => curr(acc)),
() => <>{props.components.map(el => render(el))}</>)();
</script>

View file

@ -1,71 +0,0 @@
<template>
<button
:style="{
backgroundImage: (unref(earned) && unref(image) && `url(${image})`) || ''
}"
:class="{
achievement: true,
locked: !unref(earned),
done: unref(earned),
small: unref(small),
}"
>
<Component />
</button>
</template>
<script setup lang="tsx">
import "components/common/features.css";
import { Requirements } from "game/requirements";
import { MaybeGetter } from "util/computed";
import { render, Renderable } from "util/vue";
import { Component, MaybeRef, Ref, unref } from "vue";
const props = defineProps<{
display?: MaybeGetter<Renderable>;
earned: Ref<boolean>;
requirements?: Requirements;
image?: MaybeRef<string>;
small?: MaybeRef<boolean>;
}>();
const Component = () => props.display == null ? <></> : render(props.display);
</script>
<style scoped>
.achievement {
height: 90px;
width: 90px;
font-size: 10px;
color: white;
text-shadow: 0 0 2px #000000;
}
.achievement:not(.small) {
height: unset;
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);
font-size: unset;
text-shadow: unset;
}
.achievement.done {
background-color: var(--bought);
cursor: default;
}
.achievement :deep(.equal-spaced) {
display: flex;
justify-content: center;
}
.achievement :deep(.equal-spaced > *) {
margin: auto;
}
</style>

View file

@ -1,257 +0,0 @@
import Select from "components/fields/Select.vue";
import { Visibility } from "features/feature";
import { globalBus } from "game/events";
import "game/notifications";
import type { Persistent } from "game/persistence";
import { persistent } from "game/persistence";
import player from "game/player";
import {
createBooleanRequirement,
createVisibilityRequirement,
displayRequirements,
Requirements,
requirementsMet
} from "game/requirements";
import settings, { registerSettingField } from "game/settings";
import { camelToTitle } from "util/common";
import { MaybeGetter, processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies";
import {
isJSXElement,
render,
Renderable,
VueFeature,
vueFeatureMixin,
VueFeatureOptions
} from "util/vue";
import { computed, MaybeRef, MaybeRefOrGetter, unref, watchEffect } from "vue";
import { useToast } from "vue-toastification";
import Achievement from "./Achievement.vue";
const toast = useToast();
/** A symbol used to identify {@link Achievement} features. */
export const AchievementType = Symbol("Achievement");
/** Modes for only displaying some achievements. */
export enum AchievementDisplay {
All = "all",
//Last = "last",
Configurable = "configurable",
Incomplete = "incomplete",
None = "none"
}
/**
* An object that configures an {@link Achievement}.
*/
export interface AchievementOptions extends VueFeatureOptions {
/** The requirement(s) to earn this achievement. Can be left null if using {@link Achievement.complete}. */
requirements?: Requirements;
/** The display to use for this achievement. */
display?:
| MaybeGetter<Renderable>
| {
/** Description of the requirement(s) for this achievement. If unspecified then the requirements will be displayed automatically based on {@link requirements}. */
requirement?: MaybeGetter<Renderable>;
/** Description of what will change (if anything) for achieving this. */
effectDisplay?: MaybeGetter<Renderable>;
/** Any additional things to display on this achievement, such as a toggle for it's effect. */
optionsDisplay?: MaybeGetter<Renderable>;
};
/** Toggles a smaller design for the feature. */
small?: MaybeRefOrGetter<boolean>;
/** An image to display as the background for this achievement. */
image?: MaybeRefOrGetter<string>;
/** Whether or not to display a notification popup when this achievement is earned. */
showPopups?: MaybeRefOrGetter<boolean>;
/** A function that is called when the achievement is completed. */
onComplete?: VoidFunction;
}
/** An object that represents a feature with requirements that is passively earned upon meeting certain requirements. */
export interface Achievement extends VueFeature {
/** The requirement(s) to earn this achievement. */
requirements?: Requirements;
/** A function that is called when the achievement is completed. */
onComplete?: VoidFunction;
/** The display to use for this achievement. */
display?: MaybeGetter<Renderable>;
/** Toggles a smaller design for the feature. */
small?: MaybeRef<boolean>;
/** An image to display as the background for this achievement. */
image?: MaybeRef<string>;
/** Whether or not to display a notification popup when this achievement is earned. */
showPopups: MaybeRef<boolean>;
/** Whether or not this achievement has been earned. */
earned: Persistent<boolean>;
/** A function to complete this achievement. */
complete: VoidFunction;
/** A symbol that helps identify features of the same type. */
type: typeof AchievementType;
}
/**
* Lazily creates an achievement with the given options.
* @param optionsFunc Achievement options.
*/
export function createAchievement<T extends AchievementOptions>(optionsFunc?: () => T) {
const earned = persistent<boolean>(false, false);
return createLazyProxy(() => {
const options = optionsFunc?.() ?? ({} as T);
const {
requirements,
display: _display,
small,
image,
showPopups,
onComplete,
...props
} = options;
const vueFeature = vueFeatureMixin("achievement", options, () => (
<Achievement
display={achievement.display}
earned={achievement.earned}
requirements={achievement.requirements}
image={achievement.image}
small={achievement.small}
/>
));
let display: MaybeGetter<Renderable> | undefined = undefined;
if (typeof _display === "object" && !isJSXElement(_display)) {
const { requirement, effectDisplay, optionsDisplay } = _display;
display = () => (
<span>
{requirement == null
? displayRequirements(requirements ?? [])
: render(requirement, el => <h3>{el}</h3>)}
{effectDisplay == null ? null : (
<div>
{render(effectDisplay, el => (
<b>{el}</b>
))}
</div>
)}
{optionsDisplay != null ? (
<div class="equal-spaced">{render(optionsDisplay)}</div>
) : null}
</span>
);
} else if (_display != null) {
display = _display;
}
const achievement = {
type: AchievementType,
...(props as Omit<typeof props, keyof VueFeature | keyof AchievementOptions>),
...vueFeature,
visibility: computed(() => {
switch (settings.msDisplay) {
default:
case AchievementDisplay.All:
return unref(vueFeature.visibility) ?? true;
case AchievementDisplay.Configurable:
if (
unref(earned) &&
!(
_display != null &&
typeof _display === "object" &&
!isJSXElement(_display)
)
) {
return Visibility.None;
}
return unref(vueFeature.visibility) ?? true;
case AchievementDisplay.Incomplete:
if (unref(earned)) {
return Visibility.None;
}
return unref(vueFeature.visibility) ?? true;
case AchievementDisplay.None:
return Visibility.None;
}
}),
earned,
onComplete,
small: processGetter(small),
image: processGetter(image),
showPopups: processGetter(showPopups) ?? true,
display,
requirements:
requirements == null
? undefined
: [
createVisibilityRequirement(vueFeature.visibility ?? true),
createBooleanRequirement(() => !earned.value),
...(Array.isArray(requirements) ? requirements : [requirements])
],
complete() {
if (earned.value) {
return;
}
earned.value = true;
achievement.onComplete?.();
if (achievement.display != null && unref(achievement.showPopups) === true) {
let display = achievement.display;
if (typeof _display === "object" && !isJSXElement(_display)) {
if (_display.requirement != null) {
display = _display.requirement;
} else {
display = displayRequirements(requirements ?? []);
}
}
toast.info(
<div>
<h3>Achievement earned!</h3>
<div>{render(display)}</div>
</div>
);
}
}
} satisfies Achievement;
if (achievement.requirements != null) {
watchEffect(() => {
if (settings.active !== player.id) return;
if (requirementsMet(achievement.requirements ?? [])) {
achievement.complete();
}
});
}
return achievement;
});
}
declare module "game/settings" {
interface Settings {
msDisplay: AchievementDisplay;
}
}
globalBus.on("loadSettings", settings => {
settings.msDisplay ??= AchievementDisplay.All;
});
const msDisplayOptions = Object.values(AchievementDisplay).map(option => ({
label: camelToTitle(option),
value: option
}));
globalBus.on("setupVue", () =>
registerSettingField(() => (
<Select
title={
<span class="option-title">
Show achievements
<desc>Select which achievements to display based on criterias.</desc>
</span>
}
options={msDisplayOptions}
onUpdate:modelValue={value => (settings.msDisplay = value as AchievementDisplay)}
modelValue={settings.msDisplay}
/>
))
);

View file

@ -1,128 +0,0 @@
<template>
<div
:style="{
width: unref(width) + 'px',
height: unref(height) + 'px',
}"
class="bar"
>
<div
class="overlayTextContainer border"
:style="[
{ width: unref(width) + 'px', height: unref(height) + 'px' },
unref(borderStyle) ?? {}
]"
>
<span v-if="display" class="overlayText" :style="unref(textStyle)">
<Component />
</span>
</div>
<div
class="border"
:style="[
{ width: unref(width) + 'px', height: unref(height) + 'px' },
unref(baseStyle) ?? {},
unref(borderStyle) ?? {}
]"
>
<div class="fill" :style="[barStyle, unref(fillStyle) ?? {}]" />
</div>
</div>
</template>
<script setup lang="ts">
import Decimal, { DecimalSource } from "util/bignum";
import { Direction } from "util/common";
import { MaybeGetter } from "util/computed";
import { render, Renderable } from "util/vue";
import type { CSSProperties, MaybeRef } from "vue";
import { computed, unref } from "vue";
const props = defineProps<{
width: MaybeRef<number>;
height: MaybeRef<number>;
direction: MaybeRef<Direction>;
borderStyle?: MaybeRef<CSSProperties>;
baseStyle?: MaybeRef<CSSProperties>;
textStyle?: MaybeRef<CSSProperties>;
fillStyle?: MaybeRef<CSSProperties>;
progress: MaybeRef<DecimalSource>;
display?: MaybeGetter<Renderable>;
}>();
const normalizedProgress = computed(() => {
let progressNumber =
props.progress instanceof Decimal
? props.progress.toNumber()
: Number(props.progress);
return (1 - Math.min(Math.max(progressNumber, 0), 1)) * 100;
});
const barStyle = computed(() => {
const barStyle: Partial<CSSProperties> = {
width: unref(props.width) + 0.5 + "px",
height: unref(props.height) + 0.5 + "px"
};
switch (props.direction) {
case Direction.Up:
barStyle.clipPath = `inset(${normalizedProgress.value}% 0% 0% 0%)`;
barStyle.width = unref(props.width) + 1 + "px";
break;
case Direction.Down:
barStyle.clipPath = `inset(0% 0% ${normalizedProgress.value}% 0%)`;
barStyle.width = unref(props.width) + 1 + "px";
break;
case Direction.Right:
barStyle.clipPath = `inset(0% ${normalizedProgress.value}% 0% 0%)`;
break;
case Direction.Left:
barStyle.clipPath = `inset(0% 0% 0% ${normalizedProgress.value}%)`;
break;
case Direction.Default:
barStyle.clipPath = "inset(0% 50% 0% 0%)";
break;
}
return barStyle;
});
const Component = () => props.display ? render(props.display) : null;
</script>
<style scoped>
.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;
transition-duration: 0.05s;
}
</style>

View file

@ -1,109 +0,0 @@
import Bar from "features/bars/Bar.vue";
import type { DecimalSource } from "util/bignum";
import { Direction } from "util/common";
import { MaybeGetter, processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies";
import { Renderable, VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
import { CSSProperties, MaybeRef, MaybeRefOrGetter } from "vue";
/** A symbol used to identify {@link Bar} features. */
export const BarType = Symbol("Bar");
/**
* An object that configures a {@link Bar}.
*/
export interface BarOptions extends VueFeatureOptions {
/** The width of the bar. */
width: MaybeRefOrGetter<number>;
/** The height of the bar. */
height: MaybeRefOrGetter<number>;
/** The direction in which the bar progresses. */
direction: MaybeRefOrGetter<Direction>;
/** CSS to apply to the bar's border. */
borderStyle?: MaybeRefOrGetter<CSSProperties>;
/** CSS to apply to the bar's base. */
baseStyle?: MaybeRefOrGetter<CSSProperties>;
/** CSS to apply to the bar's text. */
textStyle?: MaybeRefOrGetter<CSSProperties>;
/** CSS to apply to the bar's fill. */
fillStyle?: MaybeRefOrGetter<CSSProperties>;
/** The progress value of the bar, from 0 to 1. */
progress: MaybeRefOrGetter<DecimalSource>;
/** The display to use for this bar. */
display?: MaybeGetter<Renderable>;
}
/** An object that represents a feature that displays some sort of progress or completion or resource with a cap. */
export interface Bar extends VueFeature {
/** The width of the bar. */
width: MaybeRef<number>;
/** The height of the bar. */
height: MaybeRef<number>;
/** The direction in which the bar progresses. */
direction: MaybeRef<Direction>;
/** CSS to apply to the bar's border. */
borderStyle?: MaybeRef<CSSProperties>;
/** CSS to apply to the bar's base. */
baseStyle?: MaybeRef<CSSProperties>;
/** CSS to apply to the bar's text. */
textStyle?: MaybeRef<CSSProperties>;
/** CSS to apply to the bar's fill. */
fillStyle?: MaybeRef<CSSProperties>;
/** The progress value of the bar, from 0 to 1. */
progress: MaybeRef<DecimalSource>;
/** The display to use for this bar. */
display?: MaybeGetter<Renderable>;
/** A symbol that helps identify features of the same type. */
type: typeof BarType;
}
/**
* Lazily creates a bar with the given options.
* @param optionsFunc Bar options.
*/
export function createBar<T extends BarOptions>(optionsFunc: () => T) {
return createLazyProxy(() => {
const options = optionsFunc?.();
const {
width,
height,
direction,
borderStyle,
baseStyle,
textStyle,
fillStyle,
progress,
display,
...props
} = options;
const bar = {
type: BarType,
...(props as Omit<typeof props, keyof VueFeature | keyof BarOptions>),
...vueFeatureMixin("bar", options, () => (
<Bar
width={bar.width}
height={bar.height}
direction={bar.direction}
borderStyle={bar.borderStyle}
baseStyle={bar.baseStyle}
textStyle={bar.textStyle}
fillStyle={bar.fillStyle}
progress={bar.progress}
display={bar.display}
/>
)),
width: processGetter(width),
height: processGetter(height),
direction: processGetter(direction),
borderStyle: processGetter(borderStyle),
baseStyle: processGetter(baseStyle),
textStyle: processGetter(textStyle),
fillStyle: processGetter(fillStyle),
progress: processGetter(progress),
display
} satisfies Bar;
return bar;
});
}

View file

@ -1,99 +0,0 @@
<template>
<div
:style="notifyStyle"
:class="{
challenge: true,
done: unref(completed),
canStart: unref(canStart) && !unref(maxed),
maxed: unref(maxed)
}"
>
<button
class="toggleChallenge"
@click="emits('toggle')"
:disabled="!unref(canStart) || unref(maxed)"
>
{{ buttonText }}
</button>
<Component v-if="props.display" />
</div>
</template>
<script setup lang="tsx">
import "components/common/features.css";
import { getHighNotifyStyle, getNotifyStyle } from "game/notifications";
import { Requirements } from "game/requirements";
import { DecimalSource } from "util/bignum";
import { MaybeGetter } from "util/computed";
import { render, Renderable } from "util/vue";
import type { Component, MaybeRef, Ref } from "vue";
import { computed, unref } from "vue";
const props = defineProps<{
active: Ref<boolean>;
maxed: Ref<boolean>;
canComplete: Ref<DecimalSource>;
display?: MaybeGetter<Renderable>;
requirements: Requirements;
completed: Ref<boolean>;
canStart?: MaybeRef<boolean>;
}>();
const emits = defineEmits<{
(e: "toggle"): void;
}>();
const buttonText = computed(() => {
if (unref(props.active)) {
return unref(props.canComplete) ? "Finish" : "Exit Early";
}
if (unref(props.maxed)) {
return "Completed";
}
return "Start";
});
const notifyStyle = computed(() => {
const currActive = unref(props.active);
const currCanComplete = unref(props.canComplete);
if (currActive) {
if (currCanComplete) {
return getHighNotifyStyle();
}
return getNotifyStyle();
}
return {};
});
const Component = () => props.display == null ? <></> : render(props.display);
</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>

View file

@ -1,315 +0,0 @@
import Toggle from "components/fields/Toggle.vue";
import { isVisible } from "features/feature";
import type { Reset } from "features/reset";
import { globalBus } from "game/events";
import type { Persistent } from "game/persistence";
import { persistent } from "game/persistence";
import { Requirements, displayRequirements, maxRequirementsMet } from "game/requirements";
import settings, { registerSettingField } from "game/settings";
import type { DecimalSource } from "util/bignum";
import Decimal from "util/bignum";
import { MaybeGetter, processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies";
import {
Renderable,
VueFeature,
VueFeatureOptions,
isJSXElement,
render,
vueFeatureMixin
} from "util/vue";
import type { MaybeRef, MaybeRefOrGetter, Ref, WatchStopHandle } from "vue";
import { computed, unref, watch } from "vue";
import Challenge from "./Challenge.vue";
/** A symbol used to identify {@link Challenge} features. */
export const ChallengeType = Symbol("Challenge");
/**
* An object that configures a {@link Challenge}.
*/
export interface ChallengeOptions extends VueFeatureOptions {
/** Whether this challenge can be started. */
canStart?: MaybeRefOrGetter<boolean>;
/** The reset function for this challenge. */
reset?: Reset;
/** The requirement(s) to complete this challenge. */
requirements: Requirements;
/** The maximum number of times the challenge can be completed. */
completionLimit?: MaybeRefOrGetter<DecimalSource>;
/** The display to use for this challenge. */
display?:
| MaybeGetter<Renderable>
| {
/** A header to appear at the top of the display. */
title?: MaybeGetter<Renderable>;
/** The main text that appears in the display. */
description: MaybeGetter<Renderable>;
/** A description of the current goal for this challenge. If unspecified then the requirements will be displayed automatically based on {@link requirements}. */
goal?: MaybeGetter<Renderable>;
/** A description of what will change upon completing this challenge. */
reward?: MaybeGetter<Renderable>;
/** A description of the current effect of this challenge. */
effectDisplay?: MaybeGetter<Renderable>;
};
/** A function that is called when the challenge is completed. */
onComplete?: VoidFunction;
/** A function that is called when the challenge is exited. */
onExit?: VoidFunction;
/** A function that is called when the challenge is entered. */
onEnter?: VoidFunction;
}
/** An object that represents a feature that can be entered and exited, and have one or more completions with scaling requirements. */
export interface Challenge extends VueFeature {
/** The reset function for this challenge. */
reset?: Reset;
/** The requirement(s) to complete this challenge. */
requirements: Requirements;
/** A function that is called when the challenge is completed. */
onComplete?: VoidFunction;
/** A function that is called when the challenge is exited. */
onExit?: VoidFunction;
/** A function that is called when the challenge is entered. */
onEnter?: VoidFunction;
/** Whether this challenge can be started. */
canStart?: MaybeRef<boolean>;
/** The maximum number of times the challenge can be completed. */
completionLimit?: MaybeRef<DecimalSource>;
/** The display to use for this challenge. */
display?: MaybeGetter<Renderable>;
/** The current amount of times this challenge can be completed. */
canComplete: Ref<DecimalSource>;
/** The current number of times this challenge has been completed. */
completions: Persistent<DecimalSource>;
/** Whether or not this challenge has been completed. */
completed: Ref<boolean>;
/** Whether or not this challenge's completion count is at its limit. */
maxed: Ref<boolean>;
/** Whether or not this challenge is currently active. */
active: Persistent<boolean>;
/** A function to enter or leave the challenge. */
toggle: VoidFunction;
/**
* A function to complete this challenge.
* @param remainInChallenge - Optional parameter to specify if the challenge should remain active after completion.
*/
complete: (remainInChallenge?: boolean) => void;
/** A symbol that helps identify features of the same type. */
type: typeof ChallengeType;
}
/**
* Lazily creates a challenge with the given options.
* @param optionsFunc Challenge options.
*/
export function createChallenge<T extends ChallengeOptions>(optionsFunc: () => T) {
const completions = persistent<DecimalSource>(0);
const active = persistent<boolean>(false, false);
return createLazyProxy(() => {
const options = optionsFunc();
const {
requirements,
canStart,
completionLimit,
display: _display,
reset,
onComplete,
onEnter,
onExit,
...props
} = options;
const vueFeature = vueFeatureMixin("challenge", options, () => (
<Challenge
active={challenge.active}
maxed={challenge.maxed}
canComplete={challenge.canComplete}
display={challenge.display}
requirements={challenge.requirements}
completed={challenge.completed}
canStart={challenge.canStart}
onToggle={challenge.toggle}
/>
));
let display: MaybeGetter<Renderable> | undefined = undefined;
if (typeof _display === "object" && !isJSXElement(_display)) {
const { title, description, goal, reward, effectDisplay } = _display;
display = () => (
<span>
{title != null ? (
<div>
{render(title, el => (
<h3>{el}</h3>
))}
</div>
) : null}
{render(description, el => (
<div>{el}</div>
))}
<div>
<br />
Goal:{" "}
{goal == null
? displayRequirements(challenge.requirements)
: render(goal, el => <h3>{el}</h3>)}
</div>
{reward != null ? (
<div>
<br />
Reward: {render(reward)}
</div>
) : null}
{effectDisplay != null ? <div>Currently: {render(effectDisplay)}</div> : null}
</span>
);
} else if (_display != null) {
display = _display;
}
const challenge = {
type: ChallengeType,
...(props as Omit<typeof props, keyof VueFeature | keyof ChallengeOptions>),
...vueFeature,
completions,
active,
completed: computed(() => Decimal.gt(completions.value, 0)),
canComplete: computed(() => maxRequirementsMet(requirements)),
maxed: computed((): boolean =>
Decimal.gte(completions.value, unref(challenge.completionLimit))
),
canStart: processGetter(canStart) ?? true,
completionLimit: processGetter(completionLimit) ?? 1,
requirements,
reset,
onComplete,
onEnter,
onExit,
display,
toggle: function () {
if (active.value) {
if (
Decimal.gt(unref(challenge.canComplete), 0) &&
!unref<boolean>(challenge.maxed)
) {
const newCompletions = unref(challenge.canComplete);
completions.value = Decimal.min(
Decimal.add(challenge.completions.value, newCompletions),
unref(challenge.completionLimit)
);
onComplete?.();
}
active.value = false;
onExit?.();
reset?.reset();
} else if (
unref<boolean>(challenge.canStart) &&
isVisible(unref(challenge.visibility) ?? true) &&
!unref<boolean>(challenge.maxed)
) {
challenge.reset?.reset();
active.value = true;
onEnter?.();
}
},
complete: function (remainInChallenge?: boolean) {
const newCompletions = unref(challenge.canComplete);
if (
active.value &&
Decimal.gt(newCompletions, 0) &&
!unref<boolean>(challenge.maxed)
) {
completions.value = Decimal.min(
Decimal.add(challenge.completions.value, newCompletions),
unref(challenge.completionLimit)
);
onComplete?.();
if (remainInChallenge !== true) {
active.value = false;
onExit?.();
reset?.reset();
}
}
}
} satisfies Challenge;
if (challenge.reset != null) {
globalBus.on("reset", currentReset => {
if (currentReset === challenge.reset && active.value) {
challenge.toggle();
}
});
}
return challenge;
});
}
/**
* This will automatically complete a challenge when it's requirements are met.
* @param challenge The challenge to auto-complete
* @param autoActive Whether or not auto-completing should currently occur
* @param exitOnComplete Whether or not to exit the challenge after auto-completion
*/
export function setupAutoComplete(
challenge: Challenge,
autoActive: MaybeRefOrGetter<boolean> = true,
exitOnComplete = true
): WatchStopHandle {
const isActive = typeof autoActive === "function" ? computed(autoActive) : autoActive;
return watch(
[challenge.canComplete as Ref<DecimalSource>, isActive as Ref<boolean>],
([canComplete, isActive]) => {
if (Decimal.gt(canComplete, 0) && isActive) {
challenge.complete(!exitOnComplete);
}
}
);
}
/**
* Utility for taking an array of challenges where only one may be active at a time, and giving a ref to the one currently active (or null if none are active)
* @param challenges The list of challenges that are mutually exclusive
*/
export function createActiveChallenge(challenges: Challenge[]): Ref<Challenge | null> {
return computed(() => challenges.find(challenge => challenge.active.value) ?? null);
}
/**
* Utility for reporting if any challenge in a list is currently active. Intended for preventing entering a challenge if another is already active.
* @param challenges List of challenges that are mutually exclusive
*/
export function isAnyChallengeActive(
challenges: Challenge[] | Ref<Challenge | null>
): Ref<boolean> {
if (Array.isArray(challenges)) {
challenges = createActiveChallenge(challenges);
}
return computed(() => (challenges as Ref<Challenge | null>).value != null);
}
declare module "game/settings" {
interface Settings {
hideChallenges: boolean;
}
}
globalBus.on("loadSettings", settings => {
settings.hideChallenges ??= false;
});
globalBus.on("setupVue", () =>
registerSettingField(() => (
<Toggle
title={
<span class="option-title">
Hide maxed challenges
<desc>Hide challenges that have been fully completed.</desc>
</span>
}
onUpdate:modelValue={value => (settings.hideChallenges = value)}
modelValue={settings.hideChallenges}
/>
))
);

View file

@ -1,57 +0,0 @@
<template>
<button
@click="e => emits('click', e)"
@mousedown="start"
@mouseleave="stop"
@mouseup="stop"
@touchstart.passive="start"
@touchend.passive="stop"
@touchcancel.passive="stop"
:class="{
clickable: true,
can: unref(canClick),
locked: !unref(canClick)
}"
:disabled="!unref(canClick)"
>
<Component />
</button>
</template>
<script setup lang="tsx">
import "components/common/features.css";
import { MaybeGetter } from "util/computed";
import {
render,
Renderable,
setupHoldToClick
} from "util/vue";
import type { Component, MaybeRef } from "vue";
import { unref } from "vue";
const props = defineProps<{
canClick: MaybeRef<boolean>;
display?: MaybeGetter<Renderable>;
}>();
const emits = defineEmits<{
(e: "click", event?: MouseEvent | TouchEvent): void;
(e: "hold"): void;
}>();
const Component = () => props.display == null ? <></> : render(props.display);
const { start, stop } = setupHoldToClick(() => emits("hold"));
</script>
<style scoped>
.clickable {
min-height: 120px;
width: 120px;
font-size: 10px;
}
.clickable > * {
pointer-events: none;
}
</style>

View file

@ -1,186 +0,0 @@
import ClickableVue from "features/clickables/Clickable.vue";
import { findFeatures } from "features/feature";
import { globalBus } from "game/events";
import { persistent } from "game/persistence";
import { Unsubscribe } from "nanoevents";
import Decimal, { DecimalSource } from "util/bignum";
import { Direction } from "util/common";
import { MaybeGetter, processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies";
import { isJSXElement, render, Renderable, VueFeature, vueFeatureMixin } from "util/vue";
import { computed, MaybeRef, MaybeRefOrGetter, Ref, ref, unref } from "vue";
import { Bar, BarOptions, createBar } from "../bars/bar";
import { type Clickable, ClickableOptions } from "./clickable";
/** A symbol used to identify {@link Action} features. */
export const ActionType = Symbol("Action");
/**
* An object that configures an {@link Action}.
*/
export interface ActionOptions extends Omit<ClickableOptions, "onClick" | "onHold"> {
/** The cooldown during which the action cannot be performed again, in seconds. */
duration: MaybeRefOrGetter<DecimalSource>;
/** Whether or not the action should perform automatically when the cooldown is finished. */
autoStart?: MaybeRefOrGetter<boolean>;
/** A function that is called when the action is clicked. */
onClick: (amount: DecimalSource) => void;
/** A pass-through to the {@link Bar} used to display the cooldown progress for the action. */
barOptions?: Partial<BarOptions>;
}
/** An object that represents a feature that can be clicked upon, and then has a cooldown before it can be clicked again. */
export interface Action extends VueFeature {
/** The cooldown during which the action cannot be performed again, in seconds. */
duration: MaybeRef<DecimalSource>;
/** Whether or not the action should perform automatically when the cooldown is finished. */
autoStart: MaybeRef<boolean>;
/** Whether or not the action may be performed. */
canClick: MaybeRef<boolean>;
/** The display to use for this action. */
display?: MaybeGetter<Renderable>;
/** A function that is called when the action is clicked. */
onClick: (amount: DecimalSource) => void;
/** Whether or not the player is holding down the action. Actions will be considered clicked as soon as the cooldown completes when being held down. */
isHolding: Ref<boolean>;
/** The current amount of progress through the cooldown. */
progress: Ref<DecimalSource>;
/** The bar used to display the current cooldown progress. */
progressBar: Bar;
/** Update the cooldown the specified number of seconds */
update: (diff: number) => void;
/** A symbol that helps identify features of the same type. */
type: typeof ActionType;
}
/**
* Lazily creates an action with the given options.
* @param optionsFunc Action options.
*/
export function createAction<T extends ActionOptions>(optionsFunc?: () => T) {
const progress = persistent<DecimalSource>(0);
return createLazyProxy(() => {
const options = optionsFunc?.() ?? ({} as T);
const {
style,
duration,
canClick,
autoStart,
display: _display,
barOptions,
onClick,
...props
} = options;
const processedCanClick = processGetter(canClick) ?? true;
const processedStyle = processGetter(style);
const progressBar = createBar(() => ({
direction: Direction.Right,
width: 100,
height: 10,
borderStyle: { borderColor: "black" },
baseStyle: { marginTop: "-1px" },
progress: (): DecimalSource => Decimal.div(progress.value, unref(action.duration)),
...(barOptions as Omit<typeof barOptions, keyof VueFeature>)
}));
let display: MaybeGetter<Renderable>;
if (typeof _display === "object" && !isJSXElement(_display)) {
display = () => (
<span>
{_display.title != null ? (
<div>
{render(_display.title, el => (
<h3>{el}</h3>
))}
</div>
) : null}
{render(_display.description, el => (
<div>{el}</div>
))}
</span>
);
} else if (_display != null) {
display = _display;
}
const action = {
type: ActionType,
...(props as Omit<typeof props, keyof VueFeature | keyof ActionOptions>),
...vueFeatureMixin(
"action",
{
...options,
style: () => ({
cursor: Decimal.gte(progress.value, unref(action.duration))
? "pointer"
: "progress",
display: "flex",
flexDirection: "column",
...unref(processedStyle)
})
},
() => (
<ClickableVue
canClick={action.canClick}
onClick={action.onClick}
onHold={action.onClick}
display={action.display}
/>
)
),
progress,
isHolding: ref(false),
duration: processGetter(duration),
canClick: computed(
(): boolean =>
unref(processedCanClick) && Decimal.gte(progress.value, unref(action.duration))
),
autoStart: processGetter(autoStart) ?? false,
display: () => (
<>
<div style="flex-grow: 1" />
{display == null ? null : render(display)}
<div style="flex-grow: 1" />
{render(progressBar)}
</>
),
progressBar,
onClick: function () {
if (unref(action.canClick) === false) {
return;
}
const amount = Decimal.div(progress.value, unref(action.duration));
onClick?.call(action, amount);
progress.value = 0;
},
update: function (diff) {
const duration = unref(action.duration);
if (Decimal.gte(progress.value, duration)) {
progress.value = duration;
} else {
progress.value = Decimal.add(progress.value, diff);
if (action.isHolding.value || unref<boolean>(action.autoStart)) {
action.onClick();
}
}
}
} satisfies Action satisfies Omit<Clickable, "type"> & { type: typeof ActionType };
return action;
});
}
const listeners: Record<string, Unsubscribe | undefined> = {};
globalBus.on("addLayer", layer => {
const actions: Action[] = findFeatures(layer, ActionType) as Action[];
listeners[layer.id] = layer.on("postUpdate", (diff: number) => {
actions.forEach(action => action.update(diff));
});
});
globalBus.on("removeLayer", layer => {
// unsubscribe from postUpdate
listeners[layer.id]?.();
listeners[layer.id] = undefined;
});

View file

@ -1,136 +0,0 @@
import Clickable from "features/clickables/Clickable.vue";
import type { BaseLayer } from "game/layers";
import type { Unsubscribe } from "nanoevents";
import { MaybeGetter, processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies";
import {
isJSXElement,
render,
Renderable,
VueFeature,
vueFeatureMixin,
VueFeatureOptions
} from "util/vue";
import { computed, MaybeRef, MaybeRefOrGetter, unref } from "vue";
/** A symbol used to identify {@link Clickable} features. */
export const ClickableType = Symbol("Clickable");
/**
* An object that configures a {@link Clickable}.
*/
export interface ClickableOptions extends VueFeatureOptions {
/** Whether or not the clickable may be clicked. */
canClick?: MaybeRefOrGetter<boolean>;
/** The display to use for this clickable. */
display?:
| MaybeGetter<Renderable>
| {
/** A header to appear at the top of the display. */
title?: MaybeGetter<Renderable>;
/** The main text that appears in the display. */
description: MaybeGetter<Renderable>;
};
/** A function that is called when the clickable is clicked. */
onClick?: (e?: MouseEvent | TouchEvent) => void;
/** A function that is called when the clickable is held down. */
onHold?: VoidFunction;
}
/** An object that represents a feature that can be clicked or held down. */
export interface Clickable extends VueFeature {
/** A function that is called when the clickable is clicked. */
onClick?: (e?: MouseEvent | TouchEvent) => void;
/** A function that is called when the clickable is held down. */
onHold?: VoidFunction;
/** Whether or not the clickable may be clicked. */
canClick: MaybeRef<boolean>;
/** The display to use for this clickable. */
display?: MaybeGetter<Renderable>;
/** A symbol that helps identify features of the same type. */
type: typeof ClickableType;
}
/**
* Lazily creates a clickable with the given options.
* @param optionsFunc Clickable options.
*/
export function createClickable<T extends ClickableOptions>(optionsFunc?: () => T) {
return createLazyProxy(() => {
const options = optionsFunc?.() ?? ({} as T);
const { canClick, display: _display, onClick: onClick, onHold: onHold, ...props } = options;
let display: MaybeGetter<Renderable> | undefined = undefined;
if (typeof _display === "object" && !isJSXElement(_display)) {
display = () => (
<span>
{_display.title != null ? (
<div>
{render(_display.title, el => (
<h3>{el}</h3>
))}
</div>
) : null}
{render(_display.description, el => (
<div>{el}</div>
))}
</span>
);
} else if (_display != null) {
display = _display;
}
const clickable = {
type: ClickableType,
...(props as Omit<typeof props, keyof VueFeature | keyof ClickableOptions>),
...vueFeatureMixin("clickable", options, () => (
<Clickable
canClick={clickable.canClick}
onClick={clickable.onClick}
onHold={clickable.onClick}
display={clickable.display}
/>
)),
canClick: processGetter(canClick) ?? true,
display,
onClick:
onClick == null
? undefined
: function (e?: MouseEvent | TouchEvent) {
if (unref(clickable.canClick) !== false) {
onClick.call(clickable, e);
}
},
onHold:
onHold == null
? undefined
: function () {
if (unref(clickable.canClick) !== false) {
onHold.call(clickable);
}
}
} satisfies Clickable;
return clickable;
});
}
/**
* Utility to auto click a clickable whenever it can be.
* @param layer The layer the clickable is apart of
* @param clickable The clicker to click automatically
* @param autoActive Whether or not the clickable should currently be auto-clicking
*/
export function setupAutoClick(
layer: BaseLayer,
clickable: Clickable,
autoActive: MaybeRefOrGetter<boolean> = true
): Unsubscribe {
const isActive: MaybeRef<boolean> =
typeof autoActive === "function" ? computed(autoActive) : autoActive;
return layer.on("update", () => {
if (unref(isActive) && unref<boolean>(clickable.canClick)) {
clickable.onClick?.();
}
});
}

View file

@ -1,197 +0,0 @@
import Clickable from "features/clickables/Clickable.vue";
import { Visibility } from "features/feature";
import { DefaultValue, Persistent, persistent } from "game/persistence";
import {
createVisibilityRequirement,
displayRequirements,
maxRequirementsMet,
payRequirements,
Requirements,
requirementsMet
} from "game/requirements";
import type { DecimalSource } from "util/bignum";
import Decimal, { formatWhole } from "util/bignum";
import { MaybeGetter, processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies";
import { isJSXElement, render, Renderable, VueFeature, vueFeatureMixin } from "util/vue";
import type { MaybeRef, MaybeRefOrGetter, Ref } from "vue";
import { computed, unref } from "vue";
import { ClickableOptions } from "./clickable";
/** A symbol used to identify {@link Repeatable} features. */
export const RepeatableType = Symbol("Repeatable");
/** An object that configures a {@link Repeatable}. */
export interface RepeatableOptions extends ClickableOptions {
/** The requirement(s) to increase this repeatable. */
requirements: Requirements;
/** The maximum amount obtainable for this repeatable. */
limit?: MaybeRefOrGetter<DecimalSource>;
/** The initial amount this repeatable has on a new save / after reset. */
initialAmount?: DecimalSource;
/** The display to use for this repeatable. */
display?:
| MaybeGetter<Renderable>
| {
/** A header to appear at the top of the display. */
title?: MaybeGetter<Renderable>;
/** The main text that appears in the display. */
description: MaybeGetter<Renderable>;
/** A description of the current effect of this repeatable, based off its amount. */
effectDisplay?: MaybeGetter<Renderable>;
/** Whether or not to show the current amount of this repeatable at the bottom of the display. */
showAmount?: boolean;
};
}
/** An object that represents a feature with multiple "levels" with scaling requirements. */
export interface Repeatable extends VueFeature {
/** The requirement(s) to increase this repeatable. */
requirements: Requirements;
/** The maximum amount obtainable for this repeatable. */
limit: MaybeRef<DecimalSource>;
/** The initial amount this repeatable has on a new save / after reset. */
initialAmount?: DecimalSource;
/** The display to use for this repeatable. */
display?: MaybeGetter<Renderable>;
/** Whether or not the repeatable may be clicked. */
canClick: Ref<boolean>;
/** A function that is called when the repeatable is clicked. */
onClick: (event?: MouseEvent | TouchEvent) => void;
/** The current amount this repeatable has. */
amount: Persistent<DecimalSource>;
/** Whether or not this repeatable's amount is at it's limit. */
maxed: Ref<boolean>;
/** How much amount can be increased by, or 1 if unclickable. **/
amountToIncrease: Ref<DecimalSource>;
/** A symbol that helps identify features of the same type. */
type: typeof RepeatableType;
}
/**
* Lazily creates a repeatable with the given options.
* @param optionsFunc Repeatable options.
*/
export function createRepeatable<T extends RepeatableOptions>(optionsFunc: () => T) {
const amount = persistent<DecimalSource>(0);
return createLazyProxy(() => {
const options = optionsFunc();
const {
requirements: _requirements,
display: _display,
limit,
onClick,
initialAmount,
...props
} = options;
if (options.classes == null) {
options.classes = computed(() => ({ bought: unref(repeatable.maxed) }));
} else {
const classes = processGetter(options.classes);
options.classes = computed(() => ({
...unref(classes),
bought: unref(repeatable.maxed)
}));
}
const vueFeature = vueFeatureMixin("repeatable", options, () => (
<Clickable
canClick={repeatable.canClick}
onClick={repeatable.onClick}
onHold={repeatable.onClick}
display={repeatable.display}
/>
));
const limitRequirement = {
requirementMet: computed(
(): DecimalSource => Decimal.sub(unref(repeatable.limit), unref(amount))
),
requiresPay: false,
visibility: Visibility.None,
canMaximize: true
} as const;
const requirements: Requirements = [
...(Array.isArray(_requirements) ? _requirements : [_requirements]),
limitRequirement
];
if (vueFeature.visibility != null) {
requirements.push(createVisibilityRequirement(vueFeature.visibility));
}
let display;
if (typeof _display === "object" && !isJSXElement(_display)) {
const { title, description, effectDisplay, showAmount } = _display;
display = () => (
<span>
{title == null ? null : (
<div>
{render(title, el => (
<h3>{el}</h3>
))}
</div>
)}
{render(description)}
{showAmount === false ? null : (
<div>
<br />
<>Amount: {formatWhole(unref(amount))}</>
{Decimal.isFinite(unref(repeatable.limit)) ? (
<> / {formatWhole(unref(repeatable.limit))}</>
) : undefined}
</div>
)}
{effectDisplay == null ? null : (
<div>
<br />
Currently: {render(effectDisplay)}
</div>
)}
{unref(repeatable.maxed) ? null : (
<div>
<br />
{displayRequirements(requirements, unref(repeatable.amountToIncrease))}
</div>
)}
</span>
);
} else if (_display != null) {
display = _display;
}
amount[DefaultValue] = initialAmount ?? 0;
const repeatable = {
type: RepeatableType,
...(props as Omit<typeof props, keyof VueFeature | keyof RepeatableOptions>),
...vueFeature,
amount,
requirements,
initialAmount,
limit: processGetter(limit) ?? Decimal.dInf,
classes: computed(() => {
const currClasses = unref(vueFeature.classes) || {};
if (unref(repeatable.maxed)) {
currClasses.bought = true;
}
return currClasses;
}),
maxed: computed((): boolean => Decimal.gte(unref(amount), unref(repeatable.limit))),
canClick: computed(() => requirementsMet(requirements)),
amountToIncrease: computed(() => Decimal.clampMin(maxRequirementsMet(requirements), 1)),
onClick(event?: MouseEvent | TouchEvent) {
if (!unref(repeatable.canClick)) {
return;
}
const purchaseAmount = unref(repeatable.amountToIncrease) ?? 1;
payRequirements(requirements, purchaseAmount);
amount.value = Decimal.add(unref(amount), purchaseAmount);
onClick?.(event);
},
display
} satisfies Repeatable;
return repeatable;
});
}

View file

@ -1,174 +0,0 @@
import { findFeatures } from "features/feature";
import { Layer } from "game/layers";
import type { Persistent } from "game/persistence";
import { persistent } from "game/persistence";
import {
Requirements,
createVisibilityRequirement,
displayRequirements,
payRequirements,
requirementsMet
} from "game/requirements";
import { isFunction } from "util/common";
import { MaybeGetter, processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies";
import {
Renderable,
VueFeature,
VueFeatureOptions,
isJSXElement,
render,
vueFeatureMixin
} from "util/vue";
import type { MaybeRef, MaybeRefOrGetter, Ref } from "vue";
import { computed, unref } from "vue";
import Clickable from "./Clickable.vue";
import { ClickableOptions } from "./clickable";
/** A symbol used to identify {@link Upgrade} features. */
export const UpgradeType = Symbol("Upgrade");
/**
* An object that configures a {@link Upgrade}.
*/
export interface UpgradeOptions extends VueFeatureOptions, ClickableOptions {
/** The display to use for this upgrade. */
display?:
| MaybeGetter<Renderable>
| {
/** A header to appear at the top of the display. */
title?: MaybeGetter<Renderable>;
/** The main text that appears in the display. */
description: MaybeGetter<Renderable>;
/** A description of the current effect of the achievement. Useful when the effect changes dynamically. */
effectDisplay?: MaybeGetter<Renderable>;
};
/** The requirements to purchase this upgrade. */
requirements: Requirements;
/** A function that is called when the upgrade is purchased. */
onPurchase?: VoidFunction;
}
/** An object that represents a feature that can be purchased a single time. */
export interface Upgrade extends VueFeature {
/** The requirements to purchase this upgrade. */
requirements: Requirements;
/** The display to use for this upgrade. */
display?: MaybeGetter<Renderable>;
/** Whether or not this upgrade has been purchased. */
bought: Persistent<boolean>;
/** Whether or not the upgrade can currently be purchased. */
canPurchase: Ref<boolean>;
/** A function that is called when the upgrade is purchased. */
onPurchase?: VoidFunction;
/** Purchase the upgrade */
purchase: VoidFunction;
/** A symbol that helps identify features of the same type. */
type: typeof UpgradeType;
}
/**
* Lazily creates an upgrade with the given options.
* @param optionsFunc Upgrade options.
*/
export function createUpgrade<T extends UpgradeOptions>(optionsFunc: () => T) {
const bought = persistent<boolean>(false, false);
return createLazyProxy(() => {
const options = optionsFunc();
const { requirements: _requirements, display: _display, onHold, ...props } = options;
if (options.classes == null) {
options.classes = computed(() => ({ bought: unref(upgrade.bought) }));
} else {
const classes = processGetter(options.classes);
options.classes = computed(() => ({
...unref(classes),
bought: unref(upgrade.bought)
}));
}
const vueFeature = vueFeatureMixin("upgrade", options, () => (
<Clickable
onClick={upgrade.purchase}
onHold={upgrade.onHold}
canClick={upgrade.canPurchase}
display={upgrade.display}
/>
));
const requirements = Array.isArray(_requirements) ? _requirements : [_requirements];
if (vueFeature.visibility != null) {
requirements.push(createVisibilityRequirement(vueFeature.visibility));
}
let display;
if (typeof _display === "object" && !isJSXElement(_display)) {
const { title, description, effectDisplay } = _display;
display = () => (
<span>
{title != null ? (
<div>
{render(title, el => (
<h3>{el}</h3>
))}
</div>
) : null}
{render(description, el => (
<div>{el}</div>
))}
{effectDisplay != null ? <div>Currently: {render(effectDisplay)}</div> : null}
{bought.value ? null : (
<>
<br />
{displayRequirements(requirements)}
</>
)}
</span>
);
} else if (_display != null) {
display = _display;
}
const upgrade = {
type: UpgradeType,
...(props as Omit<typeof props, keyof VueFeature | keyof UpgradeOptions>),
...vueFeature,
bought,
canPurchase: computed(() => !bought.value && requirementsMet(requirements)),
requirements,
display,
onHold,
purchase() {
if (!unref(upgrade.canPurchase)) {
return;
}
payRequirements(requirements);
bought.value = true;
options.onPurchase?.();
}
} satisfies Upgrade;
return upgrade;
});
}
/**
* Utility to auto purchase a list of upgrades whenever they're affordable.
* @param layer The layer the upgrades are apart of
* @param autoActive Whether or not the upgrades should currently be auto-purchasing
* @param upgrades The specific upgrades to upgrade. If unspecified, uses all upgrades on the layer.
*/
export function setupAutoPurchase(
layer: Layer,
autoActive: MaybeRefOrGetter<boolean>,
upgrades: Upgrade[] = []
): void {
upgrades = upgrades.length === 0 ? (findFeatures(layer, UpgradeType) as Upgrade[]) : upgrades;
const isAutoActive: MaybeRef<boolean> = isFunction(autoActive)
? computed(autoActive)
: autoActive;
layer.on("update", () => {
if (unref(isAutoActive)) {
upgrades.forEach(upgrade => upgrade.purchase());
}
});
}

View file

@ -1,320 +0,0 @@
import type { Resource } from "features/resources/resource";
import Formula from "game/formulas/formulas";
import { InvertibleFormula, InvertibleIntegralFormula } from "game/formulas/types";
import type { BaseLayer } from "game/layers";
import { createBooleanRequirement } from "game/requirements";
import type { DecimalSource } from "util/bignum";
import Decimal from "util/bignum";
import { MaybeGetter, processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies";
import { Renderable } from "util/vue";
import { computed, MaybeRef, MaybeRefOrGetter, unref } from "vue";
/** A symbol used to identify {@link Conversion} features. */
export const ConversionType = Symbol("Conversion");
/** An object that configures a {@link Conversion}. */
export interface ConversionOptions {
/**
* The formula used to determine how much {@link gainResource} should be earned by this converting.
* The passed value will be a Formula representing the {@link baseResource} variable.
*/
formula: (variable: InvertibleIntegralFormula) => InvertibleFormula;
/**
* How much of the output resource the conversion can currently convert for.
* Typically this will be set for you in a conversion constructor.
*/
currentGain?: MaybeRefOrGetter<DecimalSource>;
/**
* The absolute amount the output resource will be changed by.
* Typically this will be set for you in a conversion constructor.
* This will differ from {@link currentGain} in the cases where the conversion isn't just adding the converted amount to the output resource.
*/
actualGain?: MaybeRefOrGetter<DecimalSource>;
/**
* The amount of the input resource currently being required in order to produce the {@link currentGain}.
* That is, if it went below this value then {@link currentGain} would decrease.
* Typically this will be set for you in a conversion constructor.
*/
currentAt?: MaybeRefOrGetter<DecimalSource>;
/**
* The amount of the input resource required to make {@link currentGain} increase.
* Typically this will be set for you in a conversion constructor.
*/
nextAt?: MaybeRefOrGetter<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?: MaybeRefOrGetter<boolean>;
/**
* The function that performs the actual conversion from {@link baseResource} to {@link gainResource}.
* Typically this will be set for you in a conversion constructor.
*/
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;
}
/**
* The properties that are added onto a processed {@link ConversionOptions} to create a {@link Conversion}.
*/
export interface Conversion {
/**
* The formula used to determine how much {@link gainResource} should be earned by this converting.
*/
formula: InvertibleFormula;
/**
* How much of the output resource the conversion can currently convert for.
* Typically this will be set for you in a conversion constructor.
*/
currentGain: MaybeRef<DecimalSource>;
/**
* The absolute amount the output resource will be changed by.
* Typically this will be set for you in a conversion constructor.
* This will differ from {@link currentGain} in the cases where the conversion isn't just adding the converted amount to the output resource.
*/
actualGain: MaybeRef<DecimalSource>;
/**
* The amount of the input resource currently being required in order to produce the {@link currentGain}.
* That is, if it went below this value then {@link currentGain} would decrease.
* Typically this will be set for you in a conversion constructor.
*/
currentAt: MaybeRef<DecimalSource>;
/**
* The amount of the input resource required to make {@link currentGain} increase.
* Typically this will be set for you in a conversion constructor.
*/
nextAt: MaybeRef<DecimalSource>;
/**
* The input {@link features/resources/resource.Resource} for this conversion.
*/
baseResource: Resource;
/**
* The output {@link features/resources/resource.Resource} for this conversion. i.e. the resource being generated.
*/
gainResource: Resource;
/**
* Whether or not to cap the amount of the output resource gained by converting at 1.
* Defaults to true.
*/
buyMax: MaybeRef<boolean>;
/**
* The function that performs the actual conversion from {@link baseResource} to {@link gainResource}.
* Typically this will be set for you in a conversion constructor.
*/
convert: VoidFunction;
/**
* The function that spends the {@link baseResource} as part of the conversion.
* Defaults to setting the {@link baseResource} amount to 0.
*/
spend: (amountGained: DecimalSource) => void;
/**
* A callback that happens after a conversion has been completed.
* Receives the amount gained via conversion.
* This will not be called whenever using currentGain without calling convert (e.g. passive generation)
*/
onConvert?: (amountGained: DecimalSource) => void;
}
/**
* 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: () => T) {
return createLazyProxy(() => {
const options = optionsFunc();
const {
baseResource,
gainResource,
formula,
currentGain: _currentGain,
actualGain,
currentAt,
nextAt,
convert,
spend,
buyMax,
onConvert,
...props
} = options;
const currentGain =
_currentGain == null
? computed((): Decimal => {
let gain = Decimal.floor(conversion.formula.evaluate(baseResource.value)).max(
0
);
if (unref(conversion.buyMax) === false) {
gain = gain.min(1);
}
return gain;
})
: processGetter(_currentGain);
const conversion = {
type: ConversionType,
...(props as Omit<typeof props, keyof ConversionOptions>),
baseResource,
gainResource,
formula: formula(Formula.variable(baseResource)),
currentGain,
actualGain: actualGain == null ? currentGain : processGetter(actualGain),
currentAt:
currentAt == null
? computed(
(): DecimalSource =>
conversion.formula.invert(
Decimal.floor(unref(conversion.currentGain))
)
)
: processGetter(currentAt),
nextAt:
nextAt == null
? computed(
(): DecimalSource =>
conversion.formula.invert(
Decimal.floor(unref(conversion.currentGain)).add(1)
)
)
: processGetter(nextAt),
convert:
convert ??
function () {
const amountGained = unref(conversion.currentGain);
gainResource.value = Decimal.add(gainResource.value, amountGained);
conversion.spend(amountGained);
onConvert?.(amountGained);
},
spend: spend ?? (() => (baseResource.value = 0)),
buyMax: processGetter(buyMax) ?? true,
onConvert
} satisfies Conversion;
return conversion;
});
}
/**
* 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<T extends ConversionOptions>(optionsFunc: () => T) {
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<T extends ConversionOptions>(optionsFunc: () => T) {
const conversion = createConversion(() => {
const options = optionsFunc();
options.buyMax ??= false;
options.currentGain ??= computed(() => {
let gain = Decimal.floor(conversion.formula.evaluate(options.baseResource.value)).max(
options.gainResource.value
);
if (unref(options.buyMax as MaybeRef<boolean>) === false) {
gain = gain.min(Decimal.add(options.gainResource.value, 1));
}
return gain;
});
options.actualGain ??= computed(() => {
let gain = Decimal.sub(
conversion.formula.evaluate(options.baseResource.value),
options.gainResource.value
)
.floor()
.max(0);
if (unref(options.buyMax as MaybeRef<boolean>) === false) {
gain = gain.min(1);
}
return gain;
});
options.convert ??= function () {
const amountGained = unref(conversion.actualGain);
options.gainResource.value = unref(conversion.currentGain);
conversion.spend(amountGained);
conversion.onConvert?.(amountGained);
};
return options;
});
return conversion;
}
/**
* 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.
*/
export function setupPassiveGeneration(
layer: BaseLayer,
conversion: Conversion,
rate: MaybeRefOrGetter<DecimalSource> = 1,
cap: MaybeRefOrGetter<DecimalSource> = Decimal.dInf
): void {
const processedRate = processGetter(rate);
const processedCap = processGetter(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))
.max(conversion.gainResource.value);
}
});
}
/**
* Creates requirement that is met when the conversion hits a specified gain amount
* @param conversion The conversion to check the gain amount of
* @param minGainAmount The minimum gain amount that must be met for the requirement to be met
*/
export function createCanConvertRequirement(
conversion: Conversion,
minGainAmount: MaybeRefOrGetter<DecimalSource> = 1,
display?: MaybeGetter<Renderable>
) {
const computedMinGainAmount = processGetter(minGainAmount);
return createBooleanRequirement(
() => Decimal.gte(unref(conversion.actualGain), unref(computedMinGainAmount)),
display
);
}

View file

@ -1,137 +0,0 @@
import Decimal from "util/bignum";
import { Renderable, renderCol, VueFeature } from "util/vue";
import { computed, isRef, MaybeRef, Ref, unref } from "vue";
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
}
/**
* Utility function for determining if a visibility value is anything but Visibility.None.
Booleans are allowed and false will be considered to be Visibility.None.
* @param visibility The ref to either a visibility value or boolean
* @returns True if the visibility is either true, Visibility.Visible, or Visibility.Hidden
*/
export function isVisible(visibility: MaybeRef<Visibility | boolean>) {
const currVisibility = unref(visibility);
return currVisibility !== Visibility.None && currVisibility !== false;
}
/**
* Utility function for determining if a visibility value is Visibility.Hidden.
Booleans are allowed but will never be considered to be Visible.Hidden.
* @param visibility The ref to either a visibility value or boolean
* @returns True if the visibility is Visibility.Hidden
*/
export function isHidden(visibility: MaybeRef<Visibility | boolean>) {
const currVisibility = unref(visibility);
return currVisibility === Visibility.Hidden;
}
/**
* Utility function for narrowing something that may or may not be a specified type of feature.
* Works off the principle that all features have a unique symbol to identify themselves with.
* @param object The object to determine whether or not is of the specified type
* @param type The symbol to look for in the object's "type" property
* @returns Whether or not the object is the specified type
*/
export function isType<T extends symbol>(object: unknown, type: T): object is { type: T } {
return object != null && typeof object === "object" && "type" in object && object.type === type;
}
/**
* 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: object, ...types: symbol[]): unknown[] {
const objects: unknown[] = [];
const handleObject = (obj: object) => {
Object.keys(obj).forEach(key => {
const value: unknown = obj[key as keyof typeof obj];
if (
value != null &&
typeof value === "object" &&
(value as Record<string, unknown>).__v_isVNode !== true
) {
if (types.includes((value as Record<string, unknown>).type as symbol)) {
objects.push(value);
} else if (!(value instanceof Decimal) && !isRef(value)) {
handleObject(value as Record<string, unknown>);
}
}
});
};
handleObject(obj);
return objects;
}
/**
* Utility function for taking a list of features and filtering them out, but keeping a reference to the first filtered out feature. Used for having a collapsible of the filtered out content, with the first filtered out item remaining outside the collapsible for easy reference.
* @param features The list of features to search through
* @param filter The filter to use to determine features that shouldn't be collapsible
* @returns An object containing a ref to the first filtered _out_ feature, a render function for the collapsed content, and a ref for whether or not there is any collapsed content to show
*/
export function getFirstFeature<T extends VueFeature>(
features: T[],
filter: (feature: T) => boolean
): {
firstFeature: Ref<T | undefined>;
collapsedContent: () => Renderable;
hasCollapsedContent: Ref<boolean>;
} {
const filteredFeatures = computed(() =>
features.filter(feature => isVisible(feature.visibility ?? true) && filter(feature))
);
return {
firstFeature: computed(() => filteredFeatures.value[0]),
collapsedContent: () => renderCol(...filteredFeatures.value.slice(1)),
hasCollapsedContent: computed(() => filteredFeatures.value.length > 1)
};
}
/**
* Traverses an object and returns all features that are _not_ any of the given types.
* Features are any object with a "type" property that has a symbol value.
* @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" &&
(value as Record<string, unknown>).__v_isVNode !== true
) {
const type = (value as Record<string, unknown>).type;
if (typeof type === "symbol" && !types.includes(type)) {
objects.push(value);
} else if (!(value instanceof Decimal) && !isRef(value)) {
handleObject(value as Record<string, unknown>);
}
}
});
};
handleObject(obj);
return objects;
}

Some files were not shown because too many files have changed in this diff Show more