@jjlmoya/utils-games-development 1.61.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 (72) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +3 -1
  3. package/src/entries.ts +5 -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/gameUIAccessibilityTester/bibliography.astro +6 -0
  8. package/src/tool/gameUIAccessibilityTester/bibliography.ts +10 -0
  9. package/src/tool/gameUIAccessibilityTester/component.astro +210 -0
  10. package/src/tool/gameUIAccessibilityTester/controller-actions.ts +92 -0
  11. package/src/tool/gameUIAccessibilityTester/controller.ts +237 -0
  12. package/src/tool/gameUIAccessibilityTester/dom-views.ts +193 -0
  13. package/src/tool/gameUIAccessibilityTester/entry.ts +28 -0
  14. package/src/tool/gameUIAccessibilityTester/evaluator.ts +43 -0
  15. package/src/tool/gameUIAccessibilityTester/game-ui-accessibility-stress-tester.css +982 -0
  16. package/src/tool/gameUIAccessibilityTester/i18n/de.ts +203 -0
  17. package/src/tool/gameUIAccessibilityTester/i18n/en.ts +199 -0
  18. package/src/tool/gameUIAccessibilityTester/i18n/es.ts +203 -0
  19. package/src/tool/gameUIAccessibilityTester/i18n/fr.ts +203 -0
  20. package/src/tool/gameUIAccessibilityTester/i18n/id.ts +203 -0
  21. package/src/tool/gameUIAccessibilityTester/i18n/it.ts +203 -0
  22. package/src/tool/gameUIAccessibilityTester/i18n/ja.ts +203 -0
  23. package/src/tool/gameUIAccessibilityTester/i18n/ko.ts +203 -0
  24. package/src/tool/gameUIAccessibilityTester/i18n/nl.ts +203 -0
  25. package/src/tool/gameUIAccessibilityTester/i18n/pl.ts +203 -0
  26. package/src/tool/gameUIAccessibilityTester/i18n/pt.ts +203 -0
  27. package/src/tool/gameUIAccessibilityTester/i18n/ru.ts +203 -0
  28. package/src/tool/gameUIAccessibilityTester/i18n/sv.ts +203 -0
  29. package/src/tool/gameUIAccessibilityTester/i18n/tr.ts +203 -0
  30. package/src/tool/gameUIAccessibilityTester/i18n/zh.ts +203 -0
  31. package/src/tool/gameUIAccessibilityTester/index.ts +11 -0
  32. package/src/tool/gameUIAccessibilityTester/logic.test.ts +57 -0
  33. package/src/tool/gameUIAccessibilityTester/logic.ts +157 -0
  34. package/src/tool/gameUIAccessibilityTester/report.ts +59 -0
  35. package/src/tool/gameUIAccessibilityTester/seo.astro +15 -0
  36. package/src/tool/gameUIAccessibilityTester/storage.ts +34 -0
  37. package/src/tool/gameUIAccessibilityTester/ui.ts +80 -0
  38. package/src/tool/hitboxHurtboxAnimator/bibliography.astro +12 -0
  39. package/src/tool/hitboxHurtboxAnimator/bibliography.ts +10 -0
  40. package/src/tool/hitboxHurtboxAnimator/canvas-interactions.ts +59 -0
  41. package/src/tool/hitboxHurtboxAnimator/canvas-metrics.ts +8 -0
  42. package/src/tool/hitboxHurtboxAnimator/component.astro +148 -0
  43. package/src/tool/hitboxHurtboxAnimator/controller.ts +247 -0
  44. package/src/tool/hitboxHurtboxAnimator/dom-views.ts +194 -0
  45. package/src/tool/hitboxHurtboxAnimator/editor-state.ts +19 -0
  46. package/src/tool/hitboxHurtboxAnimator/entry.ts +28 -0
  47. package/src/tool/hitboxHurtboxAnimator/evaluator.ts +15 -0
  48. package/src/tool/hitboxHurtboxAnimator/file-io.ts +82 -0
  49. package/src/tool/hitboxHurtboxAnimator/hitbox-hurtbox-animator.css +614 -0
  50. package/src/tool/hitboxHurtboxAnimator/i18n/de.ts +195 -0
  51. package/src/tool/hitboxHurtboxAnimator/i18n/en.ts +191 -0
  52. package/src/tool/hitboxHurtboxAnimator/i18n/es.ts +195 -0
  53. package/src/tool/hitboxHurtboxAnimator/i18n/fr.ts +195 -0
  54. package/src/tool/hitboxHurtboxAnimator/i18n/id.ts +195 -0
  55. package/src/tool/hitboxHurtboxAnimator/i18n/it.ts +195 -0
  56. package/src/tool/hitboxHurtboxAnimator/i18n/ja.ts +195 -0
  57. package/src/tool/hitboxHurtboxAnimator/i18n/ko.ts +195 -0
  58. package/src/tool/hitboxHurtboxAnimator/i18n/nl.ts +195 -0
  59. package/src/tool/hitboxHurtboxAnimator/i18n/pl.ts +195 -0
  60. package/src/tool/hitboxHurtboxAnimator/i18n/pt.ts +195 -0
  61. package/src/tool/hitboxHurtboxAnimator/i18n/ru.ts +195 -0
  62. package/src/tool/hitboxHurtboxAnimator/i18n/sv.ts +195 -0
  63. package/src/tool/hitboxHurtboxAnimator/i18n/tr.ts +195 -0
  64. package/src/tool/hitboxHurtboxAnimator/i18n/zh.ts +195 -0
  65. package/src/tool/hitboxHurtboxAnimator/index.ts +11 -0
  66. package/src/tool/hitboxHurtboxAnimator/logic.test.ts +97 -0
  67. package/src/tool/hitboxHurtboxAnimator/logic.ts +173 -0
  68. package/src/tool/hitboxHurtboxAnimator/project-actions.ts +128 -0
  69. package/src/tool/hitboxHurtboxAnimator/seo.astro +13 -0
  70. package/src/tool/hitboxHurtboxAnimator/storage.ts +27 -0
  71. package/src/tool/hitboxHurtboxAnimator/ui.ts +70 -0
  72. package/src/tools.ts +4 -0
@@ -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
+ }
@@ -0,0 +1,194 @@
1
+ import type { EditorState } from './editor-state';
2
+ import type { AnimationFrame, CollisionShape, CollisionType } from './logic';
3
+ import { evaluateProject } from './evaluator';
4
+
5
+ const COLORS: Record<CollisionType, string> = {
6
+ hitbox: '#ff4d6d',
7
+ hurtbox: '#2dd4bf',
8
+ pushbox: '#8b5cf6',
9
+ grabbox: '#f59e0b',
10
+ sensor: '#38bdf8',
11
+ custom: '#f472b6',
12
+ };
13
+
14
+ const element = <T extends Element>(root: ParentNode, selector: string): T | null => root.querySelector<T>(selector);
15
+
16
+ export function formatCopy(template: string, values: Record<string, string | number>): string {
17
+ return Object.entries(values).reduce((copy, [key, value]) => copy.replace(`{${key}}`, String(value)), template);
18
+ }
19
+
20
+ interface ShapePaint {
21
+ context: CanvasRenderingContext2D;
22
+ shape: CollisionShape;
23
+ scale: number;
24
+ selected: boolean;
25
+ alpha?: number | undefined;
26
+ }
27
+
28
+ function drawShape(input: ShapePaint): void {
29
+ const { context, shape, scale, selected } = input;
30
+ const alpha = input.alpha ?? 1;
31
+ const color = COLORS[shape.type];
32
+ context.save();
33
+ context.globalAlpha = alpha;
34
+ context.fillStyle = `${color}33`;
35
+ context.strokeStyle = selected ? '#fff' : color;
36
+ context.lineWidth = selected ? 3 : 2;
37
+ context.setLineDash(selected ? [] : [5, 3]);
38
+ const x = shape.x * scale;
39
+ const y = shape.y * scale;
40
+ const width = shape.width * scale;
41
+ const height = shape.height * scale;
42
+ context.beginPath();
43
+ if (shape.geometry === 'circle') context.ellipse(x + width / 2, y + height / 2, width / 2, height / 2, 0, 0, Math.PI * 2);
44
+ else context.rect(x, y, width, height);
45
+ context.fill();
46
+ context.stroke();
47
+ context.restore();
48
+ }
49
+
50
+ interface FramePaint {
51
+ context: CanvasRenderingContext2D;
52
+ frame: AnimationFrame;
53
+ image?: HTMLImageElement | undefined;
54
+ scale: number;
55
+ selectedId?: string;
56
+ alpha?: number;
57
+ }
58
+
59
+ export function paintFrame(input: FramePaint): void {
60
+ const { context, frame, image, scale } = input;
61
+ if (image) context.drawImage(image, frame.sourceX, frame.sourceY, frame.width, frame.height, 0, 0, frame.width * scale, frame.height * scale);
62
+ frame.shapes.forEach((shape) => drawShape({ context, shape, scale, selected: shape.id === input.selectedId, alpha: input.alpha }));
63
+ }
64
+
65
+ function paintOnion(state: EditorState, context: CanvasRenderingContext2D, index: number, scale: number): void {
66
+ const frame = state.project.frames[index];
67
+ if (!frame) return;
68
+ context.save();
69
+ context.globalAlpha = 0.18;
70
+ paintFrame({ context, frame, image: state.images[frame.imageIndex], scale, alpha: 0.55 });
71
+ context.restore();
72
+ }
73
+
74
+ function canvasScale(frame: AnimationFrame): number {
75
+ return Math.max(1, Math.min(8, 720 / frame.width, 520 / frame.height));
76
+ }
77
+
78
+ function renderCanvas(state: EditorState): void {
79
+ const canvas = element<HTMLCanvasElement>(state.root, '[data-role="stage"]');
80
+ const frame = state.project.frames[state.currentFrame];
81
+ if (!canvas || !frame) return;
82
+ const scale = canvasScale(frame);
83
+ canvas.width = Math.round(frame.width * scale);
84
+ canvas.height = Math.round(frame.height * scale);
85
+ canvas.dataset.scale = String(scale);
86
+ const context = canvas.getContext('2d');
87
+ if (!context) return;
88
+ context.imageSmoothingEnabled = false;
89
+ context.clearRect(0, 0, canvas.width, canvas.height);
90
+ if (state.preferences.onionPrevious) paintOnion(state, context, state.currentFrame - 1, scale);
91
+ if (state.preferences.onionNext) paintOnion(state, context, state.currentFrame + 1, scale);
92
+ paintFrame({ context, frame, image: state.images[frame.imageIndex], scale, selectedId: state.selectedId });
93
+ context.fillStyle = '#fff';
94
+ context.beginPath();
95
+ context.arc(frame.pivot.x * scale, frame.pivot.y * scale, 3, 0, Math.PI * 2);
96
+ context.fill();
97
+ }
98
+
99
+ function typeMarks(frame: AnimationFrame): string {
100
+ return Array.from(new Set(frame.shapes.map(({ type }) => type))).map((type) => `<i style="--mark:${COLORS[type]}"></i>`).join('');
101
+ }
102
+
103
+ function renderTimeline(state: EditorState): void {
104
+ const timeline = element<HTMLElement>(state.root, '[data-role="timeline"]');
105
+ if (!timeline) return;
106
+ timeline.innerHTML = state.project.frames.map((frame) => {
107
+ const active = frame.index === state.currentFrame ? ' is-active' : '';
108
+ const label = formatCopy(state.ui.frameReadout, { current: frame.index + 1, total: state.project.frames.length });
109
+ return `<button type="button" class="hha-frame${active}" data-frame="${frame.index}" aria-label="${label}"><span>${frame.index + 1}</span><b>${frame.shapes.length}</b><em>${typeMarks(frame)}</em></button>`;
110
+ }).join('');
111
+ }
112
+
113
+ function setField(root: HTMLElement, field: string, value: string | number, disabled: boolean): void {
114
+ const input = element<HTMLInputElement>(root, `[data-field="${field}"]`);
115
+ if (!input) return;
116
+ input.value = String(value);
117
+ input.disabled = disabled;
118
+ }
119
+
120
+ function renderShapeFields(state: EditorState, shape?: CollisionShape): void {
121
+ if (!shape) {
122
+ ['name', 'x', 'y', 'width', 'height', 'radius'].forEach((field) => setField(state.root, field, field === 'name' ? '' : 0, true));
123
+ return;
124
+ }
125
+ setField(state.root, 'name', shape.name, false);
126
+ setField(state.root, 'x', shape.x, false);
127
+ setField(state.root, 'y', shape.y, false);
128
+ setField(state.root, 'width', shape.width, false);
129
+ setField(state.root, 'height', shape.height, false);
130
+ setField(state.root, 'radius', shape.width / 2, false);
131
+ }
132
+
133
+ function toggleInspector(state: EditorState, shape?: CollisionShape): void {
134
+ const empty = element<HTMLElement>(state.root, '[data-role="no-selection"]');
135
+ const fields = element<HTMLElement>(state.root, '[data-role="shape-fields"]');
136
+ if (empty) empty.hidden = Boolean(shape);
137
+ if (fields) fields.hidden = !shape;
138
+ const radius = element<HTMLElement>(state.root, '[data-radius-row]');
139
+ if (radius) radius.hidden = !shape || shape.geometry !== 'circle';
140
+ }
141
+
142
+ function renderInspector(state: EditorState): void {
143
+ const frame = state.project.frames[state.currentFrame];
144
+ if (!frame) return;
145
+ const shape = frame.shapes.find(({ id }) => id === state.selectedId);
146
+ toggleInspector(state, shape);
147
+ renderShapeFields(state, shape);
148
+ setField(state.root, 'pivotX', frame.pivot.x, false);
149
+ setField(state.root, 'pivotY', frame.pivot.y, false);
150
+ }
151
+
152
+ function renderDiagnostics(state: EditorState): void {
153
+ const diagnostics = evaluateProject(state.project);
154
+ const frames = element<HTMLElement>(state.root, '[data-role="frames-badge"]');
155
+ const shapes = element<HTMLElement>(state.root, '[data-role="shapes-badge"]');
156
+ const coverage = element<HTMLElement>(state.root, '[data-role="coverage-badge"]');
157
+ if (frames) frames.textContent = formatCopy(state.ui.framesBadge, { count: diagnostics.frameCount });
158
+ if (shapes) shapes.textContent = formatCopy(state.ui.shapesBadge, { count: diagnostics.shapeCount });
159
+ if (coverage) coverage.textContent = formatCopy(state.ui.coverageBadge, { percent: diagnostics.coveragePercent });
160
+ }
161
+
162
+ function renderControls(state: EditorState): void {
163
+ state.root.classList.toggle('has-images', state.images.length > 0);
164
+ state.root.querySelectorAll<HTMLElement>('[data-type]').forEach((item) => item.classList.toggle('is-active', item.dataset.type === state.collisionType));
165
+ state.root.querySelectorAll<HTMLElement>('[data-geometry]').forEach((item) => item.classList.toggle('is-active', item.dataset.geometry === state.geometry));
166
+ state.root.querySelectorAll<HTMLElement>('[data-mode]').forEach((item) => item.classList.toggle('is-active', item.dataset.mode === state.mode));
167
+ const play = element<HTMLElement>(state.root, '[data-action="play"]');
168
+ if (play) play.textContent = state.playing ? state.ui.pause : state.ui.play;
169
+ }
170
+
171
+ export function renderEditor(state: EditorState): void {
172
+ renderCanvas(state);
173
+ renderTimeline(state);
174
+ renderInspector(state);
175
+ renderDiagnostics(state);
176
+ renderControls(state);
177
+ }
178
+
179
+ export function renderDraft(state: EditorState, start: { x: number; y: number }, end: { x: number; y: number }): void {
180
+ renderCanvas(state);
181
+ const canvas = element<HTMLCanvasElement>(state.root, '[data-role="stage"]');
182
+ const context = canvas?.getContext('2d');
183
+ if (!canvas || !context) return;
184
+ const scale = Number(canvas.dataset.scale ?? 1);
185
+ const width = state.geometry === 'circle' ? Math.min(Math.abs(end.x - start.x), Math.abs(end.y - start.y)) : Math.abs(end.x - start.x);
186
+ const height = state.geometry === 'circle' ? width : Math.abs(end.y - start.y);
187
+ const shape: CollisionShape = { id: '', name: '', type: state.collisionType, geometry: state.geometry, x: Math.min(start.x, end.x), y: Math.min(start.y, end.y), width, height };
188
+ drawShape({ context, shape, scale, selected: true, alpha: 0.9 });
189
+ }
190
+
191
+ export function setStatus(state: EditorState, copy: string): void {
192
+ const status = element<HTMLElement>(state.root, '[data-role="status"]');
193
+ if (status) status.textContent = copy;
194
+ }
@@ -0,0 +1,19 @@
1
+ import type { HitboxHurtboxAnimatorUI } from './ui';
2
+ import type { CollisionProject, CollisionType, ShapeGeometry } from './logic';
3
+ import type { EditorPreferences } from './storage';
4
+
5
+ export interface EditorState {
6
+ root: HTMLElement;
7
+ ui: HitboxHurtboxAnimatorUI;
8
+ project: CollisionProject;
9
+ images: HTMLImageElement[];
10
+ currentFrame: number;
11
+ selectedId: string;
12
+ mode: 'select' | 'draw';
13
+ collisionType: CollisionType;
14
+ geometry: ShapeGeometry;
15
+ playing: boolean;
16
+ preferences: EditorPreferences;
17
+ undoStack: CollisionProject[];
18
+ redoStack: CollisionProject[];
19
+ }
@@ -0,0 +1,28 @@
1
+ import type { GamesToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { HitboxHurtboxAnimatorUI } from './ui';
3
+
4
+ export type { HitboxHurtboxAnimatorUI };
5
+
6
+ export type HitboxHurtboxAnimatorLocaleContent = ToolLocaleContent<HitboxHurtboxAnimatorUI>;
7
+
8
+ export const hitboxHurtboxAnimator: GamesToolEntry<HitboxHurtboxAnimatorUI> = {
9
+ id: 'hitbox-hurtbox-animator',
10
+ icons: { bg: 'mdi:animation-play-outline', fg: 'mdi:vector-rectangle' },
11
+ i18n: {
12
+ de: () => import('./i18n/de').then((module) => module.content),
13
+ en: () => import('./i18n/en').then((module) => module.content),
14
+ es: () => import('./i18n/es').then((module) => module.content),
15
+ fr: () => import('./i18n/fr').then((module) => module.content),
16
+ id: () => import('./i18n/id').then((module) => module.content),
17
+ it: () => import('./i18n/it').then((module) => module.content),
18
+ ja: () => import('./i18n/ja').then((module) => module.content),
19
+ ko: () => import('./i18n/ko').then((module) => module.content),
20
+ nl: () => import('./i18n/nl').then((module) => module.content),
21
+ pl: () => import('./i18n/pl').then((module) => module.content),
22
+ pt: () => import('./i18n/pt').then((module) => module.content),
23
+ ru: () => import('./i18n/ru').then((module) => module.content),
24
+ sv: () => import('./i18n/sv').then((module) => module.content),
25
+ tr: () => import('./i18n/tr').then((module) => module.content),
26
+ zh: () => import('./i18n/zh').then((module) => module.content),
27
+ },
28
+ };
@@ -0,0 +1,15 @@
1
+ import type { CollisionProject } from './logic';
2
+
3
+ export interface ProjectDiagnostics {
4
+ frameCount: number;
5
+ shapeCount: number;
6
+ coveredFrames: number;
7
+ coveragePercent: number;
8
+ }
9
+
10
+ export function evaluateProject(project: CollisionProject): ProjectDiagnostics {
11
+ const shapeCount = project.frames.reduce((total, frame) => total + frame.shapes.length, 0);
12
+ const coveredFrames = project.frames.filter((frame) => frame.shapes.length > 0).length;
13
+ const coveragePercent = project.frames.length === 0 ? 0 : Math.round((coveredFrames / project.frames.length) * 100);
14
+ return { frameCount: project.frames.length, shapeCount, coveredFrames, coveragePercent };
15
+ }
@@ -0,0 +1,82 @@
1
+ import type { CollisionProject, ImageReference } from './logic';
2
+ import { paintFrame } from './dom-views';
3
+
4
+ export interface LoadedImages {
5
+ elements: HTMLImageElement[];
6
+ references: ImageReference[];
7
+ }
8
+
9
+ function decodeImage(file: File): Promise<HTMLImageElement> {
10
+ return new Promise((resolve, reject) => {
11
+ const image = new Image();
12
+ const url = URL.createObjectURL(file);
13
+ image.onload = () => {
14
+ URL.revokeObjectURL(url);
15
+ resolve(image);
16
+ };
17
+ image.onerror = () => {
18
+ URL.revokeObjectURL(url);
19
+ reject(new Error(file.name));
20
+ };
21
+ image.src = url;
22
+ });
23
+ }
24
+
25
+ export async function loadLocalImages(files: File[]): Promise<LoadedImages> {
26
+ const ordered = [...files].sort((left, right) => left.name.localeCompare(right.name, undefined, { numeric: true }));
27
+ const elements = await Promise.all(ordered.map(decodeImage));
28
+ const references = elements.map((image, index) => ({ name: ordered[index]?.name ?? '', width: image.naturalWidth, height: image.naturalHeight }));
29
+ return { elements, references };
30
+ }
31
+
32
+ export function downloadText(source: string, fileName: string): void {
33
+ const blob = new Blob([source], { type: 'application/json' });
34
+ const url = URL.createObjectURL(blob);
35
+ const anchor = document.createElement('a');
36
+ anchor.href = url;
37
+ anchor.download = fileName;
38
+ anchor.click();
39
+ URL.revokeObjectURL(url);
40
+ }
41
+
42
+ function sheetMetrics(project: CollisionProject): { columns: number; rows: number; width: number; height: number } {
43
+ const columns = Math.max(1, Math.ceil(Math.sqrt(project.frames.length)));
44
+ const rows = Math.max(1, Math.ceil(project.frames.length / columns));
45
+ const width = Math.max(1, ...project.frames.map((frame) => frame.width));
46
+ const height = Math.max(1, ...project.frames.map((frame) => frame.height));
47
+ return { columns, rows, width, height };
48
+ }
49
+
50
+ function drawSheet(project: CollisionProject, images: HTMLImageElement[]): HTMLCanvasElement {
51
+ const metrics = sheetMetrics(project);
52
+ const padding = 18;
53
+ const canvas = document.createElement('canvas');
54
+ canvas.width = metrics.columns * (metrics.width + padding) + padding;
55
+ canvas.height = metrics.rows * (metrics.height + padding) + padding;
56
+ const context = canvas.getContext('2d');
57
+ if (!context) return canvas;
58
+ context.fillStyle = '#15111f';
59
+ context.fillRect(0, 0, canvas.width, canvas.height);
60
+ project.frames.forEach((frame, index) => {
61
+ const column = index % metrics.columns;
62
+ const row = Math.floor(index / metrics.columns);
63
+ context.save();
64
+ context.translate(padding + column * (metrics.width + padding), padding + row * (metrics.height + padding));
65
+ paintFrame({ context, frame, image: images[frame.imageIndex], scale: 1 });
66
+ context.restore();
67
+ });
68
+ return canvas;
69
+ }
70
+
71
+ export function downloadContactSheet(project: CollisionProject, images: HTMLImageElement[]): void {
72
+ const canvas = drawSheet(project, images);
73
+ canvas.toBlob((blob) => {
74
+ if (!blob) return;
75
+ const url = URL.createObjectURL(blob);
76
+ const anchor = document.createElement('a');
77
+ anchor.href = url;
78
+ anchor.download = 'collision-contact-sheet.png';
79
+ anchor.click();
80
+ URL.revokeObjectURL(url);
81
+ }, 'image/png');
82
+ }