@jjlmoya/utils-games-development 1.46.0 → 1.49.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 +8 -1
- package/src/tests/locale_completeness.test.ts +1 -20
- package/src/tests/tool_validation.test.ts +2 -3
- package/src/tool/itchioGameTester/audit.ts +51 -0
- package/src/tool/itchioGameTester/bibliography.astro +6 -0
- package/src/tool/itchioGameTester/bibliography.ts +14 -0
- package/src/tool/itchioGameTester/client.ts +238 -0
- package/src/tool/itchioGameTester/component.astro +97 -0
- package/src/tool/itchioGameTester/entry.ts +28 -0
- package/src/tool/itchioGameTester/i18n/de.ts +107 -0
- package/src/tool/itchioGameTester/i18n/en.ts +164 -0
- package/src/tool/itchioGameTester/i18n/es.ts +172 -0
- package/src/tool/itchioGameTester/i18n/fr.ts +107 -0
- package/src/tool/itchioGameTester/i18n/id.ts +107 -0
- package/src/tool/itchioGameTester/i18n/it.ts +107 -0
- package/src/tool/itchioGameTester/i18n/ja.ts +107 -0
- package/src/tool/itchioGameTester/i18n/ko.ts +107 -0
- package/src/tool/itchioGameTester/i18n/nl.ts +107 -0
- package/src/tool/itchioGameTester/i18n/pl.ts +107 -0
- package/src/tool/itchioGameTester/i18n/pt.ts +107 -0
- package/src/tool/itchioGameTester/i18n/ru.ts +107 -0
- package/src/tool/itchioGameTester/i18n/sv.ts +107 -0
- package/src/tool/itchioGameTester/i18n/tr.ts +107 -0
- package/src/tool/itchioGameTester/i18n/zh.ts +107 -0
- package/src/tool/itchioGameTester/index.ts +11 -0
- package/src/tool/itchioGameTester/itchio-game-tester.css +273 -0
- package/src/tool/itchioGameTester/logic.test.ts +56 -0
- package/src/tool/itchioGameTester/logic.ts +266 -0
- package/src/tool/itchioGameTester/seo.astro +15 -0
- package/src/tool/itchioGameTester/ui.ts +20 -0
- package/src/tool/spriteSheetPacker/app-client.ts +248 -0
- package/src/tool/spriteSheetPacker/bibliography.astro +6 -0
- package/src/tool/spriteSheetPacker/bibliography.ts +12 -0
- package/src/tool/spriteSheetPacker/component.astro +229 -0
- package/src/tool/spriteSheetPacker/dropzone-client.ts +55 -0
- package/src/tool/spriteSheetPacker/entry.ts +28 -0
- package/src/tool/spriteSheetPacker/exporter.ts +116 -0
- package/src/tool/spriteSheetPacker/extractor-client.ts +127 -0
- package/src/tool/spriteSheetPacker/flipbook-client.ts +60 -0
- package/src/tool/spriteSheetPacker/i18n/de.ts +270 -0
- package/src/tool/spriteSheetPacker/i18n/en.ts +275 -0
- package/src/tool/spriteSheetPacker/i18n/es.ts +275 -0
- package/src/tool/spriteSheetPacker/i18n/fr.ts +270 -0
- package/src/tool/spriteSheetPacker/i18n/id.ts +270 -0
- package/src/tool/spriteSheetPacker/i18n/it.ts +270 -0
- package/src/tool/spriteSheetPacker/i18n/ja.ts +270 -0
- package/src/tool/spriteSheetPacker/i18n/ko.ts +270 -0
- package/src/tool/spriteSheetPacker/i18n/nl.ts +270 -0
- package/src/tool/spriteSheetPacker/i18n/pl.ts +270 -0
- package/src/tool/spriteSheetPacker/i18n/pt.ts +270 -0
- package/src/tool/spriteSheetPacker/i18n/ru.ts +266 -0
- package/src/tool/spriteSheetPacker/i18n/sv.ts +270 -0
- package/src/tool/spriteSheetPacker/i18n/tr.ts +270 -0
- package/src/tool/spriteSheetPacker/i18n/zh.ts +270 -0
- package/src/tool/spriteSheetPacker/index.ts +11 -0
- package/src/tool/spriteSheetPacker/logic.test.ts +155 -0
- package/src/tool/spriteSheetPacker/logic.ts +32 -0
- package/src/tool/spriteSheetPacker/packer-core.ts +121 -0
- package/src/tool/spriteSheetPacker/packer-ui.ts +86 -0
- package/src/tool/spriteSheetPacker/seo.astro +15 -0
- package/src/tool/spriteSheetPacker/sprite-sheet-packer.css +542 -0
- package/src/tool/spriteSheetPacker/types.ts +89 -0
- package/src/tool/spriteSheetPacker/ui.ts +42 -0
- package/src/tools.ts +4 -1
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
export interface WebGameFileInfo {
|
|
2
|
+
name: string;
|
|
3
|
+
size: number;
|
|
4
|
+
isDirectory: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface GameValidationIssue {
|
|
8
|
+
type: 'error' | 'warning' | 'info';
|
|
9
|
+
code: string;
|
|
10
|
+
message: string;
|
|
11
|
+
details?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type DetectedGameEngine = 'Godot 4' | 'Godot 3' | 'Unity WebGL' | 'Phaser / Construct' | 'Custom HTML5';
|
|
15
|
+
|
|
16
|
+
export interface GameValidationResult {
|
|
17
|
+
score: number;
|
|
18
|
+
engine: DetectedGameEngine;
|
|
19
|
+
hasIndexHtml: boolean;
|
|
20
|
+
indexHtmlPath: string;
|
|
21
|
+
totalFiles: number;
|
|
22
|
+
totalUncompressedSize: number;
|
|
23
|
+
issues: GameValidationIssue[];
|
|
24
|
+
fileList: WebGameFileInfo[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ResolutionPreset {
|
|
28
|
+
name: string;
|
|
29
|
+
width: number;
|
|
30
|
+
height: number;
|
|
31
|
+
ratio: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const RESOLUTION_PRESETS: ResolutionPreset[] = [
|
|
35
|
+
{ name: '16:9 Standard HD', width: 1280, height: 720, ratio: '16:9' },
|
|
36
|
+
{ name: '16:9 Itch Classic', width: 960, height: 540, ratio: '16:9' },
|
|
37
|
+
{ name: '16:9 Full HD', width: 1920, height: 1080, ratio: '16:9' },
|
|
38
|
+
{ name: '4:3 VGA', width: 800, height: 600, ratio: '4:3' },
|
|
39
|
+
{ name: '4:3 XGA', width: 1024, height: 768, ratio: '4:3' },
|
|
40
|
+
{ name: '3:2 Classic', width: 960, height: 640, ratio: '3:2' },
|
|
41
|
+
{ name: '1:1 Square', width: 800, height: 800, ratio: '1:1' },
|
|
42
|
+
{ name: 'Itch Desktop Default', width: 960, height: 600, ratio: '16:10' },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export function detectGameEngine(files: WebGameFileInfo[]): DetectedGameEngine {
|
|
46
|
+
const fileNames = files.map((f) => f.name.replace(/\\/g, '/'));
|
|
47
|
+
|
|
48
|
+
if (fileNames.some((n) => n.endsWith('.pck') || n.endsWith('.wasm'))) {
|
|
49
|
+
const isGodot4 = fileNames.some((n) => n.toLowerCase().includes('godot') || n.endsWith('.side.wasm'));
|
|
50
|
+
return isGodot4 ? 'Godot 4' : 'Godot 3';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (fileNames.some((n) => n.includes('Build/') || n.endsWith('.framework.js') || n.endsWith('.data'))) {
|
|
54
|
+
return 'Unity WebGL';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (fileNames.some((n) => n.includes('phaser') || n.includes('c2runtime') || n.includes('c3runtime'))) {
|
|
58
|
+
return 'Phaser / Construct';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return 'Custom HTML5';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function checkCaseConflict(normalizedPath: string, lowerCaseMap: Map<string, string>): GameValidationIssue | null {
|
|
65
|
+
const lowerPath = normalizedPath.toLowerCase();
|
|
66
|
+
const existing = lowerCaseMap.get(lowerPath);
|
|
67
|
+
if (existing && existing !== normalizedPath) {
|
|
68
|
+
return {
|
|
69
|
+
type: 'warning',
|
|
70
|
+
code: 'CASE_CONFLICT',
|
|
71
|
+
message: 'File name case sensitivity conflict detected',
|
|
72
|
+
details: `${normalizedPath} conflicts with ${existing}. Case differences cause 404 errors on Itch.io Linux/macOS servers.`
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
lowerCaseMap.set(lowerPath, normalizedPath);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function checkFileExtension(file: WebGameFileInfo): GameValidationIssue | null {
|
|
80
|
+
const ext = file.name.split('.').pop() || '';
|
|
81
|
+
if (ext && ext !== ext.toLowerCase() && !file.isDirectory) {
|
|
82
|
+
return {
|
|
83
|
+
type: 'warning',
|
|
84
|
+
code: 'UPPERCASE_EXTENSION',
|
|
85
|
+
message: 'Uppercase file extension detected',
|
|
86
|
+
details: `${file.name} has an uppercase extension .${ext}. Web servers may fail to send correct MIME types.`
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function checkHeavyAudio(file: WebGameFileInfo): GameValidationIssue | null {
|
|
93
|
+
const ext = file.name.split('.').pop() || '';
|
|
94
|
+
if (ext.toLowerCase() === 'wav' && file.size > 5 * 1024 * 1024) {
|
|
95
|
+
return {
|
|
96
|
+
type: 'info',
|
|
97
|
+
code: 'HEAVY_AUDIO',
|
|
98
|
+
message: 'Large uncompressed WAV audio file',
|
|
99
|
+
details: `${file.name} is ${Math.round(file.size / (1024 * 1024))}MB. Convert to OGG or MP3 for faster web loading.`
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function evaluateIndexHtml(hasIndexHtml: boolean, indexHtmlPath: string): { issue: GameValidationIssue | null; penalty: number } {
|
|
106
|
+
if (hasIndexHtml) {
|
|
107
|
+
return { issue: null, penalty: 0 };
|
|
108
|
+
}
|
|
109
|
+
if (indexHtmlPath !== '') {
|
|
110
|
+
return {
|
|
111
|
+
issue: {
|
|
112
|
+
type: 'error',
|
|
113
|
+
code: 'NESTED_INDEX',
|
|
114
|
+
message: 'index.html is nested inside a subfolder',
|
|
115
|
+
details: `Found index.html at ${indexHtmlPath}. Itch.io requires index.html to be located in the root directory.`
|
|
116
|
+
},
|
|
117
|
+
penalty: 50
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
issue: {
|
|
122
|
+
type: 'error',
|
|
123
|
+
code: 'MISSING_INDEX',
|
|
124
|
+
message: 'Missing index.html file',
|
|
125
|
+
details: 'No index.html file was found in the uploaded package. Web builds require an index.html entry point.'
|
|
126
|
+
},
|
|
127
|
+
penalty: 80
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface ScanAccumulator {
|
|
132
|
+
score: number;
|
|
133
|
+
hasIndexHtml: boolean;
|
|
134
|
+
indexHtmlPath: string;
|
|
135
|
+
totalFiles: number;
|
|
136
|
+
totalUncompressedSize: number;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function checkIndexLocation(normalizedPath: string, acc: ScanAccumulator) {
|
|
140
|
+
if (normalizedPath === 'index.html') {
|
|
141
|
+
acc.hasIndexHtml = true;
|
|
142
|
+
acc.indexHtmlPath = normalizedPath;
|
|
143
|
+
} else if ((normalizedPath.endsWith('/index.html') || normalizedPath.endsWith('\\index.html')) && !acc.hasIndexHtml) {
|
|
144
|
+
acc.indexHtmlPath = normalizedPath;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function processSingleFile(file: WebGameFileInfo, lowerCaseMap: Map<string, string>, acc: ScanAccumulator, issues: GameValidationIssue[]) {
|
|
149
|
+
if (!file.isDirectory) {
|
|
150
|
+
acc.totalFiles++;
|
|
151
|
+
acc.totalUncompressedSize += file.size;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const normalizedPath = file.name.replace(/\\/g, '/');
|
|
155
|
+
const caseIssue = checkCaseConflict(normalizedPath, lowerCaseMap);
|
|
156
|
+
if (caseIssue) {
|
|
157
|
+
issues.push(caseIssue);
|
|
158
|
+
acc.score -= 10;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
checkIndexLocation(normalizedPath, acc);
|
|
162
|
+
|
|
163
|
+
const extIssue = checkFileExtension(file);
|
|
164
|
+
if (extIssue) {
|
|
165
|
+
issues.push(extIssue);
|
|
166
|
+
acc.score -= 5;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const audioIssue = checkHeavyAudio(file);
|
|
170
|
+
if (audioIssue) {
|
|
171
|
+
issues.push(audioIssue);
|
|
172
|
+
acc.score -= 2;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function appendEngineAndSizeIssues(engine: DetectedGameEngine, totalUncompressedSize: number, score: number, issues: GameValidationIssue[]): number {
|
|
177
|
+
let updatedScore = score;
|
|
178
|
+
if (engine === 'Godot 4') {
|
|
179
|
+
issues.push({
|
|
180
|
+
type: 'info',
|
|
181
|
+
code: 'GODOT4_HEADERS',
|
|
182
|
+
message: 'Godot 4 Web export detected',
|
|
183
|
+
details: 'Ensure "SharedArrayBuffer support" or Cross-Origin Isolation headers are enabled in your Itch.io game edit page.'
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (totalUncompressedSize > 500 * 1024 * 1024) {
|
|
188
|
+
issues.push({
|
|
189
|
+
type: 'warning',
|
|
190
|
+
code: 'LARGE_BUILD',
|
|
191
|
+
message: 'Total build size exceeds 500MB',
|
|
192
|
+
details: 'Large web builds may experience high bounce rates or memory crashes on mobile browsers.'
|
|
193
|
+
});
|
|
194
|
+
updatedScore -= 15;
|
|
195
|
+
}
|
|
196
|
+
return updatedScore;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function validateGameFiles(files: WebGameFileInfo[]): GameValidationResult {
|
|
200
|
+
const issues: GameValidationIssue[] = [];
|
|
201
|
+
const engine = detectGameEngine(files);
|
|
202
|
+
const lowerCaseMap = new Map<string, string>();
|
|
203
|
+
const acc: ScanAccumulator = { score: 100, hasIndexHtml: false, indexHtmlPath: '', totalFiles: 0, totalUncompressedSize: 0 };
|
|
204
|
+
|
|
205
|
+
for (const file of files) {
|
|
206
|
+
processSingleFile(file, lowerCaseMap, acc, issues);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const indexEval = evaluateIndexHtml(acc.hasIndexHtml, acc.indexHtmlPath);
|
|
210
|
+
if (indexEval.issue) {
|
|
211
|
+
issues.unshift(indexEval.issue);
|
|
212
|
+
acc.score -= indexEval.penalty;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const finalScore = appendEngineAndSizeIssues(engine, acc.totalUncompressedSize, acc.score, issues);
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
score: Math.max(0, finalScore),
|
|
219
|
+
engine,
|
|
220
|
+
hasIndexHtml: acc.hasIndexHtml,
|
|
221
|
+
indexHtmlPath: acc.indexHtmlPath,
|
|
222
|
+
totalFiles: acc.totalFiles,
|
|
223
|
+
totalUncompressedSize: acc.totalUncompressedSize,
|
|
224
|
+
issues,
|
|
225
|
+
fileList: files,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function calculateAspectRatio(width: number, height: number): string {
|
|
230
|
+
if (!width || !height) return '16:9';
|
|
231
|
+
const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b));
|
|
232
|
+
const divisor = gcd(width, height);
|
|
233
|
+
const wRatio = width / divisor;
|
|
234
|
+
const hRatio = height / divisor;
|
|
235
|
+
|
|
236
|
+
const ratioKey = `${wRatio}:${hRatio}`;
|
|
237
|
+
const knownRatios: Record<string, string> = {
|
|
238
|
+
'16:9': '16:9',
|
|
239
|
+
'4:3': '4:3',
|
|
240
|
+
'3:2': '3:2',
|
|
241
|
+
'16:10': '16:10',
|
|
242
|
+
'1:1': '1:1',
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
if (knownRatios[ratioKey]) {
|
|
246
|
+
return knownRatios[ratioKey];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const decimal = width / height;
|
|
250
|
+
if (Math.abs(decimal - 16 / 9) < 0.05) return '16:9 Approx';
|
|
251
|
+
if (Math.abs(decimal - 4 / 3) < 0.05) return '4:3 Approx';
|
|
252
|
+
|
|
253
|
+
return ratioKey;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function generateItchioEmbedConfig(width: number, height: number, orientation: 'landscape' | 'portrait' = 'landscape') {
|
|
257
|
+
return {
|
|
258
|
+
viewportWidth: width,
|
|
259
|
+
viewportHeight: height,
|
|
260
|
+
embedMode: 'Embed in page',
|
|
261
|
+
scrollbars: false,
|
|
262
|
+
fullscreenButton: true,
|
|
263
|
+
orientation,
|
|
264
|
+
codeSnippet: `Dimensions: ${width}px x ${height}px\nEmbed Mode: Embed in page\nMobile Orientation: ${orientation}\nFullscreen Button: Enabled`
|
|
265
|
+
};
|
|
266
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { itchioGameTester } from './entry';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
locale?: KnownLocale;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { locale = 'en' } = Astro.props;
|
|
11
|
+
const loader = itchioGameTester.i18n[locale] ?? itchioGameTester.i18n.en;
|
|
12
|
+
const content = loader ? await loader() : null;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
{content && content.seo && content.seo.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface ItchioGameTesterUI extends Record<string, string> {
|
|
2
|
+
dropzoneTitle: string;
|
|
3
|
+
dropzoneHint: string;
|
|
4
|
+
chooseFiles: string;
|
|
5
|
+
engineDetected: string;
|
|
6
|
+
compatibilityScore: string;
|
|
7
|
+
viewportWidth: string;
|
|
8
|
+
viewportHeight: string;
|
|
9
|
+
presets: string;
|
|
10
|
+
fitTest: string;
|
|
11
|
+
copySettings: string;
|
|
12
|
+
copied: string;
|
|
13
|
+
embedMode: string;
|
|
14
|
+
scrollbars: string;
|
|
15
|
+
noIssuesFound: string;
|
|
16
|
+
filesInspected: string;
|
|
17
|
+
resetViewport: string;
|
|
18
|
+
autoScaleToggle: string;
|
|
19
|
+
scaledNotice: string;
|
|
20
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import JSZip from 'jszip';
|
|
2
|
+
import { bindDropzone } from './dropzone-client';
|
|
3
|
+
import { initExtractor, updateExtractor } from './extractor-client';
|
|
4
|
+
import { bindFlipbook, startFlipbookLoop } from './flipbook-client';
|
|
5
|
+
import { calculateBinPacking, type ExportFormat, type SpriteFrameInput } from './logic';
|
|
6
|
+
import { drawEmptyCanvasMessage, setupStepper, spawnParticle, triggerButtonFeedback, type LoadedImageItem } from './packer-ui';
|
|
7
|
+
|
|
8
|
+
let loadedImages: LoadedImageItem[] = [];
|
|
9
|
+
let currentAtlasData: ReturnType<typeof calculateBinPacking> | null = null;
|
|
10
|
+
|
|
11
|
+
function parseInputValue(id: string, fallback: string): number {
|
|
12
|
+
const el = document.getElementById(id) as HTMLInputElement | null;
|
|
13
|
+
return parseInt(el?.value || fallback, 10);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function renderAtlasCanvas(): void {
|
|
17
|
+
const canvas = document.getElementById('ssp-atlas-canvas') as HTMLCanvasElement | null;
|
|
18
|
+
if (!canvas) return;
|
|
19
|
+
|
|
20
|
+
if (!currentAtlasData || loadedImages.length === 0) {
|
|
21
|
+
drawEmptyCanvasMessage(canvas, 'Upload PNG frames to preview packed texture atlas');
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
canvas.width = currentAtlasData.textureWidth;
|
|
26
|
+
canvas.height = currentAtlasData.textureHeight;
|
|
27
|
+
|
|
28
|
+
const ctx = canvas.getContext('2d');
|
|
29
|
+
if (!ctx) return;
|
|
30
|
+
|
|
31
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
32
|
+
for (const frame of currentAtlasData.frames) {
|
|
33
|
+
const found = loadedImages.find((img) => img.id === frame.id);
|
|
34
|
+
if (found) {
|
|
35
|
+
ctx.drawImage(found.img, frame.x, frame.y, frame.width, frame.height);
|
|
36
|
+
ctx.strokeStyle = 'rgba(99, 102, 241, 0.4)';
|
|
37
|
+
ctx.lineWidth = 1;
|
|
38
|
+
ctx.strokeRect(frame.x, frame.y, frame.width, frame.height);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function updateStatsUI(): void {
|
|
44
|
+
if (!currentAtlasData) return;
|
|
45
|
+
|
|
46
|
+
const effEl = document.getElementById('ssp-stat-efficiency');
|
|
47
|
+
const dcEl = document.getElementById('ssp-stat-drawcalls');
|
|
48
|
+
const framesEl = document.getElementById('ssp-stat-frames');
|
|
49
|
+
const sizeEl = document.getElementById('ssp-stat-size');
|
|
50
|
+
const codeEl = document.getElementById('ssp-code-snippet');
|
|
51
|
+
|
|
52
|
+
if (effEl) effEl.textContent = `${currentAtlasData.efficiency}%`;
|
|
53
|
+
if (dcEl) dcEl.textContent = `${currentAtlasData.drawCallsBefore} -> ${currentAtlasData.drawCallsAfter}`;
|
|
54
|
+
if (framesEl) framesEl.textContent = currentAtlasData.totalFrames.toString();
|
|
55
|
+
if (sizeEl) sizeEl.textContent = `${currentAtlasData.textureWidth}x${currentAtlasData.textureHeight}`;
|
|
56
|
+
if (codeEl) codeEl.textContent = currentAtlasData.codeSnippet;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getPackerSettings() {
|
|
60
|
+
const pad = parseInputValue('ssp-padding', '2');
|
|
61
|
+
const ext = parseInputValue('ssp-extrusion', '0');
|
|
62
|
+
const max = parseInputValue('ssp-max-size', '2048');
|
|
63
|
+
const fmt = ((document.getElementById('ssp-export-format') as HTMLSelectElement)?.value || 'generic-json-hash') as ExportFormat;
|
|
64
|
+
const pot = (document.getElementById('ssp-power-two') as HTMLInputElement)?.checked ?? true;
|
|
65
|
+
return { padding: pad, borderExtrusion: ext, format: fmt, maxTextureWidth: max, forcePowerOfTwo: pot };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function updatePacker(): void {
|
|
69
|
+
const { padding, borderExtrusion, format, maxTextureWidth, forcePowerOfTwo } = getPackerSettings();
|
|
70
|
+
|
|
71
|
+
const frameInputs: SpriteFrameInput[] = loadedImages.map((item) => ({
|
|
72
|
+
id: item.id,
|
|
73
|
+
name: item.name,
|
|
74
|
+
width: item.img.width,
|
|
75
|
+
height: item.img.height,
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
currentAtlasData = calculateBinPacking(frameInputs, {
|
|
79
|
+
padding,
|
|
80
|
+
borderExtrusion,
|
|
81
|
+
forcePowerOfTwo,
|
|
82
|
+
maxTextureWidth,
|
|
83
|
+
maxTextureHeight: maxTextureWidth,
|
|
84
|
+
allowRotation: false,
|
|
85
|
+
trimTransparency: false,
|
|
86
|
+
format,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
renderAtlasCanvas();
|
|
90
|
+
updateStatsUI();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function setPresetValues(padding: number, extrusion: number, maxSize: string, pot: boolean): void {
|
|
94
|
+
const padInput = document.getElementById('ssp-padding') as HTMLInputElement | null;
|
|
95
|
+
const extInput = document.getElementById('ssp-extrusion') as HTMLInputElement | null;
|
|
96
|
+
const maxSelect = document.getElementById('ssp-max-size') as HTMLSelectElement | null;
|
|
97
|
+
const potCheck = document.getElementById('ssp-power-two') as HTMLInputElement | null;
|
|
98
|
+
|
|
99
|
+
if (padInput) {
|
|
100
|
+
padInput.value = padding.toString();
|
|
101
|
+
const valEl = document.getElementById('ssp-padding-val');
|
|
102
|
+
if (valEl) valEl.textContent = padding.toString();
|
|
103
|
+
}
|
|
104
|
+
if (extInput) {
|
|
105
|
+
extInput.value = extrusion.toString();
|
|
106
|
+
const valEl = document.getElementById('ssp-extrusion-val');
|
|
107
|
+
if (valEl) valEl.textContent = extrusion.toString();
|
|
108
|
+
}
|
|
109
|
+
if (maxSelect) maxSelect.value = maxSize;
|
|
110
|
+
if (potCheck) potCheck.checked = pot;
|
|
111
|
+
|
|
112
|
+
updatePacker();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function downloadZipPackage(e: MouseEvent): void {
|
|
116
|
+
if (!currentAtlasData || currentAtlasData.frames.length === 0) return;
|
|
117
|
+
const canvas = document.getElementById('ssp-atlas-canvas') as HTMLCanvasElement | null;
|
|
118
|
+
if (!canvas) return;
|
|
119
|
+
|
|
120
|
+
triggerButtonFeedback(document.getElementById('ssp-btn-download-zip'), 'ZIP Generated!', e);
|
|
121
|
+
|
|
122
|
+
const zip = new JSZip();
|
|
123
|
+
canvas.toBlob((blob) => {
|
|
124
|
+
if (!blob) return;
|
|
125
|
+
zip.file('spritesheet.png', blob);
|
|
126
|
+
zip.file('spritesheet.json', currentAtlasData!.atlasJson);
|
|
127
|
+
zip.generateAsync({ type: 'blob' }).then((content) => {
|
|
128
|
+
const link = document.createElement('a');
|
|
129
|
+
link.href = URL.createObjectURL(content);
|
|
130
|
+
link.download = 'sprite-sheet-package.zip';
|
|
131
|
+
link.click();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function bindTabs(): void {
|
|
137
|
+
const tabPacker = document.getElementById('ssp-tab-packer');
|
|
138
|
+
const tabExtractor = document.getElementById('ssp-tab-extractor');
|
|
139
|
+
const panelPacker = document.getElementById('ssp-packer-panel');
|
|
140
|
+
const panelExtractor = document.getElementById('ssp-extractor-panel');
|
|
141
|
+
|
|
142
|
+
tabPacker?.addEventListener('click', () => {
|
|
143
|
+
tabPacker.classList.add('active');
|
|
144
|
+
tabExtractor?.classList.remove('active');
|
|
145
|
+
if (panelPacker) panelPacker.style.display = 'grid';
|
|
146
|
+
if (panelExtractor) panelExtractor.style.display = 'none';
|
|
147
|
+
renderAtlasCanvas();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
tabExtractor?.addEventListener('click', () => {
|
|
151
|
+
tabExtractor.classList.add('active');
|
|
152
|
+
tabPacker?.classList.remove('active');
|
|
153
|
+
if (panelPacker) panelPacker.style.display = 'none';
|
|
154
|
+
if (panelExtractor) panelExtractor.style.display = 'grid';
|
|
155
|
+
updateExtractor();
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function bindPresets(): void {
|
|
160
|
+
const setActivePreset = (target: HTMLElement) => {
|
|
161
|
+
document.querySelectorAll('.ssp-chip-btn').forEach((el) => el.classList.remove('active'));
|
|
162
|
+
target.classList.add('active');
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
document.getElementById('ssp-preset-pixel')?.addEventListener('click', (e) => {
|
|
166
|
+
setActivePreset(e.target as HTMLElement);
|
|
167
|
+
setPresetValues(2, 0, '512', true);
|
|
168
|
+
spawnParticle(e.clientX, e.clientY, 'PIXEL 16x16');
|
|
169
|
+
});
|
|
170
|
+
document.getElementById('ssp-preset-hd')?.addEventListener('click', (e) => {
|
|
171
|
+
setActivePreset(e.target as HTMLElement);
|
|
172
|
+
setPresetValues(4, 1, '1024', true);
|
|
173
|
+
spawnParticle(e.clientX, e.clientY, 'HD 1024');
|
|
174
|
+
});
|
|
175
|
+
document.getElementById('ssp-preset-mobile')?.addEventListener('click', (e) => {
|
|
176
|
+
setActivePreset(e.target as HTMLElement);
|
|
177
|
+
setPresetValues(2, 0, '2048', true);
|
|
178
|
+
spawnParticle(e.clientX, e.clientY, 'MOBILE 2048');
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function bindSliders(): void {
|
|
183
|
+
const paddingSlider = document.getElementById('ssp-padding') as HTMLInputElement | null;
|
|
184
|
+
const extrusionSlider = document.getElementById('ssp-extrusion') as HTMLInputElement | null;
|
|
185
|
+
|
|
186
|
+
paddingSlider?.addEventListener('input', () => {
|
|
187
|
+
const valEl = document.getElementById('ssp-padding-val');
|
|
188
|
+
if (valEl) valEl.textContent = paddingSlider.value;
|
|
189
|
+
updatePacker();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
extrusionSlider?.addEventListener('input', () => {
|
|
193
|
+
const valEl = document.getElementById('ssp-extrusion-val');
|
|
194
|
+
if (valEl) valEl.textContent = extrusionSlider.value;
|
|
195
|
+
updatePacker();
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function bindExportCopies(): void {
|
|
200
|
+
document.getElementById('ssp-btn-copy-json')?.addEventListener('click', (e) => {
|
|
201
|
+
if (!currentAtlasData) return;
|
|
202
|
+
triggerButtonFeedback(document.getElementById('ssp-btn-copy-json'), 'Copied!', e);
|
|
203
|
+
navigator.clipboard.writeText(currentAtlasData.atlasJson);
|
|
204
|
+
});
|
|
205
|
+
document.getElementById('ssp-btn-download-png')?.addEventListener('click', (e) => {
|
|
206
|
+
const canvas = document.getElementById('ssp-atlas-canvas') as HTMLCanvasElement | null;
|
|
207
|
+
if (!canvas) return;
|
|
208
|
+
triggerButtonFeedback(document.getElementById('ssp-btn-download-png'), 'Downloaded!', e);
|
|
209
|
+
const link = document.createElement('a');
|
|
210
|
+
link.href = canvas.toDataURL('image/png');
|
|
211
|
+
link.download = 'spritesheet.png';
|
|
212
|
+
link.click();
|
|
213
|
+
});
|
|
214
|
+
document.getElementById('ssp-btn-copy-code')?.addEventListener('click', (e) => {
|
|
215
|
+
if (!currentAtlasData) return;
|
|
216
|
+
triggerButtonFeedback(document.getElementById('ssp-btn-copy-code'), 'Copied Code!', e);
|
|
217
|
+
navigator.clipboard.writeText(currentAtlasData.codeSnippet);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function bindActionButtons(): void {
|
|
222
|
+
setupStepper('ssp-pad-dec', 'ssp-pad-inc', 'ssp-padding', updatePacker);
|
|
223
|
+
setupStepper('ssp-ext-dec', 'ssp-ext-inc', 'ssp-extrusion', updatePacker);
|
|
224
|
+
bindSliders();
|
|
225
|
+
bindExportCopies();
|
|
226
|
+
|
|
227
|
+
document.getElementById('ssp-export-format')?.addEventListener('change', updatePacker);
|
|
228
|
+
document.getElementById('ssp-max-size')?.addEventListener('change', updatePacker);
|
|
229
|
+
document.getElementById('ssp-power-two')?.addEventListener('change', updatePacker);
|
|
230
|
+
document.getElementById('ssp-btn-download-zip')?.addEventListener('click', (e) => downloadZipPackage(e));
|
|
231
|
+
document.getElementById('ssp-btn-clear')?.addEventListener('click', (e) => {
|
|
232
|
+
loadedImages = [];
|
|
233
|
+
updatePacker();
|
|
234
|
+
spawnParticle(e.clientX, e.clientY, 'CLEARED!');
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function initSpriteSheetPackerApp(): void {
|
|
239
|
+
bindTabs();
|
|
240
|
+
bindPresets();
|
|
241
|
+
bindDropzone(loadedImages, updatePacker);
|
|
242
|
+
bindActionButtons();
|
|
243
|
+
bindFlipbook();
|
|
244
|
+
initExtractor();
|
|
245
|
+
startFlipbookLoop(loadedImages);
|
|
246
|
+
renderAtlasCanvas();
|
|
247
|
+
updateExtractor();
|
|
248
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliographyEntries: BibliographyEntry[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'Godot Engine 2D Sprite Sheets Documentation',
|
|
6
|
+
url: 'https://docs.godotengine.org/en/stable/tutorials/2d/2d_sprite_animation.html',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'Unity Sprite Atlas Manual',
|
|
10
|
+
url: 'https://docs.unity3d.com/Manual/class-SpriteAtlas.html',
|
|
11
|
+
},
|
|
12
|
+
];
|