@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,97 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
clampShape,
|
|
4
|
+
cloneProject,
|
|
5
|
+
copyShapesToFrame,
|
|
6
|
+
createProject,
|
|
7
|
+
createSequenceFrames,
|
|
8
|
+
createShape,
|
|
9
|
+
createSheetFrames,
|
|
10
|
+
duplicateShape,
|
|
11
|
+
mirrorShape,
|
|
12
|
+
moveShape,
|
|
13
|
+
parseProject,
|
|
14
|
+
serializeProject,
|
|
15
|
+
shapeAtPoint,
|
|
16
|
+
} from './logic';
|
|
17
|
+
|
|
18
|
+
const image = { name: 'fighter.png', width: 128, height: 64 };
|
|
19
|
+
|
|
20
|
+
describe('hitbox animator logic', () => {
|
|
21
|
+
it('slices a sheet row first with stable pixel coordinates', () => {
|
|
22
|
+
const frames = createSheetFrames(image, 2, 4);
|
|
23
|
+
expect(frames).toHaveLength(8);
|
|
24
|
+
expect(frames[5]).toMatchObject({ index: 5, sourceX: 32, sourceY: 32, width: 32, height: 32 });
|
|
25
|
+
expect(frames[0]?.pivot).toEqual({ x: 16, y: 16 });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('normalizes invalid slicing counts', () => {
|
|
29
|
+
const frames = createSheetFrames(image, 0, -3);
|
|
30
|
+
expect(frames).toHaveLength(1);
|
|
31
|
+
expect(frames[0]).toMatchObject({ width: 128, height: 64 });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('creates one full frame for every ordered image', () => {
|
|
35
|
+
const frames = createSequenceFrames([image, { name: 'fighter-2.webp', width: 96, height: 80 }]);
|
|
36
|
+
expect(frames[1]).toMatchObject({ imageIndex: 1, width: 96, height: 80, sourceX: 0, sourceY: 0 });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('creates sheet and sequence projects with top left coordinates', () => {
|
|
40
|
+
const sheet = createProject([image], 2, 4);
|
|
41
|
+
const sequence = createProject([image, image], 4, 8);
|
|
42
|
+
const empty = createProject([]);
|
|
43
|
+
expect(sheet).toMatchObject({ rows: 2, columns: 4, fps: 12, coordinateSystem: 'top-left-pixels' });
|
|
44
|
+
expect(sequence).toMatchObject({ rows: 1, columns: 1 });
|
|
45
|
+
expect(empty.frames[0]).toMatchObject({ width: 1, height: 1 });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('creates normalized rectangles and square circles inside a frame', () => {
|
|
49
|
+
const frame = createSheetFrames(image, 1, 1)[0]!;
|
|
50
|
+
const rectangle = createShape({ frame, type: 'hitbox', geometry: 'rectangle', startX: 60, startY: 40, endX: 20, endY: 10, name: 'Strike' });
|
|
51
|
+
const circle = createShape({ frame: { ...frame, shapes: [rectangle] }, type: 'sensor', geometry: 'circle', startX: -2, startY: 5, endX: 80, endY: 25, name: 'Range' });
|
|
52
|
+
expect(rectangle).toMatchObject({ id: 'shape-1-1', x: 20, y: 10, width: 40, height: 30 });
|
|
53
|
+
expect(circle).toMatchObject({ id: 'shape-1-2', x: 0, y: 5, width: 20, height: 20 });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('clamps, moves, mirrors, and duplicates shapes', () => {
|
|
57
|
+
const frame = createSheetFrames(image, 1, 1)[0]!;
|
|
58
|
+
const shape = { id: 'shape-1-7', name: 'Body', type: 'hurtbox' as const, geometry: 'circle' as const, x: -5, y: 60, width: -20, height: 9 };
|
|
59
|
+
const clamped = clampShape(shape, frame);
|
|
60
|
+
expect(clamped).toMatchObject({ x: 0, y: 44, width: 20, height: 20 });
|
|
61
|
+
expect(moveShape(clamped, frame, 500, -500)).toMatchObject({ x: 108, y: 0 });
|
|
62
|
+
expect(mirrorShape(clamped, 128).x).toBe(108);
|
|
63
|
+
expect(duplicateShape(clamped, { ...frame, shapes: [shape] })).toMatchObject({ id: 'shape-1-8', name: 'Body copy', x: 2, y: 44 });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('copies shapes with frame local identifiers', () => {
|
|
67
|
+
const [source, target] = createSheetFrames(image, 1, 2);
|
|
68
|
+
source!.shapes = [{ id: 'shape-1-1', name: 'Attack', type: 'hitbox', geometry: 'rectangle', x: 4, y: 5, width: 20, height: 10 }];
|
|
69
|
+
const copied = copyShapesToFrame(source!, target!);
|
|
70
|
+
expect(copied.shapes[0]).toMatchObject({ id: 'shape-2-1', name: 'Attack' });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('finds the topmost shape and returns undefined outside shapes', () => {
|
|
74
|
+
const shapes = [
|
|
75
|
+
{ id: 'a', name: 'A', type: 'hitbox' as const, geometry: 'rectangle' as const, x: 0, y: 0, width: 20, height: 20 },
|
|
76
|
+
{ id: 'b', name: 'B', type: 'hurtbox' as const, geometry: 'rectangle' as const, x: 5, y: 5, width: 20, height: 20 },
|
|
77
|
+
];
|
|
78
|
+
expect(shapeAtPoint(shapes, 10, 10)?.id).toBe('b');
|
|
79
|
+
expect(shapeAtPoint(shapes, 40, 40)).toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('round trips projects without sharing mutable references', () => {
|
|
83
|
+
const project = createProject([image], 2, 2);
|
|
84
|
+
const source = serializeProject(project);
|
|
85
|
+
const parsed = parseProject(source);
|
|
86
|
+
const cloned = cloneProject(project);
|
|
87
|
+
parsed.frames[0]!.pivot.x = 99;
|
|
88
|
+
cloned.frames[0]!.pivot.y = 88;
|
|
89
|
+
expect(project.frames[0]?.pivot).toEqual({ x: 32, y: 16 });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('rejects malformed project JSON', () => {
|
|
93
|
+
expect(() => parseProject('{"version":1}')).toThrow('Invalid collision project');
|
|
94
|
+
expect(() => parseProject('not json')).toThrow();
|
|
95
|
+
expect(() => parseProject('null')).toThrow('Invalid collision project');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
export type CollisionType = 'hitbox' | 'hurtbox' | 'pushbox' | 'grabbox' | 'sensor' | 'custom';
|
|
2
|
+
export type ShapeGeometry = 'rectangle' | 'circle';
|
|
3
|
+
|
|
4
|
+
export interface ImageReference {
|
|
5
|
+
name: string;
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface CollisionShape {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
type: CollisionType;
|
|
14
|
+
geometry: ShapeGeometry;
|
|
15
|
+
x: number;
|
|
16
|
+
y: number;
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface AnimationFrame {
|
|
22
|
+
index: number;
|
|
23
|
+
imageIndex: number;
|
|
24
|
+
sourceX: number;
|
|
25
|
+
sourceY: number;
|
|
26
|
+
width: number;
|
|
27
|
+
height: number;
|
|
28
|
+
pivot: { x: number; y: number };
|
|
29
|
+
shapes: CollisionShape[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CollisionProject {
|
|
33
|
+
schema: 'jjlmoya.hitbox-animation';
|
|
34
|
+
version: 1;
|
|
35
|
+
coordinateSystem: 'top-left-pixels';
|
|
36
|
+
images: ImageReference[];
|
|
37
|
+
rows: number;
|
|
38
|
+
columns: number;
|
|
39
|
+
fps: number;
|
|
40
|
+
frames: AnimationFrame[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface ShapeInput {
|
|
44
|
+
frame: AnimationFrame;
|
|
45
|
+
type: CollisionType;
|
|
46
|
+
geometry: ShapeGeometry;
|
|
47
|
+
startX: number;
|
|
48
|
+
startY: number;
|
|
49
|
+
endX: number;
|
|
50
|
+
endY: number;
|
|
51
|
+
name: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const round = (value: number): number => Math.round(value * 1000) / 1000;
|
|
55
|
+
const clamp = (value: number, minimum: number, maximum: number): number => Math.min(maximum, Math.max(minimum, value));
|
|
56
|
+
|
|
57
|
+
export function createSheetFrames(image: ImageReference, rows: number, columns: number): AnimationFrame[] {
|
|
58
|
+
const safeRows = Math.max(1, Math.floor(rows));
|
|
59
|
+
const safeColumns = Math.max(1, Math.floor(columns));
|
|
60
|
+
const width = image.width / safeColumns;
|
|
61
|
+
const height = image.height / safeRows;
|
|
62
|
+
return Array.from({ length: safeRows * safeColumns }, (_, index) => createFrame({ index, imageIndex: 0, width, height, columns: safeColumns }));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface FrameInput {
|
|
66
|
+
index: number;
|
|
67
|
+
imageIndex: number;
|
|
68
|
+
width: number;
|
|
69
|
+
height: number;
|
|
70
|
+
columns: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function createFrame(input: FrameInput): AnimationFrame {
|
|
74
|
+
const column = input.index % input.columns;
|
|
75
|
+
const row = Math.floor(input.index / input.columns);
|
|
76
|
+
return {
|
|
77
|
+
index: input.index,
|
|
78
|
+
imageIndex: input.imageIndex,
|
|
79
|
+
sourceX: round(column * input.width),
|
|
80
|
+
sourceY: round(row * input.height),
|
|
81
|
+
width: round(input.width),
|
|
82
|
+
height: round(input.height),
|
|
83
|
+
pivot: { x: round(input.width / 2), y: round(input.height / 2) },
|
|
84
|
+
shapes: [],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createSequenceFrames(images: ImageReference[]): AnimationFrame[] {
|
|
89
|
+
return images.map((image, index) => ({
|
|
90
|
+
...createFrame({ index, imageIndex: index, width: image.width, height: image.height, columns: 1 }),
|
|
91
|
+
sourceX: 0,
|
|
92
|
+
sourceY: 0,
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createProject(images: ImageReference[], rows = 1, columns = 1): CollisionProject {
|
|
97
|
+
const frames = images.length > 1 ? createSequenceFrames(images) : createSheetFrames(images[0] ?? { name: '', width: 1, height: 1 }, rows, columns);
|
|
98
|
+
return {
|
|
99
|
+
schema: 'jjlmoya.hitbox-animation',
|
|
100
|
+
version: 1,
|
|
101
|
+
coordinateSystem: 'top-left-pixels',
|
|
102
|
+
images,
|
|
103
|
+
rows: images.length > 1 ? 1 : Math.max(1, Math.floor(rows)),
|
|
104
|
+
columns: images.length > 1 ? 1 : Math.max(1, Math.floor(columns)),
|
|
105
|
+
fps: 12,
|
|
106
|
+
frames,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function shapeId(frame: AnimationFrame): string {
|
|
111
|
+
const numbers = frame.shapes.map(({ id }) => Number(id.match(/(\d+)$/)?.[1] ?? 0));
|
|
112
|
+
return `shape-${frame.index + 1}-${Math.max(0, ...numbers) + 1}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function createShape(input: ShapeInput): CollisionShape {
|
|
116
|
+
const left = clamp(Math.min(input.startX, input.endX), 0, input.frame.width);
|
|
117
|
+
const top = clamp(Math.min(input.startY, input.endY), 0, input.frame.height);
|
|
118
|
+
const right = clamp(Math.max(input.startX, input.endX), 0, input.frame.width);
|
|
119
|
+
const bottom = clamp(Math.max(input.startY, input.endY), 0, input.frame.height);
|
|
120
|
+
const size = input.geometry === 'circle' ? Math.min(right - left, bottom - top) : 0;
|
|
121
|
+
const width = input.geometry === 'circle' ? size : right - left;
|
|
122
|
+
const height = input.geometry === 'circle' ? size : bottom - top;
|
|
123
|
+
return { id: shapeId(input.frame), name: input.name, type: input.type, geometry: input.geometry, x: round(left), y: round(top), width: round(width), height: round(height) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function clampShape(shape: CollisionShape, frame: AnimationFrame): CollisionShape {
|
|
127
|
+
const width = clamp(Math.abs(shape.width), 1, frame.width);
|
|
128
|
+
const heightValue = shape.geometry === 'circle' ? width : Math.abs(shape.height);
|
|
129
|
+
const height = clamp(heightValue, 1, frame.height);
|
|
130
|
+
return { ...shape, x: round(clamp(shape.x, 0, frame.width - width)), y: round(clamp(shape.y, 0, frame.height - height)), width: round(width), height: round(height) };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function moveShape(shape: CollisionShape, frame: AnimationFrame, deltaX: number, deltaY: number): CollisionShape {
|
|
134
|
+
return clampShape({ ...shape, x: shape.x + deltaX, y: shape.y + deltaY }, frame);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function mirrorShape(shape: CollisionShape, frameWidth: number): CollisionShape {
|
|
138
|
+
return { ...shape, x: round(frameWidth - shape.x - shape.width) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function duplicateShape(shape: CollisionShape, frame: AnimationFrame): CollisionShape {
|
|
142
|
+
return clampShape({ ...shape, id: shapeId(frame), name: `${shape.name} copy`, x: shape.x + 2, y: shape.y + 2 }, frame);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function copyShapesToFrame(source: AnimationFrame, target: AnimationFrame): AnimationFrame {
|
|
146
|
+
const base = { ...target, shapes: [] as CollisionShape[] };
|
|
147
|
+
const shapes = source.shapes.map((shape) => duplicateShape({ ...shape, name: shape.name }, { ...base, shapes: base.shapes })).map((shape, index) => ({ ...shape, id: `shape-${target.index + 1}-${index + 1}`, name: source.shapes[index]?.name ?? shape.name }));
|
|
148
|
+
return { ...target, shapes };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function shapeAtPoint(shapes: CollisionShape[], x: number, y: number): CollisionShape | undefined {
|
|
152
|
+
return [...shapes].reverse().find((shape) => x >= shape.x && y >= shape.y && x <= shape.x + shape.width && y <= shape.y + shape.height);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function cloneProject(project: CollisionProject): CollisionProject {
|
|
156
|
+
return JSON.parse(JSON.stringify(project)) as CollisionProject;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function serializeProject(project: CollisionProject): string {
|
|
160
|
+
return JSON.stringify(project, null, 2);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isProject(value: unknown): value is CollisionProject {
|
|
164
|
+
if (!value || typeof value !== 'object') return false;
|
|
165
|
+
const candidate = value as Partial<CollisionProject>;
|
|
166
|
+
return candidate.schema === 'jjlmoya.hitbox-animation' && candidate.version === 1 && Array.isArray(candidate.images) && Array.isArray(candidate.frames);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function parseProject(source: string): CollisionProject {
|
|
170
|
+
const value: unknown = JSON.parse(source);
|
|
171
|
+
if (!isProject(value)) throw new Error('Invalid collision project');
|
|
172
|
+
return cloneProject(value);
|
|
173
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { EditorState } from './editor-state';
|
|
2
|
+
import type { CollisionShape } from './logic';
|
|
3
|
+
import {
|
|
4
|
+
clampShape,
|
|
5
|
+
cloneProject,
|
|
6
|
+
copyShapesToFrame,
|
|
7
|
+
createProject,
|
|
8
|
+
createShape,
|
|
9
|
+
duplicateShape,
|
|
10
|
+
mirrorShape,
|
|
11
|
+
moveShape,
|
|
12
|
+
} from './logic';
|
|
13
|
+
|
|
14
|
+
export function checkpoint(state: EditorState): void {
|
|
15
|
+
state.undoStack.push(cloneProject(state.project));
|
|
16
|
+
if (state.undoStack.length > 50) state.undoStack.shift();
|
|
17
|
+
state.redoStack = [];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function undoProject(state: EditorState): boolean {
|
|
21
|
+
const previous = state.undoStack.pop();
|
|
22
|
+
if (!previous) return false;
|
|
23
|
+
state.redoStack.push(cloneProject(state.project));
|
|
24
|
+
state.project = previous;
|
|
25
|
+
normalizeSelection(state);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function redoProject(state: EditorState): boolean {
|
|
30
|
+
const next = state.redoStack.pop();
|
|
31
|
+
if (!next) return false;
|
|
32
|
+
state.undoStack.push(cloneProject(state.project));
|
|
33
|
+
state.project = next;
|
|
34
|
+
normalizeSelection(state);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeSelection(state: EditorState): void {
|
|
39
|
+
state.currentFrame = Math.min(state.currentFrame, Math.max(0, state.project.frames.length - 1));
|
|
40
|
+
const exists = state.project.frames[state.currentFrame]?.shapes.some(({ id }) => id === state.selectedId);
|
|
41
|
+
if (!exists) state.selectedId = '';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function replaceSlicing(state: EditorState, rows: number, columns: number): void {
|
|
45
|
+
if (state.project.images.length !== 1) return;
|
|
46
|
+
checkpoint(state);
|
|
47
|
+
const next = createProject(state.project.images, rows, columns);
|
|
48
|
+
next.fps = state.project.fps;
|
|
49
|
+
state.project = next;
|
|
50
|
+
state.currentFrame = 0;
|
|
51
|
+
state.selectedId = '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function addDrawnShape(state: EditorState, start: { x: number; y: number }, end: { x: number; y: number }): boolean {
|
|
55
|
+
const frame = state.project.frames[state.currentFrame];
|
|
56
|
+
if (!frame || Math.abs(end.x - start.x) < 1 || Math.abs(end.y - start.y) < 1) return false;
|
|
57
|
+
checkpoint(state);
|
|
58
|
+
const typeName = state.ui[`type${state.collisionType[0]?.toUpperCase()}${state.collisionType.slice(1)}`] ?? state.collisionType;
|
|
59
|
+
const shape = createShape({ frame, type: state.collisionType, geometry: state.geometry, startX: start.x, startY: start.y, endX: end.x, endY: end.y, name: `${typeName} ${frame.shapes.length + 1}` });
|
|
60
|
+
frame.shapes.push(shape);
|
|
61
|
+
state.selectedId = shape.id;
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function updateSelected(state: EditorState, update: Partial<CollisionShape>): boolean {
|
|
66
|
+
const frame = state.project.frames[state.currentFrame];
|
|
67
|
+
const index = frame?.shapes.findIndex(({ id }) => id === state.selectedId) ?? -1;
|
|
68
|
+
if (!frame || index < 0) return false;
|
|
69
|
+
checkpoint(state);
|
|
70
|
+
frame.shapes[index] = clampShape({ ...frame.shapes[index]!, ...update }, frame);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function moveSelected(state: EditorState, deltaX: number, deltaY: number): boolean {
|
|
75
|
+
const frame = state.project.frames[state.currentFrame];
|
|
76
|
+
const index = frame?.shapes.findIndex(({ id }) => id === state.selectedId) ?? -1;
|
|
77
|
+
if (!frame || index < 0) return false;
|
|
78
|
+
frame.shapes[index] = moveShape(frame.shapes[index]!, frame, deltaX, deltaY);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function deleteSelected(state: EditorState): boolean {
|
|
83
|
+
const frame = state.project.frames[state.currentFrame];
|
|
84
|
+
const index = frame?.shapes.findIndex(({ id }) => id === state.selectedId) ?? -1;
|
|
85
|
+
if (!frame || index < 0) return false;
|
|
86
|
+
checkpoint(state);
|
|
87
|
+
frame.shapes.splice(index, 1);
|
|
88
|
+
state.selectedId = '';
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function duplicateSelected(state: EditorState): boolean {
|
|
93
|
+
const frame = state.project.frames[state.currentFrame];
|
|
94
|
+
const shape = frame?.shapes.find(({ id }) => id === state.selectedId);
|
|
95
|
+
if (!frame || !shape) return false;
|
|
96
|
+
checkpoint(state);
|
|
97
|
+
const duplicate = duplicateShape(shape, frame);
|
|
98
|
+
frame.shapes.push(duplicate);
|
|
99
|
+
state.selectedId = duplicate.id;
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function mirrorSelected(state: EditorState): boolean {
|
|
104
|
+
const frame = state.project.frames[state.currentFrame];
|
|
105
|
+
const index = frame?.shapes.findIndex(({ id }) => id === state.selectedId) ?? -1;
|
|
106
|
+
if (!frame || index < 0) return false;
|
|
107
|
+
checkpoint(state);
|
|
108
|
+
frame.shapes[index] = mirrorShape(frame.shapes[index]!, frame.width);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function copyPreviousFrame(state: EditorState): boolean {
|
|
113
|
+
const target = state.project.frames[state.currentFrame];
|
|
114
|
+
const source = state.project.frames[state.currentFrame - 1];
|
|
115
|
+
if (!source || !target) return false;
|
|
116
|
+
checkpoint(state);
|
|
117
|
+
state.project.frames[state.currentFrame] = copyShapesToFrame(source, target);
|
|
118
|
+
state.selectedId = '';
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function copyCurrentToAll(state: EditorState): boolean {
|
|
123
|
+
const source = state.project.frames[state.currentFrame];
|
|
124
|
+
if (!source) return false;
|
|
125
|
+
checkpoint(state);
|
|
126
|
+
state.project.frames = state.project.frames.map((frame) => frame.index === source.index ? frame : copyShapesToFrame(source, frame));
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import type { SEOSection } from '../../types';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
locale?: string;
|
|
7
|
+
sections?: SEOSection[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { locale = 'en', sections = [] } = Astro.props;
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
<SEORenderer content={{ locale, sections }} />
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface EditorPreferences {
|
|
2
|
+
fps: number;
|
|
3
|
+
onionPrevious: boolean;
|
|
4
|
+
onionNext: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const STORAGE_KEY = 'jjlmoya-hitbox-animator-preferences';
|
|
8
|
+
const defaults: EditorPreferences = { fps: 12, onionPrevious: true, onionNext: true };
|
|
9
|
+
|
|
10
|
+
export function loadPreferences(): EditorPreferences {
|
|
11
|
+
try {
|
|
12
|
+
const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '') as Partial<EditorPreferences>;
|
|
13
|
+
return {
|
|
14
|
+
fps: typeof parsed.fps === 'number' ? parsed.fps : defaults.fps,
|
|
15
|
+
onionPrevious: typeof parsed.onionPrevious === 'boolean' ? parsed.onionPrevious : defaults.onionPrevious,
|
|
16
|
+
onionNext: typeof parsed.onionNext === 'boolean' ? parsed.onionNext : defaults.onionNext,
|
|
17
|
+
};
|
|
18
|
+
} catch {
|
|
19
|
+
return { ...defaults };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function savePreferences(preferences: EditorPreferences): void {
|
|
24
|
+
try {
|
|
25
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences));
|
|
26
|
+
} catch {}
|
|
27
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export interface HitboxHurtboxAnimatorUI extends Record<string, string> {
|
|
2
|
+
onboarding: string;
|
|
3
|
+
privacyNote: string;
|
|
4
|
+
loadSprite: string;
|
|
5
|
+
loadHint: string;
|
|
6
|
+
chooseImages: string;
|
|
7
|
+
slicingTitle: string;
|
|
8
|
+
rowsLabel: string;
|
|
9
|
+
columnsLabel: string;
|
|
10
|
+
applySlicing: string;
|
|
11
|
+
playbackTitle: string;
|
|
12
|
+
previousFrame: string;
|
|
13
|
+
play: string;
|
|
14
|
+
pause: string;
|
|
15
|
+
nextFrame: string;
|
|
16
|
+
fpsLabel: string;
|
|
17
|
+
onionPrevious: string;
|
|
18
|
+
onionNext: string;
|
|
19
|
+
layerTitle: string;
|
|
20
|
+
typeHitbox: string;
|
|
21
|
+
typeHurtbox: string;
|
|
22
|
+
typePushbox: string;
|
|
23
|
+
typeGrabbox: string;
|
|
24
|
+
typeSensor: string;
|
|
25
|
+
typeCustom: string;
|
|
26
|
+
shapeRectangle: string;
|
|
27
|
+
shapeCircle: string;
|
|
28
|
+
drawShape: string;
|
|
29
|
+
selectShape: string;
|
|
30
|
+
stageLabel: string;
|
|
31
|
+
emptyStage: string;
|
|
32
|
+
frameReadout: string;
|
|
33
|
+
timelineTitle: string;
|
|
34
|
+
inspectorTitle: string;
|
|
35
|
+
noSelection: string;
|
|
36
|
+
nameLabel: string;
|
|
37
|
+
xLabel: string;
|
|
38
|
+
yLabel: string;
|
|
39
|
+
widthLabel: string;
|
|
40
|
+
heightLabel: string;
|
|
41
|
+
radiusLabel: string;
|
|
42
|
+
duplicateShape: string;
|
|
43
|
+
mirrorShape: string;
|
|
44
|
+
deleteShape: string;
|
|
45
|
+
copyPrevious: string;
|
|
46
|
+
copyAll: string;
|
|
47
|
+
pivotTitle: string;
|
|
48
|
+
pivotXLabel: string;
|
|
49
|
+
pivotYLabel: string;
|
|
50
|
+
exportTitle: string;
|
|
51
|
+
exportJson: string;
|
|
52
|
+
importJson: string;
|
|
53
|
+
exportContactSheet: string;
|
|
54
|
+
resetProject: string;
|
|
55
|
+
undo: string;
|
|
56
|
+
redo: string;
|
|
57
|
+
statusReady: string;
|
|
58
|
+
statusImageLoaded: string;
|
|
59
|
+
statusShapeCreated: string;
|
|
60
|
+
statusShapeUpdated: string;
|
|
61
|
+
statusImported: string;
|
|
62
|
+
statusExported: string;
|
|
63
|
+
statusError: string;
|
|
64
|
+
framesBadge: string;
|
|
65
|
+
shapesBadge: string;
|
|
66
|
+
coverageBadge: string;
|
|
67
|
+
coordinatesNote: string;
|
|
68
|
+
localOnlyDisclosure: string;
|
|
69
|
+
limitationDisclosure: string;
|
|
70
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { STEAM_BBCODE_TRANSLATOR_TOOL } from './tool/steamBbcodeTranslator';
|
|
|
10
10
|
import { RETRO_SFX_GENERATOR_TOOL } from './tool/retroSfxGenerator';
|
|
11
11
|
import { PIXEL_ART_PALETTE_SWAPPER_TOOL } from './tool/pixelArtPaletteSwapper';
|
|
12
12
|
import { GAME_UI_ACCESSIBILITY_TESTER_TOOL } from './tool/gameUIAccessibilityTester';
|
|
13
|
+
import { HITBOX_HURTBOX_ANIMATOR_TOOL } from './tool/hitboxHurtboxAnimator';
|
|
13
14
|
|
|
14
15
|
export const ALL_TOOLS: ToolDefinition[] = [
|
|
15
16
|
STEAM_CAPSULE_GENERATOR_TOOL,
|
|
@@ -22,4 +23,5 @@ export const ALL_TOOLS: ToolDefinition[] = [
|
|
|
22
23
|
RETRO_SFX_GENERATOR_TOOL,
|
|
23
24
|
PIXEL_ART_PALETTE_SWAPPER_TOOL,
|
|
24
25
|
GAME_UI_ACCESSIBILITY_TESTER_TOOL,
|
|
26
|
+
HITBOX_HURTBOX_ANIMATOR_TOOL,
|
|
25
27
|
];
|