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>
|