@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.
- package/package.json +1 -1
- package/src/category/index.ts +2 -1
- package/src/entries.ts +4 -1
- package/src/index.ts +1 -0
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/hitboxHurtboxAnimator/bibliography.astro +12 -0
- package/src/tool/hitboxHurtboxAnimator/bibliography.ts +10 -0
- package/src/tool/hitboxHurtboxAnimator/canvas-interactions.ts +59 -0
- package/src/tool/hitboxHurtboxAnimator/canvas-metrics.ts +8 -0
- package/src/tool/hitboxHurtboxAnimator/component.astro +148 -0
- package/src/tool/hitboxHurtboxAnimator/controller.ts +247 -0
- package/src/tool/hitboxHurtboxAnimator/dom-views.ts +194 -0
- package/src/tool/hitboxHurtboxAnimator/editor-state.ts +19 -0
- package/src/tool/hitboxHurtboxAnimator/entry.ts +28 -0
- package/src/tool/hitboxHurtboxAnimator/evaluator.ts +15 -0
- package/src/tool/hitboxHurtboxAnimator/file-io.ts +82 -0
- package/src/tool/hitboxHurtboxAnimator/hitbox-hurtbox-animator.css +614 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/de.ts +195 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/en.ts +191 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/es.ts +195 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/fr.ts +195 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/id.ts +195 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/it.ts +195 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/ja.ts +195 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/ko.ts +195 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/nl.ts +195 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/pl.ts +195 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/pt.ts +195 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/ru.ts +195 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/sv.ts +195 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/tr.ts +195 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/zh.ts +195 -0
- package/src/tool/hitboxHurtboxAnimator/index.ts +11 -0
- package/src/tool/hitboxHurtboxAnimator/logic.test.ts +97 -0
- package/src/tool/hitboxHurtboxAnimator/logic.ts +173 -0
- package/src/tool/hitboxHurtboxAnimator/project-actions.ts +128 -0
- package/src/tool/hitboxHurtboxAnimator/seo.astro +13 -0
- package/src/tool/hitboxHurtboxAnimator/storage.ts +27 -0
- package/src/tool/hitboxHurtboxAnimator/ui.ts +70 -0
- package/src/tools.ts +2 -0
package/package.json
CHANGED
package/src/category/index.ts
CHANGED
|
@@ -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('
|
|
6
|
-
expect(ALL_TOOLS.length).toBe(
|
|
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
|
|
8
|
-
expect(ALL_TOOLS.length).toBe(
|
|
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
|
+
}
|