cubeforge 0.4.10 → 0.4.12

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/dist/index.d.ts CHANGED
@@ -531,8 +531,42 @@ interface ParticleEmitterProps {
531
531
  * - `'screen'` — lightens, softer than additive
532
532
  */
533
533
  blendMode?: 'normal' | 'additive' | 'multiply' | 'screen';
534
+ /**
535
+ * Visual shape of each particle.
536
+ * - `'soft'` (default) — radial gradient with glow halo; pairs well with `blendMode="additive"`
537
+ * - `'circle'` — hard-edged anti-aliased circle, no glow falloff
538
+ * - `'square'` — solid quad (fastest, no texture lookup)
539
+ */
540
+ particleShape?: 'soft' | 'circle' | 'square';
541
+ /**
542
+ * Formation mode: particles lerp toward fixed target positions instead of
543
+ * being emitted with velocity. Enables logo reveals, constellations, shape morphing.
544
+ * - `'standard'` (default) — normal emit/gravity/lifetime behaviour
545
+ * - `'formation'` — particles seek `formationPoints`; no gravity, no expiry
546
+ */
547
+ mode?: 'standard' | 'formation';
548
+ /**
549
+ * Target positions for formation mode. One particle is spawned per point.
550
+ * Change this array to morph the formation — particles smoothly lerp to new targets.
551
+ */
552
+ formationPoints?: {
553
+ x: number;
554
+ y: number;
555
+ }[];
556
+ /**
557
+ * How strongly particles seek their target each frame in formation mode.
558
+ * Exponential lerp factor (0–1). Default 0.055 (~5.5% per frame at 60 fps).
559
+ */
560
+ seekStrength?: number;
561
+ /**
562
+ * Smoothly transition all particles to this color.
563
+ * Works in both standard and formation modes.
564
+ */
565
+ targetColor?: string;
566
+ /** Duration of the global color transition in seconds. Default 0.5. */
567
+ colorTransitionDuration?: number;
534
568
  }
535
- declare function ParticleEmitter({ active, preset, rate, speed, spread, angle, particleLife, particleSize, color, gravity, maxParticles, burstCount, emitShape, emitRadius, emitWidth, emitHeight, textureSrc, enableRotation, rotationSpeedRange, sizeOverLife, attractors, colorOverLife, blendMode, }: ParticleEmitterProps): null;
569
+ declare function ParticleEmitter({ active, preset, rate, speed, spread, angle, particleLife, particleSize, color, gravity, maxParticles, burstCount, emitShape, emitRadius, emitWidth, emitHeight, textureSrc, enableRotation, rotationSpeedRange, sizeOverLife, attractors, colorOverLife, blendMode, mode, formationPoints, seekStrength, targetColor, colorTransitionDuration, particleShape, }: ParticleEmitterProps): null;
536
570
 
537
571
  interface VirtualJoystickProps {
538
572
  /** Diameter of the joystick base in pixels (default 120) */
package/dist/index.js CHANGED
@@ -2793,6 +2793,8 @@ var RenderSystem = class {
2793
2793
  this.uTexture = gl.getUniformLocation(this.program, "u_texture");
2794
2794
  this.uUseTexture = gl.getUniformLocation(this.program, "u_useTexture");
2795
2795
  this.whiteTexture = createWhiteTexture(gl);
2796
+ this.particleTextureSoft = this._createParticleTexture("soft");
2797
+ this.particleTextureCircle = this._createParticleTexture("circle");
2796
2798
  this.parallaxProgram = createProgram(gl, PARALLAX_VERT_SRC, PARALLAX_FRAG_SRC);
2797
2799
  const fsVerts = new Float32Array([-1, -1, 1, -1, -1, 1, 1, -1, 1, 1, -1, 1]);
2798
2800
  this.parallaxVAO = gl.createVertexArray();
@@ -2819,6 +2821,8 @@ var RenderSystem = class {
2819
2821
  instanceBuffer;
2820
2822
  instanceData;
2821
2823
  whiteTexture;
2824
+ particleTextureSoft;
2825
+ particleTextureCircle;
2822
2826
  textures = /* @__PURE__ */ new Map();
2823
2827
  /** Tracks texture access order for LRU eviction (most recent at end). */
2824
2828
  textureLRU = [];
@@ -3041,6 +3045,38 @@ var RenderSystem = class {
3041
3045
  gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
3042
3046
  gl.activeTexture(gl.TEXTURE0);
3043
3047
  }
3048
+ /** Pre-baked particle shape textures: white alpha mask, tinted by instance color at render time. */
3049
+ _createParticleTexture(shape) {
3050
+ const { gl } = this;
3051
+ const SIZE = 64;
3052
+ const canvas = document.createElement("canvas");
3053
+ canvas.width = SIZE;
3054
+ canvas.height = SIZE;
3055
+ const ctx = canvas.getContext("2d");
3056
+ const cx = SIZE / 2;
3057
+ if (shape === "soft") {
3058
+ const g = ctx.createRadialGradient(cx, cx, 0, cx, cx, cx);
3059
+ g.addColorStop(0, "rgba(255,255,255,1)");
3060
+ g.addColorStop(0.25, "rgba(255,255,255,0.9)");
3061
+ g.addColorStop(0.5, "rgba(255,255,255,0.2)");
3062
+ g.addColorStop(1, "rgba(255,255,255,0)");
3063
+ ctx.fillStyle = g;
3064
+ ctx.fillRect(0, 0, SIZE, SIZE);
3065
+ } else {
3066
+ ctx.fillStyle = "white";
3067
+ ctx.beginPath();
3068
+ ctx.arc(cx, cx, cx - 1, 0, Math.PI * 2);
3069
+ ctx.fill();
3070
+ }
3071
+ const tex = gl.createTexture();
3072
+ gl.bindTexture(gl.TEXTURE_2D, tex);
3073
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
3074
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
3075
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
3076
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
3077
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
3078
+ return tex;
3079
+ }
3044
3080
  // ── Texture management (sprite textures — CLAMP_TO_EDGE) ──────────────────
3045
3081
  loadTexture(src) {
3046
3082
  const cached = this.textures.get(src);
@@ -3283,6 +3319,8 @@ var RenderSystem = class {
3283
3319
  const { gl, canvas } = this;
3284
3320
  const W = canvas.width;
3285
3321
  const H = canvas.height;
3322
+ const Wl = canvas.clientWidth || W;
3323
+ const Hl = canvas.clientHeight || H;
3286
3324
  let camX = 0, camY = 0, zoom = 1;
3287
3325
  let background = this.defaultBackground;
3288
3326
  let shakeX = 0, shakeY = 0;
@@ -3472,7 +3510,7 @@ var RenderSystem = class {
3472
3510
  gl.useProgram(this.program);
3473
3511
  gl.uniform2f(this.uCamPos, camX, camY);
3474
3512
  gl.uniform1f(this.uZoom, zoom);
3475
- gl.uniform2f(this.uCanvasSize, W, H);
3513
+ gl.uniform2f(this.uCanvasSize, Wl, Hl);
3476
3514
  gl.uniform2f(this.uShake, shakeX, shakeY);
3477
3515
  gl.uniform1i(this.uTexture, 0);
3478
3516
  gl.activeTexture(gl.TEXTURE0);
@@ -3653,15 +3691,87 @@ var RenderSystem = class {
3653
3691
  for (const id of world.query("Transform", "ParticlePool")) {
3654
3692
  const t = world.getComponent(id, "Transform");
3655
3693
  const pool = world.getComponent(id, "ParticlePool");
3694
+ if (pool.targetColor && pool._colorTransitionFrom !== void 0) {
3695
+ pool._colorTransitionElapsed = (pool._colorTransitionElapsed ?? 0) + dt;
3696
+ const dur = pool.colorTransitionDuration ?? 0.5;
3697
+ const ct = Math.min((pool._colorTransitionElapsed ?? 0) / dur, 1);
3698
+ const ease = ct * ct * (3 - 2 * ct);
3699
+ const [cr0, cg0, cb0] = parseCSSColor(pool._colorTransitionFrom);
3700
+ const [cr1, cg1, cb1] = parseCSSColor(pool.targetColor);
3701
+ const ri = Math.round((cr0 + (cr1 - cr0) * ease) * 255);
3702
+ const gi = Math.round((cg0 + (cg1 - cg0) * ease) * 255);
3703
+ const bi = Math.round((cb0 + (cb1 - cb0) * ease) * 255);
3704
+ pool.color = `rgb(${ri},${gi},${bi})`;
3705
+ if (ct >= 1) {
3706
+ pool._colorTransitionFrom = void 0;
3707
+ pool._colorTransitionElapsed = void 0;
3708
+ }
3709
+ }
3710
+ const isFormation = pool.mode === "formation";
3656
3711
  pool.particles = pool.particles.filter((p) => {
3712
+ if (isFormation) {
3713
+ if (p.targetX !== void 0 && p.targetY !== void 0) {
3714
+ const seek2 = pool.seekStrength ?? 0.055;
3715
+ p.x += (p.targetX - p.x) * seek2;
3716
+ p.y += (p.targetY - p.y) * seek2;
3717
+ }
3718
+ if (pool.attractors) {
3719
+ for (const attr of pool.attractors) {
3720
+ const adx = p.x - attr.x;
3721
+ const ady = p.y - attr.y;
3722
+ const dist = Math.sqrt(adx * adx + ady * ady);
3723
+ if (dist < attr.radius && dist > 0) {
3724
+ const magnitude = -attr.strength * (1 - dist / attr.radius) * dt;
3725
+ p.x += adx / dist * magnitude;
3726
+ p.y += ady / dist * magnitude;
3727
+ }
3728
+ }
3729
+ }
3730
+ return true;
3731
+ }
3657
3732
  p.life -= dt;
3733
+ if (pool.attractors) {
3734
+ for (const attr of pool.attractors) {
3735
+ const adx = attr.x - p.x;
3736
+ const ady = attr.y - p.y;
3737
+ const dist = Math.sqrt(adx * adx + ady * ady);
3738
+ if (dist < attr.radius && dist > 0) {
3739
+ const force = attr.strength * (1 - dist / attr.radius);
3740
+ p.vx += adx / dist * force * dt;
3741
+ p.vy += ady / dist * force * dt;
3742
+ }
3743
+ }
3744
+ }
3658
3745
  p.x += p.vx * dt;
3659
3746
  p.y += p.vy * dt;
3660
3747
  p.vy += p.gravity * dt;
3661
3748
  if (p.rotationSpeed !== void 0) p.rotation = (p.rotation ?? 0) + p.rotationSpeed * dt;
3662
3749
  return p.life > 0;
3663
3750
  });
3664
- if (pool.active && pool.particles.length < pool.maxParticles) {
3751
+ if (isFormation) {
3752
+ const fp = pool.formationPoints ?? [];
3753
+ while (pool.particles.length < fp.length) {
3754
+ const idx = pool.particles.length;
3755
+ const startSize = pool.sizeOverLife?.start ?? pool.particleSize;
3756
+ const endSize = pool.sizeOverLife?.end ?? pool.particleSize;
3757
+ pool.particles.push({
3758
+ x: t.x + (world.rng() - 0.5) * 20,
3759
+ y: t.y + (world.rng() - 0.5) * 20,
3760
+ vx: 0,
3761
+ vy: 0,
3762
+ life: 1,
3763
+ maxLife: 1,
3764
+ size: startSize,
3765
+ startSize,
3766
+ endSize,
3767
+ color: pool.color,
3768
+ gravity: 0,
3769
+ rotation: 0,
3770
+ targetX: fp[idx].x,
3771
+ targetY: fp[idx].y
3772
+ });
3773
+ }
3774
+ } else if (pool.active && pool.particles.length < pool.maxParticles) {
3665
3775
  let spawnCount;
3666
3776
  if (pool.burstCount != null && pool.burstCount > 0) {
3667
3777
  spawnCount = pool.burstCount;
@@ -3711,11 +3821,22 @@ var RenderSystem = class {
3711
3821
  }
3712
3822
  }
3713
3823
  let pCount = 0;
3714
- const pKey = `__color__`;
3824
+ const pShape = pool.particleShape ?? "soft";
3715
3825
  const pBlend = pool.blendMode ?? "normal";
3826
+ const flushParticles = (n) => {
3827
+ if (n === 0) return;
3828
+ if (pBlend !== "normal") this.applyBlendMode(pBlend);
3829
+ if (pShape === "square") {
3830
+ this.flush(n, "__color__");
3831
+ } else {
3832
+ const tex = pShape === "circle" ? this.particleTextureCircle : this.particleTextureSoft;
3833
+ this.flushWithTex(n, tex, true);
3834
+ }
3835
+ if (pBlend !== "normal") this.applyBlendMode("normal");
3836
+ };
3716
3837
  for (const p of pool.particles) {
3717
3838
  if (pCount >= MAX_INSTANCES) {
3718
- this.flush(pCount, pKey, void 0, pBlend);
3839
+ flushParticles(pCount);
3719
3840
  pCount = 0;
3720
3841
  }
3721
3842
  const lifeFrac = p.life / p.maxLife;
@@ -3762,7 +3883,7 @@ var RenderSystem = class {
3762
3883
  );
3763
3884
  pCount++;
3764
3885
  }
3765
- if (pCount > 0) this.flush(pCount, pKey, void 0, pBlend);
3886
+ if (pCount > 0) flushParticles(pCount);
3766
3887
  }
3767
3888
  for (const id of world.query("Transform", "Trail")) {
3768
3889
  const t = world.getComponent(id, "Transform");
@@ -15556,7 +15677,13 @@ function ParticleEmitter({
15556
15677
  sizeOverLife,
15557
15678
  attractors,
15558
15679
  colorOverLife,
15559
- blendMode
15680
+ blendMode,
15681
+ mode,
15682
+ formationPoints,
15683
+ seekStrength,
15684
+ targetColor,
15685
+ colorTransitionDuration,
15686
+ particleShape
15560
15687
  }) {
15561
15688
  const presetConfig = preset ? PARTICLE_PRESETS[preset] : {};
15562
15689
  const resolvedRate = rate ?? presetConfig.rate ?? 20;
@@ -15596,7 +15723,12 @@ function ParticleEmitter({
15596
15723
  sizeOverLife,
15597
15724
  attractors,
15598
15725
  colorOverLife,
15599
- blendMode
15726
+ blendMode,
15727
+ mode,
15728
+ formationPoints,
15729
+ seekStrength,
15730
+ colorTransitionDuration,
15731
+ particleShape
15600
15732
  });
15601
15733
  return () => engine.ecs.removeComponent(entityId, "ParticlePool");
15602
15734
  }, []);
@@ -15605,6 +15737,37 @@ function ParticleEmitter({
15605
15737
  if (!pool) return;
15606
15738
  pool.active = active;
15607
15739
  }, [active, engine, entityId]);
15740
+ useEffect28(() => {
15741
+ const pool = engine.ecs.getComponent(entityId, "ParticlePool");
15742
+ if (!pool) return;
15743
+ pool.blendMode = blendMode;
15744
+ pool.particleShape = particleShape;
15745
+ }, [blendMode, particleShape, engine, entityId]);
15746
+ useEffect28(() => {
15747
+ const pool = engine.ecs.getComponent(entityId, "ParticlePool");
15748
+ if (!pool) return;
15749
+ pool.attractors = attractors;
15750
+ }, [attractors, engine, entityId]);
15751
+ useEffect28(() => {
15752
+ const pool = engine.ecs.getComponent(entityId, "ParticlePool");
15753
+ if (!pool || pool.mode !== "formation" || !formationPoints) return;
15754
+ pool.formationPoints = formationPoints;
15755
+ const shuffled = [...formationPoints].sort(() => Math.random() - 0.5);
15756
+ pool.particles.forEach((p, i) => {
15757
+ if (shuffled[i]) {
15758
+ p.targetX = shuffled[i].x;
15759
+ p.targetY = shuffled[i].y;
15760
+ }
15761
+ });
15762
+ }, [formationPoints, engine, entityId]);
15763
+ useEffect28(() => {
15764
+ const pool = engine.ecs.getComponent(entityId, "ParticlePool");
15765
+ if (!pool || !targetColor) return;
15766
+ pool._colorTransitionFrom = pool.color;
15767
+ pool._colorTransitionElapsed = 0;
15768
+ pool.targetColor = targetColor;
15769
+ pool.colorTransitionDuration = colorTransitionDuration;
15770
+ }, [targetColor, colorTransitionDuration, engine, entityId]);
15608
15771
  return null;
15609
15772
  }
15610
15773
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cubeforge",
3
- "version": "0.4.10",
3
+ "version": "0.4.12",
4
4
  "description": "React-first 2D browser game engine",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",