@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.
- package/package.json +1 -1
- package/src/category/index.ts +3 -1
- package/src/entries.ts +5 -1
- package/src/index.ts +2 -0
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/gameUIAccessibilityTester/bibliography.astro +6 -0
- package/src/tool/gameUIAccessibilityTester/bibliography.ts +10 -0
- package/src/tool/gameUIAccessibilityTester/component.astro +210 -0
- package/src/tool/gameUIAccessibilityTester/controller-actions.ts +92 -0
- package/src/tool/gameUIAccessibilityTester/controller.ts +237 -0
- package/src/tool/gameUIAccessibilityTester/dom-views.ts +193 -0
- package/src/tool/gameUIAccessibilityTester/entry.ts +28 -0
- package/src/tool/gameUIAccessibilityTester/evaluator.ts +43 -0
- package/src/tool/gameUIAccessibilityTester/game-ui-accessibility-stress-tester.css +982 -0
- package/src/tool/gameUIAccessibilityTester/i18n/de.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/en.ts +199 -0
- package/src/tool/gameUIAccessibilityTester/i18n/es.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/fr.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/id.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/it.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/ja.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/ko.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/nl.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/pl.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/pt.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/ru.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/sv.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/tr.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/zh.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/index.ts +11 -0
- package/src/tool/gameUIAccessibilityTester/logic.test.ts +57 -0
- package/src/tool/gameUIAccessibilityTester/logic.ts +157 -0
- package/src/tool/gameUIAccessibilityTester/report.ts +59 -0
- package/src/tool/gameUIAccessibilityTester/seo.astro +15 -0
- package/src/tool/gameUIAccessibilityTester/storage.ts +34 -0
- package/src/tool/gameUIAccessibilityTester/ui.ts +80 -0
- 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 +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
|
+
}
|