@flighthq/particles-formats 0.1.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.
Files changed (99) hide show
  1. package/dist/detect.d.ts +18 -0
  2. package/dist/detect.d.ts.map +1 -0
  3. package/dist/detect.js +75 -0
  4. package/dist/detect.js.map +1 -0
  5. package/dist/formatRegistry.d.ts +60 -0
  6. package/dist/formatRegistry.d.ts.map +1 -0
  7. package/dist/formatRegistry.js +75 -0
  8. package/dist/formatRegistry.js.map +1 -0
  9. package/dist/index.d.ts +21 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +21 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/libgdxParse.d.ts +27 -0
  14. package/dist/libgdxParse.d.ts.map +1 -0
  15. package/dist/libgdxParse.js +316 -0
  16. package/dist/libgdxParse.js.map +1 -0
  17. package/dist/libgdxSchema.d.ts +68 -0
  18. package/dist/libgdxSchema.d.ts.map +1 -0
  19. package/dist/libgdxSchema.js +12 -0
  20. package/dist/libgdxSchema.js.map +1 -0
  21. package/dist/libgdxSerialize.d.ts +17 -0
  22. package/dist/libgdxSerialize.d.ts.map +1 -0
  23. package/dist/libgdxSerialize.js +295 -0
  24. package/dist/libgdxSerialize.js.map +1 -0
  25. package/dist/parseParticleConfig.d.ts +34 -0
  26. package/dist/parseParticleConfig.d.ts.map +1 -0
  27. package/dist/parseParticleConfig.js +96 -0
  28. package/dist/parseParticleConfig.js.map +1 -0
  29. package/dist/particleDesignerParse.d.ts +23 -0
  30. package/dist/particleDesignerParse.d.ts.map +1 -0
  31. package/dist/particleDesignerParse.js +214 -0
  32. package/dist/particleDesignerParse.js.map +1 -0
  33. package/dist/particleDesignerSchema.d.ts +51 -0
  34. package/dist/particleDesignerSchema.d.ts.map +1 -0
  35. package/dist/particleDesignerSchema.js +5 -0
  36. package/dist/particleDesignerSchema.js.map +1 -0
  37. package/dist/particleDesignerSerialize.d.ts +22 -0
  38. package/dist/particleDesignerSerialize.d.ts.map +1 -0
  39. package/dist/particleDesignerSerialize.js +209 -0
  40. package/dist/particleDesignerSerialize.js.map +1 -0
  41. package/dist/pixiParse.d.ts +18 -0
  42. package/dist/pixiParse.d.ts.map +1 -0
  43. package/dist/pixiParse.js +173 -0
  44. package/dist/pixiParse.js.map +1 -0
  45. package/dist/serializeResult.d.ts +15 -0
  46. package/dist/serializeResult.d.ts.map +1 -0
  47. package/dist/serializeResult.js +2 -0
  48. package/dist/serializeResult.js.map +1 -0
  49. package/dist/spineParse.d.ts +18 -0
  50. package/dist/spineParse.d.ts.map +1 -0
  51. package/dist/spineParse.js +263 -0
  52. package/dist/spineParse.js.map +1 -0
  53. package/dist/spineSchema.d.ts +41 -0
  54. package/dist/spineSchema.d.ts.map +1 -0
  55. package/dist/spineSchema.js +8 -0
  56. package/dist/spineSchema.js.map +1 -0
  57. package/dist/spineSerialize.d.ts +16 -0
  58. package/dist/spineSerialize.d.ts.map +1 -0
  59. package/dist/spineSerialize.js +108 -0
  60. package/dist/spineSerialize.js.map +1 -0
  61. package/dist/starlingPexParse.d.ts +29 -0
  62. package/dist/starlingPexParse.d.ts.map +1 -0
  63. package/dist/starlingPexParse.js +254 -0
  64. package/dist/starlingPexParse.js.map +1 -0
  65. package/dist/starlingPexSchema.d.ts +49 -0
  66. package/dist/starlingPexSchema.d.ts.map +1 -0
  67. package/dist/starlingPexSchema.js +15 -0
  68. package/dist/starlingPexSchema.js.map +1 -0
  69. package/dist/starlingPexSerialize.d.ts +20 -0
  70. package/dist/starlingPexSerialize.d.ts.map +1 -0
  71. package/dist/starlingPexSerialize.js +184 -0
  72. package/dist/starlingPexSerialize.js.map +1 -0
  73. package/dist/unityParse.d.ts +23 -0
  74. package/dist/unityParse.d.ts.map +1 -0
  75. package/dist/unityParse.js +347 -0
  76. package/dist/unityParse.js.map +1 -0
  77. package/dist/unitySchema.d.ts +109 -0
  78. package/dist/unitySchema.d.ts.map +1 -0
  79. package/dist/unitySchema.js +9 -0
  80. package/dist/unitySchema.js.map +1 -0
  81. package/dist/unitySerialize.d.ts +20 -0
  82. package/dist/unitySerialize.d.ts.map +1 -0
  83. package/dist/unitySerialize.js +148 -0
  84. package/dist/unitySerialize.js.map +1 -0
  85. package/package.json +38 -0
  86. package/src/detect.test.ts +99 -0
  87. package/src/formatRegistry.test.ts +118 -0
  88. package/src/libgdxParse.test.ts +309 -0
  89. package/src/libgdxSerialize.test.ts +279 -0
  90. package/src/parseParticleConfig.test.ts +447 -0
  91. package/src/particleDesignerParse.test.ts +279 -0
  92. package/src/particleDesignerSerialize.test.ts +113 -0
  93. package/src/pixiParse.test.ts +131 -0
  94. package/src/spineParse.test.ts +289 -0
  95. package/src/spineSerialize.test.ts +81 -0
  96. package/src/starlingPexParse.test.ts +154 -0
  97. package/src/starlingPexSerialize.test.ts +115 -0
  98. package/src/unityParse.test.ts +328 -0
  99. package/src/unitySerialize.test.ts +77 -0
@@ -0,0 +1,279 @@
1
+ import { parseParticleDesignerPlist, parseParticleDesignerPlistDocument } from './particleDesignerParse';
2
+ import { serializeParticleDesignerPlist } from './particleDesignerSerialize';
3
+
4
+ const FIRE_PLIST = `<?xml version="1.0" encoding="utf-8"?>
5
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
6
+ <plist version="1.0">
7
+ <dict>
8
+ <key>maxParticles</key><integer>200</integer>
9
+ <key>emitterType</key><integer>0</integer>
10
+ <key>duration</key><real>-1</real>
11
+ <key>particleLifespan</key><real>1.5</real>
12
+ <key>particleLifespanVariance</key><real>0.5</real>
13
+ <key>speed</key><real>100</real>
14
+ <key>speedVariance</key><real>20</real>
15
+ <key>angle</key><real>90</real>
16
+ <key>angleVariance</key><real>30</real>
17
+ <key>gravityx</key><real>0</real>
18
+ <key>gravityy</key><real>200</real>
19
+ <key>sourcePositionVariancex</key><real>0</real>
20
+ <key>sourcePositionVariancey</key><real>0</real>
21
+ <key>startParticleSize</key><real>32</real>
22
+ <key>startParticleSizeVariance</key><real>8</real>
23
+ <key>finishParticleSize</key><real>8</real>
24
+ <key>finishParticleSizeVariance</key><real>0</real>
25
+ <key>startColorRed</key><real>1</real>
26
+ <key>startColorGreen</key><real>0.5</real>
27
+ <key>startColorBlue</key><real>0</real>
28
+ <key>startColorAlpha</key><real>1</real>
29
+ <key>startColorVarianceRed</key><real>0</real>
30
+ <key>startColorVarianceGreen</key><real>0</real>
31
+ <key>startColorVarianceBlue</key><real>0</real>
32
+ <key>startColorVarianceAlpha</key><real>0</real>
33
+ <key>finishColorRed</key><real>1</real>
34
+ <key>finishColorGreen</key><real>0</real>
35
+ <key>finishColorBlue</key><real>0</real>
36
+ <key>finishColorAlpha</key><real>0</real>
37
+ <key>finishColorVarianceRed</key><real>0</real>
38
+ <key>finishColorVarianceGreen</key><real>0</real>
39
+ <key>finishColorVarianceBlue</key><real>0</real>
40
+ <key>finishColorVarianceAlpha</key><real>0</real>
41
+ <key>rotationStart</key><real>0</real>
42
+ <key>rotationStartVariance</key><real>0</real>
43
+ <key>rotationEnd</key><real>0</real>
44
+ <key>rotationEndVariance</key><real>0</real>
45
+ <key>maxRadius</key><real>0</real>
46
+ <key>maxRadiusVariance</key><real>0</real>
47
+ <key>minRadius</key><real>0</real>
48
+ <key>minRadiusVariance</key><real>0</real>
49
+ <key>rotatePerSecond</key><real>0</real>
50
+ <key>rotatePerSecondVariance</key><real>0</real>
51
+ <key>blendFuncSource</key><integer>770</integer>
52
+ <key>blendFuncDestination</key><integer>771</integer>
53
+ <key>textureFileName</key><string>fire.png</string>
54
+ </dict>
55
+ </plist>`;
56
+
57
+ describe('parseParticleDesignerPlist', () => {
58
+ describe('color variance and blend mode', () => {
59
+ it('maps startColorVariance fields to colorStartVariance', () => {
60
+ const plist = FIRE_PLIST.replace(
61
+ '<key>startColorVarianceRed</key><real>0</real>',
62
+ '<key>startColorVarianceRed</key><real>0.3</real>',
63
+ );
64
+ const c = parseParticleDesignerPlist(plist);
65
+ expect(c.colorStartVarianceR).toBeCloseTo(0.3);
66
+ });
67
+
68
+ it('maps additive blend func (770, 1) to blendMode="add"', () => {
69
+ const plist = FIRE_PLIST.replace(
70
+ '<key>blendFuncDestination</key><integer>771</integer>',
71
+ '<key>blendFuncDestination</key><integer>1</integer>',
72
+ );
73
+ const c = parseParticleDesignerPlist(plist);
74
+ expect(c.blendMode).toBe('add');
75
+ });
76
+
77
+ it('round-trips color variance through serialize', () => {
78
+ const modified = FIRE_PLIST.replace(
79
+ '<key>startColorVarianceGreen</key><real>0</real>',
80
+ '<key>startColorVarianceGreen</key><real>0.2</real>',
81
+ );
82
+ const config = parseParticleDesignerPlist(modified);
83
+ const { document } = parseParticleDesignerPlistDocument(modified);
84
+ const xml = serializeParticleDesignerPlist(config, document);
85
+ const config2 = parseParticleDesignerPlist(xml);
86
+ expect(config2.colorStartVarianceG).toBeCloseTo(0.2, 3);
87
+ });
88
+ });
89
+
90
+ describe('lightweight, returns config directly', () => {
91
+ it('returns a ParticleEmitterConfig (not a Parsed object)', () => {
92
+ const result = parseParticleDesignerPlist(FIRE_PLIST);
93
+ expect(typeof result.maxParticles).toBe('number');
94
+ expect((result as unknown as Record<string, unknown>).document).toBeUndefined();
95
+ });
96
+
97
+ it('parses maxParticles', () => {
98
+ expect(parseParticleDesignerPlist(FIRE_PLIST).maxParticles).toBe(200);
99
+ });
100
+
101
+ it('converts lifetime with variance to min/max', () => {
102
+ const c = parseParticleDesignerPlist(FIRE_PLIST);
103
+ expect(c.lifetimeMin).toBeCloseTo(1.0);
104
+ expect(c.lifetimeMax).toBeCloseTo(2.0);
105
+ });
106
+
107
+ it('converts speed with variance to min/max', () => {
108
+ const c = parseParticleDesignerPlist(FIRE_PLIST);
109
+ expect(c.speedMin).toBeCloseTo(80);
110
+ expect(c.speedMax).toBeCloseTo(120);
111
+ });
112
+
113
+ it('maps angle=90 to upward direction (−Y screen)', () => {
114
+ const c = parseParticleDesignerPlist(FIRE_PLIST);
115
+ expect(c.directionX).toBeCloseTo(0, 3);
116
+ expect(c.directionY).toBeCloseTo(-1, 3);
117
+ });
118
+
119
+ it('converts angleVariance to spread in radians', () => {
120
+ expect(parseParticleDesignerPlist(FIRE_PLIST).spread).toBeCloseTo((30 * Math.PI) / 180, 4);
121
+ });
122
+
123
+ it('maps gravity directly', () => {
124
+ const c = parseParticleDesignerPlist(FIRE_PLIST);
125
+ expect(c.gravityX).toBeCloseTo(0);
126
+ expect(c.gravityY).toBeCloseTo(200);
127
+ });
128
+
129
+ it('maps color start and end', () => {
130
+ const c = parseParticleDesignerPlist(FIRE_PLIST);
131
+ expect(c.colorStartR).toBeCloseTo(1);
132
+ expect(c.colorStartG).toBeCloseTo(0.5);
133
+ expect(c.colorStartB).toBeCloseTo(0);
134
+ expect(c.colorEndR).toBeCloseTo(1);
135
+ expect(c.colorEndG).toBeCloseTo(0);
136
+ expect(c.colorEndB).toBeCloseTo(0);
137
+ });
138
+
139
+ it('maps alpha start and end', () => {
140
+ const c = parseParticleDesignerPlist(FIRE_PLIST);
141
+ expect(c.alphaStart).toBeCloseTo(1);
142
+ expect(c.alphaEnd).toBeCloseTo(0);
143
+ });
144
+
145
+ it('normalises scale by textureSize', () => {
146
+ const c = parseParticleDesignerPlist(FIRE_PLIST, { textureSize: 32 });
147
+ expect(c.scaleMin).toBeCloseTo(0.75);
148
+ expect(c.scaleMax).toBeCloseTo(1.25);
149
+ });
150
+
151
+ it('computes scaleEnd as finishSize / startSize', () => {
152
+ const c = parseParticleDesignerPlist(FIRE_PLIST, { textureSize: 32 });
153
+ expect(c.scaleEnd).toBeCloseTo(0.25);
154
+ });
155
+
156
+ it('uses point emitter shape when sourcePositionVariance is zero', () => {
157
+ expect(parseParticleDesignerPlist(FIRE_PLIST).emitterShape).toBe('point');
158
+ });
159
+
160
+ it('maps duration=-1 to an infinite (looping) emitter', () => {
161
+ const c = parseParticleDesignerPlist(FIRE_PLIST);
162
+ expect(c.loop).toBe(true);
163
+ expect(c.duration).toBe(0);
164
+ });
165
+
166
+ it('maps a positive duration to a finite, non-looping emitter', () => {
167
+ const plist = FIRE_PLIST.replace('<key>duration</key><real>-1</real>', '<key>duration</key><real>2.5</real>');
168
+ const c = parseParticleDesignerPlist(plist);
169
+ expect(c.loop).toBe(false);
170
+ expect(c.duration).toBeCloseTo(2.5);
171
+ });
172
+ });
173
+
174
+ describe('malformed input', () => {
175
+ it('falls back to defaults (not NaN) for empty numeric tags', () => {
176
+ const xml =
177
+ '<plist><dict>' +
178
+ '<key>maxParticles</key><integer></integer>' +
179
+ '<key>speed</key><real></real>' +
180
+ '</dict></plist>';
181
+ const c = parseParticleDesignerPlist(xml);
182
+ expect(c.maxParticles).toBe(200); // default, not NaN-coerced 0
183
+ expect(Number.isFinite(c.speedMin)).toBe(true);
184
+ expect(Number.isFinite(c.speedMax)).toBe(true);
185
+ });
186
+
187
+ it('returns an all-defaults config for empty/garbage input without throwing', () => {
188
+ expect(() => parseParticleDesignerPlist('')).not.toThrow();
189
+ const c = parseParticleDesignerPlist('not xml at all');
190
+ expect(Number.isFinite(c.maxParticles)).toBe(true);
191
+ expect(Number.isFinite(c.gravityY)).toBe(true);
192
+ });
193
+ });
194
+ });
195
+
196
+ describe('parseParticleDesignerPlistDocument', () => {
197
+ describe('full round-trip, returns { config, document }', () => {
198
+ it('returns the same config values as parseParticleDesignerPlist', () => {
199
+ const config = parseParticleDesignerPlist(FIRE_PLIST, { textureSize: 32 });
200
+ const { config: loadedConfig } = parseParticleDesignerPlistDocument(FIRE_PLIST, { textureSize: 32 });
201
+ expect(loadedConfig.maxParticles).toBe(config.maxParticles);
202
+ expect(loadedConfig.directionY).toBeCloseTo(config.directionY, 5);
203
+ expect(loadedConfig.gravityY).toBeCloseTo(config.gravityY, 5);
204
+ });
205
+
206
+ it('preserves textureFileName in document', () => {
207
+ expect(parseParticleDesignerPlistDocument(FIRE_PLIST).document.textureFileName).toBe('fire.png');
208
+ });
209
+
210
+ it('preserves blend function in document', () => {
211
+ const { document } = parseParticleDesignerPlistDocument(FIRE_PLIST);
212
+ expect(document.blendFuncSource).toBe(770);
213
+ expect(document.blendFuncDestination).toBe(771);
214
+ });
215
+ });
216
+
217
+ describe('import warnings', () => {
218
+ it('has no warnings for a standard gravity emitter', () => {
219
+ expect(parseParticleDesignerPlistDocument(FIRE_PLIST).warnings).toEqual([]);
220
+ });
221
+
222
+ it('warns that a radial emitter is approximated', () => {
223
+ const plist = FIRE_PLIST.replace(
224
+ '<key>emitterType</key><integer>0</integer>',
225
+ '<key>emitterType</key><integer>1</integer>',
226
+ );
227
+ expect(parseParticleDesignerPlistDocument(plist).warnings.some((w) => w.toLowerCase().includes('radial'))).toBe(
228
+ true,
229
+ );
230
+ });
231
+
232
+ it('warns about unsupported radial/tangential acceleration', () => {
233
+ const plist = FIRE_PLIST.replace(
234
+ '<key>maxParticles</key><integer>200</integer>',
235
+ '<key>maxParticles</key><integer>200</integer><key>radialAcceleration</key><real>50</real>',
236
+ );
237
+ expect(parseParticleDesignerPlistDocument(plist).warnings.some((w) => w.includes('radialAcceleration'))).toBe(
238
+ true,
239
+ );
240
+ });
241
+ });
242
+ });
243
+
244
+ describe('serializeParticleDesignerPlist', () => {
245
+ it('round-trips key config fields', () => {
246
+ const config = parseParticleDesignerPlist(FIRE_PLIST, { textureSize: 32 });
247
+ const { document } = parseParticleDesignerPlistDocument(FIRE_PLIST, { textureSize: 32 });
248
+ const xml = serializeParticleDesignerPlist(config, document, { textureSize: 32 });
249
+ const config2 = parseParticleDesignerPlist(xml, { textureSize: 32 });
250
+ expect(config2.maxParticles).toBe(config.maxParticles);
251
+ expect(config2.directionX).toBeCloseTo(config.directionX, 3);
252
+ expect(config2.gravityY).toBeCloseTo(config.gravityY, 1);
253
+ expect(config2.colorStartR).toBeCloseTo(config.colorStartR, 3);
254
+ expect(config2.alphaEnd).toBeCloseTo(config.alphaEnd, 3);
255
+ });
256
+
257
+ it('preserves textureFileName', () => {
258
+ const config = parseParticleDesignerPlist(FIRE_PLIST);
259
+ const { document } = parseParticleDesignerPlistDocument(FIRE_PLIST);
260
+ expect(serializeParticleDesignerPlist(config, document)).toContain('fire.png');
261
+ });
262
+
263
+ it('produces valid plist XML', () => {
264
+ const config = parseParticleDesignerPlist(FIRE_PLIST);
265
+ const xml = serializeParticleDesignerPlist(config);
266
+ expect(xml).toContain('<?xml version="1.0"');
267
+ expect(xml).toContain('<plist version="1.0">');
268
+ });
269
+
270
+ it('escapes XML special characters in the texture filename and round-trips them', () => {
271
+ const config = parseParticleDesignerPlist(FIRE_PLIST);
272
+ const xml = serializeParticleDesignerPlist(config, { textureFileName: 'a&b<c>.png' });
273
+ // Raw special characters must not appear unescaped (would be invalid XML).
274
+ expect(xml).not.toContain('a&b<c>');
275
+ expect(xml).toContain('&amp;');
276
+ // ...and the original value survives a parse round-trip intact.
277
+ expect(parseParticleDesignerPlistDocument(xml).document.textureFileName).toBe('a&b<c>.png');
278
+ });
279
+ });
@@ -0,0 +1,113 @@
1
+ import { createParticleEmitterConfig } from '@flighthq/particles';
2
+
3
+ import { parseParticleDesignerPlist, parseParticleDesignerPlistDocument } from './particleDesignerParse';
4
+ import { serializeParticleDesignerPlist, serializeParticleDesignerPlistDocument } from './particleDesignerSerialize';
5
+
6
+ const FIRE_PLIST = `<?xml version="1.0" encoding="utf-8"?>
7
+ <plist version="1.0">
8
+ <dict>
9
+ <key>maxParticles</key><integer>200</integer>
10
+ <key>emitterType</key><integer>0</integer>
11
+ <key>duration</key><real>-1</real>
12
+ <key>particleLifespan</key><real>1.5</real>
13
+ <key>particleLifespanVariance</key><real>0.5</real>
14
+ <key>speed</key><real>100</real>
15
+ <key>speedVariance</key><real>20</real>
16
+ <key>angle</key><real>90</real>
17
+ <key>angleVariance</key><real>30</real>
18
+ <key>gravityx</key><real>0</real>
19
+ <key>gravityy</key><real>200</real>
20
+ <key>sourcePositionVariancex</key><real>0</real>
21
+ <key>sourcePositionVariancey</key><real>0</real>
22
+ <key>startParticleSize</key><real>32</real>
23
+ <key>startParticleSizeVariance</key><real>8</real>
24
+ <key>finishParticleSize</key><real>8</real>
25
+ <key>finishParticleSizeVariance</key><real>0</real>
26
+ <key>startColorRed</key><real>1</real>
27
+ <key>startColorGreen</key><real>0.5</real>
28
+ <key>startColorBlue</key><real>0</real>
29
+ <key>startColorAlpha</key><real>1</real>
30
+ <key>startColorVarianceRed</key><real>0</real>
31
+ <key>startColorVarianceGreen</key><real>0</real>
32
+ <key>startColorVarianceBlue</key><real>0</real>
33
+ <key>startColorVarianceAlpha</key><real>0</real>
34
+ <key>finishColorRed</key><real>1</real>
35
+ <key>finishColorGreen</key><real>0</real>
36
+ <key>finishColorBlue</key><real>0</real>
37
+ <key>finishColorAlpha</key><real>0</real>
38
+ <key>finishColorVarianceRed</key><real>0</real>
39
+ <key>finishColorVarianceGreen</key><real>0</real>
40
+ <key>finishColorVarianceBlue</key><real>0</real>
41
+ <key>finishColorVarianceAlpha</key><real>0</real>
42
+ <key>rotationStart</key><real>0</real>
43
+ <key>rotationStartVariance</key><real>0</real>
44
+ <key>rotationEnd</key><real>0</real>
45
+ <key>rotationEndVariance</key><real>0</real>
46
+ <key>maxRadius</key><real>0</real>
47
+ <key>maxRadiusVariance</key><real>0</real>
48
+ <key>minRadius</key><real>0</real>
49
+ <key>minRadiusVariance</key><real>0</real>
50
+ <key>rotatePerSecond</key><real>0</real>
51
+ <key>rotatePerSecondVariance</key><real>0</real>
52
+ <key>blendFuncSource</key><integer>770</integer>
53
+ <key>blendFuncDestination</key><integer>771</integer>
54
+ <key>textureFileName</key><string>fire.png</string>
55
+ </dict>
56
+ </plist>`;
57
+
58
+ describe('serializeParticleDesignerPlist', () => {
59
+ it('produces valid plist XML with required structure', () => {
60
+ const config = parseParticleDesignerPlist(FIRE_PLIST);
61
+ const xml = serializeParticleDesignerPlist(config);
62
+ expect(xml).toContain('<?xml version="1.0"');
63
+ expect(xml).toContain('<plist version="1.0">');
64
+ expect(xml).toContain('<dict>');
65
+ });
66
+
67
+ it('preserves textureFileName from existing document', () => {
68
+ const config = parseParticleDesignerPlist(FIRE_PLIST);
69
+ const { document } = parseParticleDesignerPlistDocument(FIRE_PLIST);
70
+ expect(serializeParticleDesignerPlist(config, document)).toContain('fire.png');
71
+ });
72
+
73
+ it('round-trips key config fields through parse → serialize → parse', () => {
74
+ const config = parseParticleDesignerPlist(FIRE_PLIST, { textureSize: 32 });
75
+ const { document } = parseParticleDesignerPlistDocument(FIRE_PLIST, { textureSize: 32 });
76
+ const xml = serializeParticleDesignerPlist(config, document, { textureSize: 32 });
77
+ const config2 = parseParticleDesignerPlist(xml, { textureSize: 32 });
78
+ expect(config2.maxParticles).toBe(config.maxParticles);
79
+ expect(config2.directionX).toBeCloseTo(config.directionX, 3);
80
+ expect(config2.gravityY).toBeCloseTo(config.gravityY, 1);
81
+ });
82
+
83
+ it('escapes XML special characters in the texture filename', () => {
84
+ const config = parseParticleDesignerPlist(FIRE_PLIST);
85
+ const xml = serializeParticleDesignerPlist(config, { textureFileName: 'a&b<c>.png' });
86
+ expect(xml).not.toContain('a&b<c>');
87
+ expect(xml).toContain('&amp;');
88
+ });
89
+ });
90
+
91
+ describe('serializeParticleDesignerPlistDocument', () => {
92
+ it('returns { text, warnings } for a standard config', () => {
93
+ const config = parseParticleDesignerPlist(FIRE_PLIST);
94
+ const result = serializeParticleDesignerPlistDocument(config);
95
+ expect(typeof result.text).toBe('string');
96
+ expect(result.text).toContain('<plist version="1.0">');
97
+ expect(Array.isArray(result.warnings)).toBe(true);
98
+ expect(result.warnings).toHaveLength(0);
99
+ });
100
+
101
+ it('warns when the config uses unsupported features', () => {
102
+ const config = createParticleEmitterConfig({ burstCount: 5 });
103
+ const result = serializeParticleDesignerPlistDocument(config);
104
+ expect(result.warnings.some((w) => w.toLowerCase().includes('burst'))).toBe(true);
105
+ });
106
+
107
+ it('preserves textureFileName from existing document in the text output', () => {
108
+ const config = parseParticleDesignerPlist(FIRE_PLIST);
109
+ const { document } = parseParticleDesignerPlistDocument(FIRE_PLIST);
110
+ const result = serializeParticleDesignerPlistDocument(config, document);
111
+ expect(result.text).toContain('fire.png');
112
+ });
113
+ });
@@ -0,0 +1,131 @@
1
+ import { parsePixiParticle, parsePixiParticleDocument } from './pixiParse';
2
+
3
+ const FIRE_PIXI = JSON.stringify({
4
+ alpha: { start: 1, end: 0 },
5
+ scale: { start: 0.5, end: 1.5, minimumScaleMultiplier: 1 },
6
+ color: { start: 'ffaa00', end: 'ff0000' },
7
+ speed: { start: 200, end: 50 },
8
+ acceleration: { x: 0, y: 0 },
9
+ maxSpeed: 0,
10
+ maxParticles: 1000,
11
+ startRotation: { min: 0, max: 360 },
12
+ noRotation: false,
13
+ rotationSpeed: { min: 0, max: 0 },
14
+ lifetime: { min: 0.5, max: 1.5 },
15
+ blendMode: 'add',
16
+ frequency: 0.001,
17
+ emitterLifetime: -1,
18
+ pos: { x: 0, y: 0 },
19
+ addAtBack: false,
20
+ spawnType: 'point',
21
+ });
22
+
23
+ const RECT_PIXI = JSON.stringify({
24
+ alpha: { start: 1, end: 0 },
25
+ scale: { start: 1, end: 1 },
26
+ color: { start: 'ffffff', end: 'ffffff' },
27
+ speed: { start: 100, end: 100 },
28
+ maxParticles: 200,
29
+ lifetime: { min: 1, max: 2 },
30
+ blendMode: 'normal',
31
+ frequency: 0.01,
32
+ pos: { x: 0, y: 0 },
33
+ spawnRect: { x: -50, y: -50, w: 100, h: 100 },
34
+ });
35
+
36
+ describe('parsePixiParticle', () => {
37
+ it('returns a ParticleEmitterConfig (not a ParseResult object)', () => {
38
+ const result = parsePixiParticle(FIRE_PIXI) as unknown as Record<string, unknown>;
39
+ expect(typeof result.maxParticles).toBe('number');
40
+ expect(result.warnings).toBeUndefined();
41
+ });
42
+ it('parses maxParticles', () => {
43
+ expect(parsePixiParticle(FIRE_PIXI).maxParticles).toBe(1000);
44
+ });
45
+ it('converts frequency to spawnRate', () => {
46
+ // frequency=0.001 -> spawnRate = 1/0.001 = 1000
47
+ expect(parsePixiParticle(FIRE_PIXI).spawnRate).toBeCloseTo(1000, 0);
48
+ });
49
+ it('maps lifetime to lifetimeMin/Max', () => {
50
+ const c = parsePixiParticle(FIRE_PIXI);
51
+ expect(c.lifetimeMin).toBeCloseTo(0.5);
52
+ expect(c.lifetimeMax).toBeCloseTo(1.5);
53
+ });
54
+ it('maps speed start/end to speedMin/Max (sorted)', () => {
55
+ const c = parsePixiParticle(FIRE_PIXI);
56
+ // speed start=200, end=50 -> min=50, max=200
57
+ expect(c.speedMin).toBeCloseTo(50);
58
+ expect(c.speedMax).toBeCloseTo(200);
59
+ });
60
+ it('maps alpha start/end to alphaStart/End', () => {
61
+ const c = parsePixiParticle(FIRE_PIXI);
62
+ expect(c.alphaStart).toBeCloseTo(1);
63
+ expect(c.alphaEnd).toBeCloseTo(0);
64
+ });
65
+ it('maps color start/end to colorStart/End', () => {
66
+ const c = parsePixiParticle(FIRE_PIXI);
67
+ expect(c.colorStartR).toBeCloseTo(1);
68
+ expect(c.colorStartG).toBeCloseTo(0xaa / 255, 2);
69
+ expect(c.colorEndR).toBeCloseTo(1);
70
+ expect(c.colorEndG).toBeCloseTo(0);
71
+ });
72
+ it('maps blendMode="add" to blendMode="add"', () => {
73
+ expect(parsePixiParticle(FIRE_PIXI).blendMode).toBe('add');
74
+ });
75
+ it('maps blendMode="normal" to blendMode="normal"', () => {
76
+ expect(parsePixiParticle(RECT_PIXI).blendMode).toBe('normal');
77
+ });
78
+ it('maps spawnRect to rect emitter shape', () => {
79
+ const c = parsePixiParticle(RECT_PIXI);
80
+ expect(c.emitterShape).toBe('rect');
81
+ expect(c.emitterWidth).toBeCloseTo(100);
82
+ expect(c.emitterHeight).toBeCloseTo(100);
83
+ });
84
+ it('maps spawnCircle to circle emitter shape', () => {
85
+ const json = JSON.stringify({ ...JSON.parse(FIRE_PIXI), spawnCircle: { x: 0, y: 0, r: 30 } });
86
+ const c = parsePixiParticle(json);
87
+ expect(c.emitterShape).toBe('circle');
88
+ expect(c.emitterRadius).toBeCloseTo(30);
89
+ });
90
+ it('maps scale to scaleMin/Max', () => {
91
+ const c = parsePixiParticle(FIRE_PIXI);
92
+ // scale start=0.5, end=1.5 -> min=0.5, max=1.5
93
+ expect(c.scaleMin).toBeCloseTo(0.5);
94
+ expect(c.scaleMax).toBeCloseTo(1.5);
95
+ });
96
+ it('throws a clear, format-tagged error on invalid JSON', () => {
97
+ expect(() => parsePixiParticle('{not valid')).toThrow(/Invalid Pixi particle JSON/);
98
+ });
99
+ it('throws a clear error when the root is not an object', () => {
100
+ expect(() => parsePixiParticle('null')).toThrow(/expected a JSON object/);
101
+ expect(() => parsePixiParticle('[1,2]')).toThrow(/expected a JSON object/);
102
+ });
103
+ it('falls back to defaults for an empty object without producing NaN', () => {
104
+ const c = parsePixiParticle('{}');
105
+ expect(Number.isFinite(c.maxParticles)).toBe(true);
106
+ expect(Number.isFinite(c.lifetimeMin)).toBe(true);
107
+ });
108
+ });
109
+
110
+ describe('parsePixiParticleDocument', () => {
111
+ it('returns the same config as parsePixiParticle', () => {
112
+ const config = parsePixiParticle(FIRE_PIXI);
113
+ const { config: loaded } = parsePixiParticleDocument(FIRE_PIXI);
114
+ expect(loaded.maxParticles).toBe(config.maxParticles);
115
+ expect(loaded.speedMax).toBeCloseTo(config.speedMax, 3);
116
+ });
117
+ it('has no warnings for a standard point emitter', () => {
118
+ const { warnings } = parsePixiParticleDocument(FIRE_PIXI);
119
+ expect(warnings).toEqual([]);
120
+ });
121
+ it('warns when acceleration is non-zero', () => {
122
+ const json = JSON.stringify({ ...JSON.parse(FIRE_PIXI), acceleration: { x: 0, y: 100 } });
123
+ const { warnings } = parsePixiParticleDocument(json);
124
+ expect(warnings.some((w) => w.includes('acceleration'))).toBe(true);
125
+ });
126
+ it('warns when spawnBurst is present', () => {
127
+ const json = JSON.stringify({ ...JSON.parse(FIRE_PIXI), spawnBurst: { count: 5, time: 0.5 } });
128
+ const { warnings } = parsePixiParticleDocument(json);
129
+ expect(warnings.some((w) => w.includes('spawnBurst'))).toBe(true);
130
+ });
131
+ });