Initial commit

This commit is contained in:
Anthony Lawn 2022-02-27 18:11:01 -06:00
commit 0e98944076
123 changed files with 39397 additions and 0 deletions

28
.eslintrc.js Normal file
View 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
View 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
View file

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

2
.replit Normal file
View file

@ -0,0 +1,2 @@
language = "nodejs"
run = "npm run serve"

3
.vs/ProjectSettings.json Normal file
View file

@ -0,0 +1,3 @@
{
"CurrentProjectSetting": null
}

10
.vs/VSWorkspaceState.json Normal file
View file

@ -0,0 +1,10 @@
{
"ExpandedNodes": [
"",
"\\src",
"\\src\\typings",
"\\src\\util"
],
"SelectedNode": "\\src\\App.vue",
"PreviewInSolutionExplorer": false
}

BIN
.vs/slnx.sqlite Normal file

Binary file not shown.

21
LICENSE Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

58
package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 873 B

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

29
public/index.html Normal file
View 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
View file

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

0
saves/.placehold Normal file
View file

1
saves/safff.txt Normal file
View file

@ -0,0 +1 @@
eyJpZCI6InRtdC14LTEwNSIsIm5hbWUiOiJEZWZhdWx0IFNhZmZmZiAtIHNvbWV0aGluZyBlbHNlIiwidGFicyI6WyJtYWluIiwiYyJdLCJ0aW1lIjoxNjI0MjQ1MjYxMDg3LCJhdXRvc2F2ZSI6dHJ1ZSwib2ZmbGluZVByb2QiOnRydWUsInRpbWVQbGF5ZWQiOiIzNDQ4LjYxNTc4MTcwOTAxIiwia2VlcEdvaW5nIjpmYWxzZSwibGFzdFRlblRpY2tzIjpbMC4wNTEsMC4wNSwwLjA0OSwwLjA1LDAuMDUsMC4wNTEsMC4wNDksMC4wNSwwLjA1LDAuMDUxXSwic2hvd1RQUyI6dHJ1ZSwibXNEaXNwbGF5IjoiYWxsIiwiaGlkZUNoYWxsZW5nZXMiOmZhbHNlLCJ0aGVtZSI6InBhcGVyIiwic3VidGFicyI6e30sIm1pbmltaXplZCI6e30sIm1vZElEIjoidG10LXgiLCJtb2RWZXJzaW9uIjoiMC4wIiwicG9pbnRzIjoiMzMwMC4zNzc3NzM4NTkwNTUiLCJtYWluIjp7InVwZ3JhZGVzIjpbXSwiYWNoaWV2ZW1lbnRzIjpbXSwibWlsZXN0b25lcyI6W10sImluZm9ib3hlcyI6e319LCJmIjp7InVwZ3JhZGVzIjpbXSwiYWNoaWV2ZW1lbnRzIjpbXSwibWlsZXN0b25lcyI6W10sImluZm9ib3hlcyI6e30sImNsaWNrYWJsZXMiOnsiMTEiOiJTdGFydCJ9LCJ1bmxvY2tlZCI6ZmFsc2UsInBvaW50cyI6IjAiLCJib29wIjpmYWxzZX0sImMiOnsidXBncmFkZXMiOlsiMTEiXSwiYWNoaWV2ZW1lbnRzIjpbXSwibWlsZXN0b25lcyI6W10sImluZm9ib3hlcyI6e30sImJ1eWFibGVzIjp7IjExIjoiMCJ9LCJjaGFsbGVuZ2VzIjp7IjExIjoiMCJ9LCJ1bmxvY2tlZCI6dHJ1ZSwicG9pbnRzIjoiMCIsImJlc3QiOiIxIiwidG90YWwiOiIwIiwiYmVlcCI6ZmFsc2UsInRoaW5neSI6InBvaW50eSIsIm90aGVyVGhpbmd5IjoxMCwic3BlbnRPbkJ1eWFibGVzIjoiMCJ9LCJhIjp7InVwZ3JhZGVzIjpbXSwiYWNoaWV2ZW1lbnRzIjpbIjExIl0sIm1pbGVzdG9uZXMiOltdLCJpbmZvYm94ZXMiOnt9LCJ1bmxvY2tlZCI6dHJ1ZSwicG9pbnRzIjoiMCJ9LCJnIjp7InVwZ3JhZGVzIjpbXSwiYWNoaWV2ZW1lbnRzIjpbXSwibWlsZXN0b25lcyI6W10sImluZm9ib3hlcyI6e319LCJoIjp7InVwZ3JhZGVzIjpbXSwiYWNoaWV2ZW1lbnRzIjpbXSwibWlsZXN0b25lcyI6W10sImluZm9ib3hlcyI6e319LCJzcG9vayI6eyJ1cGdyYWRlcyI6W10sImFjaGlldmVtZW50cyI6W10sIm1pbGVzdG9uZXMiOltdLCJpbmZvYm94ZXMiOnt9fSwib29tcHNNYWciOjAsImxhc3RQb2ludHMiOiIzMzAwLjM3Nzc3Mzg1OTA1NSJ9

48
src/App.vue Normal file
View 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
View 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>

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

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

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

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

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

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

View 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;
}

View 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;
}

View 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;
}
*/

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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

File diff suppressed because it is too large Load diff

112
src/data/mod.tsx Normal file
View 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
View 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
View 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>;

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

View 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
View 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
View 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>;
});
}

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

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

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

View 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
View 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>;
});
}

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

View 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}
/>
))
);

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

View 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
View 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
View 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;
}

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

View 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
View 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
View 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();
}
};

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

View 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
View 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");

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

View 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
View 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;
}
}

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

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

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

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

View 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
View 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>;
});
}

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

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