├── src ├── types │ ├── ReactionMeta.ts │ ├── ColumnCopyMode.ts │ ├── DisplayedProp.tsx │ ├── MathComponent.ts │ ├── DamageData.tsx │ ├── RecordEntry.ts │ ├── SelectOption.ts │ ├── StatSectionDefinition.ts │ ├── ValueData.ts │ ├── DamageGroups.ts │ ├── VariableOutput.ts │ ├── PartialAttack.ts │ ├── ImportedCharacter.ts │ ├── Stat.ts │ ├── StatData.ts │ ├── ColumnState.ts │ ├── ReactionType.ts │ └── EquationData.ts ├── vite-env.d.ts ├── less │ ├── AttacksRow.less │ ├── LoadSavedPopup.less │ ├── DamageTypeRow.less │ ├── AttrStatInput.less │ ├── TopButtonRow.less │ ├── DamageOutput.less │ ├── Footer.less │ ├── PopupHeader.less │ ├── MathIndicator.less │ ├── ImportPopup.less │ ├── ExportPopup.less │ ├── EquationLine.less │ ├── RowLabel.less │ ├── StatInputRow.less │ ├── AttackList.less │ ├── GameImportArea.less │ ├── popup.less │ ├── CalculationPopup.less │ ├── ReactionDesc.less │ ├── SVGButton.less │ ├── CalculatorForm.less │ ├── index.less │ ├── HelpPage.less │ └── FormInput.less ├── utils │ ├── IDGenerator.ts │ ├── roundDecimals.ts │ ├── displayDamage.ts │ ├── evalulateExpression.ts │ ├── Damage.ts │ ├── damageTypes.ts │ ├── attributes.ts │ ├── statSections.ts │ ├── elements.ts │ ├── topDescs.tsx │ ├── columnListReducer.ts │ ├── ColumnStorage.ts │ ├── Column.ts │ ├── transformativeLevelMultipliers.ts │ ├── __tests__ │ │ ├── csv.ts │ │ └── DamageCalculator.ts │ ├── EnkaImporter.tsx │ ├── reactionTypes.ts │ ├── csv.ts │ ├── ColumnList.ts │ ├── Attack.ts │ ├── stats.tsx │ └── DamageCalculator.ts ├── pages │ ├── CalculatorPage.tsx │ └── HelpPage.tsx ├── components │ ├── MathIndicator.tsx │ ├── Page.tsx │ ├── Footer.tsx │ ├── AsyncContent.ts │ ├── HeadingRow.tsx │ ├── EquationLine.tsx │ ├── PopupHeader.tsx │ ├── SelectOptions.tsx │ ├── DifferenceOutput.tsx │ ├── RowLabel.tsx │ ├── AttacksExpr.tsx │ ├── LabelRow.tsx │ ├── SVGButton.tsx │ ├── DamageOutput.tsx │ ├── AttackList.tsx │ ├── TopButtonRow.tsx │ ├── RemoveColumnRow.tsx │ ├── StatInput.tsx │ ├── CalculatorSection.tsx │ ├── DamageOutputRow.tsx │ ├── LoadSavedPopup.tsx │ ├── AttacksRow.tsx │ ├── DamageTypeRow.tsx │ ├── ImportPopup.tsx │ ├── ReactionDesc.tsx │ ├── ExportPopup.tsx │ ├── CalculatorForm.tsx │ ├── AttrStatInput.tsx │ ├── SecondaryReactionRow.tsx │ ├── FormInput.tsx │ ├── CalculationPopup.tsx │ ├── StatInputRow.tsx │ └── GameImportArea.tsx ├── svgs │ ├── AddSVG.tsx │ ├── CloseSVG.tsx │ ├── SearchSVG.tsx │ ├── LoadSVG.tsx │ ├── SaveSVG.tsx │ ├── CopySVG.tsx │ ├── ImportSVG.tsx │ ├── HelpSVG.tsx │ ├── DeleteSVG.tsx │ ├── ExportSVG.tsx │ ├── SyncSVG.tsx │ ├── CalculatorSVG.tsx │ └── StatIcon.tsx ├── index.tsx └── App.tsx ├── .env ├── babel.config.cjs ├── vite.config.ts ├── .gitignore ├── jest.config.ts ├── index.html ├── .github └── workflows │ └── test.yml ├── tsconfig.json ├── README.md ├── LICENSE └── package.json /src/types/ReactionMeta.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | VITE_ENKA_PROXY = "https://www.brandonfowler.me/enka-proxy" -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/less/AttacksRow.less: -------------------------------------------------------------------------------- 1 | .column-attacks { 2 | padding: 4px 0; 3 | } -------------------------------------------------------------------------------- /src/less/LoadSavedPopup.less: -------------------------------------------------------------------------------- 1 | .load-saved > * { 2 | margin-top: 4px; 3 | } -------------------------------------------------------------------------------- /src/less/DamageTypeRow.less: -------------------------------------------------------------------------------- 1 | .damage-type { 2 | optgroup { 3 | color: white; 4 | } 5 | } -------------------------------------------------------------------------------- /src/less/AttrStatInput.less: -------------------------------------------------------------------------------- 1 | .attr-inputs { 2 | > * + * { 3 | margin-top: 8px; 4 | } 5 | } 6 | 7 | -------------------------------------------------------------------------------- /src/less/TopButtonRow.less: -------------------------------------------------------------------------------- 1 | .column-top { 2 | display: flex; 3 | justify-content: center; 4 | gap: 4px; 5 | } -------------------------------------------------------------------------------- /src/less/DamageOutput.less: -------------------------------------------------------------------------------- 1 | .damage-output { 2 | margin: 3px 0; 3 | display: flex; 4 | align-items: center; 5 | gap: 0.5em; 6 | } -------------------------------------------------------------------------------- /src/less/Footer.less: -------------------------------------------------------------------------------- 1 | footer { 2 | margin: 1em 0; 3 | 4 | > :not(:first-child) { 5 | margin-top: 1em; 6 | text-align: center; 7 | } 8 | } -------------------------------------------------------------------------------- /src/utils/IDGenerator.ts: -------------------------------------------------------------------------------- 1 | export default class IDGenerator { 2 | private static id = 0; 3 | 4 | static generate() { 5 | return IDGenerator.id++; 6 | } 7 | } -------------------------------------------------------------------------------- /src/types/ColumnCopyMode.ts: -------------------------------------------------------------------------------- 1 | const enum ColumnCopyMode { 2 | CopyNone, 3 | CopyAttacks, 4 | CopyDataAndId 5 | } 6 | 7 | export default ColumnCopyMode; -------------------------------------------------------------------------------- /src/types/DisplayedProp.tsx: -------------------------------------------------------------------------------- 1 | type DisplayedProp = { 2 | name: string; 3 | desc?: React.ReactNode; 4 | prop: keyof T; 5 | }; 6 | 7 | export default DisplayedProp; -------------------------------------------------------------------------------- /src/less/PopupHeader.less: -------------------------------------------------------------------------------- 1 | .popup-header { 2 | display: flex; 3 | gap: 1em; 4 | align-items: center; 5 | 6 | h2 { 7 | flex: 1; 8 | margin: 0; 9 | } 10 | } -------------------------------------------------------------------------------- /src/less/MathIndicator.less: -------------------------------------------------------------------------------- 1 | .math-indicator { 2 | display: grid; 3 | line-height: 0.7; 4 | grid-template-columns: 1fr 1fr; 5 | column-gap: 3px; 6 | font-size: 0.8em; 7 | } -------------------------------------------------------------------------------- /src/types/MathComponent.ts: -------------------------------------------------------------------------------- 1 | import RecordEntry from "./RecordEntry"; 2 | 3 | type MathComponent = { 4 | value: string; 5 | label: RecordEntry[]; 6 | } 7 | 8 | export default MathComponent; -------------------------------------------------------------------------------- /src/utils/roundDecimals.ts: -------------------------------------------------------------------------------- 1 | export default function roundDecimals(value: number, count: number): number { 2 | const mult = Math.pow(10, count); 3 | return Math.round(value * mult) / mult; 4 | } -------------------------------------------------------------------------------- /src/less/ImportPopup.less: -------------------------------------------------------------------------------- 1 | .import-popup { 2 | > :not(:first-child) { 3 | margin-top: 1em; 4 | } 5 | } 6 | 7 | .import-sep { 8 | border: none; 9 | border-top: 1px solid; 10 | } -------------------------------------------------------------------------------- /src/utils/displayDamage.ts: -------------------------------------------------------------------------------- 1 | import roundDecimals from "./roundDecimals"; 2 | 3 | export default function displayDamage(damage: number): string { 4 | return roundDecimals(damage, 2).toString(); 5 | } -------------------------------------------------------------------------------- /babel.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', {targets: {node: 'current'}}], 4 | ['@babel/preset-react', {runtime: 'automatic'}], 5 | '@babel/preset-typescript', 6 | ], 7 | }; -------------------------------------------------------------------------------- /src/types/DamageData.tsx: -------------------------------------------------------------------------------- 1 | import { EquationOutput } from "./VariableOutput"; 2 | 3 | export default interface DamageData { 4 | nonCrit?: EquationOutput; 5 | crit?: EquationOutput; 6 | avgDmg: EquationOutput; 7 | }; 8 | -------------------------------------------------------------------------------- /src/less/ExportPopup.less: -------------------------------------------------------------------------------- 1 | .export { 2 | > :not(:first-child) { 3 | margin-top: 1em; 4 | } 5 | } 6 | 7 | .export-check { 8 | display: flex; 9 | gap: 0.5em; 10 | align-items: center; 11 | } -------------------------------------------------------------------------------- /src/utils/evalulateExpression.ts: -------------------------------------------------------------------------------- 1 | import Formula from "fparser"; 2 | 3 | export default function evaluateExpression(expr: string): number { 4 | try { 5 | return Formula.calc(expr, {}) as number; 6 | } catch { 7 | return NaN; 8 | } 9 | } -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react-swc' 3 | 4 | // https://vite.dev/config/ 5 | export default defineConfig({ 6 | base: process.env.BASE_URL ?? '', 7 | plugins: [react()], 8 | }) 9 | -------------------------------------------------------------------------------- /src/less/EquationLine.less: -------------------------------------------------------------------------------- 1 | .calc { 2 | padding-top: 0.4em; 3 | 4 | .calc { 5 | border-left: 1px solid #aaa; 6 | margin-left: 0.15ch; 7 | padding-left: calc(1em - 0.15ch); 8 | } 9 | } 10 | 11 | .calc-note { 12 | color: #6dd0fc; 13 | } -------------------------------------------------------------------------------- /src/pages/CalculatorPage.tsx: -------------------------------------------------------------------------------- 1 | import CalculatorForm from "../components/CalculatorForm"; 2 | import Page from "../components/Page"; 3 | 4 | export default function CalculatorPage() { 5 | return 6 | 7 | ; 8 | } -------------------------------------------------------------------------------- /src/components/MathIndicator.tsx: -------------------------------------------------------------------------------- 1 | import '../less/MathIndicator.less'; 2 | 3 | export default function MathIndicator() { 4 | return
5 |
+
×
÷
6 |
; 7 | } -------------------------------------------------------------------------------- /src/types/RecordEntry.ts: -------------------------------------------------------------------------------- 1 | export enum RecordEntryType { 2 | Note = 'note', 3 | Value = 'value', 4 | Mathematical = 'math' 5 | }; 6 | 7 | type RecordEntry = { 8 | value: React.ReactNode; 9 | type: RecordEntryType; 10 | } 11 | 12 | export default RecordEntry; -------------------------------------------------------------------------------- /src/less/RowLabel.less: -------------------------------------------------------------------------------- 1 | .row-label { 2 | display: flex; 3 | align-items: center; 4 | 5 | .has-desc { 6 | text-decoration: underline dotted; 7 | cursor: help; 8 | } 9 | } 10 | 11 | .label-icon svg { 12 | width: 1.15em; 13 | margin-right: 0.35em; 14 | vertical-align: middle; 15 | } -------------------------------------------------------------------------------- /src/less/StatInputRow.less: -------------------------------------------------------------------------------- 1 | .stat-input-row { 2 | display: flex; 3 | align-items: center; 4 | gap: 0.25em; 5 | } 6 | 7 | .sync-btn-cnt { 8 | .svg-button { 9 | padding: 0; 10 | 11 | .sync-stat { 12 | height: 1.25em; 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /src/types/SelectOption.ts: -------------------------------------------------------------------------------- 1 | export type SingleOption = { 2 | name: string; 3 | value?: string; 4 | disabled?: boolean; 5 | style?: React.CSSProperties; 6 | }; 7 | 8 | type SelectOption = SingleOption | { 9 | label: string; 10 | options: SingleOption[]; 11 | }; 12 | 13 | export default SelectOption; -------------------------------------------------------------------------------- /src/less/AttackList.less: -------------------------------------------------------------------------------- 1 | .attack-list-entry { 2 | text-wrap: nowrap; 3 | display: inline-flex; 4 | align-items: flex-start; 5 | gap: 3px; 6 | margin-right: 6px; 7 | 8 | &.active-attack { 9 | text-decoration: underline; 10 | text-underline-offset: 3px; 11 | } 12 | } -------------------------------------------------------------------------------- /src/components/Page.tsx: -------------------------------------------------------------------------------- 1 | const baseTitle = document.title; // Load from index.html 2 | 3 | export default function Page(props: Readonly<{ 4 | title?: string; 5 | children?: React.ReactNode; 6 | }>) { 7 | document.title = props.title ? `${props.title} - ${baseTitle}` : baseTitle; 8 | return props.children; 9 | }; -------------------------------------------------------------------------------- /src/types/StatSectionDefinition.ts: -------------------------------------------------------------------------------- 1 | type StatSectionDefinition = { 2 | name: string; 3 | sub?: boolean; 4 | value: StatSection; 5 | }; 6 | 7 | export const enum StatSection { 8 | Character, 9 | CharacterBase, 10 | CharacterStats, 11 | Enemy 12 | }; 13 | 14 | export default StatSectionDefinition; -------------------------------------------------------------------------------- /src/types/ValueData.ts: -------------------------------------------------------------------------------- 1 | import StatData from "./StatData"; 2 | 3 | export type ValueInfo = { 4 | name: string; 5 | value: number; 6 | }; 7 | 8 | type ValueData = Record; 9 | 10 | export default ValueData; -------------------------------------------------------------------------------- /src/svgs/AddSVG.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react"; 2 | 3 | export default function AddSVG(props: SVGProps) { 4 | return ; 7 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # testing 7 | /coverage 8 | 9 | # build 10 | /dist 11 | tsconfig.tsbuildinfo 12 | 13 | # misc 14 | .DS_Store 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | -------------------------------------------------------------------------------- /src/svgs/CloseSVG.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react"; 2 | 3 | export default function CloseSVG(props: SVGProps) { 4 | return ; 7 | } -------------------------------------------------------------------------------- /src/types/DamageGroups.ts: -------------------------------------------------------------------------------- 1 | const enum DamageGroup { 2 | None = 0, 3 | Talent = 1 << 0, 4 | Level = 1 << 1, 5 | Reaction = 1 << 2, 6 | SecondaryReaction = 1 << 3, 7 | Transformative = 1 << 4, 8 | NonTransformative = 1 << 5, 9 | Crit = 1 << 6, 10 | TransformativeCrit = 1 << 7, 11 | All = (1 << 8) - 1, 12 | } 13 | 14 | export default DamageGroup; -------------------------------------------------------------------------------- /src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import '../less/Footer.less'; 2 | 3 | export function Footer() { 4 | return
5 |
6 | Calculate in-game character damage for Genshin Impact based off of numeric stat values. 7 |
8 |
9 | Source code 10 |
11 |
; 12 | } -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "jest"; 2 | 3 | const config: Config = { 4 | testEnvironment: 'node', 5 | moduleNameMapper: { 6 | 'csv-stringify/browser/esm/sync': '/node_modules/csv-stringify/dist/cjs/sync.cjs', 7 | 'csv-parse/browser/esm/sync': '/node_modules/csv-parse/dist/cjs/sync.cjs' 8 | }, 9 | transformIgnorePatterns: [] 10 | }; 11 | 12 | export default config; -------------------------------------------------------------------------------- /src/types/VariableOutput.ts: -------------------------------------------------------------------------------- 1 | import RecordEntry from "./RecordEntry"; 2 | 3 | export type EquationOutput = { 4 | value: number; 5 | label: RecordEntry[]; 6 | annotated: RecordEntry[]; 7 | children: Record; 8 | fullRawExpr: string; 9 | } 10 | 11 | type VariableOutput = { 12 | value: number; 13 | label: RecordEntry[]; 14 | } | EquationOutput; 15 | 16 | export default VariableOutput; -------------------------------------------------------------------------------- /src/svgs/SearchSVG.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react"; 2 | 3 | export default function SearchSVG(props: SVGProps) { 4 | return ; 8 | } -------------------------------------------------------------------------------- /src/less/GameImportArea.less: -------------------------------------------------------------------------------- 1 | .stat-import { 2 | > * { 3 | margin-top: 1em; 4 | } 5 | 6 | .flex-row { 7 | display: flex; 8 | gap: 0.5em; 9 | align-items: center; 10 | } 11 | 12 | .notice { 13 | width: 0; 14 | flex: 1; 15 | } 16 | 17 | .nickname-row { 18 | svg, 19 | img { 20 | height: 1.4em; 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /src/less/popup.less: -------------------------------------------------------------------------------- 1 | .popup-content { 2 | background: #555; 3 | border: none; 4 | font-size: 0.9em; 5 | line-height: 1.35; 6 | 7 | &[role="dialog"] { 8 | width: max-content; 9 | max-width: 90vw; 10 | height: max-content; 11 | max-height: 90vh; 12 | overflow: auto; 13 | padding: 1em; 14 | border-radius: 8px; 15 | background: #333; 16 | } 17 | } 18 | 19 | .popup-arrow { 20 | color: #555; 21 | stroke: none; 22 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Numeric Genshin Damage Calculator 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/components/AsyncContent.ts: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | 3 | export default function AsyncContent(props: Readonly<{ 4 | promise: Promise; 5 | }>) { 6 | const [content, setContent] = React.useState(); 7 | 8 | useEffect( 9 | () => { 10 | (async () => setContent(await props.promise))() 11 | }, 12 | [props.promise] 13 | ); 14 | 15 | return content ?? ''; 16 | } -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App'; 4 | import { BrowserRouter } from "react-router"; 5 | 6 | const root = ReactDOM.createRoot( 7 | document.getElementById('root') as HTMLElement 8 | ); 9 | 10 | root.render( 11 | 12 | 13 | 14 | 15 | 16 | ); -------------------------------------------------------------------------------- /src/svgs/LoadSVG.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react"; 2 | 3 | export default function LoadSVG(props: SVGProps) { 4 | return ; 9 | } -------------------------------------------------------------------------------- /src/utils/Damage.ts: -------------------------------------------------------------------------------- 1 | import DamageData from "../types/DamageData"; 2 | import { EquationOutput } from "../types/VariableOutput"; 3 | 4 | export default class Damage implements DamageData { 5 | constructor( 6 | public readonly avgDmg: EquationOutput, 7 | public readonly crit?: EquationOutput, 8 | public readonly nonCrit?: EquationOutput 9 | ) {} 10 | 11 | getWithDefault(prop: keyof DamageData): EquationOutput { 12 | return this[prop] ?? this.avgDmg; 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /src/svgs/SaveSVG.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react"; 2 | 3 | export default function SaveSVG(props: SVGProps) { 4 | return ; 9 | } -------------------------------------------------------------------------------- /src/svgs/CopySVG.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react"; 2 | 3 | export default function CopySVG(props: SVGProps) { 4 | return ; 8 | } -------------------------------------------------------------------------------- /src/types/PartialAttack.ts: -------------------------------------------------------------------------------- 1 | import StatData from "./StatData"; 2 | 3 | interface PartialAttack { 4 | reactionType?: number; 5 | reaction?: number; 6 | secondaryType?: number; 7 | secondary?: number; 8 | label?: string; 9 | statData?: Partial>; 10 | synced?: (keyof StatData)[]; 11 | unmodified?: boolean; 12 | } 13 | 14 | export interface StoredAttack extends PartialAttack { 15 | shown?: boolean; 16 | group?: number; 17 | } 18 | 19 | export default PartialAttack; -------------------------------------------------------------------------------- /src/svgs/ImportSVG.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react"; 2 | 3 | export default function ImportSVG(props: SVGProps) { 4 | return ; 11 | } -------------------------------------------------------------------------------- /src/svgs/HelpSVG.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react"; 2 | 3 | export default function HelpSVG(props: SVGProps) { 4 | return ; 9 | } 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/components/HeadingRow.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type HeadingRowProps = { 4 | title: string; 5 | level?: number; 6 | span: number; 7 | className?: string; 8 | }; 9 | 10 | export default React.forwardRef((props, ref) => { 11 | const Tag = `h${props.level ?? 2}` as 'h2'; 12 | 13 | return 19 | {props.title} 20 | ; 21 | }); -------------------------------------------------------------------------------- /src/utils/damageTypes.ts: -------------------------------------------------------------------------------- 1 | import DisplayedProp from "../types/DisplayedProp"; 2 | import DamageData from "../types/DamageData"; 3 | 4 | const damageTypes: DisplayedProp[] = [ 5 | { 6 | name: "CRIT Hit", 7 | prop: "crit" 8 | }, 9 | { 10 | name: "Non-CRIT Hit", 11 | prop: "nonCrit" 12 | }, 13 | { 14 | name: "Average DMG", 15 | desc: "The average DMG done between crit and non-crit hits. This exact number will not be done in-game.", 16 | prop: "avgDmg" 17 | } 18 | ]; 19 | 20 | export default damageTypes; -------------------------------------------------------------------------------- /src/components/EquationLine.tsx: -------------------------------------------------------------------------------- 1 | import { EquationOutput } from "../types/VariableOutput"; 2 | import '../less/EquationLine.less'; 3 | 4 | export default function EquationLine(props: Readonly<{ 5 | equation: EquationOutput 6 | }>) { 7 | return
8 | {props.equation.annotated.map((entry, i) => 9 | {entry.value} 10 | )} 11 | {Object.entries(props.equation.children).map(([key, row]) => 12 | 13 | )} 14 |
15 | } -------------------------------------------------------------------------------- /src/less/CalculationPopup.less: -------------------------------------------------------------------------------- 1 | .calc-popup-content { 2 | max-width: min(90vw, 1200px) !important; 3 | } 4 | 5 | .calc-popup-row { 6 | padding-top: 0.4em; 7 | 8 | &.top-row { 9 | display: flex; 10 | align-items: center; 11 | flex-wrap: wrap; 12 | column-gap: 1em; 13 | row-gap: 0.5em; 14 | 15 | &.expanded { 16 | flex-direction: column; 17 | align-items: start; 18 | } 19 | } 20 | } 21 | 22 | .toggle-label { 23 | display: flex; 24 | align-items: center; 25 | gap: 0.5em; 26 | } 27 | 28 | .toggle-label input { 29 | margin: 0; 30 | } -------------------------------------------------------------------------------- /src/utils/attributes.ts: -------------------------------------------------------------------------------- 1 | import StatData from "../types/StatData"; 2 | 3 | const attributes = ['ATK', 'DEF', 'HP', 'EM'] as const; 4 | 5 | /** 6 | * Get the name of the property for the stat that corresponds to the given type. 7 | * @param prop The base stat's property. 8 | * @param attr The attribute. 9 | * @returns The name of the property. 10 | */ 11 | export function getAttrStat(prop: keyof StatData, attr: typeof attributes[number]) { 12 | return prop + (attr === 'ATK' ? '' : attr) as keyof StatData; 13 | } 14 | 15 | export default attributes; -------------------------------------------------------------------------------- /src/utils/statSections.ts: -------------------------------------------------------------------------------- 1 | import StatSectionDefinition, { StatSection } from "../types/StatSectionDefinition"; 2 | 3 | const statSections: StatSectionDefinition[] = [ 4 | { 5 | name: 'Character', 6 | value: StatSection.Character 7 | }, 8 | { 9 | name: 'Base', 10 | sub: true, 11 | value: StatSection.CharacterBase 12 | }, 13 | { 14 | name: 'Stats', 15 | sub: true, 16 | value: StatSection.CharacterStats 17 | }, 18 | { 19 | name: 'Enemy', 20 | value: StatSection.Enemy 21 | } 22 | ]; 23 | 24 | export default statSections; -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: '18' 23 | 24 | - name: Install dependencies 25 | run: npm install 26 | 27 | - name: Run tests 28 | run: npm test 29 | -------------------------------------------------------------------------------- /src/types/ImportedCharacter.ts: -------------------------------------------------------------------------------- 1 | import elements from "../utils/elements"; 2 | 3 | export interface EnkaBuild { 4 | avatarId: number; 5 | propMap: Record; 6 | fightPropMap: Record; 7 | } 8 | 9 | export interface EnkaShown { 10 | energyType: number; 11 | } 12 | 13 | export interface ImportedIdentity { 14 | name: string; 15 | icon: Promise; 16 | element: typeof elements[number]; 17 | } 18 | 19 | type ImportedCharacter = EnkaBuild & ImportedIdentity; 20 | 21 | export default ImportedCharacter; -------------------------------------------------------------------------------- /src/less/ReactionDesc.less: -------------------------------------------------------------------------------- 1 | .reaction-desc { 2 | .popup-content & { 3 | overflow: auto; 4 | max-height: 50vh; 5 | padding: 4px; 6 | } 7 | 8 | div + div { 9 | margin-top: 0.5em; 10 | } 11 | 12 | h2 { 13 | font-size: 110%; 14 | margin: 0; 15 | margin-top: 1em; 16 | } 17 | 18 | table { 19 | .popup-content & { 20 | width: 100%; 21 | } 22 | 23 | border-collapse: collapse; 24 | margin-top: 0.5em; 25 | 26 | th { 27 | text-align: left; 28 | } 29 | 30 | th, td { 31 | padding: 2px 4px; 32 | border: 1px solid white; 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /src/svgs/DeleteSVG.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react"; 2 | 3 | export default function DeleteSVG(props: SVGProps) { 4 | return ; 11 | } -------------------------------------------------------------------------------- /src/svgs/ExportSVG.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react"; 2 | 3 | export default function ExportSVG(props: SVGProps) { 4 | return ; 12 | } -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import "./less/index.less"; 2 | import "reactjs-popup/dist/index.css"; 3 | import "./less/popup.less"; 4 | import { Footer } from "./components/Footer"; 5 | import { Route, Routes } from "react-router"; 6 | import HelpPage from "./pages/HelpPage"; 7 | import CalculatorPage from "./pages/CalculatorPage"; 8 | 9 | export default function App() { 10 | return
11 |

Numeric Genshin Damage Calculator

12 | 13 | 14 | 15 | 16 |
17 |
; 18 | } 19 | -------------------------------------------------------------------------------- /src/svgs/SyncSVG.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react"; 2 | 3 | export default function SyncSVG(props: { noSync?: boolean; } & SVGProps) { 4 | const domProps = {...props}; 5 | delete domProps.noSync; 6 | 7 | return 12 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx", 22 | "downlevelIteration": true 23 | }, 24 | "include": [ 25 | "src" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /src/components/PopupHeader.tsx: -------------------------------------------------------------------------------- 1 | import SVGButton from "./SVGButton"; 2 | import CloseSVG from "../svgs/CloseSVG"; 3 | import { PopupActions } from "reactjs-popup/dist/types"; 4 | import "../less/PopupHeader.less"; 5 | import React, { RefObject } from "react"; 6 | 7 | type PopupHederProps = { 8 | title: string; 9 | }; 10 | 11 | export default React.forwardRef((props, ref) => 12 |
13 |

{props.title}

14 | } 16 | label="Close" 17 | hideLabel={true} 18 | onClick={() => (ref as RefObject).current?.close()} 19 | /> 20 |
21 | ); -------------------------------------------------------------------------------- /src/components/SelectOptions.tsx: -------------------------------------------------------------------------------- 1 | import SelectOption from '../types/SelectOption'; 2 | import '../less/FormInput.less'; 3 | 4 | export default function SelectOptions(props: Readonly<{ options: SelectOption[] }>) { 5 | return <>{props.options.map(option => { 6 | if ('label' in option) 7 | return 8 | 9 | 10 | 11 | return ; 19 | })}; 20 | }; -------------------------------------------------------------------------------- /src/less/SVGButton.less: -------------------------------------------------------------------------------- 1 | .svg-button { 2 | color: #fff; 3 | background: none; 4 | padding: 4px; 5 | margin: 0; 6 | border: none; 7 | cursor: pointer; 8 | font-size: unset; 9 | border-radius: 4px; 10 | 11 | &-mini { 12 | padding: 0; 13 | } 14 | 15 | &:disabled { 16 | opacity: 0.75; 17 | } 18 | 19 | &:hover, 20 | &:focus { 21 | background: #444; 22 | } 23 | 24 | &-mini:hover, 25 | &-mini:focus, 26 | &:disabled:hover, 27 | &:disabled:focus { 28 | background: none; 29 | } 30 | 31 | img, 32 | svg { 33 | height: 1.4em; 34 | } 35 | 36 | &-mini img, 37 | &-mini svg { 38 | height: 1em; 39 | } 40 | 41 | * { 42 | vertical-align: middle; 43 | } 44 | } -------------------------------------------------------------------------------- /src/utils/elements.ts: -------------------------------------------------------------------------------- 1 | const elements = ['Physical', 'Pyro', 'Electro', 'Hydro', 'Dendro', 'Anemo', 'Cyro', 'Geo'] as const; 2 | 3 | export default elements; 4 | 5 | export const elementColors: Record = { 6 | Physical: '#ffffff', 7 | Pyro: '#ff9b00', 8 | Electro: '#e19bff', 9 | Hydro: '#33ccff', 10 | Dendro: '#00ea53', 11 | Anemo: '#66ffcc', 12 | Geo: '#ffcc66', 13 | Cyro: '#99ffff' 14 | } 15 | 16 | export const energyTypeElementMap: Record= { 17 | 1: 'Pyro', 18 | 2: 'Hydro', 19 | 3: 'Dendro', 20 | 4: 'Electro', 21 | 5: 'Cyro', 22 | 7: 'Anemo', 23 | 8: 'Geo' 24 | }; -------------------------------------------------------------------------------- /src/components/DifferenceOutput.tsx: -------------------------------------------------------------------------------- 1 | import Popup from "reactjs-popup"; 2 | 3 | export default function DifferenceOutput(props: Readonly<{ 4 | initial?: number; 5 | value?: number 6 | }>) { 7 | if (!props.initial || !props.value) 8 | return null; 9 | 10 | let number = Math.round((props.value - props.initial) / props.initial * 100); 11 | let className = number > 0 ? 'pos' : number < 0 ? 'neg' : ''; 12 | 13 | return 16 | {(number <= 0 ? '' : '+') + number + '%'} 17 | 18 | } 19 | position={['top center', 'bottom center', 'top right']} 20 | on={'hover'} 21 | arrow={true} 22 | > 23 | Difference verses first column 24 | ; 25 | } -------------------------------------------------------------------------------- /src/less/CalculatorForm.less: -------------------------------------------------------------------------------- 1 | .form-section { 2 | max-width: 100%; 3 | } 4 | 5 | .form-top { 6 | display: flex; 7 | justify-content: right; 8 | gap: 6px; 9 | padding: 0 1em; 10 | margin-bottom: 0.2em; 11 | } 12 | 13 | .grid-container { 14 | flex: 1; 15 | min-width: min(40em, 50vw); 16 | background: #2d2d2d; 17 | max-width: 100%; 18 | border-radius: 8px; 19 | padding: 1em; 20 | text-align: left; 21 | } 22 | 23 | .grid { 24 | display: grid; 25 | align-items: flex-start; 26 | row-gap: 8px; 27 | column-gap: 1em; 28 | max-width: 100%; 29 | overflow: auto; 30 | 31 | h2, h3 { 32 | margin: 8px 0 0 0; 33 | } 34 | 35 | h2 { 36 | font-size: 1.5em; 37 | } 38 | 39 | h3 { 40 | font-size: 1.25em; 41 | } 42 | } -------------------------------------------------------------------------------- /src/less/index.less: -------------------------------------------------------------------------------- 1 | :root { 2 | color-scheme: dark; 3 | } 4 | 5 | * { 6 | box-sizing: border-box; 7 | } 8 | 9 | body { 10 | font-family: sans-serif; 11 | background: #222; 12 | color: #fff; 13 | margin: 8px; 14 | height: unset; 15 | } 16 | 17 | main { 18 | margin: auto; 19 | font-size: 0.9em; 20 | text-align: center; 21 | } 22 | 23 | a { 24 | color: #4fcbf5; 25 | } 26 | 27 | a:visited { 28 | color: #eb83eb; 29 | } 30 | 31 | .center-items { 32 | display: flex; 33 | flex-direction: column; 34 | align-items: center; 35 | } 36 | 37 | .pos { 38 | color: #00d200; 39 | } 40 | 41 | .neg { 42 | color: #f33f3f; 43 | } 44 | 45 | select:focus, 46 | button:focus, 47 | textarea:focus, 48 | input:focus { 49 | outline: revert !important; 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/topDescs.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import ReactionDesc from "../components/ReactionDesc"; 3 | 4 | export const topDescs = new Map([ 5 | [ 6 | 'Label', 7 | 'A cosmetic name for the column. Makes it easier to identify saved columns.' 8 | ], 9 | [ 10 | 'Attack', 11 | 'Individual damage instances that contribute to the final calculated damage.' 12 | ], 13 | [ 14 | 'Reaction', 15 | 16 | ], 17 | [ 18 | 'Secondary Reaction', 19 | 'When reactions apply an element to adjacent entities, those applications can trigger reactions. The base damage is the damage of the original reaction. For triggered transformative reactions, add a separate attack instead.' 20 | ], 21 | ]); -------------------------------------------------------------------------------- /src/components/RowLabel.tsx: -------------------------------------------------------------------------------- 1 | import Popup from "reactjs-popup"; 2 | import '../less/RowLabel.less'; 3 | 4 | export default function RowLabel(props: Readonly<{ 5 | label: React.ReactNode; 6 | desc?: React.ReactNode; 7 | wide?: boolean; 8 | icon?: React.ReactNode; 9 | }>) { 10 | return
11 | {props.icon} 12 | {props.desc 13 | ? <>{props.label}} 15 | position={['top center', 'bottom center', 'top right', 'bottom right']} 16 | on={['hover', 'focus']} 17 | arrow 18 | contentStyle={props.wide ? { width: '350px', maxWidth: 'calc(100vw - 3em)' } : undefined } 19 | > 20 | {props.desc} 21 | 22 | : {props.label} 23 | } 24 |
; 25 | } -------------------------------------------------------------------------------- /src/components/AttacksExpr.tsx: -------------------------------------------------------------------------------- 1 | import DamageData from "../types/DamageData"; 2 | import Attack from "../utils/Attack"; 3 | 4 | const colors = ['#6dd0fc', '#fc6d85', '#befc6d']; 5 | 6 | export default function AttacksExpr(props: Readonly<{ 7 | attacks: Attack[]; 8 | prop: keyof DamageData; 9 | }>) { 10 | return props.attacks.map((attack, i) => { 11 | let level = 0; 12 | 13 | return [ 14 | i > 0 ? ' + ' : '', 15 | attack.damage.getWithDefault(props.prop).fullRawExpr 16 | .replace(/\*/g, '×') 17 | .split(/([()])/g) 18 | .map((m, j) => { 19 | if (m !== '(' && m !== ')') 20 | return {m}; 21 | 22 | if (m === ')') level--; 23 | const out = {m}; 24 | if (m === '(') level++; 25 | 26 | return out; 27 | }) 28 | ]; 29 | }).flat(); 30 | } 31 | -------------------------------------------------------------------------------- /src/components/LabelRow.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Column from "../utils/Column"; 3 | import FormInput from "./FormInput"; 4 | import RowLabel from "./RowLabel"; 5 | import { ColumnStateAction } from "../types/ColumnState"; 6 | import { topDescs } from "../utils/topDescs"; 7 | 8 | export default function LabelRow(props: Readonly<{ 9 | columns: Column[]; 10 | dispatch: React.Dispatch; 11 | }>) { 12 | return <> 13 | 14 | {props.columns.map(column => props.dispatch({ 18 | type: 'modifyAttack', 19 | colId: column.id, 20 | atkId: column.first.id, 21 | modifier: attack => { 22 | attack.label = value; 23 | } 24 | })} 25 | />)} 26 | ; 27 | } -------------------------------------------------------------------------------- /src/less/HelpPage.less: -------------------------------------------------------------------------------- 1 | .help-page { 2 | line-height: 1.5em; 3 | max-width: 800px; 4 | text-align: start; 5 | margin-bottom: 2em; 6 | font-size: 1rem; 7 | 8 | nav { 9 | margin-top: 1em; 10 | 11 | ol { 12 | display: inline-flex; 13 | list-style: none; 14 | gap: 0.5em; 15 | margin: 0; 16 | padding: 0; 17 | padding-left: 0.5em; 18 | flex-wrap: wrap; 19 | } 20 | } 21 | 22 | h2 { 23 | margin-top: 0; 24 | padding-top: 4px; 25 | } 26 | 27 | & > h3 { 28 | margin: 2em 0 1em; 29 | padding-bottom: 0.5em; 30 | border-bottom: 1px solid white; 31 | } 32 | 33 | dl { 34 | display: grid; 35 | grid-template-columns: minmax(min-content, 20%) auto; 36 | grid-column-gap: 1em; 37 | grid-row-gap: 0.5em; 38 | margin-bottom: 1em; 39 | 40 | dt, 41 | dd { 42 | margin: 0; 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/components/SVGButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import '../less/SVGButton.less'; 3 | 4 | type SVGButtonProps = { 5 | svg?: React.ReactNode; 6 | label: string; 7 | onClick?: () => any; 8 | hideLabel?: boolean; 9 | mini?: boolean; 10 | disabled?: boolean; 11 | title?: string; 12 | style?: React.CSSProperties; 13 | }; 14 | 15 | export default React.forwardRef((props, ref) => 16 | 30 | ); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Numeric Genshin Damage Calculator 2 | 3 | This React app allows you to easily calculate in-game character damage for Genshin Impact based off of numeric stat values. 4 | 5 | The live version of the app is located at . 6 | 7 | ## Running 8 | 9 | Install dependencies using `npm install`. Launch the app in development mode using `npm run start` and produce a build of the app using `npm run build`. 10 | 11 | ## Environment Variables 12 | 13 | Environment variables are defined in `.env` and can be overridden locally in the `.env.local` file see [Vite's documentation](https://vite.dev/guide/env-and-mode#env-files) for more info. 14 | 15 | * `VITE_ENKA_PROXY` - The prefix to use for requests to Enka.Network API to work around CORS. The proxy should send requests to `https://enka.network/api/[REQUEST_PATH]` By default this is . 16 | -------------------------------------------------------------------------------- /src/components/DamageOutput.tsx: -------------------------------------------------------------------------------- 1 | import CalculationPopup from "./CalculationPopup"; 2 | import DifferenceOutput from "./DifferenceOutput"; 3 | import '../less/DamageOutput.less'; 4 | import displayDamage from "../utils/displayDamage"; 5 | import Column from "../utils/Column"; 6 | import DisplayedProp from "../types/DisplayedProp"; 7 | import DamageData from "../types/DamageData"; 8 | 9 | export default function DamageOutput(props: Readonly<{ 10 | column: Column; 11 | displayedProp: DisplayedProp; 12 | value: number; 13 | error?: boolean; 14 | initial?: number; 15 | }>) { 16 | return
17 | 18 | {' '} 19 | {props.error ? 'ERROR' : displayDamage(props.value)} 20 | {' '} 21 | 22 |
23 | } -------------------------------------------------------------------------------- /src/types/Stat.ts: -------------------------------------------------------------------------------- 1 | import attributes from "../utils/attributes"; 2 | import elements from "../utils/elements"; 3 | import DamageGroup from "./DamageGroups"; 4 | import DisplayedProp from "./DisplayedProp"; 5 | import StatData from "./StatData"; 6 | import { StatSection } from "./StatSectionDefinition"; 7 | 8 | type MapInfo = 9 | | { 10 | map: 'char'; 11 | mapNumber: number; 12 | } 13 | | { 14 | map: 'fight'; 15 | mapNumber: number | Record; 16 | }; 17 | 18 | type Stat = DisplayedProp & (MapInfo | {}) & { 19 | attr?: typeof attributes[number]; 20 | default: number; 21 | type: StatType; 22 | section: StatSection; 23 | groups?: DamageGroup; 24 | /** 25 | * True if the stat is a series of multipliers that are multiplied with in-game attributes (ATK etc.) 26 | */ 27 | usesAttrs?: boolean; 28 | icon: React.ReactNode; 29 | }; 30 | 31 | export const enum StatType { 32 | Number, 33 | Percent, 34 | Seconds 35 | } 36 | 37 | export default Stat; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Brandon Fowler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/AttackList.tsx: -------------------------------------------------------------------------------- 1 | import SVGButton from "./SVGButton"; 2 | import DeleteSVG from "../svgs/DeleteSVG"; 3 | import '../less/AttackList.less'; 4 | import Attack from "../utils/Attack"; 5 | 6 | export default function AttackList(props: Readonly<{ 7 | attacks: Attack[]; 8 | activeIndex: number; 9 | setActive: (id: number, index: number) => void; 10 | deleteAttack?: (id: number) => void; 11 | }>) { 12 | return <> 13 | {props.attacks.map((attack, i) => 17 | props.setActive(attack.id, i)} 21 | /> 22 | {props.deleteAttack && props.attacks.length > 1 && } 24 | label="Delete Attack" 25 | mini 26 | hideLabel 27 | onClick={() => props.deleteAttack!(attack.id)} 28 | />} 29 | )} 30 | ; 31 | } -------------------------------------------------------------------------------- /src/components/TopButtonRow.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import SVGButton from "./SVGButton"; 3 | import AddSVG from "../svgs/AddSVG"; 4 | import LoadSavedPopup from "./LoadSavedPopup"; 5 | import '../less/TopButtonRow.less'; 6 | import ImportPopup from "./ImportPopup"; 7 | import ExportPopup from "./ExportPopup"; 8 | import ColumnState, { ColumnStateAction } from '../types/ColumnState'; 9 | import HelpSVG from '../svgs/HelpSVG'; 10 | import { Link } from 'react-router'; 11 | 12 | export default function TopButtonRow(props: Readonly<{ 13 | state: ColumnState; 14 | dispatch: React.Dispatch; 15 | }>) { 16 | return
17 | 18 | } 20 | label="Help" 21 | title="Form Information and Help" 22 | /> 23 | 24 | } 26 | label="Add" 27 | title="Add Column" 28 | onClick={() => props.dispatch({type: 'addEmpty'})} 29 | /> 30 | 31 | 32 | 33 |
; 34 | } -------------------------------------------------------------------------------- /src/types/StatData.ts: -------------------------------------------------------------------------------- 1 | type StatData = { 2 | baseTalentScale?: string; 3 | additionalBonusTalentScale?: string; 4 | bonusTalentScale?: string; 5 | baseDEF?: string; 6 | additionalBonusDEF?: string; 7 | bonusDEF?: string; 8 | baseHP?: string; 9 | additionalBonusHP?: string; 10 | bonusHP?: string; 11 | baseEM?: string; 12 | additionalBonusEM?: string; 13 | bonusEM?: string; 14 | talent?: string; 15 | talentDEF?: string; 16 | talentHP?: string; 17 | talentEM?: string; 18 | talentDamageBonus?: string; 19 | talentDamageBonusDEF?: string; 20 | talentDamageBonusHP?: string; 21 | talentDamageBonusEM?: string; 22 | baseDamageMultiplier: string; 23 | flatDamage: string; 24 | extraRxnDMG: string; 25 | damageBonus: string; 26 | em: string; 27 | characterLevel: string; 28 | reactionBonus: string; 29 | secondaryReactionBonus: string; 30 | critDamage: string; 31 | critRate: string; 32 | critDamageTransformative: string; 33 | critRateTransformative: string; 34 | enemyLevel: string; 35 | defenseIgnore: string; 36 | defenseDecrease: string; 37 | resistanceReduction: string; 38 | resistance: string; 39 | bowAimedTravelTime: string; 40 | }; 41 | 42 | export default StatData; 43 | -------------------------------------------------------------------------------- /src/components/RemoveColumnRow.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import DeleteSVG from "../svgs/DeleteSVG"; 3 | import SaveSVG from "../svgs/SaveSVG"; 4 | import Column from "../utils/Column"; 5 | import SVGButton from "./SVGButton"; 6 | import CopySVG from "../svgs/CopySVG"; 7 | import { ColumnStateAction } from "../types/ColumnState"; 8 | 9 | export default function RemoveColumnRow(props: Readonly<{ 10 | columns: Column[]; 11 | dispatch: React.Dispatch; 12 | }>) { 13 | return <> 14 | {props.columns.map(column => 15 |
16 | } 18 | label="Save and Close Column" 19 | hideLabel={true} 20 | onClick={() => props.dispatch({type: 'unload', colId: column.id})} 21 | /> 22 | } 24 | label="Duplicate Column" 25 | hideLabel={true} 26 | onClick={() => props.dispatch({type: 'duplicate', colId: column.id})} 27 | /> 28 | } 30 | label="Delete Column" 31 | hideLabel={true} 32 | onClick={() => props.dispatch({type: 'remove', colId: column.id})} 33 | /> 34 |
35 | )} 36 | ; 37 | } -------------------------------------------------------------------------------- /src/components/StatInput.tsx: -------------------------------------------------------------------------------- 1 | import Stat, { StatType } from "../types/Stat"; 2 | import FormInput from "./FormInput"; 3 | import MathIndicator from "./MathIndicator"; 4 | import SelectOption from '../types/SelectOption'; 5 | 6 | const backIcons: Partial> = { 7 | [StatType.Percent]:
%
, 8 | [StatType.Seconds]:
secs
9 | }; 10 | 11 | const backIconLabels: Partial> = { 12 | [StatType.Percent]: 'Percent', 13 | [StatType.Seconds]: 'Seconds' 14 | }; 15 | 16 | export default function StatInput(props: Readonly<{ 17 | stat: Stat; 18 | value: string; 19 | onChange: (val: string) => void; 20 | disabled?: boolean; 21 | unit?: string; 22 | unitOptions?: SelectOption[]; 23 | onUnitChange?: (val: string) => void; 24 | }>) { 25 | return } 30 | backIcon={backIcons[props.stat.type]} 31 | expandIconLabel="Equation, Click to Expand" 32 | backIconLabel={backIconLabels[props.stat.type]} 33 | unit={props.unit} 34 | unitOptions={props.unitOptions} 35 | onUnitChange={props.onUnitChange} 36 | />; 37 | } -------------------------------------------------------------------------------- /src/components/CalculatorSection.tsx: -------------------------------------------------------------------------------- 1 | import Column from "../utils/Column"; 2 | import StatSectionDefinition from "../types/StatSectionDefinition"; 3 | import stats from "../utils/stats"; 4 | import HeadingRow from "./HeadingRow"; 5 | import StatInputRow from "./StatInputRow"; 6 | import { useEffect, useRef } from "react"; 7 | import { ColumnStateAction } from "../types/ColumnState"; 8 | 9 | export default function CalculatorSection(props: Readonly<{ 10 | section: StatSectionDefinition, 11 | headerSpan: number, 12 | columns: Column[], 13 | dispatch: React.Dispatch; 14 | }>) { 15 | const heading = useRef(null); 16 | const headingLevel = props.section.sub ? 3 : 2; 17 | 18 | useEffect(() => { 19 | const levelAttr = heading.current!.nextElementSibling?.getAttribute('data-level'); 20 | 21 | heading.current!.style.display = levelAttr && parseInt(levelAttr) <= headingLevel ? 'none' : ''; 22 | }); 23 | 24 | return <> 25 | 26 | {stats.filter(stat => stat.section === props.section.value).map(stat => 27 | 28 | )} 29 | ; 30 | } -------------------------------------------------------------------------------- /src/components/DamageOutputRow.tsx: -------------------------------------------------------------------------------- 1 | import DisplayedProp from "../types/DisplayedProp"; 2 | import Column from "../utils/Column"; 3 | import DamageOutput from "./DamageOutput"; 4 | import RowLabel from "./RowLabel"; 5 | import DamageData from "../types/DamageData"; 6 | 7 | export default function DamageOutputRow(props: Readonly<{ 8 | damageType: DisplayedProp; 9 | columns: Column[]; 10 | }>) { 11 | let initial: number | undefined; 12 | let hasValues = false; 13 | 14 | let damageOutputs = props.columns.map((column, i) => { 15 | const { damage, hadError, anyWithValue } = column.sumDamage(props.damageType.prop); 16 | 17 | if (i === 0) { 18 | initial = damage; 19 | } 20 | 21 | if (!anyWithValue) { 22 | return
; 23 | } 24 | 25 | hasValues = true; 26 | 27 | return ; 35 | }); 36 | 37 | if (!hasValues) return null; 38 | 39 | return <> 40 | 41 | {damageOutputs} 42 | ; 43 | } -------------------------------------------------------------------------------- /src/types/ColumnState.ts: -------------------------------------------------------------------------------- 1 | import Attack from "../utils/Attack"; 2 | import Column from "../utils/Column"; 3 | import ColumnList from "../utils/ColumnList"; 4 | import elements from "../utils/elements"; 5 | import ImportedCharacter from "./ImportedCharacter"; 6 | 7 | interface ColumnState { 8 | shown: ColumnList; 9 | closed: ColumnList; 10 | } 11 | 12 | export type ColumnStateAction = { 13 | type: 'add', 14 | columns: Column[] 15 | } | { 16 | type: 'addEmpty' 17 | } | { 18 | type: 'duplicate', 19 | colId: number 20 | } | { 21 | type: 'remove', 22 | colId: number 23 | } | { 24 | type: 'load', 25 | colId: number 26 | } | { 27 | type: 'unload', 28 | colId: number 29 | } | { 30 | type: 'import', 31 | build: ImportedCharacter, 32 | element: typeof elements[number] | '' 33 | } | { 34 | type: 'setActiveAttack', 35 | colId: number, 36 | atkId: number, 37 | } | { 38 | type: 'addAttackFromBase', 39 | colId: number, 40 | attack: Attack, 41 | } | { 42 | type: 'removeAttack', 43 | colId: number, 44 | atkId: number 45 | } | { 46 | type: 'modifyAttack', 47 | colId: number, 48 | atkId: number, 49 | modifier: (column: Attack) => void 50 | } | { 51 | type: 'modifyAttacks', 52 | colId: number, 53 | modifier: (column: Attack[]) => void 54 | }; 55 | 56 | export default ColumnState; -------------------------------------------------------------------------------- /src/components/LoadSavedPopup.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import Popup from "reactjs-popup"; 3 | import { PopupActions } from "reactjs-popup/dist/types"; 4 | import SVGButton from "./SVGButton"; 5 | import '../less/LoadSavedPopup.less'; 6 | import LoadSVG from "../svgs/LoadSVG"; 7 | import Column from "../utils/Column"; 8 | import PopupHeader from "./PopupHeader"; 9 | import { ColumnStateAction } from "../types/ColumnState"; 10 | 11 | export default function LoadSavedPopup(props: Readonly<{ 12 | closedColumns: Column[]; 13 | dispatch: React.Dispatch; 14 | }>) { 15 | const ref = React.useRef(null); 16 | 17 | useEffect(() => { 18 | if (ref.current && !props.closedColumns.length) 19 | ref.current.close(); 20 | }); 21 | 22 | return } 25 | label="Load" 26 | title="Load Saved Column" 27 | disabled={!props.closedColumns.length} 28 | /> 29 | } ref={ref} modal> 30 | 31 |
32 | {props.closedColumns.map((column, i) =>
33 | props.dispatch({ 36 | type: 'load', 37 | colId: column.id 38 | })} 39 | /> 40 |
)} 41 |
42 |
43 | } -------------------------------------------------------------------------------- /src/less/FormInput.less: -------------------------------------------------------------------------------- 1 | .input-mixin() { 2 | background: #4a4a4a; 3 | border: none; 4 | color: white; 5 | margin: 0; 6 | } 7 | 8 | .form-input, 9 | .form-width { 10 | width: 12em; 11 | max-width: 40vw; 12 | 13 | .wide-inputs & { 14 | width: 14em; 15 | } 16 | } 17 | 18 | .form-input { 19 | display: flex; 20 | 21 | &.adaptive-width { 22 | width: unset; 23 | } 24 | 25 | > :not(.mini-select) { 26 | padding-left: 4px; 27 | padding-right: 4px; 28 | } 29 | 30 | > :first-child { 31 | border-top-left-radius: 4px; 32 | border-bottom-left-radius: 4px; 33 | } 34 | 35 | > :last-child { 36 | border-top-right-radius: 4px; 37 | border-bottom-right-radius: 4px; 38 | } 39 | 40 | input, 41 | select:not(.mini-select), 42 | .label-text { 43 | padding-top: 4px; 44 | padding-bottom: 4px; 45 | } 46 | 47 | input, select { 48 | .input-mixin(); 49 | } 50 | 51 | input, 52 | &:not(.adaptive-width) select:not(.mini-select) { 53 | flex: 1; 54 | width: 0; 55 | } 56 | 57 | .mini-select { 58 | background: #3a3a3a; 59 | margin-left: -4px; 60 | } 61 | 62 | input:disabled, 63 | select:disabled { 64 | background: #3a3a3a; 65 | color: #3a3a3a; 66 | } 67 | } 68 | 69 | .input-icon { 70 | display: flex; 71 | background: #3a3a3a; 72 | align-items: center; 73 | user-select: none; 74 | } 75 | 76 | .popup-textarea { 77 | .input-mixin(); 78 | font-family: inherit; 79 | border-radius: 4px; 80 | width: 25em; 81 | max-width: 100%; 82 | padding: 4px; 83 | resize: vertical; 84 | } -------------------------------------------------------------------------------- /src/types/ReactionType.ts: -------------------------------------------------------------------------------- 1 | import elements from "../utils/elements"; 2 | 3 | export const enum BaseDamage { 4 | Talent = 1, 5 | Level 6 | }; 7 | 8 | export const enum RxnMode { 9 | None, 10 | Multiplicative, 11 | Additive 12 | }; 13 | 14 | export const enum EMBonusType { 15 | None, 16 | Amplifying, 17 | Additive, 18 | Transformative, 19 | Lunar 20 | }; 21 | 22 | export type Reaction = { 23 | name: string; 24 | secondaryName?: string; 25 | multiplier?: number; 26 | color: string; 27 | element: typeof elements[number] | 'Varies'; 28 | canApply?: boolean; 29 | }; 30 | 31 | type ReactionType = { 32 | name: string; 33 | secondaryName?: string; 34 | /** 35 | * Base formula type for base damage. 36 | */ 37 | baseDamage: BaseDamage; 38 | /** 39 | * Base formula type for additive damage. 40 | */ 41 | additiveBaseDamage?: BaseDamage; 42 | /** 43 | * Where reaction damage is added. 44 | */ 45 | rxnMode: RxnMode; 46 | /** 47 | * The EM bonus type used for this damage. 48 | */ 49 | emBonus: EMBonusType; 50 | /** 51 | * True if the damage ignores DMG bonus, enemy DEF, and is a separate damage number. 52 | */ 53 | isTransformative: boolean; 54 | /** 55 | * True if the damage uses transformative crit rate/damage. 56 | */ 57 | transformativeCrit?: boolean; 58 | reactions: Map; 59 | desc: string; 60 | } & ({ 61 | rxnMode: RxnMode.Additive; 62 | additiveBaseDamage: BaseDamage; 63 | } | { 64 | rxnMode: Exclude; 65 | additiveBaseDamage?: never; 66 | }); 67 | 68 | export default ReactionType; -------------------------------------------------------------------------------- /src/types/EquationData.ts: -------------------------------------------------------------------------------- 1 | export type EquationInfo = { 2 | name: string; 3 | /** 4 | * @remark 5 | * INLINE_x attempts to inline the expression named x 6 | * 7 | * @remark 8 | * SECONDARY_x makes secondary reaction substitutions while processing x 9 | */ 10 | expr: string | (() => string); 11 | }; 12 | 13 | type EquationData = { 14 | // Stats 15 | atk: EquationInfo; 16 | def: EquationInfo; 17 | hp: EquationInfo; 18 | 19 | // Final base damage 20 | baseDamage: EquationInfo; 21 | flatDamageBasic: EquationInfo; 22 | finalBaseDamage: EquationInfo; 23 | 24 | // Reaction 25 | emBonus: EquationInfo; 26 | reactionBonusMultiplier: EquationInfo; 27 | amplifyingMul: EquationInfo; 28 | 29 | // Amplified 30 | amplifiedDamage: EquationInfo; 31 | secondaryAmplifiedDamage: EquationInfo; 32 | 33 | // Extra transformative damage 34 | extraTransformativeDamage: EquationInfo; 35 | 36 | // Additive 37 | flatDamageReactionBonus: EquationInfo; 38 | additiveDamage: EquationInfo; 39 | secondaryAdditiveDamage: EquationInfo; 40 | 41 | // Damage bonus 42 | bonusDamage: EquationInfo; 43 | 44 | // Enemy factors 45 | enemyDefenseFactor: EquationInfo; 46 | characterDefenseFactor: EquationInfo; 47 | enemyDefenseMul: EquationInfo; 48 | enemyResistance: EquationInfo; 49 | enemyResistanceMul: EquationInfo; 50 | travelPenalty: EquationInfo; 51 | travelMultiplier: EquationInfo; 52 | generalDamage: EquationInfo; 53 | 54 | // CRIT 55 | realCritRate: EquationInfo; 56 | critBonus: EquationInfo; 57 | critHit: EquationInfo; 58 | avgDamage: EquationInfo; 59 | }; 60 | 61 | export default EquationData; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "numeric-genshin-damage-calc", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "dependencies": { 7 | "csv-parse": "^5.5.3", 8 | "csv-stringify": "^6.4.5", 9 | "fparser": "^3.0.1", 10 | "react": "^18.2.0", 11 | "react-dom": "^18.2.0", 12 | "react-router": "^7.6.3", 13 | "reactjs-popup": "^2.0.5", 14 | "typescript": "^5.6.2" 15 | }, 16 | "devDependencies": { 17 | "@babel/core": "^7.25.7", 18 | "@babel/preset-env": "^7.25.7", 19 | "@babel/preset-react": "^7.25.7", 20 | "@babel/preset-typescript": "^7.25.7", 21 | "@eslint/js": "^9.12.0", 22 | "@types/fparser": "^2.0.0", 23 | "@types/jest": "^29.5.13", 24 | "@types/react": "^18.3.11", 25 | "@types/react-dom": "^18.0.6", 26 | "@vitejs/plugin-react-swc": "^3.5.0", 27 | "babel-jest": "^29.7.0", 28 | "eslint": "^9.12.0", 29 | "eslint-plugin-react-hooks": "^5.1.0-rc.0", 30 | "eslint-plugin-react-refresh": "^0.4.12", 31 | "globals": "^15.10.0", 32 | "jest": "^29.7.0", 33 | "less": "^4.2.0", 34 | "react-test-renderer": "^18.3.1", 35 | "ts-node": "^10.9.2", 36 | "typescript": "^5.5.3", 37 | "typescript-eslint": "^8.7.0", 38 | "vite": "^5.4.8" 39 | }, 40 | "scripts": { 41 | "start": "vite", 42 | "build": "tsc -b && vite build", 43 | "test": "jest", 44 | "test:watch": "jest --watch" 45 | }, 46 | "eslintConfig": { 47 | "extends": [ 48 | "react-app" 49 | ] 50 | }, 51 | "browserslist": { 52 | "production": [ 53 | ">0.2%", 54 | "not dead", 55 | "not op_mini all" 56 | ], 57 | "development": [ 58 | "last 1 chrome version", 59 | "last 1 firefox version", 60 | "last 1 safari version" 61 | ] 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/svgs/CalculatorSVG.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react"; 2 | 3 | export default function CalculatorSVG(props: SVGProps) { 4 | return ; 7 | } -------------------------------------------------------------------------------- /src/pages/HelpPage.tsx: -------------------------------------------------------------------------------- 1 | import "../less/index.less"; 2 | import "reactjs-popup/dist/index.css"; 3 | import "../less/popup.less"; 4 | import stats from "../utils/stats"; 5 | import { Link } from "react-router"; 6 | import { Fragment } from "react/jsx-runtime"; 7 | import { topDescs } from "../utils/topDescs"; 8 | import '../less/HelpPage.less'; 9 | import Page from "../components/Page"; 10 | import damageTypes from "../utils/damageTypes"; 11 | 12 | const entries = [ 13 | { 14 | name: 'Column Options', 15 | section: 'general', 16 | data: [...topDescs.entries()].map(([name, desc]) => ({ name, desc })) 17 | }, 18 | { 19 | name: 'Stat Information', 20 | section: 'stats', 21 | data: stats 22 | }, 23 | { 24 | name: 'Damage Types', 25 | section: 'damage', 26 | data: damageTypes 27 | } 28 | ]; 29 | 30 | export default function HelpPage() { 31 | return 32 |
33 |

Form Information and Help

34 | ← Back to Calculator 35 | 43 | {entries.map(entry => 44 |

{entry.name}

45 |
46 | {entry.data.map(({ name, desc }) => 47 | 48 |
{name}
49 |
{desc ?? '-'}
50 |
51 | )} 52 |
53 |
)} 54 |
55 |
; 56 | } 57 | -------------------------------------------------------------------------------- /src/utils/columnListReducer.ts: -------------------------------------------------------------------------------- 1 | import ColumnState, { ColumnStateAction } from "../types/ColumnState"; 2 | 3 | export default function columnListReducer(oldState: ColumnState, action: ColumnStateAction) { 4 | const state = {...oldState}; 5 | 6 | switch (action.type) { 7 | case 'add': 8 | state.shown = state.shown.clone().add(...action.columns); 9 | break; 10 | case 'addEmpty': 11 | state.shown = state.shown.clone().addEmpty(); 12 | break; 13 | case 'duplicate': 14 | state.shown = state.shown.clone().duplicate(action.colId); 15 | break; 16 | case 'remove': 17 | [state.shown,] = state.shown.clone().remove(action.colId, true); 18 | break; 19 | case 'load': 20 | state.shown = state.shown.clone(); 21 | state.closed = state.closed.clone().transfer(action.colId, state.shown); 22 | break; 23 | case 'unload': 24 | state.closed = state.closed.clone(); 25 | state.shown = state.shown.clone().transfer(action.colId, state.closed, true); 26 | break; 27 | case 'import': 28 | state.shown = state.shown.clone().import(action.build, action.element); 29 | break; 30 | case 'addAttackFromBase': 31 | state.shown = state.shown.clone().addAttackFromBase(action.colId, action.attack); 32 | break; 33 | case 'removeAttack': 34 | state.shown = state.shown.clone().removeAttack(action.colId, action.atkId); 35 | break; 36 | case 'setActiveAttack': 37 | state.shown = state.shown.clone().setActiveAttack(action.colId, action.atkId); 38 | break; 39 | case 'modifyAttack': 40 | state.shown = state.shown.clone().transformAttack(action.colId, action.atkId, action.modifier); 41 | break; 42 | case 'modifyAttacks': 43 | state.shown = state.shown.clone().transformAttacks(action.colId, action.modifier); 44 | } 45 | 46 | return state; 47 | } -------------------------------------------------------------------------------- /src/components/AttacksRow.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import SVGButton from "./SVGButton"; 3 | import AddSVG from "../svgs/AddSVG"; 4 | import Column from "../utils/Column"; 5 | import AttackList from "./AttackList"; 6 | import '../less/FormInput.less'; 7 | import '../less/AttacksRow.less'; 8 | import RowLabel from "./RowLabel"; 9 | import { ColumnStateAction } from "../types/ColumnState"; 10 | import { topDescs } from "../utils/topDescs"; 11 | 12 | export default function AttacksRow(props: Readonly<{ 13 | columns: Column[], 14 | dispatch: React.Dispatch; 15 | }>) { 16 | return <> 17 | 18 | {props.columns.map(column =>
19 | props.dispatch({ 23 | type: 'setActiveAttack', 24 | colId: column.id, 25 | atkId 26 | })} 27 | deleteAttack={atkId => props.dispatch({ 28 | type: 'removeAttack', 29 | colId: column.id, 30 | atkId 31 | })} 32 | /> 33 | } 35 | label="Add" 36 | mini 37 | hideLabel 38 | onClick={() => props.dispatch({ 39 | type: 'addAttackFromBase', 40 | colId: column.id, 41 | attack: column.last 42 | })} 43 | /> 44 |
)} 45 | ; 46 | } -------------------------------------------------------------------------------- /src/components/DamageTypeRow.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Column from "../utils/Column"; 3 | import RowLabel from "./RowLabel"; 4 | import FormInput from "./FormInput"; 5 | import '../less/DamageTypeRow.less'; 6 | import { ColumnStateAction } from "../types/ColumnState"; 7 | import reactionTypes from "../utils/reactionTypes"; 8 | import { topDescs } from "../utils/topDescs"; 9 | 10 | export default function DamageTypeRow(props: Readonly<{ 11 | columns: Column[]; 12 | dispatch: React.Dispatch; 13 | }>) { 14 | return <> 15 | 16 | {props.columns.map(column => { 17 | const reaction = reactionTypes.get(column.active.reactionType)?.reactions.get(column.active.reaction); 18 | 19 | return props.dispatch({ 27 | type: 'modifyAttack', 28 | colId: column.id, 29 | atkId: column.active.id, 30 | modifier: attack => { 31 | [ 32 | attack.reactionType, 33 | attack.reaction 34 | ] = value.split(',').map(Number); 35 | 36 | attack.secondaryType = 0; 37 | attack.secondary = 0; 38 | } 39 | })} 40 | options={ 41 | [...reactionTypes.entries().map(([id, damageType]) => ({ 42 | label: damageType.name, 43 | options: [...damageType.reactions.entries().map(([subID, damageSubType]) => ({ 44 | name: damageSubType.name, 45 | value: `${id},${subID}`, 46 | style: { color: damageSubType.color ?? 'white' } 47 | }))] 48 | }))] 49 | } 50 | />; 51 | })} 52 | ; 53 | } -------------------------------------------------------------------------------- /src/components/ImportPopup.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEvent, useState } from "react"; 2 | import Popup from "reactjs-popup"; 3 | import { PopupActions } from "reactjs-popup/dist/types"; 4 | import SVGButton from "./SVGButton"; 5 | import '../less/ImportPopup.less'; 6 | import Column from "../utils/Column"; 7 | import PopupHeader from "./PopupHeader"; 8 | import ImportSVG from "../svgs/ImportSVG"; 9 | import GameImportArea from "./GameImportArea"; 10 | import { csvImport } from "../utils/csv"; 11 | import { ColumnStateAction } from "../types/ColumnState"; 12 | 13 | export default function ImportPopup(props: Readonly<{ 14 | dispatch: React.Dispatch; 15 | }>) { 16 | const ref = React.useRef(null); 17 | const [fileImportError, setFileImportError] = useState(); 18 | 19 | async function importFile(e: ChangeEvent) { 20 | const file = e.target.files![0]; 21 | 22 | if (!file) return; 23 | 24 | const text = await file.text(); 25 | 26 | let importedColumns: Column[]; 27 | 28 | try { 29 | importedColumns = csvImport(text); 30 | } catch (e) { 31 | setFileImportError((e as Error).message); 32 | return; 33 | } 34 | 35 | ref.current?.close(); 36 | props.dispatch({ 37 | type: 'add', 38 | columns: importedColumns 39 | }); 40 | } 41 | 42 | return } label="Import" title="Import from CSV or In-Game" /> 44 | } ref={ref} modal onClose={() => setFileImportError(undefined)}> 45 |
46 | 47 |

File

48 |
49 | 50 |
51 | {fileImportError &&
Error: {fileImportError}
} 52 |
53 |

In-Game Stats

54 | { 55 | ref.current?.close(); 56 | props.dispatch(action); 57 | }} /> 58 |
59 |
60 | } -------------------------------------------------------------------------------- /src/components/ReactionDesc.tsx: -------------------------------------------------------------------------------- 1 | import { Reaction } from "../types/ReactionType"; 2 | import { elementColors } from "../utils/elements"; 3 | import reactionTypes from "../utils/reactionTypes"; 4 | import '../less/ReactionDesc.less'; 5 | import { Fragment } from "react/jsx-runtime"; 6 | 7 | function getElementColor(element: Reaction['element']) { 8 | if (element === 'Varies') 9 | return '#ffffff'; 10 | 11 | return elementColors[element]; 12 | } 13 | 14 | export default function ReactionDesc() { 15 | const spreadingReactions = [...reactionTypes.get(2)!.reactions.values().filter(reaction => reaction.canApply)]; 16 | 17 | return
18 |
19 | The reaction type of the attack. Different reactions have different properties and multipliers. 20 |
21 |
22 | {spreadingReactions.map((reaction, i, arr) => 23 | 24 | {i == arr.length - 1 ? ' and ' : i > 0 && ', '} 25 | {reaction.name} 26 | 27 | )}{' '} 28 | can trigger a further reaction when spread to another enemy. An additional input will be shown to calculate the additional damage from this secondary reaction. 29 |
30 | {[...reactionTypes.entries().map(([id, damageType]) => 31 |
32 |

{damageType.name}

33 |
{damageType.desc}
34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | {[...damageType.reactions.entries().map(([subID, damageSubType]) => 44 | 45 | 46 | 47 | 48 | 49 | )]} 50 | 51 |
NameDamage TypeMultiplier
{damageSubType.name}{damageSubType.element}{damageSubType.multiplier ?? 'N/A'}
52 |
53 | )]} 54 |
; 55 | } -------------------------------------------------------------------------------- /src/components/ExportPopup.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import Popup from "reactjs-popup"; 3 | import { PopupActions } from "reactjs-popup/dist/types"; 4 | import SVGButton from "./SVGButton"; 5 | import '../less/ExportPopup.less'; 6 | import PopupHeader from "./PopupHeader"; 7 | import ExportSVG from "../svgs/ExportSVG"; 8 | import { csvExport } from "../utils/csv"; 9 | import ColumnState from "../types/ColumnState"; 10 | 11 | export default function ExportPopup(props: Readonly<{ 12 | state: ColumnState; 13 | }>) { 14 | const ref = React.useRef(null); 15 | const [includeClosed, setIncludeClosed] = useState(false); 16 | 17 | function runExport() { 18 | const str = csvExport( 19 | includeClosed 20 | ? [...props.state.shown.columns, ...props.state.closed.columns] 21 | : props.state.shown.columns 22 | ); 23 | 24 | const blob = new Blob([str], { 25 | type: 'text/csv' 26 | }); 27 | 28 | const link = document.createElement('a') 29 | link.href = URL.createObjectURL(blob); 30 | link.download = 'genshin-damage-calcs.csv'; 31 | link.style.display = 'none' 32 | 33 | document.body.append(link); 34 | link.click(); 35 | link.remove(); 36 | 37 | URL.revokeObjectURL(link.href); 38 | ref.current?.close(); 39 | } 40 | 41 | return } label="Export" title="Export as CSV" /> 43 | } ref={ref} modal> 44 |
45 | 46 | 54 |
55 | } 57 | label="Export CSV" 58 | onClick={runExport} 59 | mini 60 | /> 61 |
62 |
63 |
64 | } -------------------------------------------------------------------------------- /src/components/CalculatorForm.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useReducer } from "react"; 2 | import DamageOutputRow from "./DamageOutputRow"; 3 | import DamageTypeRow from "./DamageTypeRow"; 4 | import HeadingRow from "./HeadingRow"; 5 | import AttacksRow from "./AttacksRow"; 6 | import '../less/CalculatorForm.less'; 7 | import TopButtonRow from "./TopButtonRow"; 8 | import RemoveColumnRow from "./RemoveColumnRow"; 9 | import statSections from "../utils/statSections"; 10 | import CalculatorSection from "./CalculatorSection"; 11 | import LabelRow from "./LabelRow"; 12 | import damageTypes from "../utils/damageTypes"; 13 | import columnListReducer from "../utils/columnListReducer"; 14 | import ColumnStorage from "../utils/ColumnStorage"; 15 | import SecondaryReactionRow from "./SecondaryReactionRow"; 16 | 17 | export default function CalculatorForm() { 18 | let [columnState, dispatchColumnState] = useReducer(columnListReducer, undefined, () => ColumnStorage.load()); 19 | useEffect(() => ColumnStorage.save(columnState), [columnState]); 20 | 21 | const columns = columnState.shown.columns; 22 | 23 | return
24 | 25 |
26 |
29 | 30 | 31 | 32 | 33 | 34 | 35 | {statSections.map(statSection => 36 | 37 | )} 38 | 39 | {damageTypes.map(damageType => 40 | 41 | )} 42 | 43 |
44 |
; 45 | } -------------------------------------------------------------------------------- /src/utils/ColumnStorage.ts: -------------------------------------------------------------------------------- 1 | import ColumnState from "../types/ColumnState"; 2 | import { StoredAttack } from "../types/PartialAttack"; 3 | import Column from "./Column"; 4 | import ColumnList from "./ColumnList"; 5 | 6 | export default class ColumnStorage { 7 | static load(): ColumnState { 8 | const storedAttacks = (JSON.parse(localStorage.getItem('GIDC-data') ?? '[]') as StoredAttack[]); 9 | 10 | const shownMap = new Map(); 11 | const closedMap = new Map(); 12 | 13 | storedAttacks.forEach((storedAttack, i) => { 14 | // Support saved configs before groups where added 15 | const group = storedAttack.group ?? `SG_${i}`; 16 | const map = storedAttack.shown !== false ? shownMap : closedMap; 17 | 18 | if (!map.has(group)) 19 | map.set(group, []); 20 | 21 | map.get(group)!.push(storedAttack); 22 | }); 23 | 24 | const shownGroups = [...shownMap.values()]; 25 | const closedGroups = [...closedMap.values()]; 26 | 27 | if (!shownGroups.length) 28 | shownGroups.push([{}]); 29 | 30 | const shown = new ColumnList(shownGroups.map(group => new Column(group))); 31 | const closed = new ColumnList(closedGroups.map(group => new Column(group))) 32 | 33 | return { 34 | shown, 35 | closed 36 | }; 37 | } 38 | 39 | static save({shown, closed}: ColumnState) { 40 | localStorage.setItem('GIDC-data', JSON.stringify([ 41 | ...ColumnStorage.encodeList(shown, true), 42 | ...ColumnStorage.encodeList(closed, false) 43 | ])); 44 | } 45 | 46 | private static encodeList(columnList: ColumnList, shown: boolean): StoredAttack[] { 47 | const itemLists = columnList.cleanedColumns.map(column => column.attacks); 48 | 49 | const processedItemLists = itemLists.map((items, itemsIndex) => items.map((attack, atkIndex) => { 50 | const storedAttack = attack.toObject() as StoredAttack; 51 | 52 | if (items.length > 1) 53 | storedAttack.group = itemsIndex; 54 | 55 | if (atkIndex === 0) { 56 | storedAttack.shown = shown; 57 | } else { 58 | delete storedAttack.label; 59 | delete storedAttack.synced; 60 | } 61 | 62 | return storedAttack; 63 | })); 64 | 65 | return processedItemLists.flat(); 66 | } 67 | } -------------------------------------------------------------------------------- /src/components/AttrStatInput.tsx: -------------------------------------------------------------------------------- 1 | import StatInput from "./StatInput"; 2 | import Attack from "../utils/Attack"; 3 | import Stat from "../types/Stat"; 4 | import StatData from "../types/StatData"; 5 | import attributes, { getAttrStat } from "../utils/attributes"; 6 | import AddSVG from "../svgs/AddSVG"; 7 | import SVGButton from "./SVGButton"; 8 | import '../less/AttrStatInput.less'; 9 | 10 | export default function AttrStatInput(props: Readonly<{ 11 | stat: Stat; 12 | attack: Attack; 13 | onChange: (name: keyof StatData, val?: string) => void, 14 | }>) { 15 | const [ 16 | activeAttributes, 17 | inactiveAttributes 18 | ] = attributes.reduce( 19 | (accum, attr) => { 20 | accum[props.attack.hasStat(getAttrStat(props.stat.prop, attr)) ? 0 : 1].push(attr); 21 | return accum; 22 | }, 23 | [ 24 | [] as (typeof attributes)[number][], 25 | [] as (typeof attributes)[number][] 26 | ] 27 | ); 28 | 29 | if (!activeAttributes.length) { 30 | const attr = inactiveAttributes.shift()!; 31 | 32 | activeAttributes.push(attr); 33 | props.attack.setStat(getAttrStat(props.stat.prop, attr), props.stat.default.toString()); 34 | } 35 | 36 | return
37 | {activeAttributes.map(attr => { 38 | const prop = getAttrStat(props.stat.prop, attr); 39 | const value = props.attack.getStat(prop) ?? ''; 40 | 41 | const types: { name: string; value?: string; disabled?: boolean; }[] = attributes.map(optionAttr => ({ 42 | name: optionAttr, 43 | disabled: !inactiveAttributes.includes(optionAttr) && optionAttr !== attr 44 | })); 45 | 46 | if (activeAttributes.length > 1) 47 | types.push({ name: '-', value: '' }) 48 | 49 | return props.onChange(prop, value)} 54 | unit={attr} 55 | unitOptions={types} 56 | onUnitChange={newAttr => { 57 | props.onChange(prop, undefined); 58 | 59 | if (newAttr) 60 | props.onChange( 61 | getAttrStat( 62 | props.stat.prop, 63 | newAttr as typeof attributes[number] 64 | ), 65 | value 66 | ); 67 | }} 68 | /> 69 | })} 70 | {inactiveAttributes.length > 0 && 71 | } 74 | label="Add Stat" 75 | onClick={() => props.onChange( 76 | getAttrStat(props.stat.prop, inactiveAttributes[0]), 77 | props.stat.default.toString() 78 | )} 79 | /> 80 | } 81 |
82 | } -------------------------------------------------------------------------------- /src/components/SecondaryReactionRow.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Column from "../utils/Column"; 3 | import RowLabel from "./RowLabel"; 4 | import FormInput from "./FormInput"; 5 | import '../less/DamageTypeRow.less'; 6 | import { ColumnStateAction } from "../types/ColumnState"; 7 | import reactionTypes from "../utils/reactionTypes"; 8 | import { topDescs } from "../utils/topDescs"; 9 | 10 | export default function SecondaryReactionRow(props: Readonly<{ 11 | columns: Column[]; 12 | dispatch: React.Dispatch; 13 | }>) { 14 | const typePairs = [...reactionTypes.entries()]; 15 | let anyEnabled = false; 16 | 17 | const dropdowns = props.columns.map(column => { 18 | const reaction = reactionTypes.get(column.active.reactionType)?.reactions.get(column.active.reaction); 19 | const secondary = reactionTypes.get(column.active.secondaryType)?.reactions.get(column.active.secondary); 20 | const enabled = reaction?.canApply ?? false; 21 | 22 | anyEnabled ||= enabled; 23 | 24 | return props.dispatch({ 31 | type: 'modifyAttack', 32 | colId: column.id, 33 | atkId: column.active.id, 34 | modifier: attack => { 35 | [ 36 | attack.secondaryType, 37 | attack.secondary 38 | ] = value.split(',').map(Number); 39 | } 40 | })} 41 | options={ 42 | typePairs 43 | .filter(([, type]) => !type.isTransformative) 44 | .map(([id, damageType]) => ({ 45 | label: damageType.secondaryName ?? damageType.name, 46 | options: [...damageType.reactions.entries()] 47 | .filter(([, damageSubType]) => 48 | damageSubType?.element === reaction?.element || damageSubType?.element === 'Varies' || reaction?.element === 'Varies' 49 | ) 50 | .map(([subID, damageSubType]) => ({ 51 | name: damageSubType.secondaryName ?? damageSubType.name, 52 | value: `${id},${subID}`, 53 | style: { color: damageSubType.color ?? 'white' } 54 | })) 55 | })) 56 | .filter(({ options }) => options.length > 0) 57 | } 58 | /> 59 | }); 60 | 61 | if (!anyEnabled) return null; 62 | 63 | return <> 64 | 65 | {dropdowns} 66 | ; 67 | } -------------------------------------------------------------------------------- /src/components/FormInput.tsx: -------------------------------------------------------------------------------- 1 | import '../less/FormInput.less'; 2 | import SelectOptions from './SelectOptions'; 3 | import SelectOption from '../types/SelectOption'; 4 | import Popup from 'reactjs-popup'; 5 | import PopupHeader from './PopupHeader'; 6 | import React from 'react'; 7 | import { PopupActions } from 'reactjs-popup/dist/types'; 8 | import SVGButton from './SVGButton'; 9 | 10 | export default function FormInput(props: Readonly<{ 11 | value: string; 12 | onChange: (val: string) => void; 13 | options?: SelectOption[]; 14 | type?: string; 15 | disabled?: boolean; 16 | expandIcon?: React.ReactNode | boolean, 17 | expandIconLabel?: string; 18 | backIcon?: React.ReactNode | boolean, 19 | backIconLabel?: string; 20 | unit?: string; 21 | unitOptions?: SelectOption[]; 22 | onUnitChange?: (val: string) => void; 23 | style?: React.CSSProperties; 24 | class?: string; 25 | id?: string; 26 | placeholder?: string; 27 | }>) { 28 | const Tag = props.options ? 'select' : 'input' as const; 29 | const popupRef = React.useRef(null); 30 | 31 | return
32 | {!props.disabled && props.expandIcon && 33 |
34 | } ref={popupRef} modal> 35 | 36 |
37 |