particles.test.ts 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. import { describe, expect, it } from "vitest";
  2. import type { ParticleDef } from "@nengine/core";
  3. import {
  4. particleRadius,
  5. particleSpawn,
  6. particleTick,
  7. resolveParticleDef,
  8. PARTICLE_DEFAULT_EMIT_RATE,
  9. type ParticleState,
  10. } from "../src/particles.js";
  11. function rand(): () => number {
  12. let n = 0.42;
  13. return () => {
  14. n = (n * 9301 + 49297) % 233280;
  15. return n / 233280;
  16. };
  17. }
  18. const def = (overrides: Partial<ParticleDef> = {}): ParticleDef => ({ id: "dust", ...overrides });
  19. describe("resolveParticleDef", () => {
  20. it("fills documented engine defaults for an empty def", () => {
  21. expect(resolveParticleDef(def())).toEqual({
  22. emitRate: 10,
  23. lifetimeMin: 500,
  24. lifetimeMax: 1000,
  25. speedMin: 10,
  26. speedMax: 30,
  27. angle: 0,
  28. spread: 360,
  29. gravity: 0,
  30. sizeMin: 2,
  31. sizeMax: 4,
  32. colors: ["#ffffff"],
  33. fade: true,
  34. });
  35. });
  36. it("honors explicit fields over defaults", () => {
  37. const resolved = resolveParticleDef(
  38. def({ emitRate: 24, angle: 90, spread: 8, gravity: 220, fade: false, colors: ["#64b5f6"] }),
  39. );
  40. expect(resolved).toMatchObject({
  41. emitRate: 24,
  42. angle: 90,
  43. spread: 8,
  44. gravity: 220,
  45. fade: false,
  46. colors: ["#64b5f6"],
  47. });
  48. });
  49. });
  50. describe("particleSpawn", () => {
  51. it("spawns emitRate particles at the requested pixel position", () => {
  52. const states = particleSpawn(def(), 64, 96, rand());
  53. expect(states).toHaveLength(PARTICLE_DEFAULT_EMIT_RATE);
  54. for (const state of states) {
  55. expect(state.x).toBe(64);
  56. expect(state.y).toBe(96);
  57. }
  58. });
  59. it("keeps speed/lifetime/size within the def ranges", () => {
  60. const rain = def({
  61. emitRate: 64,
  62. speed: { min: 100, max: 120 },
  63. lifetime: { min: 400, max: 600 },
  64. size: { min: 1, max: 2 },
  65. angle: 90,
  66. spread: 0,
  67. });
  68. const states = particleSpawn(rain, 0, 0, rand());
  69. expect(states).toHaveLength(64);
  70. for (const state of states) {
  71. const speed = Math.hypot(state.vx, state.vy);
  72. expect(speed).toBeGreaterThanOrEqual(100);
  73. expect(speed).toBeLessThanOrEqual(120);
  74. expect(state.lifeMs).toBeGreaterThanOrEqual(400);
  75. expect(state.lifeMs).toBeLessThanOrEqual(600);
  76. expect(state.size).toBeGreaterThanOrEqual(1);
  77. expect(state.size).toBeLessThanOrEqual(2);
  78. expect(state.vx).toBeCloseTo(0, 6);
  79. expect(state.vy).toBeGreaterThan(0);
  80. }
  81. });
  82. it("draws color from the def palette as a 0xrrggbb number", () => {
  83. const states = particleSpawn(def({ emitRate: 1, colors: ["#ffd54f"] }), 0, 0, rand());
  84. expect(states[0]?.color).toBe(0xffd54f);
  85. });
  86. it("full-circle spread launches in every direction", () => {
  87. const states = particleSpawn(def({ emitRate: 64 }), 0, 0, rand());
  88. const upward = states.some((s) => s.vy < 0);
  89. const downward = states.some((s) => s.vy > 0);
  90. const left = states.some((s) => s.vx < 0);
  91. const right = states.some((s) => s.vx > 0);
  92. expect(upward && downward && left && right).toBe(true);
  93. });
  94. });
  95. describe("particleTick", () => {
  96. it("applies gravity each second and moves by velocity", () => {
  97. const [state] = particleSpawn(
  98. def({ emitRate: 1, angle: 0, spread: 0, speed: { min: 10, max: 10 }, gravity: 100, lifetime: { min: 5000, max: 5000 } }),
  99. 0,
  100. 0,
  101. rand(),
  102. );
  103. const stepped = particleTick([state], 1000);
  104. expect(stepped[0]?.x).toBeCloseTo(10, 6);
  105. expect(stepped[0]?.y).toBeCloseTo(100, 6);
  106. });
  107. it("expires particles whose lifetime elapses", () => {
  108. const states = particleSpawn(def({ emitRate: 3, lifetime: { min: 100, max: 100 } }), 0, 0, rand());
  109. const after = particleTick(states, 100);
  110. expect(after).toHaveLength(0);
  111. });
  112. it("keeps particles alive before their lifetime elapses", () => {
  113. const states = particleSpawn(def({ emitRate: 3, lifetime: { min: 1000, max: 1000 } }), 0, 0, rand());
  114. const after = particleTick(states, 500);
  115. expect(after).toHaveLength(3);
  116. expect(after[0]?.elapsedMs).toBe(500);
  117. });
  118. it("partial expiry keeps only survivors", () => {
  119. const mixed = particleSpawn(def({ emitRate: 1, lifetime: { min: 100, max: 100 } }), 0, 0, rand());
  120. const long = particleSpawn(def({ emitRate: 2, lifetime: { min: 900, max: 900 } }), 0, 0, rand());
  121. const after = particleTick([...mixed, ...long], 500);
  122. expect(after).toHaveLength(2);
  123. });
  124. });
  125. describe("particleRadius", () => {
  126. it("returns the full size when fade is off", () => {
  127. const [state] = particleSpawn(def({ emitRate: 1, size: { min: 4, max: 4 }, fade: false }), 0, 0, rand());
  128. expect(particleRadius(state)).toBe(4);
  129. });
  130. it("shrinks toward the documented minimum over the lifetime when fading", () => {
  131. const spawn = (): ParticleState => {
  132. const [state] = particleSpawn(
  133. def({ emitRate: 1, size: { min: 4, max: 4 }, lifetime: { min: 2000, max: 2000 } }),
  134. 0,
  135. 0,
  136. rand(),
  137. );
  138. return state;
  139. };
  140. expect(particleRadius(spawn())).toBe(4);
  141. const [half] = particleTick([spawn()], 500);
  142. expect(particleRadius(half)).toBeCloseTo(3.25, 6);
  143. const [nearly] = particleTick([spawn()], 1999);
  144. expect(particleRadius(nearly)).toBeCloseTo(1.0015, 4);
  145. expect(particleTick([spawn()], 2000)).toHaveLength(0);
  146. });
  147. });