Initial commit
28
.eslintrc.js
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
require("@rushstack/eslint-patch/modern-module-resolution");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: {
|
||||||
|
node: true
|
||||||
|
},
|
||||||
|
extends: [
|
||||||
|
"plugin:vue/vue3-essential",
|
||||||
|
"@vue/eslint-config-typescript/recommended",
|
||||||
|
"@vue/eslint-config-prettier"
|
||||||
|
],
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2020
|
||||||
|
},
|
||||||
|
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"
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
defineProps: "readonly",
|
||||||
|
defineEmits: "readonly"
|
||||||
|
}
|
||||||
|
};
|
23
.gitignore
vendored
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
/dist
|
||||||
|
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
7
.prettierrc.json
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"endOfLine": "auto",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"trailingComma": "none"
|
||||||
|
}
|
2
.replit
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
language = "nodejs"
|
||||||
|
run = "npm run serve"
|
3
.vs/ProjectSettings.json
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"CurrentProjectSetting": null
|
||||||
|
}
|
10
.vs/VSWorkspaceState.json
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"ExpandedNodes": [
|
||||||
|
"",
|
||||||
|
"\\src",
|
||||||
|
"\\src\\typings",
|
||||||
|
"\\src\\util"
|
||||||
|
],
|
||||||
|
"SelectedNode": "\\src\\App.vue",
|
||||||
|
"PreviewInSolutionExplorer": false
|
||||||
|
}
|
BIN
.vs/slnx.sqlite
Normal file
21
LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2021 Anthony Lawn
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
24
README.md
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
# Profectus
|
||||||
|
|
||||||
|
## Project setup
|
||||||
|
```
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compiles and hot-reloads for development
|
||||||
|
```
|
||||||
|
npm run serve
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compiles and minifies for production
|
||||||
|
```
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lints and fixes files
|
||||||
|
```
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Customize configuration
|
||||||
|
See [Configuration Reference](https://cli.vuejs.org/config/).
|
12
babel.config.js
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
module.exports = {
|
||||||
|
presets: ["@vue/cli-plugin-babel/preset"],
|
||||||
|
plugins: [
|
||||||
|
[
|
||||||
|
"module:@jetblack/operator-overloading",
|
||||||
|
{
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@vue/babel-plugin-jsx"
|
||||||
|
]
|
||||||
|
};
|
21517
package-lock.json
generated
Normal file
58
package.json
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
{
|
||||||
|
"name": "profectus",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"serve": "vue-cli-service serve",
|
||||||
|
"build": "vue-cli-service build",
|
||||||
|
"lint": "vue-cli-service lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"core-js": "^3.6.5",
|
||||||
|
"lodash.clonedeep": "^4.5.0",
|
||||||
|
"nanoevents": "^6.0.2",
|
||||||
|
"vue": "^3.2.26",
|
||||||
|
"vue-next-select": "^2.10.2",
|
||||||
|
"vue-panzoom": "^1.1.6",
|
||||||
|
"vue-textarea-autosize": "^1.1.1",
|
||||||
|
"vue-toastification": "^2.0.0-rc.1",
|
||||||
|
"vue-transition-expand": "^0.1.0",
|
||||||
|
"vuedraggable": "^4.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@ivanv/vue-collapse-transition": "^1.0.2",
|
||||||
|
"@jetblack/operator-overloading": "^0.2.0",
|
||||||
|
"@rushstack/eslint-patch": "^1.1.0",
|
||||||
|
"@types/lodash.clonedeep": "^4.5.6",
|
||||||
|
"@vue/babel-plugin-jsx": "^1.1.1",
|
||||||
|
"@vue/cli-plugin-babel": "~5.0.0-rc.1",
|
||||||
|
"@vue/cli-plugin-eslint": "~5.0.0-rc.1",
|
||||||
|
"@vue/cli-plugin-typescript": "~5.0.0-rc.1",
|
||||||
|
"@vue/cli-service": "~5.0.0-rc.1",
|
||||||
|
"@vue/compiler-sfc": "^3.2.26",
|
||||||
|
"@vue/eslint-config-prettier": "^7.0.0",
|
||||||
|
"@vue/eslint-config-typescript": "^10.0.0",
|
||||||
|
"babel-eslint": "^10.1.0",
|
||||||
|
"eslint": "^8.6.0",
|
||||||
|
"prettier": "^2.5.1",
|
||||||
|
"raw-loader": "^4.0.2",
|
||||||
|
"sass": "^1.48.0",
|
||||||
|
"sass-loader": "^10.2.0",
|
||||||
|
"tsconfig-paths-webpack-plugin": "^3.5.1",
|
||||||
|
"typescript": "^4.5.4"
|
||||||
|
},
|
||||||
|
"browserslist": [
|
||||||
|
"> 1%",
|
||||||
|
"last 2 versions",
|
||||||
|
"not dead"
|
||||||
|
],
|
||||||
|
"gitHooks": {
|
||||||
|
"pre-commit": "lint-staged"
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*.{js,jsx,ts,tsx,vue}": [
|
||||||
|
"vue-cli-service lint",
|
||||||
|
"git add"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
BIN
public/Logo.png
Normal file
After Width: | Height: | Size: 19 KiB |
BIN
public/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
public/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 38 KiB |
BIN
public/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 9.5 KiB |
BIN
public/favicon-16x16.png
Normal file
After Width: | Height: | Size: 442 B |
BIN
public/favicon-32x32.png
Normal file
After Width: | Height: | Size: 873 B |
BIN
public/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
29
public/index.html
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
<!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="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><%= htmlWebpackPlugin.options.title %></title>
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||||
|
</noscript>
|
||||||
|
<div id="app"></div>
|
||||||
|
<!-- built files will be auto injected -->
|
||||||
|
</body>
|
||||||
|
</html>
|
1
public/site.webmanifest
Normal 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"}
|
0
saves/.placehold
Normal file
1
saves/safff.txt
Normal file
|
@ -0,0 +1 @@
|
||||||
|
eyJpZCI6InRtdC14LTEwNSIsIm5hbWUiOiJEZWZhdWx0IFNhZmZmZiAtIHNvbWV0aGluZyBlbHNlIiwidGFicyI6WyJtYWluIiwiYyJdLCJ0aW1lIjoxNjI0MjQ1MjYxMDg3LCJhdXRvc2F2ZSI6dHJ1ZSwib2ZmbGluZVByb2QiOnRydWUsInRpbWVQbGF5ZWQiOiIzNDQ4LjYxNTc4MTcwOTAxIiwia2VlcEdvaW5nIjpmYWxzZSwibGFzdFRlblRpY2tzIjpbMC4wNTEsMC4wNSwwLjA0OSwwLjA1LDAuMDUsMC4wNTEsMC4wNDksMC4wNSwwLjA1LDAuMDUxXSwic2hvd1RQUyI6dHJ1ZSwibXNEaXNwbGF5IjoiYWxsIiwiaGlkZUNoYWxsZW5nZXMiOmZhbHNlLCJ0aGVtZSI6InBhcGVyIiwic3VidGFicyI6e30sIm1pbmltaXplZCI6e30sIm1vZElEIjoidG10LXgiLCJtb2RWZXJzaW9uIjoiMC4wIiwicG9pbnRzIjoiMzMwMC4zNzc3NzM4NTkwNTUiLCJtYWluIjp7InVwZ3JhZGVzIjpbXSwiYWNoaWV2ZW1lbnRzIjpbXSwibWlsZXN0b25lcyI6W10sImluZm9ib3hlcyI6e319LCJmIjp7InVwZ3JhZGVzIjpbXSwiYWNoaWV2ZW1lbnRzIjpbXSwibWlsZXN0b25lcyI6W10sImluZm9ib3hlcyI6e30sImNsaWNrYWJsZXMiOnsiMTEiOiJTdGFydCJ9LCJ1bmxvY2tlZCI6ZmFsc2UsInBvaW50cyI6IjAiLCJib29wIjpmYWxzZX0sImMiOnsidXBncmFkZXMiOlsiMTEiXSwiYWNoaWV2ZW1lbnRzIjpbXSwibWlsZXN0b25lcyI6W10sImluZm9ib3hlcyI6e30sImJ1eWFibGVzIjp7IjExIjoiMCJ9LCJjaGFsbGVuZ2VzIjp7IjExIjoiMCJ9LCJ1bmxvY2tlZCI6dHJ1ZSwicG9pbnRzIjoiMCIsImJlc3QiOiIxIiwidG90YWwiOiIwIiwiYmVlcCI6ZmFsc2UsInRoaW5neSI6InBvaW50eSIsIm90aGVyVGhpbmd5IjoxMCwic3BlbnRPbkJ1eWFibGVzIjoiMCJ9LCJhIjp7InVwZ3JhZGVzIjpbXSwiYWNoaWV2ZW1lbnRzIjpbIjExIl0sIm1pbGVzdG9uZXMiOltdLCJpbmZvYm94ZXMiOnt9LCJ1bmxvY2tlZCI6dHJ1ZSwicG9pbnRzIjoiMCJ9LCJnIjp7InVwZ3JhZGVzIjpbXSwiYWNoaWV2ZW1lbnRzIjpbXSwibWlsZXN0b25lcyI6W10sImluZm9ib3hlcyI6e319LCJoIjp7InVwZ3JhZGVzIjpbXSwiYWNoaWV2ZW1lbnRzIjpbXSwibWlsZXN0b25lcyI6W10sImluZm9ib3hlcyI6e319LCJzcG9vayI6eyJ1cGdyYWRlcyI6W10sImFjaGlldmVtZW50cyI6W10sIm1pbGVzdG9uZXMiOltdLCJpbmZvYm94ZXMiOnt9fSwib29tcHNNYWciOjAsImxhc3RQb2ludHMiOiIzMzAwLjM3Nzc3Mzg1OTA1NSJ9
|
48
src/App.vue
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
<template>
|
||||||
|
<div id="modal-root" :style="theme" />
|
||||||
|
<div class="app" @mousemove="updateMouse" :style="theme" :class="{ useHeader }">
|
||||||
|
<Nav v-if="useHeader" />
|
||||||
|
<Game />
|
||||||
|
<TPS v-if="unref(showTPS)" />
|
||||||
|
<GameOverScreen />
|
||||||
|
<NaNScreen />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, toRef, unref } from "vue";
|
||||||
|
import Game from "./components/Game.vue";
|
||||||
|
import GameOverScreen from "./components/GameOverScreen.vue";
|
||||||
|
import NaNScreen from "./components/NaNScreen.vue";
|
||||||
|
import Nav from "./components/Nav.vue";
|
||||||
|
import TPS from "./components/TPS.vue";
|
||||||
|
import modInfo from "./data/modInfo.json";
|
||||||
|
import themes from "./data/themes";
|
||||||
|
import settings from "./game/settings";
|
||||||
|
import "./main.css";
|
||||||
|
|
||||||
|
function updateMouse(/* event */) {
|
||||||
|
// TODO use event to update mouse position for particles
|
||||||
|
}
|
||||||
|
|
||||||
|
const useHeader = modInfo.useHeader;
|
||||||
|
const theme = computed(() => themes[settings.theme].variables);
|
||||||
|
const showTPS = toRef(settings, "showTPS");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app {
|
||||||
|
background-color: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
min-height: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#modal-root {
|
||||||
|
position: absolute;
|
||||||
|
min-height: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
86
src/components/Game.vue
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
<template>
|
||||||
|
<div class="tabs-container">
|
||||||
|
<div v-for="(tab, index) in tabs" :key="index" class="tab" :ref="`tab-${index}`">
|
||||||
|
<Nav v-if="index === 0 && !useHeader" />
|
||||||
|
<div class="inner-tab">
|
||||||
|
<Layer
|
||||||
|
v-if="layerKeys.includes(tab)"
|
||||||
|
v-bind="gatherLayerProps(layers[tab]!)"
|
||||||
|
:index="index"
|
||||||
|
:tab="() => (($refs[`tab-${index}`] as HTMLElement[] | undefined)?.[0])"
|
||||||
|
/>
|
||||||
|
<component :is="tab" :index="index" v-else />
|
||||||
|
</div>
|
||||||
|
<div class="separator" v-if="index !== tabs.length - 1"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import modInfo from "@/data/modInfo.json";
|
||||||
|
import { GenericLayer, layers } from "@/game/layers";
|
||||||
|
import player from "@/game/player";
|
||||||
|
import { computed, toRef } from "vue";
|
||||||
|
import Layer from "./Layer.vue";
|
||||||
|
import Nav from "./Nav.vue";
|
||||||
|
|
||||||
|
const tabs = toRef(player, "tabs");
|
||||||
|
const layerKeys = computed(() => Object.keys(layers));
|
||||||
|
const useHeader = modInfo.useHeader;
|
||||||
|
|
||||||
|
function gatherLayerProps(layer: GenericLayer) {
|
||||||
|
const { display, minimized, minWidth, name, color, style, classes, links, minimizable } = layer;
|
||||||
|
return { display, minimized, minWidth, name, color, style, classes, links, minimizable };
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tabs-container {
|
||||||
|
width: 100vw;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
position: absolute;
|
||||||
|
right: -4px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 8px;
|
||||||
|
background: var(--outline);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tab hr {
|
||||||
|
height: 4px;
|
||||||
|
border: none;
|
||||||
|
background: var(--outline);
|
||||||
|
margin: var(--feature-margin) -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab .modal-body hr {
|
||||||
|
margin: 7px 0;
|
||||||
|
}
|
||||||
|
</style>
|
107
src/components/GameOverScreen.vue
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
<template>
|
||||||
|
<Modal :model-value="isOpen">
|
||||||
|
<template v-slot:header>
|
||||||
|
<div class="game-over-modal-header">
|
||||||
|
<img class="game-over-modal-logo" v-if="logo" :src="logo" :alt="title" />
|
||||||
|
<div class="game-over-modal-title">
|
||||||
|
<h2>Congratulations!</h2>
|
||||||
|
<h4>You've beaten {{ title }} v{{ versionNumber }}: {{ versionTitle }}</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body="{ shown }">
|
||||||
|
<div v-if="shown">
|
||||||
|
<div>It took you {{ timePlayed }} to beat the game.</div>
|
||||||
|
<br />
|
||||||
|
<div>
|
||||||
|
Please check the Discord to discuss the game or to check for new content
|
||||||
|
updates!
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<div>
|
||||||
|
<a :href="discordLink" class="game-over-modal-discord-link">
|
||||||
|
<span class="material-icons game-over-modal-discord">discord</span>
|
||||||
|
{{ discordName }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<Toggle title="Autosave" v-model="autosave" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:footer>
|
||||||
|
<div class="game-over-footer">
|
||||||
|
<button @click="keepGoing" class="button">Keep Going</button>
|
||||||
|
<button @click="playAgain" class="button danger">Play Again</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Modal from "@/components/Modal.vue";
|
||||||
|
import { hasWon } from "@/data/mod";
|
||||||
|
import modInfo from "@/data/modInfo.json";
|
||||||
|
import player from "@/game/player";
|
||||||
|
import { formatTime } from "@/util/bignum";
|
||||||
|
import { loadSave, newSave } from "@/util/save";
|
||||||
|
import { computed, toRef } from "vue";
|
||||||
|
import Toggle from "./fields/Toggle.vue";
|
||||||
|
|
||||||
|
const { title, logo, discordName, discordLink, versionNumber, versionTitle } = modInfo;
|
||||||
|
|
||||||
|
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>
|
112
src/components/Info.vue
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
<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 }}: {{ versionTitle }}</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body="{ shown }">
|
||||||
|
<div v-if="shown">
|
||||||
|
<div v-if="author">By {{ author }}</div>
|
||||||
|
<div>
|
||||||
|
Made in Profectus, by thepaperpilot with inspiration from Acameada and Jacorb
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<div class="link" @click="openChangelog">Changelog</div>
|
||||||
|
<br />
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
:href="discordLink"
|
||||||
|
v-if="discordLink !== 'https://discord.gg/WzejVAx'"
|
||||||
|
class="info-modal-discord-link"
|
||||||
|
>
|
||||||
|
<span class="material-icons info-modal-discord">discord</span>
|
||||||
|
{{ discordName }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="https://discord.gg/WzejVAx" class="info-modal-discord-link">
|
||||||
|
<span class="material-icons info-modal-discord">discord</span>
|
||||||
|
The Paper Pilot Community
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="https://discord.gg/F3xveHV" class="info-modal-discord-link">
|
||||||
|
<span class="material-icons info-modal-discord">discord</span>
|
||||||
|
The Modding Tree
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<div>Time Played: {{ timePlayed }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Modal from "@/components/Modal.vue";
|
||||||
|
import type Changelog from "@/data/Changelog.vue";
|
||||||
|
import modInfo from "@/data/modInfo.json";
|
||||||
|
import player from "@/game/player";
|
||||||
|
import { formatTime } from "@/util/bignum";
|
||||||
|
import { computed, ref, toRefs, unref } from "vue";
|
||||||
|
|
||||||
|
const { title, logo, author, discordName, discordLink, versionNumber, versionTitle } = modInfo;
|
||||||
|
|
||||||
|
const _props = defineProps<{ changelog: typeof Changelog | null }>();
|
||||||
|
const props = toRefs(_props);
|
||||||
|
|
||||||
|
const isOpen = ref(false);
|
||||||
|
|
||||||
|
const timePlayed = computed(() => formatTime(player.timePlayed));
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
open() {
|
||||||
|
isOpen.value = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function openChangelog() {
|
||||||
|
unref(props.changelog)?.open();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.info-modal-header {
|
||||||
|
display: flex;
|
||||||
|
margin: -20px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
background: var(--raised-background);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-modal-header * {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-modal-logo {
|
||||||
|
height: 4em;
|
||||||
|
width: 4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-modal-title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 10px 0;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-modal-discord-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-modal-discord {
|
||||||
|
margin: 0;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
214
src/components/Layer.vue
Normal file
|
@ -0,0 +1,214 @@
|
||||||
|
<template>
|
||||||
|
<div class="layer-container" :style="{ '--layer-color': unref(color) }">
|
||||||
|
<button v-if="showGoBack" class="goBack" @click="goBack">←</button>
|
||||||
|
<button class="layer-tab minimized" v-if="minimized.value" @click="minimized.value = false">
|
||||||
|
<div>{{ unref(name) }}</div>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
class="layer-tab"
|
||||||
|
:style="unref(style)"
|
||||||
|
:class="[{ showGoBack }, unref(classes)]"
|
||||||
|
v-else
|
||||||
|
>
|
||||||
|
<Links :links="unref(links)">
|
||||||
|
<component :is="component" />
|
||||||
|
</Links>
|
||||||
|
</div>
|
||||||
|
<button v-if="unref(minimizable)" class="minimize" @click="minimized.value = true">
|
||||||
|
▼
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Links from "@/components/links/Links.vue";
|
||||||
|
import modInfo from "@/data/modInfo.json";
|
||||||
|
import { CoercableComponent, PersistentRef, StyleValue } from "@/features/feature";
|
||||||
|
import { Link } from "@/features/links";
|
||||||
|
import player from "@/game/player";
|
||||||
|
import { computeComponent, processedPropType, wrapRef } from "@/util/vue";
|
||||||
|
import { computed, defineComponent, nextTick, PropType, toRefs, unref, watch } from "vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
components: { Links },
|
||||||
|
props: {
|
||||||
|
index: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
tab: {
|
||||||
|
type: Function as PropType<() => HTMLElement | undefined>,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
display: {
|
||||||
|
type: processedPropType<CoercableComponent>(Object, String, Function),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
minimized: {
|
||||||
|
type: Object as PropType<PersistentRef<boolean>>,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
minWidth: {
|
||||||
|
type: processedPropType<number>(Number),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: processedPropType<string>(String),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
color: processedPropType<string>(String),
|
||||||
|
style: processedPropType<StyleValue>(String, Object, Array),
|
||||||
|
classes: processedPropType<Record<string, boolean>>(Object),
|
||||||
|
links: processedPropType<Link[]>(Array),
|
||||||
|
minimizable: processedPropType<boolean>(Boolean)
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const { display, index, minimized, minWidth, tab } = toRefs(props);
|
||||||
|
|
||||||
|
const component = computeComponent(display);
|
||||||
|
const showGoBack = computed(
|
||||||
|
() => modInfo.allowGoBack && index.value > 0 && !minimized.value
|
||||||
|
);
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
player.tabs.splice(unref(props.index), Infinity);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTick(() => updateTab(minimized.value, unref(minWidth.value)));
|
||||||
|
watch([minimized, wrapRef(minWidth)], ([minimized, minWidth]) =>
|
||||||
|
updateTab(minimized, minWidth)
|
||||||
|
);
|
||||||
|
|
||||||
|
function updateTab(minimized: boolean, minWidth: number) {
|
||||||
|
const tabValue = tab.value();
|
||||||
|
if (tabValue != undefined) {
|
||||||
|
if (minimized) {
|
||||||
|
tabValue.style.flexGrow = "0";
|
||||||
|
tabValue.style.flexShrink = "0";
|
||||||
|
tabValue.style.width = "60px";
|
||||||
|
tabValue.style.minWidth = tabValue.style.flexBasis = "";
|
||||||
|
tabValue.style.margin = "0";
|
||||||
|
} else {
|
||||||
|
tabValue.style.flexGrow = "";
|
||||||
|
tabValue.style.flexShrink = "";
|
||||||
|
tabValue.style.width = "";
|
||||||
|
tabValue.style.minWidth = tabValue.style.flexBasis = `${minWidth}px`;
|
||||||
|
tabValue.style.margin = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
component,
|
||||||
|
showGoBack,
|
||||||
|
unref,
|
||||||
|
goBack
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.layer-container {
|
||||||
|
min-width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-tab:not(.minimized) {
|
||||||
|
padding-top: 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
min-height: 100%;
|
||||||
|
flex-grow: 1;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inner-tab > .layer-container > .layer-tab:not(.minimized) {
|
||||||
|
padding-top: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-tab.minimized {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
padding: 0;
|
||||||
|
padding-top: 55px;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 40px;
|
||||||
|
color: var(--foreground);
|
||||||
|
border: none;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-tab.minimized div {
|
||||||
|
margin: 0;
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
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;
|
||||||
|
width: 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;
|
||||||
|
padding: 0;
|
||||||
|
margin-top: -44px;
|
||||||
|
margin-right: -30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimized + .minimize {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
top: 10px;
|
||||||
|
right: 18px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goBack {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 20px;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 40px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 40px;
|
||||||
|
z-index: 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goBack:hover {
|
||||||
|
transform: scale(1.1, 1.1);
|
||||||
|
text-shadow: 0 0 7px var(--foreground);
|
||||||
|
}
|
||||||
|
</style>
|
61
src/components/MarkNode.vue
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="mark">
|
||||||
|
<div v-if="mark === true" class="mark star"></div>
|
||||||
|
<img v-else class="mark" :src="mark" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ mark?: boolean | string }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mark {
|
||||||
|
position: absolute;
|
||||||
|
left: -25px;
|
||||||
|
top: -10px;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
margin-left: 0.9em;
|
||||||
|
margin-right: 0.9em;
|
||||||
|
margin-bottom: 1.2em;
|
||||||
|
border-right: 0.3em solid transparent;
|
||||||
|
border-bottom: 0.7em solid transparent;
|
||||||
|
border-left: 0.3em solid transparent;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star {
|
||||||
|
left: -10px;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
margin-left: 0.9em;
|
||||||
|
margin-right: 0.9em;
|
||||||
|
margin-bottom: 1.2em;
|
||||||
|
border-right: 0.3em solid transparent;
|
||||||
|
border-bottom: 0.7em solid #ffcc00;
|
||||||
|
border-left: 0.3em solid transparent;
|
||||||
|
font-size: 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star::before,
|
||||||
|
.star::after {
|
||||||
|
content: "";
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 0.6em;
|
||||||
|
left: -1em;
|
||||||
|
border-right: 1em solid transparent;
|
||||||
|
border-bottom: 0.7em solid #ffcc00;
|
||||||
|
border-left: 1em solid transparent;
|
||||||
|
transform: rotate(-35deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.star::after {
|
||||||
|
transform: rotate(35deg);
|
||||||
|
}
|
||||||
|
</style>
|
136
src/components/Modal.vue
Normal file
|
@ -0,0 +1,136 @@
|
||||||
|
<template>
|
||||||
|
<teleport to="#modal-root">
|
||||||
|
<transition
|
||||||
|
name="modal"
|
||||||
|
@before-enter="isAnimating = true"
|
||||||
|
@after-leave="isAnimating = false"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="modal-mask"
|
||||||
|
v-show="modelValue"
|
||||||
|
v-on:pointerdown.self="close"
|
||||||
|
v-bind="$attrs"
|
||||||
|
>
|
||||||
|
<div class="modal-wrapper">
|
||||||
|
<div class="modal-container">
|
||||||
|
<div class="modal-header">
|
||||||
|
<slot name="header" :shown="isOpen"> default header </slot>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<Links v-if="links" :links="links">
|
||||||
|
<slot name="body" :shown="isOpen"> default body </slot>
|
||||||
|
</Links>
|
||||||
|
<slot name="body" v-else :shown="isOpen"> default body </slot>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<slot name="footer" :shown="isOpen">
|
||||||
|
<div class="modal-default-footer">
|
||||||
|
<div class="modal-default-flex-grow"></div>
|
||||||
|
<button class="button modal-default-button" @click="close">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Link } from "@/features/links";
|
||||||
|
import { computed, ref, toRefs } from "vue";
|
||||||
|
import Links from "./links/Links.vue";
|
||||||
|
|
||||||
|
const _props = defineProps<{
|
||||||
|
modelValue: boolean;
|
||||||
|
links?: Link[];
|
||||||
|
}>();
|
||||||
|
const props = toRefs(_props);
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", value: boolean): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isOpen = computed(() => props.modelValue || isAnimating.value);
|
||||||
|
function close() {
|
||||||
|
emit("update:modelValue", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAnimating = ref(false);
|
||||||
|
|
||||||
|
defineExpose({ isOpen });
|
||||||
|
</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>
|
124
src/components/NaNScreen.vue
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
<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" class="nan-modal-discord-link">
|
||||||
|
<span class="material-icons nan-modal-discord">discord</span>
|
||||||
|
{{ discordName }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<Toggle title="Autosave" v-model="autosave" />
|
||||||
|
<Toggle title="Pause game" v-model="isPaused" />
|
||||||
|
</template>
|
||||||
|
<template v-slot:footer>
|
||||||
|
<div class="nan-footer">
|
||||||
|
<button @click="savesManager?.open()" class="button">Open Saves Manager</button>
|
||||||
|
<button @click="setZero" class="button">Set to 0</button>
|
||||||
|
<button @click="setOne" class="button">Set to 1</button>
|
||||||
|
<button
|
||||||
|
@click="hasNaN = false"
|
||||||
|
class="button"
|
||||||
|
v-if="previous && Decimal.neq(previous, 0) && Decimal.neq(previous, 1)"
|
||||||
|
>
|
||||||
|
Set to previous
|
||||||
|
</button>
|
||||||
|
<button @click="ignore" class="button danger">Ignore</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
<SavesManager ref="savesManager" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Modal from "@/components/Modal.vue";
|
||||||
|
import modInfo from "@/data/modInfo.json";
|
||||||
|
import player from "@/game/player";
|
||||||
|
import state from "@/game/state";
|
||||||
|
import Decimal, { DecimalSource, format } from "@/util/bignum";
|
||||||
|
import { ComponentPublicInstance, computed, ref, toRef } from "vue";
|
||||||
|
import Toggle from "./fields/Toggle.vue";
|
||||||
|
import SavesManager from "./SavesManager.vue";
|
||||||
|
|
||||||
|
const { discordName, discordLink } = modInfo;
|
||||||
|
const autosave = toRef(player, "autosave");
|
||||||
|
const hasNaN = toRef(state, "hasNaN");
|
||||||
|
const savesManager = ref<ComponentPublicInstance<typeof SavesManager> | null>(null);
|
||||||
|
|
||||||
|
const path = computed(() => state.NaNPath?.join("."));
|
||||||
|
const property = computed(() => state.NaNPath?.slice(-1)[0]);
|
||||||
|
const previous = computed<DecimalSource | null>(() => {
|
||||||
|
if (state.NaNReceiver && property.value) {
|
||||||
|
return state.NaNReceiver[property.value] as DecimalSource;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const isPaused = computed({
|
||||||
|
get() {
|
||||||
|
return player.devSpeed === 0;
|
||||||
|
},
|
||||||
|
set(value: boolean) {
|
||||||
|
player.devSpeed = value ? null : 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function setZero() {
|
||||||
|
if (state.NaNReceiver && property.value) {
|
||||||
|
state.NaNReceiver[property.value] = new Decimal(0);
|
||||||
|
state.hasNaN = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOne() {
|
||||||
|
if (state.NaNReceiver && property.value) {
|
||||||
|
state.NaNReceiver[property.value] = new Decimal(1);
|
||||||
|
state.hasNaN = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ignore() {
|
||||||
|
if (state.NaNReceiver && property.value) {
|
||||||
|
state.NaNReceiver[property.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 {
|
||||||
|
height: 2em;
|
||||||
|
margin: 0;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
264
src/components/Nav.vue
Normal file
|
@ -0,0 +1,264 @@
|
||||||
|
<template>
|
||||||
|
<div class="nav" v-if="useHeader" v-bind="$attrs">
|
||||||
|
<img v-if="banner" :src="banner" height="100%" :alt="title" />
|
||||||
|
<div v-else class="title">{{ title }}</div>
|
||||||
|
<div @click="changelog?.open()" class="version-container">
|
||||||
|
<Tooltip display="Changelog" bottom 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 !== 'https://discord.gg/WzejVAx'">
|
||||||
|
<a :href="discordLink" target="_blank">{{ discordName }}</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://discord.gg/WzejVAx" target="_blank"
|
||||||
|
>The Paper Pilot Community</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://discord.gg/F3xveHV" target="_blank">The Modding Tree</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="https://forums.moddingtree.com/" target="_blank">
|
||||||
|
<Tooltip display="Forums" bottom yoffset="5px">
|
||||||
|
<span class="material-icons">forum</span>
|
||||||
|
</Tooltip>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div @click="info?.open()">
|
||||||
|
<Tooltip display="Info" bottom class="info">
|
||||||
|
<span class="material-icons">info</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div @click="savesManager?.open()">
|
||||||
|
<Tooltip display="Saves" bottom xoffset="-20px">
|
||||||
|
<span class="material-icons">library_books</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div @click="options?.open()">
|
||||||
|
<Tooltip display="Options" bottom 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" right xoffset="25%" class="version">
|
||||||
|
<span>v{{ versionNumber }}</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div @click="savesManager?.open()">
|
||||||
|
<Tooltip display="Saves" right>
|
||||||
|
<span class="material-icons">library_books</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div @click="options?.open()">
|
||||||
|
<Tooltip display="Options" right>
|
||||||
|
<span class="material-icons">settings</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div @click="info?.open()">
|
||||||
|
<Tooltip display="Info" right>
|
||||||
|
<span class="material-icons">info</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="https://forums.moddingtree.com/" target="_blank">
|
||||||
|
<Tooltip display="Forums" 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 !== 'https://discord.gg/WzejVAx'">
|
||||||
|
<a :href="discordLink" target="_blank">{{ discordName }}</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://discord.gg/WzejVAx" target="_blank"
|
||||||
|
>The Paper Pilot Community</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://discord.gg/F3xveHV" target="_blank">The Modding Tree</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Info ref="info" :changelog="changelog" />
|
||||||
|
<SavesManager ref="savesManager" />
|
||||||
|
<Options ref="options" />
|
||||||
|
<Changelog ref="changelog" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Changelog from "@/data/Changelog.vue";
|
||||||
|
import modInfo from "@/data/modInfo.json";
|
||||||
|
import { ComponentPublicInstance, ref } from "vue";
|
||||||
|
import Info from "./Info.vue";
|
||||||
|
import Options from "./Options.vue";
|
||||||
|
import SavesManager from "./SavesManager.vue";
|
||||||
|
import Tooltip from "./Tooltip.vue";
|
||||||
|
|
||||||
|
const info = ref<ComponentPublicInstance<typeof Info> | null>(null);
|
||||||
|
const savesManager = ref<ComponentPublicInstance<typeof SavesManager> | null>(null);
|
||||||
|
const options = ref<ComponentPublicInstance<typeof Options> | null>(null);
|
||||||
|
// For some reason Info won't accept the changelog unless I do this:
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const changelog = ref<ComponentPublicInstance<any> | null>(null);
|
||||||
|
|
||||||
|
const { useHeader, banner, title, discordName, discordLink, versionNumber } = modInfo;
|
||||||
|
|
||||||
|
function openDiscord() {
|
||||||
|
window.open(discordLink, "mywindow");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.nav {
|
||||||
|
background-color: var(--raised-background);
|
||||||
|
display: flex;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 46px;
|
||||||
|
width: 100%;
|
||||||
|
border-bottom: 4px solid var(--outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav > * {
|
||||||
|
height: 46px;
|
||||||
|
width: 46px;
|
||||||
|
display: flex;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-nav > * {
|
||||||
|
height: 50px;
|
||||||
|
width: 50px;
|
||||||
|
display: flex;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 36px;
|
||||||
|
text-align: left;
|
||||||
|
margin-left: 12px;
|
||||||
|
cursor: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav > .title {
|
||||||
|
width: unset;
|
||||||
|
flex-shrink: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav .saves,
|
||||||
|
.nav .info {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-nav .discord {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-links {
|
||||||
|
position: fixed;
|
||||||
|
top: 45px;
|
||||||
|
padding: 20px;
|
||||||
|
right: -280px;
|
||||||
|
width: 200px;
|
||||||
|
transition: right 0.25s ease;
|
||||||
|
background: var(--raised-background);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-nav .discord-links {
|
||||||
|
position: absolute;
|
||||||
|
left: -280px;
|
||||||
|
right: unset;
|
||||||
|
transition: left 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-nav .discord:hover .discord-links {
|
||||||
|
left: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-links li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-links li:first-child {
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
*:not(.overlay-nav) .discord:hover .discord-links {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-icons {
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-icons:hover {
|
||||||
|
text-shadow: 5px 0 10px var(--link), -3px 0 12px var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav .version-container {
|
||||||
|
display: flex;
|
||||||
|
height: 25px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-nav .version-container {
|
||||||
|
width: unset;
|
||||||
|
height: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version {
|
||||||
|
color: var(--points);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version:hover span {
|
||||||
|
text-shadow: 5px 0 10px var(--points), -3px 0 12px var(--points);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav > div > a,
|
||||||
|
.overlay-nav > div > a {
|
||||||
|
color: var(--foreground);
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
</style>
|
88
src/components/Options.vue
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
<template>
|
||||||
|
<Modal v-model="isOpen">
|
||||||
|
<template v-slot:header>
|
||||||
|
<div class="header">
|
||||||
|
<h2>Options</h2>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body>
|
||||||
|
<Select title="Theme" :options="themes" v-model="theme" />
|
||||||
|
<component :is="settingFieldsComponent" />
|
||||||
|
<Toggle title="Show TPS" v-model="showTPS" />
|
||||||
|
<hr />
|
||||||
|
<Toggle title="Unthrottled" v-model="unthrottled" />
|
||||||
|
<Toggle :title="offlineProdTitle" v-model="offlineProd" />
|
||||||
|
<Toggle :title="autosaveTitle" v-model="autosave" />
|
||||||
|
<Toggle :title="isPausedTitle" v-model="isPaused" />
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="tsx">
|
||||||
|
import Modal from "@/components/Modal.vue";
|
||||||
|
import rawThemes from "@/data/themes";
|
||||||
|
import player from "@/game/player";
|
||||||
|
import settings, { settingFields } from "@/game/settings";
|
||||||
|
import { camelToTitle } from "@/util/common";
|
||||||
|
import { computed, ref, toRefs } from "vue";
|
||||||
|
import Toggle from "./fields/Toggle.vue";
|
||||||
|
import Select from "./fields/Select.vue";
|
||||||
|
import Tooltip from "./Tooltip.vue";
|
||||||
|
import { jsx } from "@/features/feature";
|
||||||
|
import { coerceComponent, render } from "@/util/vue";
|
||||||
|
|
||||||
|
const isOpen = ref(false);
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
open() {
|
||||||
|
isOpen.value = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const themes = Object.keys(rawThemes).map(theme => ({
|
||||||
|
label: camelToTitle(theme),
|
||||||
|
value: theme
|
||||||
|
}));
|
||||||
|
|
||||||
|
const settingFieldsComponent = computed(() => {
|
||||||
|
return coerceComponent(jsx(() => <>{settingFields.map(render)}</>));
|
||||||
|
});
|
||||||
|
|
||||||
|
const { showTPS, theme, unthrottled } = toRefs(settings);
|
||||||
|
const { autosave, offlineProd } = toRefs(player);
|
||||||
|
const isPaused = computed({
|
||||||
|
get() {
|
||||||
|
return player.devSpeed === 0;
|
||||||
|
},
|
||||||
|
set(value: boolean) {
|
||||||
|
player.devSpeed = value ? 0 : null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const offlineProdTitle = jsx(() => (
|
||||||
|
<span>
|
||||||
|
Offline Production<Tooltip display="Save-specific">*</Tooltip>
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
const autosaveTitle = jsx(() => (
|
||||||
|
<span>
|
||||||
|
Autosave<Tooltip display="Save-specific">*</Tooltip>
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
const isPausedTitle = jsx(() => (
|
||||||
|
<span>
|
||||||
|
Pause game<Tooltip display="Save-specific">*</Tooltip>
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.header {
|
||||||
|
margin-bottom: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*:deep() .tooltip-container {
|
||||||
|
display: inline;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
</style>
|
195
src/components/Profectus.vue
Normal file
|
@ -0,0 +1,195 @@
|
||||||
|
<template>
|
||||||
|
<transition appear>
|
||||||
|
<svg
|
||||||
|
id="eaRe02fYmMp1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
viewBox="0 0 228 521"
|
||||||
|
shape-rendering="geometricPrecision"
|
||||||
|
text-rendering="geometricPrecision"
|
||||||
|
>
|
||||||
|
<g id="P">
|
||||||
|
<path
|
||||||
|
d="m 101,512.877 c -17.547386,-5.3519 -50.794681,-10.26296 -80,0 10.737201,-217.43031 5.7244,-300.999 0,-464.9995 0,0 46.6144,-37.1164 80,-42.00002 33.386,-4.883633 86.025,10.45942 120,50.00002 5,30 -4.353,106.6565 -44,156.0005 -34.149,42.5 -130,38.48 -130,92.999 0,102 54,208 54,208 z"
|
||||||
|
style="
|
||||||
|
display: inline;
|
||||||
|
fill: none;
|
||||||
|
stroke: rgb(163, 190, 140);
|
||||||
|
stroke-width: 10;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-miterlimit: 16;
|
||||||
|
"
|
||||||
|
id="trunk"
|
||||||
|
class="svg-elem-1"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M 221,55.8775 C 209.023,126.453 185.39,166.835 158.997,191.5 93.783098,252.444 11.718998,217.436 46.999998,304.877"
|
||||||
|
style="
|
||||||
|
display: inline;
|
||||||
|
fill: none;
|
||||||
|
stroke: rgb(163, 190, 140);
|
||||||
|
stroke-width: 5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-miterlimit: 16;
|
||||||
|
"
|
||||||
|
id="vine2"
|
||||||
|
class="svg-elem-2"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="m 194.5,188 c -11.225,4.447 -19.066,5.134 -35.503,3.5"
|
||||||
|
style="
|
||||||
|
display: inline;
|
||||||
|
fill: none;
|
||||||
|
stroke: rgb(163, 190, 140);
|
||||||
|
stroke-width: 5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-miterlimit: 16;
|
||||||
|
"
|
||||||
|
id="short_vine4"
|
||||||
|
class="svg-elem-3"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M 73.499996,246.5 C 111.145,245.626 127.011,238.775 156.5,228"
|
||||||
|
style="
|
||||||
|
display: inline;
|
||||||
|
fill: none;
|
||||||
|
stroke: rgb(163, 190, 140);
|
||||||
|
stroke-width: 5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-miterlimit: 16;
|
||||||
|
"
|
||||||
|
id="short_vine3"
|
||||||
|
class="svg-elem-4"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M 221,55.8775 C 169.5,17.8262 86.0943,44.9468 47,107 c -4.743,7.528 -7.1041,15.373 -8.326,24 -3.5282,24.91 2.4426,56.34 -12.0011,105.5"
|
||||||
|
style="
|
||||||
|
display: inline;
|
||||||
|
fill: none;
|
||||||
|
stroke: rgb(163, 190, 140);
|
||||||
|
stroke-width: 5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-miterlimit: 16;
|
||||||
|
"
|
||||||
|
id="vine1"
|
||||||
|
class="svg-elem-5"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M 21,47.8775 38.674,131"
|
||||||
|
style="
|
||||||
|
display: inline;
|
||||||
|
fill: none;
|
||||||
|
stroke: rgb(163, 190, 140);
|
||||||
|
stroke-width: 5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-miterlimit: 16;
|
||||||
|
"
|
||||||
|
id="short_vine2"
|
||||||
|
class="svg-elem-6"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="m 3,326.5 c 13.1783,22.208 16.4863,42.834 21.6997,81"
|
||||||
|
style="
|
||||||
|
display: inline;
|
||||||
|
fill: none;
|
||||||
|
stroke: rgb(163, 190, 140);
|
||||||
|
stroke-width: 5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-miterlimit: 16;
|
||||||
|
"
|
||||||
|
id="short_vine1"
|
||||||
|
class="svg-elem-7"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
svg {
|
||||||
|
background: #2e3440;
|
||||||
|
}
|
||||||
|
|
||||||
|
/***************************************************
|
||||||
|
* Generated by SVG Artista on 1/7/2022, 4:39:47 PM
|
||||||
|
* MIT license (https://opensource.org/licenses/MIT)
|
||||||
|
* W. https://svgartista.net
|
||||||
|
**************************************************/
|
||||||
|
|
||||||
|
svg .svg-elem-1 {
|
||||||
|
stroke-dashoffset: 2648.758056640625px;
|
||||||
|
stroke-dasharray: 1324.3790283203125px;
|
||||||
|
transition: stroke-dashoffset 1s cubic-bezier(0.47, 0, 0.745, 0.715) 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.v-enter-from .svg-elem-1,
|
||||||
|
svg.v-leave-to .svg-elem-1 {
|
||||||
|
stroke-dashoffset: 1324.3790283203125px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg .svg-elem-2 {
|
||||||
|
stroke-dashoffset: 680.4000854492188px;
|
||||||
|
stroke-dasharray: 340.2000427246094px;
|
||||||
|
transition: stroke-dashoffset 1s ease-out 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.v-enter-from .svg-elem-2,
|
||||||
|
svg.v-leave-to .svg-elem-2 {
|
||||||
|
stroke-dashoffset: 340.2000427246094px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg .svg-elem-3 {
|
||||||
|
stroke-dashoffset: 76.21031951904297px;
|
||||||
|
stroke-dasharray: 38.105159759521484px;
|
||||||
|
transition: stroke-dashoffset 1s ease-out 0.8s;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.v-enter-from .svg-elem-3,
|
||||||
|
svg.v-leave-to .svg-elem-3 {
|
||||||
|
stroke-dashoffset: 38.105159759521484px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg .svg-elem-4 {
|
||||||
|
stroke-dashoffset: 175.18072509765625px;
|
||||||
|
stroke-dasharray: 87.59036254882812px;
|
||||||
|
transition: stroke-dashoffset 1s cubic-bezier(0.47, 0, 0.745, 0.715) 0.36s;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.v-enter-from .svg-elem-4,
|
||||||
|
svg.v-leave-to .svg-elem-4 {
|
||||||
|
stroke-dashoffset: 87.59036254882812px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg .svg-elem-5 {
|
||||||
|
stroke-dashoffset: 671.9447021484375px;
|
||||||
|
stroke-dasharray: 335.97235107421875px;
|
||||||
|
transition: stroke-dashoffset 1s ease-out 0.8s;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.v-enter-from .svg-elem-5,
|
||||||
|
svg.v-leave-to .svg-elem-5 {
|
||||||
|
stroke-dashoffset: 335.97235107421875px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg .svg-elem-6 {
|
||||||
|
stroke-dashoffset: 173.96141052246094px;
|
||||||
|
stroke-dasharray: 86.98070526123047px;
|
||||||
|
transition: stroke-dashoffset 1s ease-out 1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.v-enter-from .svg-elem-6,
|
||||||
|
svg.v-leave-to .svg-elem-6 {
|
||||||
|
stroke-dashoffset: 86.98070526123047px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg .svg-elem-7 {
|
||||||
|
stroke-dashoffset: 172.99151611328125px;
|
||||||
|
stroke-dasharray: 86.49575805664062px;
|
||||||
|
transition: stroke-dashoffset 1s ease-out 0.85s;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.v-enter-from .svg-elem-7,
|
||||||
|
svg.v-leave-to .svg-elem-7 {
|
||||||
|
stroke-dashoffset: 86.49575805664062px;
|
||||||
|
}
|
||||||
|
</style>
|
190
src/components/Save.vue
Normal file
|
@ -0,0 +1,190 @@
|
||||||
|
<template>
|
||||||
|
<div class="save" :class="{ active: isActive }">
|
||||||
|
<div class="handle material-icons">drag_handle</div>
|
||||||
|
<div class="actions" v-if="!isEditing">
|
||||||
|
<FeedbackButton
|
||||||
|
@click="emit('export')"
|
||||||
|
class="button"
|
||||||
|
left
|
||||||
|
v-if="save.error == undefined && !isConfirming"
|
||||||
|
>
|
||||||
|
<span class="material-icons">content_paste</span>
|
||||||
|
</FeedbackButton>
|
||||||
|
<button
|
||||||
|
@click="emit('duplicate')"
|
||||||
|
class="button"
|
||||||
|
v-if="save.error == undefined && !isConfirming"
|
||||||
|
>
|
||||||
|
<span class="material-icons">content_copy</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="isEditing = !isEditing"
|
||||||
|
class="button"
|
||||||
|
v-if="save.error == undefined && !isConfirming"
|
||||||
|
>
|
||||||
|
<span class="material-icons">edit</span>
|
||||||
|
</button>
|
||||||
|
<DangerButton
|
||||||
|
:disabled="isActive"
|
||||||
|
@click="emit('delete')"
|
||||||
|
@confirmingChanged="value => (isConfirming = value)"
|
||||||
|
>
|
||||||
|
<span class="material-icons" style="margin: -2px">delete</span>
|
||||||
|
</DangerButton>
|
||||||
|
</div>
|
||||||
|
<div class="actions" v-else>
|
||||||
|
<button @click="changeName" class="button">
|
||||||
|
<span class="material-icons">check</span>
|
||||||
|
</button>
|
||||||
|
<button @click="isEditing = !isEditing" class="button">
|
||||||
|
<span class="material-icons">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="details" v-if="save.error == undefined && !isEditing">
|
||||||
|
<button class="button open" @click="emit('open')">
|
||||||
|
<h3>{{ save.name }}</h3>
|
||||||
|
</button>
|
||||||
|
<span class="save-version">v{{ save.modVersion }}</span
|
||||||
|
><br />
|
||||||
|
<div v-if="currentTime">Last played {{ dateFormat.format(currentTime) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="details" v-else-if="save.error == undefined && isEditing">
|
||||||
|
<Text v-model="newName" class="editname" @submit="changeName" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="details error">
|
||||||
|
Error: Failed to load save with id {{ save.id }}<br />{{ save.error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import player from "@/game/player";
|
||||||
|
import { computed, ref, toRefs, watch } from "vue";
|
||||||
|
import DangerButton from "./fields/DangerButton.vue";
|
||||||
|
import FeedbackButton from "./fields/FeedbackButton.vue";
|
||||||
|
import Text from "./fields/Text.vue";
|
||||||
|
import { LoadablePlayerData } from "./SavesManager.vue";
|
||||||
|
|
||||||
|
const _props = defineProps<{
|
||||||
|
save: LoadablePlayerData;
|
||||||
|
}>();
|
||||||
|
const { save } = toRefs(_props);
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "export"): void;
|
||||||
|
(e: "open"): void;
|
||||||
|
(e: "duplicate"): void;
|
||||||
|
(e: "delete"): void;
|
||||||
|
(e: "editName", name: string): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const dateFormat = new Intl.DateTimeFormat("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "numeric",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "numeric",
|
||||||
|
second: "numeric"
|
||||||
|
});
|
||||||
|
|
||||||
|
const isEditing = ref(false);
|
||||||
|
const isConfirming = ref(false);
|
||||||
|
const newName = ref("");
|
||||||
|
|
||||||
|
watch(isEditing, () => (newName.value = save.value.name || ""));
|
||||||
|
|
||||||
|
const isActive = computed(() => save.value && save.value.id === player.id);
|
||||||
|
const currentTime = computed(() =>
|
||||||
|
isActive.value ? player.time : (save.value && save.value.time) || 0
|
||||||
|
);
|
||||||
|
|
||||||
|
function changeName() {
|
||||||
|
emit("editName", newName.value);
|
||||||
|
isEditing.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.save {
|
||||||
|
position: relative;
|
||||||
|
border: solid 4px var(--outline);
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--raised-background);
|
||||||
|
margin: var(--feature-margin);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save.active {
|
||||||
|
border-color: var(--bought);
|
||||||
|
}
|
||||||
|
|
||||||
|
.open {
|
||||||
|
display: inline;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle {
|
||||||
|
flex-grow: 0;
|
||||||
|
margin-right: 8px;
|
||||||
|
margin-left: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
margin: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
margin-right: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-version {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 0.7em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 4px;
|
||||||
|
display: flex;
|
||||||
|
padding: 4px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editname {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.save button {
|
||||||
|
transition-duration: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save .actions button {
|
||||||
|
display: flex;
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save .actions button .material-icons {
|
||||||
|
font-size: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save .button.danger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save .field {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
280
src/components/SavesManager.vue
Normal file
|
@ -0,0 +1,280 @@
|
||||||
|
<template>
|
||||||
|
<Modal v-model="isOpen" ref="modal">
|
||||||
|
<template v-slot:header>
|
||||||
|
<h2>Saves Manager</h2>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body>
|
||||||
|
<Draggable
|
||||||
|
:list="settings.saves"
|
||||||
|
handle=".handle"
|
||||||
|
v-if="unref(modal?.isOpen)"
|
||||||
|
:itemKey="(save: string) => save"
|
||||||
|
>
|
||||||
|
<template #item="{ element }">
|
||||||
|
<Save
|
||||||
|
:save="saves[element]"
|
||||||
|
@open="openSave(element)"
|
||||||
|
@export="exportSave(element)"
|
||||||
|
@editName="name => editSave(element, name)"
|
||||||
|
@duplicate="duplicateSave(element)"
|
||||||
|
@delete="deleteSave(element)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Draggable>
|
||||||
|
</template>
|
||||||
|
<template v-slot:footer>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<Text
|
||||||
|
v-model="saveToImport"
|
||||||
|
title="Import Save"
|
||||||
|
placeholder="Paste your save here!"
|
||||||
|
:class="{ importingFailed }"
|
||||||
|
/>
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-title">Create Save</span>
|
||||||
|
<div class="field-buttons">
|
||||||
|
<button class="button" @click="newSave">New Game</button>
|
||||||
|
<Select
|
||||||
|
v-if="Object.keys(bank).length > 0"
|
||||||
|
:options="bank"
|
||||||
|
:modelValue="undefined"
|
||||||
|
@update:modelValue="preset => newFromPreset(preset as string)"
|
||||||
|
closeOnSelect
|
||||||
|
placeholder="Select preset"
|
||||||
|
class="presets"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<div style="flex-grow: 1"></div>
|
||||||
|
<button class="button modal-default-button" @click="isOpen = false">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Modal from "@/components/Modal.vue";
|
||||||
|
import player, { PlayerData } from "@/game/player";
|
||||||
|
import settings from "@/game/settings";
|
||||||
|
import { getUniqueID, loadSave, save, newSave } from "@/util/save";
|
||||||
|
import {
|
||||||
|
ComponentPublicInstance,
|
||||||
|
computed,
|
||||||
|
nextTick,
|
||||||
|
ref,
|
||||||
|
shallowReactive,
|
||||||
|
unref,
|
||||||
|
watch
|
||||||
|
} from "vue";
|
||||||
|
import Select from "./fields/Select.vue";
|
||||||
|
import Text from "./fields/Text.vue";
|
||||||
|
import Save from "./Save.vue";
|
||||||
|
import Draggable from "vuedraggable";
|
||||||
|
|
||||||
|
export type LoadablePlayerData = Omit<Partial<PlayerData>, "id"> & { id: string; error?: unknown };
|
||||||
|
|
||||||
|
const isOpen = ref(false);
|
||||||
|
const modal = ref<ComponentPublicInstance<typeof Modal> | null>(null);
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
open() {
|
||||||
|
isOpen.value = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const importingFailed = ref(false);
|
||||||
|
const saveToImport = ref("");
|
||||||
|
|
||||||
|
watch(saveToImport, save => {
|
||||||
|
if (save) {
|
||||||
|
nextTick(() => {
|
||||||
|
try {
|
||||||
|
const playerData = JSON.parse(decodeURIComponent(escape(atob(save))));
|
||||||
|
if (typeof playerData !== "object") {
|
||||||
|
importingFailed.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = getUniqueID();
|
||||||
|
playerData.id = id;
|
||||||
|
localStorage.setItem(
|
||||||
|
id,
|
||||||
|
btoa(unescape(encodeURIComponent(JSON.stringify(playerData))))
|
||||||
|
);
|
||||||
|
saveToImport.value = "";
|
||||||
|
importingFailed.value = false;
|
||||||
|
|
||||||
|
settings.saves.push(id);
|
||||||
|
} catch (e) {
|
||||||
|
importingFailed.value = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
importingFailed.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let bankContext = require.context("raw-loader!../../saves", true, /\.txt$/);
|
||||||
|
let bank = ref(
|
||||||
|
bankContext.keys().reduce((acc: Array<{ label: string; value: string }>, curr) => {
|
||||||
|
// .slice(2, -4) strips the leading ./ and the trailing .txt
|
||||||
|
acc.push({
|
||||||
|
label: curr.slice(2, -4),
|
||||||
|
value: bankContext(curr).default
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
const cachedSaves = shallowReactive<Record<string, LoadablePlayerData | undefined>>({});
|
||||||
|
function getCachedSave(id: string) {
|
||||||
|
if (cachedSaves[id] == null) {
|
||||||
|
const save = localStorage.getItem(id);
|
||||||
|
if (save == null) {
|
||||||
|
cachedSaves[id] = { error: `Save doesn't exist in localStorage`, id };
|
||||||
|
} else if (save === "dW5kZWZpbmVk") {
|
||||||
|
cachedSaves[id] = { error: `Save is undefined`, id };
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
cachedSaves[id] = { ...JSON.parse(decodeURIComponent(escape(atob(save)))), id };
|
||||||
|
} catch (error) {
|
||||||
|
cachedSaves[id] = { error, id };
|
||||||
|
console.warn(
|
||||||
|
`SavesManager: Failed to load info about save with id ${id}:\n${error}\n${save}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
return cachedSaves[id]!;
|
||||||
|
}
|
||||||
|
// Wipe cache whenever the modal is opened
|
||||||
|
watch(isOpen, isOpen => {
|
||||||
|
if (isOpen) {
|
||||||
|
Object.keys(cachedSaves).forEach(key => delete cachedSaves[key]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const saves = computed(() =>
|
||||||
|
settings.saves.reduce((acc: Record<string, LoadablePlayerData>, curr: string) => {
|
||||||
|
acc[curr] = getCachedSave(curr);
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
);
|
||||||
|
|
||||||
|
function exportSave(id: string) {
|
||||||
|
let saveToExport;
|
||||||
|
if (player.id === id) {
|
||||||
|
saveToExport = save();
|
||||||
|
} else {
|
||||||
|
saveToExport = btoa(unescape(encodeURIComponent(JSON.stringify(saves.value[id]))));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() };
|
||||||
|
localStorage.setItem(
|
||||||
|
playerData.id,
|
||||||
|
btoa(unescape(encodeURIComponent(JSON.stringify(playerData))))
|
||||||
|
);
|
||||||
|
|
||||||
|
settings.saves.push(playerData.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSave(id: string) {
|
||||||
|
settings.saves = settings.saves.filter((save: string) => save !== id);
|
||||||
|
localStorage.removeItem(id);
|
||||||
|
cachedSaves[id] = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSave(id: string) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
saves.value[player.id]!.time = player.time;
|
||||||
|
save();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
loadSave(saves.value[id]!);
|
||||||
|
// Delete cached version in case of opening it again
|
||||||
|
cachedSaves[id] = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function newFromPreset(preset: string) {
|
||||||
|
const playerData = JSON.parse(decodeURIComponent(escape(atob(preset))));
|
||||||
|
playerData.id = getUniqueID();
|
||||||
|
localStorage.setItem(
|
||||||
|
playerData.id,
|
||||||
|
btoa(unescape(encodeURIComponent(JSON.stringify(playerData))))
|
||||||
|
);
|
||||||
|
|
||||||
|
settings.saves.push(playerData.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function editSave(id: string, newName: string) {
|
||||||
|
const currSave = saves.value[id];
|
||||||
|
if (currSave) {
|
||||||
|
currSave.name = newName;
|
||||||
|
if (player.id === id) {
|
||||||
|
player.name = newName;
|
||||||
|
save();
|
||||||
|
} else {
|
||||||
|
localStorage.setItem(id, btoa(unescape(encodeURIComponent(JSON.stringify(currSave)))));
|
||||||
|
cachedSaves[id] = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.field form,
|
||||||
|
.field .field-title,
|
||||||
|
.field .field-buttons {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-buttons {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-buttons .field {
|
||||||
|
margin: 0;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
margin-top: -20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.importingFailed input {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-buttons .v-select {
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presets .vue-select[aria-expanded="true"] vue-dropdown {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
52
src/components/TPS.vue
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
<template>
|
||||||
|
<div class="tpsDisplay" v-if="!tps.isNan()">
|
||||||
|
TPS: {{ formatWhole(tps) }}
|
||||||
|
<transition name="fade"
|
||||||
|
><span v-if="showLow" class="low">{{ formatWhole(low) }}</span></transition
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import state from "@/game/state";
|
||||||
|
import Decimal, { DecimalSource, formatWhole } from "@/util/bignum";
|
||||||
|
import { computed, ref, watchEffect } from "vue";
|
||||||
|
|
||||||
|
const tps = computed(() =>
|
||||||
|
Decimal.div(
|
||||||
|
state.lastTenTicks.length,
|
||||||
|
state.lastTenTicks.reduce((acc, curr) => acc + curr, 0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const lastTenFPS = ref<number[]>([]);
|
||||||
|
watchEffect(() => {
|
||||||
|
lastTenFPS.value.push(Math.round(tps.value.toNumber()));
|
||||||
|
if (lastTenFPS.value.length > 10) {
|
||||||
|
lastTenFPS.value = lastTenFPS.value.slice(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const low = computed(() =>
|
||||||
|
lastTenFPS.value.reduce<DecimalSource>((acc, curr) => Decimal.max(acc, curr), 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const showLow = computed(() => Decimal.sub(tps.value, low.value).gt(1));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tpsDisplay {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.low {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
167
src/components/Tooltip.vue
Normal file
|
@ -0,0 +1,167 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="tooltip-container"
|
||||||
|
:class="{ shown: isShown }"
|
||||||
|
@mouseenter="isHovered = true"
|
||||||
|
@mouseleave="isHovered = false"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
<transition name="fade">
|
||||||
|
<div
|
||||||
|
v-if="isShown"
|
||||||
|
class="tooltip"
|
||||||
|
:class="{
|
||||||
|
top: unref(top),
|
||||||
|
left: unref(left),
|
||||||
|
right: unref(right),
|
||||||
|
bottom: unref(bottom)
|
||||||
|
}"
|
||||||
|
:style="{
|
||||||
|
'--xoffset': unref(xoffset) || '0px',
|
||||||
|
'--yoffset': unref(yoffset) || '0px'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<component v-if="comp" :is="comp" />
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { CoercableComponent } from "@/features/feature";
|
||||||
|
import { computeOptionalComponent, processedPropType, unwrapRef } from "@/util/vue";
|
||||||
|
import { computed, defineComponent, ref, toRefs, unref } from "vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
display: processedPropType<CoercableComponent>(Object, String, Function),
|
||||||
|
top: processedPropType<boolean>(Boolean),
|
||||||
|
left: processedPropType<boolean>(Boolean),
|
||||||
|
right: processedPropType<boolean>(Boolean),
|
||||||
|
bottom: processedPropType<boolean>(Boolean),
|
||||||
|
xoffset: processedPropType<string>(String),
|
||||||
|
yoffset: processedPropType<string>(String),
|
||||||
|
force: processedPropType<boolean>(Boolean)
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const { display, force } = toRefs(props);
|
||||||
|
|
||||||
|
const isHovered = ref(false);
|
||||||
|
const isShown = computed(() => (unwrapRef(force) || isHovered.value) && comp.value);
|
||||||
|
const comp = computeOptionalComponent(display);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isHovered,
|
||||||
|
isShown,
|
||||||
|
comp,
|
||||||
|
unref
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tooltip-container {
|
||||||
|
position: relative;
|
||||||
|
--xoffset: 0px;
|
||||||
|
--yoffset: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip,
|
||||||
|
.tooltip::after {
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip {
|
||||||
|
text-align: center;
|
||||||
|
width: 150px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.2;
|
||||||
|
bottom: calc(100% + var(--yoffset));
|
||||||
|
left: calc(50% + var(--xoffset));
|
||||||
|
margin-bottom: 5px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 7px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: var(--tooltip-background);
|
||||||
|
color: var(--points);
|
||||||
|
z-index: 100 !important;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shown {
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip::after {
|
||||||
|
content: " ";
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
bottom: 100%;
|
||||||
|
left: calc(50% - var(--xoffset));
|
||||||
|
width: 0;
|
||||||
|
margin-left: -5px;
|
||||||
|
border-width: 5px;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: var(--tooltip-background) transparent transparent transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip.left,
|
||||||
|
.side-nodes .tooltip:not(.right):not(.bottom):not(.top) {
|
||||||
|
bottom: calc(50% + var(--yoffset));
|
||||||
|
left: unset;
|
||||||
|
right: calc(100% + var(--xoffset));
|
||||||
|
margin-bottom: unset;
|
||||||
|
margin-right: 5px;
|
||||||
|
transform: translateY(50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip.left::after,
|
||||||
|
.side-nodes .tooltip:not(.right):not(.bottom):not(.top)::after {
|
||||||
|
top: calc(50% + var(--yoffset));
|
||||||
|
bottom: unset;
|
||||||
|
left: 100%;
|
||||||
|
right: 100%;
|
||||||
|
margin-left: unset;
|
||||||
|
margin-top: -5px;
|
||||||
|
border-color: transparent transparent transparent var(--tooltip-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip.right {
|
||||||
|
bottom: calc(50% + var(--yoffset));
|
||||||
|
left: calc(100% + var(--xoffset));
|
||||||
|
margin-bottom: unset;
|
||||||
|
margin-left: 5px;
|
||||||
|
transform: translateY(50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip.right::after {
|
||||||
|
top: calc(50% + var(--yoffset));
|
||||||
|
left: 0;
|
||||||
|
right: 100%;
|
||||||
|
margin-left: -10px;
|
||||||
|
margin-top: -5px;
|
||||||
|
border-color: transparent var(--tooltip-background) transparent transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip.bottom {
|
||||||
|
top: calc(100% + var(--yoffset));
|
||||||
|
bottom: unset;
|
||||||
|
left: calc(50% + var(--xoffset));
|
||||||
|
margin-bottom: unset;
|
||||||
|
margin-top: 5px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip.bottom::after {
|
||||||
|
top: 0;
|
||||||
|
margin-top: -10px;
|
||||||
|
border-color: transparent transparent var(--tooltip-background) transparent;
|
||||||
|
}
|
||||||
|
</style>
|
35
src/components/common/features.css
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
.feature:not(li),
|
||||||
|
.feature:not(li) button {
|
||||||
|
position: relative;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.125);
|
||||||
|
margin: var(--feature-margin);
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: var(--feature-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.can,
|
||||||
|
.can button {
|
||||||
|
background-color: var(--layer-color);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.can:hover,
|
||||||
|
.can:hover button {
|
||||||
|
transform: scale(1.15, 1.15);
|
||||||
|
box-shadow: 0 0 20px var(--points);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locked,
|
||||||
|
.locked button {
|
||||||
|
background-color: var(--locked);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bought,
|
||||||
|
.bought button {
|
||||||
|
background-color: var(--bought);
|
||||||
|
cursor: default;
|
||||||
|
}
|
13
src/components/common/fields.css
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
min-height: 2em;
|
||||||
|
margin: 10px 0;
|
||||||
|
user-select: none;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field > * {
|
||||||
|
margin: 0;
|
||||||
|
}
|
91
src/components/common/table.css
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
.table {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column wrap;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table + .table {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row wrap;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: stretch;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row > :not(.feature) {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column wrap;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.mergeAdjacent > .feature,
|
||||||
|
.row.mergeAdjacent > .tooltip-container > .feature {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.mergeAdjacent > .feature:first-child,
|
||||||
|
.row.mergeAdjacent > .tooltip-container:first-child > .feature {
|
||||||
|
border-radius: var(--border-radius) 0 0 var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.mergeAdjacent > .feature:last-child,
|
||||||
|
.row.mergeAdjacent > .tooltip-container:last-child > .feature {
|
||||||
|
border-radius: 0 var(--border-radius) var(--border-radius) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.mergeAdjacent > .feature:first-child:last-child,
|
||||||
|
.row.mergeAdjacent > .tooltip-container:first-child:last-child > .feature {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
TODO how to implement mergeAdjacent for grids?
|
||||||
|
.row.mergeAdjacent + .row.mergeAdjacent > .feature {
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
.col.mergeAdjacent .feature {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col.mergeAdjacent .feature:first-child {
|
||||||
|
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col.mergeAdjacent .feature:last-child {
|
||||||
|
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col.mergeAdjacent .feature:first-child:last-child {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
TODO how to implement mergeAdjacent for grids?
|
||||||
|
.col.mergeAdjacent + .col.mergeAdjacent > .feature {
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
}
|
||||||
|
*/
|
78
src/components/fields/DangerButton.vue
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
<template>
|
||||||
|
<span class="container" :class="{ confirming: isConfirming }">
|
||||||
|
<span v-if="isConfirming">Are you sure?</span>
|
||||||
|
<button @click.stop="click" class="button danger" :disabled="disabled">
|
||||||
|
<span v-if="isConfirming">Yes</span>
|
||||||
|
<slot v-else />
|
||||||
|
</button>
|
||||||
|
<button v-if="isConfirming" class="button" @click.stop="cancel">No</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, toRefs, unref, watch } from "vue";
|
||||||
|
|
||||||
|
const _props = defineProps<{
|
||||||
|
disabled?: boolean;
|
||||||
|
skipConfirm?: boolean;
|
||||||
|
}>();
|
||||||
|
const props = toRefs(_props);
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "click"): void;
|
||||||
|
(e: "confirmingChanged", value: boolean): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isConfirming = ref(false);
|
||||||
|
|
||||||
|
watch(isConfirming, isConfirming => {
|
||||||
|
emit("confirmingChanged", isConfirming);
|
||||||
|
});
|
||||||
|
|
||||||
|
function click() {
|
||||||
|
if (unref(props.skipConfirm)) {
|
||||||
|
emit("click");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isConfirming.value) {
|
||||||
|
emit("click");
|
||||||
|
}
|
||||||
|
isConfirming.value = !isConfirming.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
isConfirming.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--raised-background);
|
||||||
|
box-shadow: var(--raised-background) 0 2px 3px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container.confirming button {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container > * {
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.danger,
|
||||||
|
.button.danger {
|
||||||
|
position: relative;
|
||||||
|
border: solid 2px var(--danger);
|
||||||
|
border-right-width: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger::after {
|
||||||
|
position: absolute;
|
||||||
|
content: "!";
|
||||||
|
color: white;
|
||||||
|
right: -13px;
|
||||||
|
}
|
||||||
|
</style>
|
74
src/components/fields/FeedbackButton.vue
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
<template>
|
||||||
|
<button @click.stop="click" class="feedback" :class="{ activated, left }">
|
||||||
|
<slot />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, ref } from "vue";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
left?: boolean;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "click"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const activated = ref(false);
|
||||||
|
const activatedTimeout = ref<number | null>(null);
|
||||||
|
|
||||||
|
function click() {
|
||||||
|
emit("click");
|
||||||
|
|
||||||
|
// Give feedback to user
|
||||||
|
if (activatedTimeout.value) {
|
||||||
|
clearTimeout(activatedTimeout.value);
|
||||||
|
}
|
||||||
|
activated.value = false;
|
||||||
|
nextTick(() => {
|
||||||
|
activated.value = true;
|
||||||
|
activatedTimeout.value = setTimeout(() => (activated.value = false), 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.feedback {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback::after {
|
||||||
|
position: absolute;
|
||||||
|
left: calc(100% + 5px);
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
content: "✔";
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: inset 0 0 0 35px rgba(111, 148, 182, 0);
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback.left::after {
|
||||||
|
left: unset;
|
||||||
|
right: calc(100% + 5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback.activated::after {
|
||||||
|
animation: feedback 0.5s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes feedback {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale3d(0.4, 0.4, 1), translateY(-50%);
|
||||||
|
}
|
||||||
|
80% {
|
||||||
|
opacity: 0.1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale3d(1.2, 1.2, 1), translateY(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
93
src/components/fields/Select.vue
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
<template>
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-title" v-if="titleComponent"><component :is="titleComponent" /></span>
|
||||||
|
<VueNextSelect
|
||||||
|
:options="options"
|
||||||
|
v-model="value"
|
||||||
|
@update:model-value="onUpdate"
|
||||||
|
:min="1"
|
||||||
|
label-by="label"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:close-on-select="closeOnSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import "@/components/common/fields.css";
|
||||||
|
import { CoercableComponent } from "@/features/feature";
|
||||||
|
import { computeOptionalComponent } from "@/util/vue";
|
||||||
|
import { ref, toRef, watch } from "vue";
|
||||||
|
import VueNextSelect from "vue-next-select";
|
||||||
|
import "vue-next-select/dist/index.css";
|
||||||
|
|
||||||
|
export type SelectOption = { label: string; value: unknown };
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
title?: CoercableComponent;
|
||||||
|
modelValue?: unknown;
|
||||||
|
options: SelectOption[];
|
||||||
|
placeholder?: string;
|
||||||
|
closeOnSelect?: boolean;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", value: unknown): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const titleComponent = computeOptionalComponent(toRef(props, "title"), "span");
|
||||||
|
|
||||||
|
const value = ref<SelectOption | undefined>(
|
||||||
|
props.options.find(option => option.value === props.modelValue)
|
||||||
|
);
|
||||||
|
watch(toRef(props, "modelValue"), modelValue => {
|
||||||
|
if (value.value?.value !== modelValue) {
|
||||||
|
value.value = props.options.find(option => option.value === modelValue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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(--feature-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::placeholder {
|
||||||
|
color: var(--link);
|
||||||
|
}
|
||||||
|
</style>
|
40
src/components/fields/Slider.vue
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
<template>
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-title" v-if="title">{{ title }}</span>
|
||||||
|
<Tooltip :display="`${value}`" :class="{ fullWidth: !title }">
|
||||||
|
<input type="range" v-model="value" :min="min" :max="max" />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, toRefs, unref } from "vue";
|
||||||
|
import Tooltip from "../Tooltip.vue";
|
||||||
|
import "@/components/common/fields.css";
|
||||||
|
|
||||||
|
const _props = defineProps<{
|
||||||
|
title?: string;
|
||||||
|
modelValue?: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
}>();
|
||||||
|
const props = toRefs(_props);
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", value: number): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const value = computed({
|
||||||
|
get() {
|
||||||
|
return String(unref(props.modelValue) || 0);
|
||||||
|
},
|
||||||
|
set(value: string) {
|
||||||
|
emit("update:modelValue", Number(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fullWidth {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
92
src/components/fields/Text.vue
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
<template>
|
||||||
|
<form @submit.prevent="submit">
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-title" v-if="titleComponent"
|
||||||
|
><component :is="titleComponent"
|
||||||
|
/></span>
|
||||||
|
<VueTextareaAutosize
|
||||||
|
v-if="textArea"
|
||||||
|
v-model="value"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:maxHeight="maxHeight"
|
||||||
|
@blur="submit"
|
||||||
|
ref="field"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
type="text"
|
||||||
|
v-model="value"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:class="{ fullWidth: !title }"
|
||||||
|
@blur="submit"
|
||||||
|
ref="field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { CoercableComponent } from "@/features/feature";
|
||||||
|
import { coerceComponent } from "@/util/vue";
|
||||||
|
import { computed, onMounted, ref, toRefs, unref } from "vue";
|
||||||
|
import VueTextareaAutosize from "vue-textarea-autosize";
|
||||||
|
import "@/components/common/fields.css";
|
||||||
|
|
||||||
|
const _props = defineProps<{
|
||||||
|
title?: CoercableComponent;
|
||||||
|
modelValue?: string;
|
||||||
|
textArea?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
maxHeight?: number;
|
||||||
|
}>();
|
||||||
|
const props = toRefs(_props);
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", value: string): void;
|
||||||
|
(e: "submit"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const titleComponent = computed(
|
||||||
|
() => props.title?.value && coerceComponent(unref(props.title.value), "span")
|
||||||
|
);
|
||||||
|
|
||||||
|
const field = ref<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");
|
||||||
|
}
|
||||||
|
</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>
|
115
src/components/fields/Toggle.vue
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
<template>
|
||||||
|
<label class="field">
|
||||||
|
<input type="checkbox" class="toggle" v-model="value" />
|
||||||
|
<component :is="component" />
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { CoercableComponent } from "@/features/feature";
|
||||||
|
import { coerceComponent } from "@/util/vue";
|
||||||
|
import { computed, unref } from "vue";
|
||||||
|
import "@/components/common/fields.css";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
title?: CoercableComponent;
|
||||||
|
modelValue?: boolean;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", value: boolean): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const component = computed(() => coerceComponent(unref(props.title) || "<span></span>", "span"));
|
||||||
|
|
||||||
|
const value = computed({
|
||||||
|
get() {
|
||||||
|
return !!props.modelValue;
|
||||||
|
},
|
||||||
|
set(value: boolean) {
|
||||||
|
emit("update:modelValue", value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.field {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
appearance: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* track */
|
||||||
|
input + span::before {
|
||||||
|
content: "";
|
||||||
|
float: right;
|
||||||
|
margin: 5px 0 5px 10px;
|
||||||
|
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: 2px;
|
||||||
|
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>
|
16
src/components/layout/Column.vue
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<template>
|
||||||
|
<div class="table">
|
||||||
|
<div class="col" :class="{ mergeAdjacent }">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import "@/components/common/table.css";
|
||||||
|
import themes from "@/data/themes";
|
||||||
|
import settings from "@/game/settings";
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const mergeAdjacent = computed(() => themes[settings.theme].mergeAdjacent);
|
||||||
|
</script>
|
16
src/components/layout/Row.vue
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<template>
|
||||||
|
<div class="table">
|
||||||
|
<div class="row" :class="{ mergeAdjacent }">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import "@/components/common/table.css";
|
||||||
|
import themes from "@/data/themes";
|
||||||
|
import settings from "@/game/settings";
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const mergeAdjacent = computed(() => themes[settings.theme].mergeAdjacent);
|
||||||
|
</script>
|
16
src/components/layout/Spacer.vue
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<template>
|
||||||
|
<div :style="{ width, height }"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
width?: string;
|
||||||
|
height?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
width: "8px",
|
||||||
|
height: "17px"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
59
src/components/layout/Sticky.vue
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
<template>
|
||||||
|
<div class="sticky" :style="{ top }" ref="element" data-v-sticky>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, onMounted, ref } from "vue";
|
||||||
|
|
||||||
|
const top = ref("0");
|
||||||
|
const observer = new ResizeObserver(updateTop);
|
||||||
|
const element = ref<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);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const el = element.value?.parentElement;
|
||||||
|
if (el) {
|
||||||
|
observer.observe(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sticky {
|
||||||
|
position: sticky;
|
||||||
|
background: var(--background);
|
||||||
|
margin-left: -10px;
|
||||||
|
margin-right: -10px;
|
||||||
|
padding-left: 10px;
|
||||||
|
padding-right: 10px;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body .sticky {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
</style>
|
18
src/components/layout/VerticalRule.vue
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
<template>
|
||||||
|
<div class="vr" :style="{ height }"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
height?: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.vr {
|
||||||
|
width: 4px;
|
||||||
|
background: var(--outline);
|
||||||
|
height: 100%;
|
||||||
|
margin: auto var(--feature-margin);
|
||||||
|
}
|
||||||
|
</style>
|
43
src/components/links/Link.vue
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
<template>
|
||||||
|
<line
|
||||||
|
stroke-width="15px"
|
||||||
|
stroke="white"
|
||||||
|
v-bind="link"
|
||||||
|
:x1="startPosition.x"
|
||||||
|
:y1="startPosition.y"
|
||||||
|
:x2="endPosition.x"
|
||||||
|
:y2="endPosition.y"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Link, LinkNode } from "@/features/links";
|
||||||
|
import { computed, toRefs, unref } from "vue";
|
||||||
|
|
||||||
|
const _props = defineProps<{
|
||||||
|
link: Link;
|
||||||
|
startNode: LinkNode;
|
||||||
|
endNode: LinkNode;
|
||||||
|
}>();
|
||||||
|
const props = toRefs(_props);
|
||||||
|
|
||||||
|
const startPosition = computed(() => {
|
||||||
|
const position = { x: props.startNode.value.x || 0, y: props.startNode.value.y || 0 };
|
||||||
|
if (props.link.value.offsetStart) {
|
||||||
|
position.x += unref(props.link.value.offsetStart).x;
|
||||||
|
position.y += unref(props.link.value.offsetStart).y;
|
||||||
|
}
|
||||||
|
return position;
|
||||||
|
});
|
||||||
|
|
||||||
|
const endPosition = computed(() => {
|
||||||
|
const position = { x: props.endNode.value.x || 0, y: props.endNode.value.y || 0 };
|
||||||
|
if (props.link.value.offsetEnd) {
|
||||||
|
position.x += unref(props.link.value.offsetEnd).x;
|
||||||
|
position.y += unref(props.link.value.offsetEnd).y;
|
||||||
|
}
|
||||||
|
return position;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
42
src/components/links/LinkNode.vue
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
<template>
|
||||||
|
<div class="branch" ref="node"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { RegisterLinkNodeInjectionKey, UnregisterLinkNodeInjectionKey } from "@/features/links";
|
||||||
|
import { computed, inject, onUnmounted, ref, toRefs, unref, watch } from "vue";
|
||||||
|
|
||||||
|
const _props = defineProps<{ id: string }>();
|
||||||
|
const props = toRefs(_props);
|
||||||
|
|
||||||
|
const register = inject(RegisterLinkNodeInjectionKey);
|
||||||
|
const unregister = inject(UnregisterLinkNodeInjectionKey);
|
||||||
|
|
||||||
|
const node = ref<HTMLElement | null>(null);
|
||||||
|
const parentNode = computed(() => node.value && node.value.parentElement);
|
||||||
|
|
||||||
|
if (register && unregister) {
|
||||||
|
watch([parentNode, props.id], ([newNode, newID], [prevNode, prevID]) => {
|
||||||
|
if (prevNode) {
|
||||||
|
unregister(unref(prevID));
|
||||||
|
}
|
||||||
|
if (newNode) {
|
||||||
|
register(newID, newNode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => unregister(unref(props.id)));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.branch {
|
||||||
|
position: absolute;
|
||||||
|
z-index: -10;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
112
src/components/links/Links.vue
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
<template>
|
||||||
|
<slot />
|
||||||
|
<div ref="resizeListener" class="resize-listener" />
|
||||||
|
<svg v-if="validLinks" v-bind="$attrs">
|
||||||
|
<LinkVue
|
||||||
|
v-for="(link, index) in validLinks"
|
||||||
|
:key="index"
|
||||||
|
:link="link"
|
||||||
|
:startNode="nodes[link.startNode.id]!"
|
||||||
|
:endNode="nodes[link.endNode.id]!"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Link,
|
||||||
|
LinkNode,
|
||||||
|
RegisterLinkNodeInjectionKey,
|
||||||
|
UnregisterLinkNodeInjectionKey
|
||||||
|
} from "@/features/links";
|
||||||
|
import { computed, nextTick, onMounted, provide, ref, toRef } from "vue";
|
||||||
|
import LinkVue from "./Link.vue";
|
||||||
|
|
||||||
|
const _props = defineProps<{ links?: Link[] }>();
|
||||||
|
const links = toRef(_props, "links");
|
||||||
|
|
||||||
|
const observer = new MutationObserver(updateNodes);
|
||||||
|
const resizeObserver = new ResizeObserver(updateBounds);
|
||||||
|
|
||||||
|
const nodes = ref<Record<string, LinkNode | undefined>>({});
|
||||||
|
const boundingRect = ref(new DOMRect());
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
updateNodes();
|
||||||
|
});
|
||||||
|
|
||||||
|
const validLinks = computed(
|
||||||
|
() =>
|
||||||
|
links.value?.filter(link => {
|
||||||
|
const n = nodes.value;
|
||||||
|
return (
|
||||||
|
n[link.startNode.id]?.x != undefined &&
|
||||||
|
n[link.startNode.id]?.y != undefined &&
|
||||||
|
n[link.endNode.id]?.x != undefined &&
|
||||||
|
n[link.endNode.id]?.y != undefined
|
||||||
|
);
|
||||||
|
}) ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
const observerOptions = {
|
||||||
|
attributes: true,
|
||||||
|
childList: true,
|
||||||
|
subtree: true
|
||||||
|
};
|
||||||
|
|
||||||
|
provide(RegisterLinkNodeInjectionKey, (id, element) => {
|
||||||
|
nodes.value[id] = { element };
|
||||||
|
observer.observe(element, observerOptions);
|
||||||
|
nextTick(() => {
|
||||||
|
if (resizeListener.value != null) {
|
||||||
|
updateNode(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
provide(UnregisterLinkNodeInjectionKey, id => {
|
||||||
|
nodes.value[id] = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateNodes() {
|
||||||
|
if (resizeListener.value != null) {
|
||||||
|
Object.keys(nodes.value).forEach(id => updateNode(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateNode(id: string) {
|
||||||
|
const node = nodes.value[id];
|
||||||
|
if (!node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const linkStartRect = node.element.getBoundingClientRect();
|
||||||
|
node.x = linkStartRect.x + linkStartRect.width / 2 - boundingRect.value.x;
|
||||||
|
node.y = linkStartRect.y + linkStartRect.height / 2 - boundingRect.value.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBounds() {
|
||||||
|
if (resizeListener.value != null) {
|
||||||
|
boundingRect.value = resizeListener.value.getBoundingClientRect();
|
||||||
|
updateNodes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
svg,
|
||||||
|
.resize-listener {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: -10;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
83
src/data/Changelog.vue
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
<template>
|
||||||
|
<Modal v-model="isOpen">
|
||||||
|
<template v-slot:header>
|
||||||
|
<h2>Changelog</h2>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body>
|
||||||
|
<details open>
|
||||||
|
<summary>v0.0 Initial Commit - <time>2021-09-04</time></summary>
|
||||||
|
This is the first release :D
|
||||||
|
<ul>
|
||||||
|
<li class="feature">Did everything</li>
|
||||||
|
<li class="fix">Had some fun</li>
|
||||||
|
<li class="breaking">Removed everything</li>
|
||||||
|
<li class="balancing">Created some bugs to fix later</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Modal from "@/components/Modal.vue";
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
const isOpen = ref(false);
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
open() {
|
||||||
|
isOpen.value = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
details {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
margin-left: -18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: var(--feature-margin) 0;
|
||||||
|
background: var(--raised-background);
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.125);
|
||||||
|
padding: 5px 5px 5px 15px;
|
||||||
|
list-style: inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li::before {
|
||||||
|
padding: 2px 8px;
|
||||||
|
margin-right: 8px;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature::before {
|
||||||
|
content: "Feature";
|
||||||
|
background: var(--accent1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fix::before {
|
||||||
|
content: "Fix";
|
||||||
|
background: var(--accent2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balancing::before {
|
||||||
|
content: "Balancing";
|
||||||
|
background: var(--accent3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breaking::before {
|
||||||
|
content: "Breaking";
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
</style>
|
153
src/data/common.tsx
Normal file
|
@ -0,0 +1,153 @@
|
||||||
|
import {
|
||||||
|
Clickable,
|
||||||
|
ClickableOptions,
|
||||||
|
createClickable,
|
||||||
|
GenericClickable
|
||||||
|
} from "@/features/clickables/clickable";
|
||||||
|
import { GenericConversion } from "@/features/conversion";
|
||||||
|
import { CoercableComponent, jsx, Replace, setDefault } from "@/features/feature";
|
||||||
|
import { displayResource } from "@/features/resources/resource";
|
||||||
|
import {
|
||||||
|
createTreeNode,
|
||||||
|
GenericTree,
|
||||||
|
GenericTreeNode,
|
||||||
|
TreeNode,
|
||||||
|
TreeNodeOptions
|
||||||
|
} from "@/features/trees/tree";
|
||||||
|
import player from "@/game/player";
|
||||||
|
import Decimal from "@/util/bignum";
|
||||||
|
import {
|
||||||
|
Computable,
|
||||||
|
GetComputableTypeWithDefault,
|
||||||
|
processComputable,
|
||||||
|
ProcessedComputable
|
||||||
|
} from "@/util/computed";
|
||||||
|
import { computed, Ref, unref } from "vue";
|
||||||
|
|
||||||
|
export interface ResetButtonOptions extends ClickableOptions {
|
||||||
|
conversion: GenericConversion;
|
||||||
|
tree: GenericTree;
|
||||||
|
treeNode: GenericTreeNode;
|
||||||
|
resetDescription?: Computable<string>;
|
||||||
|
showNextAt?: Computable<boolean>;
|
||||||
|
display?: Computable<CoercableComponent>;
|
||||||
|
canClick?: Computable<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResetButton<T extends ResetButtonOptions> = Replace<
|
||||||
|
Clickable<T>,
|
||||||
|
{
|
||||||
|
resetDescription: GetComputableTypeWithDefault<T["resetDescription"], Ref<string>>;
|
||||||
|
showNextAt: GetComputableTypeWithDefault<T["showNextAt"], true>;
|
||||||
|
display: GetComputableTypeWithDefault<T["display"], Ref<JSX.Element>>;
|
||||||
|
canClick: GetComputableTypeWithDefault<T["canClick"], Ref<boolean>>;
|
||||||
|
onClick: VoidFunction;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericResetButton = Replace<
|
||||||
|
GenericClickable & ResetButton<ResetButtonOptions>,
|
||||||
|
{
|
||||||
|
resetDescription: ProcessedComputable<string>;
|
||||||
|
showNextAt: ProcessedComputable<boolean>;
|
||||||
|
display: ProcessedComputable<CoercableComponent>;
|
||||||
|
canClick: ProcessedComputable<boolean>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createResetButton<T extends ClickableOptions & ResetButtonOptions>(
|
||||||
|
optionsFunc: () => T
|
||||||
|
): ResetButton<T> {
|
||||||
|
return createClickable(() => {
|
||||||
|
const resetButton = optionsFunc();
|
||||||
|
|
||||||
|
processComputable(resetButton as T, "showNextAt");
|
||||||
|
setDefault(resetButton, "showNextAt", true);
|
||||||
|
|
||||||
|
if (resetButton.resetDescription == null) {
|
||||||
|
resetButton.resetDescription = computed(() =>
|
||||||
|
Decimal.lt(resetButton.conversion.gainResource.value, 1e3) ? "Reset for " : ""
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
processComputable(resetButton as T, "resetDescription");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resetButton.display == null) {
|
||||||
|
resetButton.display = jsx(() => (
|
||||||
|
<span>
|
||||||
|
{unref(resetButton.resetDescription as ProcessedComputable<string>)}
|
||||||
|
<b>
|
||||||
|
{displayResource(
|
||||||
|
resetButton.conversion.gainResource,
|
||||||
|
unref(resetButton.conversion.currentGain)
|
||||||
|
)}
|
||||||
|
</b>{" "}
|
||||||
|
{resetButton.conversion.gainResource.displayName}
|
||||||
|
<div v-show={unref(resetButton.showNextAt)}>
|
||||||
|
<br />
|
||||||
|
Next:{" "}
|
||||||
|
{displayResource(
|
||||||
|
resetButton.conversion.baseResource,
|
||||||
|
unref(resetButton.conversion.nextAt)
|
||||||
|
)}{" "}
|
||||||
|
{resetButton.conversion.baseResource.displayName}
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resetButton.canClick == null) {
|
||||||
|
resetButton.canClick = computed(() =>
|
||||||
|
Decimal.gt(unref(resetButton.conversion.currentGain), 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClick = resetButton.onClick;
|
||||||
|
resetButton.onClick = function () {
|
||||||
|
resetButton.conversion.convert();
|
||||||
|
resetButton.tree.reset(resetButton.treeNode);
|
||||||
|
onClick?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return resetButton;
|
||||||
|
}) as unknown as ResetButton<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayerTreeNodeOptions extends TreeNodeOptions {
|
||||||
|
layerID: string;
|
||||||
|
color: string;
|
||||||
|
append?: boolean;
|
||||||
|
}
|
||||||
|
export type LayerTreeNode<T extends LayerTreeNodeOptions> = Replace<
|
||||||
|
TreeNode<T>,
|
||||||
|
{
|
||||||
|
append: ProcessedComputable<boolean>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
export type GenericLayerTreeNode = LayerTreeNode<LayerTreeNodeOptions>;
|
||||||
|
|
||||||
|
export function createLayerTreeNode<T extends LayerTreeNodeOptions>(
|
||||||
|
optionsFunc: () => T
|
||||||
|
): LayerTreeNode<T> {
|
||||||
|
return createTreeNode(() => {
|
||||||
|
const options = optionsFunc();
|
||||||
|
processComputable(options as T, "append");
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
display: options.layerID,
|
||||||
|
onClick:
|
||||||
|
options.append != null && options.append
|
||||||
|
? function () {
|
||||||
|
if (player.tabs.includes(options.layerID)) {
|
||||||
|
const index = player.tabs.lastIndexOf(options.layerID);
|
||||||
|
player.tabs.splice(index, 1);
|
||||||
|
} else {
|
||||||
|
player.tabs.push(options.layerID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: function () {
|
||||||
|
player.tabs.splice(1, 1, options.layerID);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}) as unknown as LayerTreeNode<T>;
|
||||||
|
}
|
135
src/data/layers/aca/a.tsx
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
import Row from "@/components/layout/Row.vue";
|
||||||
|
import Tooltip from "@/components/Tooltip.vue";
|
||||||
|
import { main } from "@/data/mod";
|
||||||
|
import { createAchievement } from "@/features/achievements/achievement";
|
||||||
|
import { jsx } from "@/features/feature";
|
||||||
|
import { createGrid } from "@/features/grids/grid";
|
||||||
|
import { createResource } from "@/features/resources/resource";
|
||||||
|
import { createTreeNode } from "@/features/trees/tree";
|
||||||
|
import { createLayer } from "@/game/layers";
|
||||||
|
import { DecimalSource } from "@/lib/break_eternity";
|
||||||
|
import Decimal from "@/util/bignum";
|
||||||
|
import { render, renderRow } from "@/util/vue";
|
||||||
|
import { computed } from "vue";
|
||||||
|
import f from "./f";
|
||||||
|
|
||||||
|
const layer = createLayer(() => {
|
||||||
|
const id = "a";
|
||||||
|
const color = "yellow";
|
||||||
|
const name = "Achievements";
|
||||||
|
const points = createResource<DecimalSource>(0, "achievement power");
|
||||||
|
|
||||||
|
const treeNode = createTreeNode(() => ({
|
||||||
|
display: "A",
|
||||||
|
color,
|
||||||
|
tooltip: {
|
||||||
|
display: "Achievements",
|
||||||
|
right: true
|
||||||
|
},
|
||||||
|
onClick() {
|
||||||
|
main.showAchievements();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ach1 = createAchievement(() => ({
|
||||||
|
image: "https://unsoftcapped2.github.io/The-Modding-Tree-2/discord.png",
|
||||||
|
display: "Get me!",
|
||||||
|
tooltip: computed(() => {
|
||||||
|
if (ach1.earned.value) {
|
||||||
|
return "You did it!";
|
||||||
|
}
|
||||||
|
return "How did this happen?";
|
||||||
|
}),
|
||||||
|
shouldEarn: true
|
||||||
|
}));
|
||||||
|
const ach2 = createAchievement(() => ({
|
||||||
|
display: "Impossible!",
|
||||||
|
tooltip: computed(() => {
|
||||||
|
if (ach2.earned.value) {
|
||||||
|
return "HOW????";
|
||||||
|
}
|
||||||
|
return "Mwahahaha!";
|
||||||
|
}),
|
||||||
|
style: { color: "#04e050" }
|
||||||
|
}));
|
||||||
|
const ach3 = createAchievement(() => ({
|
||||||
|
display: "EIEIO",
|
||||||
|
tooltip:
|
||||||
|
"Get a farm point.\n\nReward: The dinosaur is now your friend (you can max Farm Points).",
|
||||||
|
shouldEarn: function () {
|
||||||
|
return Decimal.gte(f.points.value, 1);
|
||||||
|
},
|
||||||
|
onComplete() {
|
||||||
|
console.log("Bork bork bork!");
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const achievements = [ach1, ach2, ach3];
|
||||||
|
|
||||||
|
const grid = createGrid(() => ({
|
||||||
|
rows: 2,
|
||||||
|
cols: 2,
|
||||||
|
getStartState(id) {
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
getStyle(id, state) {
|
||||||
|
return { backgroundColor: `#${(Number(state) * 1234) % 999999}` };
|
||||||
|
},
|
||||||
|
// TODO display should return an object
|
||||||
|
getTitle(id) {
|
||||||
|
let direction = "";
|
||||||
|
if (id === "101") {
|
||||||
|
direction = "top";
|
||||||
|
} else if (id === "102") {
|
||||||
|
direction = "bottom";
|
||||||
|
} else if (id === "201") {
|
||||||
|
direction = "left";
|
||||||
|
} else if (id === "202") {
|
||||||
|
direction = "right";
|
||||||
|
}
|
||||||
|
return jsx(() => (
|
||||||
|
<Tooltip display={JSON.stringify(this.cells[id].style)} {...{ [direction]: true }}>
|
||||||
|
<h3>Gridable #{id}</h3>
|
||||||
|
</Tooltip>
|
||||||
|
));
|
||||||
|
},
|
||||||
|
getDisplay(id, state) {
|
||||||
|
return String(state);
|
||||||
|
},
|
||||||
|
getCanClick() {
|
||||||
|
return Decimal.eq(main.points.value, 10);
|
||||||
|
},
|
||||||
|
onClick(id, state) {
|
||||||
|
this.cells[id].state = Number(state) + 1;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const display = jsx(() => (
|
||||||
|
<>
|
||||||
|
<Row>
|
||||||
|
<Tooltip display={ach1.tooltip} bottom>
|
||||||
|
{render(ach1)}
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip display={ach2.tooltip} bottom>
|
||||||
|
{render(ach2)}
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip display={ach3.tooltip} bottom>
|
||||||
|
{render(ach3)}
|
||||||
|
</Tooltip>
|
||||||
|
</Row>
|
||||||
|
{renderRow(grid)}
|
||||||
|
</>
|
||||||
|
));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
color,
|
||||||
|
name,
|
||||||
|
points,
|
||||||
|
achievements,
|
||||||
|
grid,
|
||||||
|
treeNode,
|
||||||
|
display
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export default layer;
|
686
src/data/layers/aca/c.tsx
Normal file
|
@ -0,0 +1,686 @@
|
||||||
|
import Slider from "@/components/fields/Slider.vue";
|
||||||
|
import Text from "@/components/fields/Text.vue";
|
||||||
|
import Toggle from "@/components/fields/Toggle.vue";
|
||||||
|
import Column from "@/components/layout/Column.vue";
|
||||||
|
import Row from "@/components/layout/Row.vue";
|
||||||
|
import Spacer from "@/components/layout/Spacer.vue";
|
||||||
|
import Sticky from "@/components/layout/Sticky.vue";
|
||||||
|
import VerticalRule from "@/components/layout/VerticalRule.vue";
|
||||||
|
import Modal from "@/components/Modal.vue";
|
||||||
|
import { createLayerTreeNode, createResetButton } from "@/data/common";
|
||||||
|
import { main } from "@/data/mod";
|
||||||
|
import themes from "@/data/themes";
|
||||||
|
import { createBar, Direction } from "@/features/bars/bar";
|
||||||
|
import { createBuyable } from "@/features/buyable";
|
||||||
|
import { createChallenge } from "@/features/challenges/challenge";
|
||||||
|
import { createClickable } from "@/features/clickables/clickable";
|
||||||
|
import {
|
||||||
|
addSoftcap,
|
||||||
|
createCumulativeConversion,
|
||||||
|
createExponentialScaling
|
||||||
|
} from "@/features/conversion";
|
||||||
|
import { jsx, showIf, Visibility } from "@/features/feature";
|
||||||
|
import { createHotkey } from "@/features/hotkey";
|
||||||
|
import { createInfobox } from "@/features/infoboxes/infobox";
|
||||||
|
import { createMilestone } from "@/features/milestones/milestone";
|
||||||
|
import { createReset } from "@/features/reset";
|
||||||
|
import MainDisplay from "@/features/resources/MainDisplay.vue";
|
||||||
|
import { createResource, displayResource, trackBest } from "@/features/resources/resource";
|
||||||
|
import Resource from "@/features/resources/Resource.vue";
|
||||||
|
import { createTab } from "@/features/tabs/tab";
|
||||||
|
import { createTabFamily } from "@/features/tabs/tabFamily";
|
||||||
|
import { createTree, createTreeNode, GenericTreeNode, TreeBranch } from "@/features/trees/tree";
|
||||||
|
import { createUpgrade } from "@/features/upgrades/upgrade";
|
||||||
|
import { createLayer } from "@/game/layers";
|
||||||
|
import { persistent } from "@/game/persistence";
|
||||||
|
import settings from "@/game/settings";
|
||||||
|
import { DecimalSource } from "@/lib/break_eternity";
|
||||||
|
import Decimal, { format, formatWhole } from "@/util/bignum";
|
||||||
|
import { render, renderCol, renderRow } from "@/util/vue";
|
||||||
|
import { computed, ComputedRef, ref } from "vue";
|
||||||
|
import f from "./f";
|
||||||
|
|
||||||
|
const layer = createLayer(() => {
|
||||||
|
const id = "c";
|
||||||
|
const color = "#4BDC13";
|
||||||
|
const name = "Candies";
|
||||||
|
const points = createResource<DecimalSource>(0, "lollipops");
|
||||||
|
const best = trackBest(points);
|
||||||
|
const beep = persistent<boolean>(false);
|
||||||
|
const thingy = persistent<string>("pointy");
|
||||||
|
const otherThingy = persistent<number>(10);
|
||||||
|
const spentOnBuyables = persistent(new Decimal(10));
|
||||||
|
|
||||||
|
const waffleBoost = computed(() => Decimal.pow(points.value, 0.2));
|
||||||
|
const icecreamCap = computed(() => Decimal.times(points.value, 10));
|
||||||
|
|
||||||
|
const coolInfo = createInfobox(() => ({
|
||||||
|
title: "Lore",
|
||||||
|
titleStyle: { color: "#FE0000" },
|
||||||
|
display: "DEEP LORE!",
|
||||||
|
bodyStyle: { backgroundColor: "#0000EE" },
|
||||||
|
color: "rgb(75, 220, 19)"
|
||||||
|
}));
|
||||||
|
|
||||||
|
const lollipopMilestone3 = createMilestone(() => ({
|
||||||
|
shouldEarn() {
|
||||||
|
return Decimal.gte(best.value, 3);
|
||||||
|
},
|
||||||
|
display: {
|
||||||
|
requirement: "3 Lollipops",
|
||||||
|
effectDisplay: "Unlock the next milestone"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const lollipopMilestone4 = createMilestone(() => ({
|
||||||
|
visibility() {
|
||||||
|
return showIf(lollipopMilestone3.earned.value);
|
||||||
|
},
|
||||||
|
shouldEarn() {
|
||||||
|
return Decimal.gte(best.value, 4);
|
||||||
|
},
|
||||||
|
display: {
|
||||||
|
requirement: "4 Lollipops",
|
||||||
|
effectDisplay: "You can toggle beep and boop (which do nothing)",
|
||||||
|
optionsDisplay: jsx(() => (
|
||||||
|
<>
|
||||||
|
<Toggle
|
||||||
|
title="beep"
|
||||||
|
onUpdate:modelValue={value => (beep.value = value)}
|
||||||
|
modelValue={beep.value}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
title="boop"
|
||||||
|
onUpdate:modelValue={value => (f.boop.value = value)}
|
||||||
|
modelValue={f.boop.value}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
},
|
||||||
|
style() {
|
||||||
|
if (this.earned) {
|
||||||
|
return { backgroundColor: "#1111DD" };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const lollipopMilestones = [lollipopMilestone3, lollipopMilestone4];
|
||||||
|
|
||||||
|
const funChallenge = createChallenge(() => ({
|
||||||
|
title: "Fun",
|
||||||
|
completionLimit: 3,
|
||||||
|
display() {
|
||||||
|
return {
|
||||||
|
description: `Makes the game 0% harder<br>${formatWhole(this.completions.value)}/${
|
||||||
|
this.completionLimit
|
||||||
|
} completions`,
|
||||||
|
goal: "Have 20 points I guess",
|
||||||
|
reward: "Says hi",
|
||||||
|
effectDisplay: format(funEffect.value) + "x"
|
||||||
|
};
|
||||||
|
},
|
||||||
|
visibility() {
|
||||||
|
return showIf(Decimal.gt(best.value, 0));
|
||||||
|
},
|
||||||
|
goal: 20,
|
||||||
|
resource: main.points,
|
||||||
|
onComplete() {
|
||||||
|
console.log("hiii");
|
||||||
|
},
|
||||||
|
onEnter() {
|
||||||
|
main.points.value = 0;
|
||||||
|
main.best.value = main.points.value;
|
||||||
|
main.total.value = main.points.value;
|
||||||
|
console.log("So challenging");
|
||||||
|
},
|
||||||
|
onExit() {
|
||||||
|
console.log("Sweet freedom!");
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
height: "200px"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const funEffect = computed(() => Decimal.add(points.value, 1).tetrate(0.02));
|
||||||
|
|
||||||
|
const generatorUpgrade = createUpgrade(() => ({
|
||||||
|
display: {
|
||||||
|
title: "Generator of Genericness",
|
||||||
|
description: "Gain 1 point every second"
|
||||||
|
},
|
||||||
|
cost: 1,
|
||||||
|
resource: points
|
||||||
|
}));
|
||||||
|
const lollipopMultiplierUpgrade = createUpgrade(() => ({
|
||||||
|
display: () => ({
|
||||||
|
description: "Point generation is faster based on your unspent Lollipops",
|
||||||
|
effectDisplay: `${format(lollipopMultiplierEffect.value)}x`
|
||||||
|
}),
|
||||||
|
cost: 1,
|
||||||
|
resource: points,
|
||||||
|
visibility: () => showIf(generatorUpgrade.bought.value)
|
||||||
|
}));
|
||||||
|
const lollipopMultiplierEffect = computed(() => {
|
||||||
|
let ret = Decimal.add(points.value, 1).pow(0.5);
|
||||||
|
if (ret.gte("1e20000000")) ret = ret.sqrt().times("1e10000000");
|
||||||
|
return ret;
|
||||||
|
});
|
||||||
|
const unlockIlluminatiUpgrade = createUpgrade(() => ({
|
||||||
|
visibility() {
|
||||||
|
return showIf(lollipopMultiplierUpgrade.bought.value);
|
||||||
|
},
|
||||||
|
canAfford() {
|
||||||
|
return Decimal.lt(main.points.value, 7);
|
||||||
|
},
|
||||||
|
onPurchase() {
|
||||||
|
main.points.value = Decimal.add(main.points.value, 7);
|
||||||
|
},
|
||||||
|
display:
|
||||||
|
"Only buyable with less than 7 points, and gives you 7 more. Unlocks a secret subtab.",
|
||||||
|
style() {
|
||||||
|
if (this.bought) {
|
||||||
|
return { backgroundColor: "#1111dd" };
|
||||||
|
}
|
||||||
|
if (!this.canAfford) {
|
||||||
|
return { backgroundColor: "#dd1111" };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const quasiUpgrade = createUpgrade(() => ({
|
||||||
|
resource: createResource(exhancers.amount, "Exhancers", 0),
|
||||||
|
cost: 3,
|
||||||
|
display: {
|
||||||
|
title: "This upgrade doesn't exist",
|
||||||
|
description: "Or does it?"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const upgrades = [generatorUpgrade, lollipopMultiplierUpgrade, unlockIlluminatiUpgrade];
|
||||||
|
|
||||||
|
const exhancers = createBuyable(() => ({
|
||||||
|
resource: points,
|
||||||
|
cost() {
|
||||||
|
let x = new Decimal(this.amount.value);
|
||||||
|
if (x.gte(25)) {
|
||||||
|
x = x.pow(2).div(25);
|
||||||
|
}
|
||||||
|
const cost = Decimal.pow(2, x.pow(1.5));
|
||||||
|
return cost.floor();
|
||||||
|
},
|
||||||
|
display() {
|
||||||
|
return {
|
||||||
|
title: "Exhancers",
|
||||||
|
description: `Adds ${format(
|
||||||
|
thingEffect.value
|
||||||
|
)} things and multiplies stuff by ${format(stuffEffect.value)}.`
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onPurchase(cost) {
|
||||||
|
spentOnBuyables.value = Decimal.add(spentOnBuyables.value, cost);
|
||||||
|
},
|
||||||
|
style: { height: "222px" },
|
||||||
|
purchaseLimit: 4
|
||||||
|
}));
|
||||||
|
// The following need redundant ComputedRef<Decimal> type annotations because otherwise the ts
|
||||||
|
// interpreter thinks exhancers are cyclically referenced
|
||||||
|
const thingEffect: ComputedRef<Decimal> = computed(() => {
|
||||||
|
if (Decimal.gte(exhancers.amount.value, 0)) {
|
||||||
|
return Decimal.pow(25, Decimal.pow(exhancers.amount.value, 1.1));
|
||||||
|
}
|
||||||
|
return Decimal.pow(1 / 25, Decimal.times(exhancers.amount.value, -1).pow(1.1));
|
||||||
|
});
|
||||||
|
const stuffEffect: ComputedRef<Decimal> = computed(() => {
|
||||||
|
if (Decimal.gte(exhancers.amount.value, 0)) {
|
||||||
|
return Decimal.pow(25, Decimal.pow(exhancers.amount.value, 1.1));
|
||||||
|
}
|
||||||
|
return Decimal.pow(1 / 25, Decimal.times(exhancers.amount.value, -1).pow(1.1));
|
||||||
|
});
|
||||||
|
const confirmRespec = persistent<boolean>(false);
|
||||||
|
const confirming = ref(false);
|
||||||
|
const respecBuyables = createClickable(() => ({
|
||||||
|
small: true,
|
||||||
|
display: "Respec Thingies",
|
||||||
|
onClick() {
|
||||||
|
if (confirmRespec.value && !confirming.value) {
|
||||||
|
confirming.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
points.value = Decimal.add(points.value, spentOnBuyables.value);
|
||||||
|
exhancers.amount.value = 0;
|
||||||
|
main.tree.reset(treeNode);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const sellExhancer = createClickable(() => ({
|
||||||
|
small: true,
|
||||||
|
display: "Sell One",
|
||||||
|
onClick() {
|
||||||
|
if (Decimal.lte(exhancers.amount.value, 0)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
exhancers.amount.value = Decimal.sub(exhancers.amount.value, 1);
|
||||||
|
points.value = Decimal.add(points.value, exhancers.cost.value);
|
||||||
|
spentOnBuyables.value = Decimal.sub(spentOnBuyables.value, exhancers.cost.value);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const buyablesDisplay = jsx(() => (
|
||||||
|
<Column>
|
||||||
|
<Row>
|
||||||
|
<Toggle
|
||||||
|
title="Confirm"
|
||||||
|
onUpdate:modelValue={value => (confirmRespec.value = value)}
|
||||||
|
modelValue={confirmRespec.value}
|
||||||
|
/>
|
||||||
|
{renderRow(respecBuyables)}
|
||||||
|
</Row>
|
||||||
|
{renderRow(exhancers)}
|
||||||
|
{renderRow(sellExhancer)}
|
||||||
|
<Modal
|
||||||
|
modelValue={confirming.value}
|
||||||
|
onUpdate:modelValue={value => (confirming.value = value)}
|
||||||
|
v-slots={{
|
||||||
|
header: () => <h2>Confirm Respec</h2>,
|
||||||
|
body: () => <>Are you sure? Respeccing these doesn't accomplish much</>,
|
||||||
|
footer: () => (
|
||||||
|
<div class="modal-default-footer">
|
||||||
|
<div class="modal-default-flex-grow"></div>
|
||||||
|
<button
|
||||||
|
class="button modal-default-button"
|
||||||
|
onClick={() => (confirming.value = false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="button modal-default-button danger"
|
||||||
|
onClick={() => {
|
||||||
|
respecBuyables.onClick();
|
||||||
|
confirming.value = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Respec
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
));
|
||||||
|
|
||||||
|
const longBoi = createBar(() => ({
|
||||||
|
fillStyle: { backgroundColor: "#FFFFFF" },
|
||||||
|
baseStyle: { backgroundColor: "#696969" },
|
||||||
|
textStyle: { color: "#04e050" },
|
||||||
|
direction: Direction.Right,
|
||||||
|
width: 300,
|
||||||
|
height: 30,
|
||||||
|
progress() {
|
||||||
|
return Decimal.add(main.points.value, 1).log(10).div(10).toNumber();
|
||||||
|
},
|
||||||
|
display() {
|
||||||
|
return format(main.points.value) + " / 1e10 points";
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const tallBoi = createBar(() => ({
|
||||||
|
fillStyle: { backgroundColor: "#4BEC13" },
|
||||||
|
baseStyle: { backgroundColor: "#000000" },
|
||||||
|
textStyle: { textShadow: "0px 0px 2px #000000" },
|
||||||
|
borderStyle: { borderWidth: "7px" },
|
||||||
|
direction: Direction.Up,
|
||||||
|
width: 50,
|
||||||
|
height: 200,
|
||||||
|
progress() {
|
||||||
|
return Decimal.div(main.points.value, 100);
|
||||||
|
},
|
||||||
|
display() {
|
||||||
|
return formatWhole(Decimal.div(main.points.value, 1).min(100)) + "%";
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const flatBoi = createBar(() => ({
|
||||||
|
fillStyle: { backgroundColor: "#FE0102" },
|
||||||
|
baseStyle: { backgroundColor: "#222222" },
|
||||||
|
textStyle: { textShadow: "0px 0px 2px #000000" },
|
||||||
|
direction: Direction.Up,
|
||||||
|
width: 100,
|
||||||
|
height: 30,
|
||||||
|
progress() {
|
||||||
|
return Decimal.div(points.value, 50);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const conversion = createCumulativeConversion(() => ({
|
||||||
|
scaling: addSoftcap(createExponentialScaling(10, 5, 0.5), 1e100, 0.5),
|
||||||
|
baseResource: main.points,
|
||||||
|
gainResource: points,
|
||||||
|
roundUpCost: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
const reset = createReset(() => ({
|
||||||
|
thingsToReset: (): Record<string, unknown>[] => [layer]
|
||||||
|
}));
|
||||||
|
|
||||||
|
const hotkeys = [
|
||||||
|
createHotkey(() => ({
|
||||||
|
key: "c",
|
||||||
|
description: "reset for lollipops or whatever",
|
||||||
|
onPress() {
|
||||||
|
if (resetButton.canClick.value) {
|
||||||
|
resetButton.onClick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
createHotkey(() => ({
|
||||||
|
key: "ctrl+c",
|
||||||
|
description: "respec things",
|
||||||
|
onPress() {
|
||||||
|
respecBuyables.onClick();
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
|
||||||
|
const treeNode = createLayerTreeNode(() => ({
|
||||||
|
layerID: id,
|
||||||
|
color,
|
||||||
|
reset,
|
||||||
|
mark: "https://unsoftcapped2.github.io/The-Modding-Tree-2/discord.png",
|
||||||
|
tooltip() {
|
||||||
|
let tooltip = displayResource(points);
|
||||||
|
if (Decimal.gt(exhancers.amount.value, 0)) {
|
||||||
|
tooltip += `<br><i><br><br><br>${formatWhole(
|
||||||
|
exhancers.amount.value
|
||||||
|
)} Exhancers</i>`;
|
||||||
|
}
|
||||||
|
return tooltip;
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
color: "#3325CC",
|
||||||
|
textDecoration: "underline"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const resetButton = createResetButton(() => ({
|
||||||
|
conversion,
|
||||||
|
tree: main.tree,
|
||||||
|
treeNode,
|
||||||
|
style: {
|
||||||
|
color: "#AA66AA"
|
||||||
|
},
|
||||||
|
resetDescription: "Melt your points into "
|
||||||
|
}));
|
||||||
|
|
||||||
|
const g = createTreeNode(() => ({
|
||||||
|
display: "TH",
|
||||||
|
color: "#6d3678",
|
||||||
|
canClick() {
|
||||||
|
return Decimal.gte(main.points.value, 10);
|
||||||
|
},
|
||||||
|
tooltip: "Thanos your points",
|
||||||
|
onClick() {
|
||||||
|
main.points.value = Decimal.div(main.points.value, 2);
|
||||||
|
console.log("Thanos'd");
|
||||||
|
},
|
||||||
|
glowColor() {
|
||||||
|
if (Decimal.eq(exhancers.amount.value, 1)) {
|
||||||
|
return "orange";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const h = createTreeNode(() => ({
|
||||||
|
display: "h",
|
||||||
|
color() {
|
||||||
|
return themes[settings.theme].variables["--locked"];
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
display: computed(() => `Restore your points to ${format(otherThingy.value)}`),
|
||||||
|
right: true
|
||||||
|
},
|
||||||
|
canClick() {
|
||||||
|
return Decimal.lt(main.points.value, otherThingy.value);
|
||||||
|
},
|
||||||
|
onClick() {
|
||||||
|
main.points.value = otherThingy.value;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const spook = createTreeNode(() => ({
|
||||||
|
visibility: Visibility.Hidden
|
||||||
|
}));
|
||||||
|
const tree = createTree(() => ({
|
||||||
|
nodes(): GenericTreeNode[][] {
|
||||||
|
return [
|
||||||
|
[f.treeNode, treeNode],
|
||||||
|
[g, spook, h]
|
||||||
|
];
|
||||||
|
},
|
||||||
|
branches(): TreeBranch[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
startNode: f.treeNode,
|
||||||
|
endNode: treeNode,
|
||||||
|
"stroke-width": "25px",
|
||||||
|
stroke: "green",
|
||||||
|
style: {
|
||||||
|
filter: "blur(5px)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ startNode: treeNode, endNode: g },
|
||||||
|
{ startNode: g, endNode: h }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const illuminatiTabs = createTabFamily(() => ({
|
||||||
|
tabs: {
|
||||||
|
first: {
|
||||||
|
tab: jsx(() => (
|
||||||
|
<>
|
||||||
|
{renderRow(...upgrades)}
|
||||||
|
{renderRow(quasiUpgrade)}
|
||||||
|
<div>confirmed</div>
|
||||||
|
</>
|
||||||
|
)),
|
||||||
|
display: "first"
|
||||||
|
},
|
||||||
|
second: {
|
||||||
|
tab: f.display,
|
||||||
|
display: "second"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
width: "660px",
|
||||||
|
backgroundColor: "brown",
|
||||||
|
"--background": "brown",
|
||||||
|
border: "solid white",
|
||||||
|
marginLeft: "auto",
|
||||||
|
marginRight: "auto"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const tabs = createTabFamily(() => ({
|
||||||
|
tabs: {
|
||||||
|
mainTab: {
|
||||||
|
tab: createTab(() => ({
|
||||||
|
display: jsx(() => (
|
||||||
|
<>
|
||||||
|
<MainDisplay
|
||||||
|
resource={points}
|
||||||
|
color={color}
|
||||||
|
effectDisplay={`which are boosting waffles by ${format(
|
||||||
|
waffleBoost.value
|
||||||
|
)} and increasing the Ice Cream cap by ${format(
|
||||||
|
icecreamCap.value
|
||||||
|
)}`}
|
||||||
|
/>
|
||||||
|
<Sticky>{render(resetButton)}</Sticky>
|
||||||
|
<Resource resource={points} color={color} />
|
||||||
|
<Spacer height="5px" />
|
||||||
|
<button onClick={() => console.log("yeet")}>'HI'</button>
|
||||||
|
<div>Name your points!</div>
|
||||||
|
<Text
|
||||||
|
modelValue={thingy.value}
|
||||||
|
onUpdate:modelValue={value => (thingy.value = value)}
|
||||||
|
/>
|
||||||
|
<Sticky style="color: red; font-size: 32px; font-family: Comic Sans MS;">
|
||||||
|
I have {displayResource(main.points)} {thingy.value} points!
|
||||||
|
</Sticky>
|
||||||
|
<hr />
|
||||||
|
{renderCol(...lollipopMilestones)}
|
||||||
|
<Spacer />
|
||||||
|
{renderRow(...upgrades)}
|
||||||
|
{renderRow(quasiUpgrade)}
|
||||||
|
{renderRow(funChallenge)}
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
})),
|
||||||
|
display: "main tab",
|
||||||
|
glowColor() {
|
||||||
|
if (
|
||||||
|
generatorUpgrade.canPurchase.value ||
|
||||||
|
lollipopMultiplierUpgrade.canPurchase.value ||
|
||||||
|
unlockIlluminatiUpgrade.canPurchase.value ||
|
||||||
|
funChallenge.canComplete.value
|
||||||
|
) {
|
||||||
|
return "blue";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
style: { color: "orange" }
|
||||||
|
},
|
||||||
|
thingies: {
|
||||||
|
tab: createTab(() => ({
|
||||||
|
style() {
|
||||||
|
return { backgroundColor: "#222222", "--background": "#222222" };
|
||||||
|
},
|
||||||
|
display: jsx(() => (
|
||||||
|
<>
|
||||||
|
{render(buyablesDisplay)}
|
||||||
|
<Spacer />
|
||||||
|
<Row style="width: 600px; height: 350px; background-color: green; border-style: solid;">
|
||||||
|
<Toggle
|
||||||
|
onUpdate:modelValue={value => (beep.value = value)}
|
||||||
|
modelValue={beep.value}
|
||||||
|
/>
|
||||||
|
<Spacer width="30px" height="10px" />
|
||||||
|
<div>
|
||||||
|
<span>Beep</span>
|
||||||
|
</div>
|
||||||
|
<Spacer />
|
||||||
|
<VerticalRule height="200px" />
|
||||||
|
</Row>
|
||||||
|
<Spacer />
|
||||||
|
<img src="https://unsoftcapped2.github.io/The-Modding-Tree-2/discord.png" />
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
})),
|
||||||
|
glowColor: "white",
|
||||||
|
display: "thingies",
|
||||||
|
style: { borderColor: "orange" }
|
||||||
|
},
|
||||||
|
jail: {
|
||||||
|
tab: createTab(() => ({
|
||||||
|
display: jsx(() => (
|
||||||
|
<>
|
||||||
|
{render(coolInfo)}
|
||||||
|
{render(longBoi)}
|
||||||
|
<Spacer />
|
||||||
|
<Row>
|
||||||
|
<Column style="background-color: #555555; padding: 15px">
|
||||||
|
<div style="color: teal">Sugar level:</div>
|
||||||
|
<Spacer />
|
||||||
|
{render(tallBoi)}
|
||||||
|
</Column>
|
||||||
|
<Spacer />
|
||||||
|
<Column>
|
||||||
|
<div>idk</div>
|
||||||
|
<Spacer width="0" height="50px" />
|
||||||
|
{render(flatBoi)}
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
<Spacer />
|
||||||
|
<div>It's jail because "bars"! So funny! Ha ha!</div>
|
||||||
|
{render(tree)}
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
})),
|
||||||
|
display: "jail"
|
||||||
|
},
|
||||||
|
illuminati: {
|
||||||
|
tab: createTab(() => ({
|
||||||
|
display: jsx(() => (
|
||||||
|
// This should really just be <> and </>, however for some reason the
|
||||||
|
// typescript interpreter can't figure out this layer and f.tsx otherwise
|
||||||
|
<div>
|
||||||
|
<h1> C O N F I R M E D </h1>
|
||||||
|
<Spacer />
|
||||||
|
{render(illuminatiTabs)}
|
||||||
|
<div>Adjust how many points H gives you!</div>
|
||||||
|
<Slider
|
||||||
|
onUpdate:modelValue={value => (otherThingy.value = value)}
|
||||||
|
modelValue={otherThingy.value}
|
||||||
|
min={1}
|
||||||
|
max={30}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)),
|
||||||
|
style: {
|
||||||
|
backgroundColor: "#3325CC"
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
visibility() {
|
||||||
|
return showIf(unlockIlluminatiUpgrade.bought.value);
|
||||||
|
},
|
||||||
|
display: "illuminati"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
color,
|
||||||
|
name,
|
||||||
|
links() {
|
||||||
|
const links = tree.links.value.slice();
|
||||||
|
links.push({
|
||||||
|
startNode: h,
|
||||||
|
endNode: flatBoi,
|
||||||
|
"stroke-width": "5px",
|
||||||
|
stroke: "red",
|
||||||
|
offsetEnd: { x: -50 + 100 * flatBoi.progress.value.toNumber(), y: 0 }
|
||||||
|
});
|
||||||
|
return links;
|
||||||
|
},
|
||||||
|
points,
|
||||||
|
best,
|
||||||
|
beep,
|
||||||
|
thingy,
|
||||||
|
otherThingy,
|
||||||
|
spentOnBuyables,
|
||||||
|
waffleBoost,
|
||||||
|
icecreamCap,
|
||||||
|
coolInfo,
|
||||||
|
lollipopMilestones,
|
||||||
|
funChallenge,
|
||||||
|
funEffect,
|
||||||
|
generatorUpgrade,
|
||||||
|
lollipopMultiplierUpgrade,
|
||||||
|
lollipopMultiplierEffect,
|
||||||
|
unlockIlluminatiUpgrade,
|
||||||
|
quasiUpgrade,
|
||||||
|
exhancers,
|
||||||
|
respecBuyables,
|
||||||
|
sellExhancer,
|
||||||
|
bars: { tallBoi, longBoi, flatBoi },
|
||||||
|
tree,
|
||||||
|
g,
|
||||||
|
h,
|
||||||
|
spook,
|
||||||
|
conversion,
|
||||||
|
reset,
|
||||||
|
hotkeys,
|
||||||
|
treeNode,
|
||||||
|
resetButton,
|
||||||
|
confirmRespec,
|
||||||
|
minWidth: 800,
|
||||||
|
tabs,
|
||||||
|
display: jsx(() => <>{render(tabs)}</>)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export default layer;
|
179
src/data/layers/aca/f.tsx
Normal file
|
@ -0,0 +1,179 @@
|
||||||
|
import { createLayerTreeNode, createResetButton } from "@/data/common";
|
||||||
|
import { main } from "@/data/mod";
|
||||||
|
import { createClickable } from "@/features/clickables/clickable";
|
||||||
|
import { createExponentialScaling, createIndependentConversion } from "@/features/conversion";
|
||||||
|
import { jsx } from "@/features/feature";
|
||||||
|
import { createInfobox } from "@/features/infoboxes/infobox";
|
||||||
|
import { createReset } from "@/features/reset";
|
||||||
|
import MainDisplay from "@/features/resources/MainDisplay.vue";
|
||||||
|
import { createResource, displayResource } from "@/features/resources/resource";
|
||||||
|
import { createLayer } from "@/game/layers";
|
||||||
|
import { persistent } from "@/game/persistence";
|
||||||
|
import Decimal, { DecimalSource, formatWhole } from "@/util/bignum";
|
||||||
|
import { render } from "@/util/vue";
|
||||||
|
import c from "./c";
|
||||||
|
|
||||||
|
const layer = createLayer(() => {
|
||||||
|
const id = "f";
|
||||||
|
const color = "#FE0102";
|
||||||
|
const name = "Farms";
|
||||||
|
const points = createResource<DecimalSource>(0, "farm points");
|
||||||
|
const boop = persistent<boolean>(false);
|
||||||
|
|
||||||
|
const coolInfo = createInfobox(() => ({
|
||||||
|
title: "Lore",
|
||||||
|
titleStyle: { color: "#FE0000" },
|
||||||
|
display: "DEEP LORE!",
|
||||||
|
bodyStyle: { backgroundColor: "#0000EE" }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const clickableState = persistent<string>("Start");
|
||||||
|
const clickable = createClickable(() => ({
|
||||||
|
display() {
|
||||||
|
return {
|
||||||
|
title: "Clicky clicky!",
|
||||||
|
description: "Current state:<br>" + clickableState.value
|
||||||
|
};
|
||||||
|
},
|
||||||
|
initialState: "Start",
|
||||||
|
canClick() {
|
||||||
|
return clickableState.value !== "Borkened...";
|
||||||
|
},
|
||||||
|
onClick() {
|
||||||
|
switch (clickableState.value) {
|
||||||
|
case "Start":
|
||||||
|
clickableState.value = "A new state!";
|
||||||
|
break;
|
||||||
|
case "A new state!":
|
||||||
|
clickableState.value = "Keep going!";
|
||||||
|
break;
|
||||||
|
case "Keep going!":
|
||||||
|
clickableState.value = "Maybe that's a bit too far...";
|
||||||
|
break;
|
||||||
|
case "Maybe that's a bit too far...":
|
||||||
|
//makeParticles(coolParticle, 4)
|
||||||
|
clickableState.value = "Borkened...";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
clickableState.value = "Start";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onHold() {
|
||||||
|
console.log("Clickkkkk...");
|
||||||
|
},
|
||||||
|
style() {
|
||||||
|
switch (clickableState.value) {
|
||||||
|
case "Start":
|
||||||
|
return { "background-color": "green" };
|
||||||
|
case "A new state!":
|
||||||
|
return { "background-color": "yellow" };
|
||||||
|
case "Keep going!":
|
||||||
|
return { "background-color": "orange" };
|
||||||
|
case "Maybe that's a bit too far...":
|
||||||
|
return { "background-color": "red" };
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const resetClickable = createClickable(() => ({
|
||||||
|
onClick() {
|
||||||
|
if (clickableState.value == "Borkened...") {
|
||||||
|
clickableState.value = "Start";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
display() {
|
||||||
|
return clickableState.value == "Borkened..." ? "Fix the clickable!" : "Does nothing";
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const reset = createReset(() => ({
|
||||||
|
thingsToReset: (): Record<string, unknown>[] => [layer]
|
||||||
|
}));
|
||||||
|
|
||||||
|
const conversion = createIndependentConversion(() => ({
|
||||||
|
scaling: createExponentialScaling(10, 3, 0.5),
|
||||||
|
baseResource: main.points,
|
||||||
|
gainResource: points,
|
||||||
|
modifyGainAmount: gain => Decimal.times(gain, c.otherThingy.value)
|
||||||
|
}));
|
||||||
|
|
||||||
|
const treeNode = createLayerTreeNode(() => ({
|
||||||
|
layerID: id,
|
||||||
|
color,
|
||||||
|
reset,
|
||||||
|
tooltip() {
|
||||||
|
if (treeNode.canClick.value) {
|
||||||
|
return `${displayResource(points)} ${points.displayName}`;
|
||||||
|
}
|
||||||
|
return `This weird farmer dinosaur will only see you if you have at least 10 points. You only have ${displayResource(
|
||||||
|
main.points
|
||||||
|
)}`;
|
||||||
|
},
|
||||||
|
canClick() {
|
||||||
|
return Decimal.gte(main.points.value, 10);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const resetButton = createResetButton(() => ({
|
||||||
|
conversion,
|
||||||
|
tree: main.tree,
|
||||||
|
treeNode,
|
||||||
|
display: jsx(() => {
|
||||||
|
if (resetButton.conversion.buyMax) {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
Hi! I'm a <u>weird dinosaur</u> and I'll give you{" "}
|
||||||
|
<b>{formatWhole(resetButton.conversion.currentGain.value)}</b> Farm Points
|
||||||
|
in exchange for all of your points and lollipops! (You'll get another one at{" "}
|
||||||
|
{formatWhole(resetButton.conversion.nextAt.value)} points)
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
Hi! I'm a <u>weird dinosaur</u> and I'll give you a Farm Point in exchange
|
||||||
|
for all of your points and lollipops! (At least{" "}
|
||||||
|
{formatWhole(resetButton.conversion.nextAt.value)} points)
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
const tab = jsx(() => (
|
||||||
|
<>
|
||||||
|
{render(coolInfo)}
|
||||||
|
<MainDisplay resource={points} color={color} />
|
||||||
|
{render(resetButton)}
|
||||||
|
<div>You have {formatWhole(conversion.baseResource.value)} points</div>
|
||||||
|
<div>
|
||||||
|
<br />
|
||||||
|
<img src="https://images.beano.com/store/24ab3094eb95e5373bca1ccd6f330d4406db8d1f517fc4170b32e146f80d?auto=compress%2Cformat&dpr=1&w=390" />
|
||||||
|
<div>Bork Bork!</div>
|
||||||
|
</div>
|
||||||
|
{render(clickable)}
|
||||||
|
</>
|
||||||
|
));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
color,
|
||||||
|
name,
|
||||||
|
points,
|
||||||
|
boop,
|
||||||
|
coolInfo,
|
||||||
|
clickable,
|
||||||
|
clickableState,
|
||||||
|
resetClickable,
|
||||||
|
reset,
|
||||||
|
conversion,
|
||||||
|
treeNode,
|
||||||
|
resetButton,
|
||||||
|
display: tab
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export default layer;
|
354
src/data/layers/demo-infinity.ts
Normal file
|
@ -0,0 +1,354 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
import { layers } from "@/game/layers";
|
||||||
|
import player from "@/game/player";
|
||||||
|
import { Layer, RawLayer } from "@/typings/layer";
|
||||||
|
import Decimal, { format } from "@/util/bignum";
|
||||||
|
import {
|
||||||
|
getBuyableAmount, hasChallenge, hasMilestone, hasUpgrade, setBuyableAmount
|
||||||
|
} from "@/util/features";
|
||||||
|
import { resetLayer } from "@/util/layers";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "i",
|
||||||
|
position: 2, // Horizontal position within a row. By default it uses the layer id and sorts in alphabetical order
|
||||||
|
startData() {
|
||||||
|
return {
|
||||||
|
unlocked: false,
|
||||||
|
points: new Decimal(0)
|
||||||
|
};
|
||||||
|
},
|
||||||
|
branches: ["p"],
|
||||||
|
color: "#964B00",
|
||||||
|
requires() {
|
||||||
|
const require = new Decimal(8).plus(
|
||||||
|
player.layers.i.points
|
||||||
|
.div(10)
|
||||||
|
.floor()
|
||||||
|
.times(2)
|
||||||
|
);
|
||||||
|
return require;
|
||||||
|
}, // Can be a function that takes requirement increases into account
|
||||||
|
effectDisplay() {
|
||||||
|
return (
|
||||||
|
"Multiplying points and prestige points by " +
|
||||||
|
format(
|
||||||
|
player.layers[this.layer].points
|
||||||
|
.plus(1)
|
||||||
|
.pow(hasUpgrade("p", 235) ? 6.942 : 1)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
resource: "Infinity", // Name of prestige currency
|
||||||
|
baseResource: "pointy points", // Name of resource prestige is based on
|
||||||
|
baseAmount() {
|
||||||
|
return player.layers.p.buyables![21];
|
||||||
|
}, // Get the current amount of baseResource
|
||||||
|
type: "custom", // normal: cost to gain currency depends on amount gained. static: cost depends on how much you already have
|
||||||
|
resetGain() {
|
||||||
|
if (hasMilestone("p", 12)) {
|
||||||
|
return getBuyableAmount("p", 21)!
|
||||||
|
.div(2)
|
||||||
|
.floor()
|
||||||
|
.times(2)
|
||||||
|
.times(5)
|
||||||
|
.sub(30)
|
||||||
|
.sub(player.layers.i.points);
|
||||||
|
}
|
||||||
|
return player.layers.p.buyables![21].gte(layers.i.requires!) ? 1 : 0;
|
||||||
|
}, // Prestige currency exponent
|
||||||
|
getNextAt() {
|
||||||
|
return new Decimal(100);
|
||||||
|
},
|
||||||
|
canReset() {
|
||||||
|
return player.layers.p.buyables![21].gte(layers.i.requires!);
|
||||||
|
},
|
||||||
|
prestigeButtonDisplay() {
|
||||||
|
return (
|
||||||
|
"Reset everything for +" +
|
||||||
|
format(layers.i.resetGain) +
|
||||||
|
" Infinity.<br>You need " +
|
||||||
|
format(layers.i.requires!) +
|
||||||
|
" pointy points to reset."
|
||||||
|
);
|
||||||
|
},
|
||||||
|
row: 1, // Row the layer is in on the tree (0 is the first row)
|
||||||
|
hotkeys: [
|
||||||
|
{
|
||||||
|
key: "i",
|
||||||
|
description: "I: Infinity",
|
||||||
|
press() {
|
||||||
|
if (layers.i.canReset) resetLayer(this.layer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
layerShown() {
|
||||||
|
return (
|
||||||
|
player.layers[this.layer].unlocked ||
|
||||||
|
new Decimal(player.layers.p.buyables[21]).gte(8)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
milestones: {
|
||||||
|
data: {
|
||||||
|
0: {
|
||||||
|
requirementDisplay: "2 Infinity points",
|
||||||
|
effectDisplay: "Keep ALL milestones on reset",
|
||||||
|
done() {
|
||||||
|
return player.layers[this.layer].points.gte(2);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
1: {
|
||||||
|
requirementDisplay: "3 Infinity points",
|
||||||
|
effectDisplay: "Pointy points don't reset generators",
|
||||||
|
done() {
|
||||||
|
return player.layers[this.layer].points.gte(3);
|
||||||
|
},
|
||||||
|
unlocked() {
|
||||||
|
return hasMilestone(this.layer, Number(this.id) - 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
requirementDisplay: "4 Infinity points",
|
||||||
|
effectDisplay:
|
||||||
|
"Start with 6 <b>Time Dilation</b>, 3 <b>Point</b>, and 1 of the other 2 challenges",
|
||||||
|
done() {
|
||||||
|
return player.layers[this.layer].points.gte(4);
|
||||||
|
},
|
||||||
|
unlocked() {
|
||||||
|
return hasMilestone(this.layer, Number(this.id) - 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
requirementDisplay: "5 Infinity points",
|
||||||
|
effectDisplay: "Start with 40 upgrades and 6 boosts",
|
||||||
|
done() {
|
||||||
|
return player.layers[this.layer].points.gte(5);
|
||||||
|
},
|
||||||
|
unlocked() {
|
||||||
|
return hasMilestone(this.layer, Number(this.id) - 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
requirementDisplay: "6 Infinity points",
|
||||||
|
effectDisplay:
|
||||||
|
"You can choose all of the 14th row upgrades, and remove the respec button",
|
||||||
|
done() {
|
||||||
|
return player.layers[this.layer].points.gte(6);
|
||||||
|
},
|
||||||
|
unlocked() {
|
||||||
|
return hasMilestone(this.layer, Number(this.id) - 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
requirementDisplay: "8 Infinity points",
|
||||||
|
effectDisplay: "Keep all upgrades and 7 Time dilation",
|
||||||
|
done() {
|
||||||
|
return player.layers[this.layer].points.gte(8);
|
||||||
|
},
|
||||||
|
unlocked() {
|
||||||
|
return hasMilestone(this.layer, Number(this.id) - 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
6: {
|
||||||
|
requirementDisplay: "10 Infinity points",
|
||||||
|
effectDisplay: "Infinity reset nothing and auto prestige",
|
||||||
|
done() {
|
||||||
|
return player.layers[this.layer].points.gte(10);
|
||||||
|
},
|
||||||
|
unlocked() {
|
||||||
|
return hasMilestone(this.layer, Number(this.id) - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resetsNothing() {
|
||||||
|
return hasMilestone(this.layer, 6);
|
||||||
|
},
|
||||||
|
update(this: Layer) {
|
||||||
|
if (hasMilestone(this.layer, 0)) {
|
||||||
|
if (!hasMilestone("p", 0)) {
|
||||||
|
player.layers.p.milestones!.push(0);
|
||||||
|
player.layers.p.milestones!.push(1);
|
||||||
|
player.layers.p.milestones!.push(2);
|
||||||
|
player.layers.p.milestones!.push(3);
|
||||||
|
player.layers.p.milestones!.push(4);
|
||||||
|
player.layers.p.milestones!.push(5);
|
||||||
|
player.layers.p.milestones!.push(6);
|
||||||
|
player.layers.p.milestones!.push(7);
|
||||||
|
player.layers.p.milestones!.push(8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasMilestone(this.layer, 2)) {
|
||||||
|
if (!hasChallenge("p", 11)) {
|
||||||
|
player.layers.p.challenges![11] = new Decimal(
|
||||||
|
hasMilestone(this.layer, 5) ? 7 : 6
|
||||||
|
);
|
||||||
|
player.layers.p.challenges![12] = new Decimal(3);
|
||||||
|
player.layers.p.challenges![21] = new Decimal(1);
|
||||||
|
player.layers.p.challenges![22] = new Decimal(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasMilestone(this.layer, 3)) {
|
||||||
|
if (!hasUpgrade("p", 71)) {
|
||||||
|
player.layers.p.upgrades = [
|
||||||
|
11,
|
||||||
|
12,
|
||||||
|
13,
|
||||||
|
14,
|
||||||
|
21,
|
||||||
|
22,
|
||||||
|
23,
|
||||||
|
24,
|
||||||
|
31,
|
||||||
|
32,
|
||||||
|
33,
|
||||||
|
34,
|
||||||
|
41,
|
||||||
|
42,
|
||||||
|
43,
|
||||||
|
44,
|
||||||
|
51,
|
||||||
|
52,
|
||||||
|
53,
|
||||||
|
54,
|
||||||
|
61,
|
||||||
|
62,
|
||||||
|
63,
|
||||||
|
64,
|
||||||
|
71,
|
||||||
|
72,
|
||||||
|
73,
|
||||||
|
74,
|
||||||
|
81,
|
||||||
|
82,
|
||||||
|
83,
|
||||||
|
84,
|
||||||
|
91,
|
||||||
|
92,
|
||||||
|
93,
|
||||||
|
94,
|
||||||
|
101,
|
||||||
|
102,
|
||||||
|
103,
|
||||||
|
104
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (getBuyableAmount("p", 11)!.lt(6)) {
|
||||||
|
setBuyableAmount("p", 11, new Decimal(6));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasUpgrade(this.layer, 13)) {
|
||||||
|
for (
|
||||||
|
let i = 0;
|
||||||
|
i < (hasUpgrade("p", 222) ? 100 : hasUpgrade("p", 215) ? 10 : 1);
|
||||||
|
i++
|
||||||
|
) {
|
||||||
|
if (layers.p.buyables!.data[12].canAfford) layers.p.buyables!.data[12].buy();
|
||||||
|
if (layers.p.buyables!.data[13].canAfford) layers.p.buyables!.data[13].buy();
|
||||||
|
if (
|
||||||
|
layers.p.buyables!.data[14].canAfford &&
|
||||||
|
layers.p.buyables!.data[14].unlocked
|
||||||
|
)
|
||||||
|
layers.p.buyables!.data[14].buy();
|
||||||
|
if (layers.p.buyables!.data[21].canAfford) layers.p.buyables!.data[21].buy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasUpgrade("p", 223)) {
|
||||||
|
if (hasMilestone("p", 14))
|
||||||
|
player.layers.p.buyables![22] = player.layers.p.buyables![22].max(
|
||||||
|
player.layers.p.buyables![21].sub(7)
|
||||||
|
);
|
||||||
|
else if (layers.p.buyables!.data[22].canAfford) layers.p.buyables!.data[22].buy();
|
||||||
|
}
|
||||||
|
if (hasMilestone(this.layer, 5) && !hasUpgrade("p", 111)) {
|
||||||
|
player.layers.p.upgrades = [
|
||||||
|
11,
|
||||||
|
12,
|
||||||
|
13,
|
||||||
|
14,
|
||||||
|
21,
|
||||||
|
22,
|
||||||
|
23,
|
||||||
|
24,
|
||||||
|
31,
|
||||||
|
32,
|
||||||
|
33,
|
||||||
|
34,
|
||||||
|
41,
|
||||||
|
42,
|
||||||
|
43,
|
||||||
|
44,
|
||||||
|
51,
|
||||||
|
52,
|
||||||
|
53,
|
||||||
|
54,
|
||||||
|
61,
|
||||||
|
62,
|
||||||
|
63,
|
||||||
|
64,
|
||||||
|
71,
|
||||||
|
72,
|
||||||
|
73,
|
||||||
|
74,
|
||||||
|
81,
|
||||||
|
82,
|
||||||
|
83,
|
||||||
|
84,
|
||||||
|
91,
|
||||||
|
92,
|
||||||
|
93,
|
||||||
|
94,
|
||||||
|
101,
|
||||||
|
102,
|
||||||
|
103,
|
||||||
|
104,
|
||||||
|
111,
|
||||||
|
121,
|
||||||
|
122,
|
||||||
|
131,
|
||||||
|
132,
|
||||||
|
141,
|
||||||
|
142,
|
||||||
|
143
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (hasMilestone(this.layer, 6)) {
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
upgrades: {
|
||||||
|
rows: 999,
|
||||||
|
cols: 5,
|
||||||
|
data: {
|
||||||
|
11: {
|
||||||
|
title: "Prestige",
|
||||||
|
description: "Gain 100% of prestige points per second",
|
||||||
|
cost() {
|
||||||
|
return new Decimal(1);
|
||||||
|
},
|
||||||
|
unlocked() {
|
||||||
|
return hasMilestone(this.layer, 4);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
12: {
|
||||||
|
title: "Automation",
|
||||||
|
description: "Remove the nerf of upgrade <b>Active</b>",
|
||||||
|
cost() {
|
||||||
|
return new Decimal(2);
|
||||||
|
},
|
||||||
|
unlocked() {
|
||||||
|
return hasUpgrade(this.layer, 11);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
13: {
|
||||||
|
title: "Pointy",
|
||||||
|
description: "Automatically buy generators and pointy points",
|
||||||
|
cost() {
|
||||||
|
return new Decimal(5);
|
||||||
|
},
|
||||||
|
unlocked() {
|
||||||
|
return hasUpgrade(this.layer, 11);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as RawLayer;
|
2298
src/data/layers/demo.ts
Normal file
112
src/data/mod.tsx
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
import Profectus from "@/components/Profectus.vue";
|
||||||
|
import Spacer from "@/components/layout/Spacer.vue";
|
||||||
|
import { jsx } from "@/features/feature";
|
||||||
|
import { createResource, trackBest, trackOOMPS, trackTotal } from "@/features/resources/resource";
|
||||||
|
import { branchedResetPropagation, createTree, GenericTree } from "@/features/trees/tree";
|
||||||
|
import { globalBus } from "@/game/events";
|
||||||
|
import { createLayer, GenericLayer, setupLayerModal } from "@/game/layers";
|
||||||
|
import player, { PlayerData } from "@/game/player";
|
||||||
|
import { DecimalSource } from "@/lib/break_eternity";
|
||||||
|
import Decimal, { format, formatTime } from "@/util/bignum";
|
||||||
|
import { render } from "@/util/vue";
|
||||||
|
import { computed, toRaw } from "vue";
|
||||||
|
import a from "./layers/aca/a";
|
||||||
|
import c from "./layers/aca/c";
|
||||||
|
import f from "./layers/aca/f";
|
||||||
|
|
||||||
|
export const main = createLayer(() => {
|
||||||
|
const points = createResource<DecimalSource>(10);
|
||||||
|
const best = trackBest(points);
|
||||||
|
const total = trackTotal(points);
|
||||||
|
|
||||||
|
const pointGain = computed(() => {
|
||||||
|
if (!c.generatorUpgrade.bought.value) return new Decimal(0);
|
||||||
|
let gain = new Decimal(3.19);
|
||||||
|
if (c.lollipopMultiplierUpgrade.bought.value)
|
||||||
|
gain = gain.times(c.lollipopMultiplierEffect.value);
|
||||||
|
return gain;
|
||||||
|
});
|
||||||
|
globalBus.on("update", diff => {
|
||||||
|
points.value = Decimal.add(points.value, Decimal.times(pointGain.value, diff));
|
||||||
|
});
|
||||||
|
const oomps = trackOOMPS(points, pointGain);
|
||||||
|
|
||||||
|
const { openModal, modal } = setupLayerModal(a);
|
||||||
|
|
||||||
|
// Note: Casting as generic tree to avoid recursive type definitions
|
||||||
|
const tree = createTree(() => ({
|
||||||
|
nodes: [[c.treeNode], [f.treeNode, c.spook]],
|
||||||
|
leftSideNodes: [a.treeNode, c.h],
|
||||||
|
branches: [
|
||||||
|
{
|
||||||
|
startNode: f.treeNode,
|
||||||
|
endNode: c.treeNode,
|
||||||
|
stroke: "blue",
|
||||||
|
"stroke-width": "25px",
|
||||||
|
style: {
|
||||||
|
filter: "blur(5px)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
onReset() {
|
||||||
|
points.value = toRaw(this.resettingNode.value) === toRaw(c.treeNode) ? 0 : 10;
|
||||||
|
best.value = points.value;
|
||||||
|
total.value = points.value;
|
||||||
|
},
|
||||||
|
resetPropagation: branchedResetPropagation
|
||||||
|
})) as GenericTree;
|
||||||
|
|
||||||
|
// Note: layers don't _need_ a reference to everything,
|
||||||
|
// but I'd recommend it over trying to remember what does and doesn't need to be included.
|
||||||
|
// Officially all you need are anything with persistency or that you want to access elsewhere
|
||||||
|
return {
|
||||||
|
id: "main",
|
||||||
|
name: "Tree",
|
||||||
|
links: tree.links,
|
||||||
|
display: jsx(() => (
|
||||||
|
<>
|
||||||
|
<div v-show={player.devSpeed === 0}>Game Paused</div>
|
||||||
|
<div v-show={player.devSpeed && player.devSpeed !== 1}>
|
||||||
|
Dev Speed: {format(player.devSpeed || 0)}x
|
||||||
|
</div>
|
||||||
|
<div v-show={player.offlineTime != undefined}>
|
||||||
|
Offline Time: {formatTime(player.offlineTime || 0)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span v-show={Decimal.lt(points.value, "1e1000")}>You have </span>
|
||||||
|
<h2>{format(points.value)}</h2>
|
||||||
|
<span v-show={Decimal.lt(points.value, "1e1e6")}> points</span>
|
||||||
|
</div>
|
||||||
|
<div v-show={Decimal.gt(pointGain.value, 0)}>({oomps.value})</div>
|
||||||
|
<Spacer />
|
||||||
|
<button onClick={openModal}>open achievements</button>
|
||||||
|
{render(modal)}
|
||||||
|
{render(tree)}
|
||||||
|
<Profectus height="200px" style="margin: 10px auto; display: block" />
|
||||||
|
</>
|
||||||
|
)),
|
||||||
|
points,
|
||||||
|
best,
|
||||||
|
total,
|
||||||
|
oomps,
|
||||||
|
tree,
|
||||||
|
showAchievements: openModal
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getInitialLayers = (
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
|
||||||
|
player: Partial<PlayerData>
|
||||||
|
): Array<GenericLayer> => [main, f, c, a];
|
||||||
|
|
||||||
|
export const hasWon = computed(() => {
|
||||||
|
return Decimal.gt(main.points.value, 25);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
export function fixOldSave(
|
||||||
|
oldVersion: string | undefined,
|
||||||
|
player: Partial<PlayerData>
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
): void {}
|
||||||
|
/* eslint-enable @typescript-eslint/no-unused-vars */
|
21
src/data/modInfo.json
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"title": "Profectus",
|
||||||
|
"id": "profectus-demo",
|
||||||
|
"author": "thepaperpilot",
|
||||||
|
"discordName": "The Paper Pilot Community",
|
||||||
|
"discordLink": "https://discord.gg/WzejVAx",
|
||||||
|
|
||||||
|
"versionNumber": "0.0",
|
||||||
|
"versionTitle": "Initial Commit",
|
||||||
|
|
||||||
|
"allowGoBack": true,
|
||||||
|
"allowSmall": false,
|
||||||
|
"defaultDecimalsShown": 2,
|
||||||
|
"useHeader": true,
|
||||||
|
"banner": null,
|
||||||
|
"logo": "Logo.png",
|
||||||
|
"initialTabs": [ "main", "c" ],
|
||||||
|
|
||||||
|
"maxTickLength": 3600,
|
||||||
|
"offlineLimit": 1
|
||||||
|
}
|
128
src/data/themes.ts
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Theme {
|
||||||
|
variables: ThemeVars;
|
||||||
|
stackedInfoboxes: boolean;
|
||||||
|
floatingTabs: boolean;
|
||||||
|
showSingleTab: boolean;
|
||||||
|
mergeAdjacent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@vue/runtime-dom" {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||||
|
interface CSSProperties extends Partial<ThemeVars> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultTheme: Theme = {
|
||||||
|
variables: {
|
||||||
|
"--foreground": "#dfdfdf",
|
||||||
|
"--background": "#0f0f0f",
|
||||||
|
"--feature-foreground": "#eee",
|
||||||
|
"--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"
|
||||||
|
},
|
||||||
|
stackedInfoboxes: false,
|
||||||
|
floatingTabs: true,
|
||||||
|
showSingleTab: false,
|
||||||
|
mergeAdjacent: true
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum Themes {
|
||||||
|
Classic = "classic",
|
||||||
|
Paper = "paper",
|
||||||
|
Nordic = "nordic",
|
||||||
|
Aqua = "aqua"
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
},
|
||||||
|
stackedInfoboxes: true,
|
||||||
|
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"
|
||||||
|
},
|
||||||
|
stackedInfoboxes: true,
|
||||||
|
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>;
|
79
src/features/achievements/Achievement.vue
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="unref(visibility) !== Visibility.None"
|
||||||
|
:style="[
|
||||||
|
{
|
||||||
|
visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined,
|
||||||
|
backgroundImage: (earned && image && `url(${image})`) || ''
|
||||||
|
},
|
||||||
|
unref(style) ?? []
|
||||||
|
]"
|
||||||
|
:class="{
|
||||||
|
feature: true,
|
||||||
|
achievement: true,
|
||||||
|
locked: !unref(earned),
|
||||||
|
bought: unref(earned),
|
||||||
|
...unref(classes)
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<component v-if="component" :is="component" />
|
||||||
|
<MarkNode :mark="unref(mark)" />
|
||||||
|
<LinkNode :id="id" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { CoercableComponent, Visibility } from "@/features/feature";
|
||||||
|
import { computeOptionalComponent, processedPropType } from "@/util/vue";
|
||||||
|
import { defineComponent, StyleValue, toRefs, unref } from "vue";
|
||||||
|
import Tooltip from "@/components/Tooltip.vue";
|
||||||
|
import LinkNode from "@/components/links/LinkNode.vue";
|
||||||
|
import MarkNode from "@/components/MarkNode.vue";
|
||||||
|
import "@/components/common/features.css";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
visibility: {
|
||||||
|
type: processedPropType<Visibility>(Number),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
display: processedPropType<CoercableComponent>(Object, String, Function),
|
||||||
|
earned: {
|
||||||
|
type: processedPropType<boolean>(Boolean),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
image: processedPropType<string>(String),
|
||||||
|
style: processedPropType<StyleValue>(String, Object, Array),
|
||||||
|
classes: processedPropType<Record<string, boolean>>(Object),
|
||||||
|
mark: processedPropType<boolean | string>(Boolean, String),
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
LinkNode,
|
||||||
|
MarkNode,
|
||||||
|
Tooltip
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const { display } = toRefs(props);
|
||||||
|
|
||||||
|
return {
|
||||||
|
component: computeOptionalComponent(display),
|
||||||
|
unref,
|
||||||
|
Visibility
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.achievement {
|
||||||
|
height: 90px;
|
||||||
|
width: 90px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: white;
|
||||||
|
text-shadow: 0 0 2px #000000;
|
||||||
|
}
|
||||||
|
</style>
|
143
src/features/achievements/achievement.tsx
Normal file
|
@ -0,0 +1,143 @@
|
||||||
|
import AchievementComponent from "@/features/achievements/Achievement.vue";
|
||||||
|
import {
|
||||||
|
CoercableComponent,
|
||||||
|
Component,
|
||||||
|
findFeatures,
|
||||||
|
GatherProps,
|
||||||
|
getUniqueID,
|
||||||
|
Replace,
|
||||||
|
setDefault,
|
||||||
|
StyleValue,
|
||||||
|
Visibility
|
||||||
|
} from "@/features/feature";
|
||||||
|
import { globalBus } from "@/game/events";
|
||||||
|
import "@/game/notifications";
|
||||||
|
import { Persistent, makePersistent, PersistentState } from "@/game/persistence";
|
||||||
|
import {
|
||||||
|
Computable,
|
||||||
|
GetComputableType,
|
||||||
|
GetComputableTypeWithDefault,
|
||||||
|
processComputable,
|
||||||
|
ProcessedComputable
|
||||||
|
} from "@/util/computed";
|
||||||
|
import { createLazyProxy } from "@/util/proxies";
|
||||||
|
import { coerceComponent } from "@/util/vue";
|
||||||
|
import { Unsubscribe } from "nanoevents";
|
||||||
|
import { Ref, unref } from "vue";
|
||||||
|
import { useToast } from "vue-toastification";
|
||||||
|
|
||||||
|
export const AchievementType = Symbol("Achievement");
|
||||||
|
|
||||||
|
export interface AchievementOptions {
|
||||||
|
visibility?: Computable<Visibility>;
|
||||||
|
shouldEarn?: Computable<boolean>;
|
||||||
|
display?: Computable<CoercableComponent>;
|
||||||
|
mark?: Computable<boolean | string>;
|
||||||
|
image?: Computable<string>;
|
||||||
|
style?: Computable<StyleValue>;
|
||||||
|
classes?: Computable<Record<string, boolean>>;
|
||||||
|
onComplete?: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseAchievement extends Persistent<boolean> {
|
||||||
|
id: string;
|
||||||
|
earned: Ref<boolean>;
|
||||||
|
complete: VoidFunction;
|
||||||
|
type: typeof AchievementType;
|
||||||
|
[Component]: typeof AchievementComponent;
|
||||||
|
[GatherProps]: () => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Achievement<T extends AchievementOptions> = Replace<
|
||||||
|
T & BaseAchievement,
|
||||||
|
{
|
||||||
|
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||||
|
shouldEarn: GetComputableType<T["shouldEarn"]>;
|
||||||
|
display: GetComputableType<T["display"]>;
|
||||||
|
mark: GetComputableType<T["mark"]>;
|
||||||
|
image: GetComputableType<T["image"]>;
|
||||||
|
style: GetComputableType<T["style"]>;
|
||||||
|
classes: GetComputableType<T["classes"]>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericAchievement = Replace<
|
||||||
|
Achievement<AchievementOptions>,
|
||||||
|
{
|
||||||
|
visibility: ProcessedComputable<Visibility>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createAchievement<T extends AchievementOptions>(
|
||||||
|
optionsFunc: () => T & ThisType<Achievement<T>>
|
||||||
|
): Achievement<T> {
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const achievement: T & Partial<BaseAchievement> = optionsFunc();
|
||||||
|
makePersistent<boolean>(achievement, false);
|
||||||
|
achievement.id = getUniqueID("achievement-");
|
||||||
|
achievement.type = AchievementType;
|
||||||
|
achievement[Component] = AchievementComponent;
|
||||||
|
|
||||||
|
achievement.earned = achievement[PersistentState];
|
||||||
|
achievement.complete = function () {
|
||||||
|
achievement[PersistentState].value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
processComputable(achievement as T, "visibility");
|
||||||
|
setDefault(achievement, "visibility", Visibility.Visible);
|
||||||
|
processComputable(achievement as T, "shouldEarn");
|
||||||
|
processComputable(achievement as T, "display");
|
||||||
|
processComputable(achievement as T, "mark");
|
||||||
|
processComputable(achievement as T, "image");
|
||||||
|
processComputable(achievement as T, "style");
|
||||||
|
processComputable(achievement as T, "classes");
|
||||||
|
|
||||||
|
achievement[GatherProps] = function (this: GenericAchievement) {
|
||||||
|
const { visibility, display, earned, image, style, classes, mark, id } = this;
|
||||||
|
return { visibility, display, earned, image, style, classes, mark, id };
|
||||||
|
};
|
||||||
|
|
||||||
|
return achievement as unknown as Achievement<T>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const listeners: Record<string, Unsubscribe | undefined> = {};
|
||||||
|
globalBus.on("addLayer", layer => {
|
||||||
|
const achievements: GenericAchievement[] = (
|
||||||
|
findFeatures(layer, AchievementType) as GenericAchievement[]
|
||||||
|
).filter(ach => ach.shouldEarn != null);
|
||||||
|
if (achievements.length) {
|
||||||
|
listeners[layer.id] = layer.on("postUpdate", () => {
|
||||||
|
achievements.forEach(achievement => {
|
||||||
|
if (
|
||||||
|
unref(achievement.visibility) === Visibility.Visible &&
|
||||||
|
!unref(achievement.earned) &&
|
||||||
|
unref(achievement.shouldEarn)
|
||||||
|
) {
|
||||||
|
achievement[PersistentState].value = true;
|
||||||
|
achievement.onComplete?.();
|
||||||
|
if (achievement.display) {
|
||||||
|
const Display = coerceComponent(unref(achievement.display));
|
||||||
|
toast.info(
|
||||||
|
<div>
|
||||||
|
<h3>Achievement earned!</h3>
|
||||||
|
<div>
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
<Display />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
globalBus.on("removeLayer", layer => {
|
||||||
|
// unsubscribe from postUpdate
|
||||||
|
listeners[layer.id]?.();
|
||||||
|
listeners[layer.id] = undefined;
|
||||||
|
});
|
182
src/features/bars/Bar.vue
Normal file
|
@ -0,0 +1,182 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="unref(visibility) !== Visibility.None"
|
||||||
|
:style="[
|
||||||
|
{
|
||||||
|
width: unref(width) + 'px',
|
||||||
|
height: unref(height) + 'px',
|
||||||
|
visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined
|
||||||
|
},
|
||||||
|
unref(style) ?? {}
|
||||||
|
]"
|
||||||
|
:class="{
|
||||||
|
bar: true,
|
||||||
|
...unref(classes)
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="overlayTextContainer border"
|
||||||
|
:style="[
|
||||||
|
{ width: unref(width) + 'px', height: unref(height) + 'px' },
|
||||||
|
unref(borderStyle) ?? {}
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
v-if="component"
|
||||||
|
class="overlayText"
|
||||||
|
:style="unref(textStyle)"
|
||||||
|
:is="component"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="border"
|
||||||
|
:style="[
|
||||||
|
{ width: unref(width) + 'px', height: unref(height) + 'px' },
|
||||||
|
unref(style) ?? {},
|
||||||
|
unref(baseStyle) ?? {},
|
||||||
|
unref(borderStyle) ?? {}
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="fill" :style="[barStyle, unref(style) ?? {}, unref(fillStyle) ?? {}]" />
|
||||||
|
</div>
|
||||||
|
<MarkNode :mark="unref(mark)" />
|
||||||
|
<LinkNode :id="id" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Direction } from "./bar";
|
||||||
|
import { CoercableComponent, Visibility } from "@/features/feature";
|
||||||
|
import Decimal, { DecimalSource } from "@/util/bignum";
|
||||||
|
import { computeOptionalComponent, processedPropType, unwrapRef } from "@/util/vue";
|
||||||
|
import { computed, CSSProperties, defineComponent, StyleValue, toRefs, unref } from "vue";
|
||||||
|
import LinkNode from "@/components/links/LinkNode.vue";
|
||||||
|
import MarkNode from "@/components/MarkNode.vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
progress: {
|
||||||
|
type: processedPropType<DecimalSource>(String, Object, Number),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
type: processedPropType<number>(Number),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: processedPropType<number>(Number),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
direction: {
|
||||||
|
type: processedPropType<Direction>(String),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
display: processedPropType<CoercableComponent>(Object, String, Function),
|
||||||
|
visibility: {
|
||||||
|
type: processedPropType<Visibility>(Number),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
style: processedPropType<StyleValue>(Object, String, Array),
|
||||||
|
classes: processedPropType<Record<string, boolean>>(Object),
|
||||||
|
borderStyle: processedPropType<StyleValue>(Object, String, Array),
|
||||||
|
textStyle: processedPropType<StyleValue>(Object, String, Array),
|
||||||
|
baseStyle: processedPropType<StyleValue>(Object, String, Array),
|
||||||
|
fillStyle: processedPropType<StyleValue>(Object, String, Array),
|
||||||
|
mark: processedPropType<boolean | string>(Boolean, String),
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MarkNode,
|
||||||
|
LinkNode
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const { progress, width, height, direction, display } = toRefs(props);
|
||||||
|
|
||||||
|
const normalizedProgress = computed(() => {
|
||||||
|
let progressNumber =
|
||||||
|
progress.value instanceof Decimal
|
||||||
|
? progress.value.toNumber()
|
||||||
|
: Number(progress.value);
|
||||||
|
return (1 - Math.min(Math.max(progressNumber, 0), 1)) * 100;
|
||||||
|
});
|
||||||
|
|
||||||
|
const barStyle = computed(() => {
|
||||||
|
const barStyle: Partial<CSSProperties> = {
|
||||||
|
width: unwrapRef(width) + 0.5 + "px",
|
||||||
|
height: unwrapRef(height) + 0.5 + "px"
|
||||||
|
};
|
||||||
|
switch (unref(direction)) {
|
||||||
|
case Direction.Up:
|
||||||
|
barStyle.clipPath = `inset(${normalizedProgress.value}% 0% 0% 0%)`;
|
||||||
|
barStyle.width = unwrapRef(width) + 1 + "px";
|
||||||
|
break;
|
||||||
|
case Direction.Down:
|
||||||
|
barStyle.clipPath = `inset(0% 0% ${normalizedProgress.value}% 0%)`;
|
||||||
|
barStyle.width = unwrapRef(width) + 1 + "px";
|
||||||
|
break;
|
||||||
|
case Direction.Right:
|
||||||
|
barStyle.clipPath = `inset(0% ${normalizedProgress.value}% 0% 0%)`;
|
||||||
|
break;
|
||||||
|
case Direction.Left:
|
||||||
|
barStyle.clipPath = `inset(0% 0% 0% ${normalizedProgress.value} + '%)`;
|
||||||
|
break;
|
||||||
|
case Direction.Default:
|
||||||
|
barStyle.clipPath = "inset(0% 50% 0% 0%)";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return barStyle;
|
||||||
|
});
|
||||||
|
|
||||||
|
const component = computeOptionalComponent(display);
|
||||||
|
|
||||||
|
return {
|
||||||
|
normalizedProgress,
|
||||||
|
barStyle,
|
||||||
|
component,
|
||||||
|
unref,
|
||||||
|
Visibility
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bar {
|
||||||
|
position: relative;
|
||||||
|
display: table;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlayTextContainer {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 10px;
|
||||||
|
vertical-align: middle;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlayText {
|
||||||
|
z-index: 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border {
|
||||||
|
border: 2px solid;
|
||||||
|
border-radius: 10px;
|
||||||
|
border-color: var(--foreground);
|
||||||
|
overflow: hidden;
|
||||||
|
mask-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAA5JREFUeNpiYGBgAAgwAAAEAAGbA+oJAAAAAElFTkSuQmCC);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill {
|
||||||
|
position: absolute;
|
||||||
|
background-color: var(--foreground);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-left: -0.5px;
|
||||||
|
transition-duration: 0.2s;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
</style>
|
140
src/features/bars/bar.ts
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
import BarComponent from "@/features/bars/Bar.vue";
|
||||||
|
import {
|
||||||
|
CoercableComponent,
|
||||||
|
Component,
|
||||||
|
GatherProps,
|
||||||
|
getUniqueID,
|
||||||
|
Replace,
|
||||||
|
setDefault,
|
||||||
|
StyleValue,
|
||||||
|
Visibility
|
||||||
|
} from "@/features/feature";
|
||||||
|
import { DecimalSource } from "@/lib/break_eternity";
|
||||||
|
import {
|
||||||
|
Computable,
|
||||||
|
GetComputableType,
|
||||||
|
GetComputableTypeWithDefault,
|
||||||
|
processComputable,
|
||||||
|
ProcessedComputable
|
||||||
|
} from "@/util/computed";
|
||||||
|
import { createLazyProxy } from "@/util/proxies";
|
||||||
|
|
||||||
|
export const BarType = Symbol("Bar");
|
||||||
|
|
||||||
|
export enum Direction {
|
||||||
|
Up = "Up",
|
||||||
|
Down = "Down",
|
||||||
|
Left = "Left",
|
||||||
|
Right = "Right",
|
||||||
|
Default = "Up"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarOptions {
|
||||||
|
visibility?: Computable<Visibility>;
|
||||||
|
width: Computable<number>;
|
||||||
|
height: Computable<number>;
|
||||||
|
direction: Computable<Direction>;
|
||||||
|
style?: Computable<StyleValue>;
|
||||||
|
classes?: Computable<Record<string, boolean>>;
|
||||||
|
borderStyle?: Computable<StyleValue>;
|
||||||
|
baseStyle?: Computable<StyleValue>;
|
||||||
|
textStyle?: Computable<StyleValue>;
|
||||||
|
fillStyle?: Computable<StyleValue>;
|
||||||
|
progress: Computable<DecimalSource>;
|
||||||
|
display?: Computable<CoercableComponent>;
|
||||||
|
mark?: Computable<boolean | string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseBar {
|
||||||
|
id: string;
|
||||||
|
type: typeof BarType;
|
||||||
|
[Component]: typeof BarComponent;
|
||||||
|
[GatherProps]: () => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Bar<T extends BarOptions> = Replace<
|
||||||
|
T & BaseBar,
|
||||||
|
{
|
||||||
|
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||||
|
width: GetComputableType<T["width"]>;
|
||||||
|
height: GetComputableType<T["height"]>;
|
||||||
|
direction: GetComputableType<T["direction"]>;
|
||||||
|
style: GetComputableType<T["style"]>;
|
||||||
|
classes: GetComputableType<T["classes"]>;
|
||||||
|
borderStyle: GetComputableType<T["borderStyle"]>;
|
||||||
|
baseStyle: GetComputableType<T["baseStyle"]>;
|
||||||
|
textStyle: GetComputableType<T["textStyle"]>;
|
||||||
|
fillStyle: GetComputableType<T["fillStyle"]>;
|
||||||
|
progress: GetComputableType<T["progress"]>;
|
||||||
|
display: GetComputableType<T["display"]>;
|
||||||
|
mark: GetComputableType<T["mark"]>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericBar = Replace<
|
||||||
|
Bar<BarOptions>,
|
||||||
|
{
|
||||||
|
visibility: ProcessedComputable<Visibility>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createBar<T extends BarOptions>(optionsFunc: () => T & ThisType<Bar<T>>): Bar<T> {
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const bar: T & Partial<BaseBar> = optionsFunc();
|
||||||
|
bar.id = getUniqueID("bar-");
|
||||||
|
bar.type = BarType;
|
||||||
|
bar[Component] = BarComponent;
|
||||||
|
|
||||||
|
processComputable(bar as T, "visibility");
|
||||||
|
setDefault(bar, "visibility", Visibility.Visible);
|
||||||
|
processComputable(bar as T, "width");
|
||||||
|
processComputable(bar as T, "height");
|
||||||
|
processComputable(bar as T, "direction");
|
||||||
|
processComputable(bar as T, "style");
|
||||||
|
processComputable(bar as T, "classes");
|
||||||
|
processComputable(bar as T, "borderStyle");
|
||||||
|
processComputable(bar as T, "baseStyle");
|
||||||
|
processComputable(bar as T, "textStyle");
|
||||||
|
processComputable(bar as T, "fillStyle");
|
||||||
|
processComputable(bar as T, "progress");
|
||||||
|
processComputable(bar as T, "display");
|
||||||
|
processComputable(bar as T, "mark");
|
||||||
|
|
||||||
|
bar[GatherProps] = function (this: GenericBar) {
|
||||||
|
const {
|
||||||
|
progress,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
direction,
|
||||||
|
display,
|
||||||
|
visibility,
|
||||||
|
style,
|
||||||
|
classes,
|
||||||
|
borderStyle,
|
||||||
|
textStyle,
|
||||||
|
baseStyle,
|
||||||
|
fillStyle,
|
||||||
|
mark,
|
||||||
|
id
|
||||||
|
} = this;
|
||||||
|
return {
|
||||||
|
progress,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
direction,
|
||||||
|
display,
|
||||||
|
visibility,
|
||||||
|
style,
|
||||||
|
classes,
|
||||||
|
borderStyle,
|
||||||
|
textStyle,
|
||||||
|
baseStyle,
|
||||||
|
fillStyle,
|
||||||
|
mark,
|
||||||
|
id
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return bar as unknown as Bar<T>;
|
||||||
|
});
|
||||||
|
}
|
231
src/features/boards/Board.vue
Normal file
|
@ -0,0 +1,231 @@
|
||||||
|
<template>
|
||||||
|
<panZoom
|
||||||
|
v-if="visibility !== Visibility.None"
|
||||||
|
v-show="visibility === Visibility.Visible"
|
||||||
|
:style="[
|
||||||
|
{
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
},
|
||||||
|
style
|
||||||
|
]"
|
||||||
|
:class="classes"
|
||||||
|
selector=".g1"
|
||||||
|
:options="{ initialZoom: 1, minZoom: 0.1, maxZoom: 10, zoomDoubleClickSpeed: 1 }"
|
||||||
|
ref="stage"
|
||||||
|
@init="onInit"
|
||||||
|
@mousemove="drag"
|
||||||
|
@touchmove="drag"
|
||||||
|
@mousedown="(e: MouseEvent) => mouseDown(e)"
|
||||||
|
@touchstart="(e: TouchEvent) => mouseDown(e)"
|
||||||
|
@mouseup="() => endDragging(dragging)"
|
||||||
|
@touchend="() => endDragging(dragging)"
|
||||||
|
@mouseleave="() => endDragging(dragging)"
|
||||||
|
>
|
||||||
|
<svg class="stage" width="100%" height="100%">
|
||||||
|
<g class="g1">
|
||||||
|
<transition-group name="link" appear>
|
||||||
|
<g v-for="(link, i) in links || []" :key="i">
|
||||||
|
<BoardLinkVue :link="link" />
|
||||||
|
</g>
|
||||||
|
</transition-group>
|
||||||
|
<transition-group name="grow" :duration="500" appear>
|
||||||
|
<g v-for="node in sortedNodes" :key="node.id" style="transition-duration: 0s">
|
||||||
|
<BoardNodeVue
|
||||||
|
:node="node"
|
||||||
|
:nodeType="types[node.type]"
|
||||||
|
:dragging="draggingNode"
|
||||||
|
:dragged="dragged"
|
||||||
|
:hasDragged="hasDragged"
|
||||||
|
:receivingNode="receivingNode?.id === node.id"
|
||||||
|
:selectedNode="selectedNode"
|
||||||
|
:selectedAction="selectedAction"
|
||||||
|
@mouseDown="mouseDown"
|
||||||
|
@endDragging="endDragging"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</transition-group>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</panZoom>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { BoardNode, GenericBoard, getNodeProperty } from "@/features/boards/board";
|
||||||
|
import { FeatureComponent, PersistentState, Visibility } from "@/features/feature";
|
||||||
|
import { computed, ref, toRefs } from "vue";
|
||||||
|
import panZoom from "vue-panzoom";
|
||||||
|
import BoardLinkVue from "./BoardLink.vue";
|
||||||
|
import BoardNodeVue from "./BoardNode.vue";
|
||||||
|
|
||||||
|
const _props = defineProps<FeatureComponent<GenericBoard>>();
|
||||||
|
const props = toRefs(_props);
|
||||||
|
|
||||||
|
const lastMousePosition = ref({ x: 0, y: 0 });
|
||||||
|
const dragged = ref({ x: 0, y: 0 });
|
||||||
|
const dragging = ref<number | null>(null);
|
||||||
|
const hasDragged = ref(false);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const stage = ref<any>(null);
|
||||||
|
|
||||||
|
const draggingNode = computed(() =>
|
||||||
|
dragging.value == null ? undefined : props.nodes.value.find(node => node.id === dragging.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortedNodes = computed(() => {
|
||||||
|
const nodes = props.nodes.value.slice();
|
||||||
|
if (draggingNode.value) {
|
||||||
|
const node = nodes.splice(nodes.indexOf(draggingNode.value), 1)[0];
|
||||||
|
nodes.push(node);
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
});
|
||||||
|
|
||||||
|
const receivingNode = computed(() => {
|
||||||
|
const node = draggingNode.value;
|
||||||
|
if (node == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = {
|
||||||
|
x: node.position.x + dragged.value.x,
|
||||||
|
y: node.position.y + dragged.value.y
|
||||||
|
};
|
||||||
|
let smallestDistance = Number.MAX_VALUE;
|
||||||
|
return props.nodes.value.reduce((smallest: BoardNode | null, curr: BoardNode) => {
|
||||||
|
if (curr.id === node.id) {
|
||||||
|
return smallest;
|
||||||
|
}
|
||||||
|
const nodeType = props.types.value[curr.type];
|
||||||
|
const canAccept = getNodeProperty(nodeType.canAccept, curr);
|
||||||
|
if (!canAccept) {
|
||||||
|
return smallest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distanceSquared =
|
||||||
|
Math.pow(position.x - curr.position.x, 2) + Math.pow(position.y - curr.position.y, 2);
|
||||||
|
let size = getNodeProperty(nodeType.size, curr);
|
||||||
|
if (distanceSquared > smallestDistance || distanceSquared > size * size) {
|
||||||
|
return smallest;
|
||||||
|
}
|
||||||
|
|
||||||
|
smallestDistance = distanceSquared;
|
||||||
|
return curr;
|
||||||
|
}, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function onInit(panzoomInstance: any) {
|
||||||
|
panzoomInstance.setTransformOrigin(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mouseDown(e: MouseEvent | TouchEvent, nodeID: number | null = null, draggable = false) {
|
||||||
|
if (dragging.value == null) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
let clientX, clientY;
|
||||||
|
if ("touches" in e) {
|
||||||
|
if (e.touches.length === 1) {
|
||||||
|
clientX = e.touches[0].clientX;
|
||||||
|
clientY = e.touches[0].clientY;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clientX = e.clientX;
|
||||||
|
clientY = e.clientY;
|
||||||
|
}
|
||||||
|
lastMousePosition.value = {
|
||||||
|
x: clientX,
|
||||||
|
y: clientY
|
||||||
|
};
|
||||||
|
dragged.value = { x: 0, y: 0 };
|
||||||
|
hasDragged.value = false;
|
||||||
|
|
||||||
|
if (draggable) {
|
||||||
|
dragging.value = nodeID;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nodeID != null) {
|
||||||
|
props[PersistentState].value.selectedNode = null;
|
||||||
|
props[PersistentState].value.selectedAction = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drag(e: MouseEvent | TouchEvent) {
|
||||||
|
const zoom = stage.value.$panZoomInstance.getTransform().scale;
|
||||||
|
|
||||||
|
let clientX, clientY;
|
||||||
|
if ("touches" in e) {
|
||||||
|
if (e.touches.length === 1) {
|
||||||
|
clientX = e.touches[0].clientX;
|
||||||
|
clientY = e.touches[0].clientY;
|
||||||
|
} else {
|
||||||
|
endDragging(dragging.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clientX = e.clientX;
|
||||||
|
clientY = e.clientY;
|
||||||
|
}
|
||||||
|
|
||||||
|
dragged.value = {
|
||||||
|
x: dragged.value.x + (clientX - lastMousePosition.value.x) / zoom,
|
||||||
|
y: dragged.value.y + (clientY - lastMousePosition.value.y) / zoom
|
||||||
|
};
|
||||||
|
lastMousePosition.value = {
|
||||||
|
x: clientX,
|
||||||
|
y: clientY
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Math.abs(dragged.value.x) > 10 || Math.abs(dragged.value.y) > 10) {
|
||||||
|
hasDragged.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dragging.value) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDragging(nodeID: number | null) {
|
||||||
|
if (dragging.value != null && dragging.value === nodeID && draggingNode.value != null) {
|
||||||
|
draggingNode.value.position.x += Math.round(dragged.value.x / 25) * 25;
|
||||||
|
draggingNode.value.position.y += Math.round(dragged.value.y / 25) * 25;
|
||||||
|
|
||||||
|
const nodes = props.nodes.value;
|
||||||
|
nodes.splice(nodes.indexOf(draggingNode.value), 1);
|
||||||
|
nodes.push(draggingNode.value);
|
||||||
|
|
||||||
|
if (receivingNode.value) {
|
||||||
|
props.types.value[receivingNode.value.type].onDrop?.(
|
||||||
|
receivingNode.value,
|
||||||
|
draggingNode.value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
dragging.value = null;
|
||||||
|
} else if (!hasDragged.value) {
|
||||||
|
props[PersistentState].value.selectedNode = null;
|
||||||
|
props[PersistentState].value.selectedAction = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.vue-pan-zoom-scene {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g1 {
|
||||||
|
transition-duration: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-enter-from,
|
||||||
|
.link-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
59
src/features/boards/BoardLink.vue
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
<template>
|
||||||
|
<line
|
||||||
|
class="link"
|
||||||
|
v-bind="link"
|
||||||
|
:class="{ pulsing: link.pulsing }"
|
||||||
|
:x1="startPosition.x"
|
||||||
|
:y1="startPosition.y"
|
||||||
|
:x2="endPosition.x"
|
||||||
|
:y2="endPosition.y"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { BoardNodeLink } from "@/features/boards/board";
|
||||||
|
import { computed, toRefs, unref } from "vue";
|
||||||
|
|
||||||
|
const _props = defineProps<{
|
||||||
|
link: BoardNodeLink;
|
||||||
|
}>();
|
||||||
|
const props = toRefs(_props);
|
||||||
|
|
||||||
|
const startPosition = computed(() => {
|
||||||
|
const position = props.link.value.startNode.position;
|
||||||
|
if (props.link.value.offsetStart) {
|
||||||
|
position.x += unref(props.link.value.offsetStart).x;
|
||||||
|
position.y += unref(props.link.value.offsetStart).y;
|
||||||
|
}
|
||||||
|
return position;
|
||||||
|
});
|
||||||
|
|
||||||
|
const endPosition = computed(() => {
|
||||||
|
const position = props.link.value.endNode.position;
|
||||||
|
if (props.link.value.offsetEnd) {
|
||||||
|
position.x += unref(props.link.value.offsetEnd).x;
|
||||||
|
position.y += unref(props.link.value.offsetEnd).y;
|
||||||
|
}
|
||||||
|
return position;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.link.pulsing {
|
||||||
|
animation: pulsing 2s ease-in infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulsing {
|
||||||
|
0% {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
371
src/features/boards/BoardNode.vue
Normal file
|
@ -0,0 +1,371 @@
|
||||||
|
<template>
|
||||||
|
<g
|
||||||
|
class="boardnode"
|
||||||
|
:class="node.type"
|
||||||
|
:style="{ opacity: dragging?.id === node.id && hasDragged ? 0.5 : 1 }"
|
||||||
|
:transform="`translate(${position.x},${position.y})`"
|
||||||
|
>
|
||||||
|
<transition name="actions" appear>
|
||||||
|
<g v-if="isSelected && actions">
|
||||||
|
<!-- TODO move to separate file -->
|
||||||
|
<g
|
||||||
|
v-for="(action, index) in actions"
|
||||||
|
:key="action.id"
|
||||||
|
class="action"
|
||||||
|
:class="{ selected: selectedAction?.id === action.id }"
|
||||||
|
:transform="`translate(
|
||||||
|
${
|
||||||
|
(-size - 30) *
|
||||||
|
Math.sin(((actions.length - 1) / 2 - index) * actionDistance)
|
||||||
|
},
|
||||||
|
${
|
||||||
|
(size + 30) *
|
||||||
|
Math.cos(((actions.length - 1) / 2 - index) * actionDistance)
|
||||||
|
}
|
||||||
|
)`"
|
||||||
|
@mousedown="e => performAction(e, action)"
|
||||||
|
@touchstart="e => performAction(e, action)"
|
||||||
|
@mouseup="e => actionMouseUp(e, action)"
|
||||||
|
@touchend.stop="e => actionMouseUp(e, action)"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
:fill="getNodeProperty(action.fillColor, node)"
|
||||||
|
r="20"
|
||||||
|
:stroke-width="selectedAction?.id === action.id ? 4 : 0"
|
||||||
|
:stroke="outlineColor"
|
||||||
|
/>
|
||||||
|
<text :fill="titleColor" class="material-icons">{{
|
||||||
|
getNodeProperty(action.icon, node)
|
||||||
|
}}</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<g
|
||||||
|
class="node-container"
|
||||||
|
@mouseenter="isHovering = true"
|
||||||
|
@mouseleave="isHovering = false"
|
||||||
|
@mousedown="mouseDown"
|
||||||
|
@touchstart="mouseDown"
|
||||||
|
@mouseup="mouseUp"
|
||||||
|
@touchend="mouseUp"
|
||||||
|
>
|
||||||
|
<g v-if="shape === Shape.Circle">
|
||||||
|
<circle
|
||||||
|
v-if="canAccept"
|
||||||
|
class="receiver"
|
||||||
|
:r="size + 8"
|
||||||
|
:fill="backgroundColor"
|
||||||
|
:stroke="receivingNode ? '#0F0' : '#0F03'"
|
||||||
|
:stroke-width="2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<circle
|
||||||
|
class="body"
|
||||||
|
:r="size"
|
||||||
|
:fill="fillColor"
|
||||||
|
:stroke="outlineColor"
|
||||||
|
:stroke-width="4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<circle
|
||||||
|
class="progressFill"
|
||||||
|
v-if="progressDisplay === ProgressDisplay.Fill"
|
||||||
|
:r="Math.max(size * progress - 2, 0)"
|
||||||
|
:fill="progressColor"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
v-else
|
||||||
|
:r="size + 4.5"
|
||||||
|
class="progressRing"
|
||||||
|
fill="transparent"
|
||||||
|
:stroke-dasharray="(size + 4.5) * 2 * Math.PI"
|
||||||
|
:stroke-width="5"
|
||||||
|
:stroke-dashoffset="
|
||||||
|
(size + 4.5) * 2 * Math.PI - progress * (size + 4.5) * 2 * Math.PI
|
||||||
|
"
|
||||||
|
:stroke="progressColor"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g v-else-if="shape === Shape.Diamond" transform="rotate(45, 0, 0)">
|
||||||
|
<rect
|
||||||
|
v-if="canAccept"
|
||||||
|
class="receiver"
|
||||||
|
:width="size * sqrtTwo + 16"
|
||||||
|
:height="size * sqrtTwo + 16"
|
||||||
|
:transform="`translate(${-(size * sqrtTwo + 16) / 2}, ${
|
||||||
|
-(size * sqrtTwo + 16) / 2
|
||||||
|
})`"
|
||||||
|
:fill="backgroundColor"
|
||||||
|
:stroke="receivingNode ? '#0F0' : '#0F03'"
|
||||||
|
:stroke-width="2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<rect
|
||||||
|
class="body"
|
||||||
|
:width="size * sqrtTwo"
|
||||||
|
:height="size * sqrtTwo"
|
||||||
|
:transform="`translate(${(-size * sqrtTwo) / 2}, ${(-size * sqrtTwo) / 2})`"
|
||||||
|
:fill="fillColor"
|
||||||
|
:stroke="outlineColor"
|
||||||
|
:stroke-width="4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<rect
|
||||||
|
v-if="progressDisplay === ProgressDisplay.Fill"
|
||||||
|
class="progressFill"
|
||||||
|
:width="Math.max(size * sqrtTwo * progress - 2, 0)"
|
||||||
|
:height="Math.max(size * sqrtTwo * progress - 2, 0)"
|
||||||
|
:transform="`translate(${-Math.max(size * sqrtTwo * progress - 2, 0) / 2}, ${
|
||||||
|
-Math.max(size * sqrtTwo * progress - 2, 0) / 2
|
||||||
|
})`"
|
||||||
|
:fill="progressColor"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
v-else
|
||||||
|
class="progressDiamond"
|
||||||
|
:width="size * sqrtTwo + 9"
|
||||||
|
:height="size * sqrtTwo + 9"
|
||||||
|
:transform="`translate(${-(size * sqrtTwo + 9) / 2}, ${
|
||||||
|
-(size * sqrtTwo + 9) / 2
|
||||||
|
})`"
|
||||||
|
fill="transparent"
|
||||||
|
:stroke-dasharray="(size * sqrtTwo + 9) * 4"
|
||||||
|
:stroke-width="5"
|
||||||
|
:stroke-dashoffset="
|
||||||
|
(size * sqrtTwo + 9) * 4 - progress * (size * sqrtTwo + 9) * 4
|
||||||
|
"
|
||||||
|
:stroke="progressColor"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text :fill="titleColor" class="node-title">{{ title }}</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<transition name="fade" appear>
|
||||||
|
<g v-if="label">
|
||||||
|
<text
|
||||||
|
:fill="label.color || titleColor"
|
||||||
|
class="node-title"
|
||||||
|
:class="{ pulsing: label.pulsing }"
|
||||||
|
:y="-size - 20"
|
||||||
|
>{{ label.text }}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<transition name="fade" appear>
|
||||||
|
<text
|
||||||
|
v-if="isSelected && selectedAction"
|
||||||
|
:fill="titleColor"
|
||||||
|
class="node-title"
|
||||||
|
:y="size + 75"
|
||||||
|
>Tap again to confirm</text
|
||||||
|
>
|
||||||
|
</transition>
|
||||||
|
</g>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import themes from "@/data/themes";
|
||||||
|
import {
|
||||||
|
BoardNode,
|
||||||
|
GenericBoardNodeAction,
|
||||||
|
GenericNodeType,
|
||||||
|
getNodeProperty,
|
||||||
|
ProgressDisplay,
|
||||||
|
Shape
|
||||||
|
} from "@/features/boards/board";
|
||||||
|
import { Visibility } from "@/features/feature";
|
||||||
|
import settings from "@/game/settings";
|
||||||
|
import { computed, ref, toRefs, unref, watch } from "vue";
|
||||||
|
|
||||||
|
const sqrtTwo = Math.sqrt(2);
|
||||||
|
|
||||||
|
const _props = defineProps<{
|
||||||
|
node: BoardNode;
|
||||||
|
nodeType: GenericNodeType;
|
||||||
|
dragging?: BoardNode;
|
||||||
|
dragged?: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
hasDragged?: boolean;
|
||||||
|
receivingNode?: boolean;
|
||||||
|
selectedNode?: BoardNode | null;
|
||||||
|
selectedAction?: GenericBoardNodeAction | null;
|
||||||
|
}>();
|
||||||
|
const props = toRefs(_props);
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "mouseDown", event: MouseEvent | TouchEvent, node: number, isDraggable: boolean): void;
|
||||||
|
(e: "endDragging", node: number): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isHovering = ref(false);
|
||||||
|
const isSelected = computed(() => unref(props.selectedNode) === unref(props.node));
|
||||||
|
const isDraggable = computed(() =>
|
||||||
|
getNodeProperty(props.nodeType.value.draggable, unref(props.node))
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(isDraggable, value => {
|
||||||
|
const node = unref(props.node);
|
||||||
|
if (unref(props.dragging) === node && !value) {
|
||||||
|
emit("endDragging", node.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const actions = computed(() => {
|
||||||
|
const node = unref(props.node);
|
||||||
|
return getNodeProperty(props.nodeType.value.actions, node)?.filter(
|
||||||
|
action => getNodeProperty(action.visibility, node) !== Visibility.None
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const position = computed(() => {
|
||||||
|
const node = unref(props.node);
|
||||||
|
const dragged = unref(props.dragged);
|
||||||
|
|
||||||
|
return getNodeProperty(props.nodeType.value.draggable, node) &&
|
||||||
|
unref(props.dragging)?.id === node.id &&
|
||||||
|
dragged
|
||||||
|
? {
|
||||||
|
x: node.position.x + Math.round(dragged.x / 25) * 25,
|
||||||
|
y: node.position.y + Math.round(dragged.y / 25) * 25
|
||||||
|
}
|
||||||
|
: node.position;
|
||||||
|
});
|
||||||
|
|
||||||
|
const shape = computed(() => getNodeProperty(props.nodeType.value.shape, unref(props.node)));
|
||||||
|
const title = computed(() => getNodeProperty(props.nodeType.value.title, unref(props.node)));
|
||||||
|
const label = computed(() => getNodeProperty(props.nodeType.value.label, unref(props.node)));
|
||||||
|
const size = computed(() => getNodeProperty(props.nodeType.value.size, unref(props.node)));
|
||||||
|
const progress = computed(
|
||||||
|
() => getNodeProperty(props.nodeType.value.progress, unref(props.node)) || 0
|
||||||
|
);
|
||||||
|
const backgroundColor = computed(() => themes[settings.theme].variables["--background"]);
|
||||||
|
const outlineColor = computed(
|
||||||
|
() =>
|
||||||
|
getNodeProperty(props.nodeType.value.outlineColor, unref(props.node)) ||
|
||||||
|
themes[settings.theme].variables["--outline"]
|
||||||
|
);
|
||||||
|
const fillColor = computed(
|
||||||
|
() =>
|
||||||
|
getNodeProperty(props.nodeType.value.fillColor, unref(props.node)) ||
|
||||||
|
themes[settings.theme].variables["--raised-background"]
|
||||||
|
);
|
||||||
|
const progressColor = computed(() =>
|
||||||
|
getNodeProperty(props.nodeType.value.progressColor, unref(props.node))
|
||||||
|
);
|
||||||
|
const titleColor = computed(
|
||||||
|
() =>
|
||||||
|
getNodeProperty(props.nodeType.value.titleColor, unref(props.node)) ||
|
||||||
|
themes[settings.theme].variables["--foreground"]
|
||||||
|
);
|
||||||
|
const progressDisplay = computed(() =>
|
||||||
|
getNodeProperty(props.nodeType.value.progressDisplay, unref(props.node))
|
||||||
|
);
|
||||||
|
const canAccept = computed(
|
||||||
|
() =>
|
||||||
|
unref(props.dragging) != null &&
|
||||||
|
unref(props.hasDragged) &&
|
||||||
|
getNodeProperty(props.nodeType.value.canAccept, unref(props.node))
|
||||||
|
);
|
||||||
|
const actionDistance = computed(() =>
|
||||||
|
getNodeProperty(props.nodeType.value.actionDistance, unref(props.node))
|
||||||
|
);
|
||||||
|
|
||||||
|
function mouseDown(e: MouseEvent | TouchEvent) {
|
||||||
|
emit("mouseDown", e, props.node.value.id, isDraggable.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mouseUp() {
|
||||||
|
if (!props.hasDragged?.value) {
|
||||||
|
props.nodeType.value.onClick?.(props.node.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function performAction(e: MouseEvent | TouchEvent, action: GenericBoardNodeAction) {
|
||||||
|
// If the onClick function made this action selected,
|
||||||
|
// don't propagate the event (which will deselect everything)
|
||||||
|
if (action.onClick(unref(props.node)) || unref(props.selectedAction)?.id === action.id) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionMouseUp(e: MouseEvent | TouchEvent, action: GenericBoardNodeAction) {
|
||||||
|
if (unref(props.selectedAction)?.id === action.id) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.boardnode {
|
||||||
|
cursor: pointer;
|
||||||
|
transition-duration: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-title {
|
||||||
|
text-anchor: middle;
|
||||||
|
dominant-baseline: middle;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 200%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressRing {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action:not(.boardnode):hover circle,
|
||||||
|
.action:not(.boardnode).selected circle {
|
||||||
|
r: 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action:not(.boardnode):hover text,
|
||||||
|
.action:not(.boardnode).selected text {
|
||||||
|
font-size: 187.5%; /* 150% * 1.25 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.action:not(.boardnode) text {
|
||||||
|
text-anchor: middle;
|
||||||
|
dominant-baseline: central;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulsing {
|
||||||
|
animation: pulsing 2s ease-in infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulsing {
|
||||||
|
0% {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.actions-enter-from .action,
|
||||||
|
.actions-leave-to .action {
|
||||||
|
transform: translate(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow-enter-from .node-container,
|
||||||
|
.grow-leave-to .node-container {
|
||||||
|
transform: scale(0);
|
||||||
|
}
|
||||||
|
</style>
|
363
src/features/boards/board.ts
Normal file
|
@ -0,0 +1,363 @@
|
||||||
|
import BoardComponent from "@/features/boards/Board.vue";
|
||||||
|
import {
|
||||||
|
Component,
|
||||||
|
findFeatures,
|
||||||
|
GatherProps,
|
||||||
|
getUniqueID,
|
||||||
|
Replace,
|
||||||
|
setDefault,
|
||||||
|
StyleValue,
|
||||||
|
Visibility
|
||||||
|
} from "@/features/feature";
|
||||||
|
import { globalBus } from "@/game/events";
|
||||||
|
import { State, Persistent, makePersistent, PersistentState } from "@/game/persistence";
|
||||||
|
import Decimal, { DecimalSource } from "@/lib/break_eternity";
|
||||||
|
import { isFunction } from "@/util/common";
|
||||||
|
import {
|
||||||
|
Computable,
|
||||||
|
GetComputableType,
|
||||||
|
GetComputableTypeWithDefault,
|
||||||
|
processComputable,
|
||||||
|
ProcessedComputable
|
||||||
|
} from "@/util/computed";
|
||||||
|
import { createLazyProxy } from "@/util/proxies";
|
||||||
|
import { Unsubscribe } from "nanoevents";
|
||||||
|
import { computed, Ref, unref } from "vue";
|
||||||
|
import { Link } from "../links";
|
||||||
|
|
||||||
|
export const BoardType = Symbol("Board");
|
||||||
|
|
||||||
|
export type NodeComputable<T> = Computable<T> | ((node: BoardNode) => T);
|
||||||
|
|
||||||
|
export enum ProgressDisplay {
|
||||||
|
Outline = "Outline",
|
||||||
|
Fill = "Fill"
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum Shape {
|
||||||
|
Circle = "Circle",
|
||||||
|
Diamond = "Triangle"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardNode {
|
||||||
|
id: number;
|
||||||
|
position: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
type: string;
|
||||||
|
state?: State;
|
||||||
|
pinned?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardNodeLink extends Omit<Link, "startNode" | "endNode"> {
|
||||||
|
startNode: BoardNode;
|
||||||
|
endNode: BoardNode;
|
||||||
|
pulsing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeLabel {
|
||||||
|
text: string;
|
||||||
|
color?: string;
|
||||||
|
pulsing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BoardData = {
|
||||||
|
nodes: BoardNode[];
|
||||||
|
selectedNode: number | null;
|
||||||
|
selectedAction: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface NodeTypeOptions {
|
||||||
|
title: NodeComputable<string>;
|
||||||
|
label?: NodeComputable<NodeLabel | null>;
|
||||||
|
size: NodeComputable<number>;
|
||||||
|
draggable?: NodeComputable<boolean>;
|
||||||
|
shape: NodeComputable<Shape>;
|
||||||
|
canAccept?: boolean | Ref<boolean> | ((node: BoardNode, otherNode: BoardNode) => boolean);
|
||||||
|
progress?: NodeComputable<number>;
|
||||||
|
progressDisplay?: NodeComputable<ProgressDisplay>;
|
||||||
|
progressColor?: NodeComputable<string>;
|
||||||
|
fillColor?: NodeComputable<string>;
|
||||||
|
outlineColor?: NodeComputable<string>;
|
||||||
|
titleColor?: NodeComputable<string>;
|
||||||
|
actions?: BoardNodeActionOptions[];
|
||||||
|
actionDistance?: NodeComputable<number>;
|
||||||
|
onClick?: (node: BoardNode) => void;
|
||||||
|
onDrop?: (node: BoardNode, otherNode: BoardNode) => void;
|
||||||
|
update?: (node: BoardNode, diff: DecimalSource) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseNodeType {
|
||||||
|
nodes: Ref<BoardNode[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NodeType<T extends NodeTypeOptions> = Replace<
|
||||||
|
T & BaseNodeType,
|
||||||
|
{
|
||||||
|
title: GetComputableType<T["title"]>;
|
||||||
|
label: GetComputableType<T["label"]>;
|
||||||
|
size: GetComputableTypeWithDefault<T["size"], 50>;
|
||||||
|
draggable: GetComputableTypeWithDefault<T["draggable"], false>;
|
||||||
|
shape: GetComputableTypeWithDefault<T["shape"], Shape.Circle>;
|
||||||
|
canAccept: GetComputableTypeWithDefault<T["canAccept"], false>;
|
||||||
|
progress: GetComputableType<T["progress"]>;
|
||||||
|
progressDisplay: GetComputableTypeWithDefault<T["progressDisplay"], ProgressDisplay.Fill>;
|
||||||
|
progressColor: GetComputableTypeWithDefault<T["progressColor"], "none">;
|
||||||
|
fillColor: GetComputableType<T["fillColor"]>;
|
||||||
|
outlineColor: GetComputableType<T["outlineColor"]>;
|
||||||
|
titleColor: GetComputableType<T["titleColor"]>;
|
||||||
|
actions?: GenericBoardNodeAction[];
|
||||||
|
actionDistance: GetComputableTypeWithDefault<T["actionDistance"], number>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericNodeType = Replace<
|
||||||
|
NodeType<NodeTypeOptions>,
|
||||||
|
{
|
||||||
|
size: NodeComputable<number>;
|
||||||
|
draggable: NodeComputable<boolean>;
|
||||||
|
shape: NodeComputable<Shape>;
|
||||||
|
canAccept: NodeComputable<boolean>;
|
||||||
|
progressDisplay: NodeComputable<ProgressDisplay>;
|
||||||
|
progressColor: NodeComputable<string>;
|
||||||
|
actionDistance: NodeComputable<number>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface BoardNodeActionOptions {
|
||||||
|
id: string;
|
||||||
|
visibility?: NodeComputable<Visibility>;
|
||||||
|
icon: NodeComputable<string>;
|
||||||
|
fillColor?: NodeComputable<string>;
|
||||||
|
tooltip: NodeComputable<string>;
|
||||||
|
links?: NodeComputable<BoardNodeLink[]>;
|
||||||
|
onClick: (node: BoardNode) => boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseBoardNodeAction {
|
||||||
|
links?: Ref<BoardNodeLink[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BoardNodeAction<T extends BoardNodeActionOptions> = Replace<
|
||||||
|
T & BaseBoardNodeAction,
|
||||||
|
{
|
||||||
|
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||||
|
icon: GetComputableType<T["icon"]>;
|
||||||
|
fillColor: GetComputableType<T["fillColor"]>;
|
||||||
|
tooltip: GetComputableType<T["tooltip"]>;
|
||||||
|
links: GetComputableType<T["links"]>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericBoardNodeAction = Replace<
|
||||||
|
BoardNodeAction<BoardNodeActionOptions>,
|
||||||
|
{
|
||||||
|
visibility: NodeComputable<Visibility>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface BoardOptions {
|
||||||
|
visibility?: Computable<Visibility>;
|
||||||
|
height?: Computable<string>;
|
||||||
|
width?: Computable<string>;
|
||||||
|
classes?: Computable<Record<string, boolean>>;
|
||||||
|
style?: Computable<StyleValue>;
|
||||||
|
startNodes: () => Omit<BoardNode, "id">[];
|
||||||
|
types: Record<string, NodeTypeOptions>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseBoard extends Persistent<BoardData> {
|
||||||
|
id: string;
|
||||||
|
links: Ref<BoardNodeLink[] | null>;
|
||||||
|
nodes: Ref<BoardNode[]>;
|
||||||
|
selectedNode: Ref<BoardNode | null>;
|
||||||
|
selectedAction: Ref<GenericBoardNodeAction | null>;
|
||||||
|
type: typeof BoardType;
|
||||||
|
[Component]: typeof BoardComponent;
|
||||||
|
[GatherProps]: () => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Board<T extends BoardOptions> = Replace<
|
||||||
|
T & BaseBoard,
|
||||||
|
{
|
||||||
|
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||||
|
types: Record<string, GenericNodeType>;
|
||||||
|
height: GetComputableType<T["height"]>;
|
||||||
|
width: GetComputableType<T["width"]>;
|
||||||
|
classes: GetComputableType<T["classes"]>;
|
||||||
|
style: GetComputableType<T["style"]>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericBoard = Replace<
|
||||||
|
Board<BoardOptions>,
|
||||||
|
{
|
||||||
|
visibility: ProcessedComputable<Visibility>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createBoard<T extends BoardOptions>(
|
||||||
|
optionsFunc: () => T & ThisType<Board<T>>
|
||||||
|
): Board<T> {
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const board: T & Partial<BaseBoard> = optionsFunc();
|
||||||
|
makePersistent<BoardData>(board, {
|
||||||
|
nodes: [],
|
||||||
|
selectedNode: null,
|
||||||
|
selectedAction: null
|
||||||
|
});
|
||||||
|
board.id = getUniqueID("board-");
|
||||||
|
board.type = BoardType;
|
||||||
|
board[Component] = BoardComponent;
|
||||||
|
|
||||||
|
board.nodes = computed(() => processedBoard[PersistentState].value.nodes);
|
||||||
|
board.selectedNode = computed(
|
||||||
|
() =>
|
||||||
|
processedBoard.nodes.value.find(
|
||||||
|
node => node.id === board[PersistentState].value.selectedNode
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
board.selectedAction = computed(() => {
|
||||||
|
const selectedNode = processedBoard.selectedNode.value;
|
||||||
|
if (selectedNode == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const type = processedBoard.types[selectedNode.type];
|
||||||
|
if (type.actions == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
type.actions.find(
|
||||||
|
action => action.id === processedBoard[PersistentState].value.selectedAction
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
board.links = computed(() => {
|
||||||
|
if (processedBoard.selectedAction.value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (processedBoard.selectedAction.value.links && processedBoard.selectedNode.value) {
|
||||||
|
return getNodeProperty(
|
||||||
|
processedBoard.selectedAction.value.links,
|
||||||
|
processedBoard.selectedNode.value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
processComputable(board as T, "visibility");
|
||||||
|
setDefault(board, "visibility", Visibility.Visible);
|
||||||
|
processComputable(board as T, "width");
|
||||||
|
setDefault(board, "width", "100%");
|
||||||
|
processComputable(board as T, "height");
|
||||||
|
setDefault(board, "height", "400px");
|
||||||
|
processComputable(board as T, "classes");
|
||||||
|
processComputable(board as T, "style");
|
||||||
|
|
||||||
|
for (const type in board.types) {
|
||||||
|
const nodeType: NodeTypeOptions & Partial<BaseNodeType> = board.types[type];
|
||||||
|
|
||||||
|
processComputable(nodeType as NodeTypeOptions, "title");
|
||||||
|
processComputable(nodeType as NodeTypeOptions, "label");
|
||||||
|
processComputable(nodeType as NodeTypeOptions, "size");
|
||||||
|
setDefault(nodeType, "size", 50);
|
||||||
|
processComputable(nodeType as NodeTypeOptions, "draggable");
|
||||||
|
setDefault(nodeType, "draggable", false);
|
||||||
|
processComputable(nodeType as NodeTypeOptions, "shape");
|
||||||
|
setDefault(nodeType, "shape", Shape.Circle);
|
||||||
|
processComputable(nodeType as NodeTypeOptions, "canAccept");
|
||||||
|
setDefault(nodeType, "canAccept", false);
|
||||||
|
processComputable(nodeType as NodeTypeOptions, "progress");
|
||||||
|
processComputable(nodeType as NodeTypeOptions, "progressDisplay");
|
||||||
|
setDefault(nodeType, "progressDisplay", ProgressDisplay.Fill);
|
||||||
|
processComputable(nodeType as NodeTypeOptions, "progressColor");
|
||||||
|
setDefault(nodeType, "progressColor", "none");
|
||||||
|
processComputable(nodeType as NodeTypeOptions, "fillColor");
|
||||||
|
processComputable(nodeType as NodeTypeOptions, "outlineColor");
|
||||||
|
processComputable(nodeType as NodeTypeOptions, "titleColor");
|
||||||
|
processComputable(nodeType as NodeTypeOptions, "actionDistance");
|
||||||
|
setDefault(nodeType, "actionDistance", Math.PI / 6);
|
||||||
|
nodeType.nodes = computed(() =>
|
||||||
|
board[PersistentState].value.nodes.filter(node => node.type === type)
|
||||||
|
);
|
||||||
|
setDefault(nodeType, "onClick", function (node: BoardNode) {
|
||||||
|
board[PersistentState].value.selectedNode = node.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nodeType.actions) {
|
||||||
|
for (const action of nodeType.actions) {
|
||||||
|
processComputable(action, "visibility");
|
||||||
|
setDefault(action, "visibility", Visibility.Visible);
|
||||||
|
processComputable(action, "icon");
|
||||||
|
processComputable(action, "fillColor");
|
||||||
|
processComputable(action, "tooltip");
|
||||||
|
processComputable(action, "links");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
board[GatherProps] = function (this: GenericBoard) {
|
||||||
|
const {
|
||||||
|
nodes,
|
||||||
|
types,
|
||||||
|
[PersistentState]: state,
|
||||||
|
visibility,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
style,
|
||||||
|
classes,
|
||||||
|
links,
|
||||||
|
selectedAction,
|
||||||
|
selectedNode
|
||||||
|
} = this;
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
types,
|
||||||
|
[PersistentState]: state,
|
||||||
|
visibility,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
style,
|
||||||
|
classes,
|
||||||
|
links,
|
||||||
|
selectedAction,
|
||||||
|
selectedNode
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// This is necessary because board.types is different from T and Board
|
||||||
|
const processedBoard = board as unknown as Board<T>;
|
||||||
|
return processedBoard;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodeProperty<T>(property: NodeComputable<T>, node: BoardNode): T {
|
||||||
|
return isFunction(property) ? property(node) : unref(property);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUniqueNodeID(board: GenericBoard): number {
|
||||||
|
let id = 0;
|
||||||
|
board.nodes.value.forEach(node => {
|
||||||
|
if (node.id >= id) {
|
||||||
|
id = node.id + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners: Record<string, Unsubscribe | undefined> = {};
|
||||||
|
globalBus.on("addLayer", layer => {
|
||||||
|
const boards: GenericBoard[] = findFeatures(layer, BoardType) as GenericBoard[];
|
||||||
|
listeners[layer.id] = layer.on("postUpdate", (diff: Decimal) => {
|
||||||
|
boards.forEach(board => {
|
||||||
|
Object.values(board.types).forEach(type =>
|
||||||
|
type.nodes.value.forEach(node => type.update?.(node, diff))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
globalBus.on("removeLayer", layer => {
|
||||||
|
// unsubscribe from postUpdate
|
||||||
|
listeners[layer.id]?.();
|
||||||
|
listeners[layer.id] = undefined;
|
||||||
|
});
|
225
src/features/buyable.tsx
Normal file
|
@ -0,0 +1,225 @@
|
||||||
|
import ClickableComponent from "@/features/clickables/Clickable.vue";
|
||||||
|
import { Resource } from "@/features/resources/resource";
|
||||||
|
import { Persistent, makePersistent, PersistentState } from "@/game/persistence";
|
||||||
|
import Decimal, { DecimalSource, format, formatWhole } from "@/util/bignum";
|
||||||
|
import {
|
||||||
|
Computable,
|
||||||
|
GetComputableType,
|
||||||
|
GetComputableTypeWithDefault,
|
||||||
|
processComputable,
|
||||||
|
ProcessedComputable
|
||||||
|
} from "@/util/computed";
|
||||||
|
import { createLazyProxy } from "@/util/proxies";
|
||||||
|
import { coerceComponent, isCoercableComponent } from "@/util/vue";
|
||||||
|
import { computed, Ref, unref } from "vue";
|
||||||
|
import {
|
||||||
|
CoercableComponent,
|
||||||
|
Component,
|
||||||
|
GatherProps,
|
||||||
|
getUniqueID,
|
||||||
|
jsx,
|
||||||
|
Replace,
|
||||||
|
setDefault,
|
||||||
|
StyleValue,
|
||||||
|
Visibility
|
||||||
|
} from "./feature";
|
||||||
|
|
||||||
|
export const BuyableType = Symbol("Buyable");
|
||||||
|
|
||||||
|
type BuyableDisplay =
|
||||||
|
| CoercableComponent
|
||||||
|
| {
|
||||||
|
title?: CoercableComponent;
|
||||||
|
description: CoercableComponent;
|
||||||
|
effectDisplay?: CoercableComponent;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface BuyableOptions {
|
||||||
|
visibility?: Computable<Visibility>;
|
||||||
|
cost?: Computable<DecimalSource>;
|
||||||
|
resource?: Resource;
|
||||||
|
canPurchase?: Computable<boolean>;
|
||||||
|
purchaseLimit?: Computable<DecimalSource>;
|
||||||
|
classes?: Computable<Record<string, boolean>>;
|
||||||
|
style?: Computable<StyleValue>;
|
||||||
|
mark?: Computable<boolean | string>;
|
||||||
|
small?: Computable<boolean>;
|
||||||
|
display?: Computable<BuyableDisplay>;
|
||||||
|
onPurchase?: (cost: DecimalSource) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseBuyable extends Persistent<DecimalSource> {
|
||||||
|
id: string;
|
||||||
|
amount: Ref<DecimalSource>;
|
||||||
|
maxed: Ref<boolean>;
|
||||||
|
canAfford: Ref<boolean>;
|
||||||
|
canClick: ProcessedComputable<boolean>;
|
||||||
|
onClick: VoidFunction;
|
||||||
|
purchase: VoidFunction;
|
||||||
|
type: typeof BuyableType;
|
||||||
|
[Component]: typeof ClickableComponent;
|
||||||
|
[GatherProps]: () => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Buyable<T extends BuyableOptions> = Replace<
|
||||||
|
T & BaseBuyable,
|
||||||
|
{
|
||||||
|
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||||
|
cost: GetComputableType<T["cost"]>;
|
||||||
|
resource: GetComputableType<T["resource"]>;
|
||||||
|
canPurchase: GetComputableTypeWithDefault<T["canPurchase"], Ref<boolean>>;
|
||||||
|
purchaseLimit: GetComputableTypeWithDefault<T["purchaseLimit"], 1>;
|
||||||
|
classes: GetComputableType<T["classes"]>;
|
||||||
|
style: GetComputableType<T["style"]>;
|
||||||
|
mark: GetComputableType<T["mark"]>;
|
||||||
|
small: GetComputableType<T["small"]>;
|
||||||
|
display: Ref<CoercableComponent>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericBuyable = Replace<
|
||||||
|
Buyable<BuyableOptions>,
|
||||||
|
{
|
||||||
|
visibility: ProcessedComputable<Visibility>;
|
||||||
|
canPurchase: ProcessedComputable<boolean>;
|
||||||
|
purchaseLimit: ProcessedComputable<DecimalSource>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createBuyable<T extends BuyableOptions>(
|
||||||
|
optionsFunc: () => T & ThisType<Buyable<T>>
|
||||||
|
): Buyable<T> {
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const buyable: T & Partial<BaseBuyable> = optionsFunc();
|
||||||
|
|
||||||
|
if (buyable.canPurchase == null && (buyable.resource == null || buyable.cost == null)) {
|
||||||
|
console.warn(
|
||||||
|
"Cannot create buyable without a canPurchase property or a resource and cost property",
|
||||||
|
buyable
|
||||||
|
);
|
||||||
|
throw "Cannot create buyable without a canPurchase property or a resource and cost property";
|
||||||
|
}
|
||||||
|
|
||||||
|
makePersistent<DecimalSource>(buyable, 0);
|
||||||
|
buyable.id = getUniqueID("buyable-");
|
||||||
|
buyable.type = BuyableType;
|
||||||
|
buyable[Component] = ClickableComponent;
|
||||||
|
|
||||||
|
buyable.amount = buyable[PersistentState];
|
||||||
|
buyable.canAfford = computed(() => {
|
||||||
|
const genericBuyable = buyable as GenericBuyable;
|
||||||
|
const cost = unref(genericBuyable.cost);
|
||||||
|
return (
|
||||||
|
genericBuyable.resource != null &&
|
||||||
|
cost != null &&
|
||||||
|
Decimal.gte(genericBuyable.resource.value, cost)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (buyable.canPurchase == null) {
|
||||||
|
buyable.canPurchase = computed(
|
||||||
|
() =>
|
||||||
|
unref((buyable as GenericBuyable).visibility) === Visibility.Visible &&
|
||||||
|
unref((buyable as GenericBuyable).canAfford) &&
|
||||||
|
Decimal.lt(
|
||||||
|
(buyable as GenericBuyable).amount.value,
|
||||||
|
unref((buyable as GenericBuyable).purchaseLimit)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
buyable.maxed = computed(() =>
|
||||||
|
Decimal.gte(
|
||||||
|
(buyable as GenericBuyable).amount.value,
|
||||||
|
unref((buyable as GenericBuyable).purchaseLimit)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
processComputable(buyable as T, "classes");
|
||||||
|
const classes = buyable.classes as ProcessedComputable<Record<string, boolean>> | undefined;
|
||||||
|
buyable.classes = computed(() => {
|
||||||
|
const currClasses = unref(classes) || {};
|
||||||
|
if ((buyable as GenericBuyable).maxed.value) {
|
||||||
|
currClasses.bought = true;
|
||||||
|
}
|
||||||
|
return currClasses;
|
||||||
|
});
|
||||||
|
processComputable(buyable as T, "canPurchase");
|
||||||
|
buyable.canClick = buyable.canPurchase as ProcessedComputable<boolean>;
|
||||||
|
buyable.onClick = buyable.purchase = function () {
|
||||||
|
const genericBuyable = buyable as GenericBuyable;
|
||||||
|
if (
|
||||||
|
!unref(genericBuyable.canPurchase) ||
|
||||||
|
genericBuyable.cost == null ||
|
||||||
|
genericBuyable.resource == null
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cost = unref(genericBuyable.cost);
|
||||||
|
genericBuyable.resource.value = Decimal.sub(genericBuyable.resource.value, cost);
|
||||||
|
genericBuyable.amount.value = Decimal.add(genericBuyable.amount.value, 1);
|
||||||
|
this.onPurchase?.(cost);
|
||||||
|
};
|
||||||
|
processComputable(buyable as T, "display");
|
||||||
|
const display = buyable.display;
|
||||||
|
buyable.display = jsx(() => {
|
||||||
|
// TODO once processComputable types correctly, remove this "as X"
|
||||||
|
const currDisplay = unref(display) as BuyableDisplay;
|
||||||
|
if (
|
||||||
|
currDisplay != null &&
|
||||||
|
!isCoercableComponent(currDisplay) &&
|
||||||
|
buyable.cost != null &&
|
||||||
|
buyable.resource != null
|
||||||
|
) {
|
||||||
|
const genericBuyable = buyable as GenericBuyable;
|
||||||
|
const Title = coerceComponent(currDisplay.title || "", "h3");
|
||||||
|
const Description = coerceComponent(currDisplay.description);
|
||||||
|
const EffectDisplay = coerceComponent(currDisplay.effectDisplay || "");
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{currDisplay.title ? (
|
||||||
|
<div>
|
||||||
|
<Title />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Description />
|
||||||
|
<div>
|
||||||
|
<br />
|
||||||
|
Amount: {formatWhole(genericBuyable.amount.value)} /{" "}
|
||||||
|
{formatWhole(unref(genericBuyable.purchaseLimit))}
|
||||||
|
</div>
|
||||||
|
{currDisplay.effectDisplay ? (
|
||||||
|
<div>
|
||||||
|
<br />
|
||||||
|
Currently: <EffectDisplay />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{genericBuyable.cost && !genericBuyable.maxed.value ? (
|
||||||
|
<div>
|
||||||
|
<br />
|
||||||
|
Cost: {format(unref(genericBuyable.cost) || 0)}{" "}
|
||||||
|
{buyable.resource.displayName}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
processComputable(buyable as T, "visibility");
|
||||||
|
setDefault(buyable, "visibility", Visibility.Visible);
|
||||||
|
processComputable(buyable as T, "cost");
|
||||||
|
processComputable(buyable as T, "resource");
|
||||||
|
processComputable(buyable as T, "purchaseLimit");
|
||||||
|
setDefault(buyable, "purchaseLimit", 1);
|
||||||
|
processComputable(buyable as T, "style");
|
||||||
|
processComputable(buyable as T, "mark");
|
||||||
|
processComputable(buyable as T, "small");
|
||||||
|
|
||||||
|
buyable[GatherProps] = function (this: GenericBuyable) {
|
||||||
|
const { display, visibility, style, classes, onClick, canClick, small, mark, id } =
|
||||||
|
this;
|
||||||
|
return { display, visibility, style, classes, onClick, canClick, small, mark, id };
|
||||||
|
};
|
||||||
|
|
||||||
|
return buyable as unknown as Buyable<T>;
|
||||||
|
});
|
||||||
|
}
|
208
src/features/challenges/Challenge.vue
Normal file
|
@ -0,0 +1,208 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="unref(visibility) !== Visibility.None"
|
||||||
|
:style="[
|
||||||
|
{
|
||||||
|
visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined
|
||||||
|
},
|
||||||
|
notifyStyle,
|
||||||
|
unref(style) ?? {}
|
||||||
|
]"
|
||||||
|
:class="{
|
||||||
|
feature: true,
|
||||||
|
challenge: true,
|
||||||
|
done: unref(completed),
|
||||||
|
canStart: unref(canStart),
|
||||||
|
maxed: unref(maxed),
|
||||||
|
...unref(classes)
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<button class="toggleChallenge" @click="toggle">
|
||||||
|
{{ buttonText }}
|
||||||
|
</button>
|
||||||
|
<component v-if="unref(comp)" :is="unref(comp)" />
|
||||||
|
<MarkNode :mark="unref(mark)" />
|
||||||
|
<LinkNode :id="id" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="tsx">
|
||||||
|
import "@/components/common/features.css";
|
||||||
|
import { GenericChallenge } from "@/features/challenges/challenge";
|
||||||
|
import { jsx, StyleValue, Visibility } from "@/features/feature";
|
||||||
|
import { getHighNotifyStyle, getNotifyStyle } from "@/game/notifications";
|
||||||
|
import { coerceComponent, isCoercableComponent, processedPropType, unwrapRef } from "@/util/vue";
|
||||||
|
import {
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
defineComponent,
|
||||||
|
PropType,
|
||||||
|
shallowRef,
|
||||||
|
toRefs,
|
||||||
|
unref,
|
||||||
|
UnwrapRef,
|
||||||
|
watchEffect
|
||||||
|
} from "vue";
|
||||||
|
import LinkNode from "@/components/links/LinkNode.vue";
|
||||||
|
import MarkNode from "@/components/MarkNode.vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
active: {
|
||||||
|
type: processedPropType<boolean>(Boolean),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
maxed: {
|
||||||
|
type: processedPropType<boolean>(Boolean),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
canComplete: {
|
||||||
|
type: processedPropType<boolean>(Boolean),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
display: processedPropType<UnwrapRef<GenericChallenge["display"]>>(
|
||||||
|
String,
|
||||||
|
Object,
|
||||||
|
Function
|
||||||
|
),
|
||||||
|
visibility: {
|
||||||
|
type: processedPropType<Visibility>(Number),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
style: processedPropType<StyleValue>(String, Object, Array),
|
||||||
|
classes: processedPropType<Record<string, boolean>>(Object),
|
||||||
|
completed: {
|
||||||
|
type: processedPropType<boolean>(Boolean),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
canStart: {
|
||||||
|
type: processedPropType<boolean>(Boolean),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
mark: processedPropType<boolean | string>(Boolean, String),
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
toggle: {
|
||||||
|
type: Function as PropType<VoidFunction>,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MarkNode,
|
||||||
|
LinkNode
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const { active, maxed, canComplete, display } = toRefs(props);
|
||||||
|
|
||||||
|
const buttonText = computed(() => {
|
||||||
|
if (active.value) {
|
||||||
|
return canComplete.value ? "Finish" : "Exit Early";
|
||||||
|
}
|
||||||
|
if (maxed.value) {
|
||||||
|
return "Completed";
|
||||||
|
}
|
||||||
|
return "Start";
|
||||||
|
});
|
||||||
|
|
||||||
|
const comp = shallowRef<Component | string>("");
|
||||||
|
|
||||||
|
const notifyStyle = computed(() => {
|
||||||
|
const currActive = unwrapRef(active);
|
||||||
|
const currCanComplete = unwrapRef(canComplete);
|
||||||
|
if (currActive) {
|
||||||
|
if (currCanComplete) {
|
||||||
|
return getHighNotifyStyle();
|
||||||
|
}
|
||||||
|
return getNotifyStyle();
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
const currDisplay = unwrapRef(display);
|
||||||
|
if (currDisplay == null) {
|
||||||
|
comp.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isCoercableComponent(currDisplay)) {
|
||||||
|
comp.value = coerceComponent(currDisplay);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const Title = coerceComponent(currDisplay.title || "", "h3");
|
||||||
|
const Description = coerceComponent(currDisplay.description, "div");
|
||||||
|
const Goal = coerceComponent(currDisplay.goal || "");
|
||||||
|
const Reward = coerceComponent(currDisplay.reward || "");
|
||||||
|
const EffectDisplay = coerceComponent(currDisplay.effectDisplay || "");
|
||||||
|
comp.value = coerceComponent(
|
||||||
|
jsx(() => (
|
||||||
|
<span>
|
||||||
|
{currDisplay.title ? (
|
||||||
|
<div>
|
||||||
|
<Title />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Description />
|
||||||
|
{currDisplay.goal ? (
|
||||||
|
<div>
|
||||||
|
<br />
|
||||||
|
Goal: <Goal />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{currDisplay.reward ? (
|
||||||
|
<div>
|
||||||
|
<br />
|
||||||
|
Reward: <Reward />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{currDisplay.effectDisplay ? (
|
||||||
|
<div>
|
||||||
|
Currently: <EffectDisplay />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
buttonText,
|
||||||
|
notifyStyle,
|
||||||
|
comp,
|
||||||
|
Visibility,
|
||||||
|
unref
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.challenge {
|
||||||
|
background-color: var(--locked);
|
||||||
|
width: 300px;
|
||||||
|
min-height: 300px;
|
||||||
|
color: black;
|
||||||
|
font-size: 15px;
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge.done {
|
||||||
|
background-color: var(--bought);
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge button {
|
||||||
|
min-height: 50px;
|
||||||
|
width: 120px;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: none !important;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge.canStart button {
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--layer-color);
|
||||||
|
}
|
||||||
|
</style>
|
272
src/features/challenges/challenge.tsx
Normal file
|
@ -0,0 +1,272 @@
|
||||||
|
import Toggle from "@/components/fields/Toggle.vue";
|
||||||
|
import ChallengeComponent from "@/features/challenges/Challenge.vue";
|
||||||
|
import {
|
||||||
|
CoercableComponent,
|
||||||
|
Component,
|
||||||
|
GatherProps,
|
||||||
|
getUniqueID,
|
||||||
|
jsx,
|
||||||
|
Replace,
|
||||||
|
setDefault,
|
||||||
|
StyleValue,
|
||||||
|
Visibility
|
||||||
|
} from "@/features/feature";
|
||||||
|
import { GenericReset } from "@/features/reset";
|
||||||
|
import { Resource } from "@/features/resources/resource";
|
||||||
|
import { globalBus } from "@/game/events";
|
||||||
|
import { persistent, PersistentRef } from "@/game/persistence";
|
||||||
|
import settings, { registerSettingField } from "@/game/settings";
|
||||||
|
import Decimal, { DecimalSource } from "@/util/bignum";
|
||||||
|
import {
|
||||||
|
Computable,
|
||||||
|
GetComputableType,
|
||||||
|
GetComputableTypeWithDefault,
|
||||||
|
processComputable,
|
||||||
|
ProcessedComputable
|
||||||
|
} from "@/util/computed";
|
||||||
|
import { createLazyProxy } from "@/util/proxies";
|
||||||
|
import { computed, Ref, unref } from "vue";
|
||||||
|
|
||||||
|
export const ChallengeType = Symbol("ChallengeType");
|
||||||
|
|
||||||
|
export interface ChallengeOptions {
|
||||||
|
visibility?: Computable<Visibility>;
|
||||||
|
canStart?: Computable<boolean>;
|
||||||
|
reset?: GenericReset;
|
||||||
|
canComplete?: Computable<boolean | DecimalSource>;
|
||||||
|
completionLimit?: Computable<DecimalSource>;
|
||||||
|
mark?: Computable<boolean | string>;
|
||||||
|
resource?: Resource;
|
||||||
|
goal?: Computable<DecimalSource>;
|
||||||
|
classes?: Computable<Record<string, boolean>>;
|
||||||
|
style?: Computable<StyleValue>;
|
||||||
|
display?: Computable<
|
||||||
|
| CoercableComponent
|
||||||
|
| {
|
||||||
|
title?: CoercableComponent;
|
||||||
|
description: CoercableComponent;
|
||||||
|
goal?: CoercableComponent;
|
||||||
|
reward?: CoercableComponent;
|
||||||
|
effectDisplay?: CoercableComponent;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
onComplete?: VoidFunction;
|
||||||
|
onExit?: VoidFunction;
|
||||||
|
onEnter?: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseChallenge {
|
||||||
|
id: string;
|
||||||
|
completions: PersistentRef<DecimalSource>;
|
||||||
|
completed: Ref<boolean>;
|
||||||
|
maxed: Ref<boolean>;
|
||||||
|
active: PersistentRef<boolean>;
|
||||||
|
toggle: VoidFunction;
|
||||||
|
type: typeof ChallengeType;
|
||||||
|
[Component]: typeof ChallengeComponent;
|
||||||
|
[GatherProps]: () => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Challenge<T extends ChallengeOptions> = Replace<
|
||||||
|
T & BaseChallenge,
|
||||||
|
{
|
||||||
|
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||||
|
canStart: GetComputableTypeWithDefault<T["canStart"], Ref<boolean>>;
|
||||||
|
canComplete: GetComputableTypeWithDefault<T["canComplete"], Ref<boolean>>;
|
||||||
|
completionLimit: GetComputableTypeWithDefault<T["completionLimit"], 1>;
|
||||||
|
mark: GetComputableTypeWithDefault<T["mark"], Ref<boolean>>;
|
||||||
|
goal: GetComputableType<T["goal"]>;
|
||||||
|
classes: GetComputableType<T["classes"]>;
|
||||||
|
style: GetComputableType<T["style"]>;
|
||||||
|
display: GetComputableType<T["display"]>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericChallenge = Replace<
|
||||||
|
Challenge<ChallengeOptions>,
|
||||||
|
{
|
||||||
|
visibility: ProcessedComputable<Visibility>;
|
||||||
|
canStart: ProcessedComputable<boolean>;
|
||||||
|
canComplete: ProcessedComputable<boolean>;
|
||||||
|
completionLimit: ProcessedComputable<DecimalSource>;
|
||||||
|
mark: ProcessedComputable<boolean>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createActiveChallenge(
|
||||||
|
challenges: GenericChallenge[]
|
||||||
|
): Ref<GenericChallenge | undefined> {
|
||||||
|
return computed(() => challenges.find(challenge => challenge.active.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createChallenge<T extends ChallengeOptions>(
|
||||||
|
optionsFunc: () => T & ThisType<Challenge<T>>
|
||||||
|
): Challenge<T> {
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const challenge: T & Partial<BaseChallenge> = optionsFunc();
|
||||||
|
|
||||||
|
if (
|
||||||
|
challenge.canComplete == null &&
|
||||||
|
(challenge.resource == null || challenge.goal == null)
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
"Cannot create challenge without a canComplete property or a resource and goal property",
|
||||||
|
challenge
|
||||||
|
);
|
||||||
|
throw "Cannot create challenge without a canComplete property or a resource and goal property";
|
||||||
|
}
|
||||||
|
|
||||||
|
challenge.id = getUniqueID("challenge-");
|
||||||
|
challenge.type = ChallengeType;
|
||||||
|
challenge[Component] = ChallengeComponent;
|
||||||
|
|
||||||
|
challenge.completions = persistent(0);
|
||||||
|
challenge.active = persistent(false);
|
||||||
|
challenge.completed = computed(() =>
|
||||||
|
Decimal.gt((challenge as GenericChallenge).completions.value, 0)
|
||||||
|
);
|
||||||
|
challenge.maxed = computed(() =>
|
||||||
|
Decimal.gte(
|
||||||
|
(challenge as GenericChallenge).completions.value,
|
||||||
|
unref((challenge as GenericChallenge).completionLimit)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
challenge.toggle = function () {
|
||||||
|
const genericChallenge = challenge as GenericChallenge;
|
||||||
|
if (genericChallenge.active.value) {
|
||||||
|
if (
|
||||||
|
genericChallenge.canComplete &&
|
||||||
|
unref(genericChallenge.canComplete) &&
|
||||||
|
!genericChallenge.maxed.value
|
||||||
|
) {
|
||||||
|
let completions: boolean | DecimalSource = unref(genericChallenge.canComplete);
|
||||||
|
if (typeof completions === "boolean") {
|
||||||
|
completions = 1;
|
||||||
|
}
|
||||||
|
genericChallenge.completions.value = Decimal.min(
|
||||||
|
Decimal.add(genericChallenge.completions.value, completions),
|
||||||
|
unref(genericChallenge.completionLimit)
|
||||||
|
);
|
||||||
|
genericChallenge.onComplete?.();
|
||||||
|
}
|
||||||
|
genericChallenge.active.value = false;
|
||||||
|
genericChallenge.onExit?.();
|
||||||
|
genericChallenge.reset?.reset();
|
||||||
|
} else if (unref(genericChallenge.canStart)) {
|
||||||
|
genericChallenge.reset?.reset();
|
||||||
|
genericChallenge.active.value = true;
|
||||||
|
genericChallenge.onEnter?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
processComputable(challenge as T, "visibility");
|
||||||
|
setDefault(challenge, "visibility", Visibility.Visible);
|
||||||
|
const visibility = challenge.visibility as ProcessedComputable<Visibility>;
|
||||||
|
challenge.visibility = computed(() => {
|
||||||
|
if (settings.hideChallenges === true && unref(challenge.maxed)) {
|
||||||
|
return Visibility.None;
|
||||||
|
}
|
||||||
|
return unref(visibility);
|
||||||
|
});
|
||||||
|
if (challenge.canStart == null) {
|
||||||
|
challenge.canStart = computed(
|
||||||
|
() =>
|
||||||
|
unref((challenge as GenericChallenge).visibility) === Visibility.Visible &&
|
||||||
|
Decimal.lt(
|
||||||
|
(challenge as GenericChallenge).completions.value,
|
||||||
|
unref((challenge as GenericChallenge).completionLimit)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (challenge.canComplete == null) {
|
||||||
|
challenge.canComplete = computed(() => {
|
||||||
|
const genericChallenge = challenge as GenericChallenge;
|
||||||
|
if (
|
||||||
|
!genericChallenge.active.value ||
|
||||||
|
genericChallenge.resource == null ||
|
||||||
|
genericChallenge.goal == null
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Decimal.gte(genericChallenge.resource.value, unref(genericChallenge.goal));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (challenge.mark == null) {
|
||||||
|
challenge.mark = computed(
|
||||||
|
() =>
|
||||||
|
Decimal.gt(unref((challenge as GenericChallenge).completionLimit), 1) &&
|
||||||
|
!!unref(challenge.maxed)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
processComputable(challenge as T, "canStart");
|
||||||
|
processComputable(challenge as T, "canComplete");
|
||||||
|
processComputable(challenge as T, "completionLimit");
|
||||||
|
setDefault(challenge, "completionLimit", 1);
|
||||||
|
processComputable(challenge as T, "mark");
|
||||||
|
processComputable(challenge as T, "goal");
|
||||||
|
processComputable(challenge as T, "classes");
|
||||||
|
processComputable(challenge as T, "style");
|
||||||
|
processComputable(challenge as T, "display");
|
||||||
|
|
||||||
|
if (challenge.reset != null) {
|
||||||
|
globalBus.on("reset", currentReset => {
|
||||||
|
if (currentReset === challenge.reset && (challenge.active as Ref<boolean>).value) {
|
||||||
|
(challenge.toggle as VoidFunction)();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
challenge[GatherProps] = function (this: GenericChallenge) {
|
||||||
|
const {
|
||||||
|
active,
|
||||||
|
maxed,
|
||||||
|
canComplete,
|
||||||
|
display,
|
||||||
|
visibility,
|
||||||
|
style,
|
||||||
|
classes,
|
||||||
|
completed,
|
||||||
|
canStart,
|
||||||
|
mark,
|
||||||
|
id,
|
||||||
|
toggle
|
||||||
|
} = this;
|
||||||
|
return {
|
||||||
|
active,
|
||||||
|
maxed,
|
||||||
|
canComplete,
|
||||||
|
display,
|
||||||
|
visibility,
|
||||||
|
style,
|
||||||
|
classes,
|
||||||
|
completed,
|
||||||
|
canStart,
|
||||||
|
mark,
|
||||||
|
id,
|
||||||
|
toggle
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return challenge as unknown as Challenge<T>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@/game/settings" {
|
||||||
|
interface Settings {
|
||||||
|
hideChallenges: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
globalBus.on("loadSettings", settings => {
|
||||||
|
setDefault(settings, "hideChallenges", false);
|
||||||
|
});
|
||||||
|
|
||||||
|
registerSettingField(
|
||||||
|
jsx(() => (
|
||||||
|
<Toggle
|
||||||
|
title="Hide Maxed Challenges"
|
||||||
|
onUpdate:modelValue={value => (settings.hideChallenges = value)}
|
||||||
|
modelValue={settings.hideChallenges}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
);
|
143
src/features/clickables/Clickable.vue
Normal file
|
@ -0,0 +1,143 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="unref(visibility) !== Visibility.None"
|
||||||
|
:style="{ visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
:style="unref(style)"
|
||||||
|
@click="onClick"
|
||||||
|
@mousedown="start"
|
||||||
|
@mouseleave="stop"
|
||||||
|
@mouseup="stop"
|
||||||
|
@touchstart="start"
|
||||||
|
@touchend="stop"
|
||||||
|
@touchcancel="stop"
|
||||||
|
:disabled="!unref(canClick)"
|
||||||
|
:class="{
|
||||||
|
feature: true,
|
||||||
|
clickable: true,
|
||||||
|
can: unref(canClick),
|
||||||
|
locked: !unref(canClick),
|
||||||
|
small,
|
||||||
|
...unref(classes)
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<component v-if="unref(comp)" :is="unref(comp)" />
|
||||||
|
<MarkNode :mark="unref(mark)" />
|
||||||
|
<LinkNode :id="id" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="tsx">
|
||||||
|
import "@/components/common/features.css";
|
||||||
|
import LinkNode from "@/components/links/LinkNode.vue";
|
||||||
|
import MarkNode from "@/components/MarkNode.vue";
|
||||||
|
import { GenericClickable } from "@/features/clickables/clickable";
|
||||||
|
import { jsx, StyleValue, Visibility } from "@/features/feature";
|
||||||
|
import {
|
||||||
|
coerceComponent,
|
||||||
|
isCoercableComponent,
|
||||||
|
processedPropType,
|
||||||
|
setupHoldToClick,
|
||||||
|
unwrapRef
|
||||||
|
} from "@/util/vue";
|
||||||
|
import {
|
||||||
|
Component,
|
||||||
|
defineComponent,
|
||||||
|
PropType,
|
||||||
|
shallowRef,
|
||||||
|
toRefs,
|
||||||
|
unref,
|
||||||
|
UnwrapRef,
|
||||||
|
watchEffect
|
||||||
|
} from "vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
display: {
|
||||||
|
type: processedPropType<UnwrapRef<GenericClickable["display"]>>(
|
||||||
|
Object,
|
||||||
|
String,
|
||||||
|
Function
|
||||||
|
),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
visibility: {
|
||||||
|
type: processedPropType<Visibility>(Number),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
style: processedPropType<StyleValue>(Object, String, Array),
|
||||||
|
classes: processedPropType<Record<string, boolean>>(Object),
|
||||||
|
onClick: Function as PropType<VoidFunction>,
|
||||||
|
onHold: Function as PropType<VoidFunction>,
|
||||||
|
canClick: {
|
||||||
|
type: processedPropType<boolean>(Boolean),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
small: Boolean,
|
||||||
|
mark: processedPropType<boolean | string>(Boolean, String),
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
LinkNode,
|
||||||
|
MarkNode
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const { display, onClick, onHold } = toRefs(props);
|
||||||
|
|
||||||
|
const comp = shallowRef<Component | string>("");
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
const currDisplay = unwrapRef(display);
|
||||||
|
if (currDisplay == null) {
|
||||||
|
comp.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isCoercableComponent(currDisplay)) {
|
||||||
|
comp.value = coerceComponent(currDisplay);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const Title = coerceComponent(currDisplay.title || "", "h3");
|
||||||
|
const Description = coerceComponent(currDisplay.description, "div");
|
||||||
|
comp.value = coerceComponent(
|
||||||
|
jsx(() => (
|
||||||
|
<span>
|
||||||
|
{currDisplay.title ? (
|
||||||
|
<div>
|
||||||
|
<Title />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Description />
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { start, stop } = setupHoldToClick(onClick, onHold);
|
||||||
|
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
comp,
|
||||||
|
Visibility,
|
||||||
|
unref
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.clickable {
|
||||||
|
min-height: 120px;
|
||||||
|
width: 120px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable.small {
|
||||||
|
min-height: unset;
|
||||||
|
}
|
||||||
|
</style>
|
115
src/features/clickables/clickable.ts
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
import ClickableComponent from "@/features/clickables/Clickable.vue";
|
||||||
|
import {
|
||||||
|
CoercableComponent,
|
||||||
|
Component,
|
||||||
|
GatherProps,
|
||||||
|
getUniqueID,
|
||||||
|
Replace,
|
||||||
|
setDefault,
|
||||||
|
StyleValue,
|
||||||
|
Visibility
|
||||||
|
} from "@/features/feature";
|
||||||
|
import {
|
||||||
|
Computable,
|
||||||
|
GetComputableType,
|
||||||
|
GetComputableTypeWithDefault,
|
||||||
|
processComputable,
|
||||||
|
ProcessedComputable
|
||||||
|
} from "@/util/computed";
|
||||||
|
import { createLazyProxy } from "@/util/proxies";
|
||||||
|
|
||||||
|
export const ClickableType = Symbol("Clickable");
|
||||||
|
|
||||||
|
export interface ClickableOptions {
|
||||||
|
visibility?: Computable<Visibility>;
|
||||||
|
canClick?: Computable<boolean>;
|
||||||
|
classes?: Computable<Record<string, boolean>>;
|
||||||
|
style?: Computable<StyleValue>;
|
||||||
|
mark?: Computable<boolean | string>;
|
||||||
|
display?: Computable<
|
||||||
|
| CoercableComponent
|
||||||
|
| {
|
||||||
|
title?: CoercableComponent;
|
||||||
|
description: CoercableComponent;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
small?: boolean;
|
||||||
|
onClick?: VoidFunction;
|
||||||
|
onHold?: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseClickable {
|
||||||
|
id: string;
|
||||||
|
type: typeof ClickableType;
|
||||||
|
[Component]: typeof ClickableComponent;
|
||||||
|
[GatherProps]: () => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Clickable<T extends ClickableOptions> = Replace<
|
||||||
|
T & BaseClickable,
|
||||||
|
{
|
||||||
|
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||||
|
canClick: GetComputableTypeWithDefault<T["canClick"], true>;
|
||||||
|
classes: GetComputableType<T["classes"]>;
|
||||||
|
style: GetComputableType<T["style"]>;
|
||||||
|
mark: GetComputableType<T["mark"]>;
|
||||||
|
display: GetComputableType<T["display"]>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericClickable = Replace<
|
||||||
|
Clickable<ClickableOptions>,
|
||||||
|
{
|
||||||
|
visibility: ProcessedComputable<Visibility>;
|
||||||
|
canClick: ProcessedComputable<boolean>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createClickable<T extends ClickableOptions>(
|
||||||
|
optionsFunc: () => T & ThisType<Clickable<T>>
|
||||||
|
): Clickable<T> {
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const clickable: T & Partial<BaseClickable> = optionsFunc();
|
||||||
|
clickable.id = getUniqueID("clickable-");
|
||||||
|
clickable.type = ClickableType;
|
||||||
|
clickable[Component] = ClickableComponent;
|
||||||
|
|
||||||
|
processComputable(clickable as T, "visibility");
|
||||||
|
setDefault(clickable, "visibility", Visibility.Visible);
|
||||||
|
processComputable(clickable as T, "canClick");
|
||||||
|
setDefault(clickable, "canClick", true);
|
||||||
|
processComputable(clickable as T, "classes");
|
||||||
|
processComputable(clickable as T, "style");
|
||||||
|
processComputable(clickable as T, "mark");
|
||||||
|
processComputable(clickable as T, "display");
|
||||||
|
|
||||||
|
clickable[GatherProps] = function (this: GenericClickable) {
|
||||||
|
const {
|
||||||
|
display,
|
||||||
|
visibility,
|
||||||
|
style,
|
||||||
|
classes,
|
||||||
|
onClick,
|
||||||
|
onHold,
|
||||||
|
canClick,
|
||||||
|
small,
|
||||||
|
mark,
|
||||||
|
id
|
||||||
|
} = this;
|
||||||
|
return {
|
||||||
|
display,
|
||||||
|
visibility,
|
||||||
|
style,
|
||||||
|
classes,
|
||||||
|
onClick,
|
||||||
|
onHold,
|
||||||
|
canClick,
|
||||||
|
small,
|
||||||
|
mark,
|
||||||
|
id
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return clickable as unknown as Clickable<T>;
|
||||||
|
});
|
||||||
|
}
|
253
src/features/conversion.ts
Normal file
|
@ -0,0 +1,253 @@
|
||||||
|
import { GenericLayer } from "@/game/layers";
|
||||||
|
import Decimal, { DecimalSource } from "@/util/bignum";
|
||||||
|
import {
|
||||||
|
Computable,
|
||||||
|
GetComputableTypeWithDefault,
|
||||||
|
processComputable,
|
||||||
|
ProcessedComputable
|
||||||
|
} from "@/util/computed";
|
||||||
|
import { createLazyProxy } from "@/util/proxies";
|
||||||
|
import { computed, isRef, Ref, unref } from "vue";
|
||||||
|
import { Replace, setDefault } from "./feature";
|
||||||
|
import { Resource } from "./resources/resource";
|
||||||
|
|
||||||
|
export interface ConversionOptions {
|
||||||
|
scaling: ScalingFunction;
|
||||||
|
currentGain?: Computable<DecimalSource>;
|
||||||
|
nextAt?: Computable<DecimalSource>;
|
||||||
|
baseResource: Resource;
|
||||||
|
gainResource: Resource;
|
||||||
|
buyMax?: Computable<boolean>;
|
||||||
|
roundUpCost?: Computable<boolean>;
|
||||||
|
convert?: VoidFunction;
|
||||||
|
modifyGainAmount?: (gain: DecimalSource) => DecimalSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseConversion {
|
||||||
|
convert: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Conversion<T extends ConversionOptions> = Replace<
|
||||||
|
T & BaseConversion,
|
||||||
|
{
|
||||||
|
currentGain: GetComputableTypeWithDefault<T["currentGain"], Ref<DecimalSource>>;
|
||||||
|
nextAt: GetComputableTypeWithDefault<T["nextAt"], Ref<DecimalSource>>;
|
||||||
|
buyMax: GetComputableTypeWithDefault<T["buyMax"], true>;
|
||||||
|
roundUpCost: GetComputableTypeWithDefault<T["roundUpCost"], true>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericConversion = Replace<
|
||||||
|
Conversion<ConversionOptions>,
|
||||||
|
{
|
||||||
|
currentGain: ProcessedComputable<DecimalSource>;
|
||||||
|
nextAt: ProcessedComputable<DecimalSource>;
|
||||||
|
buyMax: ProcessedComputable<boolean>;
|
||||||
|
roundUpCost: ProcessedComputable<boolean>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createConversion<T extends ConversionOptions>(
|
||||||
|
optionsFunc: () => T & ThisType<Conversion<T>>
|
||||||
|
): Conversion<T> {
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const conversion: T = optionsFunc();
|
||||||
|
|
||||||
|
if (conversion.currentGain == null) {
|
||||||
|
conversion.currentGain = computed(() =>
|
||||||
|
conversion.scaling.currentGain(conversion as GenericConversion)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (conversion.nextAt == null) {
|
||||||
|
conversion.nextAt = computed(() =>
|
||||||
|
conversion.scaling.nextAt(conversion as GenericConversion)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conversion.convert == null) {
|
||||||
|
conversion.convert = function () {
|
||||||
|
conversion.gainResource.value = Decimal.add(
|
||||||
|
conversion.gainResource.value,
|
||||||
|
conversion.modifyGainAmount
|
||||||
|
? conversion.modifyGainAmount(
|
||||||
|
unref((conversion as GenericConversion).currentGain)
|
||||||
|
)
|
||||||
|
: unref((conversion as GenericConversion).currentGain)
|
||||||
|
);
|
||||||
|
// TODO just subtract cost?
|
||||||
|
conversion.baseResource.value = 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
processComputable(conversion as T, "currentGain");
|
||||||
|
processComputable(conversion as T, "nextAt");
|
||||||
|
processComputable(conversion as T, "buyMax");
|
||||||
|
setDefault(conversion, "buyMax", true);
|
||||||
|
processComputable(conversion as T, "roundUpCost");
|
||||||
|
setDefault(conversion, "roundUpCost", true);
|
||||||
|
|
||||||
|
return conversion as unknown as Conversion<T>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScalingFunction = {
|
||||||
|
currentGain: (conversion: GenericConversion) => DecimalSource;
|
||||||
|
nextAt: (conversion: GenericConversion) => DecimalSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gain formula is (baseResource - base) * coefficient
|
||||||
|
// e.g. if base is 10 and coefficient is 0.5, 10 points makes 1 gain, 12 points is 2
|
||||||
|
export function createLinearScaling(
|
||||||
|
base: DecimalSource | Ref<DecimalSource>,
|
||||||
|
coefficient: DecimalSource | Ref<DecimalSource>
|
||||||
|
): ScalingFunction {
|
||||||
|
return {
|
||||||
|
currentGain(conversion) {
|
||||||
|
if (Decimal.lt(conversion.baseResource.value, unref(base))) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let gain = Decimal.sub(conversion.baseResource.value, unref(base))
|
||||||
|
.sub(1)
|
||||||
|
.times(unref(coefficient))
|
||||||
|
.add(1)
|
||||||
|
.floor()
|
||||||
|
.max(0);
|
||||||
|
|
||||||
|
if (!conversion.buyMax) {
|
||||||
|
gain = gain.min(1);
|
||||||
|
}
|
||||||
|
return gain;
|
||||||
|
},
|
||||||
|
nextAt(conversion) {
|
||||||
|
let next = Decimal.add(unref(conversion.currentGain), 1)
|
||||||
|
.times(unref(coefficient))
|
||||||
|
.add(unref(base))
|
||||||
|
.max(unref(base));
|
||||||
|
if (conversion.roundUpCost) next = next.ceil();
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gain formula is (baseResource / base) ^ exponent
|
||||||
|
// e.g. if exponent is 0.5 and base is 10, then having 10 points makes gain 1, and 40 points is 2
|
||||||
|
export function createExponentialScaling(
|
||||||
|
base: DecimalSource | Ref<DecimalSource>,
|
||||||
|
coefficient: DecimalSource | Ref<DecimalSource>,
|
||||||
|
exponent: DecimalSource | Ref<DecimalSource>
|
||||||
|
): ScalingFunction {
|
||||||
|
return {
|
||||||
|
currentGain(conversion) {
|
||||||
|
let gain = Decimal.div(conversion.baseResource.value, unref(base))
|
||||||
|
.pow(unref(exponent))
|
||||||
|
.floor()
|
||||||
|
.max(0);
|
||||||
|
|
||||||
|
if (gain.isNan()) {
|
||||||
|
return new Decimal(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!conversion.buyMax) {
|
||||||
|
gain = gain.min(1);
|
||||||
|
}
|
||||||
|
return gain;
|
||||||
|
},
|
||||||
|
nextAt(conversion) {
|
||||||
|
let next = Decimal.add(unref(conversion.currentGain), 1)
|
||||||
|
.root(unref(exponent))
|
||||||
|
.times(unref(base))
|
||||||
|
.max(unref(base));
|
||||||
|
if (conversion.roundUpCost) next = next.ceil();
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCumulativeConversion<S extends ConversionOptions>(
|
||||||
|
optionsFunc: () => S & ThisType<Conversion<S>>
|
||||||
|
): Conversion<S> {
|
||||||
|
return createConversion(optionsFunc);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIndependentConversion<S extends ConversionOptions>(
|
||||||
|
optionsFunc: () => S & ThisType<Conversion<S>>
|
||||||
|
): Conversion<S> {
|
||||||
|
return createConversion(() => {
|
||||||
|
const conversion: S = optionsFunc();
|
||||||
|
|
||||||
|
setDefault(conversion, "buyMax", false);
|
||||||
|
|
||||||
|
if (conversion.currentGain == null) {
|
||||||
|
conversion.currentGain = computed(() =>
|
||||||
|
Decimal.sub(
|
||||||
|
conversion.scaling.currentGain(conversion as GenericConversion),
|
||||||
|
conversion.gainResource.value
|
||||||
|
)
|
||||||
|
.add(1)
|
||||||
|
.max(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setDefault(conversion, "convert", function () {
|
||||||
|
conversion.gainResource.value = conversion.modifyGainAmount
|
||||||
|
? conversion.modifyGainAmount(unref((conversion as GenericConversion).currentGain))
|
||||||
|
: unref((conversion as GenericConversion).currentGain);
|
||||||
|
// TODO just subtract cost?
|
||||||
|
// Maybe by adding a cost function to scaling and nextAt just calls the cost function
|
||||||
|
// with 1 + currentGain
|
||||||
|
conversion.baseResource.value = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return conversion;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupPassiveGeneration(
|
||||||
|
layer: GenericLayer,
|
||||||
|
conversion: GenericConversion,
|
||||||
|
rate: ProcessedComputable<DecimalSource> = 1
|
||||||
|
): void {
|
||||||
|
layer.on("preUpdate", (diff: Decimal) => {
|
||||||
|
const currRate = isRef(rate) ? rate.value : rate;
|
||||||
|
if (Decimal.neq(currRate, 0)) {
|
||||||
|
conversion.gainResource.value = Decimal.add(
|
||||||
|
conversion.gainResource.value,
|
||||||
|
Decimal.times(currRate, diff).times(unref(conversion.currentGain))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function softcap(
|
||||||
|
value: DecimalSource,
|
||||||
|
cap: DecimalSource,
|
||||||
|
power: DecimalSource = 0.5
|
||||||
|
): DecimalSource {
|
||||||
|
if (Decimal.lte(value, cap)) {
|
||||||
|
return value;
|
||||||
|
} else {
|
||||||
|
return Decimal.pow(value, power).times(Decimal.pow(cap, Decimal.sub(1, power)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addSoftcap(
|
||||||
|
scaling: ScalingFunction,
|
||||||
|
cap: ProcessedComputable<DecimalSource>,
|
||||||
|
power: ProcessedComputable<DecimalSource> = 0.5
|
||||||
|
): ScalingFunction {
|
||||||
|
return {
|
||||||
|
...scaling,
|
||||||
|
currentGain: conversion =>
|
||||||
|
softcap(scaling.currentGain(conversion), unref(cap), unref(power))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addHardcap(
|
||||||
|
scaling: ScalingFunction,
|
||||||
|
cap: ProcessedComputable<DecimalSource>
|
||||||
|
): ScalingFunction {
|
||||||
|
return {
|
||||||
|
...scaling,
|
||||||
|
currentGain: conversion => Decimal.min(scaling.currentGain(conversion), unref(cap))
|
||||||
|
};
|
||||||
|
}
|
77
src/features/feature.ts
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
import { DefaultValue } from "@/game/persistence";
|
||||||
|
import Decimal from "@/util/bignum";
|
||||||
|
import { DoNotCache, ProcessedComputable } from "@/util/computed";
|
||||||
|
import { CSSProperties, DefineComponent, isRef } from "vue";
|
||||||
|
|
||||||
|
export const Component = Symbol("Component");
|
||||||
|
export const GatherProps = Symbol("GatherProps");
|
||||||
|
|
||||||
|
export type JSXFunction = (() => JSX.Element) & { [DoNotCache]: true };
|
||||||
|
export type CoercableComponent = string | DefineComponent | JSXFunction;
|
||||||
|
export type StyleValue = string | CSSProperties | Array<string | CSSProperties>;
|
||||||
|
|
||||||
|
// TODO if importing .vue components in .tsx can become type safe,
|
||||||
|
// this type can probably be safely removed
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export type GenericComponent = DefineComponent<any, any, any>;
|
||||||
|
|
||||||
|
export type FeatureComponent<T> = Omit<
|
||||||
|
{
|
||||||
|
[K in keyof T]: T[K] extends ProcessedComputable<infer S> ? S : T[K];
|
||||||
|
},
|
||||||
|
typeof Component | typeof DefaultValue
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type Replace<T, S> = S & Omit<T, keyof S>;
|
||||||
|
|
||||||
|
let id = 0;
|
||||||
|
// Get a unique ID to allow a feature to be found for creating branches
|
||||||
|
// and any other uses requiring unique identifiers for each feature
|
||||||
|
// IDs are gauranteed unique, but should not be saved as they are not
|
||||||
|
// guaranteed to be persistent through updates and such
|
||||||
|
export function getUniqueID(prefix = "feature-"): string {
|
||||||
|
return prefix + id++;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum Visibility {
|
||||||
|
Visible,
|
||||||
|
Hidden,
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jsx(func: () => JSX.Element | ""): JSXFunction {
|
||||||
|
(func as Partial<JSXFunction>)[DoNotCache] = true;
|
||||||
|
return func as JSXFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showIf(condition: boolean, otherwise = Visibility.None): Visibility {
|
||||||
|
return condition ? Visibility.Visible : otherwise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDefault<T, K extends keyof T>(
|
||||||
|
object: T,
|
||||||
|
key: K,
|
||||||
|
value: T[K]
|
||||||
|
): asserts object is Exclude<T, K> & Required<Pick<T, K>> {
|
||||||
|
if (object[key] === undefined && value != undefined) {
|
||||||
|
object[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findFeatures(obj: Record<string, unknown>, type: symbol): unknown[] {
|
||||||
|
const objects: unknown[] = [];
|
||||||
|
const handleObject = (obj: Record<string, unknown>) => {
|
||||||
|
Object.keys(obj).forEach(key => {
|
||||||
|
const value = obj[key];
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
if ((value as Record<string, unknown>).type === type) {
|
||||||
|
objects.push(value);
|
||||||
|
} else if (!(value instanceof Decimal) && !isRef(value)) {
|
||||||
|
handleObject(value as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
handleObject(obj);
|
||||||
|
return objects;
|
||||||
|
}
|
60
src/features/grids/Grid.vue
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="unref(visibility) !== Visibility.None"
|
||||||
|
:style="{
|
||||||
|
visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined
|
||||||
|
}"
|
||||||
|
class="table"
|
||||||
|
>
|
||||||
|
<div v-for="row in unref(rows)" class="row" :class="{ mergeAdjacent }" :key="row">
|
||||||
|
<GridCell
|
||||||
|
v-for="col in unref(cols)"
|
||||||
|
:key="col"
|
||||||
|
v-bind="gatherCellProps(unref(cells)[row * 100 + col])"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import "@/components/common/table.css";
|
||||||
|
import themes from "@/data/themes";
|
||||||
|
import { Visibility } from "@/features/feature";
|
||||||
|
import { GridCell } from "@/features/grids/grid";
|
||||||
|
import settings from "@/game/settings";
|
||||||
|
import { processedPropType } from "@/util/vue";
|
||||||
|
import { computed, defineComponent, unref } from "vue";
|
||||||
|
import GridCellVue from "./GridCell.vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
visibility: {
|
||||||
|
type: processedPropType<Visibility>(Number),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
rows: {
|
||||||
|
type: processedPropType<number>(Number),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
cols: {
|
||||||
|
type: processedPropType<number>(Number),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
cells: {
|
||||||
|
type: processedPropType<Record<string, GridCell>>(Object),
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: { GridCell: GridCellVue },
|
||||||
|
setup() {
|
||||||
|
const mergeAdjacent = computed(() => themes[settings.theme].mergeAdjacent);
|
||||||
|
|
||||||
|
function gatherCellProps(cell: GridCell) {
|
||||||
|
const { visibility, onClick, onHold, display, title, style, canClick, id } = cell;
|
||||||
|
return { visibility, onClick, onHold, display, title, style, canClick, id };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { unref, gatherCellProps, Visibility, mergeAdjacent };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
91
src/features/grids/GridCell.vue
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
v-if="unref(visibility) !== Visibility.None"
|
||||||
|
:class="{ feature: true, tile: true, can: unref(canClick), locked: !unref(canClick) }"
|
||||||
|
:style="[
|
||||||
|
{
|
||||||
|
visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined
|
||||||
|
},
|
||||||
|
unref(style) ?? {}
|
||||||
|
]"
|
||||||
|
@click="onClick"
|
||||||
|
@mousedown="start"
|
||||||
|
@mouseleave="stop"
|
||||||
|
@mouseup="stop"
|
||||||
|
@touchstart="start"
|
||||||
|
@touchend="stop"
|
||||||
|
@touchcancel="stop"
|
||||||
|
:disabled="!unref(canClick)"
|
||||||
|
>
|
||||||
|
<div v-if="title"><component :is="titleComponent" /></div>
|
||||||
|
<component :is="component" style="white-space: pre-line" />
|
||||||
|
<LinkNode :id="id" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import "@/components/common/features.css";
|
||||||
|
import LinkNode from "@/components/links/LinkNode.vue";
|
||||||
|
import { CoercableComponent, StyleValue, Visibility } from "@/features/feature";
|
||||||
|
import {
|
||||||
|
computeComponent,
|
||||||
|
computeOptionalComponent,
|
||||||
|
processedPropType,
|
||||||
|
setupHoldToClick
|
||||||
|
} from "@/util/vue";
|
||||||
|
import { defineComponent, PropType, toRefs, unref } from "vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
visibility: {
|
||||||
|
type: processedPropType<Visibility>(Number),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
onClick: Function as PropType<VoidFunction>,
|
||||||
|
onHold: Function as PropType<VoidFunction>,
|
||||||
|
display: {
|
||||||
|
type: processedPropType<CoercableComponent>(Object, String, Function),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
title: processedPropType<CoercableComponent>(Object, String, Function),
|
||||||
|
style: processedPropType<StyleValue>(String, Object, Array),
|
||||||
|
canClick: {
|
||||||
|
type: processedPropType<boolean>(Boolean),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
LinkNode
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const { onClick, onHold, title, display } = toRefs(props);
|
||||||
|
|
||||||
|
const { start, stop } = setupHoldToClick(onClick, onHold);
|
||||||
|
|
||||||
|
const titleComponent = computeOptionalComponent(title);
|
||||||
|
const component = computeComponent(display);
|
||||||
|
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
titleComponent,
|
||||||
|
component,
|
||||||
|
Visibility,
|
||||||
|
unref
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tile {
|
||||||
|
min-height: 80px;
|
||||||
|
width: 80px;
|
||||||
|
font-size: 10px;
|
||||||
|
background-color: var(--layer-color);
|
||||||
|
}
|
||||||
|
</style>
|
288
src/features/grids/grid.ts
Normal file
|
@ -0,0 +1,288 @@
|
||||||
|
import GridComponent from "@/features/grids/Grid.vue";
|
||||||
|
import {
|
||||||
|
CoercableComponent,
|
||||||
|
Component,
|
||||||
|
GatherProps,
|
||||||
|
getUniqueID,
|
||||||
|
Replace,
|
||||||
|
setDefault,
|
||||||
|
StyleValue,
|
||||||
|
Visibility
|
||||||
|
} from "@/features/feature";
|
||||||
|
import { isFunction } from "@/util/common";
|
||||||
|
import {
|
||||||
|
Computable,
|
||||||
|
GetComputableType,
|
||||||
|
GetComputableTypeWithDefault,
|
||||||
|
processComputable,
|
||||||
|
ProcessedComputable
|
||||||
|
} from "@/util/computed";
|
||||||
|
import { createLazyProxy } from "@/util/proxies";
|
||||||
|
import { computed, Ref, unref } from "vue";
|
||||||
|
import { State, Persistent, makePersistent, PersistentState } from "@/game/persistence";
|
||||||
|
|
||||||
|
export const GridType = Symbol("Grid");
|
||||||
|
|
||||||
|
export type CellComputable<T> = Computable<T> | ((id: string | number, state: State) => T);
|
||||||
|
|
||||||
|
function createGridProxy(grid: GenericGrid): Record<string | number, GridCell> {
|
||||||
|
return new Proxy({}, getGridHandler(grid)) as Record<string | number, GridCell>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function getGridHandler(grid: GenericGrid): ProxyHandler<Record<string | number, GridCell>> {
|
||||||
|
const keys = computed(() => {
|
||||||
|
const keys = [];
|
||||||
|
for (let row = 1; row <= unref(grid.rows); row++) {
|
||||||
|
for (let col = 1; col <= unref(grid.cols); col++) {
|
||||||
|
keys.push((row * 100 + col).toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
get(target: Record<string | number, GridCell>, key: PropertyKey) {
|
||||||
|
if (key === "isProxy") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof key === "symbol") {
|
||||||
|
return (grid as never)[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!keys.value.includes(key.toString())) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target[key] == null) {
|
||||||
|
target[key] = new Proxy(
|
||||||
|
grid,
|
||||||
|
getCellHandler(key.toString())
|
||||||
|
) as unknown as GridCell;
|
||||||
|
}
|
||||||
|
|
||||||
|
return target[key];
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
set(target: Record<string | number, GridCell>, key: PropertyKey, value: any) {
|
||||||
|
console.warn("Cannot set grid cells", target, key, value);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
ownKeys() {
|
||||||
|
return keys.value;
|
||||||
|
},
|
||||||
|
has(target: Record<string | number, GridCell>, key: PropertyKey) {
|
||||||
|
return keys.value.includes(key.toString());
|
||||||
|
},
|
||||||
|
getOwnPropertyDescriptor(target: Record<string | number, GridCell>, key: PropertyKey) {
|
||||||
|
if (keys.value.includes(key.toString())) {
|
||||||
|
return {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
writable: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCellHandler(id: string): ProxyHandler<GenericGrid> {
|
||||||
|
const keys = [
|
||||||
|
"id",
|
||||||
|
"visibility",
|
||||||
|
"canClick",
|
||||||
|
"startState",
|
||||||
|
"state",
|
||||||
|
"style",
|
||||||
|
"classes",
|
||||||
|
"title",
|
||||||
|
"display",
|
||||||
|
"onClick",
|
||||||
|
"onHold"
|
||||||
|
];
|
||||||
|
const cache: Record<string, Ref<unknown>> = {};
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
get(target, key, receiver): any {
|
||||||
|
if (key === "isProxy") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let prop = (target as any)[key];
|
||||||
|
|
||||||
|
if (isFunction(prop)) {
|
||||||
|
return () => prop.call(receiver, id, target.getState(id));
|
||||||
|
}
|
||||||
|
if (prop != undefined || typeof key === "symbol") {
|
||||||
|
return prop;
|
||||||
|
}
|
||||||
|
|
||||||
|
key = key.slice(0, 1).toUpperCase() + key.slice(1);
|
||||||
|
|
||||||
|
if (key === "startState") {
|
||||||
|
return prop.call(receiver, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
prop = (target as any)[`get${key}`];
|
||||||
|
if (isFunction(prop)) {
|
||||||
|
if (!(key in cache)) {
|
||||||
|
cache[key] = computed(() => prop.call(receiver, id, target.getState(id)));
|
||||||
|
}
|
||||||
|
return cache[key].value;
|
||||||
|
} else if (prop != undefined) {
|
||||||
|
return unref(prop);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
prop = (target as any)[`on${key}`];
|
||||||
|
if (isFunction(prop)) {
|
||||||
|
return () => prop.call(receiver, id, target.getState(id));
|
||||||
|
} else if (prop != undefined) {
|
||||||
|
return prop;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return (target as any)[key];
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
set(target: Record<string, any>, key: string, value: any, receiver: typeof Proxy): boolean {
|
||||||
|
key = `set${key.slice(0, 1).toUpperCase() + key.slice(1)}`;
|
||||||
|
if (key in target && isFunction(target[key]) && target[key].length < 3) {
|
||||||
|
target[key].call(receiver, id, value);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.warn(`No setter for "${key}".`, target);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ownKeys() {
|
||||||
|
return keys;
|
||||||
|
},
|
||||||
|
has(target, key) {
|
||||||
|
return keys.includes(key.toString());
|
||||||
|
},
|
||||||
|
getOwnPropertyDescriptor(target, key) {
|
||||||
|
if (keys.includes(key.toString())) {
|
||||||
|
return {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
writable: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridCell {
|
||||||
|
id: string;
|
||||||
|
visibility: Visibility;
|
||||||
|
canClick: boolean;
|
||||||
|
startState: State;
|
||||||
|
state: State;
|
||||||
|
style?: StyleValue;
|
||||||
|
classes?: Record<string, boolean>;
|
||||||
|
title?: CoercableComponent;
|
||||||
|
display: CoercableComponent;
|
||||||
|
onClick?: VoidFunction;
|
||||||
|
onHold?: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridOptions {
|
||||||
|
visibility?: Computable<Visibility>;
|
||||||
|
rows: Computable<number>;
|
||||||
|
cols: Computable<number>;
|
||||||
|
getVisibility?: CellComputable<Visibility>;
|
||||||
|
getCanClick?: CellComputable<boolean>;
|
||||||
|
getStartState: Computable<State> | ((id: string | number) => State);
|
||||||
|
getStyle?: CellComputable<StyleValue>;
|
||||||
|
getClasses?: CellComputable<Record<string, boolean>>;
|
||||||
|
getTitle?: CellComputable<CoercableComponent>;
|
||||||
|
getDisplay: CellComputable<CoercableComponent>;
|
||||||
|
onClick?: (id: string | number, state: State) => void;
|
||||||
|
onHold?: (id: string | number, state: State) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseGrid extends Persistent<Record<string | number, State>> {
|
||||||
|
id: string;
|
||||||
|
getID: (id: string | number, state: State) => string;
|
||||||
|
getState: (id: string | number) => State;
|
||||||
|
setState: (id: string | number, state: State) => void;
|
||||||
|
cells: Record<string | number, GridCell>;
|
||||||
|
type: typeof GridType;
|
||||||
|
[Component]: typeof GridComponent;
|
||||||
|
[GatherProps]: () => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Grid<T extends GridOptions> = Replace<
|
||||||
|
T & BaseGrid,
|
||||||
|
{
|
||||||
|
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||||
|
rows: GetComputableType<T["rows"]>;
|
||||||
|
cols: GetComputableType<T["cols"]>;
|
||||||
|
getVisibility: GetComputableTypeWithDefault<T["getVisibility"], Visibility.Visible>;
|
||||||
|
getCanClick: GetComputableTypeWithDefault<T["getCanClick"], true>;
|
||||||
|
getStartState: GetComputableType<T["getStartState"]>;
|
||||||
|
getStyle: GetComputableType<T["getStyle"]>;
|
||||||
|
getClasses: GetComputableType<T["getClasses"]>;
|
||||||
|
getTitle: GetComputableType<T["getTitle"]>;
|
||||||
|
getDisplay: GetComputableType<T["getDisplay"]>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericGrid = Replace<
|
||||||
|
Grid<GridOptions>,
|
||||||
|
{
|
||||||
|
visibility: ProcessedComputable<Visibility>;
|
||||||
|
getVisibility: ProcessedComputable<Visibility>;
|
||||||
|
getCanClick: ProcessedComputable<boolean>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createGrid<T extends GridOptions>(
|
||||||
|
optionsFunc: () => T & ThisType<Grid<T>>
|
||||||
|
): Grid<T> {
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const grid: T & Partial<BaseGrid> = optionsFunc();
|
||||||
|
makePersistent(grid, {});
|
||||||
|
grid.id = getUniqueID("grid-");
|
||||||
|
grid[Component] = GridComponent;
|
||||||
|
|
||||||
|
grid.getID = function (this: GenericGrid, cell: string | number) {
|
||||||
|
return grid.id + "-" + cell;
|
||||||
|
};
|
||||||
|
grid.getState = function (this: GenericGrid, cell: string | number) {
|
||||||
|
if (this[PersistentState].value[cell] != undefined) {
|
||||||
|
return this[PersistentState].value[cell];
|
||||||
|
}
|
||||||
|
return this.cells[cell].startState;
|
||||||
|
};
|
||||||
|
grid.setState = function (this: GenericGrid, cell: string | number, state: State) {
|
||||||
|
this[PersistentState].value[cell] = state;
|
||||||
|
};
|
||||||
|
|
||||||
|
grid.cells = createGridProxy(grid as GenericGrid);
|
||||||
|
|
||||||
|
processComputable(grid as T, "visibility");
|
||||||
|
setDefault(grid, "visibility", Visibility.Visible);
|
||||||
|
processComputable(grid as T, "rows");
|
||||||
|
processComputable(grid as T, "cols");
|
||||||
|
processComputable(grid as T, "getVisibility");
|
||||||
|
setDefault(grid, "getVisibility", Visibility.Visible);
|
||||||
|
processComputable(grid as T, "getCanClick");
|
||||||
|
setDefault(grid, "getCanClick", true);
|
||||||
|
processComputable(grid as T, "getStartState");
|
||||||
|
processComputable(grid as T, "getStyle");
|
||||||
|
processComputable(grid as T, "getClasses");
|
||||||
|
processComputable(grid as T, "getTitle");
|
||||||
|
processComputable(grid as T, "getDisplay");
|
||||||
|
|
||||||
|
grid[GatherProps] = function (this: GenericGrid) {
|
||||||
|
const { visibility, rows, cols, cells, id } = this;
|
||||||
|
return { visibility, rows, cols, cells, id };
|
||||||
|
};
|
||||||
|
|
||||||
|
return grid as unknown as Grid<T>;
|
||||||
|
});
|
||||||
|
}
|
90
src/features/hotkey.ts
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
import { hasWon } from "@/data/mod";
|
||||||
|
import { globalBus } from "@/game/events";
|
||||||
|
import player from "@/game/player";
|
||||||
|
import {
|
||||||
|
Computable,
|
||||||
|
GetComputableTypeWithDefault,
|
||||||
|
GetComputableType,
|
||||||
|
ProcessedComputable,
|
||||||
|
processComputable
|
||||||
|
} from "@/util/computed";
|
||||||
|
import { createLazyProxy } from "@/util/proxies";
|
||||||
|
import { unref } from "vue";
|
||||||
|
import { findFeatures, Replace, setDefault } from "./feature";
|
||||||
|
|
||||||
|
export const hotkeys: Record<string, GenericHotkey | undefined> = {};
|
||||||
|
export const HotkeyType = Symbol("Hotkey");
|
||||||
|
|
||||||
|
export interface HotkeyOptions {
|
||||||
|
enabled?: Computable<boolean>;
|
||||||
|
key: string;
|
||||||
|
description: Computable<string>;
|
||||||
|
onPress: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseHotkey {
|
||||||
|
type: typeof HotkeyType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Hotkey<T extends HotkeyOptions> = Replace<
|
||||||
|
T & BaseHotkey,
|
||||||
|
{
|
||||||
|
enabled: GetComputableTypeWithDefault<T["enabled"], true>;
|
||||||
|
description: GetComputableType<T["description"]>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericHotkey = Replace<
|
||||||
|
Hotkey<HotkeyOptions>,
|
||||||
|
{
|
||||||
|
enabled: ProcessedComputable<boolean>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createHotkey<T extends HotkeyOptions>(
|
||||||
|
optionsFunc: () => T & ThisType<Hotkey<T>>
|
||||||
|
): Hotkey<T> {
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const hotkey: T & Partial<BaseHotkey> = optionsFunc();
|
||||||
|
hotkey.type = HotkeyType;
|
||||||
|
|
||||||
|
processComputable(hotkey as T, "enabled");
|
||||||
|
setDefault(hotkey, "enabled", true);
|
||||||
|
processComputable(hotkey as T, "description");
|
||||||
|
|
||||||
|
return hotkey as unknown as Hotkey<T>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
globalBus.on("addLayer", layer => {
|
||||||
|
(findFeatures(layer, HotkeyType) as GenericHotkey[]).forEach(hotkey => {
|
||||||
|
hotkeys[hotkey.key] = hotkey;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
globalBus.on("removeLayer", layer => {
|
||||||
|
(findFeatures(layer, HotkeyType) as GenericHotkey[]).forEach(hotkey => {
|
||||||
|
hotkeys[hotkey.key] = undefined;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.onkeydown = function (e) {
|
||||||
|
if ((e.target as HTMLElement | null)?.tagName === "INPUT") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasWon.value && !player.keepGoing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let key = e.key;
|
||||||
|
if (e.shiftKey) {
|
||||||
|
key = "shift+" + key;
|
||||||
|
}
|
||||||
|
if (e.ctrlKey) {
|
||||||
|
key = "ctrl+" + key;
|
||||||
|
}
|
||||||
|
const hotkey = hotkeys[key];
|
||||||
|
if (hotkey && unref(hotkey.enabled)) {
|
||||||
|
e.preventDefault();
|
||||||
|
hotkey.onPress();
|
||||||
|
}
|
||||||
|
};
|
179
src/features/infoboxes/Infobox.vue
Normal file
|
@ -0,0 +1,179 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="infobox"
|
||||||
|
v-if="unref(visibility) !== Visibility.None"
|
||||||
|
:style="[
|
||||||
|
{
|
||||||
|
borderColor: unref(color),
|
||||||
|
visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined
|
||||||
|
},
|
||||||
|
unref(style) ?? {}
|
||||||
|
]"
|
||||||
|
:class="{ collapsed: unref(collapsed), stacked, ...unref(classes) }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="title"
|
||||||
|
:style="[{ backgroundColor: unref(color) }, unref(titleStyle) || []]"
|
||||||
|
@click="collapsed.value = !unref(collapsed)"
|
||||||
|
>
|
||||||
|
<span class="toggle">▼</span>
|
||||||
|
<component :is="titleComponent" />
|
||||||
|
</button>
|
||||||
|
<CollapseTransition>
|
||||||
|
<div v-if="!unref(collapsed)" class="body" :style="{ backgroundColor: unref(color) }">
|
||||||
|
<component :is="bodyComponent" :style="unref(bodyStyle)" />
|
||||||
|
</div>
|
||||||
|
</CollapseTransition>
|
||||||
|
<LinkNode :id="id" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import LinkNode from "@/components/links/LinkNode.vue";
|
||||||
|
import themes from "@/data/themes";
|
||||||
|
import { CoercableComponent, Visibility } from "@/features/feature";
|
||||||
|
import settings from "@/game/settings";
|
||||||
|
import { computeComponent, processedPropType } from "@/util/vue";
|
||||||
|
import CollapseTransition from "@ivanv/vue-collapse-transition/src/CollapseTransition.vue";
|
||||||
|
import { computed, defineComponent, PropType, Ref, StyleValue, toRefs, unref } from "vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
visibility: {
|
||||||
|
type: processedPropType<Visibility>(Number),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
display: {
|
||||||
|
type: processedPropType<CoercableComponent>(Object, String, Function),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: processedPropType<CoercableComponent>(Object, String, Function),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
color: processedPropType<string>(String),
|
||||||
|
collapsed: {
|
||||||
|
type: Object as PropType<Ref<boolean>>,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
style: processedPropType<StyleValue>(Object, String, Array),
|
||||||
|
titleStyle: processedPropType<StyleValue>(Object, String, Array),
|
||||||
|
bodyStyle: processedPropType<StyleValue>(Object, String, Array),
|
||||||
|
classes: processedPropType<Record<string, boolean>>(Object),
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
LinkNode,
|
||||||
|
CollapseTransition
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const { title, display } = toRefs(props);
|
||||||
|
|
||||||
|
const titleComponent = computeComponent(title);
|
||||||
|
const bodyComponent = computeComponent(display);
|
||||||
|
const stacked = computed(() => themes[settings.theme].stackedInfoboxes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
titleComponent,
|
||||||
|
bodyComponent,
|
||||||
|
stacked,
|
||||||
|
unref,
|
||||||
|
Visibility
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.infobox {
|
||||||
|
position: relative;
|
||||||
|
width: 600px;
|
||||||
|
max-width: 95%;
|
||||||
|
margin-top: 0;
|
||||||
|
text-align: left;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 0px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infobox.stacked {
|
||||||
|
border-width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infobox:not(.stacked) + .infobox:not(.stacked) {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infobox + :not(.infobox) {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 24px;
|
||||||
|
color: black;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
width: auto;
|
||||||
|
text-align: left;
|
||||||
|
padding-left: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infobox:not(.stacked) .title {
|
||||||
|
border-top-left-radius: 5px;
|
||||||
|
border-top-right-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infobox.stacked + .infobox.stacked {
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
margin-top: -5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked .title {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsed:not(.stacked) .title::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 4px;
|
||||||
|
background-color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsed .toggle {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
transition-duration: 0.5s;
|
||||||
|
border-radius: 5px;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infobox:not(.stacked) .body {
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body > * {
|
||||||
|
padding: 8px;
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 5px;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
background-color: var(--background);
|
||||||
|
}
|
||||||
|
</style>
|
116
src/features/infoboxes/infobox.ts
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
import InfoboxComponent from "@/features/infoboxes/Infobox.vue";
|
||||||
|
import {
|
||||||
|
CoercableComponent,
|
||||||
|
Component,
|
||||||
|
GatherProps,
|
||||||
|
getUniqueID,
|
||||||
|
Replace,
|
||||||
|
setDefault,
|
||||||
|
StyleValue,
|
||||||
|
Visibility
|
||||||
|
} from "@/features/feature";
|
||||||
|
import {
|
||||||
|
Computable,
|
||||||
|
GetComputableType,
|
||||||
|
GetComputableTypeWithDefault,
|
||||||
|
processComputable,
|
||||||
|
ProcessedComputable
|
||||||
|
} from "@/util/computed";
|
||||||
|
import { createLazyProxy } from "@/util/proxies";
|
||||||
|
import { Ref } from "vue";
|
||||||
|
import { Persistent, makePersistent, PersistentState } from "@/game/persistence";
|
||||||
|
|
||||||
|
export const InfoboxType = Symbol("Infobox");
|
||||||
|
|
||||||
|
export interface InfoboxOptions {
|
||||||
|
visibility?: Computable<Visibility>;
|
||||||
|
color?: Computable<string>;
|
||||||
|
style?: Computable<StyleValue>;
|
||||||
|
titleStyle?: Computable<StyleValue>;
|
||||||
|
bodyStyle?: Computable<StyleValue>;
|
||||||
|
classes?: Computable<Record<string, boolean>>;
|
||||||
|
title: Computable<CoercableComponent>;
|
||||||
|
display: Computable<CoercableComponent>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseInfobox extends Persistent<boolean> {
|
||||||
|
id: string;
|
||||||
|
collapsed: Ref<boolean>;
|
||||||
|
type: typeof InfoboxType;
|
||||||
|
[Component]: typeof InfoboxComponent;
|
||||||
|
[GatherProps]: () => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Infobox<T extends InfoboxOptions> = Replace<
|
||||||
|
T & BaseInfobox,
|
||||||
|
{
|
||||||
|
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||||
|
color: GetComputableType<T["color"]>;
|
||||||
|
style: GetComputableType<T["style"]>;
|
||||||
|
titleStyle: GetComputableType<T["titleStyle"]>;
|
||||||
|
bodyStyle: GetComputableType<T["bodyStyle"]>;
|
||||||
|
classes: GetComputableType<T["classes"]>;
|
||||||
|
title: GetComputableType<T["title"]>;
|
||||||
|
display: GetComputableType<T["display"]>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericInfobox = Replace<
|
||||||
|
Infobox<InfoboxOptions>,
|
||||||
|
{
|
||||||
|
visibility: ProcessedComputable<Visibility>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createInfobox<T extends InfoboxOptions>(
|
||||||
|
optionsFunc: () => T & ThisType<Infobox<T>>
|
||||||
|
): Infobox<T> {
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const infobox: T & Partial<BaseInfobox> = optionsFunc();
|
||||||
|
makePersistent<boolean>(infobox, false);
|
||||||
|
infobox.id = getUniqueID("infobox-");
|
||||||
|
infobox.type = InfoboxType;
|
||||||
|
infobox[Component] = InfoboxComponent;
|
||||||
|
|
||||||
|
infobox.collapsed = infobox[PersistentState];
|
||||||
|
|
||||||
|
processComputable(infobox as T, "visibility");
|
||||||
|
setDefault(infobox, "visibility", Visibility.Visible);
|
||||||
|
processComputable(infobox as T, "color");
|
||||||
|
processComputable(infobox as T, "style");
|
||||||
|
processComputable(infobox as T, "titleStyle");
|
||||||
|
processComputable(infobox as T, "bodyStyle");
|
||||||
|
processComputable(infobox as T, "classes");
|
||||||
|
processComputable(infobox as T, "title");
|
||||||
|
processComputable(infobox as T, "display");
|
||||||
|
|
||||||
|
infobox[GatherProps] = function (this: GenericInfobox) {
|
||||||
|
const {
|
||||||
|
visibility,
|
||||||
|
display,
|
||||||
|
title,
|
||||||
|
color,
|
||||||
|
collapsed,
|
||||||
|
style,
|
||||||
|
titleStyle,
|
||||||
|
bodyStyle,
|
||||||
|
classes,
|
||||||
|
id
|
||||||
|
} = this;
|
||||||
|
return {
|
||||||
|
visibility,
|
||||||
|
display,
|
||||||
|
title,
|
||||||
|
color,
|
||||||
|
collapsed,
|
||||||
|
style,
|
||||||
|
titleStyle,
|
||||||
|
bodyStyle,
|
||||||
|
classes,
|
||||||
|
id
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return infobox as unknown as Infobox<T>;
|
||||||
|
});
|
||||||
|
}
|
21
src/features/links.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import { Position } from "@/game/layers";
|
||||||
|
import { InjectionKey, SVGAttributes } from "vue";
|
||||||
|
|
||||||
|
export interface LinkNode {
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
element: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Link extends SVGAttributes {
|
||||||
|
startNode: { id: string };
|
||||||
|
endNode: { id: string };
|
||||||
|
offsetStart?: Position;
|
||||||
|
offsetEnd?: Position;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RegisterLinkNodeInjectionKey: InjectionKey<
|
||||||
|
(id: string, element: HTMLElement) => void
|
||||||
|
> = Symbol("RegisterLinkNode");
|
||||||
|
export const UnregisterLinkNodeInjectionKey: InjectionKey<(id: string) => void> =
|
||||||
|
Symbol("UnregisterLinkNode");
|
127
src/features/milestones/Milestone.vue
Normal file
|
@ -0,0 +1,127 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="unref(visibility) !== Visibility.None"
|
||||||
|
:style="[
|
||||||
|
{
|
||||||
|
visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined
|
||||||
|
},
|
||||||
|
unref(style) ?? {}
|
||||||
|
]"
|
||||||
|
:class="{ feature: true, milestone: true, done: unref(earned), ...unref(classes) }"
|
||||||
|
>
|
||||||
|
<component :is="unref(comp)" />
|
||||||
|
<LinkNode :id="id" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="tsx">
|
||||||
|
import "@/components/common/features.css";
|
||||||
|
import { jsx, StyleValue, Visibility } from "@/features/feature";
|
||||||
|
import { GenericMilestone } from "@/features/milestones/milestone";
|
||||||
|
import { coerceComponent, isCoercableComponent, processedPropType, unwrapRef } from "@/util/vue";
|
||||||
|
import { Component, defineComponent, shallowRef, toRefs, unref, UnwrapRef, watchEffect } from "vue";
|
||||||
|
import LinkNode from "../../components/links/LinkNode.vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
visibility: {
|
||||||
|
type: processedPropType<Visibility>(Number),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
display: {
|
||||||
|
type: processedPropType<UnwrapRef<GenericMilestone["display"]>>(
|
||||||
|
String,
|
||||||
|
Object,
|
||||||
|
Function
|
||||||
|
),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
style: processedPropType<StyleValue>(String, Object, Array),
|
||||||
|
classes: processedPropType<Record<string, boolean>>(Object),
|
||||||
|
earned: {
|
||||||
|
type: processedPropType<boolean>(Boolean),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
LinkNode
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const { display } = toRefs(props);
|
||||||
|
|
||||||
|
const comp = shallowRef<Component | string>("");
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
const currDisplay = unwrapRef(display);
|
||||||
|
if (currDisplay == null) {
|
||||||
|
comp.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isCoercableComponent(currDisplay)) {
|
||||||
|
comp.value = coerceComponent(currDisplay);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const Requirement = coerceComponent(currDisplay.requirement, "h3");
|
||||||
|
const EffectDisplay = coerceComponent(currDisplay.effectDisplay || "", "b");
|
||||||
|
const OptionsDisplay = coerceComponent(currDisplay.optionsDisplay || "", "span");
|
||||||
|
comp.value = coerceComponent(
|
||||||
|
jsx(() => (
|
||||||
|
<span>
|
||||||
|
<Requirement />
|
||||||
|
{currDisplay.effectDisplay ? (
|
||||||
|
<div>
|
||||||
|
<EffectDisplay />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{currDisplay.optionsDisplay ? (
|
||||||
|
<div class="equal-spaced">
|
||||||
|
<OptionsDisplay />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
comp,
|
||||||
|
unref,
|
||||||
|
Visibility
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.milestone {
|
||||||
|
width: calc(100% - 10px);
|
||||||
|
min-width: 120px;
|
||||||
|
padding-left: 5px;
|
||||||
|
padding-right: 5px;
|
||||||
|
min-height: 75px;
|
||||||
|
background-color: var(--locked);
|
||||||
|
border-width: 4px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: rgba(0, 0, 0, 0.5);
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone.done {
|
||||||
|
background-color: var(--bought);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone >>> .equal-spaced {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone >>> .equal-spaced > * {
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
</style>
|
207
src/features/milestones/milestone.tsx
Normal file
|
@ -0,0 +1,207 @@
|
||||||
|
import Select from "@/components/fields/Select.vue";
|
||||||
|
import {
|
||||||
|
CoercableComponent,
|
||||||
|
Component,
|
||||||
|
findFeatures,
|
||||||
|
GatherProps,
|
||||||
|
getUniqueID,
|
||||||
|
jsx,
|
||||||
|
Replace,
|
||||||
|
setDefault,
|
||||||
|
StyleValue,
|
||||||
|
Visibility
|
||||||
|
} from "@/features/feature";
|
||||||
|
import MilestoneComponent from "@/features/milestones/Milestone.vue";
|
||||||
|
import { globalBus } from "@/game/events";
|
||||||
|
import "@/game/notifications";
|
||||||
|
import { makePersistent, Persistent, PersistentState } from "@/game/persistence";
|
||||||
|
import settings, { registerSettingField } from "@/game/settings";
|
||||||
|
import { camelToTitle } from "@/util/common";
|
||||||
|
import {
|
||||||
|
Computable,
|
||||||
|
GetComputableType,
|
||||||
|
GetComputableTypeWithDefault,
|
||||||
|
processComputable,
|
||||||
|
ProcessedComputable
|
||||||
|
} from "@/util/computed";
|
||||||
|
import { createLazyProxy } from "@/util/proxies";
|
||||||
|
import { coerceComponent, isCoercableComponent } from "@/util/vue";
|
||||||
|
import { Unsubscribe } from "nanoevents";
|
||||||
|
import { computed, Ref, unref } from "vue";
|
||||||
|
import { useToast } from "vue-toastification";
|
||||||
|
|
||||||
|
export const MilestoneType = Symbol("Milestone");
|
||||||
|
|
||||||
|
export enum MilestoneDisplay {
|
||||||
|
All = "all",
|
||||||
|
//Last = "last",
|
||||||
|
Configurable = "configurable",
|
||||||
|
Incomplete = "incomplete",
|
||||||
|
None = "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MilestoneOptions {
|
||||||
|
visibility?: Computable<Visibility>;
|
||||||
|
shouldEarn: Computable<boolean>;
|
||||||
|
style?: Computable<StyleValue>;
|
||||||
|
classes?: Computable<Record<string, boolean>>;
|
||||||
|
display?: Computable<
|
||||||
|
| CoercableComponent
|
||||||
|
| {
|
||||||
|
requirement: CoercableComponent;
|
||||||
|
effectDisplay?: CoercableComponent;
|
||||||
|
optionsDisplay?: CoercableComponent;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
onComplete?: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseMilestone extends Persistent<boolean> {
|
||||||
|
id: string;
|
||||||
|
earned: Ref<boolean>;
|
||||||
|
type: typeof MilestoneType;
|
||||||
|
[Component]: typeof MilestoneComponent;
|
||||||
|
[GatherProps]: () => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Milestone<T extends MilestoneOptions> = Replace<
|
||||||
|
T & BaseMilestone,
|
||||||
|
{
|
||||||
|
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||||
|
shouldEarn: GetComputableType<T["shouldEarn"]>;
|
||||||
|
style: GetComputableType<T["style"]>;
|
||||||
|
classes: GetComputableType<T["classes"]>;
|
||||||
|
display: GetComputableType<T["display"]>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericMilestone = Replace<
|
||||||
|
Milestone<MilestoneOptions>,
|
||||||
|
{
|
||||||
|
visibility: ProcessedComputable<Visibility>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createMilestone<T extends MilestoneOptions>(
|
||||||
|
optionsFunc: () => T & ThisType<Milestone<T>>
|
||||||
|
): Milestone<T> {
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const milestone: T & Partial<BaseMilestone> = optionsFunc();
|
||||||
|
makePersistent<boolean>(milestone, false);
|
||||||
|
milestone.id = getUniqueID("milestone-");
|
||||||
|
milestone.type = MilestoneType;
|
||||||
|
milestone[Component] = MilestoneComponent;
|
||||||
|
|
||||||
|
milestone.earned = milestone[PersistentState];
|
||||||
|
processComputable(milestone as T, "visibility");
|
||||||
|
setDefault(milestone, "visibility", Visibility.Visible);
|
||||||
|
const visibility = milestone.visibility as ProcessedComputable<Visibility>;
|
||||||
|
milestone.visibility = computed(() => {
|
||||||
|
const display = unref((milestone as GenericMilestone).display);
|
||||||
|
switch (settings.msDisplay) {
|
||||||
|
default:
|
||||||
|
case MilestoneDisplay.All:
|
||||||
|
return unref(visibility);
|
||||||
|
case MilestoneDisplay.Configurable:
|
||||||
|
if (
|
||||||
|
unref(milestone.earned) &&
|
||||||
|
!(
|
||||||
|
display != null &&
|
||||||
|
typeof display == "object" &&
|
||||||
|
"optionsDisplay" in (display as Record<string, unknown>)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return Visibility.None;
|
||||||
|
}
|
||||||
|
return unref(visibility);
|
||||||
|
case MilestoneDisplay.Incomplete:
|
||||||
|
if (unref(milestone.earned)) {
|
||||||
|
return Visibility.None;
|
||||||
|
}
|
||||||
|
return unref(visibility);
|
||||||
|
case MilestoneDisplay.None:
|
||||||
|
return Visibility.None;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
processComputable(milestone as T, "shouldEarn");
|
||||||
|
processComputable(milestone as T, "style");
|
||||||
|
processComputable(milestone as T, "classes");
|
||||||
|
processComputable(milestone as T, "display");
|
||||||
|
|
||||||
|
milestone[GatherProps] = function (this: GenericMilestone) {
|
||||||
|
const { visibility, display, style, classes, earned, id } = this;
|
||||||
|
return { visibility, display, style, classes, earned, id };
|
||||||
|
};
|
||||||
|
|
||||||
|
return milestone as unknown as Milestone<T>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const listeners: Record<string, Unsubscribe | undefined> = {};
|
||||||
|
globalBus.on("addLayer", layer => {
|
||||||
|
const milestones: GenericMilestone[] = (
|
||||||
|
findFeatures(layer, MilestoneType) as GenericMilestone[]
|
||||||
|
).filter(milestone => milestone.shouldEarn != null);
|
||||||
|
listeners[layer.id] = layer.on("postUpdate", () => {
|
||||||
|
milestones.forEach(milestone => {
|
||||||
|
if (
|
||||||
|
unref(milestone.visibility) === Visibility.Visible &&
|
||||||
|
!milestone.earned.value &&
|
||||||
|
unref(milestone.shouldEarn)
|
||||||
|
) {
|
||||||
|
milestone[PersistentState].value = true;
|
||||||
|
milestone.onComplete?.();
|
||||||
|
if (milestone.display) {
|
||||||
|
const display = unref(milestone.display);
|
||||||
|
const Display = coerceComponent(
|
||||||
|
isCoercableComponent(display) ? display : display.requirement
|
||||||
|
);
|
||||||
|
toast(
|
||||||
|
<>
|
||||||
|
<h3>Milestone earned!</h3>
|
||||||
|
<div>
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
<Display />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
globalBus.on("removeLayer", layer => {
|
||||||
|
// unsubscribe from postUpdate
|
||||||
|
listeners[layer.id]?.();
|
||||||
|
listeners[layer.id] = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
declare module "@/game/settings" {
|
||||||
|
interface Settings {
|
||||||
|
msDisplay: MilestoneDisplay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
globalBus.on("loadSettings", settings => {
|
||||||
|
setDefault(settings, "msDisplay", MilestoneDisplay.All);
|
||||||
|
});
|
||||||
|
|
||||||
|
const msDisplayOptions = Object.values(MilestoneDisplay).map(option => ({
|
||||||
|
label: camelToTitle(option),
|
||||||
|
value: option
|
||||||
|
}));
|
||||||
|
|
||||||
|
registerSettingField(
|
||||||
|
jsx(() => (
|
||||||
|
<Select
|
||||||
|
title="Show Milestones"
|
||||||
|
options={msDisplayOptions}
|
||||||
|
onUpdate:modelValue={value => (settings.msDisplay = value as MilestoneDisplay)}
|
||||||
|
modelValue={settings.msDisplay}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
);
|
109
src/features/reset.ts
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
import {
|
||||||
|
DefaultValue,
|
||||||
|
getUniqueID,
|
||||||
|
Persistent,
|
||||||
|
persistent,
|
||||||
|
PersistentRef,
|
||||||
|
PersistentState,
|
||||||
|
Replace
|
||||||
|
} from "@/features/feature";
|
||||||
|
import { globalBus } from "@/game/events";
|
||||||
|
import { GenericLayer } from "@/game/layers";
|
||||||
|
import Decimal from "@/lib/break_eternity";
|
||||||
|
import { Computable, GetComputableType, processComputable } from "@/util/computed";
|
||||||
|
import { createLazyProxy } from "@/util/proxies";
|
||||||
|
import { Unsubscribe } from "nanoevents";
|
||||||
|
import { computed, isRef, unref } from "vue";
|
||||||
|
|
||||||
|
export const ResetType = Symbol("Reset");
|
||||||
|
|
||||||
|
export interface ResetOptions {
|
||||||
|
thingsToReset: Computable<Record<string, unknown>[]>;
|
||||||
|
onReset?: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseReset {
|
||||||
|
id: string;
|
||||||
|
reset: VoidFunction;
|
||||||
|
type: typeof ResetType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Reset<T extends ResetOptions> = Replace<
|
||||||
|
T & BaseReset,
|
||||||
|
{
|
||||||
|
thingsToReset: GetComputableType<T["thingsToReset"]>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericReset = Reset<ResetOptions>;
|
||||||
|
|
||||||
|
export function createReset<T extends ResetOptions>(
|
||||||
|
optionsFunc: () => T & ThisType<Reset<T>>
|
||||||
|
): Reset<T> {
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const reset: T & Partial<BaseReset> = optionsFunc();
|
||||||
|
reset.id = getUniqueID("reset-");
|
||||||
|
reset.type = ResetType;
|
||||||
|
|
||||||
|
reset.reset = function () {
|
||||||
|
const handleObject = (obj: unknown) => {
|
||||||
|
if (obj && typeof obj === "object") {
|
||||||
|
if (PersistentState in obj) {
|
||||||
|
(obj as Persistent)[PersistentState].value = (obj as Persistent)[
|
||||||
|
DefaultValue
|
||||||
|
];
|
||||||
|
} else if (!(obj instanceof Decimal) && !isRef(obj)) {
|
||||||
|
Object.values(obj).forEach(obj =>
|
||||||
|
handleObject(obj as Record<string, unknown>)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
unref((reset as GenericReset).thingsToReset).forEach(handleObject);
|
||||||
|
globalBus.emit("reset", reset as GenericReset);
|
||||||
|
reset.onReset?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
processComputable(reset as T, "thingsToReset");
|
||||||
|
|
||||||
|
return reset as unknown as Reset<T>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupAutoReset(
|
||||||
|
layer: GenericLayer,
|
||||||
|
reset: GenericReset,
|
||||||
|
autoActive: Computable<boolean> = true
|
||||||
|
): Unsubscribe {
|
||||||
|
const isActive = typeof autoActive === "function" ? computed(autoActive) : autoActive;
|
||||||
|
return layer.on("update", () => {
|
||||||
|
if (unref(isActive)) {
|
||||||
|
reset.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners: Record<string, Unsubscribe | undefined> = {};
|
||||||
|
export function trackResetTime(layer: GenericLayer, reset: GenericReset): PersistentRef<Decimal> {
|
||||||
|
const resetTime = persistent<Decimal>(new Decimal(0));
|
||||||
|
listeners[layer.id] = layer.on("preUpdate", (diff: Decimal) => {
|
||||||
|
resetTime.value = Decimal.add(resetTime.value, diff);
|
||||||
|
});
|
||||||
|
globalBus.on("reset", currentReset => {
|
||||||
|
if (currentReset === reset) {
|
||||||
|
resetTime.value = new Decimal(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return resetTime;
|
||||||
|
}
|
||||||
|
globalBus.on("removeLayer", layer => {
|
||||||
|
// unsubscribe from preUpdate
|
||||||
|
listeners[layer.id]?.();
|
||||||
|
listeners[layer.id] = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
declare module "@/game/events" {
|
||||||
|
interface GlobalEvents {
|
||||||
|
reset: (reset: GenericReset) => void;
|
||||||
|
}
|
||||||
|
}
|
38
src/features/resources/MainDisplay.vue
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<span v-if="showPrefix">You have </span>
|
||||||
|
<ResourceVue :resource="resource" :color="color || 'white'" />
|
||||||
|
{{ resource.displayName
|
||||||
|
}}<!-- remove whitespace -->
|
||||||
|
<span v-if="effectComponent">, <component :is="effectComponent" /></span>
|
||||||
|
<br /><br />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { CoercableComponent } from "@/features/feature";
|
||||||
|
import { Resource } from "@/features/resources/resource";
|
||||||
|
import Decimal from "@/util/bignum";
|
||||||
|
import { computeOptionalComponent } from "@/util/vue";
|
||||||
|
import { computed, Ref, StyleValue, toRefs } from "vue";
|
||||||
|
import ResourceVue from "@/features/resources/Resource.vue";
|
||||||
|
|
||||||
|
const _props = defineProps<{
|
||||||
|
resource: Resource;
|
||||||
|
color?: string;
|
||||||
|
classes?: Record<string, boolean>;
|
||||||
|
style?: StyleValue;
|
||||||
|
effectDisplay?: CoercableComponent;
|
||||||
|
}>();
|
||||||
|
const props = toRefs(_props);
|
||||||
|
|
||||||
|
const effectComponent = computeOptionalComponent(
|
||||||
|
props.effectDisplay as Ref<CoercableComponent | undefined>
|
||||||
|
);
|
||||||
|
|
||||||
|
const showPrefix = computed(() => {
|
||||||
|
return Decimal.lt(props.resource.value, "1e1000");
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
17
src/features/resources/Resource.vue
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
<template>
|
||||||
|
<h2 :style="{ color, 'text-shadow': '0px 0px 10px ' + color }">
|
||||||
|
{{ amount }}
|
||||||
|
</h2>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { displayResource, Resource } from "@/features/resources/resource";
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
resource: Resource;
|
||||||
|
color: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const amount = computed(() => displayResource(props.resource));
|
||||||
|
</script>
|
109
src/features/resources/resource.ts
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
import Decimal, { DecimalSource, format, formatWhole } from "@/util/bignum";
|
||||||
|
import { computed, ComputedRef, ref, Ref, watch } from "vue";
|
||||||
|
import { globalBus } from "@/game/events";
|
||||||
|
import { State, persistent } from "@/game/persistence";
|
||||||
|
|
||||||
|
export interface Resource<T = DecimalSource> extends Ref<T> {
|
||||||
|
displayName: string;
|
||||||
|
precision: number;
|
||||||
|
small: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createResource<T extends State>(
|
||||||
|
defaultValue: T | Ref<T>,
|
||||||
|
displayName = "points",
|
||||||
|
precision = 0,
|
||||||
|
small = false
|
||||||
|
): Resource<T> {
|
||||||
|
const resource: Partial<Resource<T>> = persistent(defaultValue);
|
||||||
|
resource.displayName = displayName;
|
||||||
|
resource.precision = precision;
|
||||||
|
resource.small = small;
|
||||||
|
return resource as Resource<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackBest(resource: Resource): Ref<DecimalSource> {
|
||||||
|
const best = persistent(resource.value);
|
||||||
|
watch(resource, amount => {
|
||||||
|
if (Decimal.gt(amount, best.value)) {
|
||||||
|
best.value = amount;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackTotal(resource: Resource): Ref<DecimalSource> {
|
||||||
|
const total = persistent(resource.value);
|
||||||
|
watch(resource, (amount, prevAmount) => {
|
||||||
|
if (Decimal.gt(amount, prevAmount)) {
|
||||||
|
total.value = Decimal.add(total.value, Decimal.sub(amount, prevAmount));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackOOMPS(
|
||||||
|
resource: Resource,
|
||||||
|
pointGain?: ComputedRef<DecimalSource>
|
||||||
|
): Ref<string> {
|
||||||
|
const oomps = ref<DecimalSource>(0);
|
||||||
|
const oompsMag = ref(0);
|
||||||
|
const lastPoints = ref<DecimalSource>(0);
|
||||||
|
|
||||||
|
globalBus.on("update", diff => {
|
||||||
|
oompsMag.value = 0;
|
||||||
|
if (Decimal.lte(resource.value, 1e100)) {
|
||||||
|
lastPoints.value = resource.value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let curr = resource.value;
|
||||||
|
let prev = lastPoints.value;
|
||||||
|
lastPoints.value = curr;
|
||||||
|
if (Decimal.gt(curr, prev)) {
|
||||||
|
if (Decimal.gte(curr, "10^^8")) {
|
||||||
|
curr = Decimal.slog(curr, 1e10);
|
||||||
|
prev = Decimal.slog(prev, 1e10);
|
||||||
|
oomps.value = curr.sub(prev).div(diff);
|
||||||
|
oompsMag.value = -1;
|
||||||
|
} else {
|
||||||
|
while (
|
||||||
|
Decimal.div(curr, prev).log(10).div(diff).gte("100") &&
|
||||||
|
oompsMag.value <= 5 &&
|
||||||
|
Decimal.gt(prev, 0)
|
||||||
|
) {
|
||||||
|
curr = Decimal.log10(curr);
|
||||||
|
prev = Decimal.log10(prev);
|
||||||
|
oomps.value = curr.sub(prev).div(diff);
|
||||||
|
oompsMag.value++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const oompsString = computed(() => {
|
||||||
|
if (oompsMag.value === 0) {
|
||||||
|
return pointGain
|
||||||
|
? format(pointGain.value, resource.precision, resource.small) +
|
||||||
|
" " +
|
||||||
|
resource.displayName +
|
||||||
|
"/s"
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
format(oomps.value) +
|
||||||
|
" OOM" +
|
||||||
|
(oompsMag.value < 0 ? "^OOM" : "^" + oompsMag.value) +
|
||||||
|
"s/sec"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return oompsString;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayResource(resource: Resource, overrideAmount?: DecimalSource): string {
|
||||||
|
const amount = overrideAmount ?? resource.value;
|
||||||
|
if (Decimal.eq(resource.precision, 0)) {
|
||||||
|
return formatWhole(amount);
|
||||||
|
}
|
||||||
|
return format(amount, resource.precision, resource.small);
|
||||||
|
}
|
13
src/features/tabs/Tab.vue
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<template>
|
||||||
|
<component :is="component" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { CoercableComponent } from "@/features/feature";
|
||||||
|
import { computeComponent } from "@/util/vue";
|
||||||
|
import { toRefs } from "vue";
|
||||||
|
|
||||||
|
const _props = defineProps<{ display: CoercableComponent }>();
|
||||||
|
const { display } = toRefs(_props);
|
||||||
|
const component = computeComponent(display);
|
||||||
|
</script>
|
109
src/features/tabs/TabButton.vue
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
v-if="unref(visibility) !== Visibility.None"
|
||||||
|
@click="selectTab"
|
||||||
|
class="tabButton"
|
||||||
|
:style="[
|
||||||
|
{
|
||||||
|
visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined
|
||||||
|
},
|
||||||
|
glowColorStyle,
|
||||||
|
unref(style) ?? {}
|
||||||
|
]"
|
||||||
|
:class="{
|
||||||
|
active,
|
||||||
|
...unref(classes)
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<component :is="component" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { CoercableComponent, StyleValue, Visibility } from "@/features/feature";
|
||||||
|
import { getNotifyStyle } from "@/game/notifications";
|
||||||
|
import { computeComponent, processedPropType, unwrapRef } from "@/util/vue";
|
||||||
|
import { computed, defineComponent, toRefs, unref } from "vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
visibility: {
|
||||||
|
type: processedPropType<Visibility>(Number),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
display: {
|
||||||
|
type: processedPropType<CoercableComponent>(Object, String, Function),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
style: processedPropType<StyleValue>(String, Object, Array),
|
||||||
|
classes: processedPropType<Record<string, boolean>>(Object),
|
||||||
|
glowColor: processedPropType<string>(String),
|
||||||
|
active: Boolean,
|
||||||
|
floating: Boolean
|
||||||
|
},
|
||||||
|
emits: ["selectTab"],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const { display, glowColor, floating } = toRefs(props);
|
||||||
|
|
||||||
|
const component = computeComponent(display);
|
||||||
|
|
||||||
|
const glowColorStyle = computed(() => {
|
||||||
|
const color = unwrapRef(glowColor);
|
||||||
|
if (!color) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (unref(floating)) {
|
||||||
|
return getNotifyStyle(color);
|
||||||
|
}
|
||||||
|
return { boxShadow: `0px 9px 5px -6px ${color}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectTab() {
|
||||||
|
emit("selectTab");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectTab,
|
||||||
|
component,
|
||||||
|
glowColorStyle,
|
||||||
|
unref,
|
||||||
|
Visibility
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tabButton {
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px 20px;
|
||||||
|
margin: 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 2px solid;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-color: var(--layer-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabButton:hover {
|
||||||
|
transform: scale(1.1, 1.1);
|
||||||
|
text-shadow: 0 0 7px var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
:not(.floating) > .tabButton {
|
||||||
|
height: 50px;
|
||||||
|
margin: 0;
|
||||||
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
|
border-top: none;
|
||||||
|
border-bottom-width: 4px;
|
||||||
|
border-radius: 0;
|
||||||
|
transform: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
:not(.floating) .tabButton:not(.active) {
|
||||||
|
border-bottom-color: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
249
src/features/tabs/TabFamily.vue
Normal file
|
@ -0,0 +1,249 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="unref(visibility) !== Visibility.None"
|
||||||
|
class="tab-family-container"
|
||||||
|
:class="{ ...unref(classes), ...tabClasses }"
|
||||||
|
:style="[
|
||||||
|
{
|
||||||
|
visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined
|
||||||
|
},
|
||||||
|
unref(style) ?? [],
|
||||||
|
tabStyle ?? []
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<Sticky class="tab-buttons-container">
|
||||||
|
<div class="tab-buttons" :class="{ floating }">
|
||||||
|
<TabButton
|
||||||
|
v-for="(button, id) in unref(tabs)"
|
||||||
|
@selectTab="selected.value = id"
|
||||||
|
:floating="floating"
|
||||||
|
:key="id"
|
||||||
|
:active="unref(button.tab) === unref(activeTab)"
|
||||||
|
v-bind="gatherButtonProps(button)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Sticky>
|
||||||
|
<template v-if="unref(activeTab)">
|
||||||
|
<component :is="unref(component)" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Sticky from "@/components/layout/Sticky.vue";
|
||||||
|
import themes from "@/data/themes";
|
||||||
|
import { CoercableComponent, StyleValue, Visibility } from "@/features/feature";
|
||||||
|
import { GenericTab } from "@/features/tabs/tab";
|
||||||
|
import TabButton from "@/features/tabs/TabButton.vue";
|
||||||
|
import { GenericTabButton } from "@/features/tabs/tabFamily";
|
||||||
|
import settings from "@/game/settings";
|
||||||
|
import { coerceComponent, isCoercableComponent, processedPropType, unwrapRef } from "@/util/vue";
|
||||||
|
import {
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
defineComponent,
|
||||||
|
PropType,
|
||||||
|
Ref,
|
||||||
|
shallowRef,
|
||||||
|
toRefs,
|
||||||
|
unref,
|
||||||
|
watchEffect
|
||||||
|
} from "vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
visibility: {
|
||||||
|
type: processedPropType<Visibility>(Number),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
activeTab: {
|
||||||
|
type: processedPropType<GenericTab | CoercableComponent | null>(Object),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
selected: {
|
||||||
|
type: Object as PropType<Ref<string>>,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
tabs: {
|
||||||
|
type: processedPropType<Record<string, GenericTabButton>>(Object),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
style: processedPropType<StyleValue>(String, Object, Array),
|
||||||
|
classes: processedPropType<Record<string, boolean>>(Object)
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Sticky,
|
||||||
|
TabButton
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const { activeTab } = toRefs(props);
|
||||||
|
|
||||||
|
const floating = computed(() => {
|
||||||
|
return themes[settings.theme].floatingTabs;
|
||||||
|
});
|
||||||
|
|
||||||
|
const component = shallowRef<Component | string>("");
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
const currActiveTab = unwrapRef(activeTab);
|
||||||
|
if (currActiveTab == null) {
|
||||||
|
component.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isCoercableComponent(currActiveTab)) {
|
||||||
|
component.value = coerceComponent(currActiveTab);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
component.value = coerceComponent(unref(currActiveTab.display));
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabClasses = computed(() => {
|
||||||
|
const currActiveTab = unwrapRef(activeTab);
|
||||||
|
const tabClasses =
|
||||||
|
isCoercableComponent(currActiveTab) || !currActiveTab
|
||||||
|
? undefined
|
||||||
|
: unref(currActiveTab.classes);
|
||||||
|
return tabClasses;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabStyle = computed(() => {
|
||||||
|
const currActiveTab = unwrapRef(activeTab);
|
||||||
|
return isCoercableComponent(currActiveTab) || !currActiveTab
|
||||||
|
? undefined
|
||||||
|
: unref(currActiveTab.style);
|
||||||
|
});
|
||||||
|
|
||||||
|
function gatherButtonProps(button: GenericTabButton) {
|
||||||
|
const { display, style, classes, glowColor, visibility } = button;
|
||||||
|
return { display, style, classes, glowColor, visibility };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
floating,
|
||||||
|
tabClasses,
|
||||||
|
tabStyle,
|
||||||
|
Visibility,
|
||||||
|
component,
|
||||||
|
gatherButtonProps,
|
||||||
|
unref
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tab-family-container {
|
||||||
|
margin: calc(50px + var(--feature-margin)) 20px var(--feature-margin) 20px;
|
||||||
|
position: relative;
|
||||||
|
border: solid 4px;
|
||||||
|
border-color: var(--outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-tab > .tab-family-container:first-child {
|
||||||
|
margin: -4px -11px var(--feature-margin) -11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-tab > .tab-family-container:first-child:nth-last-child(3) {
|
||||||
|
border-bottom-style: none;
|
||||||
|
border-left-style: none;
|
||||||
|
border-right-style: none;
|
||||||
|
height: calc(100% + 50px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-family-container > :nth-child(2) {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-family-container[data-v-f18896fc] > :last-child {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-family-container .sticky {
|
||||||
|
margin-left: -3px !important;
|
||||||
|
margin-right: -3px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-buttons-container {
|
||||||
|
width: calc(100% - 14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-buttons-container:not(.floating) {
|
||||||
|
border-top: solid 4px;
|
||||||
|
border-bottom: solid 4px;
|
||||||
|
border-color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-buttons-container:not(.floating) .tab-buttons {
|
||||||
|
width: calc(100% + 14px);
|
||||||
|
margin-left: -7px;
|
||||||
|
margin-right: -7px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: left;
|
||||||
|
padding-left: 14px;
|
||||||
|
margin-bottom: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-buttons-container.floating .tab-buttons {
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: -25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-buttons {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-flow: wrap;
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-tab
|
||||||
|
> .tab-family-container:first-child:nth-last-child(3)
|
||||||
|
> .tab-buttons-container
|
||||||
|
> .tab-buttons {
|
||||||
|
padding-right: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-buttons:not(.floating) {
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: inherit;
|
||||||
|
border-width: 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body .tab-buttons {
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showGoBack
|
||||||
|
> .tab-family-container
|
||||||
|
> .tab-buttons-container:not(.floating):first-child
|
||||||
|
.tab-buttons {
|
||||||
|
padding-left: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:not(.showGoBack)
|
||||||
|
> .tab-family-container
|
||||||
|
> .tab-buttons-container:not(.floating):first-child
|
||||||
|
.tab-buttons {
|
||||||
|
padding-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-buttons-container:not(.floating):first-child {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimizable > .tab-buttons-container:not(.floating):first-child {
|
||||||
|
padding-right: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-buttons-container:not(.floating):first-child .tab-buttons {
|
||||||
|
margin-top: -50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-buttons-container + * {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
53
src/features/tabs/tab.ts
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import {
|
||||||
|
CoercableComponent,
|
||||||
|
Component,
|
||||||
|
GatherProps,
|
||||||
|
getUniqueID,
|
||||||
|
Replace,
|
||||||
|
StyleValue
|
||||||
|
} from "@/features/feature";
|
||||||
|
import TabComponent from "@/features/tabs/Tab.vue";
|
||||||
|
import { Computable, GetComputableType } from "@/util/computed";
|
||||||
|
import { createLazyProxy } from "@/util/proxies";
|
||||||
|
|
||||||
|
export const TabType = Symbol("Tab");
|
||||||
|
|
||||||
|
export interface TabOptions {
|
||||||
|
classes?: Computable<Record<string, boolean>>;
|
||||||
|
style?: Computable<StyleValue>;
|
||||||
|
display: Computable<CoercableComponent>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseTab {
|
||||||
|
id: string;
|
||||||
|
type: typeof TabType;
|
||||||
|
[Component]: typeof TabComponent;
|
||||||
|
[GatherProps]: () => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Tab<T extends TabOptions> = Replace<
|
||||||
|
T & BaseTab,
|
||||||
|
{
|
||||||
|
classes: GetComputableType<T["classes"]>;
|
||||||
|
style: GetComputableType<T["style"]>;
|
||||||
|
display: GetComputableType<T["display"]>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericTab = Tab<TabOptions>;
|
||||||
|
|
||||||
|
export function createTab<T extends TabOptions>(optionsFunc: () => T & ThisType<Tab<T>>): Tab<T> {
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const tab: T & Partial<BaseTab> = optionsFunc();
|
||||||
|
tab.id = getUniqueID("tab-");
|
||||||
|
tab.type = TabType;
|
||||||
|
tab[Component] = TabComponent;
|
||||||
|
|
||||||
|
tab[GatherProps] = function (this: GenericTab) {
|
||||||
|
const { display } = this;
|
||||||
|
return { display };
|
||||||
|
};
|
||||||
|
|
||||||
|
return tab as unknown as Tab<T>;
|
||||||
|
});
|
||||||
|
}
|
155
src/features/tabs/tabFamily.ts
Normal file
|
@ -0,0 +1,155 @@
|
||||||
|
import {
|
||||||
|
CoercableComponent,
|
||||||
|
Component,
|
||||||
|
GatherProps,
|
||||||
|
getUniqueID,
|
||||||
|
Replace,
|
||||||
|
setDefault,
|
||||||
|
StyleValue,
|
||||||
|
Visibility
|
||||||
|
} from "@/features/feature";
|
||||||
|
import TabButtonComponent from "@/features/tabs/TabButton.vue";
|
||||||
|
import TabFamilyComponent from "@/features/tabs/TabFamily.vue";
|
||||||
|
import { Persistent, makePersistent, PersistentState } from "@/game/persistence";
|
||||||
|
import {
|
||||||
|
Computable,
|
||||||
|
GetComputableType,
|
||||||
|
GetComputableTypeWithDefault,
|
||||||
|
processComputable,
|
||||||
|
ProcessedComputable
|
||||||
|
} from "@/util/computed";
|
||||||
|
import { createLazyProxy } from "@/util/proxies";
|
||||||
|
import { computed, Ref, unref } from "vue";
|
||||||
|
import { GenericTab } from "./tab";
|
||||||
|
|
||||||
|
export const TabButtonType = Symbol("TabButton");
|
||||||
|
export const TabFamilyType = Symbol("TabFamily");
|
||||||
|
|
||||||
|
export interface TabButtonOptions {
|
||||||
|
visibility?: Computable<Visibility>;
|
||||||
|
tab: Computable<GenericTab | CoercableComponent>;
|
||||||
|
display: Computable<CoercableComponent>;
|
||||||
|
classes?: Computable<Record<string, boolean>>;
|
||||||
|
style?: Computable<StyleValue>;
|
||||||
|
glowColor?: Computable<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseTabButton {
|
||||||
|
type: typeof TabButtonType;
|
||||||
|
[Component]: typeof TabButtonComponent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TabButton<T extends TabButtonOptions> = Replace<
|
||||||
|
T & BaseTabButton,
|
||||||
|
{
|
||||||
|
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||||
|
tab: GetComputableType<T["tab"]>;
|
||||||
|
display: GetComputableType<T["display"]>;
|
||||||
|
classes: GetComputableType<T["classes"]>;
|
||||||
|
style: GetComputableType<T["style"]>;
|
||||||
|
glowColor: GetComputableType<T["glowColor"]>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericTabButton = Replace<
|
||||||
|
TabButton<TabButtonOptions>,
|
||||||
|
{
|
||||||
|
visibility: ProcessedComputable<Visibility>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface TabFamilyOptions {
|
||||||
|
visibility?: Computable<Visibility>;
|
||||||
|
tabs: Record<string, TabButtonOptions>;
|
||||||
|
classes?: Computable<Record<string, boolean>>;
|
||||||
|
style?: Computable<StyleValue>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseTabFamily extends Persistent<string> {
|
||||||
|
id: string;
|
||||||
|
activeTab: Ref<GenericTab | CoercableComponent | null>;
|
||||||
|
selected: Ref<string>;
|
||||||
|
type: typeof TabFamilyType;
|
||||||
|
[Component]: typeof TabFamilyComponent;
|
||||||
|
[GatherProps]: () => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TabFamily<T extends TabFamilyOptions> = Replace<
|
||||||
|
T & BaseTabFamily,
|
||||||
|
{
|
||||||
|
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||||
|
tabs: Record<string, GenericTabButton>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type GenericTabFamily = Replace<
|
||||||
|
TabFamily<TabFamilyOptions>,
|
||||||
|
{
|
||||||
|
visibility: ProcessedComputable<Visibility>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createTabFamily<T extends TabFamilyOptions>(
|
||||||
|
optionsFunc: () => T & ThisType<TabFamily<T>>
|
||||||
|
): TabFamily<T> {
|
||||||
|
return createLazyProxy(() => {
|
||||||
|
const tabFamily: T & Partial<BaseTabFamily> = optionsFunc();
|
||||||
|
|
||||||
|
if (Object.keys(tabFamily.tabs).length === 0) {
|
||||||
|
console.warn("Cannot create tab family with 0 tabs", tabFamily);
|
||||||
|
throw "Cannot create tab family with 0 tabs";
|
||||||
|
}
|
||||||
|
|
||||||
|
tabFamily.id = getUniqueID("tabFamily-");
|
||||||
|
tabFamily.type = TabFamilyType;
|
||||||
|
tabFamily[Component] = TabFamilyComponent;
|
||||||
|
|
||||||
|
makePersistent<string>(tabFamily, Object.keys(tabFamily.tabs)[0]);
|
||||||
|
tabFamily.selected = tabFamily[PersistentState];
|
||||||
|
tabFamily.activeTab = computed(() => {
|
||||||
|
const tabs = unref(processedTabFamily.tabs);
|
||||||
|
if (
|
||||||
|
tabFamily[PersistentState].value in tabs &&
|
||||||
|
unref(tabs[processedTabFamily[PersistentState].value].visibility) ===
|
||||||
|
Visibility.Visible
|
||||||
|
) {
|
||||||
|
return unref(tabs[processedTabFamily[PersistentState].value].tab);
|
||||||
|
}
|
||||||
|
const firstTab = Object.values(tabs).find(
|
||||||
|
tab => unref(tab.visibility) === Visibility.Visible
|
||||||
|
);
|
||||||
|
if (firstTab) {
|
||||||
|
return unref(firstTab.tab);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
processComputable(tabFamily as T, "visibility");
|
||||||
|
setDefault(tabFamily, "visibility", Visibility.Visible);
|
||||||
|
processComputable(tabFamily as T, "classes");
|
||||||
|
processComputable(tabFamily as T, "style");
|
||||||
|
|
||||||
|
for (const tab in tabFamily.tabs) {
|
||||||
|
const tabButton: TabButtonOptions & Partial<BaseTabButton> = tabFamily.tabs[tab];
|
||||||
|
tabButton.type = TabButtonType;
|
||||||
|
tabButton[Component] = TabButtonComponent;
|
||||||
|
|
||||||
|
processComputable(tabButton as TabButtonOptions, "visibility");
|
||||||
|
setDefault(tabButton, "visibility", Visibility.Visible);
|
||||||
|
processComputable(tabButton as TabButtonOptions, "tab");
|
||||||
|
processComputable(tabButton as TabButtonOptions, "display");
|
||||||
|
processComputable(tabButton as TabButtonOptions, "classes");
|
||||||
|
processComputable(tabButton as TabButtonOptions, "style");
|
||||||
|
processComputable(tabButton as TabButtonOptions, "glowColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
tabFamily[GatherProps] = function (this: GenericTabFamily) {
|
||||||
|
const { visibility, activeTab, selected, tabs, style, classes } = this;
|
||||||
|
return { visibility, activeTab, selected, tabs, style, classes };
|
||||||
|
};
|
||||||
|
|
||||||
|
// This is necessary because board.types is different from T and TabFamily
|
||||||
|
const processedTabFamily = tabFamily as unknown as TabFamily<T>;
|
||||||
|
return processedTabFamily;
|
||||||
|
});
|
||||||
|
}
|
25
src/features/tooltip.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import { CoercableComponent } from "@/features/feature";
|
||||||
|
import { ProcessedComputable } from "@/util/computed";
|
||||||
|
|
||||||
|
declare module "@vue/runtime-dom" {
|
||||||
|
interface CSSProperties {
|
||||||
|
"--xoffset"?: string;
|
||||||
|
"--yoffset"?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tooltip {
|
||||||
|
display: ProcessedComputable<CoercableComponent>;
|
||||||
|
top?: ProcessedComputable<boolean>;
|
||||||
|
left?: ProcessedComputable<boolean>;
|
||||||
|
right?: ProcessedComputable<boolean>;
|
||||||
|
bottom?: ProcessedComputable<boolean>;
|
||||||
|
xoffset?: ProcessedComputable<string>;
|
||||||
|
yoffset?: ProcessedComputable<string>;
|
||||||
|
force?: ProcessedComputable<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gatherTooltipProps(tooltip: Tooltip) {
|
||||||
|
const { display, top, left, right, bottom, xoffset, yoffset, force } = tooltip;
|
||||||
|
return { display, top, left, right, bottom, xoffset, yoffset, force };
|
||||||
|
}
|
105
src/features/trees/Tree.vue
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
<template>
|
||||||
|
<span class="row" v-for="(row, index) in unref(nodes)" :key="index" v-bind="$attrs">
|
||||||
|
<TreeNode
|
||||||
|
v-for="(node, nodeIndex) in row"
|
||||||
|
:key="nodeIndex"
|
||||||
|
v-bind="gatherNodeProps(node)"
|
||||||
|
:force-tooltip="node.forceTooltip"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="left-side-nodes" v-if="unref(leftSideNodes)">
|
||||||
|
<TreeNode
|
||||||
|
v-for="(node, nodeIndex) in unref(leftSideNodes)"
|
||||||
|
:key="nodeIndex"
|
||||||
|
v-bind="gatherNodeProps(node)"
|
||||||
|
:force-tooltip="node.forceTooltip"
|
||||||
|
small
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="side-nodes" v-if="unref(rightSideNodes)">
|
||||||
|
<TreeNode
|
||||||
|
v-for="(node, nodeIndex) in unref(rightSideNodes)"
|
||||||
|
:key="nodeIndex"
|
||||||
|
v-bind="gatherNodeProps(node)"
|
||||||
|
:force-tooltip="node.forceTooltip"
|
||||||
|
small
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import "@/components/common/table.css";
|
||||||
|
import { GenericTreeNode } from "@/features/trees/tree";
|
||||||
|
import { processedPropType } from "@/util/vue";
|
||||||
|
import { defineComponent, unref } from "vue";
|
||||||
|
import TreeNode from "./TreeNode.vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
nodes: {
|
||||||
|
type: processedPropType<GenericTreeNode[][]>(Array),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
leftSideNodes: processedPropType<GenericTreeNode[]>(Array),
|
||||||
|
rightSideNodes: processedPropType<GenericTreeNode[]>(Array)
|
||||||
|
},
|
||||||
|
components: { TreeNode },
|
||||||
|
setup() {
|
||||||
|
function gatherNodeProps(node: GenericTreeNode) {
|
||||||
|
const {
|
||||||
|
display,
|
||||||
|
visibility,
|
||||||
|
style,
|
||||||
|
classes,
|
||||||
|
tooltip,
|
||||||
|
onClick,
|
||||||
|
onHold,
|
||||||
|
color,
|
||||||
|
glowColor,
|
||||||
|
forceTooltip,
|
||||||
|
canClick,
|
||||||
|
mark,
|
||||||
|
id
|
||||||
|
} = node;
|
||||||
|
return {
|
||||||
|
display,
|
||||||
|
visibility,
|
||||||
|
style,
|
||||||
|
classes,
|
||||||
|
tooltip,
|
||||||
|
onClick,
|
||||||
|
onHold,
|
||||||
|
color,
|
||||||
|
glowColor,
|
||||||
|
forceTooltip,
|
||||||
|
canClick,
|
||||||
|
mark,
|
||||||
|
id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
gatherNodeProps,
|
||||||
|
unref
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.row {
|
||||||
|
margin: 50px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-side-nodes {
|
||||||
|
position: absolute;
|
||||||
|
left: 15px;
|
||||||
|
top: 65px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-nodes {
|
||||||
|
position: absolute;
|
||||||
|
right: 15px;
|
||||||
|
top: 65px;
|
||||||
|
}
|
||||||
|
</style>
|