@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
|
@@ -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
|
+
}
|