Merge remote-tracking branch 'profectus/main'

This commit is contained in:
thepaperpilot 2024-01-28 11:58:17 -06:00
commit a195c2819f
110 changed files with 24666 additions and 2608 deletions

View file

@ -11,7 +11,8 @@ module.exports = {
"@vue/eslint-config-prettier" "@vue/eslint-config-prettier"
], ],
parserOptions: { parserOptions: {
ecmaVersion: 2020 ecmaVersion: 2020,
project: "tsconfig.json"
}, },
ignorePatterns: ["src/lib"], ignorePatterns: ["src/lib"],
rules: { rules: {
@ -19,7 +20,14 @@ module.exports = {
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off", "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
"vue/script-setup-uses-vars": "warn", "vue/script-setup-uses-vars": "warn",
"vue/no-mutating-props": "off", "vue/no-mutating-props": "off",
"vue/multi-word-component-names": "off" "vue/multi-word-component-names": "off",
"@typescript-eslint/strict-boolean-expressions": [
"error",
{
allowNullableObject: true,
allowNullableBoolean: true
}
]
}, },
globals: { globals: {
defineProps: "readonly", defineProps: "readonly",

View file

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

View file

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

View file

@ -3,6 +3,7 @@ on:
push: push:
branches: branches:
- 'main' - 'main'
workflow_dispatch:
jobs: jobs:
build-and-deploy: build-and-deploy:
if: github.repository != 'profectus-engine/Profectus' # Don't build placeholder mod on main repo if: github.repository != 'profectus-engine/Profectus' # Don't build placeholder mod on main repo

View file

@ -1,11 +1,11 @@
name: Build and Deploy name: Run Tests
on: on:
push: push:
branches: [ main ] branches: [ main ]
pull_request: pull_request:
branches: [ main ] branches: [ main ]
jobs: jobs:
build: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

13
.vscode/settings.json vendored
View file

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

View file

@ -6,6 +6,115 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.6.1] - 2023-05-17
### Added
- Error boundaries around each layer, and errors now display on the page when in development
- Utility for creating requirement based on whether a conversion has met a requirement
### Changed
- **BREAKING** Formulas/requirements refactor
- spendResources renamed to cumulativeCost
- summedPurchases renamed to directSum
- calculateMaxAffordable now takes optional 'maxBulkAmount' parameter
- cost requirements now pass cumulativeCost, maxBulkAmount, and directSum to calculateMaxAffordable
- Non-integrable and non-invertible formulas will now work in more situations
- Repeatable.maximize is removed
- Challenge.maximize is removed
- Formulas have better typing information now
- Integrate functions now log errors if the variable input is not integrable
- Cyclical proxies now throw errors
- createFormulaPreview is now a JSX function
- Tree nodes are not automatically capitalized anymore
- upgrade.canPurchase now returns false if the upgrade is already bought
- TPS display is simplified and more performant now
### Fixed
- Actions could not be constructed
- Progress bar on actions was misaligned
- Many different issues the Board features (and many changes/improvements)
- Calculating max affordable could sometimes infinite loop
- Non-integrable formulas could cause errors in cost requirements
- estimateTime would not show "never" when production is 0
- isInvertible and isIntegrable now properly handle nested formulas
- Repeatables' amount display would show the literal text "joinJSX"
- Repeatables would not buy max properly
- Reset buttons were showing wrong "currentAt" vs "nextAt"
- Step-wise formulas not updating their value correctly
- Bonus amount decorator now checks for `amount` property in the post construct callback
### Documentation
- Various typos fixed and a few sections made more thorough
## [0.6.0] - 2023-04-20
### Added
- **BREAKING** New requirements system
- Replaces many features' existing requirements with new generic form
- **BREAKING** Formulas, which can be used to calculate buy max for you
- Requirements can use them so repeatables and challenges can be "buy max" without any extra effort
- Conversions now use formulas instead of the old scaling functions system, allowing for arbitrary functions that are much easier to follow
- Modifiers have a new getFormula property
- Feature decorators, which simplify the process of adding extra values to features
- Action feature, which is a clickable with a cooldown
- ETA util (calculates time until a specific amount of a resource, based on its current gain rate)
- createCollapsibleAchievements util
- deleteLowerSaves util
- Minimized layers can now display a component
- submitOnBlur property to Text fields
- showPopups property to achievements
- Mouse/touch events to more onClick listeners
- Example hotkey to starting layer
- Schema for projInfo.json
### Changes
- **BREAKING** Buyables renamed to Repeatables
- Renamed purchaseLimit to limit
- Renamed buyMax to maximize
- Added initialAmount property
- **BREAKING** Persistent refs no longer have redundancies in save object
- Requires referencing persistent refs either through a proxy or by wrapping in `noPersist()`
- **BREAKING** Visibility properties can now take booleans
- Removed showIf util
- **BREAKING** Lazy proxies and options functions now pass the base object in as `this` as well as the first parameter.
- Tweaked settings display
- setupPassiveGeneration will no longer lower the resource
- displayResource now floors resource amounts
- Tweaked modifier displays, incl showing negative modifiers in red
- Hotkeys now appear on key graphic
- Mofifier sections now accept computable strings for title and subtitle
- Every VueFeature's `[Component]` property is now typed as GenericComponent
- Make errors throw objects instead of strings
- Updated b_e
### Fixed
- NaN detection stopped working
- Now specifically only checks persistent refs
- trackTotal would increase the total when loading the save
- PWAs wouldn't show updates
- Board feature no longer working at all
- Some discord links didn't open in new tab
- Adjacent grid cells wouldn't merge
- When fixing old saves, the modVersion would not be updated
- Default layer would display `Dev Speed: 0x` when paused
- Fixed hotkeys not working with shift + numbers
- Fixed console errors about deleted persistent refs not being included in the layer object
- Modifiers wouldn't display small numbers
- Conversions' addSoftcap wouldn't affect currentAt or nextAt
- MainDisplay not respecting style and classes props
- Tabs could sometimes not update correctly
- offlineTime not capping properly
- Tooltips being user-selectable
- Pinnable tooltips causing stack overflow
- Workflows not working with submodules
- Various minor typing issues
### Removed
- **BREAKING** Removed milestones (achievements now have small and large displays)
### Documented
- every single feature
- formulas
- requirements
### Tests
- conversions
- formulas
- modifiers
- requirements
Contributors: thepaperpilot, escapee, adsaf, ducdat
## [0.5.2] - 2022-08-22 ## [0.5.2] - 2022-08-22
### Added ### Added
- onLoad event - onLoad event

View file

@ -26,11 +26,6 @@ npm run build
npm run preview npm run preview
``` ```
### Lints and fixes files
```
npm run lint
```
### Runs the tests using vite-jest ### Runs the tests using vite-jest
``` ```
npm run test npm run test

View file

@ -1,6 +1,5 @@
{ {
"name": "profectus", "name": "profectus",
"version": "0.5.2",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "vite", "start": "vite",
@ -51,9 +50,9 @@
"eslint": "^8.6.0", "eslint": "^8.6.0",
"jsdom": "^20.0.0", "jsdom": "^20.0.0",
"prettier": "^2.5.1", "prettier": "^2.5.1",
"typescript": "^4.7.4", "typescript": "^5.0.2",
"unplugin-json-dts": "^1.2.0", "unplugin-json-dts": "^1.2.0",
"vitest": "^0.17.1", "vitest": "^0.29.3",
"vue-tsc": "^0.38.1" "vue-tsc": "^0.38.1"
}, },
"engines": { "engines": {

View file

@ -1,18 +1,25 @@
<template> <template>
<div id="modal-root" :style="theme" /> <div v-if="appErrors.length > 0" class="error-container" :style="theme"><Error :errors="appErrors" /></div>
<div class="app" :style="theme" :class="{ useHeader }"> <template v-else>
<Nav v-if="useHeader" /> <div id="modal-root" :style="theme" />
<Game /> <div class="app" :style="theme" :class="{ useHeader }">
<TPS v-if="unref(showTPS)" /> <Nav v-if="useHeader" />
<GameOverScreen /> <Game />
<NaNScreen /> <TPS v-if="unref(showTPS)" />
<component :is="gameComponent" /> <GameOverScreen />
</div> <NaNScreen />
<component :is="gameComponent" />
</div>
</template>
</template> </template>
<script setup lang="tsx"> <script setup lang="tsx">
import "@fontsource/roboto-mono";
import Error from "components/Error.vue";
import { jsx } from "features/feature"; import { jsx } from "features/feature";
import state from "game/state";
import { coerceComponent, render } from "util/vue"; import { coerceComponent, render } from "util/vue";
import { CSSProperties, watch } from "vue";
import { computed, toRef, unref } from "vue"; import { computed, toRef, unref } from "vue";
import Game from "./components/Game.vue"; import Game from "./components/Game.vue";
import GameOverScreen from "./components/GameOverScreen.vue"; import GameOverScreen from "./components/GameOverScreen.vue";
@ -23,15 +30,14 @@ import projInfo from "./data/projInfo.json";
import themes from "./data/themes"; import themes from "./data/themes";
import settings, { gameComponents } from "./game/settings"; import settings, { gameComponents } from "./game/settings";
import "./main.css"; import "./main.css";
import "@fontsource/roboto-mono";
import type { CSSProperties } from "vue";
const useHeader = projInfo.useHeader; const useHeader = projInfo.useHeader;
const theme = computed(() => themes[settings.theme].variables as CSSProperties); const theme = computed(() => themes[settings.theme].variables as CSSProperties);
const showTPS = toRef(settings, "showTPS"); const showTPS = toRef(settings, "showTPS");
const appErrors = toRef(state, "errors");
const gameComponent = computed(() => { const gameComponent = computed(() => {
return coerceComponent(jsx(() => <>{gameComponents.map(render)}</>)); return coerceComponent(jsx(() => (<>{gameComponents.map(render)}</>)));
}); });
</script> </script>
@ -49,5 +55,17 @@ const gameComponent = computed(() => {
position: absolute; position: absolute;
min-height: 100%; min-height: 100%;
height: 100%; height: 100%;
color: var(--foreground);
}
.error-container {
background: var(--background);
overflow: auto;
width: 100%;
height: 100%;
}
.error-container > .error {
position: static;
} }
</style> </style>

135
src/components/Error.vue Normal file
View file

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

View file

@ -4,7 +4,6 @@
v-for="(tab, index) in tabs" v-for="(tab, index) in tabs"
:key="index" :key="index"
class="tab" class="tab"
:ref="`tab-${index}`"
:style="unref(layers[tab]?.style)" :style="unref(layers[tab]?.style)"
:class="unref(layers[tab]?.classes)" :class="unref(layers[tab]?.classes)"
> >
@ -14,7 +13,7 @@
v-if="layerKeys.includes(tab)" v-if="layerKeys.includes(tab)"
v-bind="gatherLayerProps(layers[tab]!)" v-bind="gatherLayerProps(layers[tab]!)"
:index="index" :index="index"
:tab="() => (($refs[`tab-${index}`] as HTMLElement[] | undefined)?.[0])" @set-minimized="(value: boolean) => (layers[tab]!.minimized.value = value)"
/> />
<component :is="tab" :index="index" v-else /> <component :is="tab" :index="index" v-else />
</div> </div>
@ -36,8 +35,8 @@ const layerKeys = computed(() => Object.keys(layers));
const useHeader = projInfo.useHeader; const useHeader = projInfo.useHeader;
function gatherLayerProps(layer: GenericLayer) { function gatherLayerProps(layer: GenericLayer) {
const { display, minimized, minWidth, name, color, minimizable, nodes } = layer; const { display, minimized, name, color, minimizable, nodes, minimizedDisplay } = layer;
return { display, minimized, minWidth, name, color, minimizable, nodes }; return { display, minimized, name, color, minimizable, nodes, minimizedDisplay };
} }
</script> </script>

70
src/components/Hotkey.vue Normal file
View file

@ -0,0 +1,70 @@
<!-- Make eslint not whine about putting spaces before the +'s -->
<!-- eslint-disable prettier/prettier -->
<template>
<template v-if="isCtrl"
><div class="key">Ctrl</div
>+</template
><template v-if="isShift"
><div class="key">Shift</div
>+</template
>
<div class="key">{{ key }}</div>
</template>
<script setup lang="ts">
import { GenericHotkey } from "features/hotkey";
import { watchEffect } from "vue";
const props = defineProps<{
hotkey: GenericHotkey;
}>();
let key = "";
let isCtrl = false;
let isShift = false;
let isAlpha = false;
watchEffect(() => {
key = props.hotkey.key;
isCtrl = key.startsWith("ctrl+");
if (isCtrl) {
key = key.slice(5);
}
isShift = key.startsWith("shift+");
if (isShift) {
key = key.slice(6);
}
isAlpha = key.length == 1 && key.toLowerCase() != key.toUpperCase();
if (isAlpha) {
key = key.toUpperCase();
}
});
</script>
<style scoped>
.key {
display: inline-block;
height: 1.4em;
min-width: 1em;
margin-block: 0.1em;
padding-inline: 0.2em;
vertical-align: 0.1em;
background: var(--foreground);
color: var(--feature-foreground);
border: 1px solid #0007;
border-radius: 0.3em;
box-shadow: 0 0.1em #0007, 0 0.1em var(--foreground);
font-size: smaller;
text-align: center;
user-select: none;
transition: transform 0s, box-shadow 0s;
}
.key:active {
transform: translateY(0.1em);
box-shadow: none;
}
</style>

View file

@ -21,19 +21,32 @@
<div class="link" @click="openChangelog">Changelog</div> <div class="link" @click="openChangelog">Changelog</div>
<br /> <br />
<div> <div>
<a :href="discordLink" v-if="discordLink" class="info-modal-discord-link"> <a
:href="discordLink"
v-if="discordLink"
class="info-modal-discord-link"
target="_blank"
>
<span class="material-icons info-modal-discord">discord</span> <span class="material-icons info-modal-discord">discord</span>
{{ discordName }} {{ discordName }}
</a> </a>
</div> </div>
<div> <div>
<a href="https://discord.gg/WzejVAx" class="info-modal-discord-link"> <a
href="https://discord.gg/yJ4fjnjU54"
class="info-modal-discord-link"
target="_blank"
>
<span class="material-icons info-modal-discord">discord</span> <span class="material-icons info-modal-discord">discord</span>
The Paper Pilot Community Profectus & Friends
</a> </a>
</div> </div>
<div> <div>
<a href="https://discord.gg/F3xveHV" class="info-modal-discord-link"> <a
href="https://discord.gg/F3xveHV"
class="info-modal-discord-link"
target="_blank"
>
<span class="material-icons info-modal-discord">discord</span> <span class="material-icons info-modal-discord">discord</span>
The Modding Tree The Modding Tree
</a> </a>
@ -67,7 +80,7 @@ const isOpen = ref(false);
const timePlayed = computed(() => formatTime(player.timePlayed)); const timePlayed = computed(() => formatTime(player.timePlayed));
const infoComponent = computed(() => { const infoComponent = computed(() => {
return coerceComponent(jsx(() => <>{infoComponents.map(render)}</>)); return coerceComponent(jsx(() => (<>{infoComponents.map(render)}</>)));
}); });
defineExpose({ defineExpose({

View file

@ -1,15 +1,23 @@
<template> <template>
<div class="layer-container" :style="{ '--layer-color': unref(color) }"> <ErrorVue v-if="errors.length > 0" :errors="errors" />
<button v-if="showGoBack" class="goBack" @click="goBack"></button> <div class="layer-container" :style="{ '--layer-color': unref(color) }" v-bind="$attrs" v-else>
<button class="layer-tab minimized" v-if="minimized.value" @click="minimized.value = false"> <button v-if="showGoBack" class="goBack" @click="goBack"></button>
<div>{{ unref(name) }}</div>
<button
class="layer-tab minimized"
v-if="unref(minimized)"
@click="$emit('setMinimized', false)"
>
<component v-if="minimizedComponent" :is="minimizedComponent" />
<div v-else>{{ unref(name) }}</div>
</button> </button>
<div class="layer-tab" :class="{ showGoBack }" v-else> <div class="layer-tab" :class="{ showGoBack }" v-else>
<Context @update-nodes="updateNodes"> <Context @update-nodes="updateNodes">
<component :is="component" /> <component :is="component" />
</Context> </Context>
</div> </div>
<button v-if="unref(minimizable)" class="minimize" @click="minimized.value = true">
<button v-if="unref(minimizable)" class="minimize" @click="$emit('setMinimized', true)">
</button> </button>
</div> </div>
@ -19,34 +27,26 @@
import projInfo from "data/projInfo.json"; import projInfo from "data/projInfo.json";
import type { CoercableComponent } from "features/feature"; import type { CoercableComponent } from "features/feature";
import type { FeatureNode } from "game/layers"; import type { FeatureNode } from "game/layers";
import type { Persistent } from "game/persistence";
import player from "game/player"; import player from "game/player";
import { computeComponent, processedPropType, wrapRef } from "util/vue"; import { computeComponent, computeOptionalComponent, processedPropType, unwrapRef } from "util/vue";
import type { PropType, Ref } from "vue"; import { PropType, Ref, computed, defineComponent, onErrorCaptured, ref, toRefs, unref } from "vue";
import { computed, defineComponent, nextTick, toRefs, unref, watch } from "vue";
import Context from "./Context.vue"; import Context from "./Context.vue";
import ErrorVue from "./Error.vue";
export default defineComponent({ export default defineComponent({
components: { Context }, components: { Context, ErrorVue },
props: { props: {
index: { index: {
type: Number, type: Number,
required: true required: true
}, },
tab: {
type: Function as PropType<() => HTMLElement | undefined>,
required: true
},
display: { display: {
type: processedPropType<CoercableComponent>(Object, String, Function), type: processedPropType<CoercableComponent>(Object, String, Function),
required: true required: true
}, },
minimizedDisplay: processedPropType<CoercableComponent>(Object, String, Function),
minimized: { minimized: {
type: Object as PropType<Persistent<boolean>>, type: Object as PropType<Ref<boolean>>,
required: true
},
minWidth: {
type: processedPropType<number | string>(Number, String),
required: true required: true
}, },
name: { name: {
@ -60,56 +60,41 @@ export default defineComponent({
required: true required: true
} }
}, },
emits: ["setMinimized"],
setup(props) { setup(props) {
const { display, index, minimized, minWidth, tab } = toRefs(props); const { display, index, minimized, minimizedDisplay } = toRefs(props);
const component = computeComponent(display); const component = computeComponent(display);
const minimizedComponent = computeOptionalComponent(minimizedDisplay);
const showGoBack = computed( const showGoBack = computed(
() => projInfo.allowGoBack && index.value > 0 && !minimized.value () => projInfo.allowGoBack && index.value > 0 && !unwrapRef(minimized)
); );
function goBack() { function goBack() {
player.tabs.splice(unref(props.index), Infinity); player.tabs.splice(unref(props.index), Infinity);
} }
nextTick(() => updateTab(minimized.value, unref(minWidth.value)));
watch([minimized, wrapRef(minWidth)], ([minimized, minWidth]) =>
updateTab(minimized, minWidth)
);
function updateNodes(nodes: Record<string, FeatureNode | undefined>) { function updateNodes(nodes: Record<string, FeatureNode | undefined>) {
props.nodes.value = nodes; props.nodes.value = nodes;
} }
function updateTab(minimized: boolean, minWidth: number | string) { const errors = ref<Error[]>([]);
const width = onErrorCaptured((err, instance, info) => {
typeof minWidth === "number" || Number.isNaN(parseInt(minWidth)) console.warn(`Error caught in "${props.name}" layer`, err, instance, info);
? minWidth + "px" errors.value.push(
: minWidth; err instanceof Error ? (err as Error) : new Error(JSON.stringify(err))
const tabValue = tab.value(); );
if (tabValue != undefined) { return false;
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 = width;
tabValue.style.margin = "";
}
}
}
return { return {
component, component,
minimizedComponent,
showGoBack, showGoBack,
updateNodes, updateNodes,
unref, unref,
goBack goBack,
errors
}; };
} }
}); });
@ -155,9 +140,10 @@ export default defineComponent({
background-color: transparent; background-color: transparent;
} }
.layer-tab.minimized div { .layer-tab.minimized > * {
margin: 0; margin: 0;
writing-mode: vertical-rl; writing-mode: vertical-rl;
text-align: left;
padding-left: 10px; padding-left: 10px;
width: 50px; width: 50px;
} }
@ -201,8 +187,8 @@ export default defineComponent({
.goBack { .goBack {
position: sticky; position: sticky;
top: 6px; top: 10px;
left: 20px; left: 10px;
line-height: 30px; line-height: 30px;
margin-top: -50px; margin-top: -50px;
margin-left: -35px; margin-left: -35px;
@ -211,7 +197,7 @@ export default defineComponent({
box-shadow: var(--background) 0 2px 3px 5px; box-shadow: var(--background) 0 2px 3px 5px;
border-radius: 50%; border-radius: 50%;
color: var(--foreground); color: var(--foreground);
font-size: 40px; font-size: 30px;
cursor: pointer; cursor: pointer;
z-index: 7; z-index: 7;
} }
@ -221,3 +207,10 @@ export default defineComponent({
text-shadow: 0 0 7px var(--foreground); text-shadow: 0 0 7px var(--foreground);
} }
</style> </style>
<style>
.layer-tab.minimized > * > .desc {
color: var(--accent1);
font-size: 30px;
}
</style>

View file

@ -40,7 +40,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { FeatureNode } from "game/layers"; import type { FeatureNode } from "game/layers";
import { computed, ref, toRefs } from "vue"; import { computed, ref, toRefs, unref } from "vue";
import Context from "./Context.vue"; import Context from "./Context.vue";
const _props = defineProps<{ const _props = defineProps<{
@ -51,7 +51,7 @@ const emit = defineEmits<{
(e: "update:modelValue", value: boolean): void; (e: "update:modelValue", value: boolean): void;
}>(); }>();
const isOpen = computed(() => props.modelValue || isAnimating.value); const isOpen = computed(() => unref(props.modelValue) || isAnimating.value);
function close() { function close() {
emit("update:modelValue", false); emit("update:modelValue", false);
} }

View file

@ -14,9 +14,12 @@
</div> </div>
<br /> <br />
<div> <div>
<a :href="discordLink" class="nan-modal-discord-link"> <a
:href="discordLink || 'https://discord.gg/yJ4fjnjU54'"
class="nan-modal-discord-link"
>
<span class="material-icons nan-modal-discord">discord</span> <span class="material-icons nan-modal-discord">discord</span>
{{ discordName }} {{ discordName || "Profectus & Friends" }}
</a> </a>
</div> </div>
<br /> <br />
@ -50,49 +53,51 @@ import state from "game/state";
import type { DecimalSource } from "util/bignum"; import type { DecimalSource } from "util/bignum";
import Decimal, { format } from "util/bignum"; import Decimal, { format } from "util/bignum";
import type { ComponentPublicInstance } from "vue"; import type { ComponentPublicInstance } from "vue";
import { computed, ref, toRef } from "vue"; import { computed, ref, toRef, watch } from "vue";
import Toggle from "./fields/Toggle.vue"; import Toggle from "./fields/Toggle.vue";
import SavesManager from "./SavesManager.vue"; import SavesManager from "./SavesManager.vue";
const { discordName, discordLink } = projInfo; const { discordName, discordLink } = projInfo;
const autosave = toRef(player, "autosave"); const autosave = ref(true);
const isPaused = ref(true);
const hasNaN = toRef(state, "hasNaN"); const hasNaN = toRef(state, "hasNaN");
const savesManager = ref<ComponentPublicInstance<typeof SavesManager> | null>(null); const savesManager = ref<ComponentPublicInstance<typeof SavesManager> | null>(null);
const path = computed(() => state.NaNPath?.join(".")); watch(hasNaN, hasNaN => {
const property = computed(() => state.NaNPath?.slice(-1)[0]); if (hasNaN) {
const previous = computed<DecimalSource | null>(() => { autosave.value = player.autosave;
if (state.NaNReceiver && property.value) { isPaused.value = player.devSpeed === 0;
return state.NaNReceiver[property.value] as DecimalSource; } else {
} player.autosave = autosave.value;
return null; player.devSpeed = isPaused.value ? 0 : null;
});
const isPaused = computed({
get() {
return player.devSpeed === 0;
},
set(value: boolean) {
player.devSpeed = value ? null : 0;
} }
}); });
const path = computed(() => state.NaNPath?.join("."));
const previous = computed<DecimalSource | null>(() => {
if (state.NaNPersistent != null) {
return state.NaNPersistent.value;
}
return null;
});
function setZero() { function setZero() {
if (state.NaNReceiver && property.value) { if (state.NaNPersistent != null) {
state.NaNReceiver[property.value] = new Decimal(0); state.NaNPersistent.value = new Decimal(0);
state.hasNaN = false; state.hasNaN = false;
} }
} }
function setOne() { function setOne() {
if (state.NaNReceiver && property.value) { if (state.NaNPersistent) {
state.NaNReceiver[property.value] = new Decimal(1); state.NaNPersistent.value = new Decimal(1);
state.hasNaN = false; state.hasNaN = false;
} }
} }
function ignore() { function ignore() {
if (state.NaNReceiver && property.value) { if (state.NaNPersistent) {
state.NaNReceiver[property.value] = new Decimal(NaN); state.NaNPersistent.value = new Decimal(NaN);
state.hasNaN = false; state.hasNaN = false;
} }
} }

View file

@ -15,9 +15,7 @@
<a :href="discordLink" target="_blank">{{ discordName }}</a> <a :href="discordLink" target="_blank">{{ discordName }}</a>
</li> </li>
<li> <li>
<a href="https://discord.gg/WzejVAx" target="_blank" <a href="https://discord.gg/yJ4fjnjU54" target="_blank">Profectus & Friends</a>
>The Paper Pilot Community</a
>
</li> </li>
<li> <li>
<a href="https://discord.gg/F3xveHV" target="_blank">The Modding Tree</a> <a href="https://discord.gg/F3xveHV" target="_blank">The Modding Tree</a>
@ -47,7 +45,7 @@
</Tooltip> </Tooltip>
</div> </div>
<div @click="options?.open()"> <div @click="options?.open()">
<Tooltip display="Options" :direction="Direction.Down" xoffset="-66px"> <Tooltip display="Settings" :direction="Direction.Down" xoffset="-66px">
<span class="material-icons">settings</span> <span class="material-icons">settings</span>
</Tooltip> </Tooltip>
</div> </div>
@ -69,7 +67,7 @@
</Tooltip> </Tooltip>
</div> </div>
<div @click="options?.open()"> <div @click="options?.open()">
<Tooltip display="Options" :direction="Direction.Right"> <Tooltip display="Settings" :direction="Direction.Right">
<span class="material-icons">settings</span> <span class="material-icons">settings</span>
</Tooltip> </Tooltip>
</div> </div>
@ -92,9 +90,7 @@
<a :href="discordLink" target="_blank">{{ discordName }}</a> <a :href="discordLink" target="_blank">{{ discordName }}</a>
</li> </li>
<li> <li>
<a href="https://discord.gg/WzejVAx" target="_blank" <a href="https://discord.gg/yJ4fjnjU54" target="_blank">Profectus & Friends</a>
>The Paper Pilot Community</a
>
</li> </li>
<li> <li>
<a href="https://discord.gg/F3xveHV" target="_blank">The Modding Tree</a> <a href="https://discord.gg/F3xveHV" target="_blank">The Modding Tree</a>

View file

@ -11,6 +11,7 @@
left: 5px; left: 5px;
z-index: 10; z-index: 10;
pointer-events: none; pointer-events: none;
user-select: none;
color: var(--accent3); color: var(--accent3);
font-size: x-large; font-size: x-large;
animation: 1s linear infinite bounce; animation: 1s linear infinite bounce;

View file

@ -2,18 +2,27 @@
<Modal v-model="isOpen"> <Modal v-model="isOpen">
<template v-slot:header> <template v-slot:header>
<div class="header"> <div class="header">
<h2>Options</h2> <h2>Settings</h2>
<div class="option-tabs">
<button :class="{selected: isTab('behaviour')}" @click="setTab('behaviour')">Behaviour</button>
<button :class="{selected: isTab('appearance')}" @click="setTab('appearance')">Appearance</button>
</div>
</div> </div>
</template> </template>
<template v-slot:body> <template v-slot:body>
<Select title="Theme" :options="themes" v-model="theme" /> <div v-if="isTab('behaviour')">
<component :is="settingFieldsComponent" /> <Toggle :title="unthrottledTitle" v-model="unthrottled" />
<Toggle title="Show TPS" v-model="showTPS" /> <Toggle v-if="projInfo.enablePausing" :title="isPausedTitle" v-model="isPaused" />
<hr /> <Toggle :title="offlineProdTitle" v-model="offlineProd" />
<Toggle title="Unthrottled" v-model="unthrottled" /> <Toggle :title="autosaveTitle" v-model="autosave" />
<Toggle :title="offlineProdTitle" v-model="offlineProd" /> <FeedbackButton v-if="!autosave" class="button save-button" @click="save()">Manually save</FeedbackButton>
<Toggle :title="autosaveTitle" v-model="autosave" /> </div>
<Toggle v-if="projInfo.enablePausing" :title="isPausedTitle" v-model="isPaused" /> <div v-if="isTab('appearance')">
<Select :title="themeTitle" :options="themes" v-model="theme" />
<component :is="settingFieldsComponent" />
<Toggle :title="showTPSTitle" v-model="showTPS" />
<Toggle :title="alignModifierUnitsTitle" v-model="alignUnits" />
</div>
</template> </template>
</Modal> </Modal>
</template> </template>
@ -21,20 +30,34 @@
<script setup lang="tsx"> <script setup lang="tsx">
import Modal from "components/Modal.vue"; import Modal from "components/Modal.vue";
import projInfo from "data/projInfo.json"; import projInfo from "data/projInfo.json";
import { save } from "util/save";
import rawThemes from "data/themes"; import rawThemes from "data/themes";
import { jsx } from "features/feature"; import { jsx } from "features/feature";
import Tooltip from "features/tooltips/Tooltip.vue"; import Tooltip from "features/tooltips/Tooltip.vue";
import player from "game/player"; import player from "game/player";
import settings, { settingFields } from "game/settings"; import settings, { settingFields } from "game/settings";
import { camelToTitle } from "util/common"; import { camelToTitle, Direction } from "util/common";
import { coerceComponent, render } from "util/vue"; import { coerceComponent, render } from "util/vue";
import { computed, ref, toRefs } from "vue"; import { computed, ref, toRefs } from "vue";
import Select from "./fields/Select.vue"; import Select from "./fields/Select.vue";
import Toggle from "./fields/Toggle.vue"; import Toggle from "./fields/Toggle.vue";
import FeedbackButton from "./fields/FeedbackButton.vue";
const isOpen = ref(false); const isOpen = ref(false);
const currentTab = ref("behaviour");
function isTab(tab: string): boolean {
return tab == currentTab.value;
}
function setTab(tab: string) {
currentTab.value = tab;
}
defineExpose({ defineExpose({
isTab,
setTab,
save,
open() { open() {
isOpen.value = true; isOpen.value = true;
} }
@ -46,10 +69,10 @@ const themes = Object.keys(rawThemes).map(theme => ({
})); }));
const settingFieldsComponent = computed(() => { const settingFieldsComponent = computed(() => {
return coerceComponent(jsx(() => <>{settingFields.map(render)}</>)); return coerceComponent(jsx(() => (<>{settingFields.map(render)}</>)));
}); });
const { showTPS, theme, unthrottled } = toRefs(settings); const { showTPS, theme, unthrottled, alignUnits } = toRefs(settings);
const { autosave, offlineProd } = toRefs(player); const { autosave, offlineProd } = toRefs(player);
const isPaused = computed({ const isPaused = computed({
get() { get() {
@ -60,30 +83,85 @@ const isPaused = computed({
} }
}); });
const unthrottledTitle = jsx(() => (
<span class="option-title">
Unthrottled
<desc>Allow the game to run as fast as possible. Not battery friendly.</desc>
</span>
));
const offlineProdTitle = jsx(() => ( const offlineProdTitle = jsx(() => (
<span> <span class="option-title">
Offline Production<Tooltip display="Save-specific">*</Tooltip> Offline Production<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip>
<desc>Simulate production that occurs while the game is closed.</desc>
</span> </span>
)); ));
const autosaveTitle = jsx(() => ( const autosaveTitle = jsx(() => (
<span> <span class="option-title">
Autosave<Tooltip display="Save-specific">*</Tooltip> Autosave<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip>
<desc>Automatically save the game every second or when the game is closed.</desc>
</span> </span>
)); ));
const isPausedTitle = jsx(() => ( const isPausedTitle = jsx(() => (
<span> <span class="option-title">
Pause game<Tooltip display="Save-specific">*</Tooltip> Pause game<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip>
<desc>Stop everything from moving.</desc>
</span>
));
const themeTitle = jsx(() => (
<span class="option-title">
Theme
<desc>How the game looks.</desc>
</span>
));
const showTPSTitle = jsx(() => (
<span class="option-title">
Show TPS
<desc>Show TPS meter at the bottom-left corner of the page.</desc>
</span>
));
const alignModifierUnitsTitle = jsx(() => (
<span class="option-title">
Align modifier units
<desc>Align numbers to the beginning of the unit in modifier view.</desc>
</span> </span>
)); ));
</script> </script>
<style scoped> <style>
.header { .option-tabs {
border-bottom: 2px solid var(--outline);
margin-top: 10px;
margin-bottom: -10px; margin-bottom: -10px;
} }
*:deep() .tooltip-container { .option-tabs button {
background-color: transparent;
color: var(--foreground);
margin-bottom: -2px;
font-size: 14px;
cursor: pointer;
padding: 5px 20px;
border: none;
border-bottom: 2px solid var(--foreground);
}
.option-tabs button:not(.selected) {
border-bottom-color: transparent;
}
.option-title .tooltip-container {
display: inline; display: inline;
margin-left: 5px; margin-left: 5px;
} }
.option-title desc {
display: block;
opacity: 0.6;
font-size: small;
width: 300px;
margin-left: 0;
}
.save-button {
text-align: right;
}
</style> </style>

View file

@ -33,7 +33,7 @@
<DangerButton <DangerButton
:disabled="isActive" :disabled="isActive"
@click="emit('delete')" @click="emit('delete')"
@confirmingChanged="value => (isConfirming = value)" @confirmingChanged="(value: boolean) => (isConfirming = value)"
> >
<Tooltip display="Delete" :direction="Direction.Left" class="info"> <Tooltip display="Delete" :direction="Direction.Left" class="info">
<span class="material-icons" style="margin: -2px">delete</span> <span class="material-icons" style="margin: -2px">delete</span>
@ -104,11 +104,11 @@ const isEditing = ref(false);
const isConfirming = ref(false); const isConfirming = ref(false);
const newName = ref(""); const newName = ref("");
watch(isEditing, () => (newName.value = save.value.name || "")); watch(isEditing, () => (newName.value = save.value.name ?? ""));
const isActive = computed(() => save.value && save.value.id === player.id); const isActive = computed(() => save.value != null && save.value.id === player.id);
const currentTime = computed(() => const currentTime = computed(() =>
isActive.value ? player.time : (save.value && save.value.time) || 0 isActive.value ? player.time : (save.value != null && save.value.time) ?? 0
); );
function changeName() { function changeName() {

View file

@ -15,7 +15,7 @@
:save="saves[element]" :save="saves[element]"
@open="openSave(element)" @open="openSave(element)"
@export="exportSave(element)" @export="exportSave(element)"
@editName="name => editSave(element, name)" @editName="(name: string) => editSave(element, name)"
@duplicate="duplicateSave(element)" @duplicate="duplicateSave(element)"
@delete="deleteSave(element)" @delete="deleteSave(element)"
/> />
@ -40,7 +40,7 @@
v-if="Object.keys(bank).length > 0" v-if="Object.keys(bank).length > 0"
:options="bank" :options="bank"
:modelValue="selectedPreset" :modelValue="selectedPreset"
@update:modelValue="preset => newFromPreset(preset as string)" @update:modelValue="(preset: unknown) => newFromPreset(preset as string)"
closeOnSelect closeOnSelect
placeholder="Select preset" placeholder="Select preset"
class="presets" class="presets"
@ -62,11 +62,10 @@
import Modal from "components/Modal.vue"; import Modal from "components/Modal.vue";
import projInfo from "data/projInfo.json"; import projInfo from "data/projInfo.json";
import { isHosting, room } from "data/socket"; import { isHosting, room } from "data/socket";
import type { PlayerData } from "game/player"; import type { Player } from "game/player";
import player, { stringifySave } from "game/player"; import player, { stringifySave } from "game/player";
import settings from "game/settings"; import settings from "game/settings";
import LZString from "lz-string"; import LZString from "lz-string";
import { ProxyState } from "util/proxies";
import { getUniqueID, loadSave, newSave, save } from "util/save"; import { getUniqueID, loadSave, newSave, save } from "util/save";
import type { ComponentPublicInstance } from "vue"; import type { ComponentPublicInstance } from "vue";
import { computed, nextTick, ref, shallowReactive, watch } from "vue"; import { computed, nextTick, ref, shallowReactive, watch } from "vue";
@ -75,7 +74,7 @@ import Select from "./fields/Select.vue";
import Text from "./fields/Text.vue"; import Text from "./fields/Text.vue";
import Save from "./Save.vue"; import Save from "./Save.vue";
export type LoadablePlayerData = Omit<Partial<PlayerData>, "id"> & { id: string; error?: unknown }; export type LoadablePlayerData = Omit<Partial<Player>, "id"> & { id: string; error?: unknown };
const isOpen = ref(false); const isOpen = ref(false);
const modal = ref<ComponentPublicInstance<typeof Modal> | null>(null); const modal = ref<ComponentPublicInstance<typeof Modal> | null>(null);
@ -198,7 +197,7 @@ const saves = computed(() =>
function exportSave(id: string) { function exportSave(id: string) {
let saveToExport; let saveToExport;
if (player.id === id) { if (player.id === id) {
saveToExport = stringifySave(player[ProxyState]); saveToExport = stringifySave(player);
} else { } else {
saveToExport = JSON.stringify(saves.value[id]); saveToExport = JSON.stringify(saves.value[id]);
} }
@ -231,7 +230,7 @@ function duplicateSave(id: string) {
} }
const playerData = { ...saves.value[id], id: getUniqueID() }; const playerData = { ...saves.value[id], id: getUniqueID() };
save(playerData as PlayerData); save(playerData as Player);
settings.saves.push(playerData.id); settings.saves.push(playerData.id);
} }
@ -275,7 +274,7 @@ function newFromPreset(preset: string) {
} }
const playerData = JSON.parse(preset); const playerData = JSON.parse(preset);
playerData.id = getUniqueID(); playerData.id = getUniqueID();
save(playerData as PlayerData); save(playerData as Player);
settings.saves.push(playerData.id); settings.saves.push(playerData.id);
@ -284,13 +283,13 @@ function newFromPreset(preset: string) {
function editSave(id: string, newName: string) { function editSave(id: string, newName: string) {
const currSave = saves.value[id]; const currSave = saves.value[id];
if (currSave) { if (currSave != null) {
currSave.name = newName; currSave.name = newName;
if (player.id === id) { if (player.id === id) {
player.name = newName; player.name = newName;
save(); save();
} else { } else {
save(currSave as PlayerData); save(currSave as Player);
cachedSaves[id] = undefined; cachedSaves[id] = undefined;
} }
} }

View file

@ -1,17 +1,11 @@
<template> <template>
<div class="tpsDisplay" v-if="!tps.isNan()"> <div class="tpsDisplay" v-if="!tps.isNan()">TPS: {{ formatWhole(tps) }}</div>
TPS: {{ formatWhole(tps) }}
<transition name="fade"
><span v-if="showLow" class="low">{{ formatWhole(low) }}</span></transition
>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import state from "game/state"; import state from "game/state";
import type { DecimalSource } from "util/bignum";
import Decimal, { formatWhole } from "util/bignum"; import Decimal, { formatWhole } from "util/bignum";
import { computed, ref, watchEffect } from "vue"; import { computed } from "vue";
const tps = computed(() => const tps = computed(() =>
Decimal.div( Decimal.div(
@ -19,20 +13,6 @@ const tps = computed(() =>
state.lastTenTicks.reduce((acc, curr) => acc + curr, 0) 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> </script>
<style scoped> <style scoped>

View file

@ -1,5 +1,6 @@
.modifier-container { .modifier-container {
display: flex; display: flex;
padding: 1px 8px;
} }
.modifier-container:nth-child(2n) { .modifier-container:nth-child(2n) {
@ -7,8 +8,12 @@
} }
.modifier-amount { .modifier-amount {
flex-basis: 100px;
flex-shrink: 0; flex-shrink: 0;
text-align: right;
}
:not(:first-of-type, :last-of-type) > .modifier-amount::after {
content: var(--unit);
opacity: 0;
} }
.modifier-description { .modifier-description {

View file

@ -56,6 +56,43 @@
border-radius: var(--border-radius); border-radius: var(--border-radius);
} }
.row-grid.mergeAdjacent > .feature:not(.dontMerge),
.row-grid.mergeAdjacent > .tooltip-container > .feature:not(.dontMerge) {
margin-left: 0;
margin-right: 0;
margin-bottom: 0;
margin-top: 0;
border-radius: 0;
}
.row-grid.mergeAdjacent > .feature:not(.dontMerge):last-child,
.row-grid.mergeAdjacent > .tooltip-container:last-child > .feature:not(.dontMerge) {
border-radius: 0 0 0 0;
}
.row-grid.mergeAdjacent > .feature:not(.dontMerge):first-child,
.row-grid.mergeAdjacent > .tooltip-container:first-child > .feature:not(.dontMerge) {
border-radius: 0 0 0 0;
}
.table-grid > .row-grid.mergeAdjacent:last-child > .feature:not(.dontMerge):first-child {
border-radius: 0 0 0 var(--border-radius);
}
.table-grid > .row-grid.mergeAdjacent:first-child > .feature:not(.dontMerge):last-child {
border-radius: 0 var(--border-radius) 0 0;
}
.table-grid > .row-grid.mergeAdjacent:first-child > .feature:not(.dontMerge):first-child {
border-radius: var(--border-radius) 0 0 0;
}
.table-grid > .row-grid.mergeAdjacent:last-child > .feature:not(.dontMerge):last-child {
border-radius: 0 0 var(--border-radius) 0;
}
/* /*
TODO how to implement mergeAdjacent for grids? TODO how to implement mergeAdjacent for grids?
.row.mergeAdjacent + .row.mergeAdjacent > .feature:not(.dontMerge) { .row.mergeAdjacent + .row.mergeAdjacent > .feature:not(.dontMerge) {

View file

@ -87,6 +87,10 @@ function onUpdate(value: SelectOption) {
background-color: var(--bought); background-color: var(--bought);
} }
.vue-input input {
font-size: inherit;
}
.vue-input input::placeholder { .vue-input input::placeholder {
color: var(--link); color: var(--link);
} }

View file

@ -26,7 +26,7 @@ const emit = defineEmits<{
const value = computed({ const value = computed({
get() { get() {
return String(unref(props.modelValue) || 0); return String(unref(props.modelValue) ?? 0);
}, },
set(value: string) { set(value: string) {
emit("update:modelValue", Number(value)); emit("update:modelValue", Number(value));

View file

@ -55,7 +55,7 @@ onMounted(() => {
const value = computed({ const value = computed({
get() { get() {
return unref(props.modelValue) || ""; return unref(props.modelValue) ?? "";
}, },
set(value: string) { set(value: string) {
emit("update:modelValue", value); emit("update:modelValue", value);

View file

@ -19,7 +19,7 @@ const emit = defineEmits<{
(e: "update:modelValue", value: boolean): void; (e: "update:modelValue", value: boolean): void;
}>(); }>();
const component = computed(() => coerceComponent(unref(props.title) || "<span></span>", "span")); const component = computed(() => coerceComponent(unref(props.title) ?? "<span></span>", "span"));
const value = computed({ const value = computed({
get() { get() {
@ -43,14 +43,16 @@ input {
span { span {
width: 100%; width: 100%;
padding-right: 41px;
position: relative; position: relative;
} }
/* track */ /* track */
input + span::before { input + span::before {
content: ""; content: "";
float: right; position: absolute;
margin: 5px 0 5px 10px; top: calc(50% - 7px);
right: 0px;
border-radius: 7px; border-radius: 7px;
width: 36px; width: 36px;
height: 14px; height: 14px;
@ -64,7 +66,7 @@ input + span::before {
input + span::after { input + span::after {
content: ""; content: "";
position: absolute; position: absolute;
top: 2px; top: calc(50% - 10px);
right: 16px; right: 16px;
border-radius: 50%; border-radius: 50%;
width: 20px; width: 20px;

View file

@ -38,6 +38,7 @@ const contentComponent = computeComponent(toRef(props, "content"));
padding: var(--feature-margin); padding: var(--feature-margin);
color: var(--foreground); color: var(--foreground);
cursor: pointer; cursor: pointer;
transition-duration: 0s;
} }
.collapsible-toggle:last-child { .collapsible-toggle:last-child {

View file

@ -0,0 +1 @@

View file

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

View file

@ -1,18 +1,23 @@
import Collapsible from "components/layout/Collapsible.vue";
import { GenericAchievement } from "features/achievements/achievement";
import type { Clickable, ClickableOptions, GenericClickable } from "features/clickables/clickable"; import type { Clickable, ClickableOptions, GenericClickable } from "features/clickables/clickable";
import { createClickable } from "features/clickables/clickable"; import { createClickable } from "features/clickables/clickable";
import type { GenericConversion } from "features/conversion"; import type { GenericConversion } from "features/conversion";
import type { CoercableComponent, JSXFunction, OptionsFunc, Replace } from "features/feature"; import type { CoercableComponent, JSXFunction, OptionsFunc, Replace } from "features/feature";
import { jsx, setDefault } from "features/feature"; import { jsx, setDefault } from "features/feature";
import { displayResource } from "features/resources/resource"; import { Resource, displayResource } from "features/resources/resource";
import type { GenericTree, GenericTreeNode, TreeNode, TreeNodeOptions } from "features/trees/tree"; import type { GenericTree, GenericTreeNode, TreeNode, TreeNodeOptions } from "features/trees/tree";
import { createTreeNode } from "features/trees/tree"; import { createTreeNode } from "features/trees/tree";
import type { GenericFormula } from "game/formulas/types";
import { BaseLayer } from "game/layers";
import type { Modifier } from "game/modifiers"; import type { Modifier } from "game/modifiers";
import type { Persistent } from "game/persistence"; import type { Persistent } from "game/persistence";
import { DefaultValue, persistent } from "game/persistence"; import { DefaultValue, persistent } from "game/persistence";
import player from "game/player"; import player from "game/player";
import settings from "game/settings";
import type { DecimalSource } from "util/bignum"; import type { DecimalSource } from "util/bignum";
import Decimal, { format } from "util/bignum"; import Decimal, { format, formatSmall, formatTime } from "util/bignum";
import type { WithRequired } from "util/common"; import { WithRequired, camelToTitle } from "util/common";
import type { import type {
Computable, Computable,
GetComputableType, GetComputableType,
@ -20,9 +25,8 @@ import type {
ProcessedComputable ProcessedComputable
} from "util/computed"; } from "util/computed";
import { convertComputable, processComputable } from "util/computed"; import { convertComputable, processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { getFirstFeature, renderColJSX, renderJSX } from "util/vue";
import { renderJSX } from "util/vue"; import type { ComputedRef, Ref } from "vue";
import type { Ref } from "vue";
import { computed, unref } from "vue"; import { computed, unref } from "vue";
import "./common.css"; import "./common.css";
@ -72,7 +76,7 @@ export type ResetButton<T extends ResetButtonOptions> = Replace<
display: GetComputableTypeWithDefault<T["display"], Ref<JSX.Element>>; display: GetComputableTypeWithDefault<T["display"], Ref<JSX.Element>>;
canClick: GetComputableTypeWithDefault<T["canClick"], Ref<boolean>>; canClick: GetComputableTypeWithDefault<T["canClick"], Ref<boolean>>;
minimumGain: GetComputableTypeWithDefault<T["minimumGain"], 1>; minimumGain: GetComputableTypeWithDefault<T["minimumGain"], 1>;
onClick: VoidFunction; onClick: (event?: MouseEvent | TouchEvent) => void;
} }
>; >;
@ -95,8 +99,8 @@ export type GenericResetButton = Replace<
export function createResetButton<T extends ClickableOptions & ResetButtonOptions>( export function createResetButton<T extends ClickableOptions & ResetButtonOptions>(
optionsFunc: OptionsFunc<T> optionsFunc: OptionsFunc<T>
): ResetButton<T> { ): ResetButton<T> {
return createClickable(() => { return createClickable(feature => {
const resetButton = optionsFunc(); const resetButton = optionsFunc.call(feature, feature);
processComputable(resetButton as T, "showNextAt"); processComputable(resetButton as T, "showNextAt");
setDefault(resetButton, "showNextAt", true); setDefault(resetButton, "showNextAt", true);
@ -124,16 +128,16 @@ export function createResetButton<T extends ClickableOptions & ResetButtonOption
)} )}
</b>{" "} </b>{" "}
{resetButton.conversion.gainResource.displayName} {resetButton.conversion.gainResource.displayName}
{unref(resetButton.showNextAt) ? ( {unref(resetButton.showNextAt) != null ? (
<div> <div>
<br /> <br />
{unref(resetButton.conversion.buyMax) ? "Next:" : "Req:"}{" "} {unref(resetButton.conversion.buyMax) ? "Next:" : "Req:"}{" "}
{displayResource( {displayResource(
resetButton.conversion.baseResource, resetButton.conversion.baseResource,
unref(resetButton.conversion.buyMax) || !unref(resetButton.conversion.buyMax) &&
Decimal.floor(unref(resetButton.conversion.actualGain)).neq(1) Decimal.gte(unref(resetButton.conversion.actualGain), 1)
? unref(resetButton.conversion.nextAt) ? unref(resetButton.conversion.currentAt)
: unref(resetButton.conversion.currentAt) : unref(resetButton.conversion.nextAt)
)}{" "} )}{" "}
{resetButton.conversion.baseResource.displayName} {resetButton.conversion.baseResource.displayName}
</div> </div>
@ -152,8 +156,8 @@ export function createResetButton<T extends ClickableOptions & ResetButtonOption
} }
const onClick = resetButton.onClick; const onClick = resetButton.onClick;
resetButton.onClick = function () { resetButton.onClick = function (event?: MouseEvent | TouchEvent) {
if (!unref(resetButton.canClick)) { if (unref(resetButton.canClick) === false) {
return; return;
} }
resetButton.conversion.convert(); resetButton.conversion.convert();
@ -161,7 +165,7 @@ export function createResetButton<T extends ClickableOptions & ResetButtonOption
if (resetButton.resetTime) { if (resetButton.resetTime) {
resetButton.resetTime.value = resetButton.resetTime[DefaultValue]; resetButton.resetTime.value = resetButton.resetTime[DefaultValue];
} }
onClick?.(); onClick?.(event);
}; };
return resetButton; return resetButton;
@ -174,11 +178,6 @@ export interface LayerTreeNodeOptions extends TreeNodeOptions {
layerID: string; layerID: string;
/** The color to display this tree node as */ /** The color to display this tree node as */
color: Computable<string>; // marking as required color: Computable<string>; // marking as required
/**
* The content to display in the tree node.
* Defaults to the layer's ID
*/
display?: Computable<CoercableComponent>;
/** Whether or not to append the layer to the tabs list. /** Whether or not to append the layer to the tabs list.
* If set to false, then the tree node will instead always remove all tabs to its right and then add the layer tab. * If set to false, then the tree node will instead always remove all tabs to its right and then add the layer tab.
* Defaults to true. * Defaults to true.
@ -209,14 +208,12 @@ export type GenericLayerTreeNode = Replace<
export function createLayerTreeNode<T extends LayerTreeNodeOptions>( export function createLayerTreeNode<T extends LayerTreeNodeOptions>(
optionsFunc: OptionsFunc<T> optionsFunc: OptionsFunc<T>
): LayerTreeNode<T> { ): LayerTreeNode<T> {
return createTreeNode(() => { return createTreeNode(feature => {
const options = optionsFunc(); const options = optionsFunc.call(feature, feature);
processComputable(options as T, "display"); setDefault(options, "display", camelToTitle(options.layerID));
setDefault(options, "display", options.layerID);
processComputable(options as T, "append"); processComputable(options as T, "append");
return { return {
...options, ...options,
display: options.display,
onClick: unref((options as unknown as GenericLayerTreeNode).append) onClick: unref((options as unknown as GenericLayerTreeNode).append)
? function () { ? function () {
if (player.tabs.includes(options.layerID)) { if (player.tabs.includes(options.layerID)) {
@ -236,9 +233,9 @@ export function createLayerTreeNode<T extends LayerTreeNodeOptions>(
/** An option object for a modifier display as a single section. **/ /** An option object for a modifier display as a single section. **/
export interface Section { export interface Section {
/** The header for this modifier. **/ /** The header for this modifier. **/
title: string; title: Computable<string>;
/** A subtitle for this modifier, e.g. to explain the context for the modifier. **/ /** A subtitle for this modifier, e.g. to explain the context for the modifier. **/
subtitle?: string; subtitle?: Computable<string>;
/** The modifier to be displaying in this section. **/ /** The modifier to be displaying in this section. **/
modifier: WithRequired<Modifier, "description">; modifier: WithRequired<Modifier, "description">;
/** The base value being modified. **/ /** The base value being modified. **/
@ -249,6 +246,8 @@ export interface Section {
baseText?: Computable<CoercableComponent>; baseText?: Computable<CoercableComponent>;
/** Whether or not this section should be currently visible to the player. **/ /** Whether or not this section should be currently visible to the player. **/
visible?: Computable<boolean>; visible?: Computable<boolean>;
/** Determines if numbers larger or smaller than the base should be displayed as red. */
smallerIsBetter?: boolean;
} }
/** /**
@ -265,6 +264,8 @@ export function createCollapsibleModifierSections(
base: ProcessedComputable<DecimalSource | undefined>[]; base: ProcessedComputable<DecimalSource | undefined>[];
baseText: ProcessedComputable<CoercableComponent | undefined>[]; baseText: ProcessedComputable<CoercableComponent | undefined>[];
visible: ProcessedComputable<boolean | undefined>[]; visible: ProcessedComputable<boolean | undefined>[];
title: ProcessedComputable<string | undefined>[];
subtitle: ProcessedComputable<string | undefined>[];
} }
| Record<string, never> = {}; | Record<string, never> = {};
let calculated = false; let calculated = false;
@ -274,12 +275,14 @@ export function createCollapsibleModifierSections(
processed.base = sections.map(s => convertComputable(s.base)); processed.base = sections.map(s => convertComputable(s.base));
processed.baseText = sections.map(s => convertComputable(s.baseText)); processed.baseText = sections.map(s => convertComputable(s.baseText));
processed.visible = sections.map(s => convertComputable(s.visible)); processed.visible = sections.map(s => convertComputable(s.visible));
processed.title = sections.map(s => convertComputable(s.title));
processed.subtitle = sections.map(s => convertComputable(s.subtitle));
calculated = true; calculated = true;
} }
return sections; return sections;
} }
const collapsed = persistent<Record<number, boolean>>({}); const collapsed = persistent<Record<number, boolean>>({}, false);
const jsxFunc = jsx(() => { const jsxFunc = jsx(() => {
const sections = calculateSections(); const sections = calculateSections();
@ -296,39 +299,67 @@ export function createCollapsibleModifierSections(
> >
</span> </span>
{s.title} {unref(processed.title[i])}
{s.subtitle ? <span class="subtitle"> ({s.subtitle})</span> : null} {unref(processed.subtitle[i]) != null ? (
<span class="subtitle"> ({unref(processed.subtitle[i])})</span>
) : null}
</h3> </h3>
); );
const modifiers = unref(collapsed.value[i]) ? null : ( const modifiers = unref(collapsed.value[i]) ? null : (
<> <>
<div class="modifier-container"> <div class="modifier-container">
<span class="modifier-description">
{renderJSX(unref(processed.baseText[i]) ?? "Base")}
</span>
<span class="modifier-amount"> <span class="modifier-amount">
{format(unref(processed.base[i]) ?? 1)} {format(unref(processed.base[i]) ?? 1)}
{s.unit} {s.unit}
</span> </span>
<span class="modifier-description">
{renderJSX(unref(processed.baseText[i]) ?? "Base")}
</span>
</div> </div>
{renderJSX(unref(s.modifier.description))} {s.modifier.description == null
? null
: renderJSX(unref(s.modifier.description))}
</> </>
); );
const hasPreviousSection = !firstVisibleSection; const hasPreviousSection = !firstVisibleSection;
firstVisibleSection = false; firstVisibleSection = false;
const base = unref(processed.base[i]) ?? 1;
const total = s.modifier.apply(base);
return ( return (
<> <>
{hasPreviousSection ? <br /> : null} {hasPreviousSection ? <br /> : null}
<div> <div
style={{
"--unit":
settings.alignUnits && s.unit != null ? "'" + s.unit + "'" : ""
}}
>
{header} {header}
<br /> <br />
{modifiers} {modifiers}
<hr /> <hr />
Total: {format(s.modifier.apply(unref(processed.base[i]) ?? 1))} <div class="modifier-container">
{s.unit} <span class="modifier-description">Total</span>
<span
class="modifier-amount"
style={
(
s.smallerIsBetter === true
? Decimal.gt(total, base ?? 1)
: Decimal.lt(total, base ?? 1)
)
? "color: var(--danger)"
: ""
}
>
{formatSmall(total)}
{s.unit}
</span>
</div>
</div> </div>
</> </>
); );
@ -346,3 +377,131 @@ export function createCollapsibleModifierSections(
export function colorText(textToColor: string, color = "var(--accent2)"): JSX.Element { export function colorText(textToColor: string, color = "var(--accent2)"): JSX.Element {
return <span style={{ color }}>{textToColor}</span>; return <span style={{ color }}>{textToColor}</span>;
} }
/**
* Creates a collapsible display of a list of achievements
* @param achievements A dictionary of the achievements to display, inserted in the order from easiest to hardest
*/
export function createCollapsibleAchievements(achievements: Record<string, GenericAchievement>) {
// Achievements are typically defined from easiest to hardest, and we want to show hardest first
const orderedAchievements = Object.values(achievements).reverse();
const collapseAchievements = persistent<boolean>(true, false);
const lockedAchievements = computed(() =>
orderedAchievements.filter(m => m.earned.value === false)
);
const { firstFeature, collapsedContent, hasCollapsedContent } = getFirstFeature(
orderedAchievements,
m => m.earned.value
);
const display = jsx(() => {
const achievementsToDisplay = [...lockedAchievements.value];
if (firstFeature.value) {
achievementsToDisplay.push(firstFeature.value);
}
return renderColJSX(
...achievementsToDisplay,
jsx(() => (
<Collapsible
collapsed={collapseAchievements}
content={collapsedContent}
display={
collapseAchievements.value
? "Show other completed achievements"
: "Hide other completed achievements"
}
v-show={unref(hasCollapsedContent)}
/>
))
);
});
return {
collapseAchievements: collapseAchievements,
display
};
}
/**
* Utility function for getting an ETA for when a target will be reached by a resource with a known (and assumed consistent) gain.
* @param resource The resource that will be increasing over time.
* @param rate The rate at which the resource is increasing.
* @param target The target amount of the resource to estimate the duration until.
*/
export function estimateTime(
resource: Resource,
rate: Computable<DecimalSource>,
target: Computable<DecimalSource>
) {
const processedRate = convertComputable(rate);
const processedTarget = convertComputable(target);
return computed(() => {
const currRate = unref(processedRate);
const currTarget = unref(processedTarget);
if (Decimal.gte(resource.value, currTarget)) {
return "Now";
} else if (Decimal.lte(currRate, 0)) {
return "Never";
}
return formatTime(Decimal.sub(currTarget, resource.value).div(currRate));
});
}
/**
* Utility function for displaying the result of a formula such that it will, when told to, preview how the formula's result will change.
* Requires a formula with a single variable inside.
* @param formula The formula to display the result of.
* @param showPreview Whether or not to preview how the formula's result will change.
* @param previewAmount The amount to _add_ to the current formula's variable amount to preview the change in result.
*/
export function createFormulaPreview(
formula: GenericFormula,
showPreview: Computable<boolean>,
previewAmount: Computable<DecimalSource> = 1
) {
const processedShowPreview = convertComputable(showPreview);
const processedPreviewAmount = convertComputable(previewAmount);
if (!formula.hasVariable()) {
console.error("Cannot create formula preview if the formula does not have a variable");
}
return jsx(() => {
if (unref(processedShowPreview)) {
const curr = formatSmall(formula.evaluate());
const preview = formatSmall(
formula.evaluate(
Decimal.add(
unref(formula.innermostVariable ?? 0),
unref(processedPreviewAmount)
)
)
);
return (
<>
<b>
<i>
{curr} {preview}
</i>
</b>
</>
);
}
return <>{formatSmall(formula.evaluate())}</>;
});
}
/**
* Utility function for getting a computed boolean for whether or not a given feature is currently rendered in the DOM.
* Note it will have a true value even if the feature is off screen.
* @param layer The layer the feature appears within
* @param id The ID of the feature
*/
export function isRendered(layer: BaseLayer, id: string): ComputedRef<boolean>;
/**
* Utility function for getting a computed boolean for whether or not a given feature is currently rendered in the DOM.
* Note it will have a true value even if the feature is off screen.
* @param layer The layer the feature appears within
* @param feature The feature that may be rendered
*/
export function isRendered(layer: BaseLayer, feature: { id: string }): ComputedRef<boolean>;
export function isRendered(layer: BaseLayer, idOrFeature: string | { id: string }) {
const id = typeof idOrFeature === "string" ? idOrFeature : idOrFeature.id;
return computed(() => id in layer.nodes.value);
}

View file

@ -0,0 +1,73 @@
/**
* @module
* @hidden
*/
import { main } from "data/projEntry";
import { createCumulativeConversion } from "features/conversion";
import { jsx } from "features/feature";
import { createHotkey } from "features/hotkey";
import { createReset } from "features/reset";
import MainDisplay from "features/resources/MainDisplay.vue";
import { createResource } from "features/resources/resource";
import { addTooltip } from "features/tooltips/tooltip";
import { createResourceTooltip } from "features/trees/tree";
import { BaseLayer, createLayer } from "game/layers";
import type { DecimalSource } from "util/bignum";
import { render } from "util/vue";
import { createLayerTreeNode, createResetButton } from "../common";
const id = "p";
const layer = createLayer(id, function (this: BaseLayer) {
const name = "Prestige";
const color = "#4BDC13";
const points = createResource<DecimalSource>(0, "prestige points");
const conversion = createCumulativeConversion(() => ({
formula: x => x.div(10).sqrt(),
baseResource: main.points,
gainResource: points
}));
const reset = createReset(() => ({
thingsToReset: (): Record<string, unknown>[] => [layer]
}));
const treeNode = createLayerTreeNode(() => ({
layerID: id,
color,
reset
}));
const tooltip = addTooltip(treeNode, {
display: createResourceTooltip(points),
pinnable: true
});
const resetButton = createResetButton(() => ({
conversion,
tree: main.tree,
treeNode
}));
const hotkey = createHotkey(() => ({
description: "Reset for prestige points",
key: "p",
onPress: resetButton.onClick
}));
return {
name,
color,
points,
tooltip,
display: jsx(() => (
<>
<MainDisplay resource={points} color={color} />
{render(resetButton)}
</>
)),
treeNode,
hotkey
};
});
export default layer;

View file

@ -1,7 +1,7 @@
import { jsx } from "features/feature"; import { jsx } from "features/feature";
import type { BaseLayer, GenericLayer } from "game/layers"; import type { BaseLayer, GenericLayer } from "game/layers";
import { createLayer } from "game/layers"; import { createLayer } from "game/layers";
import type { PlayerData } from "game/player"; import type { Player } from "game/player";
import { computed } from "vue"; import { computed } from "vue";
import Chat from "./Chat.vue"; import Chat from "./Chat.vue";
import HexGrid from "./HexGrid.vue"; import HexGrid from "./HexGrid.vue";
@ -62,7 +62,7 @@ export const hasWon = computed(() => {
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
export function fixOldSave( export function fixOldSave(
oldVersion: string | undefined, oldVersion: string | undefined,
player: Partial<PlayerData> player: Partial<Player>
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
): void {} ): void {}
/* eslint-enable @typescript-eslint/no-unused-vars */ /* eslint-enable @typescript-eslint/no-unused-vars */

View file

@ -0,0 +1,93 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "The name of the project, which will appear in the info tab and the header, if enabled. The page title will also be set to this value."
},
"description": {
"type": "string",
"description": "A description of the project, which will be used when the project is installed as a Progressive Web Application."
},
"id": {
"type": "string",
"description": "This is a unique ID used when saving player data. Changing this will effectively erase all save data for all players. This ID MUST be unique to your project, and should not be left as the default value. Otherwise, your project may use the save data from another project and cause issues for both projects.",
"minLength": 1
},
"author": {
"type": "string",
"description": "The author of the project, which will appear in the info tab."
},
"discordName": {
"type": "string",
"description": "The text to display for the discord server to point users to. This will appear when hovering over the discord icon, inside the info tab, the game over screen, as well as the NaN detected screen."
},
"discordLink": {
"type": "string",
"description": "The link for the discord server to point users to."
},
"versionNumber": {
"type": "string",
"description": "The current version of the project loaded. If the player data was last saved in a different version of the project, fixOldSave will be run, so you can perform any save migrations necessary. This will also appear in the nav, the info tab, and the game over screen.",
"markdownDescription": "The current version of the project loaded. If the player data was last saved in a different version of the project, [fixOldSave](https://moddingtree.com/guide/creating-your-project/project-entry.html#fixoldsave) will be run, so you can perform any save migrations necessary. This will also appear in the nav, the info tab, and the game over screen."
},
"versionTitle": {
"type": "string",
"description": "The display name for the current version of the project loaded. This will also appear in the nav, the info tab, and the game over screen unless set to an empty string."
},
"allowGoBack": {
"type": "boolean",
"description": "Whether or not to allow tabs (besides the first) to display a \"back\" button to close them (and any other tabs to the right of them)."
},
"defaultShowSmall": {
"type": "boolean",
"description": "Whether or not to allow resources to display small values (<.001). If false they'll just display as 0. Individual resources can also be configured to override this value."
},
"defaultDecimalsShown": {
"type": "number",
"description": "Default precision to display numbers at when passed into format. Individual format calls can override this value, and resources can be configured with a custom precision as well.",
"markdownDescription": "Default precision to display numbers at when passed into format. Individual format calls can override this value, and resources can be configured with a custom precision as well."
},
"useHeader": {
"type": "boolean",
"description": "Whether or not to display the nav as a header at the top of the screen. If disabled, the nav will appear on the left side of the screen laid over the first tab."
},
"banner": {
"type": ["boolean", "null"],
"description": "A path to an image file to display as the logo of the app. If null, the title will be shown instead. This will appear in the nav when useHeader is true.",
"markdownDescription": "A path to an image file to display as the logo of the app. If null, the title will be shown instead. This will appear in the nav when `useHeader` is true."
},
"logo": {
"type": "string",
"description": "A path to an image file to display as the logo of the app within the info tab. If left blank no logo will be shown."
},
"initialTabs": {
"type": "array",
"items": {
"type": "string"
},
"minItems": 1,
"uniqueItems": true,
"description": "The list of initial tabs to display on new saves. This value must have at least one element. Each element should be the ID of the layer to display in that tab."
},
"maxTickLength": {
"type": "number",
"description": "The longest duration a single tick can be, in seconds. When calculating things like offline time, a single tick will be forced to be this amount or lower. This will make calculating offline time spread out across many ticks as necessary. The default value is 1 hour."
},
"offlineLimit": {
"type": "number",
"description": "The max amount of time that can be stored as offline time, in hours."
},
"enablePausing": {
"type": "boolean",
"description": "Whether or not to allow the player to pause the game. Turning this off disables the toggle from the options menu as well as the NaN screen. Developers can still manually pause by just setting player.devSpeed to 0 in console (or 1 to resume).",
"markdownDescription": "Whether or not to allow the player to pause the game. Turning this off disables the toggle from the options menu as well as the NaN screen. Developers can still manually pause by just running `player.devSpeed = 0` in console (or `= 1` to resume)."
},
"exportEncoding": {
"type": "string",
"enum": ["base64", "lz", "plain"],
"description": "The encoding to use when exporting to the clipboard. Plain-text is fast to generate but is easiest for the player to manipulate and cheat with. Base 64 is slightly slower and the string will be longer but will offer a small barrier to people trying to cheat. LZ-String is the slowest method, but produces the smallest strings and still offers a small barrier to those trying to cheat. Some sharing platforms like pastebin may automatically delete base64 encoded text, and some sites might not support all the characters used in lz-string exports."
}
}
}

View file

@ -1,4 +1,6 @@
{ {
"$schema": "./projInfo-schema.json",
"title": "Chromatic Latice", "title": "Chromatic Latice",
"description": "A multiplayer game about light and hexagons", "description": "A multiplayer game about light and hexagons",
"id": "chromatic", "id": "chromatic",

View file

@ -1,9 +1,9 @@
<template> <template>
<div <div
v-if="unref(visibility) !== Visibility.None" v-if="isVisible(visibility)"
:style="[ :style="[
{ {
visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined, visibility: isHidden(visibility) ? 'hidden' : undefined,
backgroundImage: (earned && image && `url(${image})`) || '' backgroundImage: (earned && image && `url(${image})`) || ''
}, },
unref(style) ?? [] unref(style) ?? []
@ -12,41 +12,44 @@
feature: true, feature: true,
achievement: true, achievement: true,
locked: !unref(earned), locked: !unref(earned),
bought: unref(earned), done: unref(earned),
small: unref(small),
...unref(classes) ...unref(classes)
}" }"
> >
<component v-if="component" :is="component" /> <component v-if="comp" :is="comp" />
<MarkNode :mark="unref(mark)" /> <MarkNode :mark="unref(mark)" />
<Node :id="id" /> <Node :id="id" />
</div> </div>
</template> </template>
<script lang="ts"> <script lang="tsx">
import "components/common/features.css"; import "components/common/features.css";
import MarkNode from "components/MarkNode.vue"; import MarkNode from "components/MarkNode.vue";
import Node from "components/Node.vue"; import Node from "components/Node.vue";
import type { CoercableComponent } from "features/feature"; import { isHidden, isVisible, jsx, Visibility } from "features/feature";
import { Visibility } from "features/feature"; import { displayRequirements, Requirements } from "game/requirements";
import { computeOptionalComponent, processedPropType } from "util/vue"; import { coerceComponent, isCoercableComponent, processedPropType, unwrapRef } from "util/vue";
import type { StyleValue } from "vue"; import { Component, defineComponent, shallowRef, StyleValue, toRefs, unref, UnwrapRef, watchEffect } from "vue";
import { defineComponent, toRefs, unref } from "vue"; import { GenericAchievement } from "./achievement";
export default defineComponent({ export default defineComponent({
props: { props: {
visibility: { visibility: {
type: processedPropType<Visibility>(Number), type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true required: true
}, },
display: processedPropType<CoercableComponent>(Object, String, Function), display: processedPropType<UnwrapRef<GenericAchievement["display"]>>(Object, String, Function),
earned: { earned: {
type: processedPropType<boolean>(Boolean), type: processedPropType<boolean>(Boolean),
required: true required: true
}, },
requirements: processedPropType<Requirements>(Object, Array),
image: processedPropType<string>(String), image: processedPropType<string>(String),
style: processedPropType<StyleValue>(String, Object, Array), style: processedPropType<StyleValue>(String, Object, Array),
classes: processedPropType<Record<string, boolean>>(Object), classes: processedPropType<Record<string, boolean>>(Object),
mark: processedPropType<boolean | string>(Boolean, String), mark: processedPropType<boolean | string>(Boolean, String),
small: processedPropType<boolean>(Boolean),
id: { id: {
type: String, type: String,
required: true required: true
@ -57,12 +60,50 @@ export default defineComponent({
MarkNode MarkNode
}, },
setup(props) { setup(props) {
const { display } = toRefs(props); const { display, requirements, earned } = 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 ? currDisplay.requirement : jsx(() => displayRequirements(unwrapRef(requirements) ?? [])), "h3");
const EffectDisplay = coerceComponent(currDisplay.effectDisplay || "", "b");
const OptionsDisplay = unwrapRef(earned) ?
coerceComponent(currDisplay.optionsDisplay || "", "span") :
"";
comp.value = coerceComponent(
jsx(() => (
<span>
<Requirement />
{currDisplay.effectDisplay != null ? (
<div>
<EffectDisplay />
</div>
) : null}
{currDisplay.optionsDisplay != null ? (
<div class="equal-spaced">
<OptionsDisplay />
</div>
) : null}
</span>
))
);
});
return { return {
component: computeOptionalComponent(display), comp,
unref, unref,
Visibility Visibility,
isVisible,
isHidden
}; };
} }
}); });
@ -76,4 +117,32 @@ export default defineComponent({
color: white; color: white;
text-shadow: 0 0 2px #000000; text-shadow: 0 0 2px #000000;
} }
.achievement:not(.small) {
height: unset;
width: calc(100% - 10px);
min-width: 120px;
padding-left: 5px;
padding-right: 5px;
background-color: var(--locked);
border-width: 4px;
border-radius: 5px;
color: rgba(0, 0, 0, 0.5);
font-size: unset;
text-shadow: unset;
}
.achievement.done {
background-color: var(--bought);
cursor: default;
}
.achievement :deep(.equal-spaced) {
display: flex;
justify-content: center;
}
.achievement :deep(.equal-spaced > *) {
margin: auto;
}
</style> </style>

View file

@ -1,20 +1,35 @@
import { computed } from "@vue/reactivity";
import { isArray } from "@vue/shared";
import Select from "components/fields/Select.vue";
import AchievementComponent from "features/achievements/Achievement.vue"; import AchievementComponent from "features/achievements/Achievement.vue";
import { GenericDecorator } from "features/decorators/common";
import { import {
CoercableComponent, CoercableComponent,
Component, Component,
GatherProps, GatherProps,
getUniqueID, GenericComponent,
OptionsFunc, OptionsFunc,
Replace, Replace,
setDefault,
StyleValue, StyleValue,
Visibility Visibility,
getUniqueID,
jsx,
setDefault
} from "features/feature"; } from "features/feature";
import { globalBus } from "game/events";
import "game/notifications"; import "game/notifications";
import type { Persistent } from "game/persistence"; import type { Persistent } from "game/persistence";
import { persistent } from "game/persistence"; import { persistent } from "game/persistence";
import player from "game/player"; import player from "game/player";
import settings from "game/settings"; import {
Requirements,
createBooleanRequirement,
createVisibilityRequirement,
displayRequirements,
requirementsMet
} from "game/requirements";
import settings, { registerSettingField } from "game/settings";
import { camelToTitle } from "util/common";
import type { import type {
Computable, Computable,
GetComputableType, GetComputableType,
@ -23,34 +38,79 @@ import type {
} from "util/computed"; } from "util/computed";
import { processComputable } from "util/computed"; import { processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy } from "util/proxies";
import { coerceComponent } from "util/vue"; import { coerceComponent, isCoercableComponent } from "util/vue";
import { unref, watchEffect } from "vue"; import { unref, watchEffect } from "vue";
import { useToast } from "vue-toastification"; import { useToast } from "vue-toastification";
const toast = useToast(); const toast = useToast();
/** A symbol used to identify {@link Achievement} features. */
export const AchievementType = Symbol("Achievement"); export const AchievementType = Symbol("Achievement");
/** Modes for only displaying some achievements. */
export enum AchievementDisplay {
All = "all",
//Last = "last",
Configurable = "configurable",
Incomplete = "incomplete",
None = "none"
}
/**
* An object that configures an {@link Achievement}.
*/
export interface AchievementOptions { export interface AchievementOptions {
visibility?: Computable<Visibility>; /** Whether this achievement should be visible. */
shouldEarn?: () => boolean; visibility?: Computable<Visibility | boolean>;
display?: Computable<CoercableComponent>; /** The requirement(s) to earn this achievement. Can be left null if using {@link BaseAchievement.complete}. */
requirements?: Requirements;
/** The display to use for this achievement. */
display?: Computable<
| CoercableComponent
| {
/** Description of the requirement(s) for this achievement. If unspecified then the requirements will be displayed automatically based on {@link requirements}. */
requirement?: CoercableComponent;
/** Description of what will change (if anything) for achieving this. */
effectDisplay?: CoercableComponent;
/** Any additional things to display on this achievement, such as a toggle for it's effect. */
optionsDisplay?: CoercableComponent;
}
>;
/** Shows a marker on the corner of the feature. */
mark?: Computable<boolean | string>; mark?: Computable<boolean | string>;
/** Toggles a smaller design for the feature. */
small?: Computable<boolean>;
/** An image to display as the background for this achievement. */
image?: Computable<string>; image?: Computable<string>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>; style?: Computable<StyleValue>;
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>; classes?: Computable<Record<string, boolean>>;
/** Whether or not to display a notification popup when this achievement is earned. */
showPopups?: Computable<boolean>;
/** A function that is called when the achievement is completed. */
onComplete?: VoidFunction; onComplete?: VoidFunction;
} }
/**
* The properties that are added onto a processed {@link AchievementOptions} to create an {@link Achievement}.
*/
export interface BaseAchievement { export interface BaseAchievement {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string; id: string;
/** Whether or not this achievement has been earned. */
earned: Persistent<boolean>; earned: Persistent<boolean>;
/** A function to complete this achievement. */
complete: VoidFunction; complete: VoidFunction;
/** A symbol that helps identify features of the same type. */
type: typeof AchievementType; type: typeof AchievementType;
[Component]: typeof AchievementComponent; /** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>; [GatherProps]: () => Record<string, unknown>;
} }
/** An object that represents a feature with requirements that is passively earned upon meeting certain requirements. */
export type Achievement<T extends AchievementOptions> = Replace< export type Achievement<T extends AchievementOptions> = Replace<
T & BaseAchievement, T & BaseAchievement,
{ {
@ -60,68 +120,169 @@ export type Achievement<T extends AchievementOptions> = Replace<
image: GetComputableType<T["image"]>; image: GetComputableType<T["image"]>;
style: GetComputableType<T["style"]>; style: GetComputableType<T["style"]>;
classes: GetComputableType<T["classes"]>; classes: GetComputableType<T["classes"]>;
showPopups: GetComputableTypeWithDefault<T["showPopups"], true>;
} }
>; >;
/** A type that matches any valid {@link Achievement} object. */
export type GenericAchievement = Replace< export type GenericAchievement = Replace<
Achievement<AchievementOptions>, Achievement<AchievementOptions>,
{ {
visibility: ProcessedComputable<Visibility>; visibility: ProcessedComputable<Visibility | boolean>;
showPopups: ProcessedComputable<boolean>;
} }
>; >;
/**
* Lazily creates an achievement with the given options.
* @param optionsFunc Achievement options.
*/
export function createAchievement<T extends AchievementOptions>( export function createAchievement<T extends AchievementOptions>(
optionsFunc?: OptionsFunc<T, BaseAchievement, GenericAchievement> optionsFunc?: OptionsFunc<T, BaseAchievement, GenericAchievement>,
...decorators: GenericDecorator[]
): Achievement<T> { ): Achievement<T> {
const earned = persistent<boolean>(false); const earned = persistent<boolean>(false, false);
return createLazyProxy(() => { const decoratedData = decorators.reduce(
const achievement = optionsFunc?.() ?? ({} as ReturnType<NonNullable<typeof optionsFunc>>); (current, next) => Object.assign(current, next.getPersistentData?.()),
{}
);
return createLazyProxy(feature => {
const achievement =
optionsFunc?.call(feature, feature) ??
({} as ReturnType<NonNullable<typeof optionsFunc>>);
achievement.id = getUniqueID("achievement-"); achievement.id = getUniqueID("achievement-");
achievement.type = AchievementType; achievement.type = AchievementType;
achievement[Component] = AchievementComponent; achievement[Component] = AchievementComponent as GenericComponent;
for (const decorator of decorators) {
decorator.preConstruct?.(achievement);
}
achievement.earned = earned; achievement.earned = earned;
achievement.complete = function () { achievement.complete = function () {
if (earned.value) {
return;
}
earned.value = true; earned.value = true;
const genericAchievement = achievement as GenericAchievement;
genericAchievement.onComplete?.();
if (
genericAchievement.display != null &&
unref(genericAchievement.showPopups) === true
) {
const display = unref(genericAchievement.display);
let Display;
if (isCoercableComponent(display)) {
Display = coerceComponent(display);
} else if (display.requirement != null) {
Display = coerceComponent(display.requirement);
} else {
Display = displayRequirements(genericAchievement.requirements ?? []);
}
toast.info(
<div>
<h3>Achievement earned!</h3>
<div>
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
{/* @ts-ignore */}
<Display />
</div>
</div>
);
}
}; };
Object.assign(achievement, decoratedData);
processComputable(achievement as T, "visibility"); processComputable(achievement as T, "visibility");
setDefault(achievement, "visibility", Visibility.Visible); setDefault(achievement, "visibility", Visibility.Visible);
const visibility = achievement.visibility as ProcessedComputable<Visibility | boolean>;
achievement.visibility = computed(() => {
const display = unref((achievement as GenericAchievement).display);
switch (settings.msDisplay) {
default:
case AchievementDisplay.All:
return unref(visibility);
case AchievementDisplay.Configurable:
if (
unref(achievement.earned) &&
!(
display != null &&
typeof display == "object" &&
"optionsDisplay" in (display as Record<string, unknown>)
)
) {
return Visibility.None;
}
return unref(visibility);
case AchievementDisplay.Incomplete:
if (unref(achievement.earned)) {
return Visibility.None;
}
return unref(visibility);
case AchievementDisplay.None:
return Visibility.None;
}
});
processComputable(achievement as T, "display"); processComputable(achievement as T, "display");
processComputable(achievement as T, "mark"); processComputable(achievement as T, "mark");
processComputable(achievement as T, "small");
processComputable(achievement as T, "image"); processComputable(achievement as T, "image");
processComputable(achievement as T, "style"); processComputable(achievement as T, "style");
processComputable(achievement as T, "classes"); processComputable(achievement as T, "classes");
processComputable(achievement as T, "showPopups");
setDefault(achievement, "showPopups", true);
for (const decorator of decorators) {
decorator.postConstruct?.(achievement);
}
const decoratedProps = decorators.reduce(
(current, next) => Object.assign(current, next.getGatheredProps?.(achievement)),
{}
);
achievement[GatherProps] = function (this: GenericAchievement) { achievement[GatherProps] = function (this: GenericAchievement) {
const { visibility, display, earned, image, style, classes, mark, id } = this; const {
return { visibility, display, earned, image, style: unref(style), classes, mark, id }; visibility,
display,
requirements,
earned,
image,
style,
classes,
mark,
small,
id
} = this;
return {
visibility,
display,
requirements,
earned,
image,
style: unref(style),
classes,
mark,
small,
id,
...decoratedProps
};
}; };
if (achievement.shouldEarn) { if (achievement.requirements) {
const genericAchievement = achievement as GenericAchievement; const genericAchievement = achievement as GenericAchievement;
const requirements = [
createVisibilityRequirement(genericAchievement),
createBooleanRequirement(() => !genericAchievement.earned.value),
...(isArray(achievement.requirements)
? achievement.requirements
: [achievement.requirements])
];
watchEffect(() => { watchEffect(() => {
if (settings.active !== player.id) return; if (settings.active !== player.id) return;
if ( if (requirementsMet(requirements)) {
!genericAchievement.earned.value && genericAchievement.complete();
unref(genericAchievement.visibility) === Visibility.Visible &&
genericAchievement.shouldEarn?.()
) {
genericAchievement.earned.value = true;
genericAchievement.onComplete?.();
if (genericAchievement.display) {
const Display = coerceComponent(unref(genericAchievement.display));
toast.info(
<div>
<h3>Achievement earned!</h3>
<div>
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
{/* @ts-ignore */}
<Display />
</div>
</div>
);
}
} }
}); });
} }
@ -129,3 +290,34 @@ export function createAchievement<T extends AchievementOptions>(
return achievement as unknown as Achievement<T>; return achievement as unknown as Achievement<T>;
}); });
} }
declare module "game/settings" {
interface Settings {
msDisplay: AchievementDisplay;
}
}
globalBus.on("loadSettings", settings => {
setDefault(settings, "msDisplay", AchievementDisplay.All);
});
const msDisplayOptions = Object.values(AchievementDisplay).map(option => ({
label: camelToTitle(option),
value: option
}));
registerSettingField(
jsx(() => (
<Select
title={jsx(() => (
<span class="option-title">
Show achievements
<desc>Select which achievements to display based on criterias.</desc>
</span>
))}
options={msDisplayOptions}
onUpdate:modelValue={value => (settings.msDisplay = value as AchievementDisplay)}
modelValue={settings.msDisplay}
/>
))
);

293
src/features/action.tsx Normal file
View file

@ -0,0 +1,293 @@
import { isArray } from "@vue/shared";
import ClickableComponent from "features/clickables/Clickable.vue";
import {
Component,
findFeatures,
GatherProps,
GenericComponent,
getUniqueID,
jsx,
JSXFunction,
OptionsFunc,
Replace,
setDefault,
StyleValue,
Visibility
} from "features/feature";
import { globalBus } from "game/events";
import { persistent } from "game/persistence";
import Decimal, { DecimalSource } from "lib/break_eternity";
import { Unsubscribe } from "nanoevents";
import { Direction } from "util/common";
import type {
Computable,
GetComputableType,
GetComputableTypeWithDefault,
ProcessedComputable
} from "util/computed";
import { processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies";
import { coerceComponent, isCoercableComponent, render } from "util/vue";
import { computed, Ref, ref, unref } from "vue";
import { BarOptions, createBar, GenericBar } from "./bars/bar";
import { ClickableOptions } from "./clickables/clickable";
import { Decorator, GenericDecorator } from "./decorators/common";
/** A symbol used to identify {@link Action} features. */
export const ActionType = Symbol("Action");
/**
* An object that configures an {@link Action}.
*/
export interface ActionOptions extends Omit<ClickableOptions, "onClick" | "onHold"> {
/** The cooldown during which the action cannot be performed again, in seconds. */
duration: Computable<DecimalSource>;
/** Whether or not the action should perform automatically when the cooldown is finished. */
autoStart?: Computable<boolean>;
/** A function that is called when the action is clicked. */
onClick: (amount: DecimalSource) => void;
/** A pass-through to the {@link Bar} used to display the cooldown progress for the action. */
barOptions?: Partial<BarOptions>;
}
/**
* The properties that are added onto a processed {@link ActionOptions} to create an {@link Action}.
*/
export interface BaseAction {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string;
/** A symbol that helps identify features of the same type. */
type: typeof ActionType;
/** Whether or not the player is holding down the action. Actions will be considered clicked as soon as the cooldown completes when being held down. */
isHolding: Ref<boolean>;
/** The current amount of progress through the cooldown. */
progress: Ref<DecimalSource>;
/** The bar used to display the current cooldown progress. */
progressBar: GenericBar;
/** Update the cooldown the specified number of seconds */
update: (diff: number) => void;
/** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>;
}
/** An object that represents a feature that can be clicked upon, and then has a cooldown before it can be clicked again. */
export type Action<T extends ActionOptions> = Replace<
T & BaseAction,
{
duration: GetComputableType<T["duration"]>;
autoStart: GetComputableTypeWithDefault<T["autoStart"], false>;
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
canClick: GetComputableTypeWithDefault<T["canClick"], true>;
classes: GetComputableType<T["classes"]>;
style: GetComputableType<T["style"]>;
mark: GetComputableType<T["mark"]>;
display: JSXFunction;
onClick: VoidFunction;
}
>;
/** A type that matches any valid {@link Action} object. */
export type GenericAction = Replace<
Action<ActionOptions>,
{
autoStart: ProcessedComputable<boolean>;
visibility: ProcessedComputable<Visibility | boolean>;
canClick: ProcessedComputable<boolean>;
}
>;
/**
* Lazily creates an action with the given options.
* @param optionsFunc Action options.
*/
export function createAction<T extends ActionOptions>(
optionsFunc?: OptionsFunc<T, BaseAction, GenericAction>,
...decorators: GenericDecorator[]
): Action<T> {
const progress = persistent<DecimalSource>(0);
const decoratedData = decorators.reduce(
(current, next) => Object.assign(current, next.getPersistentData?.()),
{}
);
return createLazyProxy(feature => {
const action =
optionsFunc?.call(feature, feature) ??
({} as ReturnType<NonNullable<typeof optionsFunc>>);
action.id = getUniqueID("action-");
action.type = ActionType;
action[Component] = ClickableComponent as GenericComponent;
// Required because of display changing types
const genericAction = action as unknown as GenericAction;
for (const decorator of decorators) {
decorator.preConstruct?.(action);
}
action.isHolding = ref(false);
action.progress = progress;
Object.assign(action, decoratedData);
processComputable(action as T, "visibility");
setDefault(action, "visibility", Visibility.Visible);
processComputable(action as T, "duration");
processComputable(action as T, "autoStart");
setDefault(action, "autoStart", false);
processComputable(action as T, "canClick");
setDefault(action, "canClick", true);
processComputable(action as T, "classes");
processComputable(action as T, "style");
processComputable(action as T, "mark");
processComputable(action as T, "display");
const style = action.style as ProcessedComputable<StyleValue | undefined>;
action.style = computed(() => {
const currStyle: StyleValue[] = [
{
cursor: Decimal.gte(
progress.value,
unref(action.duration as ProcessedComputable<DecimalSource>)
)
? "pointer"
: "progress",
display: "flex",
flexDirection: "column"
}
];
const originalStyle = unref(style);
if (isArray(originalStyle)) {
currStyle.push(...originalStyle);
} else if (originalStyle != null) {
currStyle.push(originalStyle);
}
return currStyle as StyleValue;
});
action.progressBar = createBar(() => ({
direction: Direction.Right,
width: 100,
height: 10,
borderStyle: "border-color: black",
baseStyle: "margin-top: -1px",
progress: () => Decimal.div(progress.value, unref(genericAction.duration)),
...action.barOptions
}));
const canClick = action.canClick as ProcessedComputable<boolean>;
action.canClick = computed(
() =>
unref(canClick) &&
Decimal.gte(
progress.value,
unref(action.duration as ProcessedComputable<DecimalSource>)
)
);
const display = action.display as GetComputableType<ClickableOptions["display"]>;
action.display = jsx(() => {
const currDisplay = unref(display);
let Comp: GenericComponent | undefined;
if (isCoercableComponent(currDisplay)) {
Comp = coerceComponent(currDisplay);
} else if (currDisplay != null) {
const Title = coerceComponent(currDisplay.title ?? "", "h3");
const Description = coerceComponent(currDisplay.description, "div");
Comp = coerceComponent(
jsx(() => (
<span>
{currDisplay.title != null ? (
<div>
<Title />
</div>
) : null}
<Description />
</span>
))
);
}
return (
<>
<div style="flex-grow: 1" />
{Comp == null ? null : <Comp />}
<div style="flex-grow: 1" />
{render(genericAction.progressBar)}
</>
);
});
const onClick = action.onClick.bind(action);
action.onClick = function () {
if (unref(action.canClick) === false) {
return;
}
const amount = Decimal.div(progress.value, unref(genericAction.duration));
onClick?.(amount);
progress.value = 0;
};
action.update = function (diff) {
const duration = unref(genericAction.duration);
if (Decimal.gte(progress.value, duration)) {
progress.value = duration;
} else {
progress.value = Decimal.add(progress.value, diff);
if (genericAction.isHolding.value || unref(genericAction.autoStart)) {
genericAction.onClick();
}
}
};
for (const decorator of decorators) {
decorator.postConstruct?.(action);
}
const decoratedProps = decorators.reduce(
(current, next) => Object.assign(current, next.getGatheredProps?.(action)),
{}
);
action[GatherProps] = function (this: GenericAction) {
const {
display,
visibility,
style,
classes,
onClick,
isHolding,
canClick,
small,
mark,
id
} = this;
return {
display,
visibility,
style: unref(style),
classes,
onClick,
isHolding,
canClick,
small,
mark,
id,
...decoratedProps
};
};
return action as unknown as Action<T>;
});
}
const listeners: Record<string, Unsubscribe | undefined> = {};
globalBus.on("addLayer", layer => {
const actions: GenericAction[] = findFeatures(layer, ActionType) as GenericAction[];
listeners[layer.id] = layer.on("postUpdate", diff => {
actions.forEach(action => action.update(diff));
});
});
globalBus.on("removeLayer", layer => {
// unsubscribe from postUpdate
listeners[layer.id]?.();
listeners[layer.id] = undefined;
});

View file

@ -1,11 +1,11 @@
<template> <template>
<div <div
v-if="unref(visibility) !== Visibility.None" v-if="isVisible(visibility)"
:style="[ :style="[
{ {
width: unref(width) + 'px', width: unref(width) + 'px',
height: unref(height) + 'px', height: unref(height) + 'px',
visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined visibility: isHidden(visibility) ? 'hidden' : undefined
}, },
unref(style) ?? {} unref(style) ?? {}
]" ]"
@ -44,7 +44,7 @@
<script lang="ts"> <script lang="ts">
import MarkNode from "components/MarkNode.vue"; import MarkNode from "components/MarkNode.vue";
import Node from "components/Node.vue"; import Node from "components/Node.vue";
import { CoercableComponent, Visibility } from "features/feature"; import { CoercableComponent, isHidden, isVisible, Visibility } from "features/feature";
import type { DecimalSource } from "util/bignum"; import type { DecimalSource } from "util/bignum";
import Decimal from "util/bignum"; import Decimal from "util/bignum";
import { Direction } from "util/common"; import { Direction } from "util/common";
@ -72,7 +72,7 @@ export default defineComponent({
}, },
display: processedPropType<CoercableComponent>(Object, String, Function), display: processedPropType<CoercableComponent>(Object, String, Function),
visibility: { visibility: {
type: processedPropType<Visibility>(Number), type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true required: true
}, },
style: processedPropType<StyleValue>(Object, String, Array), style: processedPropType<StyleValue>(Object, String, Array),
@ -120,7 +120,7 @@ export default defineComponent({
barStyle.clipPath = `inset(0% ${normalizedProgress.value}% 0% 0%)`; barStyle.clipPath = `inset(0% ${normalizedProgress.value}% 0% 0%)`;
break; break;
case Direction.Left: case Direction.Left:
barStyle.clipPath = `inset(0% 0% 0% ${normalizedProgress.value} + '%)`; barStyle.clipPath = `inset(0% 0% 0% ${normalizedProgress.value}%)`;
break; break;
case Direction.Default: case Direction.Default:
barStyle.clipPath = "inset(0% 50% 0% 0%)"; barStyle.clipPath = "inset(0% 50% 0% 0%)";
@ -136,7 +136,9 @@ export default defineComponent({
barStyle, barStyle,
component, component,
unref, unref,
Visibility Visibility,
isVisible,
isHidden
}; };
} }
}); });
@ -177,5 +179,6 @@ export default defineComponent({
margin-left: -0.5px; margin-left: -0.5px;
transition-duration: 0.2s; transition-duration: 0.2s;
z-index: 2; z-index: 2;
transition-duration: 0.05s;
} }
</style> </style>

View file

@ -1,6 +1,13 @@
import BarComponent from "features/bars/Bar.vue"; import BarComponent from "features/bars/Bar.vue";
import type { CoercableComponent, OptionsFunc, Replace, StyleValue } from "features/feature"; import { GenericDecorator } from "features/decorators/common";
import { Component, GatherProps, getUniqueID, setDefault, Visibility } from "features/feature"; import type {
CoercableComponent,
GenericComponent,
OptionsFunc,
Replace,
StyleValue
} from "features/feature";
import { Component, GatherProps, Visibility, getUniqueID, setDefault } from "features/feature";
import type { DecimalSource } from "util/bignum"; import type { DecimalSource } from "util/bignum";
import { Direction } from "util/common"; import { Direction } from "util/common";
import type { import type {
@ -13,31 +20,56 @@ import { processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy } from "util/proxies";
import { unref } from "vue"; import { unref } from "vue";
/** A symbol used to identify {@link Bar} features. */
export const BarType = Symbol("Bar"); export const BarType = Symbol("Bar");
/**
* An object that configures a {@link Bar}.
*/
export interface BarOptions { export interface BarOptions {
visibility?: Computable<Visibility>; /** Whether this bar should be visible. */
visibility?: Computable<Visibility | boolean>;
/** The width of the bar. */
width: Computable<number>; width: Computable<number>;
/** The height of the bar. */
height: Computable<number>; height: Computable<number>;
/** The direction in which the bar progresses. */
direction: Computable<Direction>; direction: Computable<Direction>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>; style?: Computable<StyleValue>;
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>; classes?: Computable<Record<string, boolean>>;
/** CSS to apply to the bar's border. */
borderStyle?: Computable<StyleValue>; borderStyle?: Computable<StyleValue>;
/** CSS to apply to the bar's base. */
baseStyle?: Computable<StyleValue>; baseStyle?: Computable<StyleValue>;
/** CSS to apply to the bar's text. */
textStyle?: Computable<StyleValue>; textStyle?: Computable<StyleValue>;
/** CSS to apply to the bar's fill. */
fillStyle?: Computable<StyleValue>; fillStyle?: Computable<StyleValue>;
/** The progress value of the bar, from 0 to 1. */
progress: Computable<DecimalSource>; progress: Computable<DecimalSource>;
/** The display to use for this bar. */
display?: Computable<CoercableComponent>; display?: Computable<CoercableComponent>;
/** Shows a marker on the corner of the feature. */
mark?: Computable<boolean | string>; mark?: Computable<boolean | string>;
} }
/**
* The properties that are added onto a processed {@link BarOptions} to create a {@link Bar}.
*/
export interface BaseBar { export interface BaseBar {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string; id: string;
/** A symbol that helps identify features of the same type. */
type: typeof BarType; type: typeof BarType;
[Component]: typeof BarComponent; /** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>; [GatherProps]: () => Record<string, unknown>;
} }
/** An object that represents a feature that displays some sort of progress or completion or resource with a cap. */
export type Bar<T extends BarOptions> = Replace< export type Bar<T extends BarOptions> = Replace<
T & BaseBar, T & BaseBar,
{ {
@ -57,21 +89,37 @@ export type Bar<T extends BarOptions> = Replace<
} }
>; >;
/** A type that matches any valid {@link Bar} object. */
export type GenericBar = Replace< export type GenericBar = Replace<
Bar<BarOptions>, Bar<BarOptions>,
{ {
visibility: ProcessedComputable<Visibility>; visibility: ProcessedComputable<Visibility | boolean>;
} }
>; >;
/**
* Lazily creates a bar with the given options.
* @param optionsFunc Bar options.
*/
export function createBar<T extends BarOptions>( export function createBar<T extends BarOptions>(
optionsFunc: OptionsFunc<T, BaseBar, GenericBar> optionsFunc: OptionsFunc<T, BaseBar, GenericBar>,
...decorators: GenericDecorator[]
): Bar<T> { ): Bar<T> {
return createLazyProxy(() => { const decoratedData = decorators.reduce(
const bar = optionsFunc(); (current, next) => Object.assign(current, next.getPersistentData?.()),
{}
);
return createLazyProxy(feature => {
const bar = optionsFunc.call(feature, feature);
bar.id = getUniqueID("bar-"); bar.id = getUniqueID("bar-");
bar.type = BarType; bar.type = BarType;
bar[Component] = BarComponent; bar[Component] = BarComponent as GenericComponent;
for (const decorator of decorators) {
decorator.preConstruct?.(bar);
}
Object.assign(bar, decoratedData);
processComputable(bar as T, "visibility"); processComputable(bar as T, "visibility");
setDefault(bar, "visibility", Visibility.Visible); setDefault(bar, "visibility", Visibility.Visible);
@ -88,6 +136,14 @@ export function createBar<T extends BarOptions>(
processComputable(bar as T, "display"); processComputable(bar as T, "display");
processComputable(bar as T, "mark"); processComputable(bar as T, "mark");
for (const decorator of decorators) {
decorator.postConstruct?.(bar);
}
const decoratedProps = decorators.reduce(
(current, next) => Object.assign(current, next.getGatheredProps?.(bar)),
{}
);
bar[GatherProps] = function (this: GenericBar) { bar[GatherProps] = function (this: GenericBar) {
const { const {
progress, progress,
@ -119,7 +175,8 @@ export function createBar<T extends BarOptions>(
baseStyle, baseStyle,
fillStyle, fillStyle,
mark, mark,
id id,
...decoratedProps
}; };
}; };

View file

@ -1,7 +1,6 @@
<template> <template>
<panZoom <panZoom
v-if="visibility !== Visibility.None" v-if="isVisible(visibility)"
v-show="visibility === Visibility.Visible"
:style="[ :style="[
{ {
width, width,
@ -18,16 +17,28 @@
@touchmove="drag" @touchmove="drag"
@mousedown="(e: MouseEvent) => mouseDown(e)" @mousedown="(e: MouseEvent) => mouseDown(e)"
@touchstart="(e: TouchEvent) => mouseDown(e)" @touchstart="(e: TouchEvent) => mouseDown(e)"
@mouseup="() => endDragging(dragging)" @mouseup="() => endDragging(unref(draggingNode))"
@touchend.passive="() => endDragging(dragging)" @touchend.passive="() => endDragging(unref(draggingNode))"
@mouseleave="() => endDragging(dragging)" @mouseleave="() => endDragging(unref(draggingNode), true)"
@zoom="zoom" @zoom="zoom"
> >
<svg class="stage" width="100%" height="100%"> <svg class="stage" width="100%" height="100%">
<g class="g1"> <g class="g1">
<transition-group name="link" appear> <transition-group name="link" appear>
<g v-for="(link, i) in unref(links) || []" :key="i"> <g
<BoardLinkVue :link="link" /> v-for="link in unref(links) || []"
:key="`${link.startNode.id}-${link.endNode.id}`"
>
<BoardLinkVue
:link="link"
:dragging="unref(draggingNode)"
:dragged="
link.startNode === unref(draggingNode) ||
link.endNode === unref(draggingNode)
? dragged
: undefined
"
/>
</g> </g>
</transition-group> </transition-group>
<transition-group name="grow" :duration="500" appear> <transition-group name="grow" :duration="500" appear>
@ -35,14 +46,17 @@
<BoardNodeVue <BoardNodeVue
:node="node" :node="node"
:nodeType="types[node.type]" :nodeType="types[node.type]"
:dragging="draggingNode" :dragging="unref(draggingNode)"
:dragged="dragged" :dragged="unref(draggingNode) === node ? dragged : undefined"
:hasDragged="hasDragged" :hasDragged="unref(draggingNode) == null ? false : hasDragged"
:receivingNode="receivingNode?.id === node.id" :receivingNode="unref(receivingNode) === node"
:selectedNode="unref(selectedNode)" :isSelected="unref(selectedNode) === node"
:selectedAction="unref(selectedAction)" :selectedAction="
unref(selectedNode) === node ? unref(selectedAction) : null
"
@mouseDown="mouseDown" @mouseDown="mouseDown"
@endDragging="endDragging" @endDragging="endDragging"
@clickAction="(actionId: string) => clickAction(node, actionId)"
/> />
</g> </g>
</transition-group> </transition-group>
@ -68,9 +82,9 @@ import type {
} from "features/boards/board"; } from "features/boards/board";
import { getNodeProperty } from "features/boards/board"; import { getNodeProperty } from "features/boards/board";
import type { StyleValue } from "features/feature"; import type { StyleValue } from "features/feature";
import { Visibility } from "features/feature"; import { Visibility, isVisible } from "features/feature";
import type { ProcessedComputable } from "util/computed"; import type { ProcessedComputable } from "util/computed";
import { computed, ref, Ref, toRefs, unref } from "vue"; import { Ref, computed, ref, toRefs, unref, watchEffect } from "vue";
import BoardLinkVue from "./BoardLink.vue"; import BoardLinkVue from "./BoardLink.vue";
import BoardNodeVue from "./BoardNode.vue"; import BoardNodeVue from "./BoardNode.vue";
@ -78,7 +92,7 @@ const _props = defineProps<{
nodes: Ref<BoardNode[]>; nodes: Ref<BoardNode[]>;
types: Record<string, GenericNodeType>; types: Record<string, GenericNodeType>;
state: Ref<BoardData>; state: Ref<BoardData>;
visibility: ProcessedComputable<Visibility>; visibility: ProcessedComputable<Visibility | boolean>;
width?: ProcessedComputable<string>; width?: ProcessedComputable<string>;
height?: ProcessedComputable<string>; height?: ProcessedComputable<string>;
style?: ProcessedComputable<StyleValue>; style?: ProcessedComputable<StyleValue>;
@ -86,33 +100,36 @@ const _props = defineProps<{
links: Ref<BoardNodeLink[] | null>; links: Ref<BoardNodeLink[] | null>;
selectedAction: Ref<GenericBoardNodeAction | null>; selectedAction: Ref<GenericBoardNodeAction | null>;
selectedNode: Ref<BoardNode | null>; selectedNode: Ref<BoardNode | null>;
draggingNode: Ref<BoardNode | null>;
receivingNode: Ref<BoardNode | null>;
mousePosition: Ref<{ x: number; y: number } | null>; mousePosition: Ref<{ x: number; y: number } | null>;
setReceivingNode: (node: BoardNode | null) => void;
setDraggingNode: (node: BoardNode | null) => void;
}>(); }>();
const props = toRefs(_props); const props = toRefs(_props);
const lastMousePosition = ref({ x: 0, y: 0 }); const lastMousePosition = ref({ x: 0, y: 0 });
const dragged = ref({ x: 0, y: 0 }); const dragged = ref({ x: 0, y: 0 });
const dragging = ref<number | null>(null);
const hasDragged = ref(false); const hasDragged = ref(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const stage = ref<any>(null); const stage = ref<any>(null);
const currentZoom = ref<number>(1); const currentZoom = ref<number>(1);
const draggingNode = computed(() =>
dragging.value == null ? undefined : props.nodes.value.find(node => node.id === dragging.value)
);
const sortedNodes = computed(() => { const sortedNodes = computed(() => {
const nodes = props.nodes.value.slice(); const nodes = props.nodes.value.slice();
if (draggingNode.value) { if (props.selectedNode.value) {
const node = nodes.splice(nodes.indexOf(draggingNode.value), 1)[0]; const node = nodes.splice(nodes.indexOf(props.selectedNode.value), 1)[0];
nodes.push(node);
}
if (props.draggingNode.value) {
const node = nodes.splice(nodes.indexOf(props.draggingNode.value), 1)[0];
nodes.push(node); nodes.push(node);
} }
return nodes; return nodes;
}); });
const receivingNode = computed(() => { watchEffect(() => {
const node = draggingNode.value; const node = props.draggingNode.value;
if (node == null) { if (node == null) {
return null; return null;
} }
@ -122,26 +139,30 @@ const receivingNode = computed(() => {
y: node.position.y + dragged.value.y y: node.position.y + dragged.value.y
}; };
let smallestDistance = Number.MAX_VALUE; 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 = props.setReceivingNode.value(
Math.pow(position.x - curr.position.x, 2) + Math.pow(position.y - curr.position.y, 2); props.nodes.value.reduce((smallest: BoardNode | null, curr: BoardNode) => {
let size = getNodeProperty(nodeType.size, curr); if (curr.id === node.id) {
if (distanceSquared > smallestDistance || distanceSquared > size * size) { return smallest;
return smallest; }
} const nodeType = props.types.value[curr.type];
const canAccept = getNodeProperty(nodeType.canAccept, curr, node);
if (!canAccept) {
return smallest;
}
smallestDistance = distanceSquared; const distanceSquared =
return curr; Math.pow(position.x - curr.position.x, 2) +
}, null); 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)
);
}); });
const cursors = computed(() => Object.entries(cursorPositions.value).filter(([id]) => id in nicknames.value)); const cursors = computed(() => Object.entries(cursorPositions.value).filter(([id]) => id in nicknames.value));
@ -149,10 +170,11 @@ const cursors = computed(() => Object.entries(cursorPositions.value).filter(([id
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
function onInit(panzoomInstance: any) { function onInit(panzoomInstance: any) {
panzoomInstance.setTransformOrigin(null); panzoomInstance.setTransformOrigin(null);
panzoomInstance.moveTo(stage.value.$el.clientWidth / 2, stage.value.$el.clientHeight / 2);
} }
function mouseDown(e: MouseEvent | TouchEvent, nodeID: number | null = null, draggable = false) { function mouseDown(e: MouseEvent | TouchEvent, node: BoardNode | null = null, draggable = false) {
if (dragging.value == null) { if (props.draggingNode.value == null) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -176,10 +198,10 @@ function mouseDown(e: MouseEvent | TouchEvent, nodeID: number | null = null, dra
hasDragged.value = false; hasDragged.value = false;
if (draggable) { if (draggable) {
dragging.value = nodeID; props.setDraggingNode.value(node);
} }
} }
if (nodeID != null) { if (node != null) {
props.state.value.selectedNode = null; props.state.value.selectedNode = null;
props.state.value.selectedAction = null; props.state.value.selectedAction = null;
} }
@ -194,7 +216,7 @@ function drag(e: MouseEvent | TouchEvent) {
clientX = e.touches[0].clientX; clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY; clientY = e.touches[0].clientY;
} else { } else {
endDragging(dragging.value); endDragging(props.draggingNode.value);
props.mousePosition.value = null; props.mousePosition.value = null;
return; return;
} }
@ -204,8 +226,8 @@ function drag(e: MouseEvent | TouchEvent) {
} }
props.mousePosition.value = { props.mousePosition.value = {
x: (clientX - x - 10), x: (clientX - x) / scale,
y: (clientY - y - 50) y: (clientY - y) / scale
}; };
dragged.value = { dragged.value = {
@ -221,35 +243,45 @@ function drag(e: MouseEvent | TouchEvent) {
hasDragged.value = true; hasDragged.value = true;
} }
if (dragging.value) { if (props.draggingNode.value != null) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
} }
} }
function endDragging(nodeID: number | null) { function endDragging(node: BoardNode | null, mouseLeave = false) {
if (dragging.value != null && dragging.value === nodeID && draggingNode.value != null) { if (props.draggingNode.value != null && props.draggingNode.value === node) {
draggingNode.value.position.x += Math.round(dragged.value.x / 25) * 25; if (props.receivingNode.value == null) {
draggingNode.value.position.y += Math.round(dragged.value.y / 25) * 25; props.draggingNode.value.position.x += Math.round(dragged.value.x / 25) * 25;
props.draggingNode.value.position.y += Math.round(dragged.value.y / 25) * 25;
}
const nodes = props.nodes.value; const nodes = props.nodes.value;
nodes.splice(nodes.indexOf(draggingNode.value), 1); nodes.push(nodes.splice(nodes.indexOf(props.draggingNode.value), 1)[0]);
nodes.push(draggingNode.value);
if (receivingNode.value) { if (props.receivingNode.value) {
props.types.value[receivingNode.value.type].onDrop?.( props.types.value[props.receivingNode.value.type].onDrop?.(
receivingNode.value, props.receivingNode.value,
draggingNode.value props.draggingNode.value
); );
} }
dragging.value = null; props.setDraggingNode.value(null);
} else if (!hasDragged.value) { } else if (!hasDragged.value && !mouseLeave) {
props.state.value.selectedNode = null; props.state.value.selectedNode = null;
props.state.value.selectedAction = null; props.state.value.selectedAction = null;
} }
} }
function clickAction(node: BoardNode, actionId: string) {
if (props.state.value.selectedAction === actionId) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
unref(props.selectedAction)!.onClick(unref(props.selectedNode)!);
} else {
props.state.value = { ...props.state.value, selectedAction: actionId };
}
}
function zoom() { function zoom() {
const { x, y, scale } = stage.value.panZoomInstance.getTransform(); const { x, y, scale } = stage.value.panZoomInstance.getTransform();
const { x: clientX, y: clientY } = lastMousePosition.value; const { x: clientX, y: clientY } = lastMousePosition.value;

View file

@ -1,7 +1,7 @@
<template> <template>
<line <line
class="link" class="link"
v-bind="link" v-bind="linkProps"
:class="{ pulsing: link.pulsing }" :class="{ pulsing: link.pulsing }"
:x1="startPosition.x" :x1="startPosition.x"
:y1="startPosition.y" :y1="startPosition.y"
@ -11,34 +11,55 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { BoardNodeLink } from "features/boards/board"; import type { BoardNode, BoardNodeLink } from "features/boards/board";
import { kebabifyObject } from "util/vue";
import { computed, toRefs, unref } from "vue"; import { computed, toRefs, unref } from "vue";
const _props = defineProps<{ const _props = defineProps<{
link: BoardNodeLink; link: BoardNodeLink;
dragging: BoardNode | null;
dragged?: {
x: number;
y: number;
};
}>(); }>();
const props = toRefs(_props); const props = toRefs(_props);
const startPosition = computed(() => { const startPosition = computed(() => {
const position = props.link.value.startNode.position; const position = { ...props.link.value.startNode.position };
if (props.link.value.offsetStart) { if (props.link.value.offsetStart) {
position.x += unref(props.link.value.offsetStart).x; position.x += unref(props.link.value.offsetStart).x;
position.y += unref(props.link.value.offsetStart).y; position.y += unref(props.link.value.offsetStart).y;
} }
if (props.dragging?.value === props.link.value.startNode) {
position.x += props.dragged?.value?.x ?? 0;
position.y += props.dragged?.value?.y ?? 0;
}
return position; return position;
}); });
const endPosition = computed(() => { const endPosition = computed(() => {
const position = props.link.value.endNode.position; const position = { ...props.link.value.endNode.position };
if (props.link.value.offsetEnd) { if (props.link.value.offsetEnd) {
position.x += unref(props.link.value.offsetEnd).x; position.x += unref(props.link.value.offsetEnd).x;
position.y += unref(props.link.value.offsetEnd).y; position.y += unref(props.link.value.offsetEnd).y;
} }
if (props.dragging?.value === props.link.value.endNode) {
position.x += props.dragged?.value?.x ?? 0;
position.y += props.dragged?.value?.y ?? 0;
}
return position; return position;
}); });
const linkProps = computed(() => kebabifyObject(_props.link as unknown as Record<string, unknown>));
</script> </script>
<style scoped> <style scoped>
.link {
transition-duration: 0s;
pointer-events: none;
}
.link.pulsing { .link.pulsing {
animation: pulsing 2s ease-in infinite; animation: pulsing 2s ease-in infinite;
} }

View file

@ -1,50 +1,22 @@
<template> <template>
<!-- Ugly casting to prevent TS compiler error about style because vue doesn't think it supports arrays when it does -->
<g <g
class="boardnode" class="boardnode"
:class="node.type" :class="{ [node.type]: true, isSelected, isDraggable, ...classes }"
:style="{ opacity: dragging?.id === node.id && hasDragged ? 0.5 : 1 }" :style="[{ opacity: dragging?.id === node.id && hasDragged ? 0.5 : 1 }, style ?? []] as unknown as (string | CSSProperties)"
:transform="`translate(${position.x},${position.y})`" :transform="`translate(${position.x},${position.y})${isSelected ? ' scale(1.2)' : ''}`"
> >
<transition name="actions" appear> <BoardNodeAction
<g v-if="isSelected && actions"> :actions="actions ?? []"
<!-- TODO move to separate file --> :is-selected="isSelected"
<g :node="node"
v-for="(action, index) in actions" :node-type="nodeType"
:key="action.id" :selected-action="selectedAction"
class="action" @click-action="(actionId: string) => emit('clickAction', actionId)"
: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 <g
class="node-container" class="node-container"
@mouseenter="isHovering = true"
@mouseleave="isHovering = false"
@mousedown="mouseDown" @mousedown="mouseDown"
@touchstart.passive="mouseDown" @touchstart.passive="mouseDown"
@mouseup="mouseUp" @mouseup="mouseUp"
@ -69,7 +41,7 @@
/> />
<circle <circle
class="progressFill" class="progress progressFill"
v-if="progressDisplay === ProgressDisplay.Fill" v-if="progressDisplay === ProgressDisplay.Fill"
:r="Math.max(size * progress - 2, 0)" :r="Math.max(size * progress - 2, 0)"
:fill="progressColor" :fill="progressColor"
@ -77,7 +49,7 @@
<circle <circle
v-else v-else
:r="size + 4.5" :r="size + 4.5"
class="progressRing" class="progress progressRing"
fill="transparent" fill="transparent"
:stroke-dasharray="(size + 4.5) * 2 * Math.PI" :stroke-dasharray="(size + 4.5) * 2 * Math.PI"
:stroke-width="5" :stroke-width="5"
@ -113,7 +85,7 @@
<rect <rect
v-if="progressDisplay === ProgressDisplay.Fill" v-if="progressDisplay === ProgressDisplay.Fill"
class="progressFill" class="progress progressFill"
:width="Math.max(size * sqrtTwo * progress - 2, 0)" :width="Math.max(size * sqrtTwo * progress - 2, 0)"
:height="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}, ${ :transform="`translate(${-Math.max(size * sqrtTwo * progress - 2, 0) / 2}, ${
@ -123,7 +95,7 @@
/> />
<rect <rect
v-else v-else
class="progressDiamond" class="progress progressDiamond"
:width="size * sqrtTwo + 9" :width="size * sqrtTwo + 9"
:height="size * sqrtTwo + 9" :height="size * sqrtTwo + 9"
:transform="`translate(${-(size * sqrtTwo + 9) / 2}, ${ :transform="`translate(${-(size * sqrtTwo + 9) / 2}, ${
@ -145,7 +117,7 @@
<transition name="fade" appear> <transition name="fade" appear>
<g v-if="label"> <g v-if="label">
<text <text
:fill="label.color || titleColor" :fill="label.color ?? titleColor"
class="node-title" class="node-title"
:class="{ pulsing: label.pulsing }" :class="{ pulsing: label.pulsing }"
:y="-size - 20" :y="-size - 20"
@ -157,10 +129,11 @@
<transition name="fade" appear> <transition name="fade" appear>
<text <text
v-if="isSelected && selectedAction" v-if="isSelected && selectedAction"
:fill="titleColor" :fill="confirmationLabel.color ?? titleColor"
class="node-title" class="node-title"
:class="{ pulsing: confirmationLabel.pulsing }"
:y="size + 75" :y="size + 75"
>Tap again to confirm</text >{{ confirmationLabel.text }}</text
> >
</transition> </transition>
</g> </g>
@ -169,34 +142,34 @@
<script setup lang="ts"> <script setup lang="ts">
import themes from "data/themes"; import themes from "data/themes";
import type { BoardNode, GenericBoardNodeAction, GenericNodeType } from "features/boards/board"; import type { BoardNode, GenericBoardNodeAction, GenericNodeType } from "features/boards/board";
import { ProgressDisplay, getNodeProperty, Shape } from "features/boards/board"; import { ProgressDisplay, Shape, getNodeProperty } from "features/boards/board";
import { Visibility } from "features/feature"; import { isVisible } from "features/feature";
import settings from "game/settings"; import settings from "game/settings";
import { computed, ref, toRefs, unref, watch } from "vue"; import { CSSProperties, computed, toRefs, unref, watch } from "vue";
import BoardNodeAction from "./BoardNodeAction.vue";
const sqrtTwo = Math.sqrt(2); const sqrtTwo = Math.sqrt(2);
const _props = defineProps<{ const _props = defineProps<{
node: BoardNode; node: BoardNode;
nodeType: GenericNodeType; nodeType: GenericNodeType;
dragging?: BoardNode; dragging: BoardNode | null;
dragged?: { dragged?: {
x: number; x: number;
y: number; y: number;
}; };
hasDragged?: boolean; hasDragged?: boolean;
receivingNode?: boolean; receivingNode?: boolean;
selectedNode?: BoardNode | null; isSelected: boolean;
selectedAction?: GenericBoardNodeAction | null; selectedAction: GenericBoardNodeAction | null;
}>(); }>();
const props = toRefs(_props); const props = toRefs(_props);
const emit = defineEmits<{ const emit = defineEmits<{
(e: "mouseDown", event: MouseEvent | TouchEvent, node: number, isDraggable: boolean): void; (e: "mouseDown", event: MouseEvent | TouchEvent, node: BoardNode, isDraggable: boolean): void;
(e: "endDragging", node: number): void; (e: "endDragging", node: BoardNode): void;
(e: "clickAction", actionId: string): void;
}>(); }>();
const isHovering = ref(false);
const isSelected = computed(() => unref(props.selectedNode) === unref(props.node));
const isDraggable = computed(() => const isDraggable = computed(() =>
getNodeProperty(props.nodeType.value.draggable, unref(props.node)) getNodeProperty(props.nodeType.value.draggable, unref(props.node))
); );
@ -204,47 +177,66 @@ const isDraggable = computed(() =>
watch(isDraggable, value => { watch(isDraggable, value => {
const node = unref(props.node); const node = unref(props.node);
if (unref(props.dragging) === node && !value) { if (unref(props.dragging) === node && !value) {
emit("endDragging", node.id); emit("endDragging", node);
} }
}); });
const actions = computed(() => { const actions = computed(() => {
const node = unref(props.node); const node = unref(props.node);
return getNodeProperty(props.nodeType.value.actions, node)?.filter( return getNodeProperty(props.nodeType.value.actions, node)?.filter(action =>
action => getNodeProperty(action.visibility, node) !== Visibility.None isVisible(getNodeProperty(action.visibility, node))
); );
}); });
const position = computed(() => { const position = computed(() => {
const node = unref(props.node); const node = unref(props.node);
const dragged = unref(props.dragged);
return getNodeProperty(props.nodeType.value.draggable, node) && if (
getNodeProperty(props.nodeType.value.draggable, node) &&
unref(props.dragging)?.id === node.id && unref(props.dragging)?.id === node.id &&
dragged unref(props.dragged) != null
? { ) {
x: node.position.x + Math.round(dragged.x / 25) * 25, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
y: node.position.y + Math.round(dragged.y / 25) * 25 const { x, y } = unref(props.dragged)!;
} return {
: node.position; x: node.position.x + Math.round(x / 25) * 25,
y: node.position.y + Math.round(y / 25) * 25
};
}
return node.position;
}); });
const shape = computed(() => getNodeProperty(props.nodeType.value.shape, unref(props.node))); const shape = computed(() => getNodeProperty(props.nodeType.value.shape, unref(props.node)));
const title = computed(() => getNodeProperty(props.nodeType.value.title, 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 label = computed(
() =>
(props.isSelected.value
? unref(props.selectedAction) &&
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
getNodeProperty(unref(props.selectedAction)!.tooltip, unref(props.node))
: null) ?? getNodeProperty(props.nodeType.value.label, unref(props.node))
);
const confirmationLabel = computed(() =>
getNodeProperty(
unref(props.selectedAction)?.confirmationLabel ?? {
text: "Tap again to confirm"
},
unref(props.node)
)
);
const size = computed(() => getNodeProperty(props.nodeType.value.size, unref(props.node))); const size = computed(() => getNodeProperty(props.nodeType.value.size, unref(props.node)));
const progress = computed( const progress = computed(
() => getNodeProperty(props.nodeType.value.progress, unref(props.node)) || 0 () => getNodeProperty(props.nodeType.value.progress, unref(props.node)) ?? 0
); );
const backgroundColor = computed(() => themes[settings.theme].variables["--background"]); const backgroundColor = computed(() => themes[settings.theme].variables["--background"]);
const outlineColor = computed( const outlineColor = computed(
() => () =>
getNodeProperty(props.nodeType.value.outlineColor, unref(props.node)) || getNodeProperty(props.nodeType.value.outlineColor, unref(props.node)) ??
themes[settings.theme].variables["--outline"] themes[settings.theme].variables["--outline"]
); );
const fillColor = computed( const fillColor = computed(
() => () =>
getNodeProperty(props.nodeType.value.fillColor, unref(props.node)) || getNodeProperty(props.nodeType.value.fillColor, unref(props.node)) ??
themes[settings.theme].variables["--raised-background"] themes[settings.theme].variables["--raised-background"]
); );
const progressColor = computed(() => const progressColor = computed(() =>
@ -252,7 +244,7 @@ const progressColor = computed(() =>
); );
const titleColor = computed( const titleColor = computed(
() => () =>
getNodeProperty(props.nodeType.value.titleColor, unref(props.node)) || getNodeProperty(props.nodeType.value.titleColor, unref(props.node)) ??
themes[settings.theme].variables["--foreground"] themes[settings.theme].variables["--foreground"]
); );
const progressDisplay = computed(() => const progressDisplay = computed(() =>
@ -260,36 +252,21 @@ const progressDisplay = computed(() =>
); );
const canAccept = computed( const canAccept = computed(
() => () =>
unref(props.dragging) != null && props.dragging.value != null &&
unref(props.hasDragged) && unref(props.hasDragged) &&
getNodeProperty(props.nodeType.value.canAccept, unref(props.node)) getNodeProperty(props.nodeType.value.canAccept, unref(props.node), props.dragging.value)
);
const actionDistance = computed(() =>
getNodeProperty(props.nodeType.value.actionDistance, unref(props.node))
); );
const style = computed(() => getNodeProperty(props.nodeType.value.style, unref(props.node)));
const classes = computed(() => getNodeProperty(props.nodeType.value.classes, unref(props.node)));
function mouseDown(e: MouseEvent | TouchEvent) { function mouseDown(e: MouseEvent | TouchEvent) {
emit("mouseDown", e, props.node.value.id, isDraggable.value); emit("mouseDown", e, props.node.value, isDraggable.value);
} }
function mouseUp() { function mouseUp(e: MouseEvent | TouchEvent) {
if (!props.hasDragged?.value) { if (!props.hasDragged?.value) {
emit("endDragging", props.node.value);
props.nodeType.value.onClick?.(props.node.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(); e.stopPropagation();
} }
} }
@ -301,33 +278,35 @@ function actionMouseUp(e: MouseEvent | TouchEvent, action: GenericBoardNodeActio
transition-duration: 0s; transition-duration: 0s;
} }
.boardnode:hover .body {
fill: var(--highlighted);
}
.boardnode.isSelected .body {
fill: var(--accent1) !important;
}
.boardnode:not(.isDraggable) .body {
fill: var(--locked);
}
.node-title { .node-title {
text-anchor: middle; text-anchor: middle;
dominant-baseline: middle; dominant-baseline: middle;
font-family: monospace; font-family: monospace;
font-size: 200%; font-size: 200%;
pointer-events: none; pointer-events: none;
filter: drop-shadow(3px 3px 2px var(--tooltip-background));
}
.progress {
transition-duration: 0.05s;
} }
.progressRing { .progressRing {
transform: rotate(-90deg); 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-enter-from,
.fade-leave-to { .fade-leave-to {
opacity: 0; opacity: 0;
@ -353,11 +332,6 @@ function actionMouseUp(e: MouseEvent | TouchEvent, action: GenericBoardNodeActio
</style> </style>
<style> <style>
.actions-enter-from .action,
.actions-leave-to .action {
transform: translate(0, 0);
}
.grow-enter-from .node-container, .grow-enter-from .node-container,
.grow-leave-to .node-container { .grow-leave-to .node-container {
transform: scale(0); transform: scale(0);

View file

@ -0,0 +1,109 @@
<template>
<transition name="actions" appear>
<g v-if="isSelected && actions">
<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>
</template>
<script setup lang="ts">
import themes from "data/themes";
import type { BoardNode, GenericBoardNodeAction, GenericNodeType } from "features/boards/board";
import { getNodeProperty } from "features/boards/board";
import settings from "game/settings";
import { computed, toRefs, unref } from "vue";
const _props = defineProps<{
node: BoardNode;
nodeType: GenericNodeType;
actions?: GenericBoardNodeAction[];
isSelected: boolean;
selectedAction: GenericBoardNodeAction | null;
}>();
const props = toRefs(_props);
const emit = defineEmits<{
(e: "clickAction", actionId: string): void;
}>();
const size = computed(() => getNodeProperty(props.nodeType.value.size, unref(props.node)));
const outlineColor = computed(
() =>
getNodeProperty(props.nodeType.value.outlineColor, unref(props.node)) ??
themes[settings.theme].variables["--outline"]
);
const titleColor = computed(
() =>
getNodeProperty(props.nodeType.value.titleColor, unref(props.node)) ??
themes[settings.theme].variables["--foreground"]
);
const actionDistance = computed(() =>
getNodeProperty(props.nodeType.value.actionDistance, unref(props.node))
);
function performAction(e: MouseEvent | TouchEvent, action: GenericBoardNodeAction) {
emit("clickAction", 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>
.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;
}
</style>
<style>
.actions-enter-from .action,
.actions-leave-to .action {
transform: translate(0, 0);
}
</style>

View file

@ -1,5 +1,5 @@
import BoardComponent from "features/boards/Board.vue"; import BoardComponent from "features/boards/Board.vue";
import type { OptionsFunc, Replace, StyleValue } from "features/feature"; import type { GenericComponent, OptionsFunc, Replace, StyleValue } from "features/feature";
import { import {
Component, Component,
findFeatures, findFeatures,
@ -9,10 +9,10 @@ import {
Visibility Visibility
} from "features/feature"; } from "features/feature";
import { globalBus } from "game/events"; import { globalBus } from "game/events";
import type { Persistent, State } from "game/persistence"; import { DefaultValue, deletePersistent, Persistent, State } from "game/persistence";
import { persistent } from "game/persistence"; import { persistent } from "game/persistence";
import type { Unsubscribe } from "nanoevents"; import type { Unsubscribe } from "nanoevents";
import { isFunction } from "util/common"; import { Direction, isFunction } from "util/common";
import type { import type {
Computable, Computable,
GetComputableType, GetComputableType,
@ -21,26 +21,35 @@ import type {
} from "util/computed"; } from "util/computed";
import { processComputable } from "util/computed"; import { processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy } from "util/proxies";
import { computed, ref, Ref, unref } from "vue"; import { computed, isRef, ref, Ref, unref } from "vue";
import panZoom from "vue-panzoom"; import panZoom from "vue-panzoom";
import type { Link } from "../links/links"; import type { Link } from "../links/links";
globalBus.on("setupVue", app => panZoom.install(app)); globalBus.on("setupVue", app => panZoom.install(app));
/** A symbol used to identify {@link Board} features. */
export const BoardType = Symbol("Board"); export const BoardType = Symbol("Board");
export type NodeComputable<T> = Computable<T> | ((node: BoardNode) => T); /**
* A type representing a computable value for a node on the board. Used for node types to return different values based on the given node and the state of the board.
*/
export type NodeComputable<T, S extends unknown[] = []> =
| Computable<T>
| ((node: BoardNode, ...args: S) => T);
/** Ways to display progress of an action with a duration. */
export enum ProgressDisplay { export enum ProgressDisplay {
Outline = "Outline", Outline = "Outline",
Fill = "Fill" Fill = "Fill"
} }
/** Node shapes. */
export enum Shape { export enum Shape {
Circle = "Circle", Circle = "Circle",
Diamond = "Triangle" Diamond = "Triangle"
} }
/** An object representing a node on the board. */
export interface BoardNode { export interface BoardNode {
id: number; id: number;
position: { position: {
@ -52,54 +61,90 @@ export interface BoardNode {
pinned?: boolean; pinned?: boolean;
} }
/** An object representing a link between two nodes on the board. */
export interface BoardNodeLink extends Omit<Link, "startNode" | "endNode"> { export interface BoardNodeLink extends Omit<Link, "startNode" | "endNode"> {
startNode: BoardNode; startNode: BoardNode;
endNode: BoardNode; endNode: BoardNode;
stroke: string;
strokeWidth: number;
pulsing?: boolean; pulsing?: boolean;
} }
/** An object representing a label for a node. */
export interface NodeLabel { export interface NodeLabel {
text: string; text: string;
color?: string; color?: string;
pulsing?: boolean; pulsing?: boolean;
} }
/** The persistent data for a board. */
export type BoardData = { export type BoardData = {
nodes: BoardNode[]; nodes: BoardNode[];
selectedNode: number | null; selectedNode: number | null;
selectedAction: string | null; selectedAction: string | null;
}; };
/**
* An object that configures a {@link NodeType}.
*/
export interface NodeTypeOptions { export interface NodeTypeOptions {
/** The title to display for the node. */
title: NodeComputable<string>; title: NodeComputable<string>;
/** An optional label for the node. */
label?: NodeComputable<NodeLabel | null>; label?: NodeComputable<NodeLabel | null>;
/** The size of the node - diameter for circles, width and height for squares. */
size: NodeComputable<number>; size: NodeComputable<number>;
/** CSS to apply to this node. */
style?: NodeComputable<StyleValue>;
/** Dictionary of CSS classes to apply to this node. */
classes?: NodeComputable<Record<string, boolean>>;
/** Whether the node is draggable or not. */
draggable?: NodeComputable<boolean>; draggable?: NodeComputable<boolean>;
/** The shape of the node. */
shape: NodeComputable<Shape>; shape: NodeComputable<Shape>;
canAccept?: boolean | Ref<boolean> | ((node: BoardNode, otherNode: BoardNode) => boolean); /** Whether the node can accept another node being dropped upon it. */
canAccept?: NodeComputable<boolean, [BoardNode]>;
/** The progress value of the node, from 0 to 1. */
progress?: NodeComputable<number>; progress?: NodeComputable<number>;
/** How the progress should be displayed on the node. */
progressDisplay?: NodeComputable<ProgressDisplay>; progressDisplay?: NodeComputable<ProgressDisplay>;
/** The color of the progress indicator. */
progressColor?: NodeComputable<string>; progressColor?: NodeComputable<string>;
/** The fill color of the node. */
fillColor?: NodeComputable<string>; fillColor?: NodeComputable<string>;
/** The outline color of the node. */
outlineColor?: NodeComputable<string>; outlineColor?: NodeComputable<string>;
/** The color of the title text. */
titleColor?: NodeComputable<string>; titleColor?: NodeComputable<string>;
/** The list of action options for the node. */
actions?: BoardNodeActionOptions[]; actions?: BoardNodeActionOptions[];
/** The arc between each action, in radians. */
actionDistance?: NodeComputable<number>; actionDistance?: NodeComputable<number>;
/** A function that is called when the node is clicked. */
onClick?: (node: BoardNode) => void; onClick?: (node: BoardNode) => void;
/** A function that is called when a node is dropped onto this node. */
onDrop?: (node: BoardNode, otherNode: BoardNode) => void; onDrop?: (node: BoardNode, otherNode: BoardNode) => void;
/** A function that is called for each node of this type every tick. */
update?: (node: BoardNode, diff: number) => void; update?: (node: BoardNode, diff: number) => void;
} }
/**
* The properties that are added onto a processed {@link NodeTypeOptions} to create a {@link NodeType}.
*/
export interface BaseNodeType { export interface BaseNodeType {
/** The nodes currently on the board of this type. */
nodes: Ref<BoardNode[]>; nodes: Ref<BoardNode[]>;
} }
/** An object that represents a type of node that can appear on a board. It will handle getting properties and callbacks for every node of that type. */
export type NodeType<T extends NodeTypeOptions> = Replace< export type NodeType<T extends NodeTypeOptions> = Replace<
T & BaseNodeType, T & BaseNodeType,
{ {
title: GetComputableType<T["title"]>; title: GetComputableType<T["title"]>;
label: GetComputableType<T["label"]>; label: GetComputableType<T["label"]>;
size: GetComputableTypeWithDefault<T["size"], 50>; size: GetComputableTypeWithDefault<T["size"], 50>;
style: GetComputableType<T["style"]>;
classes: GetComputableType<T["classes"]>;
draggable: GetComputableTypeWithDefault<T["draggable"], false>; draggable: GetComputableTypeWithDefault<T["draggable"], false>;
shape: GetComputableTypeWithDefault<T["shape"], Shape.Circle>; shape: GetComputableTypeWithDefault<T["shape"], Shape.Circle>;
canAccept: GetComputableTypeWithDefault<T["canAccept"], false>; canAccept: GetComputableTypeWithDefault<T["canAccept"], false>;
@ -114,33 +159,50 @@ export type NodeType<T extends NodeTypeOptions> = Replace<
} }
>; >;
/** A type that matches any valid {@link NodeType} object. */
export type GenericNodeType = Replace< export type GenericNodeType = Replace<
NodeType<NodeTypeOptions>, NodeType<NodeTypeOptions>,
{ {
size: NodeComputable<number>; size: NodeComputable<number>;
draggable: NodeComputable<boolean>; draggable: NodeComputable<boolean>;
shape: NodeComputable<Shape>; shape: NodeComputable<Shape>;
canAccept: NodeComputable<boolean>; canAccept: NodeComputable<boolean, [BoardNode]>;
progressDisplay: NodeComputable<ProgressDisplay>; progressDisplay: NodeComputable<ProgressDisplay>;
progressColor: NodeComputable<string>; progressColor: NodeComputable<string>;
actionDistance: NodeComputable<number>; actionDistance: NodeComputable<number>;
} }
>; >;
/**
* An object that configures a {@link BoardNodeAction}.
*/
export interface BoardNodeActionOptions { export interface BoardNodeActionOptions {
/** A unique identifier for the action. */
id: string; id: string;
visibility?: NodeComputable<Visibility>; /** Whether this action should be visible. */
visibility?: NodeComputable<Visibility | boolean>;
/** The icon to display for the action. */
icon: NodeComputable<string>; icon: NodeComputable<string>;
/** The fill color of the action. */
fillColor?: NodeComputable<string>; fillColor?: NodeComputable<string>;
tooltip: NodeComputable<string>; /** The tooltip text to display for the action. */
tooltip: NodeComputable<NodeLabel>;
/** The confirmation label that appears under the action. */
confirmationLabel?: NodeComputable<NodeLabel>;
/** An array of board node links associated with the action. They appear when the action is focused. */
links?: NodeComputable<BoardNodeLink[]>; links?: NodeComputable<BoardNodeLink[]>;
onClick: (node: BoardNode) => boolean | undefined; /** A function that is called when the action is clicked. */
onClick: (node: BoardNode) => void;
} }
/**
* The properties that are added onto a processed {@link BoardNodeActionOptions} to create an {@link BoardNodeAction}.
*/
export interface BaseBoardNodeAction { export interface BaseBoardNodeAction {
links?: Ref<BoardNodeLink[]>; links?: Ref<BoardNodeLink[]>;
} }
/** An object that represents an action that can be taken upon a node. */
export type BoardNodeAction<T extends BoardNodeActionOptions> = Replace< export type BoardNodeAction<T extends BoardNodeActionOptions> = Replace<
T & BaseBoardNodeAction, T & BaseBoardNodeAction,
{ {
@ -148,40 +210,73 @@ export type BoardNodeAction<T extends BoardNodeActionOptions> = Replace<
icon: GetComputableType<T["icon"]>; icon: GetComputableType<T["icon"]>;
fillColor: GetComputableType<T["fillColor"]>; fillColor: GetComputableType<T["fillColor"]>;
tooltip: GetComputableType<T["tooltip"]>; tooltip: GetComputableType<T["tooltip"]>;
confirmationLabel: GetComputableTypeWithDefault<T["confirmationLabel"], NodeLabel>;
links: GetComputableType<T["links"]>; links: GetComputableType<T["links"]>;
} }
>; >;
/** A type that matches any valid {@link BoardNodeAction} object. */
export type GenericBoardNodeAction = Replace< export type GenericBoardNodeAction = Replace<
BoardNodeAction<BoardNodeActionOptions>, BoardNodeAction<BoardNodeActionOptions>,
{ {
visibility: NodeComputable<Visibility>; visibility: NodeComputable<Visibility | boolean>;
confirmationLabel: NodeComputable<NodeLabel>;
} }
>; >;
/**
* An object that configures a {@link Board}.
*/
export interface BoardOptions { export interface BoardOptions {
visibility?: Computable<Visibility>; /** Whether this board should be visible. */
visibility?: Computable<Visibility | boolean>;
/** The height of the board. Defaults to 100% */
height?: Computable<string>; height?: Computable<string>;
/** The width of the board. Defaults to 100% */
width?: Computable<string>; width?: Computable<string>;
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>; classes?: Computable<Record<string, boolean>>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>; style?: Computable<StyleValue>;
/** A function that returns an array of initial board nodes, without IDs. */
startNodes: () => Omit<BoardNode, "id">[]; startNodes: () => Omit<BoardNode, "id">[];
/** A dictionary of node types that can appear on the board. */
types: Record<string, NodeTypeOptions>; types: Record<string, NodeTypeOptions>;
/** The persistent state of the board. */
state?: Computable<BoardData>;
/** An array of board node links to display. */
links?: Computable<BoardNodeLink[] | null>;
} }
/**
* The properties that are added onto a processed {@link BoardOptions} to create a {@link Board}.
*/
export interface BaseBoard { export interface BaseBoard {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string; id: string;
state: Persistent<BoardData>; /** All the nodes currently on the board. */
links: Ref<BoardNodeLink[] | null>;
nodes: Ref<BoardNode[]>; nodes: Ref<BoardNode[]>;
/** The currently selected node, if any. */
selectedNode: Ref<BoardNode | null>; selectedNode: Ref<BoardNode | null>;
/** The currently selected action, if any. */
selectedAction: Ref<GenericBoardNodeAction | null>; selectedAction: Ref<GenericBoardNodeAction | null>;
/** The currently being dragged node, if any. */
draggingNode: Ref<BoardNode | null>;
/** If dragging a node, the node it's currently being hovered over, if any. */
receivingNode: Ref<BoardNode | null>;
/** The current mouse position, if over the board. */
mousePosition: Ref<{ x: number; y: number } | null>; mousePosition: Ref<{ x: number; y: number } | null>;
/** Places a node in the nearest empty space in the given direction with the specified space around it. */
placeInAvailableSpace: (node: BoardNode, radius?: number, direction?: Direction) => void;
/** A symbol that helps identify features of the same type. */
type: typeof BoardType; type: typeof BoardType;
[Component]: typeof BoardComponent; /** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>; [GatherProps]: () => Record<string, unknown>;
} }
/** An object that represents a feature that is a zoomable, pannable board with various nodes upon it. */
export type Board<T extends BoardOptions> = Replace< export type Board<T extends BoardOptions> = Replace<
T & BaseBoard, T & BaseBoard,
{ {
@ -191,74 +286,133 @@ export type Board<T extends BoardOptions> = Replace<
width: GetComputableType<T["width"]>; width: GetComputableType<T["width"]>;
classes: GetComputableType<T["classes"]>; classes: GetComputableType<T["classes"]>;
style: GetComputableType<T["style"]>; style: GetComputableType<T["style"]>;
state: GetComputableTypeWithDefault<T["state"], Persistent<BoardData>>;
links: GetComputableTypeWithDefault<T["links"], Ref<BoardNodeLink[] | null>>;
} }
>; >;
/** A type that matches any valid {@link Board} object. */
export type GenericBoard = Replace< export type GenericBoard = Replace<
Board<BoardOptions>, Board<BoardOptions>,
{ {
visibility: ProcessedComputable<Visibility>; visibility: ProcessedComputable<Visibility | boolean>;
state: ProcessedComputable<BoardData>;
links: ProcessedComputable<BoardNodeLink[] | null>;
} }
>; >;
/**
* Lazily creates a board with the given options.
* @param optionsFunc Board options.
*/
export function createBoard<T extends BoardOptions>( export function createBoard<T extends BoardOptions>(
optionsFunc: OptionsFunc<T, BaseBoard, GenericBoard> optionsFunc: OptionsFunc<T, BaseBoard, GenericBoard>
): Board<T> { ): Board<T> {
return createLazyProxy(() => { const state = persistent<BoardData>(
const board = optionsFunc(); {
board.id = getUniqueID("board-"); nodes: [],
board.type = BoardType;
board[Component] = BoardComponent;
board.state = persistent<BoardData>({
nodes: board.startNodes().map((n, i) => {
(n as BoardNode).id = i;
return n as BoardNode;
}),
selectedNode: null, selectedNode: null,
selectedAction: null selectedAction: null
},
false
);
return createLazyProxy(feature => {
const board = optionsFunc.call(feature, feature);
board.id = getUniqueID("board-");
board.type = BoardType;
board[Component] = BoardComponent as GenericComponent;
if (board.state) {
deletePersistent(state);
processComputable(board as T, "state");
} else {
state[DefaultValue] = {
nodes: board.startNodes().map((n, i) => {
(n as BoardNode).id = i;
return n as BoardNode;
}),
selectedNode: null,
selectedAction: null
};
board.state = state;
}
board.nodes = computed(() => unref(processedBoard.state).nodes);
board.selectedNode = computed({
get() {
return (
processedBoard.nodes.value.find(
node => node.id === unref(processedBoard.state).selectedNode
) || null
);
},
set(node) {
if (isRef(processedBoard.state)) {
processedBoard.state.value = {
...processedBoard.state.value,
selectedNode: node?.id ?? null
};
} else {
processedBoard.state.selectedNode = node?.id ?? null;
}
}
}); });
board.nodes = computed(() => processedBoard.state.value.nodes); board.selectedAction = computed({
board.selectedNode = computed( get() {
() => const selectedNode = processedBoard.selectedNode.value;
processedBoard.nodes.value.find( if (selectedNode == null) {
node => node.id === processedBoard.state.value.selectedNode return null;
) || null }
); const type = processedBoard.types[selectedNode.type];
board.selectedAction = computed(() => { if (type.actions == null) {
const selectedNode = processedBoard.selectedNode.value; return null;
if (selectedNode == null) { }
return null; return (
type.actions.find(
action => action.id === unref(processedBoard.state).selectedAction
) || null
);
},
set(action) {
if (isRef(processedBoard.state)) {
processedBoard.state.value = {
...processedBoard.state.value,
selectedAction: action?.id ?? null
};
} else {
processedBoard.state.selectedAction = action?.id ?? null;
}
} }
const type = processedBoard.types[selectedNode.type];
if (type.actions == null) {
return null;
}
return (
type.actions.find(
action => action.id === processedBoard.state.value.selectedAction
) || null
);
}); });
board.mousePosition = ref(null); board.mousePosition = ref(null);
board.links = computed(() => { if (board.links) {
if (processedBoard.selectedAction.value == null) { processComputable(board as T, "links");
return null; } else {
} board.links = computed(() => {
if (processedBoard.selectedAction.value.links && processedBoard.selectedNode.value) { if (processedBoard.selectedAction.value == null) {
return getNodeProperty( return null;
processedBoard.selectedAction.value.links, }
if (
processedBoard.selectedAction.value.links &&
processedBoard.selectedNode.value processedBoard.selectedNode.value
); ) {
} return getNodeProperty(
return null; processedBoard.selectedAction.value.links,
}); processedBoard.selectedNode.value
);
}
return null;
});
}
board.draggingNode = ref(null);
board.receivingNode = ref(null);
processComputable(board as T, "visibility"); processComputable(board as T, "visibility");
setDefault(board, "visibility", Visibility.Visible); setDefault(board, "visibility", Visibility.Visible);
processComputable(board as T, "width"); processComputable(board as T, "width");
setDefault(board, "width", "100%"); setDefault(board, "width", "100%");
processComputable(board as T, "height"); processComputable(board as T, "height");
setDefault(board, "height", "400px"); setDefault(board, "height", "100%");
processComputable(board as T, "classes"); processComputable(board as T, "classes");
processComputable(board as T, "style"); processComputable(board as T, "style");
@ -269,6 +423,8 @@ export function createBoard<T extends BoardOptions>(
processComputable(nodeType as NodeTypeOptions, "label"); processComputable(nodeType as NodeTypeOptions, "label");
processComputable(nodeType as NodeTypeOptions, "size"); processComputable(nodeType as NodeTypeOptions, "size");
setDefault(nodeType, "size", 50); setDefault(nodeType, "size", 50);
processComputable(nodeType as NodeTypeOptions, "style");
processComputable(nodeType as NodeTypeOptions, "classes");
processComputable(nodeType as NodeTypeOptions, "draggable"); processComputable(nodeType as NodeTypeOptions, "draggable");
setDefault(nodeType, "draggable", false); setDefault(nodeType, "draggable", false);
processComputable(nodeType as NodeTypeOptions, "shape"); processComputable(nodeType as NodeTypeOptions, "shape");
@ -286,10 +442,10 @@ export function createBoard<T extends BoardOptions>(
processComputable(nodeType as NodeTypeOptions, "actionDistance"); processComputable(nodeType as NodeTypeOptions, "actionDistance");
setDefault(nodeType, "actionDistance", Math.PI / 6); setDefault(nodeType, "actionDistance", Math.PI / 6);
nodeType.nodes = computed(() => nodeType.nodes = computed(() =>
processedBoard.state.value.nodes.filter(node => node.type === type) unref(processedBoard.state).nodes.filter(node => node.type === type)
); );
setDefault(nodeType, "onClick", function (node: BoardNode) { setDefault(nodeType, "onClick", function (node: BoardNode) {
processedBoard.state.value.selectedNode = node.id; unref(processedBoard.state).selectedNode = node.id;
}); });
if (nodeType.actions) { if (nodeType.actions) {
@ -299,11 +455,92 @@ export function createBoard<T extends BoardOptions>(
processComputable(action, "icon"); processComputable(action, "icon");
processComputable(action, "fillColor"); processComputable(action, "fillColor");
processComputable(action, "tooltip"); processComputable(action, "tooltip");
processComputable(action, "confirmationLabel");
setDefault(action, "confirmationLabel", { text: "Tap again to confirm" });
processComputable(action, "links"); processComputable(action, "links");
} }
} }
} }
function setDraggingNode(node: BoardNode | null) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
board.draggingNode!.value = node;
}
function setReceivingNode(node: BoardNode | null) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
board.receivingNode!.value = node;
}
board.placeInAvailableSpace = function (
node: BoardNode,
radius = 100,
direction = Direction.Right
) {
const nodes = processedBoard.nodes.value
.slice()
.filter(n => {
// Exclude self
if (n === node) {
return false;
}
// Exclude nodes that aren't within the corridor we'll be moving within
if (
(direction === Direction.Down || direction === Direction.Up) &&
Math.abs(n.position.x - node.position.x) > radius
) {
return false;
}
if (
(direction === Direction.Left || direction === Direction.Right) &&
Math.abs(n.position.y - node.position.y) > radius
) {
return false;
}
// Exclude nodes in the wrong direction
return !(
(direction === Direction.Right &&
n.position.x < node.position.x - radius) ||
(direction === Direction.Left && n.position.x > node.position.x + radius) ||
(direction === Direction.Up && n.position.y > node.position.y + radius) ||
(direction === Direction.Down && n.position.y < node.position.y - radius)
);
})
.sort(
direction === Direction.Right
? (a, b) => a.position.x - b.position.x
: direction === Direction.Left
? (a, b) => b.position.x - a.position.x
: direction === Direction.Up
? (a, b) => b.position.y - a.position.y
: (a, b) => a.position.y - b.position.y
);
for (let i = 0; i < nodes.length; i++) {
const nodeToCheck = nodes[i];
const distance =
direction === Direction.Right || direction === Direction.Left
? Math.abs(node.position.x - nodeToCheck.position.x)
: Math.abs(node.position.y - nodeToCheck.position.y);
// If we're too close to this node, move further
if (distance < radius) {
if (direction === Direction.Right) {
node.position.x = nodeToCheck.position.x + radius;
} else if (direction === Direction.Left) {
node.position.x = nodeToCheck.position.x - radius;
} else if (direction === Direction.Up) {
node.position.y = nodeToCheck.position.y - radius;
} else if (direction === Direction.Down) {
node.position.y = nodeToCheck.position.y + radius;
}
} else if (i > 0 && distance > radius) {
// If we're further from this node than the radius, then the nodes are past us and we can early exit
break;
}
}
};
board[GatherProps] = function (this: GenericBoard) { board[GatherProps] = function (this: GenericBoard) {
const { const {
nodes, nodes,
@ -317,7 +554,9 @@ export function createBoard<T extends BoardOptions>(
links, links,
selectedAction, selectedAction,
selectedNode, selectedNode,
mousePosition mousePosition,
draggingNode,
receivingNode
} = this; } = this;
return { return {
nodes, nodes,
@ -331,7 +570,11 @@ export function createBoard<T extends BoardOptions>(
links, links,
selectedAction, selectedAction,
selectedNode, selectedNode,
mousePosition mousePosition,
draggingNode,
receivingNode,
setDraggingNode,
setReceivingNode
}; };
}; };
@ -341,10 +584,25 @@ export function createBoard<T extends BoardOptions>(
}); });
} }
export function getNodeProperty<T>(property: NodeComputable<T>, node: BoardNode): T { /**
return isFunction<T, [BoardNode], Computable<T>>(property) ? property(node) : unref(property); * Gets the value of a property for a specified node.
* @param property The property to find the value of
* @param node The node to get the property of
*/
export function getNodeProperty<T, S extends unknown[]>(
property: NodeComputable<T, S>,
node: BoardNode,
...args: S
): T {
return isFunction<T, [BoardNode, ...S], Computable<T>>(property)
? property(node, ...args)
: unref(property);
} }
/**
* Utility to get an ID for a node that is guaranteed unique.
* @param board The board feature to generate an ID for
*/
export function getUniqueNodeID(board: GenericBoard): number { export function getUniqueNodeID(board: GenericBoard): number {
let id = 0; let id = 0;
board.nodes.value.forEach(node => { board.nodes.value.forEach(node => {

View file

@ -1,243 +0,0 @@
import ClickableComponent from "features/clickables/Clickable.vue";
import type { CoercableComponent, OptionsFunc, Replace, StyleValue } from "features/feature";
import { Component, GatherProps, getUniqueID, jsx, setDefault, Visibility } from "features/feature";
import type { Resource } from "features/resources/resource";
import type { Persistent } from "game/persistence";
import { persistent } from "game/persistence";
import type { DecimalSource } from "util/bignum";
import Decimal, { format, formatWhole } from "util/bignum";
import type {
Computable,
GetComputableType,
GetComputableTypeWithDefault,
ProcessedComputable
} from "util/computed";
import { processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies";
import { coerceComponent, isCoercableComponent } from "util/vue";
import type { Ref } from "vue";
import { computed, unref } from "vue";
export const BuyableType = Symbol("Buyable");
export type BuyableDisplay =
| CoercableComponent
| {
title?: CoercableComponent;
description?: CoercableComponent;
effectDisplay?: CoercableComponent;
showAmount?: boolean;
};
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 | undefined) => void;
}
export interface BaseBuyable {
id: string;
amount: Persistent<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"], Decimal>;
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: OptionsFunc<T, BaseBuyable, GenericBuyable>
): Buyable<T> {
const amount = persistent<DecimalSource>(0);
return createLazyProxy(() => {
const buyable = 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";
}
buyable.id = getUniqueID("buyable-");
buyable.type = BuyableType;
buyable[Component] = ClickableComponent;
buyable.amount = amount;
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 =
buyable.onClick ??
buyable.purchase ??
function (this: GenericBuyable) {
const genericBuyable = buyable as GenericBuyable;
if (!unref(genericBuyable.canPurchase)) {
return;
}
const cost = unref(genericBuyable.cost);
if (genericBuyable.cost != null && genericBuyable.resource != null) {
genericBuyable.resource.value = Decimal.sub(
genericBuyable.resource.value,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
cost!
);
genericBuyable.amount.value = Decimal.add(genericBuyable.amount.value, 1);
}
genericBuyable.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 (isCoercableComponent(currDisplay)) {
const CurrDisplay = coerceComponent(currDisplay);
return <CurrDisplay />;
}
if (currDisplay != null && 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}
{currDisplay.description ? <Description /> : null}
{currDisplay.showAmount === false ? null : (
<div>
<br />
{unref(genericBuyable.purchaseLimit) === Decimal.dInf ? (
<>Amount: {formatWhole(genericBuyable.amount.value)}</>
) : (
<>
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", Decimal.dInf);
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: unref(style),
classes,
onClick,
canClick,
small,
mark,
id
};
};
return buyable as unknown as Buyable<T>;
});
}

View file

@ -1,9 +1,9 @@
<template> <template>
<div <div
v-if="unref(visibility) !== Visibility.None" v-if="isVisible(visibility)"
:style="[ :style="[
{ {
visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined visibility: isHidden(visibility) ? 'hidden' : undefined
}, },
notifyStyle, notifyStyle,
unref(style) ?? {} unref(style) ?? {}
@ -36,8 +36,9 @@ import MarkNode from "components/MarkNode.vue";
import Node from "components/Node.vue"; import Node from "components/Node.vue";
import type { GenericChallenge } from "features/challenges/challenge"; import type { GenericChallenge } from "features/challenges/challenge";
import type { StyleValue } from "features/feature"; import type { StyleValue } from "features/feature";
import { jsx, Visibility } from "features/feature"; import { isHidden, isVisible, jsx, Visibility } from "features/feature";
import { getHighNotifyStyle, getNotifyStyle } from "game/notifications"; import { getHighNotifyStyle, getNotifyStyle } from "game/notifications";
import { displayRequirements, Requirements } from "game/requirements";
import { coerceComponent, isCoercableComponent, processedPropType, unwrapRef } from "util/vue"; import { coerceComponent, isCoercableComponent, processedPropType, unwrapRef } from "util/vue";
import type { Component, PropType, UnwrapRef } from "vue"; import type { Component, PropType, UnwrapRef } from "vue";
import { computed, defineComponent, shallowRef, toRefs, unref, watchEffect } from "vue"; import { computed, defineComponent, shallowRef, toRefs, unref, watchEffect } from "vue";
@ -61,8 +62,9 @@ export default defineComponent({
Object, Object,
Function Function
), ),
requirements: processedPropType<Requirements>(Object, Array),
visibility: { visibility: {
type: processedPropType<Visibility>(Number), type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true required: true
}, },
style: processedPropType<StyleValue>(String, Object, Array), style: processedPropType<StyleValue>(String, Object, Array),
@ -90,7 +92,7 @@ export default defineComponent({
Node Node
}, },
setup(props) { setup(props) {
const { active, maxed, canComplete, display } = toRefs(props); const { active, maxed, canComplete, display, requirements } = toRefs(props);
const buttonText = computed(() => { const buttonText = computed(() => {
if (active.value) { if (active.value) {
@ -128,31 +130,29 @@ export default defineComponent({
} }
const Title = coerceComponent(currDisplay.title || "", "h3"); const Title = coerceComponent(currDisplay.title || "", "h3");
const Description = coerceComponent(currDisplay.description, "div"); const Description = coerceComponent(currDisplay.description, "div");
const Goal = coerceComponent(currDisplay.goal || ""); const Goal = coerceComponent(currDisplay.goal != null ? currDisplay.goal : jsx(() => displayRequirements(unwrapRef(requirements) ?? [])), "h3");
const Reward = coerceComponent(currDisplay.reward || ""); const Reward = coerceComponent(currDisplay.reward || "");
const EffectDisplay = coerceComponent(currDisplay.effectDisplay || ""); const EffectDisplay = coerceComponent(currDisplay.effectDisplay || "");
comp.value = coerceComponent( comp.value = coerceComponent(
jsx(() => ( jsx(() => (
<span> <span>
{currDisplay.title ? ( {currDisplay.title != null ? (
<div> <div>
<Title /> <Title />
</div> </div>
) : null} ) : null}
<Description /> <Description />
{currDisplay.goal ? ( <div>
<div> <br />
<br /> Goal: <Goal />
Goal: <Goal /> </div>
</div> {currDisplay.reward != null ? (
) : null}
{currDisplay.reward ? (
<div> <div>
<br /> <br />
Reward: <Reward /> Reward: <Reward />
</div> </div>
) : null} ) : null}
{currDisplay.effectDisplay ? ( {currDisplay.effectDisplay != null ? (
<div> <div>
Currently: <EffectDisplay /> Currently: <EffectDisplay />
</div> </div>
@ -167,6 +167,8 @@ export default defineComponent({
notifyStyle, notifyStyle,
comp, comp,
Visibility, Visibility,
isVisible,
isHidden,
unref unref
}; };
} }

View file

@ -1,13 +1,28 @@
import { isArray } from "@vue/shared"; import { isArray } from "@vue/shared";
import Toggle from "components/fields/Toggle.vue"; import Toggle from "components/fields/Toggle.vue";
import ChallengeComponent from "features/challenges/Challenge.vue"; import ChallengeComponent from "features/challenges/Challenge.vue";
import type { CoercableComponent, OptionsFunc, Replace, StyleValue } from "features/feature"; import { GenericDecorator } from "features/decorators/common";
import { Component, GatherProps, getUniqueID, jsx, setDefault, Visibility } from "features/feature"; import type {
CoercableComponent,
GenericComponent,
OptionsFunc,
Replace,
StyleValue
} from "features/feature";
import {
Component,
GatherProps,
Visibility,
getUniqueID,
isVisible,
jsx,
setDefault
} from "features/feature";
import type { GenericReset } from "features/reset"; import type { GenericReset } from "features/reset";
import type { Resource } from "features/resources/resource";
import { globalBus } from "game/events"; import { globalBus } from "game/events";
import type { Persistent } from "game/persistence"; import type { Persistent } from "game/persistence";
import { persistent } from "game/persistence"; import { persistent } from "game/persistence";
import { Requirements, maxRequirementsMet } from "game/requirements";
import settings, { registerSettingField } from "game/settings"; import settings, { registerSettingField } from "game/settings";
import type { DecimalSource } from "util/bignum"; import type { DecimalSource } from "util/bignum";
import Decimal from "util/bignum"; import Decimal from "util/bignum";
@ -22,98 +37,139 @@ import { createLazyProxy } from "util/proxies";
import type { Ref, WatchStopHandle } from "vue"; import type { Ref, WatchStopHandle } from "vue";
import { computed, unref, watch } from "vue"; import { computed, unref, watch } from "vue";
export const ChallengeType = Symbol("ChallengeType"); /** A symbol used to identify {@link Challenge} features. */
export const ChallengeType = Symbol("Challenge");
/**
* An object that configures a {@link Challenge}.
*/
export interface ChallengeOptions { export interface ChallengeOptions {
visibility?: Computable<Visibility>; /** Whether this challenge should be visible. */
visibility?: Computable<Visibility | boolean>;
/** Whether this challenge can be started. */
canStart?: Computable<boolean>; canStart?: Computable<boolean>;
/** The reset function for this challenge. */
reset?: GenericReset; reset?: GenericReset;
canComplete?: Computable<boolean | DecimalSource>; /** The requirement(s) to complete this challenge. */
requirements: Requirements;
/** The maximum number of times the challenge can be completed. */
completionLimit?: Computable<DecimalSource>; completionLimit?: Computable<DecimalSource>;
/** Shows a marker on the corner of the feature. */
mark?: Computable<boolean | string>; mark?: Computable<boolean | string>;
resource?: Resource; /** Dictionary of CSS classes to apply to this feature. */
goal?: Computable<DecimalSource>;
classes?: Computable<Record<string, boolean>>; classes?: Computable<Record<string, boolean>>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>; style?: Computable<StyleValue>;
/** The display to use for this challenge. */
display?: Computable< display?: Computable<
| CoercableComponent | CoercableComponent
| { | {
/** A header to appear at the top of the display. */
title?: CoercableComponent; title?: CoercableComponent;
/** The main text that appears in the display. */
description: CoercableComponent; description: CoercableComponent;
/** A description of the current goal for this challenge. If unspecified then the requirements will be displayed automatically based on {@link requirements}. */
goal?: CoercableComponent; goal?: CoercableComponent;
/** A description of what will change upon completing this challenge. */
reward?: CoercableComponent; reward?: CoercableComponent;
/** A description of the current effect of this challenge. */
effectDisplay?: CoercableComponent; effectDisplay?: CoercableComponent;
} }
>; >;
/** A function that is called when the challenge is completed. */
onComplete?: VoidFunction; onComplete?: VoidFunction;
/** A function that is called when the challenge is exited. */
onExit?: VoidFunction; onExit?: VoidFunction;
/** A function that is called when the challenge is entered. */
onEnter?: VoidFunction; onEnter?: VoidFunction;
} }
/**
* The properties that are added onto a processed {@link ChallengeOptions} to create a {@link Challenge}.
*/
export interface BaseChallenge { export interface BaseChallenge {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string; id: string;
/** The current amount of times this challenge can be completed. */
canComplete: Ref<DecimalSource>;
/** The current number of times this challenge has been completed. */
completions: Persistent<DecimalSource>; completions: Persistent<DecimalSource>;
/** Whether or not this challenge has been completed. */
completed: Ref<boolean>; completed: Ref<boolean>;
/** Whether or not this challenge's completion count is at its limit. */
maxed: Ref<boolean>; maxed: Ref<boolean>;
/** Whether or not this challenge is currently active. */
active: Persistent<boolean>; active: Persistent<boolean>;
/** A function to enter or leave the challenge. */
toggle: VoidFunction; toggle: VoidFunction;
/**
* A function to complete this challenge.
* @param remainInChallenge - Optional parameter to specify if the challenge should remain active after completion.
*/
complete: (remainInChallenge?: boolean) => void; complete: (remainInChallenge?: boolean) => void;
/** A symbol that helps identify features of the same type. */
type: typeof ChallengeType; type: typeof ChallengeType;
[Component]: typeof ChallengeComponent; /** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>; [GatherProps]: () => Record<string, unknown>;
} }
/** An object that represents a feature that can be entered and exited, and have one or more completions with scaling requirements. */
export type Challenge<T extends ChallengeOptions> = Replace< export type Challenge<T extends ChallengeOptions> = Replace<
T & BaseChallenge, T & BaseChallenge,
{ {
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>; visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
canStart: GetComputableTypeWithDefault<T["canStart"], true>; canStart: GetComputableTypeWithDefault<T["canStart"], true>;
canComplete: GetComputableTypeWithDefault<T["canComplete"], Ref<boolean>>; requirements: GetComputableType<T["requirements"]>;
completionLimit: GetComputableTypeWithDefault<T["completionLimit"], 1>; completionLimit: GetComputableTypeWithDefault<T["completionLimit"], 1>;
mark: GetComputableTypeWithDefault<T["mark"], Ref<boolean>>; mark: GetComputableTypeWithDefault<T["mark"], Ref<boolean>>;
goal: GetComputableType<T["goal"]>;
classes: GetComputableType<T["classes"]>; classes: GetComputableType<T["classes"]>;
style: GetComputableType<T["style"]>; style: GetComputableType<T["style"]>;
display: GetComputableType<T["display"]>; display: GetComputableType<T["display"]>;
} }
>; >;
/** A type that matches any valid {@link Challenge} object. */
export type GenericChallenge = Replace< export type GenericChallenge = Replace<
Challenge<ChallengeOptions>, Challenge<ChallengeOptions>,
{ {
visibility: ProcessedComputable<Visibility>; visibility: ProcessedComputable<Visibility | boolean>;
canStart: ProcessedComputable<boolean>; canStart: ProcessedComputable<boolean>;
canComplete: ProcessedComputable<boolean | DecimalSource>;
completionLimit: ProcessedComputable<DecimalSource>; completionLimit: ProcessedComputable<DecimalSource>;
mark: ProcessedComputable<boolean>; mark: ProcessedComputable<boolean>;
} }
>; >;
/**
* Lazily creates a challenge with the given options.
* @param optionsFunc Challenge options.
*/
export function createChallenge<T extends ChallengeOptions>( export function createChallenge<T extends ChallengeOptions>(
optionsFunc: OptionsFunc<T, BaseChallenge, GenericChallenge> optionsFunc: OptionsFunc<T, BaseChallenge, GenericChallenge>,
...decorators: GenericDecorator[]
): Challenge<T> { ): Challenge<T> {
const completions = persistent(0); const completions = persistent(0);
const active = persistent(false); const active = persistent(false, false);
return createLazyProxy(() => { const decoratedData = decorators.reduce(
const challenge = optionsFunc(); (current, next) => Object.assign(current, next.getPersistentData?.()),
{}
if ( );
challenge.canComplete == null && return createLazyProxy(feature => {
(challenge.resource == null || challenge.goal == null) const challenge = optionsFunc.call(feature, feature);
) {
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.id = getUniqueID("challenge-");
challenge.type = ChallengeType; challenge.type = ChallengeType;
challenge[Component] = ChallengeComponent; challenge[Component] = ChallengeComponent as GenericComponent;
for (const decorator of decorators) {
decorator.preConstruct?.(challenge);
}
challenge.completions = completions; challenge.completions = completions;
challenge.active = active; challenge.active = active;
Object.assign(challenge, decoratedData);
challenge.completed = computed(() => challenge.completed = computed(() =>
Decimal.gt((challenge as GenericChallenge).completions.value, 0) Decimal.gt((challenge as GenericChallenge).completions.value, 0)
); );
@ -126,11 +182,11 @@ export function createChallenge<T extends ChallengeOptions>(
challenge.toggle = function () { challenge.toggle = function () {
const genericChallenge = challenge as GenericChallenge; const genericChallenge = challenge as GenericChallenge;
if (genericChallenge.active.value) { if (genericChallenge.active.value) {
if (unref(genericChallenge.canComplete) && !genericChallenge.maxed.value) { if (
let completions: boolean | DecimalSource = unref(genericChallenge.canComplete); Decimal.gt(unref(genericChallenge.canComplete), 0) &&
if (typeof completions === "boolean") { !genericChallenge.maxed.value
completions = 1; ) {
} const completions = unref(genericChallenge.canComplete);
genericChallenge.completions.value = Decimal.min( genericChallenge.completions.value = Decimal.min(
Decimal.add(genericChallenge.completions.value, completions), Decimal.add(genericChallenge.completions.value, completions),
unref(genericChallenge.completionLimit) unref(genericChallenge.completionLimit)
@ -142,7 +198,7 @@ export function createChallenge<T extends ChallengeOptions>(
genericChallenge.reset?.reset(); genericChallenge.reset?.reset();
} else if ( } else if (
unref(genericChallenge.canStart) && unref(genericChallenge.canStart) &&
unref(genericChallenge.visibility) === Visibility.Visible && isVisible(genericChallenge.visibility) &&
!genericChallenge.maxed.value !genericChallenge.maxed.value
) { ) {
genericChallenge.reset?.reset(); genericChallenge.reset?.reset();
@ -150,18 +206,17 @@ export function createChallenge<T extends ChallengeOptions>(
genericChallenge.onEnter?.(); genericChallenge.onEnter?.();
} }
}; };
challenge.canComplete = computed(() =>
maxRequirementsMet((challenge as GenericChallenge).requirements)
);
challenge.complete = function (remainInChallenge?: boolean) { challenge.complete = function (remainInChallenge?: boolean) {
const genericChallenge = challenge as GenericChallenge; const genericChallenge = challenge as GenericChallenge;
let completions: boolean | DecimalSource = unref(genericChallenge.canComplete); const completions = unref(genericChallenge.canComplete);
if ( if (
genericChallenge.active.value && genericChallenge.active.value &&
completions !== false && Decimal.gt(completions, 0) &&
(completions === true || Decimal.neq(0, completions)) &&
!genericChallenge.maxed.value !genericChallenge.maxed.value
) { ) {
if (typeof completions === "boolean") {
completions = 1;
}
genericChallenge.completions.value = Decimal.min( genericChallenge.completions.value = Decimal.min(
Decimal.add(genericChallenge.completions.value, completions), Decimal.add(genericChallenge.completions.value, completions),
unref(genericChallenge.completionLimit) unref(genericChallenge.completionLimit)
@ -176,26 +231,13 @@ export function createChallenge<T extends ChallengeOptions>(
}; };
processComputable(challenge as T, "visibility"); processComputable(challenge as T, "visibility");
setDefault(challenge, "visibility", Visibility.Visible); setDefault(challenge, "visibility", Visibility.Visible);
const visibility = challenge.visibility as ProcessedComputable<Visibility>; const visibility = challenge.visibility as ProcessedComputable<Visibility | boolean>;
challenge.visibility = computed(() => { challenge.visibility = computed(() => {
if (settings.hideChallenges === true && unref(challenge.maxed)) { if (settings.hideChallenges === true && unref(challenge.maxed)) {
return Visibility.None; return Visibility.None;
} }
return unref(visibility); return unref(visibility);
}); });
if (challenge.canComplete == null) {
challenge.canComplete = computed(() => {
const genericChallenge = challenge as GenericChallenge;
if (
!genericChallenge.active.value ||
genericChallenge.resource == null ||
genericChallenge.goal == null
) {
return false;
}
return Decimal.gte(genericChallenge.resource.value, unref(genericChallenge.goal));
});
}
if (challenge.mark == null) { if (challenge.mark == null) {
challenge.mark = computed( challenge.mark = computed(
() => () =>
@ -206,11 +248,9 @@ export function createChallenge<T extends ChallengeOptions>(
processComputable(challenge as T, "canStart"); processComputable(challenge as T, "canStart");
setDefault(challenge, "canStart", true); setDefault(challenge, "canStart", true);
processComputable(challenge as T, "canComplete");
processComputable(challenge as T, "completionLimit"); processComputable(challenge as T, "completionLimit");
setDefault(challenge, "completionLimit", 1); setDefault(challenge, "completionLimit", 1);
processComputable(challenge as T, "mark"); processComputable(challenge as T, "mark");
processComputable(challenge as T, "goal");
processComputable(challenge as T, "classes"); processComputable(challenge as T, "classes");
processComputable(challenge as T, "style"); processComputable(challenge as T, "style");
processComputable(challenge as T, "display"); processComputable(challenge as T, "display");
@ -223,6 +263,14 @@ export function createChallenge<T extends ChallengeOptions>(
}); });
} }
for (const decorator of decorators) {
decorator.postConstruct?.(challenge);
}
const decoratedProps = decorators.reduce(
(current, next) => Object.assign(current, next.getGatheredProps?.(challenge)),
{}
);
challenge[GatherProps] = function (this: GenericChallenge) { challenge[GatherProps] = function (this: GenericChallenge) {
const { const {
active, active,
@ -236,7 +284,8 @@ export function createChallenge<T extends ChallengeOptions>(
canStart, canStart,
mark, mark,
id, id,
toggle toggle,
requirements
} = this; } = this;
return { return {
active, active,
@ -250,7 +299,9 @@ export function createChallenge<T extends ChallengeOptions>(
canStart, canStart,
mark, mark,
id, id,
toggle toggle,
requirements,
...decoratedProps
}; };
}; };
@ -258,32 +309,49 @@ export function createChallenge<T extends ChallengeOptions>(
}); });
} }
/**
* This will automatically complete a challenge when it's requirements are met.
* @param challenge The challenge to auto-complete
* @param autoActive Whether or not auto-completing should currently occur
* @param exitOnComplete Whether or not to exit the challenge after auto-completion
*/
export function setupAutoComplete( export function setupAutoComplete(
challenge: GenericChallenge, challenge: GenericChallenge,
autoActive: Computable<boolean> = true, autoActive: Computable<boolean> = true,
exitOnComplete = true exitOnComplete = true
): WatchStopHandle { ): WatchStopHandle {
const isActive = typeof autoActive === "function" ? computed(autoActive) : autoActive; const isActive = typeof autoActive === "function" ? computed(autoActive) : autoActive;
return watch([challenge.canComplete, isActive], ([canComplete, isActive]) => { return watch(
if (canComplete && isActive) { [challenge.canComplete as Ref<DecimalSource>, isActive as Ref<boolean>],
challenge.complete(!exitOnComplete); ([canComplete, isActive]) => {
if (Decimal.gt(canComplete, 0) && isActive) {
challenge.complete(!exitOnComplete);
}
} }
}); );
} }
/**
* Utility for taking an array of challenges where only one may be active at a time, and giving a ref to the one currently active (or null if none are active)
* @param challenges The list of challenges that are mutually exclusive
*/
export function createActiveChallenge( export function createActiveChallenge(
challenges: GenericChallenge[] challenges: GenericChallenge[]
): Ref<GenericChallenge | undefined> { ): Ref<GenericChallenge | null> {
return computed(() => challenges.find(challenge => challenge.active.value)); return computed(() => challenges.find(challenge => challenge.active.value) ?? null);
} }
/**
* Utility for reporting if any challenge in a list is currently active. Intended for preventing entering a challenge if another is already active.
* @param challenges List of challenges that are mutually exclusive
*/
export function isAnyChallengeActive( export function isAnyChallengeActive(
challenges: GenericChallenge[] | Ref<GenericChallenge | undefined> challenges: GenericChallenge[] | Ref<GenericChallenge | null>
): Ref<boolean> { ): Ref<boolean> {
if (isArray(challenges)) { if (isArray(challenges)) {
challenges = createActiveChallenge(challenges); challenges = createActiveChallenge(challenges);
} }
return computed(() => (challenges as Ref<GenericChallenge | undefined>).value != null); return computed(() => (challenges as Ref<GenericChallenge | null>).value != null);
} }
declare module "game/settings" { declare module "game/settings" {
@ -299,7 +367,12 @@ globalBus.on("loadSettings", settings => {
registerSettingField( registerSettingField(
jsx(() => ( jsx(() => (
<Toggle <Toggle
title="Hide Maxed Challenges" title={jsx(() => (
<span class="option-title">
Hide maxed challenges
<desc>Hide challenges that have been fully completed.</desc>
</span>
))}
onUpdate:modelValue={value => (settings.hideChallenges = value)} onUpdate:modelValue={value => (settings.hideChallenges = value)}
modelValue={settings.hideChallenges} modelValue={settings.hideChallenges}
/> />

View file

@ -1,8 +1,8 @@
<template> <template>
<button <button
v-if="unref(visibility) !== Visibility.None" v-if="isVisible(visibility)"
:style="[ :style="[
{ visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined }, { visibility: isHidden(visibility) ? 'hidden' : undefined },
unref(style) ?? [] unref(style) ?? []
]" ]"
@click="onClick" @click="onClick"
@ -33,7 +33,7 @@ import MarkNode from "components/MarkNode.vue";
import Node from "components/Node.vue"; import Node from "components/Node.vue";
import type { GenericClickable } from "features/clickables/clickable"; import type { GenericClickable } from "features/clickables/clickable";
import type { StyleValue } from "features/feature"; import type { StyleValue } from "features/feature";
import { jsx, Visibility } from "features/feature"; import { isHidden, isVisible, jsx, Visibility } from "features/feature";
import { import {
coerceComponent, coerceComponent,
isCoercableComponent, isCoercableComponent,
@ -55,7 +55,7 @@ export default defineComponent({
required: true required: true
}, },
visibility: { visibility: {
type: processedPropType<Visibility>(Number), type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true required: true
}, },
style: processedPropType<StyleValue>(Object, String, Array), style: processedPropType<StyleValue>(Object, String, Array),
@ -92,12 +92,12 @@ export default defineComponent({
comp.value = coerceComponent(currDisplay); comp.value = coerceComponent(currDisplay);
return; return;
} }
const Title = coerceComponent(currDisplay.title || "", "h3"); const Title = coerceComponent(currDisplay.title ?? "", "h3");
const Description = coerceComponent(currDisplay.description, "div"); const Description = coerceComponent(currDisplay.description, "div");
comp.value = coerceComponent( comp.value = coerceComponent(
jsx(() => ( jsx(() => (
<span> <span>
{currDisplay.title ? ( {currDisplay.title != null ? (
<div> <div>
<Title /> <Title />
</div> </div>
@ -115,6 +115,8 @@ export default defineComponent({
stop, stop,
comp, comp,
Visibility, Visibility,
isVisible,
isHidden,
unref unref
}; };
} }

View file

@ -1,6 +1,13 @@
import ClickableComponent from "features/clickables/Clickable.vue"; import ClickableComponent from "features/clickables/Clickable.vue";
import type { CoercableComponent, OptionsFunc, Replace, StyleValue } from "features/feature"; import { GenericDecorator } from "features/decorators/common";
import { Component, GatherProps, getUniqueID, setDefault, Visibility } from "features/feature"; import type {
CoercableComponent,
GenericComponent,
OptionsFunc,
Replace,
StyleValue
} from "features/feature";
import { Component, GatherProps, Visibility, getUniqueID, setDefault } from "features/feature";
import type { BaseLayer } from "game/layers"; import type { BaseLayer } from "game/layers";
import type { Unsubscribe } from "nanoevents"; import type { Unsubscribe } from "nanoevents";
import type { import type {
@ -13,33 +20,56 @@ import { processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy } from "util/proxies";
import { computed, unref } from "vue"; import { computed, unref } from "vue";
/** A symbol used to identify {@link Clickable} features. */
export const ClickableType = Symbol("Clickable"); export const ClickableType = Symbol("Clickable");
/**
* An object that configures a {@link Clickable}.
*/
export interface ClickableOptions { export interface ClickableOptions {
visibility?: Computable<Visibility>; /** Whether this clickable should be visible. */
visibility?: Computable<Visibility | boolean>;
/** Whether or not the clickable may be clicked. */
canClick?: Computable<boolean>; canClick?: Computable<boolean>;
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>; classes?: Computable<Record<string, boolean>>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>; style?: Computable<StyleValue>;
/** Shows a marker on the corner of the feature. */
mark?: Computable<boolean | string>; mark?: Computable<boolean | string>;
/** The display to use for this clickable. */
display?: Computable< display?: Computable<
| CoercableComponent | CoercableComponent
| { | {
/** A header to appear at the top of the display. */
title?: CoercableComponent; title?: CoercableComponent;
/** The main text that appears in the display. */
description: CoercableComponent; description: CoercableComponent;
} }
>; >;
/** Toggles a smaller design for the feature. */
small?: boolean; small?: boolean;
/** A function that is called when the clickable is clicked. */
onClick?: (e?: MouseEvent | TouchEvent) => void; onClick?: (e?: MouseEvent | TouchEvent) => void;
/** A function that is called when the clickable is held down. */
onHold?: VoidFunction; onHold?: VoidFunction;
} }
/**
* The properties that are added onto a processed {@link ClickableOptions} to create an {@link Clickable}.
*/
export interface BaseClickable { export interface BaseClickable {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string; id: string;
/** A symbol that helps identify features of the same type. */
type: typeof ClickableType; type: typeof ClickableType;
[Component]: typeof ClickableComponent; /** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>; [GatherProps]: () => Record<string, unknown>;
} }
/** An object that represents a feature that can be clicked or held down. */
export type Clickable<T extends ClickableOptions> = Replace< export type Clickable<T extends ClickableOptions> = Replace<
T & BaseClickable, T & BaseClickable,
{ {
@ -52,22 +82,40 @@ export type Clickable<T extends ClickableOptions> = Replace<
} }
>; >;
/** A type that matches any valid {@link Clickable} object. */
export type GenericClickable = Replace< export type GenericClickable = Replace<
Clickable<ClickableOptions>, Clickable<ClickableOptions>,
{ {
visibility: ProcessedComputable<Visibility>; visibility: ProcessedComputable<Visibility | boolean>;
canClick: ProcessedComputable<boolean>; canClick: ProcessedComputable<boolean>;
} }
>; >;
/**
* Lazily creates a clickable with the given options.
* @param optionsFunc Clickable options.
*/
export function createClickable<T extends ClickableOptions>( export function createClickable<T extends ClickableOptions>(
optionsFunc?: OptionsFunc<T, BaseClickable, GenericClickable> optionsFunc?: OptionsFunc<T, BaseClickable, GenericClickable>,
...decorators: GenericDecorator[]
): Clickable<T> { ): Clickable<T> {
return createLazyProxy(() => { const decoratedData = decorators.reduce(
const clickable = optionsFunc?.() ?? ({} as ReturnType<NonNullable<typeof optionsFunc>>); (current, next) => Object.assign(current, next.getPersistentData?.()),
{}
);
return createLazyProxy(feature => {
const clickable =
optionsFunc?.call(feature, feature) ??
({} as ReturnType<NonNullable<typeof optionsFunc>>);
clickable.id = getUniqueID("clickable-"); clickable.id = getUniqueID("clickable-");
clickable.type = ClickableType; clickable.type = ClickableType;
clickable[Component] = ClickableComponent; clickable[Component] = ClickableComponent as GenericComponent;
for (const decorator of decorators) {
decorator.preConstruct?.(clickable);
}
Object.assign(clickable, decoratedData);
processComputable(clickable as T, "visibility"); processComputable(clickable as T, "visibility");
setDefault(clickable, "visibility", Visibility.Visible); setDefault(clickable, "visibility", Visibility.Visible);
@ -81,7 +129,7 @@ export function createClickable<T extends ClickableOptions>(
if (clickable.onClick) { if (clickable.onClick) {
const onClick = clickable.onClick.bind(clickable); const onClick = clickable.onClick.bind(clickable);
clickable.onClick = function (e) { clickable.onClick = function (e) {
if (unref(clickable.canClick)) { if (unref(clickable.canClick) !== false) {
onClick(e); onClick(e);
} }
}; };
@ -89,12 +137,20 @@ export function createClickable<T extends ClickableOptions>(
if (clickable.onHold) { if (clickable.onHold) {
const onHold = clickable.onHold.bind(clickable); const onHold = clickable.onHold.bind(clickable);
clickable.onHold = function () { clickable.onHold = function () {
if (unref(clickable.canClick)) { if (unref(clickable.canClick) !== false) {
onHold(); onHold();
} }
}; };
} }
for (const decorator of decorators) {
decorator.postConstruct?.(clickable);
}
const decoratedProps = decorators.reduce(
(current, next) => Object.assign(current, next.getGatheredProps?.(clickable)),
{}
);
clickable[GatherProps] = function (this: GenericClickable) { clickable[GatherProps] = function (this: GenericClickable) {
const { const {
display, display,
@ -118,7 +174,8 @@ export function createClickable<T extends ClickableOptions>(
canClick, canClick,
small, small,
mark, mark,
id id,
...decoratedProps
}; };
}; };
@ -126,12 +183,19 @@ export function createClickable<T extends ClickableOptions>(
}); });
} }
/**
* Utility to auto click a clickable whenever it can be.
* @param layer The layer the clickable is apart of
* @param clickable The clicker to click automatically
* @param autoActive Whether or not the clickable should currently be auto-clicking
*/
export function setupAutoClick( export function setupAutoClick(
layer: BaseLayer, layer: BaseLayer,
clickable: GenericClickable, clickable: GenericClickable,
autoActive: Computable<boolean> = true autoActive: Computable<boolean> = true
): Unsubscribe { ): Unsubscribe {
const isActive = typeof autoActive === "function" ? computed(autoActive) : autoActive; const isActive: ProcessedComputable<boolean> =
typeof autoActive === "function" ? computed(autoActive) : autoActive;
return layer.on("update", () => { return layer.on("update", () => {
if (unref(isActive) && unref(clickable.canClick)) { if (unref(isActive) && unref(clickable.canClick)) {
clickable.onClick?.(); clickable.onClick?.();

View file

@ -1,23 +1,26 @@
import type { OptionsFunc, Replace } from "features/feature"; import type { CoercableComponent, OptionsFunc, Replace } from "features/feature";
import { setDefault } from "features/feature"; import { setDefault } from "features/feature";
import type { Resource } from "features/resources/resource"; import type { Resource } from "features/resources/resource";
import Formula from "game/formulas/formulas";
import { InvertibleFormula, InvertibleIntegralFormula } from "game/formulas/types";
import type { BaseLayer } from "game/layers"; import type { BaseLayer } from "game/layers";
import type { Modifier } from "game/modifiers";
import type { DecimalSource } from "util/bignum"; import type { DecimalSource } from "util/bignum";
import Decimal from "util/bignum"; import Decimal from "util/bignum";
import type { WithRequired } from "util/common";
import type { Computable, GetComputableTypeWithDefault, ProcessedComputable } from "util/computed"; import type { Computable, GetComputableTypeWithDefault, ProcessedComputable } from "util/computed";
import { convertComputable, processComputable } from "util/computed"; import { convertComputable, processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy } from "util/proxies";
import type { Ref } from "vue"; import type { Ref } from "vue";
import { computed, unref } from "vue"; import { computed, unref } from "vue";
import { GenericDecorator } from "./decorators/common";
import { createBooleanRequirement } from "game/requirements";
/** An object that configures a {@link Conversion}. */ /** An object that configures a {@link Conversion}. */
export interface ConversionOptions { export interface ConversionOptions {
/** /**
* The scaling function that is used to determine the rate of conversion from one {@link features/resources/resource.Resource} to the other. * The formula used to determine how much {@link gainResource} should be earned by this converting.
* The passed value will be a Formula representing the {@link baseResource} variable.
*/ */
scaling: ScalingFunction; formula: (variable: InvertibleIntegralFormula) => InvertibleFormula;
/** /**
* How much of the output resource the conversion can currently convert for. * How much of the output resource the conversion can currently convert for.
* Typically this will be set for you in a conversion constructor. * Typically this will be set for you in a conversion constructor.
@ -53,10 +56,6 @@ export interface ConversionOptions {
* Defaults to true. * Defaults to true.
*/ */
buyMax?: Computable<boolean>; buyMax?: Computable<boolean>;
/**
* Whether or not to round up the cost to generate a given amount of the output resource.
*/
roundUpCost?: Computable<boolean>;
/** /**
* The function that performs the actual conversion from {@link baseResource} to {@link gainResource}. * The function that performs the actual conversion from {@link baseResource} to {@link gainResource}.
* Typically this will be set for you in a conversion constructor. * Typically this will be set for you in a conversion constructor.
@ -73,20 +72,6 @@ export interface ConversionOptions {
* This will not be called whenever using currentGain without calling convert (e.g. passive generation) * This will not be called whenever using currentGain without calling convert (e.g. passive generation)
*/ */
onConvert?: (amountGained: DecimalSource) => void; onConvert?: (amountGained: DecimalSource) => void;
/**
* An additional modifier that will be applied to the gain amounts.
* Must be reversible in order to correctly calculate {@link nextAt}.
* @see {@link game/modifiers.createSequentialModifier} if you want to apply multiple modifiers.
*/
gainModifier?: WithRequired<Modifier, "revert">;
/**
* A modifier that will be applied to the cost amounts.
* That is to say, this modifier will be applied to the amount of baseResource before going into the scaling function.
* A cost modifier of x0.5 would give gain amounts equal to the player having half the baseResource they actually have.
* Must be reversible in order to correctly calculate {@link nextAt}.
* @see {@link game/modifiers.createSequentialModifier} if you want to apply multiple modifiers.
*/
costModifier?: WithRequired<Modifier, "revert">;
} }
/** /**
@ -103,13 +88,13 @@ export interface BaseConversion {
export type Conversion<T extends ConversionOptions> = Replace< export type Conversion<T extends ConversionOptions> = Replace<
T & BaseConversion, T & BaseConversion,
{ {
formula: InvertibleFormula;
currentGain: GetComputableTypeWithDefault<T["currentGain"], Ref<DecimalSource>>; currentGain: GetComputableTypeWithDefault<T["currentGain"], Ref<DecimalSource>>;
actualGain: GetComputableTypeWithDefault<T["actualGain"], Ref<DecimalSource>>; actualGain: GetComputableTypeWithDefault<T["actualGain"], Ref<DecimalSource>>;
currentAt: GetComputableTypeWithDefault<T["currentAt"], Ref<DecimalSource>>; currentAt: GetComputableTypeWithDefault<T["currentAt"], Ref<DecimalSource>>;
nextAt: GetComputableTypeWithDefault<T["nextAt"], Ref<DecimalSource>>; nextAt: GetComputableTypeWithDefault<T["nextAt"], Ref<DecimalSource>>;
buyMax: GetComputableTypeWithDefault<T["buyMax"], true>; buyMax: GetComputableTypeWithDefault<T["buyMax"], true>;
spend: undefined extends T["spend"] ? (amountGained: DecimalSource) => void : T["spend"]; spend: undefined extends T["spend"] ? (amountGained: DecimalSource) => void : T["spend"];
roundUpCost: GetComputableTypeWithDefault<T["roundUpCost"], true>;
} }
>; >;
@ -123,7 +108,6 @@ export type GenericConversion = Replace<
nextAt: ProcessedComputable<DecimalSource>; nextAt: ProcessedComputable<DecimalSource>;
buyMax: ProcessedComputable<boolean>; buyMax: ProcessedComputable<boolean>;
spend: (amountGained: DecimalSource) => void; spend: (amountGained: DecimalSource) => void;
roundUpCost: ProcessedComputable<boolean>;
} }
>; >;
@ -135,21 +119,27 @@ export type GenericConversion = Replace<
* @see {@link createIndependentConversion}. * @see {@link createIndependentConversion}.
*/ */
export function createConversion<T extends ConversionOptions>( export function createConversion<T extends ConversionOptions>(
optionsFunc: OptionsFunc<T, BaseConversion, GenericConversion> optionsFunc: OptionsFunc<T, BaseConversion, GenericConversion>,
...decorators: GenericDecorator[]
): Conversion<T> { ): Conversion<T> {
return createLazyProxy(() => { return createLazyProxy(feature => {
const conversion = optionsFunc(); const conversion = optionsFunc.call(feature, feature);
for (const decorator of decorators) {
decorator.preConstruct?.(conversion);
}
(conversion as GenericConversion).formula = conversion.formula(
Formula.variable(conversion.baseResource)
);
if (conversion.currentGain == null) { if (conversion.currentGain == null) {
conversion.currentGain = computed(() => { conversion.currentGain = computed(() => {
let gain = conversion.gainModifier let gain = Decimal.floor(
? conversion.gainModifier.apply( (conversion as GenericConversion).formula.evaluate(
conversion.scaling.currentGain(conversion as GenericConversion) conversion.baseResource.value
) )
: conversion.scaling.currentGain(conversion as GenericConversion); ).max(0);
gain = Decimal.floor(gain).max(0); if (unref(conversion.buyMax) === false) {
if (!unref(conversion.buyMax)) {
gain = gain.min(1); gain = gain.min(1);
} }
return gain; return gain;
@ -160,16 +150,16 @@ export function createConversion<T extends ConversionOptions>(
} }
if (conversion.currentAt == null) { if (conversion.currentAt == null) {
conversion.currentAt = computed(() => { conversion.currentAt = computed(() => {
let current = conversion.scaling.currentAt(conversion as GenericConversion); return (conversion as GenericConversion).formula.invert(
if (conversion.roundUpCost) current = Decimal.ceil(current); Decimal.floor(unref((conversion as GenericConversion).currentGain))
return current; );
}); });
} }
if (conversion.nextAt == null) { if (conversion.nextAt == null) {
conversion.nextAt = computed(() => { conversion.nextAt = computed(() => {
let next = conversion.scaling.nextAt(conversion as GenericConversion); return (conversion as GenericConversion).formula.invert(
if (conversion.roundUpCost) next = Decimal.ceil(next); Decimal.floor(unref((conversion as GenericConversion).currentGain)).add(1)
return next; );
}); });
} }
@ -197,177 +187,15 @@ export function createConversion<T extends ConversionOptions>(
processComputable(conversion as T, "nextAt"); processComputable(conversion as T, "nextAt");
processComputable(conversion as T, "buyMax"); processComputable(conversion as T, "buyMax");
setDefault(conversion, "buyMax", true); setDefault(conversion, "buyMax", true);
processComputable(conversion as T, "roundUpCost");
setDefault(conversion, "roundUpCost", true); for (const decorator of decorators) {
decorator.postConstruct?.(conversion);
}
return conversion as unknown as Conversion<T>; return conversion as unknown as Conversion<T>;
}); });
} }
/**
* A collection of functions that allow a conversion to scale the amount of resources gained based on the input resource.
* This typically shouldn't be created directly. Instead use one of the scaling function constructors.
* @see {@link createLinearScaling}.
* @see {@link createPolynomialScaling}.
*/
export interface ScalingFunction {
/**
* Calculates the amount of the output resource a conversion should be able to currently produce.
* This should be based off of `conversion.baseResource.value`.
* The conversion is responsible for applying the gainModifier, so this function should be un-modified.
* It does not need to be clamped or rounded.
*/
currentGain: (conversion: GenericConversion) => DecimalSource;
/**
* Calculates the amount of the input resource that is required for the current value of `conversion.currentGain`.
* Note that `conversion.currentGain` has been modified by `conversion.gainModifier`, so you will need to revert that as appropriate.
* The conversion is responsible for rounding up the amount as appropriate.
* The returned value should not be below 0.
*/
currentAt: (conversion: GenericConversion) => DecimalSource;
/**
* Calculates the amount of the input resource that would be required for the current value of `conversion.currentGain` to increase.
* Note that `conversion.currentGain` has been modified by `conversion.gainModifier`, so you will need to revert that as appropriate.
* The conversion is responsible for rounding up the amount as appropriate.
* The returned value should not be below 0.
*/
nextAt: (conversion: GenericConversion) => DecimalSource;
}
/**
* Creates a scaling function based off the formula `(baseResource - base) * coefficient`.
* If the baseResource value is less than base then the currentGain will be 0.
* @param base The base variable in the scaling formula.
* @param coefficient The coefficient variable in the scaling formula.
* @example
* A scaling function created via `createLinearScaling(10, 0.5)` would produce the following values:
* | Base Resource | Current Gain |
* | ------------- | ------------ |
* | 10 | 1 |
* | 12 | 2 |
* | 20 | 6 |
*/
export function createLinearScaling(
base: Computable<DecimalSource>,
coefficient: Computable<DecimalSource>
): ScalingFunction {
const processedBase = convertComputable(base);
const processedCoefficient = convertComputable(coefficient);
return {
currentGain(conversion) {
let baseAmount: DecimalSource = unref(conversion.baseResource.value);
if (conversion.costModifier) {
baseAmount = conversion.costModifier.apply(baseAmount);
}
if (Decimal.lt(baseAmount, unref(processedBase))) {
return 0;
}
return Decimal.sub(baseAmount, unref(processedBase))
.sub(1)
.times(unref(processedCoefficient))
.add(1);
},
currentAt(conversion) {
let current: DecimalSource = unref(conversion.currentGain);
if (conversion.gainModifier) {
current = conversion.gainModifier.revert(current);
}
current = Decimal.max(0, current)
.sub(1)
.div(unref(processedCoefficient))
.add(unref(processedBase));
if (conversion.costModifier) {
current = conversion.costModifier.revert(current);
}
return current;
},
nextAt(conversion) {
let next: DecimalSource = Decimal.add(unref(conversion.currentGain), 1).floor();
if (conversion.gainModifier) {
next = conversion.gainModifier.revert(next);
}
next = Decimal.max(0, next)
.sub(1)
.div(unref(processedCoefficient))
.add(unref(processedBase))
.max(unref(processedBase));
if (conversion.costModifier) {
next = conversion.costModifier.revert(next);
}
return next;
}
};
}
/**
* Creates a scaling function based off the formula `(baseResource / base) ^ exponent`.
* If the baseResource value is less than base then the currentGain will be 0.
* @param base The base variable in the scaling formula.
* @param exponent The exponent variable in the scaling formula.
* @example
* A scaling function created via `createPolynomialScaling(10, 0.5)` would produce the following values:
* | Base Resource | Current Gain |
* | ------------- | ------------ |
* | 10 | 1 |
* | 40 | 2 |
* | 250 | 5 |
*/
export function createPolynomialScaling(
base: Computable<DecimalSource>,
exponent: Computable<DecimalSource>
): ScalingFunction {
const processedBase = convertComputable(base);
const processedExponent = convertComputable(exponent);
return {
currentGain(conversion) {
let baseAmount: DecimalSource = unref(conversion.baseResource.value);
if (conversion.costModifier) {
baseAmount = conversion.costModifier.apply(baseAmount);
}
if (Decimal.lt(baseAmount, unref(processedBase))) {
return 0;
}
const gain = Decimal.div(baseAmount, unref(processedBase)).pow(
unref(processedExponent)
);
if (gain.isNan()) {
return new Decimal(0);
}
return gain;
},
currentAt(conversion) {
let current: DecimalSource = unref(conversion.currentGain);
if (conversion.gainModifier) {
current = conversion.gainModifier.revert(current);
}
current = Decimal.max(0, current)
.root(unref(processedExponent))
.times(unref(processedBase));
if (conversion.costModifier) {
current = conversion.costModifier.revert(current);
}
return current;
},
nextAt(conversion) {
let next: DecimalSource = Decimal.add(unref(conversion.currentGain), 1).floor();
if (conversion.gainModifier) {
next = conversion.gainModifier.revert(next);
}
next = Decimal.max(0, next)
.root(unref(processedExponent))
.times(unref(processedBase))
.max(unref(processedBase));
if (conversion.costModifier) {
next = conversion.costModifier.revert(next);
}
return next;
}
};
}
/** /**
* Creates a conversion that simply adds to the gainResource amount upon converting. * Creates a conversion that simply adds to the gainResource amount upon converting.
* This is similar to the behavior of "normal" layers in The Modding Tree. * This is similar to the behavior of "normal" layers in The Modding Tree.
@ -388,21 +216,19 @@ export function createCumulativeConversion<S extends ConversionOptions>(
export function createIndependentConversion<S extends ConversionOptions>( export function createIndependentConversion<S extends ConversionOptions>(
optionsFunc: OptionsFunc<S, BaseConversion, GenericConversion> optionsFunc: OptionsFunc<S, BaseConversion, GenericConversion>
): Conversion<S> { ): Conversion<S> {
return createConversion(() => { return createConversion(feature => {
const conversion: S = optionsFunc(); const conversion: S = optionsFunc.call(feature, feature);
setDefault(conversion, "buyMax", false); setDefault(conversion, "buyMax", false);
if (conversion.currentGain == null) { if (conversion.currentGain == null) {
conversion.currentGain = computed(() => { conversion.currentGain = computed(() => {
let gain = conversion.gainModifier let gain = Decimal.floor(
? conversion.gainModifier.apply( (conversion as unknown as GenericConversion).formula.evaluate(
conversion.scaling.currentGain(conversion as GenericConversion) conversion.baseResource.value
) )
: conversion.scaling.currentGain(conversion as GenericConversion); ).max(conversion.gainResource.value);
gain = Decimal.floor(gain).max(conversion.gainResource.value); if (unref(conversion.buyMax) === false) {
if (!unref(conversion.buyMax)) {
gain = gain.min(Decimal.add(conversion.gainResource.value, 1)); gain = gain.min(Decimal.add(conversion.gainResource.value, 1));
} }
return gain; return gain;
@ -411,24 +237,26 @@ export function createIndependentConversion<S extends ConversionOptions>(
if (conversion.actualGain == null) { if (conversion.actualGain == null) {
conversion.actualGain = computed(() => { conversion.actualGain = computed(() => {
let gain = Decimal.sub( let gain = Decimal.sub(
Decimal.floor(conversion.scaling.currentGain(conversion as GenericConversion)), (conversion as unknown as GenericConversion).formula.evaluate(
conversion.baseResource.value
),
conversion.gainResource.value conversion.gainResource.value
).max(0); )
.floor()
.max(0);
if (!unref(conversion.buyMax)) { if (unref(conversion.buyMax) === false) {
gain = gain.min(1); gain = gain.min(1);
} }
return gain; return gain;
}); });
} }
setDefault(conversion, "convert", function () { setDefault(conversion, "convert", function () {
const amountGained = unref((conversion as GenericConversion).actualGain); const amountGained = unref((conversion as unknown as GenericConversion).actualGain);
conversion.gainResource.value = conversion.gainModifier conversion.gainResource.value = unref(
? conversion.gainModifier.apply( (conversion as unknown as GenericConversion).currentGain
unref((conversion as GenericConversion).currentGain) );
) (conversion as unknown as GenericConversion).spend(amountGained);
: unref((conversion as GenericConversion).currentGain);
(conversion as GenericConversion).spend(amountGained);
conversion.onConvert?.(amountGained); conversion.onConvert?.(amountGained);
}); });
@ -443,13 +271,13 @@ export function createIndependentConversion<S extends ConversionOptions>(
* @param layer The layer this passive generation will be associated with. Typically `this` when calling this function from inside a layer's options function. * @param layer The layer this passive generation will be associated with. Typically `this` when calling this function from inside a layer's options function.
* @param conversion The conversion that will determine how much generation there is. * @param conversion The conversion that will determine how much generation there is.
* @param rate A multiplier to multiply against the conversion's currentGain. * @param rate A multiplier to multiply against the conversion's currentGain.
* @param cap A value that should not be passed via passive generation. If null, no cap is applied. * @param cap A value that should not be passed via passive generation.
*/ */
export function setupPassiveGeneration( export function setupPassiveGeneration(
layer: BaseLayer, layer: BaseLayer,
conversion: GenericConversion, conversion: GenericConversion,
rate: Computable<DecimalSource> = 1, rate: Computable<DecimalSource> = 1,
cap: Computable<DecimalSource | null> = null cap: Computable<DecimalSource> = Decimal.dInf
): void { ): void {
const processedRate = convertComputable(rate); const processedRate = convertComputable(rate);
const processedCap = convertComputable(cap); const processedCap = convertComputable(cap);
@ -459,71 +287,26 @@ export function setupPassiveGeneration(
conversion.gainResource.value = Decimal.add( conversion.gainResource.value = Decimal.add(
conversion.gainResource.value, conversion.gainResource.value,
Decimal.times(currRate, diff).times(Decimal.ceil(unref(conversion.actualGain))) Decimal.times(currRate, diff).times(Decimal.ceil(unref(conversion.actualGain)))
).min(unref(processedCap) ?? Decimal.dInf); )
.min(unref(processedCap))
.max(conversion.gainResource.value);
} }
}); });
} }
/** /**
* Given a value, this function finds the amount above a certain value and raises it to a power. * Creates requirement that is met when the conversion hits a specified gain amount
* If the power is <1, this will effectively make the value scale slower after the cap. * @param conversion The conversion to check the gain amount of
* @param value The raw value. * @param minGainAmount The minimum gain amount that must be met for the requirement to be met
* @param cap The value after which the softcap should be applied.
* @param power The power to raise value above the cap to.
* @example
* A softcap added via `addSoftcap(scaling, 100, 0.5)` would produce the following values:
* | Raw Value | Softcapped Value |
* | --------- | ---------------- |
* | 1 | 1 |
* | 100 | 100 |
* | 125 | 105 |
* | 200 | 110 |
*/ */
export function softcap( export function createCanConvertRequirement(
value: DecimalSource, conversion: GenericConversion,
cap: DecimalSource, minGainAmount: Computable<DecimalSource> = 1,
power: DecimalSource = 0.5 display?: CoercableComponent
): DecimalSource { ) {
if (Decimal.lte(value, cap)) { const computedMinGainAmount = convertComputable(minGainAmount);
return value; return createBooleanRequirement(
} else { () => Decimal.gte(unref(conversion.actualGain), unref(computedMinGainAmount)),
return Decimal.pow(value, power).times(Decimal.pow(cap, Decimal.sub(1, power))); display
} );
}
/**
* Creates a scaling function based off an existing scaling function, with a softcap applied to it.
* The softcap will take any value above a certain value and raise it to a power.
* If the power is <1, this will effectively make the value scale slower after the cap.
* @param scaling The raw scaling function.
* @param cap The value after which the softcap should be applied.
* @param power The power to raise value about the cap to.
* @see {@link softcap}.
*/
export function addSoftcap(
scaling: ScalingFunction,
cap: ProcessedComputable<DecimalSource>,
power: ProcessedComputable<DecimalSource> = 0.5
): ScalingFunction {
return {
...scaling,
currentGain: conversion =>
softcap(scaling.currentGain(conversion), unref(cap), unref(power))
};
}
/**
* Creates a scaling function off an existing function, with a hardcap applied to it.
* The harcap will ensure that the currentGain will stop at a given cap.
* @param scaling The raw scaling function.
* @param cap The maximum value the scaling function can output.
*/
export function addHardcap(
scaling: ScalingFunction,
cap: ProcessedComputable<DecimalSource>
): ScalingFunction {
return {
...scaling,
currentGain: conversion => Decimal.min(scaling.currentGain(conversion), unref(cap))
};
} }

View file

@ -0,0 +1,117 @@
import { Replace } from "features/feature";
import Decimal, { DecimalSource } from "util/bignum";
import {
Computable,
GetComputableType,
ProcessedComputable,
processComputable
} from "util/computed";
import { Ref, computed, unref } from "vue";
import { Decorator } from "./common";
export interface BonusAmountFeatureOptions {
bonusAmount: Computable<DecimalSource>;
totalAmount?: Computable<DecimalSource>;
}
export interface BonusCompletionsFeatureOptions {
bonusCompletions: Computable<DecimalSource>;
totalCompletions?: Computable<DecimalSource>;
}
export interface BaseBonusAmountFeature {
amount: Ref<DecimalSource>;
bonusAmount: ProcessedComputable<DecimalSource>;
totalAmount?: Ref<DecimalSource>;
}
export interface BaseBonusCompletionsFeature {
completions: Ref<DecimalSource>;
bonusCompletions: ProcessedComputable<DecimalSource>;
totalCompletions?: Ref<DecimalSource>;
}
export type BonusAmountFeature<T extends BonusAmountFeatureOptions> = Replace<
T,
{ bonusAmount: GetComputableType<T["bonusAmount"]> }
>;
export type BonusCompletionsFeature<T extends BonusCompletionsFeatureOptions> = Replace<
T,
{ bonusAmount: GetComputableType<T["bonusCompletions"]> }
>;
export type GenericBonusAmountFeature = Replace<
BonusAmountFeature<BonusAmountFeatureOptions>,
{
bonusAmount: ProcessedComputable<DecimalSource>;
totalAmount: ProcessedComputable<DecimalSource>;
}
>;
export type GenericBonusCompletionsFeature = Replace<
BonusCompletionsFeature<BonusCompletionsFeatureOptions>,
{
bonusCompletions: ProcessedComputable<DecimalSource>;
totalCompletions: ProcessedComputable<DecimalSource>;
}
>;
/**
* Allows the addition of "bonus levels" to the decorated feature, with an accompanying "total amount".
* To function properly, the `createFeature()` function must have its generic type extended by {@linkcode BonusAmountFeatureOptions}.
* Additionally, the base feature must have an `amount` property.
* To allow access to the decorated values outside the `createFeature()` function, the output type must be extended by {@linkcode GenericBonusAmountFeature}.
* @example ```ts
* createRepeatable<RepeatableOptions & BonusAmountFeatureOptions>(() => ({
* bonusAmount: noPersist(otherRepeatable.amount),
* ...
* }), bonusAmountDecorator) as GenericRepeatable & GenericBonusAmountFeature
*/
export const bonusAmountDecorator: Decorator<
BonusAmountFeatureOptions,
BaseBonusAmountFeature,
GenericBonusAmountFeature
> = {
postConstruct(feature) {
if (feature.amount === undefined) {
console.error(
`Decorated feature ${feature.id} does not contain the required 'amount' property"`
);
}
processComputable(feature, "bonusAmount");
if (feature.totalAmount === undefined) {
feature.totalAmount = computed(() =>
Decimal.add(
unref(feature.amount ?? 0),
unref(feature.bonusAmount as ProcessedComputable<DecimalSource>)
)
);
}
}
};
/**
* Allows the addition of "bonus levels" to the decorated feature, with an accompanying "total amount".
* To function properly, the `createFeature()` function must have its generic type extended by {@linkcode BonusCompletionFeatureOptions}.
* To allow access to the decorated values outside the `createFeature()` function, the output type must be extended by {@linkcode GenericBonusCompletionFeature}.
* @example ```ts
* createChallenge<ChallengeOptions & BonusCompletionFeatureOptions>(() => ({
* bonusCompletions: noPersist(otherChallenge.completions),
* ...
* }), bonusCompletionDecorator) as GenericChallenge & GenericBonusCompletionFeature
* ```
*/
export const bonusCompletionsDecorator: Decorator<
BonusCompletionsFeatureOptions,
BaseBonusCompletionsFeature,
GenericBonusCompletionsFeature
> = {
postConstruct(feature) {
processComputable(feature, "bonusCompletions");
if (feature.totalCompletions === undefined) {
feature.totalCompletions = computed(() =>
Decimal.add(
unref(feature.completions ?? 0),
unref(feature.bonusCompletions as ProcessedComputable<DecimalSource>)
)
);
}
}
};

View file

@ -0,0 +1,59 @@
import { Replace, OptionsObject } from "../feature";
import {
Computable,
GetComputableType,
processComputable,
ProcessedComputable
} from "util/computed";
import { Persistent, State } from "game/persistence";
export type Decorator<
FeatureOptions,
BaseFeature = object,
GenericFeature = BaseFeature,
S extends State = State
> = {
getPersistentData?(): Record<string, Persistent<S>>;
preConstruct?(
feature: OptionsObject<FeatureOptions, BaseFeature & { id: string }, GenericFeature>
): void;
postConstruct?(
feature: OptionsObject<FeatureOptions, BaseFeature & { id: string }, GenericFeature>
): void;
getGatheredProps?(
feature: OptionsObject<FeatureOptions, BaseFeature & { id: string }, GenericFeature>
): Partial<OptionsObject<FeatureOptions, BaseFeature & { id: string }, GenericFeature>>;
};
export type GenericDecorator = Decorator<unknown>;
export interface EffectFeatureOptions<T = unknown> {
effect: Computable<T>;
}
export type EffectFeature<T extends EffectFeatureOptions> = Replace<
T,
{ effect: GetComputableType<T["effect"]> }
>;
export type GenericEffectFeature<T = unknown> = Replace<
EffectFeature<EffectFeatureOptions>,
{ effect: ProcessedComputable<T> }
>;
/**
* Allows the usage of an `effect` field in the decorated feature.
* To function properly, the `createFeature()` function must have its generic type extended by {@linkcode EffectFeatureOptions}.
* To allow access to the decorated values outside the `createFeature()` function, the output type must be extended by {@linkcode GenericEffectFeature}.
* @example ```ts
* createRepeatable<RepeatableOptions & EffectFeatureOptions>(() => ({
* effect() { return Decimal.pow(2, this.amount); },
* ...
* }), effectDecorator) as GenericUpgrade & GenericEffectFeature;
* ```
*/
export const effectDecorator: Decorator<EffectFeatureOptions, unknown, GenericEffectFeature> = {
postConstruct(feature) {
processComputable(feature, "effect");
}
};

View file

@ -1,7 +1,7 @@
import Decimal from "util/bignum"; import Decimal from "util/bignum";
import { DoNotCache } from "util/computed"; import { DoNotCache, ProcessedComputable } from "util/computed";
import type { CSSProperties, DefineComponent } from "vue"; import type { CSSProperties, DefineComponent } from "vue";
import { isRef } from "vue"; import { isRef, unref } from "vue";
/** /**
* A symbol to use as a key for a vue component a feature can be rendered with * A symbol to use as a key for a vue component a feature can be rendered with
@ -42,9 +42,9 @@ export type Replace<T, S> = S & Omit<T, keyof S>;
* with "this" bound to what the type will eventually be processed into. * with "this" bound to what the type will eventually be processed into.
* Intended for making lazily evaluated objects. * Intended for making lazily evaluated objects.
*/ */
export type OptionsFunc<T, R = Record<string, unknown>, S = R> = () => T & export type OptionsFunc<T, R = unknown, S = R> = (obj: R) => OptionsObject<T, R, S>;
Partial<R> &
ThisType<T & S>; export type OptionsObject<T, R = unknown, S = R> = T & Partial<R> & ThisType<T & S>;
let id = 0; let id = 0;
/** /**
@ -67,6 +67,16 @@ export enum Visibility {
None None
} }
export function isVisible(visibility: ProcessedComputable<Visibility | boolean>) {
const currVisibility = unref(visibility);
return currVisibility !== Visibility.None && currVisibility !== false;
}
export function isHidden(visibility: ProcessedComputable<Visibility | boolean>) {
const currVisibility = unref(visibility);
return currVisibility === Visibility.Hidden;
}
/** /**
* Takes a function and marks it as JSX so it won't get auto-wrapped into a ComputedRef. * Takes a function and marks it as JSX so it won't get auto-wrapped into a ComputedRef.
* The function may also return empty string as empty JSX tags cause issues. * The function may also return empty string as empty JSX tags cause issues.
@ -76,11 +86,6 @@ export function jsx(func: () => JSX.Element | ""): JSXFunction {
return func as JSXFunction; return func as JSXFunction;
} }
/** Utility function to convert a boolean value into a Visbility value */
export function showIf(condition: boolean, otherwise = Visibility.None): Visibility {
return condition ? Visibility.Visible : otherwise;
}
/** Utility function to set a property on an object if and only if it doesn't already exist */ /** Utility function to set a property on an object if and only if it doesn't already exist */
export function setDefault<T, K extends keyof T>( export function setDefault<T, K extends keyof T>(
object: T, object: T,
@ -102,7 +107,7 @@ export function findFeatures(obj: Record<string, unknown>, ...types: symbol[]):
const handleObject = (obj: Record<string, unknown>) => { const handleObject = (obj: Record<string, unknown>) => {
Object.keys(obj).forEach(key => { Object.keys(obj).forEach(key => {
const value = obj[key]; const value = obj[key];
if (value && typeof value === "object") { if (value != null && typeof value === "object") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
if (types.includes((value as Record<string, any>).type)) { if (types.includes((value as Record<string, any>).type)) {
objects.push(value); objects.push(value);
@ -127,7 +132,7 @@ export function excludeFeatures(obj: Record<string, unknown>, ...types: symbol[]
const handleObject = (obj: Record<string, unknown>) => { const handleObject = (obj: Record<string, unknown>) => {
Object.keys(obj).forEach(key => { Object.keys(obj).forEach(key => {
const value = obj[key]; const value = obj[key];
if (value && typeof value === "object") { if (value != null && typeof value === "object") {
if ( if (
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
typeof (value as Record<string, any>).type == "symbol" && typeof (value as Record<string, any>).type == "symbol" &&

View file

@ -1,12 +1,12 @@
<template> <template>
<div <div
v-if="unref(visibility) !== Visibility.None" v-if="isVisible(visibility)"
:style="{ :style="{
visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined visibility: isHidden(visibility) ? 'hidden' : undefined
}" }"
class="table" class="table-grid"
> >
<div v-for="row in unref(rows)" class="row" :class="{ mergeAdjacent }" :key="row"> <div v-for="row in unref(rows)" class="row-grid" :class="{ mergeAdjacent }" :key="row">
<GridCell <GridCell
v-for="col in unref(cols)" v-for="col in unref(cols)"
:key="col" :key="col"
@ -19,7 +19,7 @@
<script lang="ts"> <script lang="ts">
import "components/common/table.css"; import "components/common/table.css";
import themes from "data/themes"; import themes from "data/themes";
import { Visibility } from "features/feature"; import { isHidden, isVisible, Visibility } from "features/feature";
import type { GridCell } from "features/grids/grid"; import type { GridCell } from "features/grids/grid";
import settings from "game/settings"; import settings from "game/settings";
import { processedPropType } from "util/vue"; import { processedPropType } from "util/vue";
@ -29,7 +29,7 @@ import GridCellVue from "./GridCell.vue";
export default defineComponent({ export default defineComponent({
props: { props: {
visibility: { visibility: {
type: processedPropType<Visibility>(Number), type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true required: true
}, },
rows: { rows: {
@ -54,7 +54,7 @@ export default defineComponent({
return { visibility, onClick, onHold, display, title, style, canClick, id }; return { visibility, onClick, onHold, display, title, style, canClick, id };
} }
return { unref, gatherCellProps, Visibility, mergeAdjacent }; return { unref, gatherCellProps, Visibility, mergeAdjacent, isVisible, isHidden };
} }
}); });
</script> </script>

View file

@ -1,10 +1,10 @@
<template> <template>
<button <button
v-if="unref(visibility) !== Visibility.None" v-if="isVisible(visibility)"
:class="{ feature: true, tile: true, can: unref(canClick), locked: !unref(canClick) }" :class="{ feature: true, tile: true, can: unref(canClick), locked: !unref(canClick) }"
:style="[ :style="[
{ {
visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined visibility: isHidden(visibility) ? 'hidden' : undefined
}, },
unref(style) ?? {} unref(style) ?? {}
]" ]"
@ -26,7 +26,7 @@
import "components/common/features.css"; import "components/common/features.css";
import Node from "components/Node.vue"; import Node from "components/Node.vue";
import type { CoercableComponent, StyleValue } from "features/feature"; import type { CoercableComponent, StyleValue } from "features/feature";
import { Visibility } from "features/feature"; import { isHidden, isVisible, Visibility } from "features/feature";
import { import {
computeComponent, computeComponent,
computeOptionalComponent, computeOptionalComponent,
@ -39,7 +39,7 @@ import { defineComponent, toRefs, unref } from "vue";
export default defineComponent({ export default defineComponent({
props: { props: {
visibility: { visibility: {
type: processedPropType<Visibility>(Number), type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true required: true
}, },
onClick: Function as PropType<(e?: MouseEvent | TouchEvent) => void>, onClick: Function as PropType<(e?: MouseEvent | TouchEvent) => void>,
@ -76,7 +76,9 @@ export default defineComponent({
titleComponent, titleComponent,
component, component,
Visibility, Visibility,
unref unref,
isVisible,
isHidden
}; };
} }
}); });

View file

@ -1,4 +1,10 @@
import type { CoercableComponent, OptionsFunc, Replace, StyleValue } from "features/feature"; import type {
CoercableComponent,
GenericComponent,
OptionsFunc,
Replace,
StyleValue
} from "features/feature";
import { Component, GatherProps, getUniqueID, setDefault, Visibility } from "features/feature"; import { Component, GatherProps, getUniqueID, setDefault, Visibility } from "features/feature";
import GridComponent from "features/grids/Grid.vue"; import GridComponent from "features/grids/Grid.vue";
import type { Persistent, State } from "game/persistence"; import type { Persistent, State } from "game/persistence";
@ -15,14 +21,22 @@ import { createLazyProxy } from "util/proxies";
import type { Ref } from "vue"; import type { Ref } from "vue";
import { computed, unref } from "vue"; import { computed, unref } from "vue";
/** A symbol used to identify {@link Grid} features. */
export const GridType = Symbol("Grid"); export const GridType = Symbol("Grid");
/** A type representing a computable value for a cell in the grid. */
export type CellComputable<T> = Computable<T> | ((id: string | number, state: State) => T); export type CellComputable<T> = Computable<T> | ((id: string | number, state: State) => T);
/** Create proxy to more easily get the properties of cells on a grid. */
function createGridProxy(grid: GenericGrid): Record<string | number, GridCell> { function createGridProxy(grid: GenericGrid): Record<string | number, GridCell> {
return new Proxy({}, getGridHandler(grid)) as Record<string | number, GridCell>; return new Proxy({}, getGridHandler(grid)) as Record<string | number, GridCell>;
} }
/**
* Returns traps for a proxy that will give cell proxies when accessing any numerical key.
* @param grid The grid to get the cells from.
* @see {@link createGridProxy}
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
function getGridHandler(grid: GenericGrid): ProxyHandler<Record<string | number, GridCell>> { function getGridHandler(grid: GenericGrid): ProxyHandler<Record<string | number, GridCell>> {
const keys = computed(() => { const keys = computed(() => {
@ -80,6 +94,12 @@ function getGridHandler(grid: GenericGrid): ProxyHandler<Record<string | number,
}; };
} }
/**
* Returns traps for a proxy that will get the properties for the specified cell
* @param id The grid cell ID to get properties from.
* @see {@link getGridHandler}
* @see {@link createGridProxy}
*/
function getCellHandler(id: string): ProxyHandler<GenericGrid> { function getCellHandler(id: string): ProxyHandler<GenericGrid> {
const keys = [ const keys = [
"id", "id",
@ -169,47 +189,90 @@ function getCellHandler(id: string): ProxyHandler<GenericGrid> {
}; };
} }
/**
* Represents a cell within a grid. These properties will typically be accessed via a cell proxy that calls functions on the grid to get the properties for a specific cell.
* @see {@link createGridProxy}
*/
export interface GridCell { export interface GridCell {
/** A unique identifier for the grid cell. */
id: string; id: string;
visibility: Visibility; /** Whether this cell should be visible. */
visibility: Visibility | boolean;
/** Whether this cell can be clicked. */
canClick: boolean; canClick: boolean;
/** The initial persistent state of this cell. */
startState: State; startState: State;
/** The persistent state of this cell. */
state: State; state: State;
/** CSS to apply to this feature. */
style?: StyleValue; style?: StyleValue;
/** Dictionary of CSS classes to apply to this feature. */
classes?: Record<string, boolean>; classes?: Record<string, boolean>;
/** A header to appear at the top of the display. */
title?: CoercableComponent; title?: CoercableComponent;
/** The main text that appears in the display. */
display: CoercableComponent; display: CoercableComponent;
/** A function that is called when the cell is clicked. */
onClick?: (e?: MouseEvent | TouchEvent) => void; onClick?: (e?: MouseEvent | TouchEvent) => void;
/** A function that is called when the cell is held down. */
onHold?: VoidFunction; onHold?: VoidFunction;
} }
/**
* An object that configures a {@link Grid}.
*/
export interface GridOptions { export interface GridOptions {
visibility?: Computable<Visibility>; /** Whether this grid should be visible. */
visibility?: Computable<Visibility | boolean>;
/** The number of rows in the grid. */
rows: Computable<number>; rows: Computable<number>;
/** The number of columns in the grid. */
cols: Computable<number>; cols: Computable<number>;
getVisibility?: CellComputable<Visibility>; /** A computable to determine the visibility of a cell. */
getVisibility?: CellComputable<Visibility | boolean>;
/** A computable to determine if a cell can be clicked. */
getCanClick?: CellComputable<boolean>; getCanClick?: CellComputable<boolean>;
/** A computable to get the initial persistent state of a cell. */
getStartState: Computable<State> | ((id: string | number) => State); getStartState: Computable<State> | ((id: string | number) => State);
/** A computable to get the CSS styles for a cell. */
getStyle?: CellComputable<StyleValue>; getStyle?: CellComputable<StyleValue>;
/** A computable to get the CSS classes for a cell. */
getClasses?: CellComputable<Record<string, boolean>>; getClasses?: CellComputable<Record<string, boolean>>;
/** A computable to get the title component for a cell. */
getTitle?: CellComputable<CoercableComponent>; getTitle?: CellComputable<CoercableComponent>;
/** A computable to get the display component for a cell. */
getDisplay: CellComputable<CoercableComponent>; getDisplay: CellComputable<CoercableComponent>;
/** A function that is called when a cell is clicked. */
onClick?: (id: string | number, state: State, e?: MouseEvent | TouchEvent) => void; onClick?: (id: string | number, state: State, e?: MouseEvent | TouchEvent) => void;
/** A function that is called when a cell is held down. */
onHold?: (id: string | number, state: State) => void; onHold?: (id: string | number, state: State) => void;
} }
/**
* The properties that are added onto a processed {@link BoardOptions} to create a {@link Board}.
*/
export interface BaseGrid { export interface BaseGrid {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string; id: string;
/** Get the auto-generated ID for identifying a specific cell of this grid that appears in the DOM. Will not persist between refreshes or updates. */
getID: (id: string | number, state: State) => string; getID: (id: string | number, state: State) => string;
/** Get the persistent state of the given cell. */
getState: (id: string | number) => State; getState: (id: string | number) => State;
/** Set the persistent state of the given cell. */
setState: (id: string | number, state: State) => void; setState: (id: string | number, state: State) => void;
/** A dictionary of cells within this grid. */
cells: Record<string | number, GridCell>; cells: Record<string | number, GridCell>;
/** The persistent state of this grid, which is a dictionary of cell states. */
cellState: Persistent<Record<string | number, State>>; cellState: Persistent<Record<string | number, State>>;
/** A symbol that helps identify features of the same type. */
type: typeof GridType; type: typeof GridType;
[Component]: typeof GridComponent; /** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>; [GatherProps]: () => Record<string, unknown>;
} }
/** An object that represents a feature that is a grid of cells that all behave according to the same rules. */
export type Grid<T extends GridOptions> = Replace< export type Grid<T extends GridOptions> = Replace<
T & BaseGrid, T & BaseGrid,
{ {
@ -226,23 +289,28 @@ export type Grid<T extends GridOptions> = Replace<
} }
>; >;
/** A type that matches any valid {@link Grid} object. */
export type GenericGrid = Replace< export type GenericGrid = Replace<
Grid<GridOptions>, Grid<GridOptions>,
{ {
visibility: ProcessedComputable<Visibility>; visibility: ProcessedComputable<Visibility | boolean>;
getVisibility: ProcessedComputable<Visibility>; getVisibility: ProcessedComputable<Visibility | boolean>;
getCanClick: ProcessedComputable<boolean>; getCanClick: ProcessedComputable<boolean>;
} }
>; >;
/**
* Lazily creates a grid with the given options.
* @param optionsFunc Grid options.
*/
export function createGrid<T extends GridOptions>( export function createGrid<T extends GridOptions>(
optionsFunc: OptionsFunc<T, BaseGrid, GenericGrid> optionsFunc: OptionsFunc<T, BaseGrid, GenericGrid>
): Grid<T> { ): Grid<T> {
const cellState = persistent<Record<string | number, State>>({}); const cellState = persistent<Record<string | number, State>>({}, false);
return createLazyProxy(() => { return createLazyProxy(feature => {
const grid = optionsFunc(); const grid = optionsFunc.call(feature, feature);
grid.id = getUniqueID("grid-"); grid.id = getUniqueID("grid-");
grid[Component] = GridComponent; grid[Component] = GridComponent as GenericComponent;
grid.cellState = cellState; grid.cellState = cellState;
@ -277,9 +345,9 @@ export function createGrid<T extends GridOptions>(
if (grid.onClick) { if (grid.onClick) {
const onClick = grid.onClick.bind(grid); const onClick = grid.onClick.bind(grid);
grid.onClick = function (id, state) { grid.onClick = function (id, state, e) {
if (unref((grid as GenericGrid).cells[id].canClick)) { if (unref((grid as GenericGrid).cells[id].canClick)) {
onClick(id, state); onClick(id, state, e);
} }
}; };
} }

View file

@ -13,21 +13,36 @@ import type {
import { processComputable } from "util/computed"; import { processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy } from "util/proxies";
import { shallowReactive, unref } from "vue"; import { shallowReactive, unref } from "vue";
import Hotkey from "components/Hotkey.vue";
/** A dictionary of all hotkeys. */
export const hotkeys: Record<string, GenericHotkey | undefined> = shallowReactive({}); export const hotkeys: Record<string, GenericHotkey | undefined> = shallowReactive({});
/** A symbol used to identify {@link Hotkey} features. */
export const HotkeyType = Symbol("Hotkey"); export const HotkeyType = Symbol("Hotkey");
/**
* An object that configures a {@link Hotkey}.
*/
export interface HotkeyOptions { export interface HotkeyOptions {
/** Whether or not this hotkey is currently enabled. */
enabled?: Computable<boolean>; enabled?: Computable<boolean>;
/** The key tied to this hotkey */
key: string; key: string;
/** The description of this hotkey, to display in the settings. */
description: Computable<string>; description: Computable<string>;
/** What to do upon pressing the key. */
onPress: VoidFunction; onPress: VoidFunction;
} }
/**
* The properties that are added onto a processed {@link HotkeyOptions} to create an {@link Hotkey}.
*/
export interface BaseHotkey { export interface BaseHotkey {
/** A symbol that helps identify features of the same type. */
type: typeof HotkeyType; type: typeof HotkeyType;
} }
/** An object that represents a hotkey shortcut that performs an action upon a key sequence being pressed. */
export type Hotkey<T extends HotkeyOptions> = Replace< export type Hotkey<T extends HotkeyOptions> = Replace<
T & BaseHotkey, T & BaseHotkey,
{ {
@ -36,6 +51,7 @@ export type Hotkey<T extends HotkeyOptions> = Replace<
} }
>; >;
/** A type that matches any valid {@link Hotkey} object. */
export type GenericHotkey = Replace< export type GenericHotkey = Replace<
Hotkey<HotkeyOptions>, Hotkey<HotkeyOptions>,
{ {
@ -43,11 +59,17 @@ export type GenericHotkey = Replace<
} }
>; >;
const uppercaseNumbers = [")", "!", "@", "#", "$", "%", "^", "&", "*", "("];
/**
* Lazily creates a hotkey with the given options.
* @param optionsFunc Hotkey options.
*/
export function createHotkey<T extends HotkeyOptions>( export function createHotkey<T extends HotkeyOptions>(
optionsFunc: OptionsFunc<T, BaseHotkey, GenericHotkey> optionsFunc: OptionsFunc<T, BaseHotkey, GenericHotkey>
): Hotkey<T> { ): Hotkey<T> {
return createLazyProxy(() => { return createLazyProxy(feature => {
const hotkey = optionsFunc(); const hotkey = optionsFunc.call(feature, feature);
hotkey.type = HotkeyType; hotkey.type = HotkeyType;
processComputable(hotkey as T, "enabled"); processComputable(hotkey as T, "enabled");
@ -78,7 +100,9 @@ document.onkeydown = function (e) {
return; return;
} }
let key = e.key; let key = e.key;
if (e.shiftKey) { if (uppercaseNumbers.includes(key)) {
key = "shift+" + uppercaseNumbers.indexOf(key);
} else if (e.shiftKey) {
key = "shift+" + key; key = "shift+" + key;
} }
if (e.ctrlKey) { if (e.ctrlKey) {
@ -101,11 +125,13 @@ registerInfoComponent(
<div> <div>
<br /> <br />
<h4>Hotkeys</h4> <h4>Hotkeys</h4>
{keys.map(hotkey => ( <div style="column-count: 2">
<div> {keys.map(hotkey => (
{hotkey?.key}: {hotkey?.description} <div>
</div> <Hotkey hotkey={hotkey as GenericHotkey} /> {hotkey?.description}
))} </div>
))}
</div>
</div> </div>
); );
}) })

View file

@ -1,11 +1,11 @@
<template> <template>
<div <div
class="infobox" class="infobox"
v-if="unref(visibility) !== Visibility.None" v-if="isVisible(visibility)"
:style="[ :style="[
{ {
borderColor: unref(color), borderColor: unref(color),
visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined visibility: isHidden(visibility) ? 'hidden' : undefined
}, },
unref(style) ?? {} unref(style) ?? {}
]" ]"
@ -33,7 +33,7 @@ import CollapseTransition from "@ivanv/vue-collapse-transition/src/CollapseTrans
import Node from "components/Node.vue"; import Node from "components/Node.vue";
import themes from "data/themes"; import themes from "data/themes";
import type { CoercableComponent } from "features/feature"; import type { CoercableComponent } from "features/feature";
import { Visibility } from "features/feature"; import { isHidden, isVisible, Visibility } from "features/feature";
import settings from "game/settings"; import settings from "game/settings";
import { computeComponent, processedPropType } from "util/vue"; import { computeComponent, processedPropType } from "util/vue";
import type { PropType, Ref, StyleValue } from "vue"; import type { PropType, Ref, StyleValue } from "vue";
@ -42,7 +42,7 @@ import { computed, defineComponent, toRefs, unref } from "vue";
export default defineComponent({ export default defineComponent({
props: { props: {
visibility: { visibility: {
type: processedPropType<Visibility>(Number), type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true required: true
}, },
display: { display: {
@ -83,7 +83,9 @@ export default defineComponent({
bodyComponent, bodyComponent,
stacked, stacked,
unref, unref,
Visibility Visibility,
isVisible,
isHidden
}; };
} }
}); });

View file

@ -1,4 +1,10 @@
import type { CoercableComponent, OptionsFunc, Replace, StyleValue } from "features/feature"; import type {
CoercableComponent,
GenericComponent,
OptionsFunc,
Replace,
StyleValue
} from "features/feature";
import { Component, GatherProps, getUniqueID, setDefault, Visibility } from "features/feature"; import { Component, GatherProps, getUniqueID, setDefault, Visibility } from "features/feature";
import InfoboxComponent from "features/infoboxes/Infobox.vue"; import InfoboxComponent from "features/infoboxes/Infobox.vue";
import type { Persistent } from "game/persistence"; import type { Persistent } from "game/persistence";
@ -13,27 +19,48 @@ import { processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy } from "util/proxies";
import { unref } from "vue"; import { unref } from "vue";
/** A symbol used to identify {@link Infobox} features. */
export const InfoboxType = Symbol("Infobox"); export const InfoboxType = Symbol("Infobox");
/**
* An object that configures an {@link Infobox}.
*/
export interface InfoboxOptions { export interface InfoboxOptions {
visibility?: Computable<Visibility>; /** Whether this clickable should be visible. */
visibility?: Computable<Visibility | boolean>;
/** The background color of the Infobox. */
color?: Computable<string>; color?: Computable<string>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>; style?: Computable<StyleValue>;
/** CSS to apply to the title of the infobox. */
titleStyle?: Computable<StyleValue>; titleStyle?: Computable<StyleValue>;
/** CSS to apply to the body of the infobox. */
bodyStyle?: Computable<StyleValue>; bodyStyle?: Computable<StyleValue>;
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>; classes?: Computable<Record<string, boolean>>;
/** A header to appear at the top of the display. */
title: Computable<CoercableComponent>; title: Computable<CoercableComponent>;
/** The main text that appears in the display. */
display: Computable<CoercableComponent>; display: Computable<CoercableComponent>;
} }
/**
* The properties that are added onto a processed {@link InfoboxOptions} to create an {@link Infobox}.
*/
export interface BaseInfobox { export interface BaseInfobox {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string; id: string;
/** Whether or not this infobox is collapsed. */
collapsed: Persistent<boolean>; collapsed: Persistent<boolean>;
/** A symbol that helps identify features of the same type. */
type: typeof InfoboxType; type: typeof InfoboxType;
[Component]: typeof InfoboxComponent; /** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>; [GatherProps]: () => Record<string, unknown>;
} }
/** An object that represents a feature that displays information in a collapsible way. */
export type Infobox<T extends InfoboxOptions> = Replace< export type Infobox<T extends InfoboxOptions> = Replace<
T & BaseInfobox, T & BaseInfobox,
{ {
@ -48,22 +75,27 @@ export type Infobox<T extends InfoboxOptions> = Replace<
} }
>; >;
/** A type that matches any valid {@link Infobox} object. */
export type GenericInfobox = Replace< export type GenericInfobox = Replace<
Infobox<InfoboxOptions>, Infobox<InfoboxOptions>,
{ {
visibility: ProcessedComputable<Visibility>; visibility: ProcessedComputable<Visibility | boolean>;
} }
>; >;
/**
* Lazily creates an infobox with the given options.
* @param optionsFunc Infobox options.
*/
export function createInfobox<T extends InfoboxOptions>( export function createInfobox<T extends InfoboxOptions>(
optionsFunc: OptionsFunc<T, BaseInfobox, GenericInfobox> optionsFunc: OptionsFunc<T, BaseInfobox, GenericInfobox>
): Infobox<T> { ): Infobox<T> {
const collapsed = persistent<boolean>(false); const collapsed = persistent<boolean>(false, false);
return createLazyProxy(() => { return createLazyProxy(feature => {
const infobox = optionsFunc(); const infobox = optionsFunc.call(feature, feature);
infobox.id = getUniqueID("infobox-"); infobox.id = getUniqueID("infobox-");
infobox.type = InfoboxType; infobox.type = InfoboxType;
infobox[Component] = InfoboxComponent; infobox[Component] = InfoboxComponent as GenericComponent;
infobox.collapsed = collapsed; infobox.collapsed = collapsed;

View file

@ -2,7 +2,7 @@
<line <line
stroke-width="15px" stroke-width="15px"
stroke="white" stroke="white"
v-bind="link" v-bind="linkProps"
:x1="startPosition.x" :x1="startPosition.x"
:y1="startPosition.y" :y1="startPosition.y"
:x2="endPosition.x" :x2="endPosition.x"
@ -13,6 +13,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Link } from "features/links/links"; import type { Link } from "features/links/links";
import type { FeatureNode } from "game/layers"; import type { FeatureNode } from "game/layers";
import { kebabifyObject } from "util/vue";
import { computed, toRefs } from "vue"; import { computed, toRefs } from "vue";
const _props = defineProps<{ const _props = defineProps<{
@ -54,4 +55,6 @@ const endPosition = computed(() => {
} }
return position; return position;
}); });
const linkProps = computed(() => kebabifyObject(_props.link as unknown as Record<string, unknown>));
</script> </script>

View file

@ -1,4 +1,4 @@
import type { OptionsFunc, Replace } from "features/feature"; import type { GenericComponent, OptionsFunc, Replace } from "features/feature";
import { GatherProps, Component } from "features/feature"; import { GatherProps, Component } from "features/feature";
import type { Position } from "game/layers"; import type { Position } from "game/layers";
import type { Computable, GetComputableType, ProcessedComputable } from "util/computed"; import type { Computable, GetComputableType, ProcessedComputable } from "util/computed";
@ -7,8 +7,10 @@ import { createLazyProxy } from "util/proxies";
import type { SVGAttributes } from "vue"; import type { SVGAttributes } from "vue";
import LinksComponent from "./Links.vue"; import LinksComponent from "./Links.vue";
/** A symbol used to identify {@link Links} features. */
export const LinksType = Symbol("Links"); export const LinksType = Symbol("Links");
/** Represents a link between two nodes. It will be displayed as an SVG line, and can take any appropriate properties for an SVG line element. */
export interface Link extends SVGAttributes { export interface Link extends SVGAttributes {
startNode: { id: string }; startNode: { id: string };
endNode: { id: string }; endNode: { id: string };
@ -16,16 +18,25 @@ export interface Link extends SVGAttributes {
offsetEnd?: Position; offsetEnd?: Position;
} }
/** An object that configures a {@link Links}. */
export interface LinksOptions { export interface LinksOptions {
/** The list of links to display. */
links: Computable<Link[]>; links: Computable<Link[]>;
} }
/**
* The properties that are added onto a processed {@link LinksOptions} to create an {@link Links}.
*/
export interface BaseLinks { export interface BaseLinks {
/** A symbol that helps identify features of the same type. */
type: typeof LinksType; type: typeof LinksType;
[Component]: typeof LinksComponent; /** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>; [GatherProps]: () => Record<string, unknown>;
} }
/** An object that represents a list of links between nodes, which are the elements in the DOM for any renderable feature. */
export type Links<T extends LinksOptions> = Replace< export type Links<T extends LinksOptions> = Replace<
T & BaseLinks, T & BaseLinks,
{ {
@ -33,6 +44,7 @@ export type Links<T extends LinksOptions> = Replace<
} }
>; >;
/** A type that matches any valid {@link Links} object. */
export type GenericLinks = Replace< export type GenericLinks = Replace<
Links<LinksOptions>, Links<LinksOptions>,
{ {
@ -40,13 +52,17 @@ export type GenericLinks = Replace<
} }
>; >;
/**
* Lazily creates links with the given options.
* @param optionsFunc Links options.
*/
export function createLinks<T extends LinksOptions>( export function createLinks<T extends LinksOptions>(
optionsFunc: OptionsFunc<T, BaseLinks, GenericLinks> optionsFunc: OptionsFunc<T, BaseLinks, GenericLinks>
): Links<T> { ): Links<T> {
return createLazyProxy(() => { return createLazyProxy(feature => {
const links = optionsFunc(); const links = optionsFunc.call(feature, feature);
links.type = LinksType; links.type = LinksType;
links[Component] = LinksComponent; links[Component] = LinksComponent as GenericComponent;
processComputable(links as T, "links"); processComputable(links as T, "links");

View file

@ -1,126 +0,0 @@
<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)" />
<Node :id="id" />
</div>
</template>
<script lang="tsx">
import "components/common/features.css";
import Node from "components/Node.vue";
import type { StyleValue } from "features/feature";
import { jsx, Visibility } from "features/feature";
import type { GenericMilestone } from "features/milestones/milestone";
import { coerceComponent, isCoercableComponent, processedPropType, unwrapRef } from "util/vue";
import type { Component, UnwrapRef } from "vue";
import { defineComponent, shallowRef, toRefs, unref, watchEffect } from "vue";
export default defineComponent({
props: {
visibility: {
type: processedPropType<Visibility>(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: {
Node
},
setup(props) {
const { display } = toRefs(props);
const comp = shallowRef<Component | string>("");
watchEffect(() => {
const currDisplay = unwrapRef(display);
if (currDisplay == null) {
comp.value = "";
return;
}
if (isCoercableComponent(currDisplay)) {
comp.value = coerceComponent(currDisplay);
return;
}
const Requirement = coerceComponent(currDisplay.requirement, "h3");
const EffectDisplay = coerceComponent(currDisplay.effectDisplay || "", "b");
const OptionsDisplay = coerceComponent(currDisplay.optionsDisplay || "", "span");
comp.value = coerceComponent(
jsx(() => (
<span>
<Requirement />
{currDisplay.effectDisplay ? (
<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;
background-color: var(--locked);
border-width: 4px;
border-radius: 5px;
color: rgba(0, 0, 0, 0.5);
}
.milestone.done {
background-color: var(--bought);
cursor: default;
}
.milestone :deep(.equal-spaced) {
display: flex;
justify-content: center;
}
.milestone :deep(.equal-spaced > *) {
margin: auto;
}
</style>

View file

@ -1,192 +0,0 @@
import Select from "components/fields/Select.vue";
import type { CoercableComponent, OptionsFunc, Replace, StyleValue } from "features/feature";
import { Component, GatherProps, getUniqueID, jsx, setDefault, Visibility } from "features/feature";
import MilestoneComponent from "features/milestones/Milestone.vue";
import { globalBus } from "game/events";
import "game/notifications";
import type { Persistent } from "game/persistence";
import { persistent } from "game/persistence";
import player from "game/player";
import settings, { registerSettingField } from "game/settings";
import { camelToTitle } from "util/common";
import type {
Computable,
GetComputableType,
GetComputableTypeWithDefault,
ProcessedComputable
} from "util/computed";
import { processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies";
import { coerceComponent, isCoercableComponent } from "util/vue";
import { computed, unref, watchEffect } from "vue";
import { useToast } from "vue-toastification";
const toast = useToast();
export const MilestoneType = Symbol("Milestone");
export enum MilestoneDisplay {
All = "all",
//Last = "last",
Configurable = "configurable",
Incomplete = "incomplete",
None = "none"
}
export interface MilestoneOptions {
visibility?: Computable<Visibility>;
shouldEarn?: () => boolean;
style?: Computable<StyleValue>;
classes?: Computable<Record<string, boolean>>;
display?: Computable<
| CoercableComponent
| {
requirement: CoercableComponent;
effectDisplay?: CoercableComponent;
optionsDisplay?: CoercableComponent;
}
>;
onComplete?: VoidFunction;
}
export interface BaseMilestone {
id: string;
earned: Persistent<boolean>;
complete: VoidFunction;
type: typeof MilestoneType;
[Component]: typeof MilestoneComponent;
[GatherProps]: () => Record<string, unknown>;
}
export type Milestone<T extends MilestoneOptions> = Replace<
T & BaseMilestone,
{
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
style: GetComputableType<T["style"]>;
classes: GetComputableType<T["classes"]>;
display: GetComputableType<T["display"]>;
}
>;
export type GenericMilestone = Replace<
Milestone<MilestoneOptions>,
{
visibility: ProcessedComputable<Visibility>;
}
>;
export function createMilestone<T extends MilestoneOptions>(
optionsFunc?: OptionsFunc<T, BaseMilestone, GenericMilestone>
): Milestone<T> {
const earned = persistent<boolean>(false);
return createLazyProxy(() => {
const milestone = optionsFunc?.() ?? ({} as ReturnType<NonNullable<typeof optionsFunc>>);
milestone.id = getUniqueID("milestone-");
milestone.type = MilestoneType;
milestone[Component] = MilestoneComponent;
milestone.earned = earned;
milestone.complete = function () {
earned.value = true;
};
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, "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: unref(style), classes, earned, id };
};
if (milestone.shouldEarn) {
const genericMilestone = milestone as GenericMilestone;
watchEffect(() => {
if (settings.active !== player.id) return;
if (
!genericMilestone.earned.value &&
unref(genericMilestone.visibility) === Visibility.Visible &&
genericMilestone.shouldEarn?.()
) {
genericMilestone.earned.value = true;
genericMilestone.onComplete?.();
if (genericMilestone.display) {
const display = unref(genericMilestone.display);
const Display = coerceComponent(
isCoercableComponent(display) ? display : display.requirement
);
toast(
<>
<h3>Milestone earned!</h3>
<div>
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
{/* @ts-ignore */}
<Display />
</div>
</>
);
}
}
});
}
return milestone as unknown as Milestone<T>;
});
}
declare module "game/settings" {
interface Settings {
msDisplay: MilestoneDisplay;
}
}
globalBus.on("loadSettings", settings => {
setDefault(settings, "msDisplay", MilestoneDisplay.All);
});
const msDisplayOptions = Object.values(MilestoneDisplay).map(option => ({
label: camelToTitle(option),
value: option
}));
registerSettingField(
jsx(() => (
<Select
title="Show Milestones"
options={msDisplayOptions}
onUpdate:modelValue={value => (settings.msDisplay = value as MilestoneDisplay)}
modelValue={settings.msDisplay}
/>
))
);

View file

@ -14,7 +14,7 @@ import { globalBus } from "game/events";
import "lib/pixi"; import "lib/pixi";
import { processedPropType } from "util/vue"; import { processedPropType } from "util/vue";
import type { PropType } from "vue"; import type { PropType } from "vue";
import { defineComponent, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, unref } from "vue"; import { defineComponent, nextTick, onBeforeUnmount, onMounted, shallowRef, unref } from "vue";
// TODO get typing support on the Particles component // TODO get typing support on the Particles component
export default defineComponent({ export default defineComponent({

View file

@ -8,24 +8,49 @@ import type { Computable, GetComputableType } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy } from "util/proxies";
import { Ref, shallowRef, unref } from "vue"; import { Ref, shallowRef, unref } from "vue";
/** A symbol used to identify {@link Particles} features. */
export const ParticlesType = Symbol("Particles"); export const ParticlesType = Symbol("Particles");
/**
* An object that configures {@link Particles}.
*/
export interface ParticlesOptions { export interface ParticlesOptions {
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>; classes?: Computable<Record<string, boolean>>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>; style?: Computable<StyleValue>;
/** A function that is called when the particles canvas is resized. */
onContainerResized?: (boundingRect: DOMRect) => void; onContainerResized?: (boundingRect: DOMRect) => void;
/** A function that is called whenever the particles element is reloaded during development. For restarting particle effects. */
onHotReload?: VoidFunction; onHotReload?: VoidFunction;
} }
/**
* The properties that are added onto a processed {@link ParticlesOptions} to create an {@link Particles}.
*/
export interface BaseParticles { export interface BaseParticles {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string; id: string;
/** The Pixi.JS Application powering this particles canvas. */
app: Ref<null | Application>; app: Ref<null | Application>;
/**
* A function to asynchronously add an emitter to the canvas.
* The returned emitter can then be positioned as appropriate and started.
* @see {@link Particles}
*/
addEmitter: (config: EmitterConfigV3) => Promise<Emitter>; addEmitter: (config: EmitterConfigV3) => Promise<Emitter>;
/** A symbol that helps identify features of the same type. */
type: typeof ParticlesType; type: typeof ParticlesType;
/** The Vue component used to render this feature. */
[Component]: GenericComponent; [Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>; [GatherProps]: () => Record<string, unknown>;
} }
/**
* An object that represents a feature that display particle effects on the screen.
* The config should typically be gotten by designing the effect using the [online particle effect editor](https://pixijs.io/pixi-particles-editor/) and passing it into the {@link upgradeConfig} from @pixi/particle-emitter.
*/
export type Particles<T extends ParticlesOptions> = Replace< export type Particles<T extends ParticlesOptions> = Replace<
T & BaseParticles, T & BaseParticles,
{ {
@ -34,16 +59,23 @@ export type Particles<T extends ParticlesOptions> = Replace<
} }
>; >;
/** A type that matches any valid {@link Particles} object. */
export type GenericParticles = Particles<ParticlesOptions>; export type GenericParticles = Particles<ParticlesOptions>;
/**
* Lazily creates particles with the given options.
* @param optionsFunc Particles options.
*/
export function createParticles<T extends ParticlesOptions>( export function createParticles<T extends ParticlesOptions>(
optionsFunc?: OptionsFunc<T, BaseParticles, GenericParticles> optionsFunc?: OptionsFunc<T, BaseParticles, GenericParticles>
): Particles<T> { ): Particles<T> {
return createLazyProxy(() => { return createLazyProxy(feature => {
const particles = optionsFunc?.() ?? ({} as ReturnType<NonNullable<typeof optionsFunc>>); const particles =
optionsFunc?.call(feature, feature) ??
({} as ReturnType<NonNullable<typeof optionsFunc>>);
particles.id = getUniqueID("particles-"); particles.id = getUniqueID("particles-");
particles.type = ParticlesType; particles.type = ParticlesType;
particles[Component] = ParticlesComponent; particles[Component] = ParticlesComponent as GenericComponent;
particles.app = shallowRef(null); particles.app = shallowRef(null);
particles.addEmitter = (config: EmitterConfigV3): Promise<Emitter> => { particles.addEmitter = (config: EmitterConfigV3): Promise<Emitter> => {

299
src/features/repeatable.tsx Normal file
View file

@ -0,0 +1,299 @@
import { isArray } from "@vue/shared";
import ClickableComponent from "features/clickables/Clickable.vue";
import type {
CoercableComponent,
GenericComponent,
OptionsFunc,
Replace,
StyleValue
} from "features/feature";
import { Component, GatherProps, Visibility, getUniqueID, jsx, setDefault } from "features/feature";
import { DefaultValue, Persistent, persistent } from "game/persistence";
import {
Requirements,
createVisibilityRequirement,
displayRequirements,
maxRequirementsMet,
payRequirements,
requirementsMet
} from "game/requirements";
import type { DecimalSource } from "util/bignum";
import Decimal, { formatWhole } from "util/bignum";
import type {
Computable,
GetComputableType,
GetComputableTypeWithDefault,
ProcessedComputable
} from "util/computed";
import { processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies";
import { coerceComponent, isCoercableComponent } from "util/vue";
import type { Ref } from "vue";
import { computed, unref } from "vue";
import { GenericDecorator } from "./decorators/common";
/** A symbol used to identify {@link Repeatable} features. */
export const RepeatableType = Symbol("Repeatable");
/** A type that can be used to customize the {@link Repeatable} display. */
export type RepeatableDisplay =
| CoercableComponent
| {
/** A header to appear at the top of the display. */
title?: CoercableComponent;
/** The main text that appears in the display. */
description?: CoercableComponent;
/** A description of the current effect of this repeatable, based off its amount. */
effectDisplay?: CoercableComponent;
/** Whether or not to show the current amount of this repeatable at the bottom of the display. */
showAmount?: boolean;
};
/** An object that configures a {@link Repeatable}. */
export interface RepeatableOptions {
/** Whether this repeatable should be visible. */
visibility?: Computable<Visibility | boolean>;
/** The requirement(s) to increase this repeatable. */
requirements: Requirements;
/** The maximum amount obtainable for this repeatable. */
limit?: Computable<DecimalSource>;
/** The initial amount this repeatable has on a new save / after reset. */
initialAmount?: DecimalSource;
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>;
/** Shows a marker on the corner of the feature. */
mark?: Computable<boolean | string>;
/** Toggles a smaller design for the feature. */
small?: Computable<boolean>;
/** The display to use for this repeatable. */
display?: Computable<RepeatableDisplay>;
}
/**
* The properties that are added onto a processed {@link RepeatableOptions} to create a {@link Repeatable}.
*/
export interface BaseRepeatable {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string;
/** The current amount this repeatable has. */
amount: Persistent<DecimalSource>;
/** Whether or not this repeatable's amount is at it's limit. */
maxed: Ref<boolean>;
/** Whether or not this repeatable can be clicked. */
canClick: ProcessedComputable<boolean>;
/**
* How much amount can be increased by, or 1 if unclickable.
**/
amountToIncrease: Ref<DecimalSource>;
/** A function that gets called when this repeatable is clicked. */
onClick: (event?: MouseEvent | TouchEvent) => void;
/** A symbol that helps identify features of the same type. */
type: typeof RepeatableType;
/** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>;
}
/** An object that represents a feature with multiple "levels" with scaling requirements. */
export type Repeatable<T extends RepeatableOptions> = Replace<
T & BaseRepeatable,
{
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
requirements: GetComputableType<T["requirements"]>;
limit: GetComputableTypeWithDefault<T["limit"], Decimal>;
classes: GetComputableType<T["classes"]>;
style: GetComputableType<T["style"]>;
mark: GetComputableType<T["mark"]>;
small: GetComputableType<T["small"]>;
display: Ref<CoercableComponent>;
}
>;
/** A type that matches any valid {@link Repeatable} object. */
export type GenericRepeatable = Replace<
Repeatable<RepeatableOptions>,
{
visibility: ProcessedComputable<Visibility | boolean>;
limit: ProcessedComputable<DecimalSource>;
}
>;
/**
* Lazily creates a repeatable with the given options.
* @param optionsFunc Repeatable options.
*/
export function createRepeatable<T extends RepeatableOptions>(
optionsFunc: OptionsFunc<T, BaseRepeatable, GenericRepeatable>,
...decorators: GenericDecorator[]
): Repeatable<T> {
const amount = persistent<DecimalSource>(0);
const decoratedData = decorators.reduce(
(current, next) => Object.assign(current, next.getPersistentData?.()),
{}
);
return createLazyProxy<Repeatable<T>, Repeatable<T>>(feature => {
const repeatable = optionsFunc.call(feature, feature);
repeatable.id = getUniqueID("repeatable-");
repeatable.type = RepeatableType;
repeatable[Component] = ClickableComponent as GenericComponent;
for (const decorator of decorators) {
decorator.preConstruct?.(repeatable);
}
repeatable.amount = amount;
repeatable.amount[DefaultValue] = repeatable.initialAmount ?? 0;
Object.assign(repeatable, decoratedData);
const limitRequirement = {
requirementMet: computed(() =>
Decimal.sub(
unref((repeatable as GenericRepeatable).limit),
(repeatable as GenericRepeatable).amount.value
)
),
requiresPay: false,
visibility: Visibility.None,
canMaximize: true
} as const;
const visibilityRequirement = createVisibilityRequirement(repeatable as GenericRepeatable);
if (isArray(repeatable.requirements)) {
repeatable.requirements.unshift(visibilityRequirement);
repeatable.requirements.push(limitRequirement);
} else {
repeatable.requirements = [
visibilityRequirement,
repeatable.requirements,
limitRequirement
];
}
repeatable.maxed = computed(() =>
Decimal.gte(
(repeatable as GenericRepeatable).amount.value,
unref((repeatable as GenericRepeatable).limit)
)
);
processComputable(repeatable as T, "classes");
const classes = repeatable.classes as
| ProcessedComputable<Record<string, boolean>>
| undefined;
repeatable.classes = computed(() => {
const currClasses = unref(classes) || {};
if ((repeatable as GenericRepeatable).maxed.value) {
currClasses.bought = true;
}
return currClasses;
});
repeatable.amountToIncrease = computed(() =>
Decimal.clampMin(maxRequirementsMet(repeatable.requirements), 1)
);
repeatable.canClick = computed(() => requirementsMet(repeatable.requirements));
const onClick = repeatable.onClick;
repeatable.onClick = function (this: GenericRepeatable, event?: MouseEvent | TouchEvent) {
const genericRepeatable = repeatable as GenericRepeatable;
if (!unref(genericRepeatable.canClick)) {
return;
}
const amountToIncrease = unref(repeatable.amountToIncrease) ?? 1;
payRequirements(repeatable.requirements, amountToIncrease);
genericRepeatable.amount.value = Decimal.add(
genericRepeatable.amount.value,
amountToIncrease
);
onClick?.(event);
};
processComputable(repeatable as T, "display");
const display = repeatable.display;
repeatable.display = jsx(() => {
// TODO once processComputable types correctly, remove this "as X"
const currDisplay = unref(display) as RepeatableDisplay;
if (isCoercableComponent(currDisplay)) {
const CurrDisplay = coerceComponent(currDisplay);
return <CurrDisplay />;
}
if (currDisplay != null) {
const genericRepeatable = repeatable as GenericRepeatable;
const Title = coerceComponent(currDisplay.title ?? "", "h3");
const Description = coerceComponent(currDisplay.description ?? "");
const EffectDisplay = coerceComponent(currDisplay.effectDisplay ?? "");
return (
<span>
{currDisplay.title == null ? null : (
<div>
<Title />
</div>
)}
{currDisplay.description == null ? null : <Description />}
{currDisplay.showAmount === false ? null : (
<div>
<br />
<>Amount: {formatWhole(genericRepeatable.amount.value)}</>
{Decimal.isFinite(unref(genericRepeatable.limit)) ? (
<> / {formatWhole(unref(genericRepeatable.limit))}</>
) : undefined}
</div>
)}
{currDisplay.effectDisplay == null ? null : (
<div>
<br />
Currently: <EffectDisplay />
</div>
)}
{genericRepeatable.maxed.value ? null : (
<div>
<br />
{displayRequirements(
genericRepeatable.requirements,
unref(repeatable.amountToIncrease)
)}
</div>
)}
</span>
);
}
return "";
});
processComputable(repeatable as T, "visibility");
setDefault(repeatable, "visibility", Visibility.Visible);
processComputable(repeatable as T, "limit");
setDefault(repeatable, "limit", Decimal.dInf);
processComputable(repeatable as T, "style");
processComputable(repeatable as T, "mark");
processComputable(repeatable as T, "small");
for (const decorator of decorators) {
decorator.postConstruct?.(repeatable);
}
const decoratedProps = decorators.reduce(
(current, next) => Object.assign(current, next.getGatheredProps?.(repeatable)),
{}
);
repeatable[GatherProps] = function (this: GenericRepeatable) {
const { display, visibility, style, classes, onClick, canClick, small, mark, id } =
this;
return {
display,
visibility,
style: unref(style),
classes,
onClick,
canClick,
small,
mark,
id,
...decoratedProps
};
};
return repeatable as unknown as Repeatable<T>;
});
}

View file

@ -1,9 +1,10 @@
import type { OptionsFunc, Replace } from "features/feature"; import type { OptionsFunc, Replace } from "features/feature";
import { getUniqueID } from "features/feature"; import { getUniqueID } from "features/feature";
import { globalBus } from "game/events"; import { globalBus } from "game/events";
import Formula from "game/formulas/formulas";
import type { BaseLayer } from "game/layers"; import type { BaseLayer } from "game/layers";
import type { Persistent } from "game/persistence"; import { NonPersistent, Persistent, SkipPersistence } from "game/persistence";
import { DefaultValue, persistent, PersistentState } from "game/persistence"; import { DefaultValue, persistent } from "game/persistence";
import type { Unsubscribe } from "nanoevents"; import type { Unsubscribe } from "nanoevents";
import Decimal from "util/bignum"; import Decimal from "util/bignum";
import type { Computable, GetComputableType } from "util/computed"; import type { Computable, GetComputableType } from "util/computed";
@ -11,19 +12,32 @@ import { processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy } from "util/proxies";
import { isRef, unref } from "vue"; import { isRef, unref } from "vue";
/** A symbol used to identify {@link Reset} features. */
export const ResetType = Symbol("Reset"); export const ResetType = Symbol("Reset");
/**
* An object that configures a {@link Clickable}.
*/
export interface ResetOptions { export interface ResetOptions {
thingsToReset: Computable<Record<string, unknown>[]>; /** List of things to reset. Can include objects which will be recursed over for persistent values. */
thingsToReset: Computable<unknown[]>;
/** A function that is called when the reset is performed. */
onReset?: VoidFunction; onReset?: VoidFunction;
} }
/**
* The properties that are added onto a processed {@link ResetOptions} to create an {@link Reset}.
*/
export interface BaseReset { export interface BaseReset {
/** An auto-generated ID for identifying which reset is being performed. Will not persist between refreshes or updates. */
id: string; id: string;
/** Trigger the reset. */
reset: VoidFunction; reset: VoidFunction;
/** A symbol that helps identify features of the same type. */
type: typeof ResetType; type: typeof ResetType;
} }
/** An object that represents a reset mechanic, which resets progress back to its initial state. */
export type Reset<T extends ResetOptions> = Replace< export type Reset<T extends ResetOptions> = Replace<
T & BaseReset, T & BaseReset,
{ {
@ -31,23 +45,35 @@ export type Reset<T extends ResetOptions> = Replace<
} }
>; >;
/** A type that matches any valid {@link Reset} object. */
export type GenericReset = Reset<ResetOptions>; export type GenericReset = Reset<ResetOptions>;
/**
* Lazily creates a reset with the given options.
* @param optionsFunc Reset options.
*/
export function createReset<T extends ResetOptions>( export function createReset<T extends ResetOptions>(
optionsFunc: OptionsFunc<T, BaseReset, GenericReset> optionsFunc: OptionsFunc<T, BaseReset, GenericReset>
): Reset<T> { ): Reset<T> {
return createLazyProxy(() => { return createLazyProxy(feature => {
const reset = optionsFunc(); const reset = optionsFunc.call(feature, feature);
reset.id = getUniqueID("reset-"); reset.id = getUniqueID("reset-");
reset.type = ResetType; reset.type = ResetType;
reset.reset = function () { reset.reset = function () {
const handleObject = (obj: unknown) => { const handleObject = (obj: unknown) => {
if (obj && typeof obj === "object") { if (
if (PersistentState in obj) { obj != null &&
(obj as Persistent)[PersistentState].value = (obj as Persistent)[ typeof obj === "object" &&
DefaultValue !(obj instanceof Decimal) &&
]; !(obj instanceof Formula)
) {
if (SkipPersistence in obj && obj[SkipPersistence] === true) {
return;
}
if (DefaultValue in obj) {
const persistent = obj as NonPersistent;
persistent.value = persistent[DefaultValue];
} else if (!(obj instanceof Decimal) && !isRef(obj)) { } else if (!(obj instanceof Decimal) && !isRef(obj)) {
Object.values(obj).forEach(obj => Object.values(obj).forEach(obj =>
handleObject(obj as Record<string, unknown>) handleObject(obj as Record<string, unknown>)
@ -67,6 +93,11 @@ export function createReset<T extends ResetOptions>(
} }
const listeners: Record<string, Unsubscribe | undefined> = {}; const listeners: Record<string, Unsubscribe | undefined> = {};
/**
* Track the time since the specified reset last occured.
* @param layer The layer the reset is attached to
* @param reset The reset mechanic to track the time since
*/
export function trackResetTime(layer: BaseLayer, reset: GenericReset): Persistent<Decimal> { export function trackResetTime(layer: BaseLayer, reset: GenericReset): Persistent<Decimal> {
const resetTime = persistent<Decimal>(new Decimal(0)); const resetTime = persistent<Decimal>(new Decimal(0));
globalBus.on("addLayer", layerBeingAdded => { globalBus.on("addLayer", layerBeingAdded => {

View file

@ -2,7 +2,8 @@
<Sticky> <Sticky>
<div <div
class="main-display-container" class="main-display-container"
:style="{ height: `${(effectRef?.$el.clientHeight ?? 0) + 50}px` }" :class="classes ?? {}"
:style="[{ height: `${(effectRef?.$el.clientHeight ?? 0) + 50}px` }, style ?? {}]"
> >
<div class="main-display"> <div class="main-display">
<span v-if="showPrefix">You have </span> <span v-if="showPrefix">You have </span>

View file

@ -1,36 +1,72 @@
import { globalBus } from "game/events"; import { globalBus } from "game/events";
import type { State } from "game/persistence"; import type { Persistent, State } from "game/persistence";
import { persistent } from "game/persistence"; import { NonPersistent, persistent } from "game/persistence";
import type { DecimalSource } from "util/bignum"; import type { DecimalSource } from "util/bignum";
import Decimal, { format, formatWhole } from "util/bignum"; import Decimal, { format, formatWhole } from "util/bignum";
import type { ProcessedComputable } from "util/computed"; import type { ProcessedComputable } from "util/computed";
import { loadingSave } from "util/save";
import type { ComputedRef, Ref } from "vue"; import type { ComputedRef, Ref } from "vue";
import { computed, isRef, ref, unref, watch } from "vue"; import { computed, isRef, ref, unref, watch } from "vue";
/** An object that represents a named and quantifiable resource in the game. */
export interface Resource<T = DecimalSource> extends Ref<T> { export interface Resource<T = DecimalSource> extends Ref<T> {
/** The name of this resource. */
displayName: string; displayName: string;
/** When displaying the value of this resource, how many significant digits to display. */
precision: number; precision: number;
/** Whether or not to display very small values using scientific notation, or rounding to 0. */
small?: boolean; small?: boolean;
} }
/**
* Creates a resource.
* @param defaultValue The initial value of the resource
* @param displayName The human readable name of this resource
* @param precision The number of significant digits to display by default
* @param small Whether or not to display very small values or round to 0, by default
*/
export function createResource<T extends State>(
defaultValue: T,
displayName?: string,
precision?: number,
small?: boolean | undefined
): Resource<T> & Persistent<T> & { [NonPersistent]: Resource<T> };
export function createResource<T extends State>(
defaultValue: Ref<T>,
displayName?: string,
precision?: number,
small?: boolean | undefined
): Resource<T>;
export function createResource<T extends State>( export function createResource<T extends State>(
defaultValue: T | Ref<T>, defaultValue: T | Ref<T>,
displayName = "points", displayName = "points",
precision = 0, precision = 0,
small = undefined small: boolean | undefined = undefined
): Resource<T> { ) {
const resource: Partial<Resource<T>> = isRef(defaultValue) const resource: Partial<Resource<T>> = isRef(defaultValue)
? defaultValue ? defaultValue
: persistent(defaultValue); : persistent(defaultValue);
resource.displayName = displayName; resource.displayName = displayName;
resource.precision = precision; resource.precision = precision;
resource.small = small; resource.small = small;
if (!isRef(defaultValue)) {
const nonPersistentResource = (resource as Persistent<T>)[
NonPersistent
] as unknown as Resource<T>;
nonPersistentResource.displayName = displayName;
nonPersistentResource.precision = precision;
nonPersistentResource.small = small;
}
return resource as Resource<T>; return resource as Resource<T>;
} }
/** Returns a reference to the highest amount of the resource ever owned, which is updated automatically. */
export function trackBest(resource: Resource): Ref<DecimalSource> { export function trackBest(resource: Resource): Ref<DecimalSource> {
const best = persistent(resource.value); const best = persistent(resource.value);
watch(resource, amount => { watch(resource, amount => {
if (loadingSave.value) {
return;
}
if (Decimal.gt(amount, best.value)) { if (Decimal.gt(amount, best.value)) {
best.value = amount; best.value = amount;
} }
@ -38,9 +74,13 @@ export function trackBest(resource: Resource): Ref<DecimalSource> {
return best; return best;
} }
/** Returns a reference to the total amount of the resource gained, updated automatically. "Refunds" count as gain. */
export function trackTotal(resource: Resource): Ref<DecimalSource> { export function trackTotal(resource: Resource): Ref<DecimalSource> {
const total = persistent(resource.value); const total = persistent(resource.value);
watch(resource, (amount, prevAmount) => { watch(resource, (amount, prevAmount) => {
if (loadingSave.value) {
return;
}
if (Decimal.gt(amount, prevAmount)) { if (Decimal.gt(amount, prevAmount)) {
total.value = Decimal.add(total.value, Decimal.sub(amount, prevAmount)); total.value = Decimal.add(total.value, Decimal.sub(amount, prevAmount));
} }
@ -50,6 +90,7 @@ export function trackTotal(resource: Resource): Ref<DecimalSource> {
const tetra8 = new Decimal("10^^8"); const tetra8 = new Decimal("10^^8");
const e100 = new Decimal("1e100"); const e100 = new Decimal("1e100");
/** Returns a reference to the amount of resource being gained in terms of orders of magnitude per second, calcualted over the last tick. Useful for situations where the gain rate is increasing very rapidly. */
export function trackOOMPS( export function trackOOMPS(
resource: Resource, resource: Resource,
pointGain?: ComputedRef<DecimalSource> pointGain?: ComputedRef<DecimalSource>
@ -108,14 +149,16 @@ export function trackOOMPS(
return oompsString; return oompsString;
} }
/** Utility for displaying a resource with the correct precision. */
export function displayResource(resource: Resource, overrideAmount?: DecimalSource): string { export function displayResource(resource: Resource, overrideAmount?: DecimalSource): string {
const amount = overrideAmount ?? resource.value; const amount = overrideAmount ?? resource.value;
if (Decimal.eq(resource.precision, 0)) { if (Decimal.eq(resource.precision, 0)) {
return formatWhole(amount); return formatWhole(resource.small ? amount : Decimal.floor(amount));
} }
return format(amount, resource.precision, resource.small); return format(amount, resource.precision, resource.small);
} }
/** Utility for unwrapping a resource that may or may not be inside a ref. */
export function unwrapResource(resource: ProcessedComputable<Resource>): Resource { export function unwrapResource(resource: ProcessedComputable<Resource>): Resource {
if ("displayName" in resource) { if ("displayName" in resource) {
return resource; return resource;

View file

@ -1,11 +1,11 @@
<template> <template>
<button <button
v-if="unref(visibility) !== Visibility.None" v-if="isVisible(visibility)"
@click="selectTab" @click="selectTab"
class="tabButton" class="tabButton"
:style="[ :style="[
{ {
visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined visibility: isHidden(visibility) ? 'hidden' : undefined
}, },
glowColorStyle, glowColorStyle,
unref(style) ?? {} unref(style) ?? {}
@ -21,7 +21,7 @@
<script lang="ts"> <script lang="ts">
import type { CoercableComponent, StyleValue } from "features/feature"; import type { CoercableComponent, StyleValue } from "features/feature";
import { Visibility } from "features/feature"; import { isHidden, isVisible, Visibility } from "features/feature";
import { getNotifyStyle } from "game/notifications"; import { getNotifyStyle } from "game/notifications";
import { computeComponent, processedPropType, unwrapRef } from "util/vue"; import { computeComponent, processedPropType, unwrapRef } from "util/vue";
import { computed, defineComponent, toRefs, unref } from "vue"; import { computed, defineComponent, toRefs, unref } from "vue";
@ -29,7 +29,7 @@ import { computed, defineComponent, toRefs, unref } from "vue";
export default defineComponent({ export default defineComponent({
props: { props: {
visibility: { visibility: {
type: processedPropType<Visibility>(Number), type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true required: true
}, },
display: { display: {
@ -50,7 +50,7 @@ export default defineComponent({
const glowColorStyle = computed(() => { const glowColorStyle = computed(() => {
const color = unwrapRef(glowColor); const color = unwrapRef(glowColor);
if (!color) { if (color == null || color === "") {
return {}; return {};
} }
if (unref(floating)) { if (unref(floating)) {
@ -68,7 +68,9 @@ export default defineComponent({
component, component,
glowColorStyle, glowColorStyle,
unref, unref,
Visibility Visibility,
isVisible,
isHidden
}; };
} }
}); });

View file

@ -1,11 +1,11 @@
<template> <template>
<div <div
v-if="unref(visibility) !== Visibility.None" v-if="isVisible(visibility)"
class="tab-family-container" class="tab-family-container"
:class="{ ...unref(classes), ...tabClasses }" :class="{ ...unref(classes), ...tabClasses }"
:style="[ :style="[
{ {
visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined visibility: isHidden(visibility) ? 'hidden' : undefined
}, },
unref(style) ?? [], unref(style) ?? [],
tabStyle ?? [] tabStyle ?? []
@ -37,7 +37,7 @@
import Sticky from "components/layout/Sticky.vue"; import Sticky from "components/layout/Sticky.vue";
import themes from "data/themes"; import themes from "data/themes";
import type { CoercableComponent, StyleValue } from "features/feature"; import type { CoercableComponent, StyleValue } from "features/feature";
import { Visibility } from "features/feature"; import { isHidden, isVisible, Visibility } from "features/feature";
import type { GenericTab } from "features/tabs/tab"; import type { GenericTab } from "features/tabs/tab";
import TabButton from "features/tabs/TabButton.vue"; import TabButton from "features/tabs/TabButton.vue";
import type { GenericTabButton } from "features/tabs/tabFamily"; import type { GenericTabButton } from "features/tabs/tabFamily";
@ -49,7 +49,7 @@ import { computed, defineComponent, shallowRef, toRefs, unref, watchEffect } fro
export default defineComponent({ export default defineComponent({
props: { props: {
visibility: { visibility: {
type: processedPropType<Visibility>(Number), type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true required: true
}, },
activeTab: { activeTab: {
@ -123,7 +123,9 @@ export default defineComponent({
Visibility, Visibility,
component, component,
gatherButtonProps, gatherButtonProps,
unref unref,
isVisible,
isHidden
}; };
} }
}); });
@ -220,8 +222,8 @@ export default defineComponent({
} }
.showGoBack .showGoBack
> .tab-family-container > .tab-family-container:first-child
> .tab-buttons-container:not(.floating):first-child > .tab-buttons-container:not(.floating)
.tab-buttons { .tab-buttons {
padding-left: 70px; padding-left: 70px;
} }

View file

@ -1,24 +1,48 @@
import type { CoercableComponent, OptionsFunc, Replace, StyleValue } from "features/feature"; import type {
CoercableComponent,
GenericComponent,
OptionsFunc,
Replace,
StyleValue
} from "features/feature";
import { Component, GatherProps, getUniqueID } from "features/feature"; import { Component, GatherProps, getUniqueID } from "features/feature";
import TabComponent from "features/tabs/Tab.vue"; import TabComponent from "features/tabs/Tab.vue";
import type { Computable, GetComputableType } from "util/computed"; import type { Computable, GetComputableType } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy } from "util/proxies";
/** A symbol used to identify {@link Tab} features. */
export const TabType = Symbol("Tab"); export const TabType = Symbol("Tab");
/**
* An object that configures a {@link Tab}.
*/
export interface TabOptions { export interface TabOptions {
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>; classes?: Computable<Record<string, boolean>>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>; style?: Computable<StyleValue>;
/** The display to use for this tab. */
display: Computable<CoercableComponent>; display: Computable<CoercableComponent>;
} }
/**
* The properties that are added onto a processed {@link TabOptions} to create an {@link Tab}.
*/
export interface BaseTab { export interface BaseTab {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string; id: string;
/** A symbol that helps identify features of the same type. */
type: typeof TabType; type: typeof TabType;
[Component]: typeof TabComponent; /** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>; [GatherProps]: () => Record<string, unknown>;
} }
/**
* An object representing a tab of content in a tabbed interface.
* @see {@link TabFamily}
*/
export type Tab<T extends TabOptions> = Replace< export type Tab<T extends TabOptions> = Replace<
T & BaseTab, T & BaseTab,
{ {
@ -28,16 +52,21 @@ export type Tab<T extends TabOptions> = Replace<
} }
>; >;
/** A type that matches any valid {@link Tab} object. */
export type GenericTab = Tab<TabOptions>; export type GenericTab = Tab<TabOptions>;
/**
* Lazily creates a tab with the given options.
* @param optionsFunc Tab options.
*/
export function createTab<T extends TabOptions>( export function createTab<T extends TabOptions>(
optionsFunc: OptionsFunc<T, BaseTab, GenericTab> optionsFunc: OptionsFunc<T, BaseTab, GenericTab>
): Tab<T> { ): Tab<T> {
return createLazyProxy(() => { return createLazyProxy(feature => {
const tab = optionsFunc(); const tab = optionsFunc.call(feature, feature);
tab.id = getUniqueID("tab-"); tab.id = getUniqueID("tab-");
tab.type = TabType; tab.type = TabType;
tab[Component] = TabComponent; tab[Component] = TabComponent as GenericComponent;
tab[GatherProps] = function (this: GenericTab) { tab[GatherProps] = function (this: GenericTab) {
const { display } = this; const { display } = this;

View file

@ -1,5 +1,18 @@
import type { CoercableComponent, GenericComponent, OptionsFunc, Replace, StyleValue } from "features/feature"; import type {
import { Component, GatherProps, getUniqueID, setDefault, Visibility } from "features/feature"; CoercableComponent,
GenericComponent,
OptionsFunc,
Replace,
StyleValue
} from "features/feature";
import {
Component,
GatherProps,
getUniqueID,
isVisible,
setDefault,
Visibility
} from "features/feature";
import TabButtonComponent from "features/tabs/TabButton.vue"; import TabButtonComponent from "features/tabs/TabButton.vue";
import TabFamilyComponent from "features/tabs/TabFamily.vue"; import TabFamilyComponent from "features/tabs/TabFamily.vue";
import type { Persistent } from "game/persistence"; import type { Persistent } from "game/persistence";
@ -16,23 +29,43 @@ import type { Ref } from "vue";
import { computed, unref } from "vue"; import { computed, unref } from "vue";
import type { GenericTab } from "./tab"; import type { GenericTab } from "./tab";
/** A symbol used to identify {@link TabButton} features. */
export const TabButtonType = Symbol("TabButton"); export const TabButtonType = Symbol("TabButton");
/** A symbol used to identify {@link TabFamily} features. */
export const TabFamilyType = Symbol("TabFamily"); export const TabFamilyType = Symbol("TabFamily");
/**
* An object that configures a {@link TabButton}.
*/
export interface TabButtonOptions { export interface TabButtonOptions {
visibility?: Computable<Visibility>; /** Whether this tab button should be visible. */
visibility?: Computable<Visibility | boolean>;
/** The tab to display when this button is clicked. */
tab: Computable<GenericTab | CoercableComponent>; tab: Computable<GenericTab | CoercableComponent>;
/** The label on this button. */
display: Computable<CoercableComponent>; display: Computable<CoercableComponent>;
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>; classes?: Computable<Record<string, boolean>>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>; style?: Computable<StyleValue>;
/** The color of the glow effect to display when this button is active. */
glowColor?: Computable<string>; glowColor?: Computable<string>;
} }
/**
* The properties that are added onto a processed {@link TabButtonOptions} to create an {@link TabButton}.
*/
export interface BaseTabButton { export interface BaseTabButton {
/** A symbol that helps identify features of the same type. */
type: typeof TabButtonType; type: typeof TabButtonType;
[Component]: typeof TabButtonComponent; /** The Vue component used to render this feature. */
[Component]: GenericComponent;
} }
/**
* An object that represents a button that can be clicked to change tabs in a tabbed interface.
* @see {@link TabFamily}
*/
export type TabButton<T extends TabButtonOptions> = Replace< export type TabButton<T extends TabButtonOptions> = Replace<
T & BaseTabButton, T & BaseTabButton,
{ {
@ -45,31 +78,54 @@ export type TabButton<T extends TabButtonOptions> = Replace<
} }
>; >;
/** A type that matches any valid {@link TabButton} object. */
export type GenericTabButton = Replace< export type GenericTabButton = Replace<
TabButton<TabButtonOptions>, TabButton<TabButtonOptions>,
{ {
visibility: ProcessedComputable<Visibility>; visibility: ProcessedComputable<Visibility | boolean>;
} }
>; >;
/**
* An object that configures a {@link TabFamily}.
*/
export interface TabFamilyOptions { export interface TabFamilyOptions {
visibility?: Computable<Visibility>; /** Whether this tab button should be visible. */
visibility?: Computable<Visibility | boolean>;
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>; classes?: Computable<Record<string, boolean>>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>; style?: Computable<StyleValue>;
/** A dictionary of CSS classes to apply to the list of buttons for changing tabs. */
buttonContainerClasses?: Computable<Record<string, boolean>>; buttonContainerClasses?: Computable<Record<string, boolean>>;
/** CSS to apply to the list of buttons for changing tabs. */
buttonContainerStyle?: Computable<StyleValue>; buttonContainerStyle?: Computable<StyleValue>;
} }
/**
* The properties that are added onto a processed {@link TabFamilyOptions} to create an {@link TabFamily}.
*/
export interface BaseTabFamily { export interface BaseTabFamily {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string; id: string;
/** All the tabs within this family. */
tabs: Record<string, TabButtonOptions>; tabs: Record<string, TabButtonOptions>;
/** The currently active tab, if any. */
activeTab: Ref<GenericTab | CoercableComponent | null>; activeTab: Ref<GenericTab | CoercableComponent | null>;
/** The name of the tab that is currently active. */
selected: Persistent<string>; selected: Persistent<string>;
/** A symbol that helps identify features of the same type. */
type: typeof TabFamilyType; type: typeof TabFamilyType;
/** The Vue component used to render this feature. */
[Component]: GenericComponent; [Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>; [GatherProps]: () => Record<string, unknown>;
} }
/**
* An object that represents a tabbed interface.
* @see {@link TabFamily}
*/
export type TabFamily<T extends TabFamilyOptions> = Replace< export type TabFamily<T extends TabFamilyOptions> = Replace<
T & BaseTabFamily, T & BaseTabFamily,
{ {
@ -78,25 +134,31 @@ export type TabFamily<T extends TabFamilyOptions> = Replace<
} }
>; >;
/** A type that matches any valid {@link TabFamily} object. */
export type GenericTabFamily = Replace< export type GenericTabFamily = Replace<
TabFamily<TabFamilyOptions>, TabFamily<TabFamilyOptions>,
{ {
visibility: ProcessedComputable<Visibility>; visibility: ProcessedComputable<Visibility | boolean>;
} }
>; >;
/**
* Lazily creates a tab family with the given options.
* @param optionsFunc Tab family options.
*/
export function createTabFamily<T extends TabFamilyOptions>( export function createTabFamily<T extends TabFamilyOptions>(
tabs: Record<string, () => TabButtonOptions>, tabs: Record<string, () => TabButtonOptions>,
optionsFunc?: OptionsFunc<T, BaseTabFamily, GenericTabFamily> optionsFunc?: OptionsFunc<T, BaseTabFamily, GenericTabFamily>
): TabFamily<T> { ): TabFamily<T> {
if (Object.keys(tabs).length === 0) { if (Object.keys(tabs).length === 0) {
console.warn("Cannot create tab family with 0 tabs"); console.error("Cannot create tab family with 0 tabs");
throw "Cannot create tab family with 0 tabs";
} }
const selected = persistent(Object.keys(tabs)[0]); const selected = persistent(Object.keys(tabs)[0], false);
return createLazyProxy(() => { return createLazyProxy(feature => {
const tabFamily = optionsFunc?.() ?? ({} as ReturnType<NonNullable<typeof optionsFunc>>); const tabFamily =
optionsFunc?.call(feature, feature) ??
({} as ReturnType<NonNullable<typeof optionsFunc>>);
tabFamily.id = getUniqueID("tabFamily-"); tabFamily.id = getUniqueID("tabFamily-");
tabFamily.type = TabFamilyType; tabFamily.type = TabFamilyType;
@ -106,7 +168,7 @@ export function createTabFamily<T extends TabFamilyOptions>(
(parsedTabs, tab) => { (parsedTabs, tab) => {
const tabButton: TabButtonOptions & Partial<BaseTabButton> = tabs[tab](); const tabButton: TabButtonOptions & Partial<BaseTabButton> = tabs[tab]();
tabButton.type = TabButtonType; tabButton.type = TabButtonType;
tabButton[Component] = TabButtonComponent; tabButton[Component] = TabButtonComponent as GenericComponent;
processComputable(tabButton as TabButtonOptions, "visibility"); processComputable(tabButton as TabButtonOptions, "visibility");
setDefault(tabButton, "visibility", Visibility.Visible); setDefault(tabButton, "visibility", Visibility.Visible);
@ -123,15 +185,10 @@ export function createTabFamily<T extends TabFamilyOptions>(
tabFamily.selected = selected; tabFamily.selected = selected;
tabFamily.activeTab = computed(() => { tabFamily.activeTab = computed(() => {
const tabs = unref(processedTabFamily.tabs); const tabs = unref(processedTabFamily.tabs);
if ( if (selected.value in tabs && isVisible(tabs[selected.value].visibility)) {
selected.value in tabs &&
unref(tabs[selected.value].visibility) === Visibility.Visible
) {
return unref(tabs[selected.value].tab); return unref(tabs[selected.value].tab);
} }
const firstTab = Object.values(tabs).find( const firstTab = Object.values(tabs).find(tab => isVisible(tab.visibility));
tab => unref(tab.visibility) === Visibility.Visible
);
if (firstTab) { if (firstTab) {
return unref(firstTab.tab); return unref(firstTab.tab);
} }

View file

@ -1,6 +1,6 @@
import type { CoercableComponent, Replace, StyleValue } from "features/feature"; import type { CoercableComponent, GenericComponent, Replace, StyleValue } from "features/feature";
import { Component, GatherProps, setDefault } from "features/feature"; import { Component, GatherProps, setDefault } from "features/feature";
import { persistent } from "game/persistence"; import { deletePersistent, Persistent, persistent } from "game/persistence";
import { Direction } from "util/common"; import { Direction } from "util/common";
import type { import type {
Computable, Computable,
@ -21,24 +21,38 @@ declare module "@vue/runtime-dom" {
} }
} }
/**
* An object that configures a {@link Tooltip}.
*/
export interface TooltipOptions { export interface TooltipOptions {
/** Whether or not this tooltip can be pinned, meaning it'll stay visible even when not hovered. */
pinnable?: boolean; pinnable?: boolean;
/** The text to display inside the tooltip. */
display: Computable<CoercableComponent>; display: Computable<CoercableComponent>;
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>; classes?: Computable<Record<string, boolean>>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>; style?: Computable<StyleValue>;
/** The direction in which to display the tooltip */
direction?: Computable<Direction>; direction?: Computable<Direction>;
/** The x offset of the tooltip, in px. */
xoffset?: Computable<string>; xoffset?: Computable<string>;
/** The y offset of the tooltip, in px. */
yoffset?: Computable<string>; yoffset?: Computable<string>;
} }
/**
* The properties that are added onto a processed {@link TooltipOptions} to create an {@link Tooltip}.
*/
export interface BaseTooltip { export interface BaseTooltip {
pinned?: Ref<boolean>; pinned?: Ref<boolean>;
} }
/** An object that represents a tooltip that appears when hovering over an element. */
export type Tooltip<T extends TooltipOptions> = Replace< export type Tooltip<T extends TooltipOptions> = Replace<
T & BaseTooltip, T & BaseTooltip,
{ {
pinnable: T["pinnable"] extends undefined ? false : T["pinnable"]; pinnable: undefined extends T["pinnable"] ? false : T["pinnable"];
pinned: T["pinnable"] extends true ? Ref<boolean> : undefined; pinned: T["pinnable"] extends true ? Ref<boolean> : undefined;
display: GetComputableType<T["display"]>; display: GetComputableType<T["display"]>;
classes: GetComputableType<T["classes"]>; classes: GetComputableType<T["classes"]>;
@ -49,6 +63,7 @@ export type Tooltip<T extends TooltipOptions> = Replace<
} }
>; >;
/** A type that matches any valid {@link Tooltip} object. */
export type GenericTooltip = Replace< export type GenericTooltip = Replace<
Tooltip<TooltipOptions>, Tooltip<TooltipOptions>,
{ {
@ -58,6 +73,11 @@ export type GenericTooltip = Replace<
} }
>; >;
/**
* Creates a tooltip on the given element with the given options.
* @param element The renderable feature to display the tooltip on.
* @param options Tooltip options.
*/
export function addTooltip<T extends TooltipOptions>( export function addTooltip<T extends TooltipOptions>(
element: VueFeature, element: VueFeature,
options: T & ThisType<Tooltip<T>> & Partial<BaseTooltip> options: T & ThisType<Tooltip<T>> & Partial<BaseTooltip>
@ -71,20 +91,12 @@ export function addTooltip<T extends TooltipOptions>(
processComputable(options as T, "yoffset"); processComputable(options as T, "yoffset");
if (options.pinnable) { if (options.pinnable) {
if ("pinned" in element) { options.pinned = persistent<boolean>(false, false);
console.error(
"Cannot add pinnable tooltip to element that already has a property called 'pinned'"
);
options.pinnable = false;
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(element as any).pinned = options.pinned = persistent<boolean>(false);
}
} }
nextTick(() => { nextTick(() => {
const elementComponent = element[Component]; const elementComponent = element[Component];
element[Component] = TooltipComponent; element[Component] = TooltipComponent as GenericComponent;
const elementGatherProps = element[GatherProps].bind(element); const elementGatherProps = element[GatherProps].bind(element);
element[GatherProps] = function gatherTooltipProps(this: GenericTooltip) { element[GatherProps] = function gatherTooltipProps(this: GenericTooltip) {
const { display, classes, style, direction, xoffset, yoffset, pinned } = this; const { display, classes, style, direction, xoffset, yoffset, pinned } = this;

View file

@ -1,7 +1,7 @@
<template> <template>
<div <div
v-if="unref(visibility) !== Visibility.None" v-if="isVisible(visibility)"
:style="{ visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined }" :style="{ visibility: isHidden(visibility) ? 'hidden' : undefined }"
:class="{ :class="{
treeNode: true, treeNode: true,
can: unref(canClick), can: unref(canClick),
@ -37,7 +37,7 @@
import MarkNode from "components/MarkNode.vue"; import MarkNode from "components/MarkNode.vue";
import Node from "components/Node.vue"; import Node from "components/Node.vue";
import type { CoercableComponent, StyleValue } from "features/feature"; import type { CoercableComponent, StyleValue } from "features/feature";
import { Visibility } from "features/feature"; import { isHidden, isVisible, Visibility } from "features/feature";
import { import {
computeOptionalComponent, computeOptionalComponent,
isCoercableComponent, isCoercableComponent,
@ -51,7 +51,7 @@ export default defineComponent({
props: { props: {
display: processedPropType<CoercableComponent>(Object, String, Function), display: processedPropType<CoercableComponent>(Object, String, Function),
visibility: { visibility: {
type: processedPropType<Visibility>(Number), type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true required: true
}, },
style: processedPropType<StyleValue>(String, Object, Array), style: processedPropType<StyleValue>(String, Object, Array),
@ -87,7 +87,9 @@ export default defineComponent({
comp, comp,
unref, unref,
Visibility, Visibility,
isCoercableComponent isCoercableComponent,
isVisible,
isHidden
}; };
} }
}); });
@ -111,7 +113,6 @@ export default defineComponent({
color: rgba(0, 0, 0, 0.5); color: rgba(0, 0, 0, 0.5);
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.25); text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.25);
box-shadow: -4px -4px 4px rgba(0, 0, 0, 0.25) inset, 0px 0px 20px var(--background); box-shadow: -4px -4px 4px rgba(0, 0, 0, 0.25) inset, 0px 0px 20px var(--background);
text-transform: capitalize;
display: flex; display: flex;
} }

View file

@ -1,4 +1,11 @@
import type { CoercableComponent, OptionsFunc, Replace, StyleValue } from "features/feature"; import { Decorator, GenericDecorator } from "features/decorators/common";
import type {
CoercableComponent,
GenericComponent,
OptionsFunc,
Replace,
StyleValue
} from "features/feature";
import { Component, GatherProps, getUniqueID, setDefault, Visibility } from "features/feature"; import { Component, GatherProps, getUniqueID, setDefault, Visibility } from "features/feature";
import type { Link } from "features/links/links"; import type { Link } from "features/links/links";
import type { GenericReset } from "features/reset"; import type { GenericReset } from "features/reset";
@ -19,30 +26,54 @@ import { createLazyProxy } from "util/proxies";
import type { Ref } from "vue"; import type { Ref } from "vue";
import { computed, ref, shallowRef, unref } from "vue"; import { computed, ref, shallowRef, unref } from "vue";
/** A symbol used to identify {@link TreeNode} features. */
export const TreeNodeType = Symbol("TreeNode"); export const TreeNodeType = Symbol("TreeNode");
/** A symbol used to identify {@link Tree} features. */
export const TreeType = Symbol("Tree"); export const TreeType = Symbol("Tree");
/**
* An object that configures a {@link TreeNode}.
*/
export interface TreeNodeOptions { export interface TreeNodeOptions {
visibility?: Computable<Visibility>; /** Whether this tree node should be visible. */
visibility?: Computable<Visibility | boolean>;
/** Whether or not this tree node can be clicked. */
canClick?: Computable<boolean>; canClick?: Computable<boolean>;
/** The background color for this node. */
color?: Computable<string>; color?: Computable<string>;
/** The label to display on this tree node. */
display?: Computable<CoercableComponent>; display?: Computable<CoercableComponent>;
/** The color of the glow effect shown to notify the user there's something to do with this node. */
glowColor?: Computable<string>; glowColor?: Computable<string>;
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>; classes?: Computable<Record<string, boolean>>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>; style?: Computable<StyleValue>;
/** Shows a marker on the corner of the feature. */
mark?: Computable<boolean | string>; mark?: Computable<boolean | string>;
/** A reset object attached to this node, used for propagating resets through the tree. */
reset?: GenericReset; reset?: GenericReset;
/** A function that is called when the tree node is clicked. */
onClick?: (e?: MouseEvent | TouchEvent) => void; onClick?: (e?: MouseEvent | TouchEvent) => void;
/** A function that is called when the tree node is held down. */
onHold?: VoidFunction; onHold?: VoidFunction;
} }
/**
* The properties that are added onto a processed {@link TreeNodeOptions} to create an {@link TreeNode}.
*/
export interface BaseTreeNode { export interface BaseTreeNode {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string; id: string;
/** A symbol that helps identify features of the same type. */
type: typeof TreeNodeType; type: typeof TreeNodeType;
[Component]: typeof TreeNodeComponent; /** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>; [GatherProps]: () => Record<string, unknown>;
} }
/** An object that represents a node on a tree. */
export type TreeNode<T extends TreeNodeOptions> = Replace< export type TreeNode<T extends TreeNodeOptions> = Replace<
T & BaseTreeNode, T & BaseTreeNode,
{ {
@ -57,22 +88,40 @@ export type TreeNode<T extends TreeNodeOptions> = Replace<
} }
>; >;
/** A type that matches any valid {@link TreeNode} object. */
export type GenericTreeNode = Replace< export type GenericTreeNode = Replace<
TreeNode<TreeNodeOptions>, TreeNode<TreeNodeOptions>,
{ {
visibility: ProcessedComputable<Visibility>; visibility: ProcessedComputable<Visibility | boolean>;
canClick: ProcessedComputable<boolean>; canClick: ProcessedComputable<boolean>;
} }
>; >;
/**
* Lazily creates a tree node with the given options.
* @param optionsFunc Tree Node options.
*/
export function createTreeNode<T extends TreeNodeOptions>( export function createTreeNode<T extends TreeNodeOptions>(
optionsFunc?: OptionsFunc<T, BaseTreeNode, GenericTreeNode> optionsFunc?: OptionsFunc<T, BaseTreeNode, GenericTreeNode>,
...decorators: GenericDecorator[]
): TreeNode<T> { ): TreeNode<T> {
return createLazyProxy(() => { const decoratedData = decorators.reduce(
const treeNode = optionsFunc?.() ?? ({} as ReturnType<NonNullable<typeof optionsFunc>>); (current, next) => Object.assign(current, next.getPersistentData?.()),
{}
);
return createLazyProxy(feature => {
const treeNode =
optionsFunc?.call(feature, feature) ??
({} as ReturnType<NonNullable<typeof optionsFunc>>);
treeNode.id = getUniqueID("treeNode-"); treeNode.id = getUniqueID("treeNode-");
treeNode.type = TreeNodeType; treeNode.type = TreeNodeType;
treeNode[Component] = TreeNodeComponent; treeNode[Component] = TreeNodeComponent as GenericComponent;
for (const decorator of decorators) {
decorator.preConstruct?.(treeNode);
}
Object.assign(decoratedData);
processComputable(treeNode as T, "visibility"); processComputable(treeNode as T, "visibility");
setDefault(treeNode, "visibility", Visibility.Visible); setDefault(treeNode, "visibility", Visibility.Visible);
@ -85,23 +134,31 @@ export function createTreeNode<T extends TreeNodeOptions>(
processComputable(treeNode as T, "style"); processComputable(treeNode as T, "style");
processComputable(treeNode as T, "mark"); processComputable(treeNode as T, "mark");
for (const decorator of decorators) {
decorator.postConstruct?.(treeNode);
}
if (treeNode.onClick) { if (treeNode.onClick) {
const onClick = treeNode.onClick.bind(treeNode); const onClick = treeNode.onClick.bind(treeNode);
treeNode.onClick = function () { treeNode.onClick = function (e) {
if (unref(treeNode.canClick)) { if (unref(treeNode.canClick) !== false) {
onClick(); onClick(e);
} }
}; };
} }
if (treeNode.onHold) { if (treeNode.onHold) {
const onHold = treeNode.onHold.bind(treeNode); const onHold = treeNode.onHold.bind(treeNode);
treeNode.onHold = function () { treeNode.onHold = function () {
if (unref(treeNode.canClick)) { if (unref(treeNode.canClick) !== false) {
onHold(); onHold();
} }
}; };
} }
const decoratedProps = decorators.reduce(
(current, next) => Object.assign(current, next.getGatheredProps?.(treeNode)),
{}
);
treeNode[GatherProps] = function (this: GenericTreeNode) { treeNode[GatherProps] = function (this: GenericTreeNode) {
const { const {
display, display,
@ -127,7 +184,8 @@ export function createTreeNode<T extends TreeNodeOptions>(
glowColor, glowColor,
canClick, canClick,
mark, mark,
id id,
...decoratedProps
}; };
}; };
@ -135,32 +193,52 @@ export function createTreeNode<T extends TreeNodeOptions>(
}); });
} }
/** Represents a branch between two nodes in a tree. */
export interface TreeBranch extends Omit<Link, "startNode" | "endNode"> { export interface TreeBranch extends Omit<Link, "startNode" | "endNode"> {
startNode: GenericTreeNode; startNode: GenericTreeNode;
endNode: GenericTreeNode; endNode: GenericTreeNode;
} }
/**
* An object that configures a {@link Tree}.
*/
export interface TreeOptions { export interface TreeOptions {
visibility?: Computable<Visibility>; /** Whether this clickable should be visible. */
visibility?: Computable<Visibility | boolean>;
/** The nodes within the tree, in a 2D array. */
nodes: Computable<GenericTreeNode[][]>; nodes: Computable<GenericTreeNode[][]>;
/** Nodes to show on the left side of the tree. */
leftSideNodes?: Computable<GenericTreeNode[]>; leftSideNodes?: Computable<GenericTreeNode[]>;
/** Nodes to show on the right side of the tree. */
rightSideNodes?: Computable<GenericTreeNode[]>; rightSideNodes?: Computable<GenericTreeNode[]>;
/** The branches between nodes within this tree. */
branches?: Computable<TreeBranch[]>; branches?: Computable<TreeBranch[]>;
/** How to propagate resets through the tree. */
resetPropagation?: ResetPropagation; resetPropagation?: ResetPropagation;
/** A function that is called when a node within the tree is reset. */
onReset?: (node: GenericTreeNode) => void; onReset?: (node: GenericTreeNode) => void;
} }
export interface BaseTree { export interface BaseTree {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string; id: string;
/** The link objects for each of the branches of the tree. */
links: Ref<Link[]>; links: Ref<Link[]>;
/** Cause a reset on this node and propagate it through the tree according to {@link resetPropagation}. */
reset: (node: GenericTreeNode) => void; reset: (node: GenericTreeNode) => void;
/** A flag that is true while the reset is still propagating through the tree. */
isResetting: Ref<boolean>; isResetting: Ref<boolean>;
/** A reference to the node that caused the currently propagating reset. */
resettingNode: Ref<GenericTreeNode | null>; resettingNode: Ref<GenericTreeNode | null>;
/** A symbol that helps identify features of the same type. */
type: typeof TreeType; type: typeof TreeType;
[Component]: typeof TreeComponent; /** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>; [GatherProps]: () => Record<string, unknown>;
} }
/** An object that represents a feature that is a tree of nodes with branches between them. Contains support for reset mechanics that can propagate through the tree. */
export type Tree<T extends TreeOptions> = Replace< export type Tree<T extends TreeOptions> = Replace<
T & BaseTree, T & BaseTree,
{ {
@ -172,21 +250,26 @@ export type Tree<T extends TreeOptions> = Replace<
} }
>; >;
/** A type that matches any valid {@link Tree} object. */
export type GenericTree = Replace< export type GenericTree = Replace<
Tree<TreeOptions>, Tree<TreeOptions>,
{ {
visibility: ProcessedComputable<Visibility>; visibility: ProcessedComputable<Visibility | boolean>;
} }
>; >;
/**
* Lazily creates a tree with the given options.
* @param optionsFunc Tree options.
*/
export function createTree<T extends TreeOptions>( export function createTree<T extends TreeOptions>(
optionsFunc: OptionsFunc<T, BaseTree, GenericTree> optionsFunc: OptionsFunc<T, BaseTree, GenericTree>
): Tree<T> { ): Tree<T> {
return createLazyProxy(() => { return createLazyProxy(feature => {
const tree = optionsFunc(); const tree = optionsFunc.call(feature, feature);
tree.id = getUniqueID("tree-"); tree.id = getUniqueID("tree-");
tree.type = TreeType; tree.type = TreeType;
tree[Component] = TreeComponent; tree[Component] = TreeComponent as GenericComponent;
tree.isResetting = ref(false); tree.isResetting = ref(false);
tree.resettingNode = shallowRef(null); tree.resettingNode = shallowRef(null);
@ -221,10 +304,12 @@ export function createTree<T extends TreeOptions>(
}); });
} }
/** A function that is used to propagate resets through a tree. */
export type ResetPropagation = { export type ResetPropagation = {
(tree: GenericTree, resettingNode: GenericTreeNode): void; (tree: GenericTree, resettingNode: GenericTreeNode): void;
}; };
/** Propagate resets down the tree by resetting every node in a lower row. */
export const defaultResetPropagation = function ( export const defaultResetPropagation = function (
tree: GenericTree, tree: GenericTree,
resettingNode: GenericTreeNode resettingNode: GenericTreeNode
@ -236,6 +321,7 @@ export const defaultResetPropagation = function (
} }
}; };
/** Propagate resets down the tree by resetting every node in a lower row. */
export const invertedResetPropagation = function ( export const invertedResetPropagation = function (
tree: GenericTree, tree: GenericTree,
resettingNode: GenericTreeNode resettingNode: GenericTreeNode
@ -247,6 +333,7 @@ export const invertedResetPropagation = function (
} }
}; };
/** Propagate resets down the branches of the tree. */
export const branchedResetPropagation = function ( export const branchedResetPropagation = function (
tree: GenericTree, tree: GenericTree,
resettingNode: GenericTreeNode resettingNode: GenericTreeNode
@ -282,6 +369,10 @@ export const branchedResetPropagation = function (
} }
}; };
/**
* Utility for creating a tooltip for a tree node that displays a resource-based unlock requirement, and after unlock shows the amount of another resource.
* It sounds oddly specific, but comes up a lot.
*/
export function createResourceTooltip( export function createResourceTooltip(
resource: Resource, resource: Resource,
requiredResource: Resource | null = null, requiredResource: Resource | null = null,

View file

@ -1,9 +1,9 @@
<template> <template>
<button <button
v-if="unref(visibility) !== Visibility.None" v-if="isVisible(visibility)"
:style="[ :style="[
{ {
visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined visibility: isHidden(visibility) ? 'hidden' : undefined
}, },
unref(style) ?? {} unref(style) ?? {}
]" ]"
@ -29,11 +29,9 @@ import "components/common/features.css";
import MarkNode from "components/MarkNode.vue"; import MarkNode from "components/MarkNode.vue";
import Node from "components/Node.vue"; import Node from "components/Node.vue";
import type { StyleValue } from "features/feature"; import type { StyleValue } from "features/feature";
import { jsx, Visibility } from "features/feature"; import { isHidden, isVisible, jsx, Visibility } from "features/feature";
import type { Resource } from "features/resources/resource";
import { displayResource } from "features/resources/resource";
import type { GenericUpgrade } from "features/upgrades/upgrade"; import type { GenericUpgrade } from "features/upgrades/upgrade";
import type { DecimalSource } from "util/bignum"; import { displayRequirements, Requirements } from "game/requirements";
import { coerceComponent, isCoercableComponent, processedPropType, unwrapRef } from "util/vue"; import { coerceComponent, isCoercableComponent, processedPropType, unwrapRef } from "util/vue";
import type { Component, PropType, UnwrapRef } from "vue"; import type { Component, PropType, UnwrapRef } from "vue";
import { defineComponent, shallowRef, toRefs, unref, watchEffect } from "vue"; import { defineComponent, shallowRef, toRefs, unref, watchEffect } from "vue";
@ -45,13 +43,15 @@ export default defineComponent({
required: true required: true
}, },
visibility: { visibility: {
type: processedPropType<Visibility>(Number), type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true required: true
}, },
style: processedPropType<StyleValue>(String, Object, Array), style: processedPropType<StyleValue>(String, Object, Array),
classes: processedPropType<Record<string, boolean>>(Object), classes: processedPropType<Record<string, boolean>>(Object),
resource: Object as PropType<Resource>, requirements: {
cost: processedPropType<DecimalSource>(String, Object, Number), type: Object as PropType<Requirements>,
required: true
},
canPurchase: { canPurchase: {
type: processedPropType<boolean>(Boolean), type: processedPropType<boolean>(Boolean),
required: true required: true
@ -75,7 +75,7 @@ export default defineComponent({
MarkNode MarkNode
}, },
setup(props) { setup(props) {
const { display, cost } = toRefs(props); const { display, requirements, bought } = toRefs(props);
const component = shallowRef<Component | string>(""); const component = shallowRef<Component | string>("");
@ -89,32 +89,24 @@ export default defineComponent({
component.value = coerceComponent(currDisplay); component.value = coerceComponent(currDisplay);
return; return;
} }
const currCost = unwrapRef(cost);
const Title = coerceComponent(currDisplay.title || "", "h3"); const Title = coerceComponent(currDisplay.title || "", "h3");
const Description = coerceComponent(currDisplay.description, "div"); const Description = coerceComponent(currDisplay.description, "div");
const EffectDisplay = coerceComponent(currDisplay.effectDisplay || ""); const EffectDisplay = coerceComponent(currDisplay.effectDisplay || "");
component.value = coerceComponent( component.value = coerceComponent(
jsx(() => ( jsx(() => (
<span> <span>
{currDisplay.title ? ( {currDisplay.title != null ? (
<div> <div>
<Title /> <Title />
</div> </div>
) : null} ) : null}
<Description /> <Description />
{currDisplay.effectDisplay ? ( {currDisplay.effectDisplay != null ? (
<div> <div>
Currently: <EffectDisplay /> Currently: <EffectDisplay />
</div> </div>
) : null} ) : null}
{props.resource != null ? ( {bought.value ? null : <><br />{displayRequirements(requirements.value)}</>}
<>
<br />
Cost: {props.resource &&
displayResource(props.resource, currCost)}{" "}
{props.resource?.displayName}
</>
) : null}
</span> </span>
)) ))
); );
@ -123,7 +115,9 @@ export default defineComponent({
return { return {
component, component,
unref, unref,
Visibility Visibility,
isVisible,
isHidden
}; };
} }
}); });

View file

@ -1,19 +1,30 @@
import type { CoercableComponent, OptionsFunc, Replace, StyleValue } from "features/feature"; import { isArray } from "@vue/shared";
import { GenericDecorator } from "features/decorators/common";
import type {
CoercableComponent,
GenericComponent,
OptionsFunc,
Replace,
StyleValue
} from "features/feature";
import { import {
Component, Component,
findFeatures,
GatherProps, GatherProps,
Visibility,
findFeatures,
getUniqueID, getUniqueID,
setDefault, setDefault
Visibility
} from "features/feature"; } from "features/feature";
import type { Resource } from "features/resources/resource";
import UpgradeComponent from "features/upgrades/Upgrade.vue"; import UpgradeComponent from "features/upgrades/Upgrade.vue";
import type { GenericLayer } from "game/layers"; import type { GenericLayer } from "game/layers";
import type { Persistent } from "game/persistence"; import type { Persistent } from "game/persistence";
import { persistent } from "game/persistence"; import { persistent } from "game/persistence";
import type { DecimalSource } from "util/bignum"; import {
import Decimal from "util/bignum"; Requirements,
createVisibilityRequirement,
payRequirements,
requirementsMet
} from "game/requirements";
import { isFunction } from "util/common"; import { isFunction } from "util/common";
import type { import type {
Computable, Computable,
@ -26,37 +37,60 @@ import { createLazyProxy } from "util/proxies";
import type { Ref } from "vue"; import type { Ref } from "vue";
import { computed, unref } from "vue"; import { computed, unref } from "vue";
/** A symbol used to identify {@link Upgrade} features. */
export const UpgradeType = Symbol("Upgrade"); export const UpgradeType = Symbol("Upgrade");
/**
* An object that configures a {@link Upgrade}.
*/
export interface UpgradeOptions { export interface UpgradeOptions {
visibility?: Computable<Visibility>; /** Whether this clickable should be visible. */
visibility?: Computable<Visibility | boolean>;
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>; classes?: Computable<Record<string, boolean>>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>; style?: Computable<StyleValue>;
/** Shows a marker on the corner of the feature. */
mark?: Computable<boolean | string>;
/** The display to use for this clickable. */
display?: Computable< display?: Computable<
| CoercableComponent | CoercableComponent
| { | {
/** A header to appear at the top of the display. */
title?: CoercableComponent; title?: CoercableComponent;
/** The main text that appears in the display. */
description: CoercableComponent; description: CoercableComponent;
/** A description of the current effect of the achievement. Useful when the effect changes dynamically. */
effectDisplay?: CoercableComponent; effectDisplay?: CoercableComponent;
} }
>; >;
mark?: Computable<boolean | string>; /** The requirements to purchase this upgrade. */
cost?: Computable<DecimalSource>; requirements: Requirements;
resource?: Resource; /** A function that is called when the upgrade is purchased. */
canAfford?: Computable<boolean>;
onPurchase?: VoidFunction; onPurchase?: VoidFunction;
} }
/**
* The properties that are added onto a processed {@link UpgradeOptions} to create an {@link Upgrade}.
*/
export interface BaseUpgrade { export interface BaseUpgrade {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string; id: string;
/** Whether or not this upgrade has been purchased. */
bought: Persistent<boolean>; bought: Persistent<boolean>;
/** Whether or not the upgrade can currently be purchased. */
canPurchase: Ref<boolean>; canPurchase: Ref<boolean>;
/** Purchase the upgrade */
purchase: VoidFunction; purchase: VoidFunction;
/** A symbol that helps identify features of the same type. */
type: typeof UpgradeType; type: typeof UpgradeType;
[Component]: typeof UpgradeComponent; /** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>; [GatherProps]: () => Record<string, unknown>;
} }
/** An object that represents a feature that can be purchased a single time. */
export type Upgrade<T extends UpgradeOptions> = Replace< export type Upgrade<T extends UpgradeOptions> = Replace<
T & BaseUpgrade, T & BaseUpgrade,
{ {
@ -64,88 +98,87 @@ export type Upgrade<T extends UpgradeOptions> = Replace<
classes: GetComputableType<T["classes"]>; classes: GetComputableType<T["classes"]>;
style: GetComputableType<T["style"]>; style: GetComputableType<T["style"]>;
display: GetComputableType<T["display"]>; display: GetComputableType<T["display"]>;
requirements: GetComputableType<T["requirements"]>;
mark: GetComputableType<T["mark"]>; mark: GetComputableType<T["mark"]>;
cost: GetComputableType<T["cost"]>;
canAfford: GetComputableTypeWithDefault<T["canAfford"], Ref<boolean>>;
} }
>; >;
/** A type that matches any valid {@link Upgrade} object. */
export type GenericUpgrade = Replace< export type GenericUpgrade = Replace<
Upgrade<UpgradeOptions>, Upgrade<UpgradeOptions>,
{ {
visibility: ProcessedComputable<Visibility>; visibility: ProcessedComputable<Visibility | boolean>;
canPurchase: ProcessedComputable<boolean>;
} }
>; >;
/**
* Lazily creates an upgrade with the given options.
* @param optionsFunc Upgrade options.
*/
export function createUpgrade<T extends UpgradeOptions>( export function createUpgrade<T extends UpgradeOptions>(
optionsFunc: OptionsFunc<T, BaseUpgrade, GenericUpgrade> optionsFunc: OptionsFunc<T, BaseUpgrade, GenericUpgrade>,
...decorators: GenericDecorator[]
): Upgrade<T> { ): Upgrade<T> {
const bought = persistent<boolean>(false); const bought = persistent<boolean>(false, false);
return createLazyProxy(() => { const decoratedData = decorators.reduce(
const upgrade = optionsFunc(); (current, next) => Object.assign(current, next.getPersistentData?.()),
{}
);
return createLazyProxy(feature => {
const upgrade = optionsFunc.call(feature, feature);
upgrade.id = getUniqueID("upgrade-"); upgrade.id = getUniqueID("upgrade-");
upgrade.type = UpgradeType; upgrade.type = UpgradeType;
upgrade[Component] = UpgradeComponent; upgrade[Component] = UpgradeComponent as GenericComponent;
if (upgrade.canAfford == null && (upgrade.resource == null || upgrade.cost == null)) { for (const decorator of decorators) {
console.warn( decorator.preConstruct?.(upgrade);
"Error: can't create upgrade without a canAfford property or a resource and cost property",
upgrade
);
} }
upgrade.bought = bought; upgrade.bought = bought;
if (upgrade.canAfford == null) { Object.assign(upgrade, decoratedData);
upgrade.canAfford = computed(() => {
const genericUpgrade = upgrade as GenericUpgrade;
return (
genericUpgrade.resource != null &&
genericUpgrade.cost != null &&
Decimal.gte(genericUpgrade.resource.value, unref(genericUpgrade.cost))
);
});
} else {
processComputable(upgrade as T, "canAfford");
}
upgrade.canPurchase = computed( upgrade.canPurchase = computed(
() => () => !bought.value && requirementsMet(upgrade.requirements)
unref((upgrade as GenericUpgrade).visibility) === Visibility.Visible &&
unref((upgrade as GenericUpgrade).canAfford) &&
!unref(upgrade.bought)
); );
upgrade.purchase = function () { upgrade.purchase = function () {
const genericUpgrade = upgrade as GenericUpgrade; const genericUpgrade = upgrade as GenericUpgrade;
if (!unref(genericUpgrade.canPurchase)) { if (!unref(genericUpgrade.canPurchase)) {
return; return;
} }
if (genericUpgrade.resource != null && genericUpgrade.cost != null) { payRequirements(upgrade.requirements);
genericUpgrade.resource.value = Decimal.sub(
genericUpgrade.resource.value,
unref(genericUpgrade.cost)
);
}
bought.value = true; bought.value = true;
genericUpgrade.onPurchase?.(); genericUpgrade.onPurchase?.();
}; };
const visibilityRequirement = createVisibilityRequirement(upgrade as GenericUpgrade);
if (isArray(upgrade.requirements)) {
upgrade.requirements.unshift(visibilityRequirement);
} else {
upgrade.requirements = [visibilityRequirement, upgrade.requirements];
}
processComputable(upgrade as T, "visibility"); processComputable(upgrade as T, "visibility");
setDefault(upgrade, "visibility", Visibility.Visible); setDefault(upgrade, "visibility", Visibility.Visible);
processComputable(upgrade as T, "classes"); processComputable(upgrade as T, "classes");
processComputable(upgrade as T, "style"); processComputable(upgrade as T, "style");
processComputable(upgrade as T, "display"); processComputable(upgrade as T, "display");
processComputable(upgrade as T, "mark"); processComputable(upgrade as T, "mark");
processComputable(upgrade as T, "cost");
processComputable(upgrade as T, "resource");
for (const decorator of decorators) {
decorator.postConstruct?.(upgrade);
}
const decoratedProps = decorators.reduce(
(current, next) => Object.assign(current, next.getGatheredProps?.(upgrade)),
{}
);
upgrade[GatherProps] = function (this: GenericUpgrade) { upgrade[GatherProps] = function (this: GenericUpgrade) {
const { const {
display, display,
visibility, visibility,
style, style,
classes, classes,
resource, requirements,
cost,
canPurchase, canPurchase,
bought, bought,
mark, mark,
@ -157,13 +190,13 @@ export function createUpgrade<T extends UpgradeOptions>(
visibility, visibility,
style: unref(style), style: unref(style),
classes, classes,
resource, requirements,
cost,
canPurchase, canPurchase,
bought, bought,
mark, mark,
id, id,
purchase purchase,
...decoratedProps
}; };
}; };
@ -171,13 +204,22 @@ export function createUpgrade<T extends UpgradeOptions>(
}); });
} }
/**
* Utility to auto purchase a list of upgrades whenever they're affordable.
* @param layer The layer the upgrades are apart of
* @param autoActive Whether or not the upgrades should currently be auto-purchasing
* @param upgrades The specific upgrades to upgrade. If unspecified, uses all upgrades on the layer.
*/
export function setupAutoPurchase( export function setupAutoPurchase(
layer: GenericLayer, layer: GenericLayer,
autoActive: Computable<boolean>, autoActive: Computable<boolean>,
upgrades: GenericUpgrade[] = [] upgrades: GenericUpgrade[] = []
): void { ): void {
upgrades = upgrades || findFeatures(layer, UpgradeType); upgrades =
const isAutoActive = isFunction(autoActive) ? computed(autoActive) : autoActive; upgrades.length === 0 ? (findFeatures(layer, UpgradeType) as GenericUpgrade[]) : upgrades;
const isAutoActive: ProcessedComputable<boolean> = isFunction(autoActive)
? computed(autoActive)
: autoActive;
layer.on("update", () => { layer.on("update", () => {
if (unref(isAutoActive)) { if (unref(isAutoActive)) {
upgrades.forEach(upgrade => upgrade.purchase()); upgrades.forEach(upgrade => upgrade.purchase());

View file

@ -1,12 +1,6 @@
import projInfo from "data/projInfo.json";
import player from "game/player";
import type { Settings } from "game/settings"; import type { Settings } from "game/settings";
import settings from "game/settings";
import state from "game/state";
import { createNanoEvents } from "nanoevents"; import { createNanoEvents } from "nanoevents";
import Decimal from "util/bignum"; import type { App } from "vue";
import type { App, Ref } from "vue";
import { watch } from "vue";
import type { GenericLayer } from "./layers"; import type { GenericLayer } from "./layers";
/** All types of events able to be sent or emitted from the global event bus. */ /** All types of events able to be sent or emitted from the global event bus. */
@ -60,102 +54,8 @@ export interface GlobalEvents {
/** A global event bus for hooking into {@link GlobalEvents}. */ /** A global event bus for hooking into {@link GlobalEvents}. */
export const globalBus = createNanoEvents<GlobalEvents>(); export const globalBus = createNanoEvents<GlobalEvents>();
let intervalID: NodeJS.Timer | null = null; if ("fonts" in document) {
// This line breaks tests
// Not imported immediately due to dependency cycles // JSDom doesn't add document.fonts, and Object.defineProperty doesn't seem to work on document
// This gets set during startGameLoop(), and will only be used in the update function document.fonts.onloadingdone = () => globalBus.emit("fontsLoaded");
let hasWon: null | Ref<boolean> = null;
function update() {
const now = Date.now();
let diff = (now - player.time) / 1e3;
player.time = now;
const trueDiff = diff;
state.lastTenTicks.push(trueDiff);
if (state.lastTenTicks.length > 10) {
state.lastTenTicks = state.lastTenTicks.slice(1);
}
// Stop here if the game is paused on the win screen
if (hasWon?.value && !player.keepGoing) {
return;
}
// Stop here if the player had a NaN value
if (state.hasNaN) {
return;
}
diff = Math.max(diff, 0);
if (player.devSpeed === 0) {
return;
}
// Add offline time if any
if (player.offlineTime != undefined) {
if (Decimal.gt(player.offlineTime, projInfo.offlineLimit * 3600)) {
player.offlineTime = projInfo.offlineLimit * 3600;
}
if (Decimal.gt(player.offlineTime, 0) && player.devSpeed !== 0) {
const offlineDiff = Math.max(player.offlineTime / 10, diff);
player.offlineTime = player.offlineTime - offlineDiff;
diff += offlineDiff;
} else if (player.devSpeed === 0) {
player.offlineTime += diff;
}
if (!player.offlineProd || Decimal.lt(player.offlineTime, 0)) {
player.offlineTime = null;
}
}
// Cap at max tick length
diff = Math.min(diff, projInfo.maxTickLength);
// Apply dev speed
if (player.devSpeed != undefined) {
diff *= player.devSpeed;
}
if (!Number.isFinite(diff)) {
diff = 1e308;
}
// Update
if (Decimal.eq(diff, 0)) {
return;
}
player.timePlayed += diff;
if (!Number.isFinite(player.timePlayed)) {
player.timePlayed = 1e308;
}
globalBus.emit("update", diff, trueDiff);
if (settings.unthrottled) {
requestAnimationFrame(update);
if (intervalID != null) {
clearInterval(intervalID);
intervalID = null;
}
} else if (intervalID == null) {
intervalID = setInterval(update, 50);
}
} }
/** Starts the game loop for the project, which updates the game in ticks. */
export async function startGameLoop() {
hasWon = (await import("data/projEntry")).hasWon;
watch(hasWon, hasWon => {
if (hasWon) {
globalBus.emit("gameWon");
}
});
if (settings.unthrottled) {
requestAnimationFrame(update);
} else {
intervalID = setInterval(update, 50);
}
}
document.fonts.onloadingdone = () => globalBus.emit("fontsLoaded");

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,936 @@
import Decimal, { DecimalSource } from "util/bignum";
import Formula, { hasVariable, unrefFormulaSource } from "./formulas";
import {
FormulaSource,
GenericFormula,
InvertFunction,
InvertibleFormula,
SubstitutionStack
} from "./types";
const ln10 = Decimal.ln(10);
export function passthrough<T extends GenericFormula | DecimalSource>(value: T): T {
return value;
}
export function invertPassthrough(value: DecimalSource, ...inputs: FormulaSource[]) {
const variable = inputs.find(input => hasVariable(input)) as InvertibleFormula | undefined;
if (variable == null) {
console.error("Could not invert due to no input being a variable");
return 0;
}
return variable.invert(value);
}
export function invertNeg(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.neg(value));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function integrateNeg(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
return Formula.neg(lhs.getIntegralFormula(stack));
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function applySubstitutionNeg(value: GenericFormula) {
return Formula.neg(value);
}
export function invertAdd(value: DecimalSource, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.sub(value, unrefFormulaSource(rhs)));
} else if (hasVariable(rhs)) {
return rhs.invert(Decimal.sub(value, unrefFormulaSource(lhs)));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function integrateAdd(stack: SubstitutionStack, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = lhs.getIntegralFormula(stack);
return Formula.times(rhs, lhs.innermostVariable ?? 0).add(x);
} else if (hasVariable(rhs)) {
if (!rhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = rhs.getIntegralFormula(stack);
return Formula.times(lhs, rhs.innermostVariable ?? 0).add(x);
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function integrateInnerAdd(
stack: SubstitutionStack,
lhs: FormulaSource,
rhs: FormulaSource
) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = lhs.getIntegralFormula(stack);
return Formula.add(x, rhs);
} else if (hasVariable(rhs)) {
if (!rhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = rhs.getIntegralFormula(stack);
return Formula.add(x, lhs);
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function invertSub(value: DecimalSource, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.add(value, unrefFormulaSource(rhs)));
} else if (hasVariable(rhs)) {
return rhs.invert(Decimal.sub(unrefFormulaSource(lhs), value));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function integrateSub(stack: SubstitutionStack, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = lhs.getIntegralFormula(stack);
return Formula.sub(x, Formula.times(rhs, lhs.innermostVariable ?? 0));
} else if (hasVariable(rhs)) {
if (!rhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = rhs.getIntegralFormula(stack);
return Formula.times(lhs, rhs.innermostVariable ?? 0).sub(x);
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function integrateInnerSub(
stack: SubstitutionStack,
lhs: FormulaSource,
rhs: FormulaSource
) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = lhs.getIntegralFormula(stack);
return Formula.sub(x, rhs);
} else if (hasVariable(rhs)) {
if (!rhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = rhs.getIntegralFormula(stack);
return Formula.sub(x, lhs);
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function invertMul(value: DecimalSource, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.div(value, unrefFormulaSource(rhs)));
} else if (hasVariable(rhs)) {
return rhs.invert(Decimal.div(value, unrefFormulaSource(lhs)));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function integrateMul(stack: SubstitutionStack, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = lhs.getIntegralFormula(stack);
return Formula.times(x, rhs);
} else if (hasVariable(rhs)) {
if (!rhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = rhs.getIntegralFormula(stack);
return Formula.times(x, lhs);
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function applySubstitutionMul(
value: GenericFormula,
lhs: FormulaSource,
rhs: FormulaSource
) {
if (hasVariable(lhs)) {
return Formula.div(value, rhs);
} else if (hasVariable(rhs)) {
return Formula.div(value, lhs);
}
console.error("Could not apply substitution due to no input being a variable");
return Formula.constant(0);
}
export function invertDiv(value: DecimalSource, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.mul(value, unrefFormulaSource(rhs)));
} else if (hasVariable(rhs)) {
return rhs.invert(Decimal.div(unrefFormulaSource(lhs), value));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function integrateDiv(stack: SubstitutionStack, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = lhs.getIntegralFormula(stack);
return Formula.div(x, rhs);
} else if (hasVariable(rhs)) {
if (!rhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = rhs.getIntegralFormula(stack);
return Formula.div(lhs, x);
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function applySubstitutionDiv(
value: GenericFormula,
lhs: FormulaSource,
rhs: FormulaSource
) {
if (hasVariable(lhs)) {
return Formula.mul(value, rhs);
} else if (hasVariable(rhs)) {
return Formula.mul(value, lhs);
}
console.error("Could not apply substitution due to no input being a variable");
return Formula.constant(0);
}
export function invertRecip(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.recip(value));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function integrateRecip(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = lhs.getIntegralFormula(stack);
return Formula.ln(x);
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function invertLog10(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.pow10(value));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
function internalIntegrateLog10(lhs: DecimalSource) {
return Decimal.ln(lhs).sub(1).times(lhs).div(ln10);
}
function internalInvertIntegralLog10(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
const numerator = ln10.times(value);
return lhs.invert(numerator.div(numerator.div(Math.E).lambertw()));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function integrateLog10(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
return new Formula({
inputs: [lhs.getIntegralFormula(stack)],
evaluate: internalIntegrateLog10,
invert: internalInvertIntegralLog10
});
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function invertLog(value: DecimalSource, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.pow(unrefFormulaSource(rhs), value));
} else if (hasVariable(rhs)) {
return rhs.invert(Decimal.root(unrefFormulaSource(lhs), value));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
function internalIntegrateLog(lhs: DecimalSource, rhs: DecimalSource) {
return Decimal.ln(lhs).sub(1).times(lhs).div(Decimal.ln(rhs));
}
function internalInvertIntegralLog(value: DecimalSource, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
const numerator = Decimal.ln(unrefFormulaSource(rhs)).times(value);
return lhs.invert(numerator.div(numerator.div(Math.E).lambertw()));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function integrateLog(stack: SubstitutionStack, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
return new Formula({
inputs: [lhs.getIntegralFormula(stack), rhs],
evaluate: internalIntegrateLog,
invert: internalInvertIntegralLog
});
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function invertLog2(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.pow(2, value));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
function internalIntegrateLog2(lhs: DecimalSource) {
return Decimal.ln(lhs).sub(1).times(lhs).div(Decimal.ln(2));
}
function internalInvertIntegralLog2(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
const numerator = Decimal.ln(2).times(value);
return lhs.invert(numerator.div(numerator.div(Math.E).lambertw()));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function integrateLog2(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
return new Formula({
inputs: [lhs.getIntegralFormula(stack)],
evaluate: internalIntegrateLog2,
invert: internalInvertIntegralLog2
});
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function invertLn(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.exp(value));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
function internalIntegrateLn(lhs: DecimalSource) {
return Decimal.ln(lhs).sub(1).times(lhs);
}
function internalInvertIntegralLn(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.div(value, Decimal.div(value, Math.E).lambertw()));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function integrateLn(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
return new Formula({
inputs: [lhs.getIntegralFormula(stack)],
evaluate: internalIntegrateLn,
invert: internalInvertIntegralLn
});
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function invertPow(value: DecimalSource, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.root(value, unrefFormulaSource(rhs)));
} else if (hasVariable(rhs)) {
return rhs.invert(Decimal.ln(value).div(Decimal.ln(unrefFormulaSource(lhs))));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function integratePow(stack: SubstitutionStack, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = lhs.getIntegralFormula(stack);
const pow = Formula.add(rhs, 1);
return Formula.pow(x, pow).div(pow);
} else if (hasVariable(rhs)) {
if (!rhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = rhs.getIntegralFormula(stack);
return Formula.pow(lhs, x).div(Formula.ln(lhs));
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function invertPow10(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.root(value, 10));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function integratePow10(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = lhs.getIntegralFormula(stack);
return Formula.pow10(x).div(Formula.ln(10));
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function invertPowBase(value: DecimalSource, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.ln(value).div(Decimal.ln(unrefFormulaSource(rhs))));
} else if (hasVariable(rhs)) {
return rhs.invert(Decimal.root(unrefFormulaSource(lhs), value));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function integratePowBase(stack: SubstitutionStack, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = lhs.getIntegralFormula(stack);
return Formula.pow(rhs, x).div(Formula.ln(rhs));
} else if (hasVariable(rhs)) {
if (!rhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = rhs.getIntegralFormula(stack);
const denominator = Formula.add(lhs, 1);
return Formula.pow(x, denominator).div(denominator);
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function invertRoot(value: DecimalSource, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.root(value, Decimal.recip(unrefFormulaSource(rhs))));
} else if (hasVariable(rhs)) {
return rhs.invert(Decimal.ln(unrefFormulaSource(lhs)).div(Decimal.ln(value)));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function integrateRoot(stack: SubstitutionStack, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = lhs.getIntegralFormula(stack);
return Formula.pow(x, Formula.recip(rhs).add(1)).times(rhs).div(Formula.add(rhs, 1));
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function invertExp(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.ln(value));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function integrateExp(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = lhs.getIntegralFormula(stack);
return Formula.exp(x);
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function tetrate(
value: DecimalSource,
height: DecimalSource = 2,
payload: DecimalSource = Decimal.fromComponents_noNormalize(1, 0, 1)
) {
const heightNumber = Decimal.minabs(height, 1e308).toNumber();
return Decimal.tetrate(value, heightNumber, payload);
}
export function invertTetrate(
value: DecimalSource,
base: FormulaSource,
height: FormulaSource,
payload: FormulaSource
) {
if (hasVariable(base)) {
return base.invert(Decimal.ssqrt(value));
}
// Other params can't be inverted ATM
console.error("Could not invert due to no input being a variable");
return 0;
}
export function iteratedexp(
value: DecimalSource,
height: DecimalSource = 2,
payload: DecimalSource = Decimal.fromComponents_noNormalize(1, 0, 1)
) {
const heightNumber = Decimal.minabs(height, 1e308).toNumber();
return Decimal.iteratedexp(value, heightNumber, new Decimal(payload));
}
export function invertIteratedExp(
value: DecimalSource,
lhs: FormulaSource,
height: FormulaSource,
payload: FormulaSource
) {
if (hasVariable(lhs)) {
return lhs.invert(
Decimal.iteratedlog(
value,
Math.E,
Decimal.minabs(1e308, unrefFormulaSource(height)).toNumber()
)
);
}
// Other params can't be inverted ATM
console.error("Could not invert due to no input being a variable");
return 0;
}
export function iteratedLog(
value: DecimalSource,
lhs: DecimalSource = 10,
times: DecimalSource = 2
) {
const timesNumber = Decimal.minabs(times, 1e308).toNumber();
return Decimal.iteratedlog(value, lhs, timesNumber);
}
export function slog(value: DecimalSource, lhs: DecimalSource = 10) {
const baseNumber = Decimal.minabs(lhs, 1e308).toNumber();
return Decimal.slog(value, baseNumber);
}
export function invertSlog(value: DecimalSource, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(
Decimal.tetrate(value, Decimal.minabs(1e308, unrefFormulaSource(rhs)).toNumber())
);
}
// Other params can't be inverted ATM
console.error("Could not invert due to no input being a variable");
return 0;
}
export function layeradd(value: DecimalSource, diff: DecimalSource, base: DecimalSource) {
const diffNumber = Decimal.minabs(diff, 1e308).toNumber();
return Decimal.layeradd(value, diffNumber, base);
}
export function invertLayeradd(
value: DecimalSource,
lhs: FormulaSource,
diff: FormulaSource,
base: FormulaSource
) {
if (hasVariable(lhs)) {
return lhs.invert(
Decimal.layeradd(
value,
Decimal.minabs(1e308, unrefFormulaSource(diff)).negate().toNumber()
)
);
}
// Other params can't be inverted ATM
console.error("Could not invert due to no input being a variable");
return 0;
}
export function invertLambertw(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.pow(Math.E, value).times(value));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function invertSsqrt(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.tetrate(value, 2));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function pentate(value: DecimalSource, height: DecimalSource, payload: DecimalSource) {
const heightNumber = Decimal.minabs(height, 1e308).toNumber();
return Decimal.pentate(value, heightNumber, payload);
}
export function invertSin(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.asin(value));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function integrateSin(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = lhs.getIntegralFormula(stack);
return Formula.cos(x).neg();
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function invertCos(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.acos(value));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function integrateCos(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = lhs.getIntegralFormula(stack);
return Formula.sin(x);
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function invertTan(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.atan(value));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function integrateTan(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = lhs.getIntegralFormula(stack);
return Formula.cos(x).ln().neg();
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function invertAsin(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.sin(value));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function integrateAsin(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = lhs.getIntegralFormula(stack);
return Formula.asin(x)
.times(x)
.add(Formula.sqrt(Formula.sub(1, Formula.pow(x, 2))));
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function invertAcos(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.cos(value));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function integrateAcos(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = lhs.getIntegralFormula(stack);
return Formula.acos(x)
.times(x)
.sub(Formula.sqrt(Formula.sub(1, Formula.pow(x, 2))));
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function invertAtan(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.tan(value));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function integrateAtan(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = lhs.getIntegralFormula(stack);
return Formula.atan(x)
.times(x)
.sub(Formula.ln(Formula.pow(x, 2).add(1)).div(2));
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function invertSinh(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.asinh(value));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function integrateSinh(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = lhs.getIntegralFormula(stack);
return Formula.cosh(x);
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function invertCosh(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.acosh(value));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function integrateCosh(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = lhs.getIntegralFormula(stack);
return Formula.sinh(x);
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function invertTanh(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.atanh(value));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function integrateTanh(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = lhs.getIntegralFormula(stack);
return Formula.cosh(x).ln();
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function invertAsinh(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.sinh(value));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function integrateAsinh(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = lhs.getIntegralFormula(stack);
return Formula.asinh(x).times(x).sub(Formula.pow(x, 2).add(1).sqrt());
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function invertAcosh(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.cosh(value));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function integrateAcosh(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = lhs.getIntegralFormula(stack);
return Formula.acosh(x)
.times(x)
.sub(Formula.add(x, 1).sqrt().times(Formula.sub(x, 1).sqrt()));
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function invertAtanh(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.tanh(value));
}
console.error("Could not invert due to no input being a variable");
return 0;
}
export function integrateAtanh(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
if (!lhs.isIntegrable()) {
console.error("Could not integrate due to variable not being integrable");
return Formula.constant(0);
}
const x = lhs.getIntegralFormula(stack);
return Formula.atanh(x)
.times(x)
.add(Formula.sub(1, Formula.pow(x, 2)).ln().div(2));
}
console.error("Could not integrate due to no input being a variable");
return Formula.constant(0);
}
export function createPassthroughBinaryFormula(
operation: (a: DecimalSource, b: DecimalSource) => DecimalSource
) {
return (value: FormulaSource, other: FormulaSource) =>
new Formula({
inputs: [value, other],
evaluate: operation,
invert: passthrough as InvertFunction<[FormulaSource, FormulaSource]>
});
}

76
src/game/formulas/types.d.ts vendored Normal file
View file

@ -0,0 +1,76 @@
import { InternalFormula } from "game/formulas/formulas";
import { DecimalSource } from "util/bignum";
import { ProcessedComputable } from "util/computed";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type GenericFormula = InternalFormula<any>;
type FormulaSource = ProcessedComputable<DecimalSource> | GenericFormula;
type InvertibleFormula = GenericFormula & {
invert: NonNullable<GenericFormula["invert"]>;
};
type IntegrableFormula = InvertibleFormula & {
evaluateIntegral: NonNullable<GenericFormula["evaluateIntegral"]>;
getIntegralFormula: NonNullable<GenericFormula["getIntegralFormula"]>;
calculateConstantOfIntegration: NonNullable<GenericFormula["calculateConstantOfIntegration"]>;
};
type InvertibleIntegralFormula = IntegrableFormula & {
invertIntegral: NonNullable<GenericFormula["invertIntegral"]>;
};
type EvaluateFunction<T> = (
this: InternalFormula<T>,
...inputs: GuardedFormulasToDecimals<T>
) => DecimalSource;
type InvertFunction<T> = (
this: InternalFormula<T>,
value: DecimalSource,
...inputs: T
) => DecimalSource;
type IntegrateFunction<T> = (
this: InternalFormula<T>,
stack: SubstitutionStack | undefined,
...inputs: T
) => GenericFormula;
type SubstitutionFunction<T> = (
this: InternalFormula<T>,
variable: GenericFormula,
...inputs: T
) => GenericFormula;
type VariableFormulaOptions = { variable: ProcessedComputable<DecimalSource> };
type ConstantFormulaOptions = {
inputs: [FormulaSource];
};
type GeneralFormulaOptions<T extends [FormulaSource] | FormulaSource[]> = {
inputs: T;
evaluate: EvaluateFunction<T>;
invert?: InvertFunction<T>;
integrate?: IntegrateFunction<T>;
integrateInner?: IntegrateFunction<T>;
applySubstitution?: SubstitutionFunction<T>;
};
type FormulaOptions<T extends [FormulaSource] | FormulaSource[]> =
| VariableFormulaOptions
| ConstantFormulaOptions
| GeneralFormulaOptions<T>;
type InternalFormulaProperties<T extends [FormulaSource] | FormulaSource[]> = {
inputs: T;
internalVariables: number;
internalEvaluate?: EvaluateFunction<T>;
internalInvert?: InvertFunction<T>;
internalIntegrate?: IntegrateFunction<T>;
internalIntegrateInner?: IntegrateFunction<T>;
applySubstitution?: SubstitutionFunction<T>;
innermostVariable?: ProcessedComputable<DecimalSource>;
};
type SubstitutionStack = ((value: GenericFormula) => GenericFormula)[] | undefined;
// It's really hard to type mapped tuples, but these classes seem to manage
type FormulasToDecimals<T extends FormulaSource[]> = {
[K in keyof T]: DecimalSource;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type TupleGuard<T extends any[]> = T extends any[] ? FormulasToDecimals<T> : never;
type GuardedFormulasToDecimals<T extends FormulaSource[]> = TupleGuard<T>;

109
src/game/gameLoop.ts Normal file
View file

@ -0,0 +1,109 @@
import projInfo from "data/projInfo.json";
import { globalBus } from "game/events";
import settings from "game/settings";
import Decimal from "util/bignum";
import { loadingSave } from "util/save";
import type { Ref } from "vue";
import { watch } from "vue";
import player from "./player";
import state from "./state";
let intervalID: NodeJS.Timer | null = null;
// Not imported immediately due to dependency cycles
// This gets set during startGameLoop(), and will only be used in the update function
let hasWon: null | Ref<boolean> = null;
function update() {
const now = Date.now();
let diff = (now - player.time) / 1e3;
player.time = now;
const trueDiff = diff;
state.lastTenTicks.push(trueDiff);
if (state.lastTenTicks.length > 10) {
state.lastTenTicks = state.lastTenTicks.slice(1);
}
// Stop here if the game is paused on the win screen
if (hasWon?.value && !player.keepGoing) {
return;
}
// Stop here if the player had a NaN value
if (state.hasNaN) {
return;
}
diff = Math.max(diff, 0);
if (player.devSpeed === 0) {
return;
}
loadingSave.value = false;
// Add offline time if any
if (player.offlineTime != undefined) {
if (Decimal.gt(player.offlineTime, projInfo.offlineLimit * 3600)) {
player.offlineTime = projInfo.offlineLimit * 3600;
}
if (Decimal.gt(player.offlineTime, 0) && player.devSpeed !== 0) {
const offlineDiff = Math.max(player.offlineTime / 10, diff);
player.offlineTime = player.offlineTime - offlineDiff;
diff += offlineDiff;
} else if (player.devSpeed === 0) {
player.offlineTime += diff;
}
if (!player.offlineProd || Decimal.lt(player.offlineTime, 0)) {
player.offlineTime = null;
}
}
// Cap at max tick length
diff = Math.min(diff, projInfo.maxTickLength);
// Apply dev speed
if (player.devSpeed != undefined) {
diff *= player.devSpeed;
}
if (!Number.isFinite(diff)) {
diff = 1e308;
}
// Update
if (Decimal.eq(diff, 0)) {
return;
}
player.timePlayed += diff;
if (!Number.isFinite(player.timePlayed)) {
player.timePlayed = 1e308;
}
globalBus.emit("update", diff, trueDiff);
if (settings.unthrottled) {
requestAnimationFrame(update);
if (intervalID != null) {
clearInterval(intervalID);
intervalID = null;
}
} else if (intervalID == null) {
intervalID = setInterval(update, 50);
}
}
/** Starts the game loop for the project, which updates the game in ticks. */
export async function startGameLoop() {
hasWon = (await import("data/projEntry")).hasWon;
watch(hasWon, hasWon => {
if (hasWon) {
globalBus.emit("gameWon");
}
});
if (settings.unthrottled) {
requestAnimationFrame(update);
} else {
intervalID = setInterval(update, 50);
}
}

View file

@ -21,7 +21,7 @@ import type {
} from "util/computed"; } from "util/computed";
import { processComputable } from "util/computed"; import { processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy } from "util/proxies";
import type { InjectionKey, Ref } from "vue"; import { computed, InjectionKey, Ref } from "vue";
import { ref, shallowReactive, unref } from "vue"; import { ref, shallowReactive, unref } from "vue";
/** A feature's node in the DOM that has its size tracked. */ /** A feature's node in the DOM that has its size tracked. */
@ -109,7 +109,7 @@ export interface LayerOptions {
color?: Computable<string>; color?: Computable<string>;
/** /**
* The layout of this layer's features. * The layout of this layer's features.
* When the layer is open in {@link game/player.PlayerData.tabs}, this is the content that is display. * When the layer is open in {@link game/player.PlayerData.tabs}, this is the content that is displayed.
*/ */
display: Computable<CoercableComponent>; display: Computable<CoercableComponent>;
/** An object of classes that should be applied to the display. */ /** An object of classes that should be applied to the display. */
@ -126,6 +126,11 @@ export interface LayerOptions {
* Defaults to true. * Defaults to true.
*/ */
minimizable?: Computable<boolean>; minimizable?: Computable<boolean>;
/**
* The layout of this layer's features.
* When the layer is open in {@link game/player.PlayerData.tabs}, but the tab is {@link Layer.minimized} this is the content that is displayed.
*/
minimizedDisplay?: Computable<CoercableComponent>;
/** /**
* Whether or not to force the go back button to be hidden. * Whether or not to force the go back button to be hidden.
* If true, go back will be hidden regardless of {@link data/projInfo.allowGoBack}. * If true, go back will be hidden regardless of {@link data/projInfo.allowGoBack}.
@ -170,6 +175,7 @@ export type Layer<T extends LayerOptions> = Replace<
name: GetComputableTypeWithDefault<T["name"], string>; name: GetComputableTypeWithDefault<T["name"], string>;
minWidth: GetComputableTypeWithDefault<T["minWidth"], 600>; minWidth: GetComputableTypeWithDefault<T["minWidth"], 600>;
minimizable: GetComputableTypeWithDefault<T["minimizable"], true>; minimizable: GetComputableTypeWithDefault<T["minimizable"], true>;
minimizedDisplay: GetComputableType<T["minimizedDisplay"]>;
forceHideGoBack: GetComputableType<T["forceHideGoBack"]>; forceHideGoBack: GetComputableType<T["forceHideGoBack"]>;
} }
>; >;
@ -213,24 +219,57 @@ export function createLayer<T extends LayerOptions>(
addingLayers.push(id); addingLayers.push(id);
persistentRefs[id] = new Set(); persistentRefs[id] = new Set();
layer.minimized = persistent(false); layer.minimized = persistent(false, false);
Object.assign(layer, optionsFunc.call(layer as BaseLayer)); Object.assign(layer, optionsFunc.call(layer, layer as BaseLayer));
if ( if (
addingLayers[addingLayers.length - 1] == null || addingLayers[addingLayers.length - 1] == null ||
addingLayers[addingLayers.length - 1] !== id addingLayers[addingLayers.length - 1] !== id
) { ) {
throw `Adding layers stack in invalid state. This should not happen\nStack: ${addingLayers}\nTrying to pop ${layer.id}`; throw new Error(
`Adding layers stack in invalid state. This should not happen\nStack: ${addingLayers}\nTrying to pop ${layer.id}`
);
} }
addingLayers.pop(); addingLayers.pop();
processComputable(layer as T, "color"); processComputable(layer as T, "color");
processComputable(layer as T, "display"); processComputable(layer as T, "display");
processComputable(layer as T, "classes");
processComputable(layer as T, "style");
processComputable(layer as T, "name"); processComputable(layer as T, "name");
setDefault(layer, "name", layer.id); setDefault(layer, "name", layer.id);
processComputable(layer as T, "minWidth"); processComputable(layer as T, "minWidth");
setDefault(layer, "minWidth", 600); setDefault(layer, "minWidth", 600);
processComputable(layer as T, "minimizable"); processComputable(layer as T, "minimizable");
setDefault(layer, "minimizable", true); setDefault(layer, "minimizable", true);
processComputable(layer as T, "minimizedDisplay");
const style = layer.style as ProcessedComputable<StyleValue> | undefined;
layer.style = computed(() => {
let width = unref(layer.minWidth as ProcessedComputable<number | string>);
if (typeof width === "number" || !Number.isNaN(parseInt(width))) {
width = width + "px";
}
return [
unref(style) ?? "",
layer.minimized?.value
? {
flexGrow: "0",
flexShrink: "0",
width: "60px",
minWidth: "",
flexBasis: "",
margin: "0"
}
: {
flexGrow: "",
flexShrink: "",
width: "",
minWidth: width,
flexBasis: width,
margin: ""
}
];
}) as Ref<StyleValue>;
return layer as unknown as Layer<T>; return layer as unknown as Layer<T>;
}); });

View file

@ -1,27 +1,31 @@
import "components/common/modifiers.css"; import "components/common/modifiers.css";
import type { CoercableComponent } from "features/feature"; import type { CoercableComponent, OptionsFunc } from "features/feature";
import { jsx } from "features/feature"; import { jsx } from "features/feature";
import settings from "game/settings";
import type { DecimalSource } from "util/bignum"; import type { DecimalSource } from "util/bignum";
import Decimal, { format } from "util/bignum"; import Decimal, { formatSmall } from "util/bignum";
import type { WithRequired } from "util/common"; import type { WithRequired } from "util/common";
import type { Computable, ProcessedComputable } from "util/computed"; import type { Computable, ProcessedComputable } from "util/computed";
import { convertComputable } from "util/computed"; import { convertComputable } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy } from "util/proxies";
import { renderJSX } from "util/vue"; import { renderJSX } from "util/vue";
import { computed, unref } from "vue"; import { computed, unref } from "vue";
import Formula from "./formulas/formulas";
import { FormulaSource, GenericFormula } from "./formulas/types";
/** /**
* An object that can be used to apply or unapply some modification to a number. * An object that can be used to apply or unapply some modification to a number.
* Being reversible requires the operation being invertible, but some features may rely on that. * Being reversible requires the operation being invertible, but some features may rely on that.
* Descriptions can be optionally included for displaying them to the player. * Descriptions can be optionally included for displaying them to the player.
* The built-in modifier creators are designed to display the modifiers using. * The built-in modifier creators are designed to display the modifiers using {@link createModifierSection}.
* {@link createModifierSection}.
*/ */
export interface Modifier { export interface Modifier {
/** Applies some operation on the input and returns the result. */ /** Applies some operation on the input and returns the result. */
apply: (gain: DecimalSource) => DecimalSource; apply: (gain: DecimalSource) => DecimalSource;
/** Reverses the operation applied by the apply property. Required by some features. */ /** Reverses the operation applied by the apply property. Required by some features. */
revert?: (gain: DecimalSource) => DecimalSource; invert?: (gain: DecimalSource) => DecimalSource;
/** Get a formula for this modifier. Required by some features. */
getFormula?: (gain: FormulaSource) => GenericFormula;
/** /**
* Whether or not this modifier should be considered enabled. * Whether or not this modifier should be considered enabled.
* Typically for use with modifiers passed into {@link createSequentialModifier}. * Typically for use with modifiers passed into {@link createSequentialModifier}.
@ -37,22 +41,24 @@ export interface Modifier {
/** /**
* Utility type used to narrow down a modifier type that will have a description and/or enabled property based on optional parameters, T and S (respectively). * Utility type used to narrow down a modifier type that will have a description and/or enabled property based on optional parameters, T and S (respectively).
*/ */
export type ModifierFromOptionalParams<T, S> = T extends undefined export type ModifierFromOptionalParams<T, S> = undefined extends T
? S extends undefined ? undefined extends S
? Omit<WithRequired<Modifier, "revert">, "description" | "enabled"> ? Omit<WithRequired<Modifier, "invert" | "getFormula">, "description" | "enabled">
: Omit<WithRequired<Modifier, "revert" | "enabled">, "description"> : Omit<WithRequired<Modifier, "invert" | "enabled" | "getFormula">, "description">
: S extends undefined : undefined extends S
? Omit<WithRequired<Modifier, "revert" | "description">, "enabled"> ? Omit<WithRequired<Modifier, "invert" | "description" | "getFormula">, "enabled">
: WithRequired<Modifier, "revert" | "enabled" | "description">; : WithRequired<Modifier, "invert" | "enabled" | "description" | "getFormula">;
/** An object that configures an additive modifier via {@link createAdditiveModifier}. */ /** An object that configures an additive modifier via {@link createAdditiveModifier}. */
export interface AdditiveModifierOptions { export interface AdditiveModifierOptions {
/** The amount to add to the input value. */ /** The amount to add to the input value. */
addend: Computable<DecimalSource>; addend: Computable<DecimalSource>;
/** Description of what this modifier is doing. */ /** Description of what this modifier is doing. */
description?: Computable<CoercableComponent> | undefined; description?: Computable<CoercableComponent>;
/** A computable that will be processed and passed directly into the returned modifier. */ /** A computable that will be processed and passed directly into the returned modifier. */
enabled?: Computable<boolean> | undefined; enabled?: Computable<boolean>;
/** Determines if numbers larger or smaller than 0 should be displayed as red. */
smallerIsBetter?: boolean;
} }
/** /**
@ -60,33 +66,48 @@ export interface AdditiveModifierOptions {
* @param optionsFunc Additive modifier options. * @param optionsFunc Additive modifier options.
*/ */
export function createAdditiveModifier<T extends AdditiveModifierOptions>( export function createAdditiveModifier<T extends AdditiveModifierOptions>(
optionsFunc: () => T optionsFunc: OptionsFunc<T>
): ModifierFromOptionalParams<T["description"], T["enabled"]> { ): ModifierFromOptionalParams<T["description"], T["enabled"]> {
return createLazyProxy(() => { return createLazyProxy(feature => {
const { addend, description, enabled } = optionsFunc(); const { addend, description, enabled, smallerIsBetter } = optionsFunc.call(
feature,
feature
);
const processedAddend = convertComputable(addend); const processedAddend = convertComputable(addend);
const processedDescription = convertComputable(description); const processedDescription = convertComputable(description);
const processedEnabled = enabled == null ? undefined : convertComputable(enabled); const processedEnabled = enabled == null ? undefined : convertComputable(enabled);
return { return {
apply: (gain: DecimalSource) => Decimal.add(gain, unref(processedAddend)), apply: (gain: DecimalSource) => Decimal.add(gain, unref(processedAddend)),
revert: (gain: DecimalSource) => Decimal.sub(gain, unref(processedAddend)), invert: (gain: DecimalSource) => Decimal.sub(gain, unref(processedAddend)),
getFormula: (gain: FormulaSource) => Formula.add(gain, processedAddend),
enabled: processedEnabled, enabled: processedEnabled,
description: description:
description == null description == null
? undefined ? undefined
: jsx(() => ( : jsx(() => (
<div class="modifier-container"> <div class="modifier-container">
<span class="modifier-amount"> {unref(processedDescription) != null ? (
{Decimal.gte(unref(processedAddend), 0) ? "+" : ""}
{format(unref(processedAddend))}
</span>
{unref(processedDescription) ? (
<span class="modifier-description"> <span class="modifier-description">
{/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */} {/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */}
{renderJSX(unref(processedDescription)!)} {renderJSX(unref(processedDescription)!)}
</span> </span>
) : null} ) : null}
<span
class="modifier-amount"
style={
(
smallerIsBetter === true
? Decimal.gt(unref(processedAddend), 0)
: Decimal.lt(unref(processedAddend), 0)
)
? "color: var(--danger)"
: ""
}
>
{Decimal.gte(unref(processedAddend), 0) ? "+" : ""}
{formatSmall(unref(processedAddend))}
</span>
</div> </div>
)) ))
}; };
@ -101,6 +122,8 @@ export interface MultiplicativeModifierOptions {
description?: Computable<CoercableComponent> | undefined; description?: Computable<CoercableComponent> | undefined;
/** A computable that will be processed and passed directly into the returned modifier. */ /** A computable that will be processed and passed directly into the returned modifier. */
enabled?: Computable<boolean> | undefined; enabled?: Computable<boolean> | undefined;
/** Determines if numbers larger or smaller than 1 should be displayed as red. */
smallerIsBetter?: boolean;
} }
/** /**
@ -108,32 +131,47 @@ export interface MultiplicativeModifierOptions {
* @param optionsFunc Multiplicative modifier options. * @param optionsFunc Multiplicative modifier options.
*/ */
export function createMultiplicativeModifier<T extends MultiplicativeModifierOptions>( export function createMultiplicativeModifier<T extends MultiplicativeModifierOptions>(
optionsFunc: () => T optionsFunc: OptionsFunc<T>
): ModifierFromOptionalParams<T["description"], T["enabled"]> { ): ModifierFromOptionalParams<T["description"], T["enabled"]> {
return createLazyProxy(() => { return createLazyProxy(feature => {
const { multiplier, description, enabled } = optionsFunc(); const { multiplier, description, enabled, smallerIsBetter } = optionsFunc.call(
feature,
feature
);
const processedMultiplier = convertComputable(multiplier); const processedMultiplier = convertComputable(multiplier);
const processedDescription = convertComputable(description); const processedDescription = convertComputable(description);
const processedEnabled = enabled == null ? undefined : convertComputable(enabled); const processedEnabled = enabled == null ? undefined : convertComputable(enabled);
return { return {
apply: (gain: DecimalSource) => Decimal.times(gain, unref(processedMultiplier)), apply: (gain: DecimalSource) => Decimal.times(gain, unref(processedMultiplier)),
revert: (gain: DecimalSource) => Decimal.div(gain, unref(processedMultiplier)), invert: (gain: DecimalSource) => Decimal.div(gain, unref(processedMultiplier)),
getFormula: (gain: FormulaSource) => Formula.times(gain, processedMultiplier),
enabled: processedEnabled, enabled: processedEnabled,
description: description:
description == null description == null
? undefined ? undefined
: jsx(() => ( : jsx(() => (
<div class="modifier-container"> <div class="modifier-container">
<span class="modifier-amount"> {unref(processedDescription) != null ? (
x{format(unref(processedMultiplier))}
</span>
{unref(processedDescription) ? (
<span class="modifier-description"> <span class="modifier-description">
{/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */} {/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */}
{renderJSX(unref(processedDescription)!)} {renderJSX(unref(processedDescription)!)}
</span> </span>
) : null} ) : null}
<span
class="modifier-amount"
style={
(
smallerIsBetter === true
? Decimal.gt(unref(processedMultiplier), 1)
: Decimal.lt(unref(processedMultiplier), 1)
)
? "color: var(--danger)"
: ""
}
>
×{formatSmall(unref(processedMultiplier))}
</span>
</div> </div>
)) ))
}; };
@ -150,6 +188,8 @@ export interface ExponentialModifierOptions {
enabled?: Computable<boolean> | undefined; enabled?: Computable<boolean> | undefined;
/** Add 1 before calculating, then remove it afterwards. This prevents low numbers from becoming lower. */ /** Add 1 before calculating, then remove it afterwards. This prevents low numbers from becoming lower. */
supportLowNumbers?: boolean; supportLowNumbers?: boolean;
/** Determines if numbers larger or smaller than 1 should be displayed as red. */
smallerIsBetter?: boolean;
} }
/** /**
@ -157,10 +197,11 @@ export interface ExponentialModifierOptions {
* @param optionsFunc Exponential modifier options. * @param optionsFunc Exponential modifier options.
*/ */
export function createExponentialModifier<T extends ExponentialModifierOptions>( export function createExponentialModifier<T extends ExponentialModifierOptions>(
optionsFunc: () => T optionsFunc: OptionsFunc<T>
): ModifierFromOptionalParams<T["description"], T["enabled"]> { ): ModifierFromOptionalParams<T["description"], T["enabled"]> {
return createLazyProxy(() => { return createLazyProxy(feature => {
const { exponent, description, enabled, supportLowNumbers } = optionsFunc(); const { exponent, description, enabled, supportLowNumbers, smallerIsBetter } =
optionsFunc.call(feature, feature);
const processedExponent = convertComputable(exponent); const processedExponent = convertComputable(exponent);
const processedDescription = convertComputable(description); const processedDescription = convertComputable(description);
@ -177,7 +218,7 @@ export function createExponentialModifier<T extends ExponentialModifierOptions>(
} }
return result; return result;
}, },
revert: (gain: DecimalSource) => { invert: (gain: DecimalSource) => {
let result = gain; let result = gain;
if (supportLowNumbers) { if (supportLowNumbers) {
result = Decimal.add(result, 1); result = Decimal.add(result, 1);
@ -188,22 +229,37 @@ export function createExponentialModifier<T extends ExponentialModifierOptions>(
} }
return result; return result;
}, },
getFormula: (gain: FormulaSource) =>
supportLowNumbers
? Formula.add(gain, 1).pow(processedExponent).sub(1)
: Formula.pow(gain, processedExponent),
enabled: processedEnabled, enabled: processedEnabled,
description: description:
description == null description == null
? undefined ? undefined
: jsx(() => ( : jsx(() => (
<div class="modifier-container"> <div class="modifier-container">
<span class="modifier-amount"> {unref(processedDescription) != null ? (
^{format(unref(processedExponent))}
</span>
{unref(processedDescription) ? (
<span class="modifier-description"> <span class="modifier-description">
{/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */} {/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */}
{renderJSX(unref(processedDescription)!)} {renderJSX(unref(processedDescription)!)}
{supportLowNumbers ? " (+1 effective)" : null} {supportLowNumbers ? " (+1 effective)" : null}
</span> </span>
) : null} ) : null}
<span
class="modifier-amount"
style={
(
smallerIsBetter === true
? Decimal.gt(unref(processedExponent), 1)
: Decimal.lt(unref(processedExponent), 1)
)
? "color: var(--danger)"
: ""
}
>
^{formatSmall(unref(processedExponent))}
</span>
</div> </div>
)) ))
}; };
@ -219,9 +275,9 @@ export function createExponentialModifier<T extends ExponentialModifierOptions>(
*/ */
export function createSequentialModifier< export function createSequentialModifier<
T extends Modifier[], T extends Modifier[],
S = T extends WithRequired<Modifier, "revert">[] S = T extends WithRequired<Modifier, "invert">[]
? WithRequired<Modifier, "description" | "revert"> ? WithRequired<Modifier, "description" | "invert">
: Omit<WithRequired<Modifier, "description">, "revert"> : Omit<WithRequired<Modifier, "description">, "invert">
>(modifiersFunc: () => T): S { >(modifiersFunc: () => T): S {
return createLazyProxy(() => { return createLazyProxy(() => {
const modifiers = modifiersFunc(); const modifiers = modifiersFunc();
@ -231,64 +287,106 @@ export function createSequentialModifier<
modifiers modifiers
.filter(m => unref(m.enabled) !== false) .filter(m => unref(m.enabled) !== false)
.reduce((gain, modifier) => modifier.apply(gain), gain), .reduce((gain, modifier) => modifier.apply(gain), gain),
revert: modifiers.every(m => m.revert != null) invert: modifiers.every(m => m.invert != null)
? (gain: DecimalSource) => ? (gain: DecimalSource) =>
modifiers modifiers
.filter(m => unref(m.enabled) !== false) .filter(m => unref(m.enabled) !== false)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
.reduceRight((gain, modifier) => modifier.revert!(gain), gain) .reduceRight((gain, modifier) => modifier.invert!(gain), gain)
: undefined, : undefined,
enabled: computed(() => modifiers.filter(m => unref(m.enabled) !== false).length > 0), getFormula: modifiers.every(m => m.getFormula != null)
description: jsx(() => ( ? (gain: FormulaSource) =>
<> modifiers
{( .filter(m => unref(m.enabled) !== false)
modifiers // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
.filter(m => unref(m.enabled) !== false) .reduce((acc, curr) => curr.getFormula!(acc), gain)
.map(m => unref(m.description)) : undefined,
.filter(d => d) as CoercableComponent[] enabled: modifiers.some(m => m.enabled != null)
).map(renderJSX)} ? computed(() => modifiers.filter(m => unref(m.enabled) !== false).length > 0)
</> : undefined,
)) description: modifiers.some(m => m.description != null)
? jsx(() => (
<>
{(
modifiers
.filter(m => unref(m.enabled) !== false)
.map(m => unref(m.description))
.filter(d => d) as CoercableComponent[]
).map(renderJSX)}
</>
))
: undefined
}; };
}) as unknown as S; }) as unknown as S;
} }
/** An object that configures a modifier section via {@link createModifierSection}. */
export interface ModifierSectionOptions {
/** The header for the section. */
title: string;
/** Smaller text that appears in the header after the title. */
subtitle?: string;
/** The modifier to render. */
modifier: WithRequired<Modifier, "description">;
/** The base value that'll be passed into the modifier. Defaults to 1. */
base?: DecimalSource;
/** The unit of the value being modified, if any. */
unit?: string;
/** The label to use for the base value. Defaults to "Base". */
baseText?: CoercableComponent;
/** Determines if numbers larger or smaller than the base should be displayed as red. */
smallerIsBetter?: boolean;
}
/** /**
* Create a JSX element that displays a modifier. * Create a JSX element that displays a modifier.
* Intended to be used with the output from {@link createSequentialModifier}. * Intended to be used with the output from {@link createSequentialModifier}.
* @param title The header for the section. * @param options Modifier section options.
* @param subtitle Smaller text that appears in the header after the title.
* @param modifier The modifier to render.
* @param base The base value that'll be passed into the modifier.
* @param unit The unit of the value being modified, if any.
* @param baseText The label to use for the base value.
*/ */
export function createModifierSection( export function createModifierSection({
title: string, title,
subtitle: string, subtitle,
modifier: WithRequired<Modifier, "description">, modifier,
base: DecimalSource = 1, base,
unit = "", unit,
baseText: CoercableComponent = "Base" baseText,
) { smallerIsBetter
}: ModifierSectionOptions) {
const total = modifier.apply(base ?? 1);
return ( return (
<div> <div style={{ "--unit": settings.alignUnits && unit != null ? "'" + unit + "'" : "" }}>
<h3> <h3>
{title} {title}
{subtitle ? <span class="subtitle"> ({subtitle})</span> : null} {subtitle == null ? null : <span class="subtitle"> ({subtitle})</span>}
</h3> </h3>
<br /> <br />
<div class="modifier-container"> <div class="modifier-container">
<span class="modifier-description">{renderJSX(baseText ?? "Base")}</span>
<span class="modifier-amount"> <span class="modifier-amount">
{format(base)} {formatSmall(base ?? 1)}
{unit} {unit}
</span> </span>
<span class="modifier-description">{renderJSX(baseText)}</span>
</div> </div>
{renderJSX(unref(modifier.description))} {renderJSX(unref(modifier.description))}
<hr /> <hr />
Total: {format(modifier.apply(base))} <div class="modifier-container">
{unit} <span class="modifier-description">Total</span>
<span
class="modifier-amount"
style={
(
smallerIsBetter === true
? Decimal.gt(total, base ?? 1)
: Decimal.lt(total, base ?? 1)
)
? "color: var(--danger)"
: ""
}
>
{formatSmall(total)}
{unit}
</span>
</div>
</div> </div>
); );
} }

View file

@ -5,8 +5,11 @@ import { addingLayers, persistentRefs } from "game/layers";
import type { DecimalSource } from "util/bignum"; import type { DecimalSource } from "util/bignum";
import Decimal from "util/bignum"; import Decimal from "util/bignum";
import { ProxyState } from "util/proxies"; import { ProxyState } from "util/proxies";
import type { Ref } from "vue"; import type { Ref, WritableComputedRef } from "vue";
import { isReactive, isRef, ref } from "vue"; import { computed, isReactive, isRef, ref } from "vue";
import player from "./player";
import state from "./state";
import Formula from "./formulas/formulas";
/** /**
* A symbol used in {@link Persistent} objects. * A symbol used in {@link Persistent} objects.
@ -28,6 +31,26 @@ export const StackTrace = Symbol("StackTrace");
* @see {@link Persistent[Deleted]} * @see {@link Persistent[Deleted]}
*/ */
export const Deleted = Symbol("Deleted"); export const Deleted = Symbol("Deleted");
/**
* A symbol used in {@link Persistent} objects.
* @see {@link Persistent[NonPersistent]}
*/
export const NonPersistent = Symbol("NonPersistent");
/**
* A symbol used in {@link Persistent} objects.
* @see {@link Persistent[SaveDataPath]}
*/
export const SaveDataPath = Symbol("SaveDataPath");
/**
* A symbol used in {@link Persistent} objects.
* @see {@link Persistent[CheckNaN]}
*/
export const CheckNaN = Symbol("CheckNaN");
/**
* A symbol used to flag objects that should not be checked for persistent values.
*/
export const SkipPersistence = Symbol("SkipPersistence");
/** /**
* This is a union of things that should be safely stringifiable without needing special processes or knowing what to load them in as. * This is a union of things that should be safely stringifiable without needing special processes or knowing what to load them in as.
@ -46,6 +69,7 @@ export type State =
* A {@link Ref} that has been augmented with properties to allow it to be saved and loaded within the player save data object. * A {@link Ref} that has been augmented with properties to allow it to be saved and loaded within the player save data object.
*/ */
export type Persistent<T extends State = State> = Ref<T> & { export type Persistent<T extends State = State> = Ref<T> & {
value: T;
/** A flag that this is a persistent property. Typically a circular reference. */ /** A flag that this is a persistent property. Typically a circular reference. */
[PersistentState]: Ref<T>; [PersistentState]: Ref<T>;
/** The value the ref should be set to in a fresh save, or when updating an old save to the current version. */ /** The value the ref should be set to in a fresh save, or when updating an old save to the current version. */
@ -57,32 +81,98 @@ export type Persistent<T extends State = State> = Ref<T> & {
* @see {@link deletePersistent} for marking a persistent ref as deleted. * @see {@link deletePersistent} for marking a persistent ref as deleted.
*/ */
[Deleted]: boolean; [Deleted]: boolean;
/**
* A non-persistent ref that just reads and writes ot the persistent ref. Used for passing to other features without duplicating the persistent ref in the constructed save data object.
*/
[NonPersistent]: NonPersistent<T>;
/**
* The path this persistent appears in within the save data object. Predominantly used to ensure it's only placed in there one time.
*/
[SaveDataPath]: string[] | undefined;
/**
* Whether or not to NaN-check this ref. Should only be true on values expected to always be DecimalSources.
*/
[CheckNaN]: boolean;
}; };
export type NonPersistent<T extends State = State> = WritableComputedRef<T> & { [DefaultValue]: T };
function getStackTrace() { function getStackTrace() {
return ( return (
new Error().stack new Error().stack
?.split("\n") ?.split("\n")
.slice(3, 5) .slice(3, 5)
.map(line => line.trim()) .map(line => line.trim())
.join("\n") || "" .join("\n") ?? ""
); );
} }
function checkNaNAndWrite<T extends State>(persistent: Persistent<T>, value: T) {
// Decimal is smart enough to return false on things that aren't supposed to be numbers
if (Decimal.isNaN(value as DecimalSource)) {
if (!state.hasNaN) {
player.autosave = false;
state.hasNaN = true;
state.NaNPath = persistent[SaveDataPath];
state.NaNPersistent = persistent as Persistent<DecimalSource>;
}
console.error(`Attempted to save NaN value to ${persistent[SaveDataPath]?.join(".")}`);
}
persistent[PersistentState].value = value;
}
/** /**
* Create a persistent ref, which can be saved and loaded. * Create a persistent ref, which can be saved and loaded.
* All (non-deleted) persistent refs must be included somewhere within the layer object returned by that layer's options func. * All (non-deleted) persistent refs must be included somewhere within the layer object returned by that layer's options func.
* @param defaultValue The value the persistent ref should start at on fresh saves or when reset. * @param defaultValue The value the persistent ref should start at on fresh saves or when reset.
* @param checkNaN Whether or not to check this ref for being NaN on set. Only use on refs that should always be DecimalSources.
*/ */
export function persistent<T extends State>(defaultValue: T | Ref<T>): Persistent<T> { export function persistent<T extends State>(
const persistent = ( defaultValue: T | Ref<T>,
isRef(defaultValue) ? defaultValue : (ref<T>(defaultValue) as unknown) checkNaN = true
) as Persistent<T>; ): Persistent<T> {
const persistentState: Ref<T> = isRef(defaultValue)
? defaultValue
: (ref<T>(defaultValue) as Ref<T>);
persistent[PersistentState] = persistent; if (isRef(defaultValue)) {
persistent[DefaultValue] = isRef(defaultValue) ? defaultValue.value : defaultValue; defaultValue = defaultValue.value;
persistent[StackTrace] = getStackTrace(); }
persistent[Deleted] = false;
const nonPersistent = computed({
get() {
return persistentState.value;
},
set(value) {
if (checkNaN) {
checkNaNAndWrite(persistent, value);
} else {
persistent[PersistentState].value = value;
}
}
}) as NonPersistent<T>;
nonPersistent[DefaultValue] = defaultValue;
// We're trying to mock a vue ref, which means the type expects a private [RefSymbol] property that we can't access, but the actual implementation of isRef just checks for `__v_isRef`
const persistent = {
get value() {
return persistentState.value as T;
},
set value(value: T) {
if (checkNaN) {
checkNaNAndWrite(persistent, value);
} else {
persistent[PersistentState].value = value;
}
},
__v_isRef: true,
[PersistentState]: persistentState,
[DefaultValue]: defaultValue,
[StackTrace]: getStackTrace(),
[Deleted]: false,
[NonPersistent]: nonPersistent,
[SaveDataPath]: undefined
} as unknown as Persistent<T>;
if (addingLayers.length === 0) { if (addingLayers.length === 0) {
console.warn( console.warn(
@ -94,7 +184,55 @@ export function persistent<T extends State>(defaultValue: T | Ref<T>): Persisten
persistentRefs[addingLayers[addingLayers.length - 1]].add(persistent); persistentRefs[addingLayers[addingLayers.length - 1]].add(persistent);
} }
return persistent as Persistent<T>; return persistent;
}
/**
* Type guard for whether an arbitrary value is a persistent ref
* @param value The value that may or may not be a persistent ref
*/
export function isPersistent(value: unknown): value is Persistent {
return value != null && typeof value === "object" && PersistentState in value;
}
/**
* Unwraps the non-persistent ref inside of persistent refs, to be passed to other features without duplicating values in the save data object.
* @param persistent The persistent ref to unwrap, or an object to ignore all persistent refs within
*/
export function noPersist<T extends Persistent<S>, S extends State>(
persistent: T
): T[typeof NonPersistent];
export function noPersist<T extends object>(persistent: T): T;
export function noPersist<T extends Persistent<S>, S extends State>(persistent: T | object) {
// Check for proxy state so if it's a lazy proxy we don't evaluate it's function
// Lazy proxies are not persistent refs themselves, so we know we want to wrap them
return !(ProxyState in persistent) && NonPersistent in persistent
? persistent[NonPersistent]
: new Proxy(persistent, {
get(target, p) {
if (p === PersistentState) {
return undefined;
}
if (p === SkipPersistence) {
return true;
}
return target[p as keyof typeof target];
},
set(target, key, value) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(target as Record<PropertyKey, any>)[key] = value;
return true;
},
has(target, key) {
if (key === PersistentState) {
return false;
}
if (key == SkipPersistence) {
return true;
}
return Reflect.has(target, key);
}
});
} }
/** /**
@ -117,24 +255,43 @@ globalBus.on("addLayer", (layer: GenericLayer, saveData: Record<string, unknown>
const handleObject = (obj: Record<string, unknown>, path: string[] = []): boolean => { const handleObject = (obj: Record<string, unknown>, path: string[] = []): boolean => {
let foundPersistent = false; let foundPersistent = false;
Object.keys(obj).forEach(key => { Object.keys(obj).forEach(key => {
const value = obj[key]; let value = obj[key];
if (value && typeof value === "object") { if (value != null && typeof value === "object") {
if (PersistentState in value) { if ((value as Record<PropertyKey, unknown>)[SkipPersistence] === true) {
return;
}
if (ProxyState in value) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value = (value as any)[ProxyState] as object;
}
if (isPersistent(value)) {
foundPersistent = true; foundPersistent = true;
if ((value as Persistent)[Deleted]) { if (value[Deleted]) {
console.warn( console.warn(
"Deleted persistent ref present in returned object. Ignoring...", "Deleted persistent ref present in returned object. Ignoring...",
value, value,
"\nCreated at:\n" + (value as Persistent)[StackTrace] "\nCreated at:\n" + value[StackTrace]
); );
return; return;
} }
persistentRefs[layer.id].delete( persistentRefs[layer.id].delete(value);
ProxyState in value
? // eslint-disable-next-line @typescript-eslint/no-explicit-any // Handle SaveDataPath
((value as any)[ProxyState] as Persistent) const newPath = [layer.id, ...path, key];
: (value as Persistent) if (
); value[SaveDataPath] != undefined &&
JSON.stringify(newPath) !== JSON.stringify(value[SaveDataPath])
) {
console.error(
`Persistent ref is being saved to \`${newPath.join(
"."
)}\` when it's already present at \`${value[SaveDataPath].join(
"."
)}\`.`,
"This can cause unexpected behavior when loading saves between updates."
);
}
value[SaveDataPath] = newPath;
// Construct save path if it doesn't exist // Construct save path if it doesn't exist
const persistentState = path.reduce<Record<string, unknown>>((acc, curr) => { const persistentState = path.reduce<Record<string, unknown>>((acc, curr) => {
@ -147,25 +304,24 @@ globalBus.on("addLayer", (layer: GenericLayer, saveData: Record<string, unknown>
// Cache currently saved value // Cache currently saved value
const savedValue = persistentState[key]; const savedValue = persistentState[key];
// Add ref to save data // Add ref to save data
persistentState[key] = (value as Persistent)[PersistentState]; persistentState[key] = value[PersistentState];
// Load previously saved value // Load previously saved value
if (isReactive(persistentState)) { if (isReactive(persistentState)) {
if (savedValue != null) { if (savedValue != null) {
persistentState[key] = savedValue; persistentState[key] = savedValue;
} else { } else {
persistentState[key] = (value as Persistent)[DefaultValue]; persistentState[key] = value[DefaultValue];
} }
} else { } else {
if (savedValue != null) { if (savedValue != null) {
(persistentState[key] as Ref<unknown>).value = savedValue; (persistentState[key] as Ref<unknown>).value = savedValue;
} else { } else {
(persistentState[key] as Ref<unknown>).value = (value as Persistent)[ (persistentState[key] as Ref<unknown>).value = value[DefaultValue];
DefaultValue
];
} }
} }
} else if ( } else if (
!(value instanceof Decimal) && !(value instanceof Decimal) &&
!(value instanceof Formula) &&
!isRef(value) && !isRef(value) &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
!features.includes(value as { type: typeof Symbol }) !features.includes(value as { type: typeof Symbol })
@ -200,12 +356,16 @@ globalBus.on("addLayer", (layer: GenericLayer, saveData: Record<string, unknown>
}); });
return foundPersistent; return foundPersistent;
}; };
handleObject(layer); // eslint-disable-next-line @typescript-eslint/no-explicit-any
handleObject((layer as any)[ProxyState]);
persistentRefs[layer.id].forEach(persistent => { persistentRefs[layer.id].forEach(persistent => {
if (persistent[Deleted]) {
return;
}
console.error( console.error(
`Created persistent ref in ${layer.id} without registering it to the layer! Make sure to include everything persistent in the returned object`, `Created persistent ref in ${layer.id} without registering it to the layer!`,
persistent, "Make sure to include everything persistent in the returned object.\n\nCreated at:\n" +
"\nCreated at:\n" + persistent[StackTrace] persistent[StackTrace]
); );
}); });
persistentRefs[layer.id].clear(); persistentRefs[layer.id].clear();

View file

@ -1,13 +1,8 @@
import { isPlainObject } from "is-plain-object";
import Decimal from "util/bignum";
import type { ProxiedWithState } from "util/proxies";
import { ProxyPath, ProxyState } from "util/proxies";
import { reactive, unref } from "vue";
import type { Ref } from "vue"; import type { Ref } from "vue";
import transientState from "./state"; import { reactive, unref } from "vue";
/** The player save data object. */ /** The player save data object. */
export interface PlayerData { export interface Player {
/** The ID of this save. */ /** The ID of this save. */
id: string; id: string;
/** A multiplier for time passing. Set to 0 when the game is paused. */ /** A multiplier for time passing. Set to 0 when the game is paused. */
@ -36,9 +31,6 @@ export interface PlayerData {
layers: Record<string, LayerData<unknown>>; layers: Record<string, LayerData<unknown>>;
} }
/** The proxied player that is used to track NaN values. */
export type Player = ProxiedWithState<PlayerData>;
/** A layer's save data. Automatically unwraps refs. */ /** A layer's save data. Automatically unwraps refs. */
export type LayerData<T> = { export type LayerData<T> = {
[P in keyof T]?: T[P] extends (infer U)[] [P in keyof T]?: T[P] extends (infer U)[]
@ -52,7 +44,7 @@ export type LayerData<T> = {
: T[P]; : T[P];
}; };
const state = reactive<PlayerData>({ const player = reactive<Player>({
id: "", id: "",
devSpeed: null, devSpeed: null,
name: "", name: "",
@ -68,90 +60,16 @@ const state = reactive<PlayerData>({
layers: {} layers: {}
}); });
export default window.player = player;
/** Convert a player save data object into a JSON string. Unwraps refs. */ /** Convert a player save data object into a JSON string. Unwraps refs. */
export function stringifySave(player: PlayerData): string { export function stringifySave(player: Player): string {
return JSON.stringify(player, (key, value) => unref(value)); return JSON.stringify(player, (key, value) => unref(value));
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const playerHandler: ProxyHandler<Record<PropertyKey, any>> = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
get(target: Record<PropertyKey, any>, key: PropertyKey): any {
if (key === ProxyState || key === ProxyPath) {
return target[key];
}
const value = target[ProxyState][key];
if (key !== "value" && (isPlainObject(value) || Array.isArray(value))) {
if (value !== target[key]?.[ProxyState]) {
const path = [...target[ProxyPath], key];
target[key] = new Proxy({ [ProxyState]: value, [ProxyPath]: path }, playerHandler);
}
return target[key];
}
return value;
},
set(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
target: Record<PropertyKey, any>,
property: PropertyKey,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any,
receiver: ProxyConstructor
): boolean {
if (
!transientState.hasNaN &&
((typeof value === "number" && isNaN(value)) ||
(value instanceof Decimal &&
(isNaN(value.sign) || isNaN(value.layer) || isNaN(value.mag))))
) {
const currentValue = target[ProxyState][property];
if (
!(
(typeof currentValue === "number" && isNaN(currentValue)) ||
(currentValue instanceof Decimal &&
(isNaN(currentValue.sign) ||
isNaN(currentValue.layer) ||
isNaN(currentValue.mag)))
)
) {
state.autosave = false;
transientState.hasNaN = true;
transientState.NaNPath = [...target[ProxyPath], property];
transientState.NaNReceiver = receiver as unknown as Record<string, unknown>;
console.error(
`Attempted to set NaN value`,
[...target[ProxyPath], property],
target[ProxyState]
);
throw "Attempted to set NaN value. See above for details";
}
}
target[ProxyState][property] = value;
return true;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ownKeys(target: Record<PropertyKey, any>) {
return Reflect.ownKeys(target[ProxyState]);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
has(target: Record<PropertyKey, any>, key: string) {
return Reflect.has(target[ProxyState], key);
},
getOwnPropertyDescriptor(target, key) {
return Object.getOwnPropertyDescriptor(target[ProxyState], key);
}
};
declare global { declare global {
/** Augment the window object so the player can be accessed from the console. */ /** Augment the window object so the player can be accessed from the console. */
interface Window { interface Window {
player: Player; player: Player;
} }
} }
/** The player save data object. */
export default window.player = new Proxy(
{ [ProxyState]: state, [ProxyPath]: ["player"] },
playerHandler
) as Player;

370
src/game/requirements.tsx Normal file
View file

@ -0,0 +1,370 @@
import { isArray } from "@vue/shared";
import {
CoercableComponent,
isVisible,
jsx,
OptionsFunc,
Replace,
setDefault,
Visibility
} from "features/feature";
import { displayResource, Resource } from "features/resources/resource";
import Decimal, { DecimalSource } from "lib/break_eternity";
import {
Computable,
convertComputable,
processComputable,
ProcessedComputable
} from "util/computed";
import { createLazyProxy } from "util/proxies";
import { joinJSX, renderJSX } from "util/vue";
import { computed, unref } from "vue";
import Formula, { calculateCost, calculateMaxAffordable } from "./formulas/formulas";
import type { GenericFormula } from "./formulas/types";
import { DefaultValue, Persistent } from "./persistence";
/**
* An object that can be used to describe a requirement to perform some purchase or other action.
* @see {@link createCostRequirement}
*/
export interface Requirement {
/**
* The display for this specific requirement. This is used for displays multiple requirements condensed. Required if {@link visibility} can be {@link Visibility.Visible}.
*/
partialDisplay?: (amount?: DecimalSource) => JSX.Element;
/**
* The display for this specific requirement. Required if {@link visibility} can be {@link Visibility.Visible}.
*/
display?: (amount?: DecimalSource) => JSX.Element;
/**
* Whether or not this requirement should be displayed in Vue Features. {@link displayRequirements} will respect this property.
*/
visibility: ProcessedComputable<Visibility.Visible | Visibility.None | boolean>;
/**
* Whether or not this requirement has been met.
*/
requirementMet: ProcessedComputable<DecimalSource | boolean>;
/**
* Whether or not this requirement will need to affect the game state when whatever is using this requirement gets triggered.
*/
requiresPay: ProcessedComputable<boolean>;
/**
* Whether or not this requirement can have multiple levels of requirements that can be met at once. Requirement is assumed to not have multiple levels if this property not present.
*/
canMaximize?: ProcessedComputable<boolean>;
/**
* Perform any effects to the game state that should happen when the requirement gets triggered.
* @param amount The amount of levels of requirements to pay for.
*/
pay?: (amount?: DecimalSource) => void;
}
/**
* Utility type for accepting 1 or more {@link Requirement}s.
*/
export type Requirements = Requirement | Requirement[];
/** An object that configures a {@link Requirement} based on a resource cost. */
export interface CostRequirementOptions {
/**
* The resource that will be checked for meeting the {@link cost}.
*/
resource: Resource;
/**
* The amount of {@link resource} that must be met for this requirement. You can pass a formula, in which case maximizing will work out of the box (assuming its invertible and, for more accurate calculations, its integral is invertible). If you don't pass a formula then you can still support maximizing by passing a custom {@link pay} function.
*/
cost: Computable<DecimalSource> | GenericFormula;
/**
* Pass-through to {@link Requirement.visibility}.
*/
visibility?: Computable<Visibility.Visible | Visibility.None | boolean>;
/**
* Pass-through to {@link Requirement.requiresPay}. If not set to false, the default {@link pay} function will remove {@link cost} from {@link resource}.
*/
requiresPay?: Computable<boolean>;
/**
* When calculating multiple levels to be handled at once, whether it should consider resources used for each level as spent. Setting this to false causes calculations to be faster with larger numbers and supports more math functions.
* @see {Formula}
*/
cumulativeCost?: Computable<boolean>;
/**
* Upper limit on levels that can be performed at once. Defaults to 1.
*/
maxBulkAmount?: Computable<DecimalSource>;
/**
* When calculating requirement for multiple levels, how many should be directly summed for increase accuracy. High numbers can cause lag. Defaults to 10 if cumulative cost, 0 otherwise.
*/
directSum?: Computable<number>;
/**
* Pass-through to {@link Requirement.pay}. May be required for maximizing support.
* @see {@link cost} for restrictions on maximizing support.
*/
pay?: (amount?: DecimalSource) => void;
}
export type CostRequirement = Replace<
Requirement & CostRequirementOptions,
{
cost: ProcessedComputable<DecimalSource> | GenericFormula;
visibility: ProcessedComputable<Visibility.Visible | Visibility.None | boolean>;
requiresPay: ProcessedComputable<boolean>;
cumulativeCost: ProcessedComputable<boolean>;
canMaximize: ProcessedComputable<boolean>;
}
>;
/**
* Lazily creates a requirement with the given options, that is based on meeting an amount of a resource.
* @param optionsFunc Cost requirement options.
*/
export function createCostRequirement<T extends CostRequirementOptions>(
optionsFunc: OptionsFunc<T>
): CostRequirement {
return createLazyProxy(feature => {
const req = optionsFunc.call(feature, feature) as T & Partial<Requirement>;
req.partialDisplay = amount => (
<span
style={
unref(req.requirementMet as ProcessedComputable<boolean>)
? ""
: "color: var(--danger)"
}
>
{displayResource(
req.resource,
req.cost instanceof Formula
? calculateCost(
req.cost,
amount ?? 1,
unref(req.cumulativeCost) as boolean,
unref(req.directSum) as number
)
: unref(req.cost as ProcessedComputable<DecimalSource>)
)}{" "}
{req.resource.displayName}
</span>
);
req.display = amount => (
<div>
{unref(req.requiresPay as ProcessedComputable<boolean>) ? "Costs: " : "Requires: "}
{displayResource(
req.resource,
req.cost instanceof Formula
? calculateCost(
req.cost,
amount ?? 1,
unref(req.cumulativeCost) as boolean,
unref(req.directSum) as number
)
: unref(req.cost as ProcessedComputable<DecimalSource>)
)}{" "}
{req.resource.displayName}
</div>
);
processComputable(req as T, "visibility");
setDefault(req, "visibility", Visibility.Visible);
processComputable(req as T, "cost");
processComputable(req as T, "requiresPay");
setDefault(req, "requiresPay", true);
processComputable(req as T, "cumulativeCost");
setDefault(req, "cumulativeCost", true);
processComputable(req as T, "maxBulkAmount");
setDefault(req, "maxBulkAmount", 1);
processComputable(req as T, "directSum");
setDefault(req, "pay", function (amount?: DecimalSource) {
const cost =
req.cost instanceof Formula
? calculateCost(
req.cost,
amount ?? 1,
unref(req.cumulativeCost) as boolean,
unref(req.directSum) as number
)
: unref(req.cost as ProcessedComputable<DecimalSource>);
req.resource.value = Decimal.sub(req.resource.value, cost).max(0);
});
req.canMaximize = computed(() => {
if (!(req.cost instanceof Formula)) {
return false;
}
const maxBulkAmount = unref(req.maxBulkAmount as ProcessedComputable<DecimalSource>);
if (Decimal.lte(maxBulkAmount, 1)) {
return false;
}
const cumulativeCost = unref(req.cumulativeCost as ProcessedComputable<boolean>);
const directSum =
unref(req.directSum as ProcessedComputable<number>) ?? (cumulativeCost ? 10 : 0);
if (Decimal.lte(maxBulkAmount, directSum)) {
return true;
}
if (!req.cost.isInvertible()) {
return false;
}
if (cumulativeCost === true && !req.cost.isIntegrable()) {
return false;
}
return true;
});
if (req.cost instanceof Formula) {
req.requirementMet = calculateMaxAffordable(
req.cost,
req.resource,
req.cumulativeCost ?? true,
req.directSum,
req.maxBulkAmount
);
} else {
req.requirementMet = computed(() =>
Decimal.gte(
req.resource.value,
unref(req.cost as ProcessedComputable<DecimalSource>)
) ? 1 : 0
);
}
return req as CostRequirement;
});
}
/**
* Utility function for creating a requirement that a specified vue feature is visible
* @param feature The feature to check the visibility of
*/
export function createVisibilityRequirement(feature: {
visibility: ProcessedComputable<Visibility | boolean>;
}): Requirement {
return createLazyProxy(() => ({
requirementMet: computed(() => isVisible(feature.visibility)),
visibility: Visibility.None,
requiresPay: false
}));
}
/**
* Creates a requirement based on a true/false value
* @param requirement The boolean requirement to use
* @param display How to display this requirement to the user
*/
export function createBooleanRequirement(
requirement: Computable<boolean>,
display?: CoercableComponent
): Requirement {
return createLazyProxy(() => ({
requirementMet: convertComputable(requirement),
partialDisplay: display == null ? undefined : jsx(() => renderJSX(display)),
display: display == null ? undefined : jsx(() => <>Req: {renderJSX(display)}</>),
visibility: display == null ? Visibility.None : Visibility.Visible,
requiresPay: false
}));
}
/**
* Utility for checking if 1+ requirements are all met
* @param requirements The 1+ requirements to check
*/
export function requirementsMet(requirements: Requirements): boolean {
if (isArray(requirements)) {
return requirements.every(requirementsMet);
}
const reqsMet = unref(requirements.requirementMet);
return typeof reqsMet === "boolean" ? reqsMet : Decimal.gt(reqsMet, 0);
}
/**
* Calculates the maximum number of levels that could be acquired with the current requirement states. True/false requirements will be counted as Infinity or 0.
* @param requirements The 1+ requirements to check
*/
export function maxRequirementsMet(requirements: Requirements): DecimalSource {
if (isArray(requirements)) {
return requirements.map(maxRequirementsMet).reduce(Decimal.min);
}
const reqsMet = unref(requirements.requirementMet);
if (typeof reqsMet === "boolean") {
return reqsMet ? Decimal.dInf : 0;
} else if (Decimal.gt(reqsMet, 1) && unref(requirements.canMaximize) !== true) {
return 1;
}
return reqsMet;
}
/**
* Utility function for display 1+ requirements compactly.
* @param requirements The 1+ requirements to display
* @param amount The amount of levels earned to be displayed
*/
export function displayRequirements(requirements: Requirements, amount: DecimalSource = 1) {
if (isArray(requirements)) {
requirements = requirements.filter(r => isVisible(r.visibility));
if (requirements.length === 1) {
requirements = requirements[0];
}
}
if (isArray(requirements)) {
requirements = requirements.filter(r => "partialDisplay" in r);
const withCosts = requirements.filter(r => unref(r.requiresPay));
const withoutCosts = requirements.filter(r => !unref(r.requiresPay));
return (
<>
{withCosts.length > 0 ? (
<div>
Costs:{" "}
{joinJSX(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
withCosts.map(r => r.partialDisplay!(amount)),
<>, </>
)}
</div>
) : null}
{withoutCosts.length > 0 ? (
<div>
Requires:{" "}
{joinJSX(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
withoutCosts.map(r => r.partialDisplay!(amount)),
<>, </>
)}
</div>
) : null}
</>
);
}
return requirements.display?.() ?? <></>;
}
/**
* Utility function for paying the costs for 1+ requirements
* @param requirements The 1+ requirements to pay
* @param amount How many levels to pay for
*/
export function payRequirements(requirements: Requirements, amount: DecimalSource = 1) {
if (isArray(requirements)) {
requirements.filter(r => unref(r.requiresPay)).forEach(r => r.pay?.(amount));
} else if (unref(requirements.requiresPay)) {
requirements.pay?.(amount);
}
}
export function payByDivision(this: CostRequirement, amount?: DecimalSource) {
const cost =
this.cost instanceof Formula
? calculateCost(
this.cost,
amount ?? 1,
unref(this.cumulativeCost as ProcessedComputable<boolean> | undefined) ?? true
)
: unref(this.cost as ProcessedComputable<DecimalSource>);
this.resource.value = Decimal.div(this.resource.value, cost);
}
export function payByReset(overrideDefaultValue?: DecimalSource) {
return function (this: CostRequirement) {
this.resource.value =
overrideDefaultValue ??
(this.resource as Resource & Persistent<DecimalSource>)[DefaultValue] ??
0;
};
}

View file

@ -18,6 +18,8 @@ export interface Settings {
theme: Themes; theme: Themes;
/** Whether or not to cap the project at 20 ticks per second. */ /** Whether or not to cap the project at 20 ticks per second. */
unthrottled: boolean; unthrottled: boolean;
/** Whether to align modifiers to the unit. */
alignUnits: boolean;
} }
const state = reactive<Partial<Settings>>({ const state = reactive<Partial<Settings>>({
@ -25,7 +27,8 @@ const state = reactive<Partial<Settings>>({
saves: [], saves: [],
showTPS: true, showTPS: true,
theme: Themes.Nordic, theme: Themes.Nordic,
unthrottled: false unthrottled: false,
alignUnits: false
}); });
watch( watch(
@ -57,7 +60,8 @@ export const hardResetSettings = (window.hardResetSettings = () => {
active: "", active: "",
saves: [], saves: [],
showTPS: true, showTPS: true,
theme: Themes.Nordic theme: Themes.Nordic,
alignUnits: false
}; };
globalBus.emit("loadSettings", settings); globalBus.emit("loadSettings", settings);
Object.assign(state, settings); Object.assign(state, settings);

View file

@ -1,4 +1,6 @@
import { shallowReactive } from "vue"; import type { DecimalSource } from "util/bignum";
import { reactive, shallowReactive } from "vue";
import type { Persistent } from "./persistence";
/** An object of global data that is not persistent. */ /** An object of global data that is not persistent. */
export interface Transient { export interface Transient {
@ -8,8 +10,10 @@ export interface Transient {
hasNaN: boolean; hasNaN: boolean;
/** The location within the player save data object of the NaN value. */ /** The location within the player save data object of the NaN value. */
NaNPath?: string[]; NaNPath?: string[];
/** The parent object of the NaN value. */ /** The ref that was being set to NaN. */
NaNReceiver?: Record<string, unknown>; NaNPersistent?: Persistent<DecimalSource>;
/** List of errors that have occurred, to show the user. */
errors: Error[];
} }
declare global { declare global {
@ -22,5 +26,6 @@ declare global {
export default window.state = shallowReactive<Transient>({ export default window.state = shallowReactive<Transient>({
lastTenTicks: [], lastTenTicks: [],
hasNaN: false, hasNaN: false,
NaNPath: [] NaNPath: [],
errors: reactive([])
}); });

File diff suppressed because it is too large Load diff

139
src/lib/lru-cache.ts Normal file
View file

@ -0,0 +1,139 @@
/**
* A LRU cache intended for caching pure functions.
*/
export class LRUCache<K, V> {
private map = new Map<K, ListNode<K, V>>();
// Invariant: Exactly one of the below is true before and after calling a
// LRUCache method:
// - first and last are both undefined, and map.size() is 0.
// - first and last are the same object, and map.size() is 1.
// - first and last are different objects, and map.size() is greater than 1.
private first: ListNode<K, V> | undefined = undefined;
private last: ListNode<K, V> | undefined = undefined;
maxSize: number;
/**
* @param maxSize The maximum size for this cache. We recommend setting this
* to be one less than a power of 2, as most hashtables - including V8's
* Object hashtable (https://crsrc.org/c/v8/src/objects/ordered-hash-table.cc)
* - uses powers of two for hashtable sizes. It can't exactly be a power of
* two, as a .set() call could temporarily set the size of the map to be
* maxSize + 1.
*/
constructor(maxSize: number) {
this.maxSize = maxSize;
}
get size(): number {
return this.map.size;
}
/**
* Gets the specified key from the cache, or undefined if it is not in the
* cache.
* @param key The key to get.
* @returns The cached value, or undefined if key is not in the cache.
*/
get(key: K): V | undefined {
const node = this.map.get(key);
if (node === undefined) {
return undefined;
}
// It is guaranteed that there is at least one item in the cache.
// Therefore, first and last are guaranteed to be a ListNode...
// but if there is only one item, they might be the same.
// Update the order of the list to make this node the first node in the
// list.
// This isn't needed if this node is already the first node in the list.
if (node !== this.first) {
// As this node is DIFFERENT from the first node, it is guaranteed that
// there are at least two items in the cache.
// However, this node could possibly be the last item.
if (node === this.last) {
// This node IS the last node.
this.last = node.prev;
// From the invariants, there must be at least two items in the cache,
// so node - which is the original "last node" - must have a defined
// previous node. Therefore, this.last - set above - must be defined
// here.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.last!.next = undefined;
} else {
// This node is somewhere in the middle of the list, so there must be at
// least THREE items in the list, and this node's prev and next must be
// defined here.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
node.prev!.next = node.next;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
node.next!.prev = node.prev;
}
node.next = this.first;
// From the invariants, there must be at least two items in the cache, so
// this.first must be a valid ListNode.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.first!.prev = node;
this.first = node;
}
return node.value;
}
/**
* Sets an entry in the cache.
*
* @param key The key of the entry.
* @param value The value of the entry.
* @throws Error, if the map already contains the key.
*/
set(key: K, value: V): void {
// Ensure that this.maxSize >= 1.
if (this.maxSize < 1) {
return;
}
if (this.map.has(key)) {
throw new Error("Cannot update existing keys in the cache");
}
const node = new ListNode(key, value);
// Move node to the front of the list.
if (this.first === undefined) {
// If the first is undefined, the last is undefined too.
// Therefore, this cache has no items in it.
this.first = node;
this.last = node;
} else {
// This cache has at least one item in it.
node.next = this.first;
this.first.prev = node;
this.first = node;
}
this.map.set(key, node);
while (this.map.size > this.maxSize) {
// We are guaranteed that this.maxSize >= 1,
// so this.map.size is guaranteed to be >= 2,
// so this.first and this.last must be different valid ListNodes,
// and this.last.prev must also be a valid ListNode (possibly this.first).
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const last = this.last!;
this.map.delete(last.key);
this.last = last.prev;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.last!.next = undefined;
}
}
}
/**
* A node in a doubly linked list.
*/
class ListNode<K, V> {
key: K;
value: V;
next: ListNode<K, V> | undefined = undefined;
prev: ListNode<K, V> | undefined = undefined;
constructor(key: K, value: V) {
this.key = key;
this.value = value;
}
}

View file

@ -2,6 +2,7 @@ import "@fontsource/material-icons";
import App from "App.vue"; import App from "App.vue";
import projInfo from "data/projInfo.json"; import projInfo from "data/projInfo.json";
import "game/notifications"; import "game/notifications";
import state from "game/state";
import { load } from "util/save"; import { load } from "util/save";
import { useRegisterSW } from "virtual:pwa-register/vue"; import { useRegisterSW } from "virtual:pwa-register/vue";
import type { App as VueApp } from "vue"; import type { App as VueApp } from "vue";
@ -23,10 +24,33 @@ declare global {
} }
} }
const error = console.error;
console.error = function (...args) {
if (import.meta.env.DEV) {
state.errors.push(new Error(args[0], { cause: args[1] }));
}
error(...args);
};
window.onerror = function (event, source, lineno, colno, err) {
state.errors.push(err instanceof Error ? err : new Error(JSON.stringify(err)));
error(err);
return true;
};
window.onunhandledrejection = function (event) {
state.errors.push(
event.reason instanceof Error ? event.reason : new Error(JSON.stringify(event.reason))
);
error(event.reason);
};
document.title = projInfo.title; document.title = projInfo.title;
window.projInfo = projInfo; window.projInfo = projInfo;
if ((projInfo.id as string) === "") { if (projInfo.id === "") {
throw "Project ID is empty! Please select a unique ID for this project in /src/data/projInfo.json"; console.error(
"Project ID is empty!",
"Please select a unique ID for this project in /src/data/projInfo.json"
);
} }
requestAnimationFrame(async () => { requestAnimationFrame(async () => {
@ -36,10 +60,14 @@ requestAnimationFrame(async () => {
"padding: 4px;" "padding: 4px;"
); );
await load(); await load();
const { globalBus, startGameLoop } = await import("./game/events"); const { globalBus } = await import("./game/events");
const { startGameLoop } = await import("./game/gameLoop");
// Create Vue // Create Vue
const vue = (window.vue = createApp(App)); const vue = (window.vue = createApp(App));
vue.config.errorHandler = function (err, instance, info) {
console.error(err, info, instance);
};
globalBus.emit("setupVue", vue); globalBus.emit("setupVue", vue);
vue.mount("#app"); vue.mount("#app");
@ -48,7 +76,7 @@ requestAnimationFrame(async () => {
const toast = useToast(); const toast = useToast();
const { updateServiceWorker } = useRegisterSW({ const { updateServiceWorker } = useRegisterSW({
onNeedRefresh() { onNeedRefresh() {
toast.info("New content available, click or reload to update.", { toast.info("New content available, click here to update.", {
timeout: false, timeout: false,
closeOnClick: false, closeOnClick: false,
draggable: false, draggable: false,
@ -69,7 +97,8 @@ requestAnimationFrame(async () => {
onRegisterError: console.warn, onRegisterError: console.warn,
onRegistered(r) { onRegistered(r) {
if (r) { if (r) {
setInterval(r.update, 60 * 60 * 1000); // https://stackoverflow.com/questions/65500916/typeerror-failed-to-execute-update-on-serviceworkerregistration-illegal-in
setInterval(() => r.update(), 60 * 60 * 1000);
} }
} }
}); });

View file

@ -18,7 +18,7 @@ export const {
export type DecimalSource = RawDecimalSource; export type DecimalSource = RawDecimalSource;
declare global { declare global {
/** Augment the window object so the big num functions can be access from the console. */ /** Augment the window object so the big num functions can be accessed from the console. */
interface Window { interface Window {
Decimal: typeof Decimal; Decimal: typeof Decimal;
exponentialFormat: (num: DecimalSource, precision: number, mantissa: boolean) => string; exponentialFormat: (num: DecimalSource, precision: number, mantissa: boolean) => string;

View file

@ -12,6 +12,11 @@ export function camelToTitle(camel: string): string {
return title; return title;
} }
export function camelToKebab(camel: string) {
// Split off first character so function works on upper camel (pascal) case
return (camel[0] + camel.slice(1).replace(/[A-Z]/g, c => `-${c}`)).toLowerCase();
}
export function isFunction<T, S extends ReadonlyArray<unknown>, R>( export function isFunction<T, S extends ReadonlyArray<unknown>, R>(
functionOrValue: ((...args: S) => T) | R functionOrValue: ((...args: S) => T) | R
): functionOrValue is (...args: S) => T { ): functionOrValue is (...args: S) => T {

View file

@ -1,6 +1,7 @@
import type { JSXFunction } from "features/feature";
import { isFunction } from "util/common";
import type { Ref } from "vue"; import type { Ref } from "vue";
import { computed } from "vue"; import { computed } from "vue";
import { isFunction } from "util/common";
export const DoNotCache = Symbol("DoNotCache"); export const DoNotCache = Symbol("DoNotCache");
@ -32,21 +33,22 @@ export function processComputable<T, S extends keyof ComputableKeysOf<T>>(
key: S key: S
): asserts obj is T & { [K in S]: ProcessedComputable<UnwrapComputableType<T[S]>> } { ): asserts obj is T & { [K in S]: ProcessedComputable<UnwrapComputableType<T[S]>> } {
const computable = obj[key]; const computable = obj[key];
// eslint-disable-next-line @typescript-eslint/no-explicit-any if (
if (isFunction(computable) && computable.length === 0 && !(computable as any)[DoNotCache]) { isFunction(computable) &&
computable.length === 0 &&
!(computable as unknown as JSXFunction)[DoNotCache]
) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
obj[key] = computed(computable.bind(obj)); obj[key] = computed(computable.bind(obj));
} else if (isFunction(computable)) { } else if (isFunction(computable)) {
obj[key] = computable.bind(obj) as T[S]; obj[key] = computable.bind(obj) as unknown as T[S];
// eslint-disable-next-line @typescript-eslint/no-explicit-any (obj[key] as unknown as JSXFunction)[DoNotCache] = true;
(obj[key] as any)[DoNotCache] = true;
} }
} }
export function convertComputable<T>(obj: Computable<T>): ProcessedComputable<T> { export function convertComputable<T>(obj: Computable<T>): ProcessedComputable<T> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any if (isFunction(obj) && !(obj as unknown as JSXFunction)[DoNotCache]) {
if (isFunction(obj) && !(obj as any)[DoNotCache]) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
obj = computed(obj); obj = computed(obj);

View file

@ -1,10 +1,11 @@
import type { Persistent } from "game/persistence";
import { NonPersistent } from "game/persistence";
import Decimal from "util/bignum"; import Decimal from "util/bignum";
export const ProxyState = Symbol("ProxyState"); export const ProxyState = Symbol("ProxyState");
export const ProxyPath = Symbol("ProxyPath"); export const ProxyPath = Symbol("ProxyPath");
// eslint-disable-next-line @typescript-eslint/no-explicit-any export type ProxiedWithState<T> = NonNullable<T> extends Record<PropertyKey, unknown>
export type ProxiedWithState<T> = NonNullable<T> extends Record<PropertyKey, any>
? NonNullable<T> extends Decimal ? NonNullable<T> extends Decimal
? T ? T
: { : {
@ -15,17 +16,34 @@ export type ProxiedWithState<T> = NonNullable<T> extends Record<PropertyKey, any
} }
: T; : T;
export type Proxied<T> = NonNullable<T> extends Record<PropertyKey, unknown>
? NonNullable<T> extends Persistent<infer S>
? NonPersistent<S>
: NonNullable<T> extends Decimal
? T
: {
[K in keyof T]: Proxied<T[K]>;
} & {
[ProxyState]: T;
}
: T;
// Takes a function that returns an object and pretends to be that object // Takes a function that returns an object and pretends to be that object
// Note that the object is lazily calculated // Note that the object is lazily calculated
export function createLazyProxy<T extends object, S extends T>( export function createLazyProxy<T extends object, S extends T>(
objectFunc: (baseObject: S) => T & S, objectFunc: (this: S, baseObject: S) => T & S,
baseObject: S = {} as S baseObject: S = {} as S
): T { ): T {
const obj: S & Partial<T> = baseObject; const obj: S & Partial<T> = baseObject;
let calculated = false; let calculated = false;
let calculating = false;
function calculateObj(): T { function calculateObj(): T {
if (!calculated) { if (!calculated) {
Object.assign(obj, objectFunc(obj)); if (calculating) {
console.error("Cyclical dependency detected. Cannot evaluate lazy proxy.");
}
calculating = true;
Object.assign(obj, objectFunc.call(obj, obj));
calculated = true; calculated = true;
} }
return obj as S & T; return obj as S & T;
@ -37,7 +55,11 @@ export function createLazyProxy<T extends object, S extends T>(
return calculateObj(); return calculateObj();
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
return (calculateObj() as any)[key]; const val = (calculateObj() as any)[key];
if (val != null && typeof val === "object" && NonPersistent in val) {
return val[NonPersistent];
}
return val;
}, },
set(target, key, value) { set(target, key, value) {
// TODO give warning about this? It should only be done with caution // TODO give warning about this? It should only be done with caution
@ -56,7 +78,7 @@ export function createLazyProxy<T extends object, S extends T>(
}, },
getOwnPropertyDescriptor(target, key) { getOwnPropertyDescriptor(target, key) {
if (!calculated) { if (!calculated) {
Object.assign(obj, objectFunc(obj)); Object.assign(obj, objectFunc.call(obj, obj));
calculated = true; calculated = true;
} }
return Object.getOwnPropertyDescriptor(target, key); return Object.getOwnPropertyDescriptor(target, key);

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