@jjlmoya/utils-games-development 1.63.0 → 1.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +4 -1
  4. package/src/index.ts +1 -0
  5. package/src/tests/locale_completeness.test.ts +2 -2
  6. package/src/tests/tool_validation.test.ts +2 -2
  7. package/src/tool/damageFormulaLab/bibliography.astro +24 -0
  8. package/src/tool/damageFormulaLab/bibliography.ts +10 -0
  9. package/src/tool/damageFormulaLab/component.astro +178 -0
  10. package/src/tool/damageFormulaLab/controller.ts +235 -0
  11. package/src/tool/damageFormulaLab/damage-formula-lab.css +644 -0
  12. package/src/tool/damageFormulaLab/dom-views.ts +193 -0
  13. package/src/tool/damageFormulaLab/entry.ts +28 -0
  14. package/src/tool/damageFormulaLab/evaluator.ts +84 -0
  15. package/src/tool/damageFormulaLab/expression.ts +191 -0
  16. package/src/tool/damageFormulaLab/i18n/de.ts +181 -0
  17. package/src/tool/damageFormulaLab/i18n/en.ts +231 -0
  18. package/src/tool/damageFormulaLab/i18n/es.ts +181 -0
  19. package/src/tool/damageFormulaLab/i18n/fr.ts +181 -0
  20. package/src/tool/damageFormulaLab/i18n/id.ts +181 -0
  21. package/src/tool/damageFormulaLab/i18n/it.ts +181 -0
  22. package/src/tool/damageFormulaLab/i18n/ja.ts +182 -0
  23. package/src/tool/damageFormulaLab/i18n/ko.ts +181 -0
  24. package/src/tool/damageFormulaLab/i18n/nl.ts +181 -0
  25. package/src/tool/damageFormulaLab/i18n/pl.ts +181 -0
  26. package/src/tool/damageFormulaLab/i18n/pt.ts +181 -0
  27. package/src/tool/damageFormulaLab/i18n/ru.ts +181 -0
  28. package/src/tool/damageFormulaLab/i18n/sv.ts +181 -0
  29. package/src/tool/damageFormulaLab/i18n/tr.ts +181 -0
  30. package/src/tool/damageFormulaLab/i18n/zh.ts +181 -0
  31. package/src/tool/damageFormulaLab/index.ts +11 -0
  32. package/src/tool/damageFormulaLab/logic.test.ts +138 -0
  33. package/src/tool/damageFormulaLab/logic.ts +138 -0
  34. package/src/tool/damageFormulaLab/model.ts +151 -0
  35. package/src/tool/damageFormulaLab/seo.astro +13 -0
  36. package/src/tool/damageFormulaLab/storage.ts +62 -0
  37. package/src/tool/damageFormulaLab/transfers.ts +89 -0
  38. package/src/tool/damageFormulaLab/ui.ts +62 -0
  39. package/src/tools.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-games-development",
3
- "version": "1.63.0",
3
+ "version": "1.64.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -6,10 +6,11 @@ import { saveFileEditor } from '../tool/saveFileEditor/entry';
6
6
  import { steamBbcodeTranslator } from '../tool/steamBbcodeTranslator/entry';
7
7
  import { gameUiAccessibilityTester } from '../tool/gameUIAccessibilityTester/entry';
8
8
  import { hitboxHurtboxAnimator } from '../tool/hitboxHurtboxAnimator/entry';
9
+ import { damageFormulaLab } from '../tool/damageFormulaLab/entry';
9
10
 
10
11
  export const gamesCategory: GamesCategoryEntry = {
11
12
  icon: 'mdi:gamepad-variant',
12
- tools: [steamCapsuleGenerator, spriteSheetPacker, audioLoopPointFinder, saveFileEditor, steamBbcodeTranslator, gameUiAccessibilityTester, hitboxHurtboxAnimator],
13
+ tools: [steamCapsuleGenerator, spriteSheetPacker, audioLoopPointFinder, saveFileEditor, steamBbcodeTranslator, gameUiAccessibilityTester, hitboxHurtboxAnimator, damageFormulaLab],
13
14
  i18n: {
14
15
  de: () => import('./i18n/de').then((m) => m.content),
15
16
  en: () => import('./i18n/en').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -18,6 +18,8 @@ export { pixelArtPaletteSwapper } from './tool/pixelArtPaletteSwapper/entry';
18
18
  export type { PixelArtPaletteSwapperUI, PixelArtPaletteSwapperLocaleContent } from './tool/pixelArtPaletteSwapper/entry';
19
19
  export { hitboxHurtboxAnimator } from './tool/hitboxHurtboxAnimator/entry';
20
20
  export type { HitboxHurtboxAnimatorUI, HitboxHurtboxAnimatorLocaleContent } from './tool/hitboxHurtboxAnimator/entry';
21
+ export { damageFormulaLab } from './tool/damageFormulaLab/entry';
22
+ export type { DamageFormulaLabUI, DamageFormulaLabLocaleContent } from './tool/damageFormulaLab/entry';
21
23
  export { gamesCategory } from './category';
22
24
  import { steamCapsuleGenerator } from './tool/steamCapsuleGenerator/entry';
23
25
  import { itchioGameTester } from './tool/itchioGameTester/entry';
@@ -30,5 +32,6 @@ import { retroSfxGenerator } from './tool/retroSfxGenerator/entry';
30
32
  import { pixelArtPaletteSwapper } from './tool/pixelArtPaletteSwapper/entry';
31
33
  import { gameUiAccessibilityTester } from './tool/gameUIAccessibilityTester/entry';
32
34
  import { hitboxHurtboxAnimator } from './tool/hitboxHurtboxAnimator/entry';
35
+ import { damageFormulaLab } from './tool/damageFormulaLab/entry';
33
36
 
34
- export const ALL_ENTRIES = [steamCapsuleGenerator, itchioGameTester, spriteSheetPacker, audioLoopPointFinder, saveFileEditor, localizationSanitizer, steamBbcodeTranslator, retroSfxGenerator, pixelArtPaletteSwapper, gameUiAccessibilityTester, hitboxHurtboxAnimator];
37
+ export const ALL_ENTRIES = [steamCapsuleGenerator, itchioGameTester, spriteSheetPacker, audioLoopPointFinder, saveFileEditor, localizationSanitizer, steamBbcodeTranslator, retroSfxGenerator, pixelArtPaletteSwapper, gameUiAccessibilityTester, hitboxHurtboxAnimator, damageFormulaLab];
package/src/index.ts CHANGED
@@ -22,5 +22,6 @@ export { RETRO_SFX_GENERATOR_TOOL, retroSfxGenerator } from './tool/retroSfxGene
22
22
  export { PIXEL_ART_PALETTE_SWAPPER_TOOL, pixelArtPaletteSwapper } from './tool/pixelArtPaletteSwapper';
23
23
  export { GAME_UI_ACCESSIBILITY_TESTER_TOOL, gameUiAccessibilityTester } from './tool/gameUIAccessibilityTester';
24
24
  export { HITBOX_HURTBOX_ANIMATOR_TOOL, hitboxHurtboxAnimator } from './tool/hitboxHurtboxAnimator';
25
+ export { DAMAGE_FORMULA_LAB_TOOL, damageFormulaLab } from './tool/damageFormulaLab';
25
26
 
26
27
  export type { ToolLocaleContent as GamesToolLocaleContent } from './types';
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
2
2
  import { ALL_TOOLS } from '../tools';
3
3
 
4
4
  describe('Locale Completeness Validation', () => {
5
- it('eleven tools are registered', () => {
6
- expect(ALL_TOOLS.length).toBe(11);
5
+ it('twelve tools are registered', () => {
6
+ expect(ALL_TOOLS.length).toBe(12);
7
7
  });
8
8
  });
@@ -4,8 +4,8 @@ import { gamesCategory } from '../data';
4
4
 
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
- it('should have 11 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(11);
7
+ it('should have 12 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(12);
9
9
  });
10
10
 
11
11
  it('gamesCategory should be defined', () => {
@@ -0,0 +1,24 @@
1
+ ---
2
+ import type { BibliographyEntry } from '../../types';
3
+
4
+ interface Props {
5
+ title?: string;
6
+ entries?: BibliographyEntry[];
7
+ }
8
+
9
+ const { title = 'References', entries = [] } = Astro.props;
10
+ ---
11
+
12
+ {entries.length > 0 && (
13
+ <section class="damage-lab-references" aria-labelledby="damage-lab-reference-title">
14
+ <h2 id="damage-lab-reference-title">{title}</h2>
15
+ <ul>
16
+ {entries.map((entry) => <li><a href={entry.url} rel="noopener noreferrer" target="_blank">{entry.name}</a></li>)}
17
+ </ul>
18
+ </section>
19
+ )}
20
+
21
+ <style>
22
+ .damage-lab-references { margin-block: 2rem; }
23
+ .damage-lab-references a { color: var(--n-primary); }
24
+ </style>
@@ -0,0 +1,10 @@
1
+ export const bibliographyEntries = [
2
+ {
3
+ name: "Game Balance Concepts: Numeric Relationships",
4
+ url: "https://gamebalanceconcepts.wordpress.com/2010/07/14/level-2-numeric-relationships/",
5
+ },
6
+ {
7
+ name: "Oracle Numerical Computation Guide: What Is IEEE Arithmetic?",
8
+ url: "https://docs.oracle.com/cd/E37069_01/html/E39019/z4000ac019127.html",
9
+ },
10
+ ];
@@ -0,0 +1,178 @@
1
+ ---
2
+ import type { DamageFormulaLabUI } from './ui';
3
+ import './damage-formula-lab.css';
4
+
5
+ interface Props {
6
+ ui: DamageFormulaLabUI;
7
+ }
8
+
9
+ const { ui } = Astro.props;
10
+
11
+ const coreInputs = [
12
+ ['attack', ui.attackLabel, 0, 100000, 1],
13
+ ['defense', ui.defenseLabel, 0, 100000, 1],
14
+ ['health', ui.healthLabel, 1, 10000000, 1],
15
+ ['attacksPerSecond', ui.cadenceLabel, 0.01, 100, 0.05],
16
+ ] as const;
17
+
18
+ const advancedInputs = [
19
+ ['level', ui.levelLabel, 1, 999, 1],
20
+ ['power', ui.powerLabel, 0, 100, 0.05],
21
+ ['resistance', ui.resistanceLabel, 0, 100, 1],
22
+ ['flat', ui.flatLabel, -100000, 100000, 1],
23
+ ['criticalChance', ui.criticalChanceLabel, 0, 100, 1],
24
+ ['criticalMultiplier', ui.criticalMultiplierLabel, 1, 20, 0.05],
25
+ ] as const;
26
+ ---
27
+
28
+ <section class="damage-lab" data-damage-lab>
29
+ <script is:inline type="application/json" data-damage-ui set:html={JSON.stringify(ui)} />
30
+
31
+ <header class="damage-lab-header">
32
+ <p>{ui.onboarding}</p>
33
+ <span>{ui.localNote}</span>
34
+ </header>
35
+
36
+ <section class="damage-lab-composer" aria-labelledby="damage-lab-formulas-title">
37
+ <div class="damage-lab-heading">
38
+ <h2 id="damage-lab-formulas-title">{ui.formulaDeck}</h2>
39
+ <div class="damage-lab-presets" aria-label="Formula presets">
40
+ <button type="button" data-preset="linear">{ui.presetLinear}</button>
41
+ <button type="button" data-preset="ratio">{ui.presetRatio}</button>
42
+ <button type="button" data-preset="level">{ui.presetLevel}</button>
43
+ </div>
44
+ </div>
45
+ <div class="damage-lab-formulas">
46
+ <label class="damage-lab-formula damage-lab-formula-a">
47
+ <span><i aria-hidden="true"></i>{ui.formulaALabel}</span>
48
+ <input name="formulaA" type="text" spellcheck="false" autocomplete="off" />
49
+ </label>
50
+ <label class="damage-lab-formula damage-lab-formula-b">
51
+ <span><i aria-hidden="true"></i>{ui.formulaBLabel}</span>
52
+ <input name="formulaB" type="text" spellcheck="false" autocomplete="off" />
53
+ </label>
54
+ </div>
55
+ <details class="damage-lab-language">
56
+ <summary>Formula language</summary>
57
+ <p>{ui.formulaHint}<br />{ui.formulaFunctions}</p>
58
+ </details>
59
+ </section>
60
+
61
+ <section class="damage-lab-tuning" aria-labelledby="damage-lab-tuning-title">
62
+ <div class="damage-lab-heading">
63
+ <h2 id="damage-lab-tuning-title">{ui.combatInputs}</h2>
64
+ <div class="damage-lab-scenarios" aria-label={ui.scenariosTitle}>
65
+ <button type="button" data-scenario="skirmisher">{ui.scenarioSkirmisher}</button>
66
+ <button type="button" data-scenario="guardian">{ui.scenarioGuardian}</button>
67
+ <button type="button" data-scenario="boss">{ui.scenarioBoss}</button>
68
+ <button type="button" data-scenario="custom">{ui.scenarioCustom}</button>
69
+ </div>
70
+ </div>
71
+ <div class="damage-lab-core-inputs">
72
+ {coreInputs.map(([name, label, min, max, step]) => (
73
+ <label>
74
+ <span>{label}</span>
75
+ <input name={name} type="number" min={min} max={max} step={step} inputmode="decimal" />
76
+ </label>
77
+ ))}
78
+ </div>
79
+ <details class="damage-lab-advanced">
80
+ <summary>Advanced combat and engine rules</summary>
81
+ <div class="damage-lab-advanced-grid">
82
+ {advancedInputs.map(([name, label, min, max, step]) => (
83
+ <label>
84
+ <span>{label}</span>
85
+ <input name={name} type="number" min={min} max={max} step={step} inputmode="decimal" />
86
+ </label>
87
+ ))}
88
+ </div>
89
+ <fieldset class="damage-lab-choice">
90
+ <legend>{ui.roundingLabel}</legend>
91
+ <button type="button" data-rounding="none">{ui.roundingNone}</button>
92
+ <button type="button" data-rounding="floor">{ui.roundingFloor}</button>
93
+ <button type="button" data-rounding="round">{ui.roundingRound}</button>
94
+ <button type="button" data-rounding="ceil">{ui.roundingCeil}</button>
95
+ </fieldset>
96
+ <fieldset class="damage-lab-choice">
97
+ <legend>{ui.orderLabel}</legend>
98
+ <button type="button" data-order="resistance-first">{ui.resistanceFirst}</button>
99
+ <button type="button" data-order="flat-first">{ui.flatFirst}</button>
100
+ </fieldset>
101
+ </details>
102
+ </section>
103
+
104
+ <section class="damage-lab-result" aria-labelledby="damage-lab-result-title">
105
+ <div class="damage-lab-result-heading">
106
+ <div><span>Live model</span><h2 id="damage-lab-result-title">{ui.runLabel}</h2></div>
107
+ <div class="damage-lab-gap"><small>{ui.resultDifference}</small><strong data-result="gap">0</strong></div>
108
+ </div>
109
+ <div class="damage-lab-totals">
110
+ <article>
111
+ <span>{ui.formulaAName}</span>
112
+ <strong data-result="a-damage">0</strong>
113
+ <small>{ui.resultDamage}</small>
114
+ <dl><div><dt>{ui.resultHits}</dt><dd data-result="a-hits">0</dd></div><div><dt>{ui.resultTtk}</dt><dd data-result="a-ttk">0 s</dd></div></dl>
115
+ </article>
116
+ <article>
117
+ <span>{ui.formulaBName}</span>
118
+ <strong data-result="b-damage">0</strong>
119
+ <small>{ui.resultDamage}</small>
120
+ <dl><div><dt>{ui.resultHits}</dt><dd data-result="b-hits">0</dd></div><div><dt>{ui.resultTtk}</dt><dd data-result="b-ttk">0 s</dd></div></dl>
121
+ </article>
122
+ </div>
123
+ <div class="damage-lab-curve-heading">
124
+ <div><h3>{ui.curveTitle}</h3><p>{ui.curveCaption}</p></div>
125
+ <span><b data-curve-max>0</b> max</span>
126
+ </div>
127
+ <svg class="damage-lab-chart" data-chart viewBox="0 0 100 100" preserveAspectRatio="none" role="img" aria-label={ui.curveTitle}>
128
+ <path class="damage-lab-guide" d="M0 25 H100 M0 50 H100 M0 75 H100" />
129
+ <path class="damage-lab-curve damage-lab-curve-a" data-curve="a" d="" />
130
+ <path class="damage-lab-curve damage-lab-curve-b" data-curve="b" d="" />
131
+ </svg>
132
+ <div class="damage-lab-legend"><span>{ui.formulaAName}</span><span>{ui.formulaBName}</span></div>
133
+ </section>
134
+
135
+ <details class="damage-lab-secondary">
136
+ <summary>{ui.heatmapTitle} + {ui.diagnosticsTitle}</summary>
137
+ <div class="damage-lab-notes">
138
+ <section class="damage-lab-pressure" aria-labelledby="damage-lab-heatmap-title">
139
+ <h2 id="damage-lab-heatmap-title">{ui.heatmapTitle}</h2>
140
+ <p>{ui.heatmapCaption}</p>
141
+ <div class="damage-lab-axis">{ui.attackAxis}</div>
142
+ <div class="damage-lab-heatmap" data-heatmap></div>
143
+ <small>{ui.defenseAxis}</small>
144
+ </section>
145
+ <section class="damage-lab-diagnostics" aria-labelledby="damage-lab-diagnostics-title">
146
+ <h2 id="damage-lab-diagnostics-title">{ui.diagnosticsTitle}</h2>
147
+ <ul data-diagnostics></ul>
148
+ <p>{ui.limitationDisclosure}</p>
149
+ </section>
150
+ </div>
151
+ </details>
152
+
153
+ <details class="damage-lab-export-panel">
154
+ <summary>{ui.exportTitle}</summary>
155
+ <footer class="damage-lab-exports">
156
+ <p>{ui.privacyDisclosure}</p>
157
+ <div class="damage-lab-actions">
158
+ <button type="button" data-share>{ui.copyLink}</button>
159
+ <button type="button" data-csv>{ui.exportCsv}</button>
160
+ <button type="button" data-json>{ui.exportJson}</button>
161
+ <label>{ui.importJson}<input type="file" accept="application/json,.json" data-import /></label>
162
+ <button type="button" data-png>{ui.exportPng}</button>
163
+ <button type="button" data-reset>{ui.reset}</button>
164
+ </div>
165
+ <p class="damage-lab-status" data-status aria-live="polite"></p>
166
+ </footer>
167
+ </details>
168
+ </section>
169
+
170
+ <script>
171
+ import { mountDamageFormulaLab } from './controller';
172
+ import type { DamageFormulaLabUI } from './ui';
173
+
174
+ document.querySelectorAll<HTMLElement>('[data-damage-lab]').forEach((root) => {
175
+ const source = root.querySelector<HTMLScriptElement>('[data-damage-ui]');
176
+ if (source?.textContent) mountDamageFormulaLab(root, JSON.parse(source.textContent) as DamageFormulaLabUI);
177
+ });
178
+ </script>
@@ -0,0 +1,235 @@
1
+ import { renderLab } from "./dom-views";
2
+ import {
3
+ configFromHash,
4
+ defaultConfig,
5
+ formulaPresets,
6
+ parseConfig,
7
+ scenarios,
8
+ } from "./model";
9
+ import type { LabConfig } from "./model";
10
+ import { loadPreferences, savePreferences } from "./storage";
11
+ import {
12
+ copyShareLink,
13
+ exportChartPng,
14
+ exportCsv,
15
+ exportJson,
16
+ } from "./transfers";
17
+ import type { DamageFormulaLabUI } from "./ui";
18
+
19
+ type Update = (config: LabConfig) => void;
20
+ type GetConfig = () => LabConfig;
21
+
22
+ function input(root: HTMLElement, name: string): HTMLInputElement | null {
23
+ return root.querySelector<HTMLInputElement>(`[name="${name}"]`);
24
+ }
25
+
26
+ function numberValue(
27
+ root: HTMLElement,
28
+ name: string,
29
+ fallback: number,
30
+ ): number {
31
+ const value = Number(input(root, name)?.value);
32
+ return Number.isFinite(value) ? value : fallback;
33
+ }
34
+
35
+ function setActive(root: HTMLElement, selector: string, value: string): void {
36
+ root.querySelectorAll<HTMLElement>(selector).forEach((item) => {
37
+ const candidate = item.dataset.rounding ?? item.dataset.order;
38
+ item.dataset.active = String(candidate === value);
39
+ });
40
+ }
41
+
42
+ function writeInputs(root: HTMLElement, config: LabConfig): void {
43
+ const values: Record<string, number | string> = {
44
+ formulaA: config.formulaA,
45
+ formulaB: config.formulaB,
46
+ ...config.variables,
47
+ health: config.settings.health,
48
+ attacksPerSecond: config.settings.attacksPerSecond,
49
+ };
50
+ Object.entries(values).forEach(([name, value]) => {
51
+ const field = input(root, name);
52
+ if (field) field.value = String(value);
53
+ });
54
+ setActive(root, "[data-rounding]", config.settings.rounding);
55
+ setActive(root, "[data-order]", config.settings.modifierOrder);
56
+ }
57
+
58
+ function readVariables(
59
+ root: HTMLElement,
60
+ current: LabConfig,
61
+ ): Record<string, number> {
62
+ return Object.fromEntries(
63
+ Object.keys(current.variables).map((name) => [
64
+ name,
65
+ numberValue(
66
+ root,
67
+ name,
68
+ current.variables[name as keyof typeof current.variables],
69
+ ),
70
+ ]),
71
+ );
72
+ }
73
+
74
+ function readConfig(root: HTMLElement, current: LabConfig): LabConfig {
75
+ return parseConfig({
76
+ ...current,
77
+ formulaA: input(root, "formulaA")?.value,
78
+ formulaB: input(root, "formulaB")?.value,
79
+ variables: readVariables(root, current),
80
+ settings: {
81
+ ...current.settings,
82
+ health: numberValue(root, "health", current.settings.health),
83
+ attacksPerSecond: numberValue(
84
+ root,
85
+ "attacksPerSecond",
86
+ current.settings.attacksPerSecond,
87
+ ),
88
+ },
89
+ });
90
+ }
91
+
92
+ function save(config: LabConfig): void {
93
+ savePreferences({
94
+ formulaA: config.formulaA,
95
+ formulaB: config.formulaB,
96
+ rounding: config.settings.rounding,
97
+ modifierOrder: config.settings.modifierOrder,
98
+ });
99
+ }
100
+
101
+ function initialConfig(): LabConfig {
102
+ const preferences = loadPreferences();
103
+ return (
104
+ configFromHash(location.hash) ?? {
105
+ ...structuredClone(defaultConfig),
106
+ formulaA: preferences.formulaA,
107
+ formulaB: preferences.formulaB,
108
+ settings: {
109
+ ...defaultConfig.settings,
110
+ rounding: preferences.rounding,
111
+ modifierOrder: preferences.modifierOrder,
112
+ },
113
+ }
114
+ );
115
+ }
116
+
117
+ function applyScenario(config: LabConfig, name: string): LabConfig {
118
+ const scenario = scenarios[name];
119
+ if (!scenario) return config;
120
+ const { health, ...variables } = scenario;
121
+ return {
122
+ ...config,
123
+ variables: { ...config.variables, ...variables },
124
+ settings: { ...config.settings, health },
125
+ };
126
+ }
127
+
128
+ function bindModes(root: HTMLElement, get: GetConfig, update: Update): void {
129
+ root
130
+ .querySelectorAll<HTMLButtonElement>("[data-rounding]")
131
+ .forEach((button) =>
132
+ button.addEventListener("click", () => {
133
+ const config = get();
134
+ update({
135
+ ...config,
136
+ settings: {
137
+ ...config.settings,
138
+ rounding: button.dataset
139
+ .rounding as LabConfig["settings"]["rounding"],
140
+ },
141
+ });
142
+ }),
143
+ );
144
+ root.querySelectorAll<HTMLButtonElement>("[data-order]").forEach((button) =>
145
+ button.addEventListener("click", () => {
146
+ const config = get();
147
+ update({
148
+ ...config,
149
+ settings: {
150
+ ...config.settings,
151
+ modifierOrder: button.dataset
152
+ .order as LabConfig["settings"]["modifierOrder"],
153
+ },
154
+ });
155
+ }),
156
+ );
157
+ }
158
+
159
+ function bindPresets(root: HTMLElement, get: GetConfig, update: Update): void {
160
+ root.querySelectorAll<HTMLButtonElement>("[data-preset]").forEach((button) =>
161
+ button.addEventListener("click", () => {
162
+ const formula =
163
+ formulaPresets[button.dataset.preset as keyof typeof formulaPresets];
164
+ update({ ...get(), formulaB: formula });
165
+ }),
166
+ );
167
+ root
168
+ .querySelectorAll<HTMLButtonElement>("[data-scenario]")
169
+ .forEach((button) =>
170
+ button.addEventListener("click", () => {
171
+ update(applyScenario(get(), button.dataset.scenario ?? ""));
172
+ }),
173
+ );
174
+ }
175
+
176
+ function bindImport(
177
+ root: HTMLElement,
178
+ update: Update,
179
+ ui: DamageFormulaLabUI,
180
+ ): void {
181
+ const picker = root.querySelector<HTMLInputElement>("[data-import]");
182
+ picker?.addEventListener("change", async () => {
183
+ try {
184
+ const file = picker.files?.[0];
185
+ if (file) update(parseConfig(JSON.parse(await file.text())));
186
+ } catch {
187
+ const status = root.querySelector<HTMLElement>("[data-status]");
188
+ if (status) status.textContent = ui.importError;
189
+ }
190
+ });
191
+ }
192
+
193
+ function bindExports(
194
+ root: HTMLElement,
195
+ get: GetConfig,
196
+ update: Update,
197
+ ui: DamageFormulaLabUI,
198
+ ): void {
199
+ root
200
+ .querySelector("[data-reset]")
201
+ ?.addEventListener("click", () => update(structuredClone(defaultConfig)));
202
+ root
203
+ .querySelector("[data-json]")
204
+ ?.addEventListener("click", () => exportJson(get()));
205
+ root
206
+ .querySelector("[data-csv]")
207
+ ?.addEventListener("click", () => exportCsv(get()));
208
+ root
209
+ .querySelector("[data-png]")
210
+ ?.addEventListener("click", () => exportChartPng(root));
211
+ root.querySelector("[data-share]")?.addEventListener("click", async () => {
212
+ await copyShareLink(get());
213
+ const status = root.querySelector<HTMLElement>("[data-status]");
214
+ if (status) status.textContent = ui.copiedStatus;
215
+ });
216
+ bindImport(root, update, ui);
217
+ }
218
+
219
+ export function mountDamageFormulaLab(
220
+ root: HTMLElement,
221
+ ui: DamageFormulaLabUI,
222
+ ): void {
223
+ let config = initialConfig();
224
+ const update: Update = (next) => {
225
+ config = next;
226
+ writeInputs(root, config);
227
+ save(config);
228
+ renderLab(root, config, ui);
229
+ };
230
+ root.addEventListener("input", () => update(readConfig(root, config)));
231
+ bindModes(root, () => config, update);
232
+ bindPresets(root, () => config, update);
233
+ bindExports(root, () => config, update, ui);
234
+ update(config);
235
+ }