@jjlmoya/utils-games-development 1.62.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 (73) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +3 -1
  3. package/src/entries.ts +7 -1
  4. package/src/index.ts +2 -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/tool/hitboxHurtboxAnimator/bibliography.astro +12 -0
  40. package/src/tool/hitboxHurtboxAnimator/bibliography.ts +10 -0
  41. package/src/tool/hitboxHurtboxAnimator/canvas-interactions.ts +59 -0
  42. package/src/tool/hitboxHurtboxAnimator/canvas-metrics.ts +8 -0
  43. package/src/tool/hitboxHurtboxAnimator/component.astro +148 -0
  44. package/src/tool/hitboxHurtboxAnimator/controller.ts +247 -0
  45. package/src/tool/hitboxHurtboxAnimator/dom-views.ts +194 -0
  46. package/src/tool/hitboxHurtboxAnimator/editor-state.ts +19 -0
  47. package/src/tool/hitboxHurtboxAnimator/entry.ts +28 -0
  48. package/src/tool/hitboxHurtboxAnimator/evaluator.ts +15 -0
  49. package/src/tool/hitboxHurtboxAnimator/file-io.ts +82 -0
  50. package/src/tool/hitboxHurtboxAnimator/hitbox-hurtbox-animator.css +614 -0
  51. package/src/tool/hitboxHurtboxAnimator/i18n/de.ts +195 -0
  52. package/src/tool/hitboxHurtboxAnimator/i18n/en.ts +191 -0
  53. package/src/tool/hitboxHurtboxAnimator/i18n/es.ts +195 -0
  54. package/src/tool/hitboxHurtboxAnimator/i18n/fr.ts +195 -0
  55. package/src/tool/hitboxHurtboxAnimator/i18n/id.ts +195 -0
  56. package/src/tool/hitboxHurtboxAnimator/i18n/it.ts +195 -0
  57. package/src/tool/hitboxHurtboxAnimator/i18n/ja.ts +195 -0
  58. package/src/tool/hitboxHurtboxAnimator/i18n/ko.ts +195 -0
  59. package/src/tool/hitboxHurtboxAnimator/i18n/nl.ts +195 -0
  60. package/src/tool/hitboxHurtboxAnimator/i18n/pl.ts +195 -0
  61. package/src/tool/hitboxHurtboxAnimator/i18n/pt.ts +195 -0
  62. package/src/tool/hitboxHurtboxAnimator/i18n/ru.ts +195 -0
  63. package/src/tool/hitboxHurtboxAnimator/i18n/sv.ts +195 -0
  64. package/src/tool/hitboxHurtboxAnimator/i18n/tr.ts +195 -0
  65. package/src/tool/hitboxHurtboxAnimator/i18n/zh.ts +195 -0
  66. package/src/tool/hitboxHurtboxAnimator/index.ts +11 -0
  67. package/src/tool/hitboxHurtboxAnimator/logic.test.ts +97 -0
  68. package/src/tool/hitboxHurtboxAnimator/logic.ts +173 -0
  69. package/src/tool/hitboxHurtboxAnimator/project-actions.ts +128 -0
  70. package/src/tool/hitboxHurtboxAnimator/seo.astro +13 -0
  71. package/src/tool/hitboxHurtboxAnimator/storage.ts +27 -0
  72. package/src/tool/hitboxHurtboxAnimator/ui.ts +70 -0
  73. package/src/tools.ts +4 -0
@@ -0,0 +1,151 @@
1
+ import type {
2
+ CombatSettings,
3
+ FormulaVariables,
4
+ ModifierOrder,
5
+ RoundingMode,
6
+ SweepRange,
7
+ } from "./logic";
8
+
9
+ export interface LabConfig {
10
+ version: 1;
11
+ formulaA: string;
12
+ formulaB: string;
13
+ variables: FormulaVariables;
14
+ settings: CombatSettings;
15
+ attackRange: SweepRange;
16
+ defenseRange: SweepRange;
17
+ }
18
+
19
+ export const defaultConfig: LabConfig = {
20
+ version: 1,
21
+ formulaA: "max(1, attack * power - defense)",
22
+ formulaB: "max(1, attack * power * 100 / (100 + defense))",
23
+ variables: {
24
+ attack: 80,
25
+ defense: 35,
26
+ level: 20,
27
+ power: 1.4,
28
+ resistance: 15,
29
+ flat: 3,
30
+ criticalChance: 20,
31
+ criticalMultiplier: 1.75,
32
+ },
33
+ settings: {
34
+ health: 450,
35
+ attacksPerSecond: 1.5,
36
+ rounding: "round",
37
+ modifierOrder: "resistance-first",
38
+ },
39
+ attackRange: { min: 40, max: 160, steps: 17 },
40
+ defenseRange: { min: 10, max: 90, steps: 9 },
41
+ };
42
+
43
+ export const formulaPresets = {
44
+ linear: "max(1, attack * power - defense)",
45
+ ratio: "max(1, attack * power * 100 / (100 + defense))",
46
+ level:
47
+ "max(1, ((2 * level + 10) / 250) * (attack / max(1, defense)) * power + 2)",
48
+ };
49
+
50
+ export const scenarios: Record<
51
+ string,
52
+ Partial<FormulaVariables> & { health: number }
53
+ > = {
54
+ skirmisher: { defense: 20, resistance: 5, health: 220 },
55
+ guardian: { defense: 70, resistance: 25, health: 700 },
56
+ boss: { defense: 110, resistance: 35, health: 2200 },
57
+ };
58
+
59
+ function finite(value: unknown, fallback: number): number {
60
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
61
+ }
62
+
63
+ function rounding(value: unknown): RoundingMode {
64
+ if (value === "none" || value === "floor" || value === "ceil") return value;
65
+ return "round";
66
+ }
67
+
68
+ function order(value: unknown): ModifierOrder {
69
+ return value === "flat-first" ? "flat-first" : "resistance-first";
70
+ }
71
+
72
+ function variables(value: unknown): FormulaVariables {
73
+ const source =
74
+ typeof value === "object" && value
75
+ ? (value as Partial<FormulaVariables>)
76
+ : {};
77
+ const base = defaultConfig.variables;
78
+ return {
79
+ attack: finite(source.attack, base.attack),
80
+ defense: finite(source.defense, base.defense),
81
+ level: finite(source.level, base.level),
82
+ power: finite(source.power, base.power),
83
+ resistance: finite(source.resistance, base.resistance),
84
+ flat: finite(source.flat, base.flat),
85
+ criticalChance: finite(source.criticalChance, base.criticalChance),
86
+ criticalMultiplier: finite(
87
+ source.criticalMultiplier,
88
+ base.criticalMultiplier,
89
+ ),
90
+ };
91
+ }
92
+
93
+ function range(value: unknown, fallback: SweepRange): SweepRange {
94
+ const source =
95
+ typeof value === "object" && value ? (value as Partial<SweepRange>) : {};
96
+ return {
97
+ min: finite(source.min, fallback.min),
98
+ max: finite(source.max, fallback.max),
99
+ steps: finite(source.steps, fallback.steps),
100
+ };
101
+ }
102
+
103
+ function combatSettings(value: unknown): CombatSettings {
104
+ const source =
105
+ typeof value === "object" && value
106
+ ? (value as Partial<CombatSettings>)
107
+ : {};
108
+ return {
109
+ health: finite(source.health, defaultConfig.settings.health),
110
+ attacksPerSecond: finite(
111
+ source.attacksPerSecond,
112
+ defaultConfig.settings.attacksPerSecond,
113
+ ),
114
+ rounding: rounding(source.rounding),
115
+ modifierOrder: order(source.modifierOrder),
116
+ };
117
+ }
118
+
119
+ export function parseConfig(value: unknown): LabConfig {
120
+ if (typeof value !== "object" || !value)
121
+ throw new Error("Invalid configuration");
122
+ const source = value as Partial<LabConfig>;
123
+ return {
124
+ version: 1,
125
+ formulaA:
126
+ typeof source.formulaA === "string"
127
+ ? source.formulaA
128
+ : defaultConfig.formulaA,
129
+ formulaB:
130
+ typeof source.formulaB === "string"
131
+ ? source.formulaB
132
+ : defaultConfig.formulaB,
133
+ variables: variables(source.variables),
134
+ settings: combatSettings(source.settings),
135
+ attackRange: range(source.attackRange, defaultConfig.attackRange),
136
+ defenseRange: range(source.defenseRange, defaultConfig.defenseRange),
137
+ };
138
+ }
139
+
140
+ export function configFromHash(hash: string): LabConfig | undefined {
141
+ try {
142
+ if (!hash.startsWith("#damage=")) return undefined;
143
+ return parseConfig(JSON.parse(decodeURIComponent(hash.slice(8))));
144
+ } catch {
145
+ return undefined;
146
+ }
147
+ }
148
+
149
+ export function configToHash(config: LabConfig): string {
150
+ return `#damage=${encodeURIComponent(JSON.stringify(config))}`;
151
+ }
@@ -0,0 +1,13 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import type { SEOSection } from '../../types';
4
+
5
+ interface Props {
6
+ locale?: string;
7
+ sections?: SEOSection[];
8
+ }
9
+
10
+ const { locale = 'en', sections = [] } = Astro.props;
11
+ ---
12
+
13
+ <SEORenderer content={{ locale, sections }} />
@@ -0,0 +1,62 @@
1
+ import type { ModifierOrder, RoundingMode } from "./logic";
2
+
3
+ export interface LabPreferences {
4
+ formulaA: string;
5
+ formulaB: string;
6
+ rounding: RoundingMode;
7
+ modifierOrder: ModifierOrder;
8
+ }
9
+
10
+ const STORAGE_KEY = "jjlmoya-damage-formula-lab";
11
+
12
+ export const defaultPreferences: LabPreferences = {
13
+ formulaA: "max(1, attack * power - defense)",
14
+ formulaB: "max(1, attack * power * 100 / (100 + defense))",
15
+ rounding: "round",
16
+ modifierOrder: "resistance-first",
17
+ };
18
+
19
+ function isRounding(value: unknown): value is RoundingMode {
20
+ return (
21
+ value === "none" ||
22
+ value === "floor" ||
23
+ value === "round" ||
24
+ value === "ceil"
25
+ );
26
+ }
27
+
28
+ function isOrder(value: unknown): value is ModifierOrder {
29
+ return value === "resistance-first" || value === "flat-first";
30
+ }
31
+
32
+ export function loadPreferences(): LabPreferences {
33
+ try {
34
+ const parsed = JSON.parse(
35
+ localStorage.getItem(STORAGE_KEY) ?? "",
36
+ ) as Partial<LabPreferences>;
37
+ return {
38
+ formulaA:
39
+ typeof parsed.formulaA === "string"
40
+ ? parsed.formulaA
41
+ : defaultPreferences.formulaA,
42
+ formulaB:
43
+ typeof parsed.formulaB === "string"
44
+ ? parsed.formulaB
45
+ : defaultPreferences.formulaB,
46
+ rounding: isRounding(parsed.rounding)
47
+ ? parsed.rounding
48
+ : defaultPreferences.rounding,
49
+ modifierOrder: isOrder(parsed.modifierOrder)
50
+ ? parsed.modifierOrder
51
+ : defaultPreferences.modifierOrder,
52
+ };
53
+ } catch {
54
+ return { ...defaultPreferences };
55
+ }
56
+ }
57
+
58
+ export function savePreferences(preferences: LabPreferences): void {
59
+ try {
60
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences));
61
+ } catch {}
62
+ }
@@ -0,0 +1,89 @@
1
+ import { buildCurve } from "./logic";
2
+ import { configToHash } from "./model";
3
+ import type { LabConfig } from "./model";
4
+
5
+ export function download(name: string, type: string, body: BlobPart): void {
6
+ const link = document.createElement("a");
7
+ link.href = URL.createObjectURL(new Blob([body], { type }));
8
+ link.download = name;
9
+ link.click();
10
+ URL.revokeObjectURL(link.href);
11
+ }
12
+
13
+ function curveRows(config: LabConfig): Array<Array<number | undefined>> {
14
+ const a = buildCurve(
15
+ config.formulaA,
16
+ config.variables,
17
+ config.settings,
18
+ config.attackRange,
19
+ );
20
+ const b = buildCurve(
21
+ config.formulaB,
22
+ config.variables,
23
+ config.settings,
24
+ config.attackRange,
25
+ );
26
+ return a.map((point, index) => [
27
+ point.attack,
28
+ point.defense,
29
+ point.roundedDamage,
30
+ point.hitsToKill,
31
+ point.timeToKill,
32
+ b[index]?.roundedDamage,
33
+ b[index]?.hitsToKill,
34
+ b[index]?.timeToKill,
35
+ ]);
36
+ }
37
+
38
+ export function exportCsv(config: LabConfig): void {
39
+ const header = [
40
+ "attack",
41
+ "defense",
42
+ "formula_a_damage",
43
+ "formula_a_hits",
44
+ "formula_a_ttk",
45
+ "formula_b_damage",
46
+ "formula_b_hits",
47
+ "formula_b_ttk",
48
+ ];
49
+ const body = [header, ...curveRows(config)]
50
+ .map((row) => row.join(","))
51
+ .join("\n");
52
+ download("damage-formula-sweep.csv", "text/csv", body);
53
+ }
54
+
55
+ export function exportJson(config: LabConfig): void {
56
+ download(
57
+ "damage-formula-lab.json",
58
+ "application/json",
59
+ JSON.stringify(config, null, 2),
60
+ );
61
+ }
62
+
63
+ function drawPng(image: HTMLImageElement): void {
64
+ const canvas = document.createElement("canvas");
65
+ canvas.width = 1200;
66
+ canvas.height = 700;
67
+ const context = canvas.getContext("2d");
68
+ if (!context) return;
69
+ context.fillStyle = "#f7f4ee";
70
+ context.fillRect(0, 0, canvas.width, canvas.height);
71
+ context.drawImage(image, 70, 70, 1060, 560);
72
+ canvas.toBlob((blob) => {
73
+ if (blob) download("damage-formula-curve.png", "image/png", blob);
74
+ }, "image/png");
75
+ }
76
+
77
+ export function exportChartPng(root: HTMLElement): void {
78
+ const svg = root.querySelector<SVGSVGElement>("[data-chart]");
79
+ if (!svg) return;
80
+ const source = new XMLSerializer().serializeToString(svg);
81
+ const image = new Image();
82
+ image.onload = () => drawPng(image);
83
+ image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(source)}`;
84
+ }
85
+
86
+ export async function copyShareLink(config: LabConfig): Promise<void> {
87
+ const url = `${location.origin}${location.pathname}${configToHash(config)}`;
88
+ await navigator.clipboard.writeText(url);
89
+ }
@@ -0,0 +1,62 @@
1
+ export interface DamageFormulaLabUI extends Record<string, string> {
2
+ onboarding: string;
3
+ localNote: string;
4
+ formulaDeck: string;
5
+ formulaALabel: string;
6
+ formulaBLabel: string;
7
+ formulaHint: string;
8
+ formulaFunctions: string;
9
+ presetLinear: string;
10
+ presetRatio: string;
11
+ presetLevel: string;
12
+ combatInputs: string;
13
+ attackLabel: string;
14
+ defenseLabel: string;
15
+ levelLabel: string;
16
+ powerLabel: string;
17
+ resistanceLabel: string;
18
+ flatLabel: string;
19
+ criticalChanceLabel: string;
20
+ criticalMultiplierLabel: string;
21
+ healthLabel: string;
22
+ cadenceLabel: string;
23
+ roundingLabel: string;
24
+ roundingNone: string;
25
+ roundingFloor: string;
26
+ roundingRound: string;
27
+ roundingCeil: string;
28
+ orderLabel: string;
29
+ resistanceFirst: string;
30
+ flatFirst: string;
31
+ runLabel: string;
32
+ resultDamage: string;
33
+ resultHits: string;
34
+ resultTtk: string;
35
+ resultDifference: string;
36
+ formulaAName: string;
37
+ formulaBName: string;
38
+ curveTitle: string;
39
+ curveCaption: string;
40
+ heatmapTitle: string;
41
+ heatmapCaption: string;
42
+ attackAxis: string;
43
+ defenseAxis: string;
44
+ scenariosTitle: string;
45
+ scenarioSkirmisher: string;
46
+ scenarioGuardian: string;
47
+ scenarioBoss: string;
48
+ scenarioCustom: string;
49
+ diagnosticsTitle: string;
50
+ statusBalanced: string;
51
+ exportTitle: string;
52
+ copyLink: string;
53
+ exportCsv: string;
54
+ exportJson: string;
55
+ importJson: string;
56
+ exportPng: string;
57
+ reset: string;
58
+ privacyDisclosure: string;
59
+ limitationDisclosure: string;
60
+ importError: string;
61
+ copiedStatus: string;
62
+ }
@@ -0,0 +1,12 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import type { BibliographyEntry } from '../../types';
4
+
5
+ interface Props {
6
+ links?: BibliographyEntry[];
7
+ }
8
+
9
+ const { links = [] } = Astro.props;
10
+ ---
11
+
12
+ <SharedBibliography links={links} />
@@ -0,0 +1,10 @@
1
+ export const bibliographyEntries = [
2
+ {
3
+ name: 'Godot Engine: Rect2 coordinate and size reference',
4
+ url: 'https://docs.godotengine.org/en/stable/classes/class_rect2.html',
5
+ },
6
+ {
7
+ name: 'Unity Manual: Sprite Editor Custom Physics Shape',
8
+ url: 'https://docs.unity3d.com/es/2021.1/Manual/CustomPhysicsShape.html',
9
+ },
10
+ ];
@@ -0,0 +1,59 @@
1
+ import type { EditorState } from './editor-state';
2
+ import { renderDraft, renderEditor, setStatus } from './dom-views';
3
+ import { canvasPoint } from './canvas-metrics';
4
+ import { shapeAtPoint } from './logic';
5
+ import { addDrawnShape, checkpoint, moveSelected } from './project-actions';
6
+
7
+ interface PointerSession {
8
+ start: { x: number; y: number };
9
+ last: { x: number; y: number };
10
+ dragging: boolean;
11
+ checkpointed: boolean;
12
+ }
13
+
14
+ export function bindCanvas(state: EditorState): void {
15
+ const canvas = state.root.querySelector<HTMLCanvasElement>('[data-role="stage"]');
16
+ if (!canvas) return;
17
+ let session: PointerSession | undefined;
18
+ canvas.addEventListener('pointerdown', (event) => {
19
+ const point = canvasPoint(canvas, event);
20
+ session = { start: point, last: point, dragging: false, checkpointed: false };
21
+ canvas.setPointerCapture(event.pointerId);
22
+ if (state.mode === 'draw') return;
23
+ const frame = state.project.frames[state.currentFrame];
24
+ state.selectedId = frame ? shapeAtPoint(frame.shapes, point.x, point.y)?.id ?? '' : '';
25
+ renderEditor(state);
26
+ });
27
+ canvas.addEventListener('pointermove', (event) => movePointer(state, canvas, event, session));
28
+ canvas.addEventListener('pointerup', (event) => {
29
+ endPointer(state, canvas, event, session);
30
+ session = undefined;
31
+ });
32
+ canvas.addEventListener('pointercancel', () => {
33
+ session = undefined;
34
+ renderEditor(state);
35
+ });
36
+ }
37
+
38
+ function movePointer(state: EditorState, canvas: HTMLCanvasElement, event: PointerEvent, session?: PointerSession): void {
39
+ if (!session) return;
40
+ const point = canvasPoint(canvas, event);
41
+ session.dragging = Math.abs(point.x - session.start.x) > 0.5 || Math.abs(point.y - session.start.y) > 0.5;
42
+ if (state.mode === 'draw') {
43
+ renderDraft(state, session.start, point);
44
+ } else if (state.selectedId && session.dragging) {
45
+ if (!session.checkpointed) checkpoint(state);
46
+ session.checkpointed = true;
47
+ moveSelected(state, point.x - session.last.x, point.y - session.last.y);
48
+ renderEditor(state);
49
+ }
50
+ session.last = point;
51
+ }
52
+
53
+ function endPointer(state: EditorState, canvas: HTMLCanvasElement, event: PointerEvent, session?: PointerSession): void {
54
+ if (!session) return;
55
+ const point = canvasPoint(canvas, event);
56
+ if (state.mode === 'draw' && addDrawnShape(state, session.start, point)) setStatus(state, state.ui.statusShapeCreated);
57
+ else if (session.checkpointed) setStatus(state, state.ui.statusShapeUpdated);
58
+ renderEditor(state);
59
+ }
@@ -0,0 +1,8 @@
1
+ export function canvasPoint(canvas: HTMLCanvasElement, event: PointerEvent): { x: number; y: number } {
2
+ const rect = canvas.getBoundingClientRect();
3
+ const scale = Number(canvas.dataset.scale ?? 1);
4
+ return {
5
+ x: (event.clientX - rect.left) * (canvas.width / rect.width) / scale,
6
+ y: (event.clientY - rect.top) * (canvas.height / rect.height) / scale,
7
+ };
8
+ }
@@ -0,0 +1,148 @@
1
+ ---
2
+ import type { HitboxHurtboxAnimatorUI } from './ui';
3
+
4
+ interface Props {
5
+ ui: HitboxHurtboxAnimatorUI;
6
+ }
7
+
8
+ const { ui } = Astro.props;
9
+ ---
10
+
11
+ <div class="hha" data-hitbox-root tabindex="0">
12
+ <script is:inline type="application/json" data-role="locale-data" set:html={JSON.stringify(ui)}></script>
13
+
14
+ <div class="hha-intro">
15
+ <p>{ui.onboarding}</p>
16
+ <span>{ui.privacyNote}</span>
17
+ </div>
18
+
19
+ <section class="hha-load" data-role="dropzone">
20
+ <div>
21
+ <strong>{ui.loadSprite}</strong>
22
+ <p>{ui.loadHint}</p>
23
+ </div>
24
+ <label class="hha-button hha-button-primary">
25
+ {ui.chooseImages}
26
+ <input data-role="sprite-input" type="file" accept="image/png,image/webp" multiple />
27
+ </label>
28
+ </section>
29
+
30
+ <div class="hha-control-deck">
31
+ <fieldset>
32
+ <legend>{ui.slicingTitle}</legend>
33
+ <label>{ui.rowsLabel}<input data-role="rows" type="number" min="1" max="64" value="1" /></label>
34
+ <label>{ui.columnsLabel}<input data-role="columns" type="number" min="1" max="64" value="1" /></label>
35
+ <button type="button" class="hha-button" data-action="slice">{ui.applySlicing}</button>
36
+ </fieldset>
37
+ <fieldset>
38
+ <legend>{ui.playbackTitle}</legend>
39
+ <div class="hha-transport">
40
+ <button type="button" class="hha-icon-button is-previous" data-action="previous" aria-label={ui.previousFrame}><span></span></button>
41
+ <button type="button" class="hha-button hha-play" data-action="play">{ui.play}</button>
42
+ <button type="button" class="hha-icon-button is-next" data-action="next" aria-label={ui.nextFrame}><span></span></button>
43
+ </div>
44
+ <label class="hha-range">{ui.fpsLabel}<input data-preference="fps" type="range" min="1" max="30" value="12" /></label>
45
+ <label class="hha-check"><input data-preference="onionPrevious" type="checkbox" />{ui.onionPrevious}</label>
46
+ <label class="hha-check"><input data-preference="onionNext" type="checkbox" />{ui.onionNext}</label>
47
+ </fieldset>
48
+ <div class="hha-history">
49
+ <button type="button" class="hha-button" data-action="undo">{ui.undo}</button>
50
+ <button type="button" class="hha-button" data-action="redo">{ui.redo}</button>
51
+ </div>
52
+ </div>
53
+
54
+ <div class="hha-workbench">
55
+ <aside class="hha-layers">
56
+ <h2>{ui.layerTitle}</h2>
57
+ <div class="hha-mode-switch">
58
+ <button type="button" data-mode="draw">{ui.drawShape}</button>
59
+ <button type="button" data-mode="select">{ui.selectShape}</button>
60
+ </div>
61
+ <div class="hha-type-rack">
62
+ <button type="button" data-type="hitbox"><i></i>{ui.typeHitbox}</button>
63
+ <button type="button" data-type="hurtbox"><i></i>{ui.typeHurtbox}</button>
64
+ <button type="button" data-type="pushbox"><i></i>{ui.typePushbox}</button>
65
+ <button type="button" data-type="grabbox"><i></i>{ui.typeGrabbox}</button>
66
+ <button type="button" data-type="sensor"><i></i>{ui.typeSensor}</button>
67
+ <button type="button" data-type="custom"><i></i>{ui.typeCustom}</button>
68
+ </div>
69
+ <div class="hha-geometry">
70
+ <button type="button" data-geometry="rectangle"><span></span>{ui.shapeRectangle}</button>
71
+ <button type="button" data-geometry="circle"><span></span>{ui.shapeCircle}</button>
72
+ </div>
73
+ </aside>
74
+
75
+ <main class="hha-stage-column">
76
+ <div class="hha-stage-header">
77
+ <span>{ui.stageLabel}</span>
78
+ <div class="hha-badges">
79
+ <b data-role="frames-badge"></b>
80
+ <b data-role="shapes-badge"></b>
81
+ <b data-role="coverage-badge"></b>
82
+ </div>
83
+ </div>
84
+ <div class="hha-light-table">
85
+ <canvas data-role="stage" aria-label={ui.stageLabel}></canvas>
86
+ <div class="hha-empty-stage">
87
+ <span></span>
88
+ <p>{ui.emptyStage}</p>
89
+ </div>
90
+ </div>
91
+ <div class="hha-timeline-wrap">
92
+ <strong>{ui.timelineTitle}</strong>
93
+ <div class="hha-timeline" data-role="timeline"></div>
94
+ </div>
95
+ </main>
96
+
97
+ <aside class="hha-inspector">
98
+ <h2>{ui.inspectorTitle}</h2>
99
+ <p class="hha-no-selection" data-role="no-selection">{ui.noSelection}</p>
100
+ <div class="hha-fields" data-role="shape-fields" hidden>
101
+ <label class="hha-wide">{ui.nameLabel}<input data-field="name" type="text" /></label>
102
+ <label>{ui.xLabel}<input data-field="x" type="number" step="0.5" /></label>
103
+ <label>{ui.yLabel}<input data-field="y" type="number" step="0.5" /></label>
104
+ <label>{ui.widthLabel}<input data-field="width" type="number" min="1" step="0.5" /></label>
105
+ <label>{ui.heightLabel}<input data-field="height" type="number" min="1" step="0.5" /></label>
106
+ <label class="hha-wide" data-radius-row>{ui.radiusLabel}<input data-field="radius" type="number" min="0.5" step="0.5" /></label>
107
+ <button type="button" data-action="duplicate">{ui.duplicateShape}</button>
108
+ <button type="button" data-action="mirror">{ui.mirrorShape}</button>
109
+ <button type="button" class="hha-danger hha-wide" data-action="delete">{ui.deleteShape}</button>
110
+ </div>
111
+ <div class="hha-copy-actions">
112
+ <button type="button" data-action="copyPrevious">{ui.copyPrevious}</button>
113
+ <button type="button" data-action="copyAll">{ui.copyAll}</button>
114
+ </div>
115
+ <fieldset class="hha-pivot">
116
+ <legend>{ui.pivotTitle}</legend>
117
+ <label>{ui.pivotXLabel}<input data-field="pivotX" type="number" min="0" step="0.5" /></label>
118
+ <label>{ui.pivotYLabel}<input data-field="pivotY" type="number" min="0" step="0.5" /></label>
119
+ </fieldset>
120
+ <p class="hha-coordinate-note">{ui.coordinatesNote}</p>
121
+ </aside>
122
+ </div>
123
+
124
+ <section class="hha-export">
125
+ <div>
126
+ <strong>{ui.exportTitle}</strong>
127
+ <p>{ui.localOnlyDisclosure}</p>
128
+ </div>
129
+ <div>
130
+ <button type="button" class="hha-button hha-button-primary" data-action="exportJson">{ui.exportJson}</button>
131
+ <button type="button" class="hha-button" data-action="importJson">{ui.importJson}</button>
132
+ <button type="button" class="hha-button" data-action="contactSheet">{ui.exportContactSheet}</button>
133
+ <button type="button" class="hha-button hha-danger" data-action="reset">{ui.resetProject}</button>
134
+ <input data-role="project-input" type="file" accept="application/json,.json" aria-label={ui.importJson} />
135
+ </div>
136
+ </section>
137
+
138
+ <div class="hha-footer-line">
139
+ <p data-role="status" aria-live="polite"></p>
140
+ <p>{ui.limitationDisclosure}</p>
141
+ </div>
142
+ </div>
143
+
144
+ <script>
145
+ import { mountHitboxHurtboxAnimator } from './controller';
146
+
147
+ document.querySelectorAll<HTMLElement>('[data-hitbox-root]').forEach(mountHitboxHurtboxAnimator);
148
+ </script>