Skip to content

Commit d140200

Browse files
committed
feat(particles): introduce particle class focused on location and lifecycle
- Implement lifecycle mechanics with maximum age and maximum number of invisible location updates - Add visible trail buffer with dynamic resizing - Add test case covering initialization, state transitions, and trail behavior
1 parent c20c3a1 commit d140200

File tree

2 files changed

+421
-0
lines changed

2 files changed

+421
-0
lines changed

src/lib/particles/particle.js

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
/**
2+
* Represents a single particle.
3+
*
4+
* @property {number} age - Current age of the particle in time units.
5+
* @property {number} lifeSpan - Lifespan of the particle in time units.
6+
* @property {boolean} visible - Whether the particle is currently visible.
7+
* @property {number} maxInvisibleSteps - Maximum allowed consecutive invisible steps.
8+
* @property {number} visibleTrailCapacity - Max positions in visible trail buffer.
9+
* @property {number} visibleX - Current visible X coordinate.
10+
* @property {number} visibleY - Current visible Y coordinate.
11+
* @property {number} lastVisibleX - Previous visible X coordinate.
12+
* @property {number} lastVisibleY - Previous visible Y coordinate.
13+
* @property {number} realX - Current real (possibly invisible) X coordinate.
14+
* @property {number} realY - Current real (possibly invisible) Y coordinate.
15+
*/
16+
export class Particle {
17+
#age;
18+
#lifeSpan;
19+
#visible;
20+
#visibleX;
21+
#visibleY;
22+
#lastVisibleX;
23+
#lastVisibleY;
24+
#realX;
25+
#realY;
26+
#invisibleSteps;
27+
#maxInvisibleSteps;
28+
#visibleTrailCapacity;
29+
#visibleTrailHead;
30+
#visibleTrailLength;
31+
#visibleTrailX;
32+
#visibleTrailY;
33+
34+
/**
35+
* @param {number} [lifeSpan=10] - Lifespan in time units.
36+
* @param {number} [maxInvisibleSteps=10] - Maximum number of invisible position updates the particle can survive.
37+
* @param {number} [visibleTrailCapacity=10] - Maximum number of positions to keep in trail buffer.
38+
*/
39+
constructor(
40+
lifeSpan = 10,
41+
maxInvisibleSteps = 10,
42+
visibleTrailCapacity = 10
43+
) {
44+
this.setLifeSpan(lifeSpan);
45+
this.setMaxInvisibleSteps(maxInvisibleSteps);
46+
this.setVisibleTrailCapacity(visibleTrailCapacity);
47+
48+
this.#reset();
49+
}
50+
51+
/**
52+
* Private method to reset all mutable state.
53+
*/
54+
#reset() {
55+
this.#age = 0;
56+
this.#visible = false;
57+
this.#visibleX = null;
58+
this.#visibleY = null;
59+
this.#lastVisibleX = null;
60+
this.#lastVisibleY = null;
61+
this.#realX = null;
62+
this.#realY = null;
63+
this.#invisibleSteps = 0;
64+
65+
if (this.#visibleTrailCapacity > 0) {
66+
this.#visibleTrailHead = 0;
67+
this.#visibleTrailLength = 0;
68+
}
69+
}
70+
71+
get age() {
72+
return this.#age;
73+
}
74+
75+
get lifeSpan() {
76+
return this.#lifeSpan;
77+
}
78+
79+
get visible() {
80+
return this.#visible;
81+
}
82+
83+
get visibleX() {
84+
return this.#visibleX;
85+
}
86+
87+
get visibleY() {
88+
return this.#visibleY;
89+
}
90+
91+
get lastVisibleX() {
92+
return this.#lastVisibleX;
93+
}
94+
95+
get lastVisibleY() {
96+
return this.#lastVisibleY;
97+
}
98+
99+
get realX() {
100+
return this.#realX;
101+
}
102+
103+
get realY() {
104+
return this.#realY;
105+
}
106+
107+
get invisibleSteps() {
108+
return this.#invisibleSteps;
109+
}
110+
111+
get maxInvisibleSteps() {
112+
return this.#maxInvisibleSteps;
113+
}
114+
115+
get visibleTrailCapacity() {
116+
return this.#visibleTrailCapacity;
117+
}
118+
119+
get visibleTrailLength() {
120+
return this.#visibleTrailLength;
121+
}
122+
123+
/**
124+
* Sets the life span of the particle (in time units).
125+
* Resets the particle if its age exceeds the new life span.
126+
* @param {number} value
127+
*/
128+
setLifeSpan(value) {
129+
if (typeof value !== "number" || value < 0) {
130+
throw new Error("lifeSpan must be a non-negative number");
131+
}
132+
this.#lifeSpan = value;
133+
if (this.#age > this.#lifeSpan) {
134+
this.#reset();
135+
}
136+
}
137+
138+
/**
139+
* Sets the maximum allowed consecutive invisible steps.
140+
* Resets the particle if its current invisible steps exceeds the new maximum.
141+
* @param {number} value
142+
*/
143+
setMaxInvisibleSteps(value) {
144+
if (typeof value !== "number" || value < 0) {
145+
throw new Error("maxInvisibleSteps must be a non-negative number");
146+
}
147+
this.#maxInvisibleSteps = value;
148+
if (this.#invisibleSteps > this.#maxInvisibleSteps) {
149+
this.#reset();
150+
}
151+
}
152+
153+
/**
154+
* Sets the visible trail buffer capacity, resizing internal arrays and preserving as much trail as possible.
155+
* @param {number} newCapacity
156+
*/
157+
setVisibleTrailCapacity(newCapacity) {
158+
if (typeof newCapacity !== "number" || newCapacity < 0) {
159+
throw new Error("visibleTrailCapacity must be a non-negative number");
160+
}
161+
if (newCapacity === this.#visibleTrailCapacity) return;
162+
163+
const oldTrail = this.getVisibleTrail();
164+
165+
this.#visibleTrailCapacity = newCapacity;
166+
this.#visibleTrailX = newCapacity > 0 ? new Array(newCapacity) : [];
167+
this.#visibleTrailY = newCapacity > 0 ? new Array(newCapacity) : [];
168+
this.#visibleTrailHead = 0;
169+
170+
// Copy as much of the old trail as fits, from the end (most recent)
171+
const toCopy = Math.min(oldTrail.length, newCapacity);
172+
this.#visibleTrailLength = toCopy;
173+
for (let i = 0; i < toCopy; i++) {
174+
const {x, y} = oldTrail[oldTrail.length - toCopy + i];
175+
this.#visibleTrailX[i] = x;
176+
this.#visibleTrailY[i] = y;
177+
}
178+
}
179+
180+
/**
181+
* Returns whether the particle is alive based on its current state.
182+
* The particle is considered alive if it has a position.
183+
* @returns {boolean}
184+
*/
185+
get isAlive() {
186+
return this.realX !== null && this.realY !== null;
187+
}
188+
189+
/**
190+
* Returns the relative age of the particle (age divided by lifeSpan, in [0, 1+]).
191+
* @type {number}
192+
*/
193+
get relativeAge() {
194+
return this.lifeSpan > 0 ? this.#age / this.lifeSpan : 0;
195+
}
196+
197+
/**
198+
* Resets the particle to initial state, clearing its state and trail buffer.
199+
*/
200+
kill() {
201+
this.#reset();
202+
}
203+
204+
/**
205+
* Appends a point to the visible trail buffer (private).
206+
* @param {number} x
207+
* @param {number} y
208+
* @private
209+
*/
210+
#appendToVisibleTrail(x, y) {
211+
if (this.visibleTrailCapacity === 0) return;
212+
213+
const idx =
214+
(this.#visibleTrailHead + this.#visibleTrailLength) %
215+
this.visibleTrailCapacity;
216+
this.#visibleTrailX[idx] = x;
217+
this.#visibleTrailY[idx] = y;
218+
219+
if (this.#visibleTrailLength < this.visibleTrailCapacity) {
220+
this.#visibleTrailLength++;
221+
} else {
222+
this.#visibleTrailHead =
223+
(this.#visibleTrailHead + 1) % this.visibleTrailCapacity;
224+
}
225+
}
226+
227+
/**
228+
* Returns the particle's visible trail as an array of {x, y} objects, in FIFO order.
229+
* @returns {{x: number, y: number}[]}
230+
*/
231+
getVisibleTrail() {
232+
if (this.visibleTrailCapacity === 0) return [];
233+
const result = [];
234+
for (let i = 0; i < this.#visibleTrailLength; i++) {
235+
const idx = (this.#visibleTrailHead + i) % this.visibleTrailCapacity;
236+
result.push({x: this.#visibleTrailX[idx], y: this.#visibleTrailY[idx]});
237+
}
238+
return result;
239+
}
240+
241+
/**
242+
* Updates the particle's location, visibility, and age.
243+
* @param {boolean} visible - Whether the particle is currently visible.
244+
* @param {number} x - The new x position.
245+
* @param {number} y - The new y position.
246+
* @param {number} dt - Time interval (must be > 0).
247+
*/
248+
updateLocation(visible, x, y, dt) {
249+
if (!(dt >= 0)) {
250+
throw new Error("dt must be greater than or equal to zero");
251+
}
252+
253+
this.#lastVisibleX = this.#visibleX;
254+
this.#lastVisibleY = this.#visibleY;
255+
256+
this.#realX = x;
257+
this.#realY = y;
258+
259+
this.#age += dt;
260+
if (this.#age > this.#lifeSpan) {
261+
this.#reset();
262+
return;
263+
}
264+
265+
if (!visible) {
266+
this.#visible = false;
267+
this.#invisibleSteps++;
268+
if (this.#invisibleSteps > this.#maxInvisibleSteps) {
269+
this.#reset();
270+
}
271+
return;
272+
}
273+
274+
if (!this.#visible) {
275+
this.#visible = true;
276+
this.#invisibleSteps = 0;
277+
}
278+
this.#visibleX = x;
279+
this.#visibleY = y;
280+
281+
if (this.#visibleTrailCapacity > 0) {
282+
this.#appendToVisibleTrail(x, y);
283+
}
284+
}
285+
}

0 commit comments

Comments
 (0)