@jjlmoya/utils-games-development 1.62.0 → 1.63.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 (41) 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/hitboxHurtboxAnimator/bibliography.astro +12 -0
  8. package/src/tool/hitboxHurtboxAnimator/bibliography.ts +10 -0
  9. package/src/tool/hitboxHurtboxAnimator/canvas-interactions.ts +59 -0
  10. package/src/tool/hitboxHurtboxAnimator/canvas-metrics.ts +8 -0
  11. package/src/tool/hitboxHurtboxAnimator/component.astro +148 -0
  12. package/src/tool/hitboxHurtboxAnimator/controller.ts +247 -0
  13. package/src/tool/hitboxHurtboxAnimator/dom-views.ts +194 -0
  14. package/src/tool/hitboxHurtboxAnimator/editor-state.ts +19 -0
  15. package/src/tool/hitboxHurtboxAnimator/entry.ts +28 -0
  16. package/src/tool/hitboxHurtboxAnimator/evaluator.ts +15 -0
  17. package/src/tool/hitboxHurtboxAnimator/file-io.ts +82 -0
  18. package/src/tool/hitboxHurtboxAnimator/hitbox-hurtbox-animator.css +614 -0
  19. package/src/tool/hitboxHurtboxAnimator/i18n/de.ts +195 -0
  20. package/src/tool/hitboxHurtboxAnimator/i18n/en.ts +191 -0
  21. package/src/tool/hitboxHurtboxAnimator/i18n/es.ts +195 -0
  22. package/src/tool/hitboxHurtboxAnimator/i18n/fr.ts +195 -0
  23. package/src/tool/hitboxHurtboxAnimator/i18n/id.ts +195 -0
  24. package/src/tool/hitboxHurtboxAnimator/i18n/it.ts +195 -0
  25. package/src/tool/hitboxHurtboxAnimator/i18n/ja.ts +195 -0
  26. package/src/tool/hitboxHurtboxAnimator/i18n/ko.ts +195 -0
  27. package/src/tool/hitboxHurtboxAnimator/i18n/nl.ts +195 -0
  28. package/src/tool/hitboxHurtboxAnimator/i18n/pl.ts +195 -0
  29. package/src/tool/hitboxHurtboxAnimator/i18n/pt.ts +195 -0
  30. package/src/tool/hitboxHurtboxAnimator/i18n/ru.ts +195 -0
  31. package/src/tool/hitboxHurtboxAnimator/i18n/sv.ts +195 -0
  32. package/src/tool/hitboxHurtboxAnimator/i18n/tr.ts +195 -0
  33. package/src/tool/hitboxHurtboxAnimator/i18n/zh.ts +195 -0
  34. package/src/tool/hitboxHurtboxAnimator/index.ts +11 -0
  35. package/src/tool/hitboxHurtboxAnimator/logic.test.ts +97 -0
  36. package/src/tool/hitboxHurtboxAnimator/logic.ts +173 -0
  37. package/src/tool/hitboxHurtboxAnimator/project-actions.ts +128 -0
  38. package/src/tool/hitboxHurtboxAnimator/seo.astro +13 -0
  39. package/src/tool/hitboxHurtboxAnimator/storage.ts +27 -0
  40. package/src/tool/hitboxHurtboxAnimator/ui.ts +70 -0
  41. 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.62.0",
3
+ "version": "1.63.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -5,10 +5,11 @@ import { audioLoopPointFinder } from '../tool/audioLoopPointFinder/entry';
5
5
  import { saveFileEditor } from '../tool/saveFileEditor/entry';
6
6
  import { steamBbcodeTranslator } from '../tool/steamBbcodeTranslator/entry';
7
7
  import { gameUiAccessibilityTester } from '../tool/gameUIAccessibilityTester/entry';
8
+ import { hitboxHurtboxAnimator } from '../tool/hitboxHurtboxAnimator/entry';
8
9
 
9
10
  export const gamesCategory: GamesCategoryEntry = {
10
11
  icon: 'mdi:gamepad-variant',
11
- tools: [steamCapsuleGenerator, spriteSheetPacker, audioLoopPointFinder, saveFileEditor, steamBbcodeTranslator, gameUiAccessibilityTester],
12
+ tools: [steamCapsuleGenerator, spriteSheetPacker, audioLoopPointFinder, saveFileEditor, steamBbcodeTranslator, gameUiAccessibilityTester, hitboxHurtboxAnimator],
12
13
  i18n: {
13
14
  de: () => import('./i18n/de').then((m) => m.content),
14
15
  en: () => import('./i18n/en').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -16,6 +16,8 @@ export { retroSfxGenerator } from './tool/retroSfxGenerator/entry';
16
16
  export type { RetroSfxGeneratorUI, RetroSfxGeneratorLocaleContent } from './tool/retroSfxGenerator/entry';
17
17
  export { pixelArtPaletteSwapper } from './tool/pixelArtPaletteSwapper/entry';
18
18
  export type { PixelArtPaletteSwapperUI, PixelArtPaletteSwapperLocaleContent } from './tool/pixelArtPaletteSwapper/entry';
19
+ export { hitboxHurtboxAnimator } from './tool/hitboxHurtboxAnimator/entry';
20
+ export type { HitboxHurtboxAnimatorUI, HitboxHurtboxAnimatorLocaleContent } from './tool/hitboxHurtboxAnimator/entry';
19
21
  export { gamesCategory } from './category';
20
22
  import { steamCapsuleGenerator } from './tool/steamCapsuleGenerator/entry';
21
23
  import { itchioGameTester } from './tool/itchioGameTester/entry';
@@ -27,5 +29,6 @@ import { steamBbcodeTranslator } from './tool/steamBbcodeTranslator/entry';
27
29
  import { retroSfxGenerator } from './tool/retroSfxGenerator/entry';
28
30
  import { pixelArtPaletteSwapper } from './tool/pixelArtPaletteSwapper/entry';
29
31
  import { gameUiAccessibilityTester } from './tool/gameUIAccessibilityTester/entry';
32
+ import { hitboxHurtboxAnimator } from './tool/hitboxHurtboxAnimator/entry';
30
33
 
31
- export const ALL_ENTRIES = [steamCapsuleGenerator, itchioGameTester, spriteSheetPacker, audioLoopPointFinder, saveFileEditor, localizationSanitizer, steamBbcodeTranslator, retroSfxGenerator, pixelArtPaletteSwapper, gameUiAccessibilityTester];
34
+ export const ALL_ENTRIES = [steamCapsuleGenerator, itchioGameTester, spriteSheetPacker, audioLoopPointFinder, saveFileEditor, localizationSanitizer, steamBbcodeTranslator, retroSfxGenerator, pixelArtPaletteSwapper, gameUiAccessibilityTester, hitboxHurtboxAnimator];
package/src/index.ts CHANGED
@@ -21,5 +21,6 @@ export { STEAM_BBCODE_TRANSLATOR_TOOL, steamBbcodeTranslator } from './tool/stea
21
21
  export { RETRO_SFX_GENERATOR_TOOL, retroSfxGenerator } from './tool/retroSfxGenerator';
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
+ export { HITBOX_HURTBOX_ANIMATOR_TOOL, hitboxHurtboxAnimator } from './tool/hitboxHurtboxAnimator';
24
25
 
25
26
  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('ten tools are registered', () => {
6
- expect(ALL_TOOLS.length).toBe(10);
5
+ it('eleven tools are registered', () => {
6
+ expect(ALL_TOOLS.length).toBe(11);
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 10 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(10);
7
+ it('should have 11 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(11);
9
9
  });
10
10
 
11
11
  it('gamesCategory should be defined', () => {
@@ -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>
@@ -0,0 +1,247 @@
1
+ import type { EditorState } from './editor-state';
2
+ import type { HitboxHurtboxAnimatorUI } from './ui';
3
+ import type { CollisionShape, CollisionType, ShapeGeometry } from './logic';
4
+ import { createProject, parseProject, serializeProject } from './logic';
5
+ import { bindCanvas } from './canvas-interactions';
6
+ import { downloadContactSheet, downloadText, loadLocalImages } from './file-io';
7
+ import { formatCopy, renderEditor, setStatus } from './dom-views';
8
+ import { loadPreferences, savePreferences } from './storage';
9
+ import {
10
+ copyCurrentToAll,
11
+ copyPreviousFrame,
12
+ checkpoint,
13
+ deleteSelected,
14
+ duplicateSelected,
15
+ mirrorSelected,
16
+ redoProject,
17
+ replaceSlicing,
18
+ undoProject,
19
+ updateSelected,
20
+ } from './project-actions';
21
+
22
+ const input = (root: HTMLElement, selector: string): HTMLInputElement | null => root.querySelector<HTMLInputElement>(selector);
23
+
24
+ function readUi(root: HTMLElement): HitboxHurtboxAnimatorUI {
25
+ const source = root.querySelector<HTMLScriptElement>('[data-role="locale-data"]')?.textContent ?? '{}';
26
+ return JSON.parse(source) as HitboxHurtboxAnimatorUI;
27
+ }
28
+
29
+ function createState(root: HTMLElement): EditorState {
30
+ const preferences = loadPreferences();
31
+ const project = createProject([]);
32
+ project.fps = preferences.fps;
33
+ return { root, ui: readUi(root), project, images: [], currentFrame: 0, selectedId: '', mode: 'draw', collisionType: 'hitbox', geometry: 'rectangle', playing: false, preferences, undoStack: [], redoStack: [] };
34
+ }
35
+
36
+ function stepFrame(state: EditorState, amount: number): void {
37
+ const count = state.project.frames.length;
38
+ if (count === 0) return;
39
+ state.currentFrame = (state.currentFrame + amount + count) % count;
40
+ state.selectedId = '';
41
+ renderEditor(state);
42
+ }
43
+
44
+ function bindPlayback(state: EditorState): (playing: boolean) => void {
45
+ let timer = 0;
46
+ return (playing) => {
47
+ window.clearInterval(timer);
48
+ state.playing = playing;
49
+ if (playing) timer = window.setInterval(() => stepFrame(state, 1), 1000 / state.preferences.fps);
50
+ renderEditor(state);
51
+ };
52
+ }
53
+
54
+ async function loadSprites(state: EditorState, files: File[]): Promise<void> {
55
+ if (files.length === 0) return;
56
+ try {
57
+ const loaded = await loadLocalImages(files);
58
+ state.images = loaded.elements;
59
+ state.project = createProject(loaded.references);
60
+ state.project.fps = state.preferences.fps;
61
+ state.currentFrame = 0;
62
+ state.selectedId = '';
63
+ state.undoStack = [];
64
+ state.redoStack = [];
65
+ setStatus(state, formatCopy(state.ui.statusImageLoaded, { count: files.length }));
66
+ renderEditor(state);
67
+ } catch {
68
+ setStatus(state, state.ui.statusError);
69
+ }
70
+ }
71
+
72
+ function bindFiles(state: EditorState): void {
73
+ const spriteInput = input(state.root, '[data-role="sprite-input"]');
74
+ const projectInput = input(state.root, '[data-role="project-input"]');
75
+ spriteInput?.addEventListener('change', () => void loadSprites(state, Array.from(spriteInput.files ?? [])));
76
+ projectInput?.addEventListener('change', () => void importProject(state, projectInput.files?.[0]));
77
+ const drop = state.root.querySelector<HTMLElement>('[data-role="dropzone"]');
78
+ drop?.addEventListener('dragover', (event) => event.preventDefault());
79
+ drop?.addEventListener('drop', (event) => {
80
+ event.preventDefault();
81
+ void loadSprites(state, Array.from(event.dataTransfer?.files ?? []));
82
+ });
83
+ }
84
+
85
+ async function importProject(state: EditorState, file?: File): Promise<void> {
86
+ if (!file) return;
87
+ try {
88
+ state.project = parseProject(await file.text());
89
+ state.currentFrame = 0;
90
+ state.selectedId = '';
91
+ state.undoStack = [];
92
+ state.redoStack = [];
93
+ setStatus(state, state.ui.statusImported);
94
+ renderEditor(state);
95
+ } catch {
96
+ setStatus(state, state.ui.statusError);
97
+ }
98
+ }
99
+
100
+ function updatePreference(state: EditorState, target: HTMLInputElement): void {
101
+ if (target.dataset.preference === 'fps') {
102
+ state.preferences.fps = Math.max(1, Math.min(30, Number(target.value)));
103
+ state.project.fps = state.preferences.fps;
104
+ }
105
+ if (target.dataset.preference === 'onionPrevious') state.preferences.onionPrevious = target.checked;
106
+ if (target.dataset.preference === 'onionNext') state.preferences.onionNext = target.checked;
107
+ savePreferences(state.preferences);
108
+ renderEditor(state);
109
+ }
110
+
111
+ function bindPreferences(state: EditorState): void {
112
+ const fps = input(state.root, '[data-preference="fps"]');
113
+ const previous = input(state.root, '[data-preference="onionPrevious"]');
114
+ const next = input(state.root, '[data-preference="onionNext"]');
115
+ if (fps) fps.value = String(state.preferences.fps);
116
+ if (previous) previous.checked = state.preferences.onionPrevious;
117
+ if (next) next.checked = state.preferences.onionNext;
118
+ state.root.querySelectorAll<HTMLInputElement>('[data-preference]').forEach((item) => item.addEventListener('input', () => updatePreference(state, item)));
119
+ }
120
+
121
+ function numericUpdate(field: string, value: number): Partial<CollisionShape> {
122
+ if (field === 'radius') return { width: value * 2, height: value * 2 };
123
+ if (field === 'x') return { x: value };
124
+ if (field === 'y') return { y: value };
125
+ if (field === 'width') return { width: value };
126
+ if (field === 'height') return { height: value };
127
+ return {};
128
+ }
129
+
130
+ function updateField(state: EditorState, target: HTMLInputElement): void {
131
+ const field = target.dataset.field ?? '';
132
+ const frame = state.project.frames[state.currentFrame];
133
+ if (!frame) return;
134
+ if (field === 'pivotX') {
135
+ checkpoint(state);
136
+ frame.pivot.x = Math.max(0, Math.min(frame.width, Number(target.value)));
137
+ } else if (field === 'pivotY') {
138
+ checkpoint(state);
139
+ frame.pivot.y = Math.max(0, Math.min(frame.height, Number(target.value)));
140
+ }
141
+ else if (field === 'name') updateSelected(state, { name: target.value });
142
+ else updateSelected(state, numericUpdate(field, Number(target.value)));
143
+ setStatus(state, state.ui.statusShapeUpdated);
144
+ renderEditor(state);
145
+ }
146
+
147
+ function bindFields(state: EditorState): void {
148
+ state.root.querySelectorAll<HTMLInputElement>('[data-field]').forEach((field) => field.addEventListener('change', () => updateField(state, field)));
149
+ }
150
+
151
+ function actionHandlers(state: EditorState, setPlaying: (playing: boolean) => void): Record<string, () => void> {
152
+ return {
153
+ previous: () => stepFrame(state, -1),
154
+ next: () => stepFrame(state, 1),
155
+ play: () => setPlaying(!state.playing),
156
+ slice: () => applySlice(state),
157
+ duplicate: () => runMutation(state, duplicateSelected),
158
+ mirror: () => runMutation(state, mirrorSelected),
159
+ delete: () => runMutation(state, deleteSelected),
160
+ copyPrevious: () => runMutation(state, copyPreviousFrame),
161
+ copyAll: () => runMutation(state, copyCurrentToAll),
162
+ undo: () => runMutation(state, undoProject),
163
+ redo: () => runMutation(state, redoProject),
164
+ exportJson: () => exportJson(state),
165
+ importJson: () => input(state.root, '[data-role="project-input"]')?.click(),
166
+ contactSheet: () => exportSheet(state),
167
+ reset: () => resetState(state),
168
+ };
169
+ }
170
+
171
+ function runMutation(state: EditorState, operation: (value: EditorState) => boolean): void {
172
+ if (!operation(state)) return;
173
+ setStatus(state, state.ui.statusShapeUpdated);
174
+ renderEditor(state);
175
+ }
176
+
177
+ function applySlice(state: EditorState): void {
178
+ const rows = Number(input(state.root, '[data-role="rows"]')?.value ?? 1);
179
+ const columns = Number(input(state.root, '[data-role="columns"]')?.value ?? 1);
180
+ replaceSlicing(state, rows, columns);
181
+ renderEditor(state);
182
+ }
183
+
184
+ function exportJson(state: EditorState): void {
185
+ downloadText(serializeProject(state.project), 'hitbox-animation.json');
186
+ setStatus(state, state.ui.statusExported);
187
+ }
188
+
189
+ function exportSheet(state: EditorState): void {
190
+ downloadContactSheet(state.project, state.images);
191
+ setStatus(state, state.ui.statusExported);
192
+ }
193
+
194
+ function resetState(state: EditorState): void {
195
+ state.project = createProject(state.project.images, state.project.rows, state.project.columns);
196
+ state.project.fps = state.preferences.fps;
197
+ state.currentFrame = 0;
198
+ state.selectedId = '';
199
+ state.undoStack = [];
200
+ state.redoStack = [];
201
+ setStatus(state, state.ui.statusReady);
202
+ renderEditor(state);
203
+ }
204
+
205
+ function bindClicks(state: EditorState, setPlaying: (playing: boolean) => void): void {
206
+ const actions = actionHandlers(state, setPlaying);
207
+ state.root.addEventListener('click', (event) => {
208
+ const target = (event.target as Element).closest<HTMLElement>('[data-action], [data-type], [data-geometry], [data-mode], [data-frame]');
209
+ if (!target) return;
210
+ if (target.dataset.type) state.collisionType = target.dataset.type as CollisionType;
211
+ if (target.dataset.geometry) state.geometry = target.dataset.geometry as ShapeGeometry;
212
+ if (target.dataset.mode) state.mode = target.dataset.mode as EditorState['mode'];
213
+ if (target.dataset.frame !== undefined) state.currentFrame = Number(target.dataset.frame);
214
+ if (target.dataset.action) actions[target.dataset.action]?.();
215
+ renderEditor(state);
216
+ });
217
+ }
218
+
219
+ function bindKeyboard(state: EditorState): void {
220
+ state.root.addEventListener('keydown', (event) => {
221
+ if ((event.target as Element).matches('input')) return;
222
+ const modified = event.ctrlKey || event.metaKey;
223
+ const key = modified ? `mod-${event.key.toLowerCase()}` : event.key;
224
+ const handlers: Record<string, () => void> = {
225
+ ArrowLeft: () => stepFrame(state, -1),
226
+ ArrowRight: () => stepFrame(state, 1),
227
+ Delete: () => runMutation(state, deleteSelected),
228
+ 'mod-z': () => runMutation(state, undoProject),
229
+ 'mod-y': () => runMutation(state, redoProject),
230
+ };
231
+ handlers[key]?.();
232
+ });
233
+ }
234
+
235
+ export function mountHitboxHurtboxAnimator(root: HTMLElement): void {
236
+ if (root.dataset.ready) return;
237
+ root.dataset.ready = 'true';
238
+ const state = createState(root);
239
+ bindFiles(state);
240
+ bindPreferences(state);
241
+ bindFields(state);
242
+ bindCanvas(state);
243
+ bindClicks(state, bindPlayback(state));
244
+ bindKeyboard(state);
245
+ setStatus(state, state.ui.statusReady);
246
+ renderEditor(state);
247
+ }