@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,270 @@
|
|
|
1
|
+
import type { ToolLocaleContent } from '../../../types';
|
|
2
|
+
import type { SpriteSheetPackerUI } from '../ui';
|
|
3
|
+
|
|
4
|
+
export const content: ToolLocaleContent<SpriteSheetPackerUI> = {
|
|
5
|
+
slug: 'sprite-sheet-packer',
|
|
6
|
+
title: 'Sprite Sheet 精灵图打包与提取器',
|
|
7
|
+
description:
|
|
8
|
+
'将独立的动画帧图像打包为纹理图集,或从现有精灵图集中提取单张图像,优化 2D 游戏渲染性能。',
|
|
9
|
+
ui: {
|
|
10
|
+
packerTab: '图集打包工作室',
|
|
11
|
+
extractorTab: '精灵图提取器',
|
|
12
|
+
dropZoneTitle: '拖拽动画帧图像至此',
|
|
13
|
+
dropZoneSubtitle: '上传 PNG 或 WebP 图像以生成优化的纹理图集',
|
|
14
|
+
selectFilesButton: '选择图像文件',
|
|
15
|
+
clearAllButton: '清空工作区',
|
|
16
|
+
downloadZipButton: '下载资源包 (ZIP)',
|
|
17
|
+
copyJsonButton: '复制图集 JSON',
|
|
18
|
+
downloadSheetPngButton: '下载纹理 PNG',
|
|
19
|
+
paddingLabel: '帧间距 (px)',
|
|
20
|
+
borderExtrusionLabel: '边缘外扩 (px)',
|
|
21
|
+
maxTextureSizeLabel: '最大纹理尺寸',
|
|
22
|
+
powerOfTwoLabel: '强制 2 的次幂 (POT)',
|
|
23
|
+
trimTransparencyLabel: '裁剪透明区域',
|
|
24
|
+
exportFormatLabel: '目标引擎格式',
|
|
25
|
+
presetPixelArt: '像素艺术 16x16 预设',
|
|
26
|
+
presetHdUi: '高清 UI 图集 1024 预设',
|
|
27
|
+
presetMobile: '移动 WebGL 2048 预设',
|
|
28
|
+
formatGenericHash: '通用 JSON (Hash)',
|
|
29
|
+
formatGenericArray: '通用 JSON (Array)',
|
|
30
|
+
formatUnity: 'Unity 2D 引擎',
|
|
31
|
+
formatGodot: 'Godot 2D 引擎',
|
|
32
|
+
formatPhaser: 'Phaser / PixiJS 引擎',
|
|
33
|
+
formatCss: 'Web 前端 CSS',
|
|
34
|
+
previewTitle: '纹理图集预览',
|
|
35
|
+
efficiencyBadge: '纹理利用率',
|
|
36
|
+
drawCallsBadge: '减少的 Draw Call',
|
|
37
|
+
totalFramesBadge: '已打包帧数',
|
|
38
|
+
textureSizeBadge: '图集分辨率',
|
|
39
|
+
flipbookTitle: '动画翻页播放器',
|
|
40
|
+
flipbookFpsLabel: '动画播放速度 (FPS)',
|
|
41
|
+
playAnimation: '播放序列',
|
|
42
|
+
pauseAnimation: '暂停播放',
|
|
43
|
+
extractorModeGrid: '固定网格切片',
|
|
44
|
+
extractorModeAlpha: '自动 Alpha 通道切片',
|
|
45
|
+
frameWidthLabel: '单帧宽度 (px)',
|
|
46
|
+
frameHeightLabel: '单帧高度 (px)',
|
|
47
|
+
marginLabel: '外边距偏移 (px)',
|
|
48
|
+
spacingLabel: '网格间距 (px)',
|
|
49
|
+
extractFramesButton: '提取图像帧',
|
|
50
|
+
extractedCountLabel: '提取的精灵图数',
|
|
51
|
+
codeSnippetTitle: '引擎集成代码',
|
|
52
|
+
copySnippetButton: '复制代码',
|
|
53
|
+
copiedToast: '已复制到剪贴板',
|
|
54
|
+
},
|
|
55
|
+
seo: [
|
|
56
|
+
{
|
|
57
|
+
type: 'title',
|
|
58
|
+
level: 2,
|
|
59
|
+
text: '2D 游戏引擎中的 Draw Call 优化与 GPU 批处理',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
type: 'paragraph',
|
|
63
|
+
html: '在现代 2D 图形渲染管线中,将独立散图打包为单一纹理图集可大幅减少 CPU 向 GPU 发送的 Draw Call 绘制指令数。',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
type: 'stats',
|
|
67
|
+
columns: 4,
|
|
68
|
+
items: [
|
|
69
|
+
{ value: '95%', label: 'Draw Call 降低率' },
|
|
70
|
+
{ value: '4x', label: 'GPU 批处理加速' },
|
|
71
|
+
{ value: '60 FPS', label: '移动端稳定帧率目标' },
|
|
72
|
+
{ value: '100%', label: '浏览器本地处理' },
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
type: 'title',
|
|
77
|
+
level: 2,
|
|
78
|
+
text: '独立单图与打包纹理图集对比',
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
type: 'comparative',
|
|
82
|
+
columns: 2,
|
|
83
|
+
items: [
|
|
84
|
+
{
|
|
85
|
+
title: '独立散图文件',
|
|
86
|
+
description: '单独保存的 PNG 或 WebP 文件',
|
|
87
|
+
points: [
|
|
88
|
+
'屏幕上的每个独立帧都会产生单独的 Draw Call 命令',
|
|
89
|
+
'导致显卡 GPU 频繁发生渲染上下文切换',
|
|
90
|
+
'增加 Web 游戏的 HTTP 请求数量与加载延迟',
|
|
91
|
+
'容易导致移动设备出现帧率下降与卡顿',
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
title: '打包纹理图集',
|
|
96
|
+
description: '合并后的 PNG 图像与 JSON 坐标数据',
|
|
97
|
+
points: [
|
|
98
|
+
'将数百个精灵图合并至单个 GPU Draw Call 命令中',
|
|
99
|
+
'最大化显存带宽利用率与图形渲染吞吐量',
|
|
100
|
+
'通过合并图像与数据减少网络文件请求数',
|
|
101
|
+
'确保在所有平台上实现流畅的 60 FPS 渲染',
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
type: 'title',
|
|
108
|
+
level: 2,
|
|
109
|
+
text: '亚像素摄像机移动与边缘外扩数学原理',
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
type: 'paragraph',
|
|
113
|
+
html: '通过应用 1 到 2 像素的边缘外扩 (Border Extrusion),将图像最外层像素向外复制,可有效防止摄像机平移时产生的黑边或相邻像素渗漏。',
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
type: 'tip',
|
|
117
|
+
title: '边缘外扩优化策略',
|
|
118
|
+
html: '使用边缘外扩可完全消除 2D 游戏中摄像机缩放或移动时产生的纹理采样缝隙。',
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
type: 'title',
|
|
122
|
+
level: 2,
|
|
123
|
+
text: '不同平台推荐的纹理尺寸指南',
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
type: 'table',
|
|
127
|
+
headers: ['目标平台', '推荐最大尺寸', '2 的次幂要求', '内存配置文件'],
|
|
128
|
+
rows: [
|
|
129
|
+
['移动端浏览器', '2048 x 2048 px', 'WebGL 1.0 必须', '低内存带宽'],
|
|
130
|
+
['桌面 PC / 主机', '4096 x 4096 px', '推荐', '高 GPU 容量'],
|
|
131
|
+
['复古掌机', '1024 x 1024 px', '严格要求', '严格的 VRAM 限制'],
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
type: 'proscons',
|
|
136
|
+
items: [
|
|
137
|
+
{
|
|
138
|
+
pro: '保证与旧版显卡驱动及 WebGL 1.0 的 100% 兼容性',
|
|
139
|
+
con: '精灵图数量较少时可能会留下未使用的透明区域',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
pro: '支持硬件级自动 Mipmap 贴图生成',
|
|
143
|
+
con: '不规则形状需要精确调整边距',
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
pro: '优化显卡 GPU 中的 VRAM 显存分配',
|
|
147
|
+
con: '初始纹理占用面积略微增加',
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
type: 'title',
|
|
153
|
+
level: 2,
|
|
154
|
+
text: '精灵图打包核心术语解析',
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
type: 'glossary',
|
|
158
|
+
items: [
|
|
159
|
+
{
|
|
160
|
+
term: 'Draw Call',
|
|
161
|
+
definition: 'CPU 渲染引擎向 GPU 显卡发送的绘制几何体与纹理的命令。',
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
term: 'Bin Packing (装箱算法)',
|
|
165
|
+
definition: '将不同尺寸的矩形块以最小面积高效排列的数学计算方法。',
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
term: 'Border Extrusion (边缘外扩)',
|
|
169
|
+
definition: '复制图像边缘像素向外延伸,防止摄像机移动时产生采样接缝。',
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
term: 'Flipbook Animation (翻页动画)',
|
|
173
|
+
definition: '快速连续播放静态帧图像以模拟 2D 游戏连续物理运动的方法。',
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
type: 'title',
|
|
179
|
+
level: 2,
|
|
180
|
+
text: '性能优化检查清单',
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
type: 'diagnostic',
|
|
184
|
+
variant: 'info',
|
|
185
|
+
title: '生产规则',
|
|
186
|
+
html: '将角色动画打包至共享图集,并在 WebGL 构建中使用 2 的次幂分辨率。',
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
faq: [
|
|
190
|
+
{
|
|
191
|
+
question: '什么是 Sprite Sheet 精灵图集?为什么它对 2D 游戏至关重要?',
|
|
192
|
+
answer:
|
|
193
|
+
'Sprite Sheet 是包含多个动画帧的单张组合图像文件。通过将图像合并,游戏引擎可在单个 GPU 命令中绘制多个对象,大幅提升游戏运行速度。',
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
question: '此工具中的本地处理是如何工作的?',
|
|
197
|
+
answer:
|
|
198
|
+
'您的所有图像都在浏览器内部通过 HTML5 Canvas API 本地处理,绝不会将任何数据上传至外部服务器。',
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
question: '我可以从现有精灵图集中提取单张图像吗?',
|
|
202
|
+
answer:
|
|
203
|
+
'可以。切换至提取器模式,上传精灵图集并设置网格尺寸,即可切片并下载单张图像。',
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
howTo: [
|
|
207
|
+
{
|
|
208
|
+
name: '上传动画帧',
|
|
209
|
+
text: '将 PNG 或 WebP 文件拖拽至上传区域。',
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
name: '调整打包设置',
|
|
213
|
+
text: '设置间距、边缘外扩及目标游戏引擎格式。',
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
name: '预览并下载',
|
|
217
|
+
text: '在翻页播放器中检查动画效果,然后下载 ZIP 资源包。',
|
|
218
|
+
},
|
|
219
|
+
],
|
|
220
|
+
schemas: [
|
|
221
|
+
{
|
|
222
|
+
'@context': 'https://schema.org',
|
|
223
|
+
'@type': 'SoftwareApplication',
|
|
224
|
+
name: 'Sprite Sheet 精灵图打包与提取器',
|
|
225
|
+
applicationCategory: 'DeveloperApplication',
|
|
226
|
+
operatingSystem: 'Any',
|
|
227
|
+
offers: {
|
|
228
|
+
'@type': 'Offer',
|
|
229
|
+
price: '0',
|
|
230
|
+
priceCurrency: 'USD',
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
'@context': 'https://schema.org',
|
|
235
|
+
'@type': 'FAQPage',
|
|
236
|
+
mainEntity: [
|
|
237
|
+
{
|
|
238
|
+
'@type': 'Question',
|
|
239
|
+
name: '什么是 Sprite Sheet 精灵图集?',
|
|
240
|
+
acceptedAnswer: {
|
|
241
|
+
'@type': 'Answer',
|
|
242
|
+
text: 'Sprite Sheet 是包含多个动画帧的单张组合图像文件。',
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
'@context': 'https://schema.org',
|
|
249
|
+
'@type': 'HowTo',
|
|
250
|
+
name: '如何打包与提取精灵图集',
|
|
251
|
+
step: [
|
|
252
|
+
{
|
|
253
|
+
'@type': 'HowToStep',
|
|
254
|
+
name: '上传动画帧',
|
|
255
|
+
text: '将 PNG 或 WebP 文件拖拽至上传区域。',
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
bibliography: [
|
|
261
|
+
{
|
|
262
|
+
name: 'Godot Engine 2D Sprite Sheets Documentation',
|
|
263
|
+
url: 'https://docs.godotengine.org/en/stable/tutorials/2d/2d_sprite_animation.html',
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
name: 'Unity Sprite Atlas Manual',
|
|
267
|
+
url: 'https://docs.unity3d.com/Manual/class-SpriteAtlas.html',
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ToolDefinition } from '../../types';
|
|
2
|
+
import { spriteSheetPacker } from './entry';
|
|
3
|
+
|
|
4
|
+
export * from './entry';
|
|
5
|
+
|
|
6
|
+
export const SPRITE_SHEET_PACKER_TOOL: ToolDefinition = {
|
|
7
|
+
entry: spriteSheetPacker,
|
|
8
|
+
Component: () => import('./component.astro'),
|
|
9
|
+
SEOComponent: () => import('./seo.astro'),
|
|
10
|
+
BibliographyComponent: () => import('./bibliography.astro'),
|
|
11
|
+
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
calculateBinPacking,
|
|
4
|
+
calculateGridSlices,
|
|
5
|
+
generateAtlasJson,
|
|
6
|
+
generateCssSnippet,
|
|
7
|
+
generateEngineCodeSnippet,
|
|
8
|
+
nextPowerOfTwo,
|
|
9
|
+
type PackerOptions,
|
|
10
|
+
type SpriteFrameInput,
|
|
11
|
+
} from './logic';
|
|
12
|
+
|
|
13
|
+
describe('SpriteSheetPacker Logic', () => {
|
|
14
|
+
it('calculates next power of two correctly', () => {
|
|
15
|
+
expect(nextPowerOfTwo(1)).toBe(1);
|
|
16
|
+
expect(nextPowerOfTwo(3)).toBe(4);
|
|
17
|
+
expect(nextPowerOfTwo(500)).toBe(512);
|
|
18
|
+
expect(nextPowerOfTwo(1024)).toBe(1024);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('packs empty frames array returning default empty result', () => {
|
|
22
|
+
const options: PackerOptions = {
|
|
23
|
+
padding: 2,
|
|
24
|
+
borderExtrusion: 0,
|
|
25
|
+
forcePowerOfTwo: true,
|
|
26
|
+
maxTextureWidth: 1024,
|
|
27
|
+
maxTextureHeight: 1024,
|
|
28
|
+
allowRotation: false,
|
|
29
|
+
trimTransparency: false,
|
|
30
|
+
format: 'generic-json-hash',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const result = calculateBinPacking([], options);
|
|
34
|
+
expect(result.totalFrames).toBe(0);
|
|
35
|
+
expect(result.efficiency).toBe(0);
|
|
36
|
+
expect(result.drawCallsBefore).toBe(0);
|
|
37
|
+
expect(result.drawCallsAfter).toBe(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('packs frames and calculates texture dimensions and efficiency', () => {
|
|
41
|
+
const frames: SpriteFrameInput[] = [
|
|
42
|
+
{ id: '1', name: 'hero_walk_0', width: 32, height: 32 },
|
|
43
|
+
{ id: '2', name: 'hero_walk_1', width: 32, height: 32 },
|
|
44
|
+
{ id: '3', name: 'hero_idle_0', width: 64, height: 64 },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const options: PackerOptions = {
|
|
48
|
+
padding: 0,
|
|
49
|
+
borderExtrusion: 0,
|
|
50
|
+
forcePowerOfTwo: false,
|
|
51
|
+
maxTextureWidth: 1024,
|
|
52
|
+
maxTextureHeight: 1024,
|
|
53
|
+
allowRotation: false,
|
|
54
|
+
trimTransparency: false,
|
|
55
|
+
format: 'generic-json-hash',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const result = calculateBinPacking(frames, options);
|
|
59
|
+
expect(result.totalFrames).toBe(3);
|
|
60
|
+
expect(result.drawCallsBefore).toBe(3);
|
|
61
|
+
expect(result.drawCallsAfter).toBe(1);
|
|
62
|
+
expect(result.frames.length).toBe(3);
|
|
63
|
+
expect(result.efficiency).toBeGreaterThan(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('forces power of two dimensions when requested', () => {
|
|
67
|
+
const frames: SpriteFrameInput[] = [
|
|
68
|
+
{ id: '1', name: 'frame_1', width: 100, height: 100 },
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
const options: PackerOptions = {
|
|
72
|
+
padding: 0,
|
|
73
|
+
borderExtrusion: 0,
|
|
74
|
+
forcePowerOfTwo: true,
|
|
75
|
+
maxTextureWidth: 2048,
|
|
76
|
+
maxTextureHeight: 2048,
|
|
77
|
+
allowRotation: false,
|
|
78
|
+
trimTransparency: false,
|
|
79
|
+
format: 'generic-json-array',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const result = calculateBinPacking(frames, options);
|
|
83
|
+
expect(result.textureWidth).toBe(128);
|
|
84
|
+
expect(result.textureHeight).toBe(128);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('generates atlas JSON formats for different engines', () => {
|
|
88
|
+
const frames: SpriteFrameInput[] = [
|
|
89
|
+
{ id: '1', name: 'coin', width: 16, height: 16 },
|
|
90
|
+
];
|
|
91
|
+
const options: PackerOptions = {
|
|
92
|
+
padding: 0,
|
|
93
|
+
borderExtrusion: 0,
|
|
94
|
+
forcePowerOfTwo: false,
|
|
95
|
+
maxTextureWidth: 512,
|
|
96
|
+
maxTextureHeight: 512,
|
|
97
|
+
allowRotation: false,
|
|
98
|
+
trimTransparency: false,
|
|
99
|
+
format: 'godot',
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const result = calculateBinPacking(frames, options);
|
|
103
|
+
expect(result.atlasJson).toContain('spritesheet.png');
|
|
104
|
+
expect(result.atlasJson).toContain('coin');
|
|
105
|
+
|
|
106
|
+
const unityJson = generateAtlasJson(result.frames, 512, 512, 'unity');
|
|
107
|
+
expect(unityJson).toContain('sprites');
|
|
108
|
+
|
|
109
|
+
const phaserJson = generateAtlasJson(result.frames, 512, 512, 'phaser');
|
|
110
|
+
expect(phaserJson).toContain('meta');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('generates css snippets accurately', () => {
|
|
114
|
+
const frames: SpriteFrameInput[] = [
|
|
115
|
+
{ id: '1', name: 'player_idle', width: 24, height: 24 },
|
|
116
|
+
];
|
|
117
|
+
const options: PackerOptions = {
|
|
118
|
+
padding: 0,
|
|
119
|
+
borderExtrusion: 0,
|
|
120
|
+
forcePowerOfTwo: false,
|
|
121
|
+
maxTextureWidth: 512,
|
|
122
|
+
maxTextureHeight: 512,
|
|
123
|
+
allowRotation: false,
|
|
124
|
+
trimTransparency: false,
|
|
125
|
+
format: 'css',
|
|
126
|
+
};
|
|
127
|
+
const result = calculateBinPacking(frames, options);
|
|
128
|
+
const css = generateCssSnippet(result.frames);
|
|
129
|
+
expect(css).toContain('.sprite-player_idle');
|
|
130
|
+
expect(css).toContain('background-position');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('generates engine code snippets correctly', () => {
|
|
134
|
+
expect(generateEngineCodeSnippet('unity')).toContain('Resources.LoadAll');
|
|
135
|
+
expect(generateEngineCodeSnippet('godot')).toContain('AtlasTexture');
|
|
136
|
+
expect(generateEngineCodeSnippet('phaser')).toContain('this.load.atlas');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('calculates grid slices for extractor mode', () => {
|
|
140
|
+
const slices = calculateGridSlices({
|
|
141
|
+
imageWidth: 128,
|
|
142
|
+
imageHeight: 64,
|
|
143
|
+
frameWidth: 32,
|
|
144
|
+
frameHeight: 32,
|
|
145
|
+
margin: 0,
|
|
146
|
+
spacing: 0,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(slices.length).toBe(8);
|
|
150
|
+
expect(slices[0]?.x).toBe(0);
|
|
151
|
+
expect(slices[0]?.y).toBe(0);
|
|
152
|
+
expect(slices[7]?.x).toBe(96);
|
|
153
|
+
expect(slices[7]?.y).toBe(32);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ExtractionGridConfig, ExtractedFrameSlice } from './types';
|
|
2
|
+
|
|
3
|
+
export * from './types';
|
|
4
|
+
export * from './packer-core';
|
|
5
|
+
export * from './exporter';
|
|
6
|
+
|
|
7
|
+
export function calculateGridSlices(config: ExtractionGridConfig): ExtractedFrameSlice[] {
|
|
8
|
+
const { imageWidth, imageHeight, frameWidth, frameHeight, margin, spacing } = config;
|
|
9
|
+
|
|
10
|
+
if (frameWidth <= 0 || frameHeight <= 0) {
|
|
11
|
+
return [];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const slices: ExtractedFrameSlice[] = [];
|
|
15
|
+
let index = 0;
|
|
16
|
+
|
|
17
|
+
for (let y = margin; y + frameHeight <= imageHeight; y += frameHeight + spacing) {
|
|
18
|
+
for (let x = margin; x + frameWidth <= imageWidth; x += frameWidth + spacing) {
|
|
19
|
+
slices.push({
|
|
20
|
+
id: `slice_${index}`,
|
|
21
|
+
index,
|
|
22
|
+
x,
|
|
23
|
+
y,
|
|
24
|
+
width: frameWidth,
|
|
25
|
+
height: frameHeight,
|
|
26
|
+
});
|
|
27
|
+
index++;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return slices;
|
|
32
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { PackerOptions, PackedFrame, PackedResult, SpriteFrameInput } from './types';
|
|
2
|
+
import { generateAtlasJson, generateCssSnippet, generateEngineCodeSnippet } from './exporter';
|
|
3
|
+
|
|
4
|
+
export function nextPowerOfTwo(value: number): number {
|
|
5
|
+
let power = 1;
|
|
6
|
+
while (power < value) {
|
|
7
|
+
power *= 2;
|
|
8
|
+
}
|
|
9
|
+
return power;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function emptyPackingResult(): PackedResult {
|
|
13
|
+
return {
|
|
14
|
+
textureWidth: 0,
|
|
15
|
+
textureHeight: 0,
|
|
16
|
+
efficiency: 0,
|
|
17
|
+
totalFrames: 0,
|
|
18
|
+
frames: [],
|
|
19
|
+
atlasJson: '{}',
|
|
20
|
+
cssSnippet: '',
|
|
21
|
+
codeSnippet: '',
|
|
22
|
+
drawCallsBefore: 0,
|
|
23
|
+
drawCallsAfter: 0,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getFrameOffsets(item: SpriteFrameInput) {
|
|
28
|
+
const tx = item.trimmedX ? item.trimmedX : 0;
|
|
29
|
+
const ty = item.trimmedY ? item.trimmedY : 0;
|
|
30
|
+
const ow = item.originalWidth ? item.originalWidth : item.width;
|
|
31
|
+
const oh = item.originalHeight ? item.originalHeight : item.height;
|
|
32
|
+
const px = item.pivotX !== undefined ? item.pivotX : 0.5;
|
|
33
|
+
const py = item.pivotY !== undefined ? item.pivotY : 0.5;
|
|
34
|
+
return { tx, ty, ow, oh, px, py };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createPackedFrame(item: SpriteFrameInput, x: number, y: number): PackedFrame {
|
|
38
|
+
const { tx, ty, ow, oh, px, py } = getFrameOffsets(item);
|
|
39
|
+
return {
|
|
40
|
+
id: item.id,
|
|
41
|
+
name: item.name,
|
|
42
|
+
x,
|
|
43
|
+
y,
|
|
44
|
+
width: item.width,
|
|
45
|
+
height: item.height,
|
|
46
|
+
rotated: false,
|
|
47
|
+
trimmed: tx > 0 || ty > 0,
|
|
48
|
+
spriteSourceSize: { x: tx, y: ty, w: item.width, h: item.height },
|
|
49
|
+
sourceSize: { w: ow, h: oh },
|
|
50
|
+
pivot: { x: px, y: py },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function packSortedFrames(sorted: SpriteFrameInput[], options: PackerOptions) {
|
|
55
|
+
const padding = Math.max(0, options.padding);
|
|
56
|
+
const extrusion = Math.max(0, options.borderExtrusion);
|
|
57
|
+
const totalOffset = padding + extrusion * 2;
|
|
58
|
+
|
|
59
|
+
let currentX = padding;
|
|
60
|
+
let currentY = padding;
|
|
61
|
+
let rowHeight = 0;
|
|
62
|
+
let maxW = 0;
|
|
63
|
+
let maxH = 0;
|
|
64
|
+
const packedFrames: PackedFrame[] = [];
|
|
65
|
+
|
|
66
|
+
for (const item of sorted) {
|
|
67
|
+
const itemW = item.width + totalOffset;
|
|
68
|
+
const itemH = item.height + totalOffset;
|
|
69
|
+
|
|
70
|
+
if (currentX + itemW > options.maxTextureWidth && currentX > padding) {
|
|
71
|
+
currentX = padding;
|
|
72
|
+
currentY += rowHeight + padding;
|
|
73
|
+
rowHeight = 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
packedFrames.push(createPackedFrame(item, currentX + extrusion, currentY + extrusion));
|
|
77
|
+
|
|
78
|
+
currentX += itemW + padding;
|
|
79
|
+
rowHeight = Math.max(rowHeight, itemH);
|
|
80
|
+
maxW = Math.max(maxW, currentX);
|
|
81
|
+
maxH = Math.max(maxH, currentY + rowHeight + padding);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { packedFrames, maxW, maxH };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function calculateBinPacking(frames: SpriteFrameInput[], options: PackerOptions): PackedResult {
|
|
88
|
+
if (frames.length === 0) {
|
|
89
|
+
return emptyPackingResult();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const sorted = [...frames].sort((a, b) => Math.max(b.width, b.height) - Math.max(a.width, a.height));
|
|
93
|
+
const { packedFrames, maxW, maxH } = packSortedFrames(sorted, options);
|
|
94
|
+
|
|
95
|
+
let finalWidth = options.forcePowerOfTwo ? nextPowerOfTwo(maxW) : maxW;
|
|
96
|
+
let finalHeight = options.forcePowerOfTwo ? nextPowerOfTwo(maxH) : maxH;
|
|
97
|
+
|
|
98
|
+
finalWidth = Math.min(finalWidth, options.maxTextureWidth);
|
|
99
|
+
finalHeight = Math.min(finalHeight, options.maxTextureHeight);
|
|
100
|
+
|
|
101
|
+
let usedArea = 0;
|
|
102
|
+
for (const f of packedFrames) {
|
|
103
|
+
usedArea += f.width * f.height;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const totalArea = Math.max(1, finalWidth * finalHeight);
|
|
107
|
+
const efficiency = Math.min(100, Math.round((usedArea / totalArea) * 1000) / 10);
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
textureWidth: finalWidth,
|
|
111
|
+
textureHeight: finalHeight,
|
|
112
|
+
efficiency,
|
|
113
|
+
totalFrames: packedFrames.length,
|
|
114
|
+
frames: packedFrames,
|
|
115
|
+
atlasJson: generateAtlasJson(packedFrames, finalWidth, finalHeight, options.format),
|
|
116
|
+
cssSnippet: generateCssSnippet(packedFrames),
|
|
117
|
+
codeSnippet: generateEngineCodeSnippet(options.format),
|
|
118
|
+
drawCallsBefore: frames.length,
|
|
119
|
+
drawCallsAfter: 1,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import JSZip from 'jszip';
|
|
2
|
+
|
|
3
|
+
export interface LoadedImageItem {
|
|
4
|
+
file: File;
|
|
5
|
+
img: HTMLImageElement;
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function spawnParticle(x: number, y: number, text: string): void {
|
|
11
|
+
const container = document.getElementById('ssp-particle-container');
|
|
12
|
+
if (!container) return;
|
|
13
|
+
|
|
14
|
+
const el = document.createElement('div');
|
|
15
|
+
el.className = 'ssp-particle';
|
|
16
|
+
el.textContent = text;
|
|
17
|
+
el.style.left = `${x}px`;
|
|
18
|
+
el.style.top = `${y}px`;
|
|
19
|
+
container.appendChild(el);
|
|
20
|
+
|
|
21
|
+
setTimeout(() => el.remove(), 1000);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function triggerButtonFeedback(btn: HTMLElement | null, text: string, event?: MouseEvent): void {
|
|
25
|
+
if (!btn) return;
|
|
26
|
+
btn.classList.add('ssp-btn-success');
|
|
27
|
+
const originalText = btn.textContent;
|
|
28
|
+
btn.textContent = text;
|
|
29
|
+
|
|
30
|
+
if (event) {
|
|
31
|
+
spawnParticle(event.clientX, event.clientY, text);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
setTimeout(() => {
|
|
35
|
+
btn.classList.remove('ssp-btn-success');
|
|
36
|
+
if (originalText) btn.textContent = originalText;
|
|
37
|
+
}, 1800);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function setupStepper(decId: string, incId: string, inputId: string, callback: () => void): void {
|
|
41
|
+
const input = document.getElementById(inputId) as HTMLInputElement | null;
|
|
42
|
+
document.getElementById(decId)?.addEventListener('click', () => {
|
|
43
|
+
if (!input) return;
|
|
44
|
+
const current = parseInt(input.value || '0', 10);
|
|
45
|
+
const min = parseInt(input.min || '0', 10);
|
|
46
|
+
input.value = Math.max(min, current - 1).toString();
|
|
47
|
+
input.dispatchEvent(new Event('input'));
|
|
48
|
+
callback();
|
|
49
|
+
});
|
|
50
|
+
document.getElementById(incId)?.addEventListener('click', () => {
|
|
51
|
+
if (!input) return;
|
|
52
|
+
const current = parseInt(input.value || '0', 10);
|
|
53
|
+
const max = parseInt(input.max || '9999', 10);
|
|
54
|
+
input.value = Math.min(max, current + 1).toString();
|
|
55
|
+
input.dispatchEvent(new Event('input'));
|
|
56
|
+
callback();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function drawEmptyCanvasMessage(canvas: HTMLCanvasElement, text: string): void {
|
|
61
|
+
canvas.width = 512;
|
|
62
|
+
canvas.height = 512;
|
|
63
|
+
const ctx = canvas.getContext('2d');
|
|
64
|
+
if (!ctx) return;
|
|
65
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
66
|
+
ctx.fillStyle = 'rgba(148, 163, 184, 0.4)';
|
|
67
|
+
ctx.font = '600 15px sans-serif';
|
|
68
|
+
ctx.textAlign = 'center';
|
|
69
|
+
ctx.textBaseline = 'middle';
|
|
70
|
+
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function downloadZipFromCanvas(canvas: HTMLCanvasElement, jsonContent: string): void {
|
|
74
|
+
const zip = new JSZip();
|
|
75
|
+
canvas.toBlob((blob) => {
|
|
76
|
+
if (!blob) return;
|
|
77
|
+
zip.file('spritesheet.png', blob);
|
|
78
|
+
zip.file('spritesheet.json', jsonContent);
|
|
79
|
+
zip.generateAsync({ type: 'blob' }).then((content) => {
|
|
80
|
+
const link = document.createElement('a');
|
|
81
|
+
link.href = URL.createObjectURL(content);
|
|
82
|
+
link.download = 'sprite-sheet-package.zip';
|
|
83
|
+
link.click();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { spriteSheetPacker } 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 = spriteSheetPacker.i18n[locale] ?? spriteSheetPacker.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 }} />}
|