@kernel.chat/kbot 3.83.0 → 3.85.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.
@@ -478,6 +478,141 @@ export function renderParticles(ctx, particles) {
478
478
  ctx.globalAlpha = 1;
479
479
  ctx.restore();
480
480
  }
481
+ // ─── 3b. POSITION-BASED PARTICLE DYNAMICS (PBD UPGRADE) ─────────
482
+ /**
483
+ * Tick particles using Verlet integration (PBD style) instead of simple velocity.
484
+ * Adds floor constraints with realistic bounce and attractor constraints for orbital particles.
485
+ */
486
+ export function tickParticlesPBD(particles, groundLevel = 480, attractorX = 0, attractorY = 0) {
487
+ const dt = 1; // timestep (frame-based)
488
+ return particles.filter(p => {
489
+ p.life--;
490
+ if (p.life <= 0)
491
+ return false;
492
+ // Reconstruct previous position from velocity
493
+ const prevX = p.x - p.vx;
494
+ const prevY = p.y - p.vy;
495
+ switch (p.type) {
496
+ case 'spark': {
497
+ // Store trail
498
+ if (p.trail) {
499
+ p.trail.push({ x: p.x, y: p.y });
500
+ if (p.trail.length > 3)
501
+ p.trail.shift();
502
+ }
503
+ // Apply gravity force (Verlet: position += accel * dt^2)
504
+ p.y += (p.gravity ?? 0.3) * dt * dt;
505
+ p.x += p.vx;
506
+ p.y += p.vy;
507
+ // Constraint: floor collision with bounce
508
+ if (p.y > groundLevel && p.vy > 0) {
509
+ p.y = groundLevel;
510
+ const vyFromPos = p.y - prevY;
511
+ p.vy = -Math.abs(vyFromPos) * 0.4;
512
+ if (Math.abs(p.vy) < 0.5)
513
+ p.vy = 0;
514
+ }
515
+ // Derive velocity from position change (PBD)
516
+ p.vx = p.x - prevX;
517
+ p.vy = p.y - prevY;
518
+ break;
519
+ }
520
+ case 'fire': {
521
+ p.vy += p.gravity ?? -0.2;
522
+ p.vx = Math.sin(p.life * 0.3) * 0.5;
523
+ p.x += p.vx;
524
+ p.y += p.vy;
525
+ p.size = Math.max(1, p.size - 0.05);
526
+ const ratio = p.life / p.maxLife;
527
+ if (ratio > 0.7)
528
+ p.color = '#f0c040';
529
+ else if (ratio > 0.4)
530
+ p.color = '#ff6600';
531
+ else if (ratio > 0.2)
532
+ p.color = '#cc2200';
533
+ else
534
+ p.color = '#661100';
535
+ // Floor constraint for fire (fire rises, but embers fall)
536
+ if (p.y > groundLevel) {
537
+ p.y = groundLevel;
538
+ p.vy = -Math.abs(p.vy) * 0.2;
539
+ }
540
+ break;
541
+ }
542
+ case 'magic': {
543
+ if (p.cx !== undefined && p.cy !== undefined && p.orbitRadius !== undefined && p.orbitPhase !== undefined) {
544
+ p.orbitPhase += 0.15;
545
+ p.x = p.cx + Math.cos(p.orbitPhase) * p.orbitRadius;
546
+ p.y = p.cy + Math.sin(p.orbitPhase) * p.orbitRadius;
547
+ }
548
+ // Attractor constraint: robot core pulls nearby magic particles
549
+ const dx = attractorX - p.x;
550
+ const dy = attractorY - p.y;
551
+ const dist = Math.sqrt(dx * dx + dy * dy);
552
+ if (dist < 200 && dist > 0) {
553
+ p.x += dx * 0.02;
554
+ p.y += dy * 0.02;
555
+ }
556
+ // Rainbow cycle
557
+ {
558
+ const rainbow = ['#f85149', '#f0c040', '#3fb950', '#58a6ff', '#bc8cff', '#ff6ec7'];
559
+ p.color = rainbow[Math.floor((p.maxLife - p.life) * 0.3) % rainbow.length];
560
+ }
561
+ // Derive velocity from position change (PBD)
562
+ p.vx = p.x - prevX;
563
+ p.vy = p.y - prevY;
564
+ break;
565
+ }
566
+ case 'electricity': {
567
+ if (p.startX !== undefined && p.endX !== undefined && p.startY !== undefined && p.endY !== undefined) {
568
+ if (!p.lastMidpointFrame || (p.maxLife - p.life) - (p.lastMidpointFrame ?? 0) >= 3) {
569
+ const segCount = 5 + Math.floor(Math.random() * 3);
570
+ p.midpoints = [];
571
+ for (let s = 1; s < segCount; s++) {
572
+ const t = s / segCount;
573
+ const mx = p.startX + (p.endX - p.startX) * t;
574
+ const my = p.startY + (p.endY - p.startY) * t;
575
+ const edx = p.endX - p.startX;
576
+ const edy = p.endY - p.startY;
577
+ const len = Math.sqrt(edx * edx + edy * edy) || 1;
578
+ const nx = -edy / len;
579
+ const ny = edx / len;
580
+ const offset = (Math.random() - 0.5) * 10;
581
+ p.midpoints.push({ x: mx + nx * offset, y: my + ny * offset });
582
+ }
583
+ p.lastMidpointFrame = p.maxLife - p.life;
584
+ }
585
+ }
586
+ break;
587
+ }
588
+ case 'trail':
589
+ break;
590
+ case 'smoke': {
591
+ p.vy += p.gravity ?? -0.05;
592
+ p.vx += (Math.random() - 0.5) * 0.1;
593
+ p.x += p.vx;
594
+ p.y += p.vy;
595
+ p.size += 0.15;
596
+ break;
597
+ }
598
+ case 'aura': {
599
+ // Attractor constraint for aura particles
600
+ const adx = attractorX - p.x;
601
+ const ady = attractorY - p.y;
602
+ const adist = Math.sqrt(adx * adx + ady * ady);
603
+ if (adist < 200 && adist > 0) {
604
+ p.x += adx * 0.02;
605
+ p.y += ady * 0.02;
606
+ }
607
+ // Derive velocity from position change (PBD)
608
+ p.vx = p.x - prevX;
609
+ p.vy = p.y - prevY;
610
+ break;
611
+ }
612
+ }
613
+ return true;
614
+ });
615
+ }
481
616
  // ─── 4. PROCEDURAL SKY ──────────────────────────────────────────
482
617
  // Stable star positions (seeded pseudorandom)
483
618
  const STAR_POSITIONS = [];
@@ -1358,4 +1493,516 @@ export function renderPostProcessing(ctx, width, height, frame, options) {
1358
1493
  }
1359
1494
  ctx.restore();
1360
1495
  }
1496
+ /**
1497
+ * Create an empty radiance grid (20x12 cells covering the 1280x720 canvas).
1498
+ */
1499
+ export function createRadianceGrid() {
1500
+ return {
1501
+ cells: new Float32Array(20 * 12 * 3),
1502
+ width: 20,
1503
+ height: 12,
1504
+ };
1505
+ }
1506
+ /**
1507
+ * Update radiance grid by propagating light from all sources using inverse-square falloff.
1508
+ * Clears grid each frame before re-propagating.
1509
+ */
1510
+ export function updateRadianceGrid(grid, lights) {
1511
+ // Clear
1512
+ grid.cells.fill(0);
1513
+ const cellW = 1280 / grid.width; // 64
1514
+ const cellH = 720 / grid.height; // 60
1515
+ for (const light of lights) {
1516
+ let intensity = light.intensity;
1517
+ if (light.flicker) {
1518
+ intensity *= 0.85 + Math.random() * 0.15;
1519
+ }
1520
+ const [lr, lg, lb] = hexToRgb(light.color);
1521
+ for (let cy = 0; cy < grid.height; cy++) {
1522
+ for (let cx = 0; cx < grid.width; cx++) {
1523
+ // Cell center in canvas space
1524
+ const cellCenterX = (cx + 0.5) * cellW;
1525
+ const cellCenterY = (cy + 0.5) * cellH;
1526
+ const dx = cellCenterX - light.x;
1527
+ const dy = cellCenterY - light.y;
1528
+ const distSq = dx * dx + dy * dy;
1529
+ // Inverse-square falloff with a softening factor
1530
+ const contribution = intensity / (1 + distSq * 0.001);
1531
+ // Skip negligible contributions
1532
+ if (contribution < 0.001)
1533
+ continue;
1534
+ const idx = (cy * grid.width + cx) * 3;
1535
+ grid.cells[idx] += (lr / 255) * contribution;
1536
+ grid.cells[idx + 1] += (lg / 255) * contribution;
1537
+ grid.cells[idx + 2] += (lb / 255) * contribution;
1538
+ }
1539
+ }
1540
+ }
1541
+ }
1542
+ /**
1543
+ * Render the radiance grid as an additive overlay.
1544
+ * Each grid cell is drawn as a colored rect at low opacity, creating ambient light propagation.
1545
+ */
1546
+ export function renderRadianceOverlay(ctx, grid, width, height) {
1547
+ const cellW = width / grid.width;
1548
+ const cellH = height / grid.height;
1549
+ ctx.save();
1550
+ ctx.globalCompositeOperation = 'lighter';
1551
+ for (let cy = 0; cy < grid.height; cy++) {
1552
+ for (let cx = 0; cx < grid.width; cx++) {
1553
+ const idx = (cy * grid.width + cx) * 3;
1554
+ const r = grid.cells[idx];
1555
+ const g = grid.cells[idx + 1];
1556
+ const b = grid.cells[idx + 2];
1557
+ // Skip dark cells
1558
+ const magnitude = r + g + b;
1559
+ if (magnitude < 0.01)
1560
+ continue;
1561
+ // Map accumulated radiance to color, cap at 1.0
1562
+ const cr = Math.min(255, Math.round(Math.min(1, r) * 255));
1563
+ const cg = Math.min(255, Math.round(Math.min(1, g) * 255));
1564
+ const cb = Math.min(255, Math.round(Math.min(1, b) * 255));
1565
+ // Opacity scales with magnitude (0.08-0.15 range)
1566
+ const alpha = Math.min(0.15, 0.08 + magnitude * 0.02);
1567
+ ctx.fillStyle = `rgba(${cr},${cg},${cb},${alpha})`;
1568
+ ctx.fillRect(cx * cellW, cy * cellH, cellW, cellH);
1569
+ }
1570
+ }
1571
+ ctx.globalCompositeOperation = 'source-over';
1572
+ ctx.restore();
1573
+ }
1574
+ // ─── 9. SUBSURFACE SCATTERING APPROXIMATION ─────────────────────
1575
+ /**
1576
+ * Shift a hex color's hue toward warm (red-shifted) by a given amount.
1577
+ */
1578
+ function warmShiftColor(hex, shiftDeg = 20) {
1579
+ const [r, g, b] = hexToRgb(hex);
1580
+ // Convert to HSL
1581
+ const rf = r / 255, gf = g / 255, bf = b / 255;
1582
+ const max = Math.max(rf, gf, bf), min = Math.min(rf, gf, bf);
1583
+ let h = 0, s = 0;
1584
+ const l = (max + min) / 2;
1585
+ if (max !== min) {
1586
+ const d = max - min;
1587
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
1588
+ if (max === rf)
1589
+ h = ((gf - bf) / d + (gf < bf ? 6 : 0)) * 60;
1590
+ else if (max === gf)
1591
+ h = ((bf - rf) / d + 2) * 60;
1592
+ else
1593
+ h = ((rf - gf) / d + 4) * 60;
1594
+ }
1595
+ // Shift hue toward warm (lower hue = red/orange)
1596
+ h = (h + shiftDeg) % 360;
1597
+ // HSL to RGB
1598
+ const c = (1 - Math.abs(2 * l - 1)) * s;
1599
+ const x = c * (1 - Math.abs((h / 60) % 2 - 1));
1600
+ const m = l - c / 2;
1601
+ let r1 = 0, g1 = 0, b1 = 0;
1602
+ if (h < 60) {
1603
+ r1 = c;
1604
+ g1 = x;
1605
+ b1 = 0;
1606
+ }
1607
+ else if (h < 120) {
1608
+ r1 = x;
1609
+ g1 = c;
1610
+ b1 = 0;
1611
+ }
1612
+ else if (h < 180) {
1613
+ r1 = 0;
1614
+ g1 = c;
1615
+ b1 = x;
1616
+ }
1617
+ else if (h < 240) {
1618
+ r1 = 0;
1619
+ g1 = x;
1620
+ b1 = c;
1621
+ }
1622
+ else if (h < 300) {
1623
+ r1 = x;
1624
+ g1 = 0;
1625
+ b1 = c;
1626
+ }
1627
+ else {
1628
+ r1 = c;
1629
+ g1 = 0;
1630
+ b1 = x;
1631
+ }
1632
+ return `rgb(${Math.round((r1 + m) * 255)},${Math.round((g1 + m) * 255)},${Math.round((b1 + m) * 255)})`;
1633
+ }
1634
+ /**
1635
+ * Render subsurface scattering approximation on translucent robot panels.
1636
+ * Creates a soft warm glow "leaking through" panel edges using shadowBlur + screen compositing.
1637
+ */
1638
+ export function renderSubsurfaceGlow(ctx, panels) {
1639
+ ctx.save();
1640
+ for (const panel of panels) {
1641
+ // Red-shift the color for subsurface warmth
1642
+ const sssColor = warmShiftColor(panel.color, 20);
1643
+ ctx.globalCompositeOperation = 'screen';
1644
+ ctx.shadowColor = sssColor;
1645
+ ctx.shadowBlur = 8 + panel.intensity * 4;
1646
+ ctx.strokeStyle = sssColor;
1647
+ ctx.lineWidth = 2;
1648
+ ctx.globalAlpha = 0.4 * panel.intensity;
1649
+ // Draw just the border (not filled) for edge glow effect
1650
+ ctx.strokeRect(panel.x, panel.y, panel.width, panel.height);
1651
+ }
1652
+ // Reset
1653
+ ctx.shadowBlur = 0;
1654
+ ctx.shadowColor = 'transparent';
1655
+ ctx.globalCompositeOperation = 'source-over';
1656
+ ctx.globalAlpha = 1;
1657
+ ctx.restore();
1658
+ }
1659
+ /**
1660
+ * Build SSS panel definitions for the kbot character.
1661
+ */
1662
+ export function buildSubsurfacePanels(robotX, robotY, scale, moodColor) {
1663
+ const panels = [];
1664
+ // Chest display panel
1665
+ panels.push({
1666
+ x: robotX + 12 * scale,
1667
+ y: robotY + 20 * scale,
1668
+ width: 10 * scale,
1669
+ height: 8 * scale,
1670
+ color: moodColor,
1671
+ intensity: 0.7,
1672
+ });
1673
+ // Left eye socket
1674
+ panels.push({
1675
+ x: robotX + 12 * scale,
1676
+ y: robotY + 8 * scale,
1677
+ width: 4 * scale,
1678
+ height: 3 * scale,
1679
+ color: moodColor,
1680
+ intensity: 0.5,
1681
+ });
1682
+ // Right eye socket
1683
+ panels.push({
1684
+ x: robotX + 18 * scale,
1685
+ y: robotY + 8 * scale,
1686
+ width: 4 * scale,
1687
+ height: 3 * scale,
1688
+ color: moodColor,
1689
+ intensity: 0.5,
1690
+ });
1691
+ // Antenna ball
1692
+ panels.push({
1693
+ x: robotX + 14 * scale,
1694
+ y: robotY - 4 * scale,
1695
+ width: 4 * scale,
1696
+ height: 4 * scale,
1697
+ color: moodColor,
1698
+ intensity: 0.8,
1699
+ });
1700
+ // Jet boot thrusters (left)
1701
+ panels.push({
1702
+ x: robotX + 10 * scale,
1703
+ y: robotY + 46 * scale,
1704
+ width: 5 * scale,
1705
+ height: 3 * scale,
1706
+ color: '#e8820c',
1707
+ intensity: 0.6,
1708
+ });
1709
+ // Jet boot thrusters (right)
1710
+ panels.push({
1711
+ x: robotX + 19 * scale,
1712
+ y: robotY + 46 * scale,
1713
+ width: 5 * scale,
1714
+ height: 3 * scale,
1715
+ color: '#e8820c',
1716
+ intensity: 0.6,
1717
+ });
1718
+ return panels;
1719
+ }
1720
+ /**
1721
+ * Create an empty frame cache for importance-sampled rendering.
1722
+ */
1723
+ export function createFrameCache() {
1724
+ return {
1725
+ backgroundLayer: null,
1726
+ bodyLayer: null,
1727
+ lastBackgroundFrame: -999,
1728
+ lastBodyFrame: -999,
1729
+ };
1730
+ }
1731
+ /**
1732
+ * Determine if a layer should be re-rendered this frame.
1733
+ * - Background: every 4th frame (cached)
1734
+ * - Body: every 2nd frame when idle (cached), always when moving
1735
+ * - Effects: every frame
1736
+ */
1737
+ export function shouldRenderLayer(cache, layer, currentFrame, isMoving = false, moodChanged = false, worldChanged = false) {
1738
+ // Invalidate cache on mood/world changes
1739
+ if (moodChanged || worldChanged)
1740
+ return true;
1741
+ switch (layer) {
1742
+ case 'background':
1743
+ if (!cache.backgroundLayer)
1744
+ return true;
1745
+ return (currentFrame - cache.lastBackgroundFrame) >= 4;
1746
+ case 'body':
1747
+ if (!cache.bodyLayer)
1748
+ return true;
1749
+ if (isMoving)
1750
+ return true;
1751
+ return (currentFrame - cache.lastBodyFrame) >= 2;
1752
+ case 'effects':
1753
+ return true; // always render effects
1754
+ }
1755
+ }
1756
+ /**
1757
+ * Cache a rendered layer as ImageData.
1758
+ */
1759
+ export function cacheLayer(cache, ctx, layer, x, y, w, h, currentFrame) {
1760
+ if (layer === 'background') {
1761
+ cache.backgroundLayer = ctx.getImageData(x, y, w, h);
1762
+ cache.lastBackgroundFrame = currentFrame;
1763
+ }
1764
+ else {
1765
+ cache.bodyLayer = ctx.getImageData(x, y, w, h);
1766
+ cache.lastBodyFrame = currentFrame;
1767
+ }
1768
+ }
1769
+ /**
1770
+ * Draw a cached layer onto the canvas.
1771
+ */
1772
+ export function drawCachedLayer(ctx, cache, layer) {
1773
+ if (layer === 'background' && cache.backgroundLayer) {
1774
+ ctx.putImageData(cache.backgroundLayer, 0, 0);
1775
+ }
1776
+ else if (layer === 'body' && cache.bodyLayer) {
1777
+ ctx.putImageData(cache.bodyLayer, 0, 0);
1778
+ }
1779
+ }
1780
+ // ─── 11. VOLUMETRIC FOG ─────────────────────────────────────────
1781
+ /**
1782
+ * Get fog density for a given biome.
1783
+ */
1784
+ function getFogDensityForBiome(biome) {
1785
+ switch (biome) {
1786
+ case 'ocean': return 0.4;
1787
+ case 'lava': return 0.3;
1788
+ case 'grass': return 0.1;
1789
+ case 'space': return 0.0;
1790
+ case 'city': return 0.2;
1791
+ default: return 0.15;
1792
+ }
1793
+ }
1794
+ /**
1795
+ * Render volumetric fog with drifting horizontal bands.
1796
+ * Fog is thinner near light sources and varies by biome.
1797
+ */
1798
+ export function renderVolumetricFog(ctx, width, height, frame, fogDensity, fogColor, lightSources) {
1799
+ if (fogDensity <= 0.01)
1800
+ return;
1801
+ const [fr, fg, fb] = hexToRgb(fogColor);
1802
+ const bandCount = 5;
1803
+ const bandYBase = [200, 300, 380, 430, 460]; // vertical positions
1804
+ const bandHeight = [60, 80, 70, 50, 40];
1805
+ ctx.save();
1806
+ for (let i = 0; i < bandCount; i++) {
1807
+ const drift = Math.sin(frame * 0.02 + i * 1.7) * 20;
1808
+ const bandY = bandYBase[i];
1809
+ const bh = bandHeight[i];
1810
+ // Create horizontal gradient for this band (denser in middle)
1811
+ const grad = ctx.createLinearGradient(0, bandY, 0, bandY + bh);
1812
+ // Calculate light reduction at this band's center
1813
+ let lightReduction = 0;
1814
+ const bandCenterY = bandY + bh / 2;
1815
+ for (const light of lightSources) {
1816
+ const dx = (width / 2) - light.x;
1817
+ const dy = bandCenterY - light.y;
1818
+ const dist = Math.sqrt(dx * dx + dy * dy);
1819
+ if (dist < 200) {
1820
+ lightReduction += light.intensity * (1 - dist / 200) * 0.5;
1821
+ }
1822
+ }
1823
+ const effectiveDensity = Math.max(0, fogDensity - lightReduction);
1824
+ if (effectiveDensity < 0.01)
1825
+ continue;
1826
+ const alpha = effectiveDensity * 0.15;
1827
+ grad.addColorStop(0, `rgba(${fr},${fg},${fb},0)`);
1828
+ grad.addColorStop(0.3, `rgba(${fr},${fg},${fb},${alpha})`);
1829
+ grad.addColorStop(0.5, `rgba(${fr},${fg},${fb},${alpha * 1.2})`);
1830
+ grad.addColorStop(0.7, `rgba(${fr},${fg},${fb},${alpha})`);
1831
+ grad.addColorStop(1, `rgba(${fr},${fg},${fb},0)`);
1832
+ ctx.fillStyle = grad;
1833
+ ctx.fillRect(drift, bandY, width - drift, bh);
1834
+ // Wrap-around for drift
1835
+ if (drift > 0) {
1836
+ ctx.fillRect(0, bandY, drift, bh);
1837
+ }
1838
+ }
1839
+ ctx.restore();
1840
+ }
1841
+ /**
1842
+ * Get fog parameters for the current world state.
1843
+ */
1844
+ export function getFogParams(biome, timeOfDay) {
1845
+ const baseDensity = getFogDensityForBiome(biome);
1846
+ // Increase fog at night and dawn
1847
+ let timeMod = 1.0;
1848
+ if (timeOfDay === 'night')
1849
+ timeMod = 1.3;
1850
+ else if (timeOfDay === 'dawn')
1851
+ timeMod = 1.2;
1852
+ else if (timeOfDay === 'day')
1853
+ timeMod = 0.7;
1854
+ // Fog color varies by biome
1855
+ let fogColor = '#8899aa'; // default grey-blue
1856
+ if (biome === 'lava')
1857
+ fogColor = '#332211'; // smoky brown
1858
+ else if (biome === 'ocean')
1859
+ fogColor = '#667788'; // sea mist
1860
+ else if (biome === 'city')
1861
+ fogColor = '#556677'; // smog
1862
+ else if (biome === 'space')
1863
+ fogColor = '#111122';
1864
+ return {
1865
+ density: Math.min(1, baseDensity * timeMod),
1866
+ color: fogColor,
1867
+ };
1868
+ }
1869
+ // ─── 12. PALETTE CYCLING (NEURAL TEXTURE COMPRESSION) ───────────
1870
+ /**
1871
+ * Convert hex color to HSL.
1872
+ */
1873
+ function hexToHsl(hex) {
1874
+ const [r, g, b] = hexToRgb(hex);
1875
+ const rf = r / 255, gf = g / 255, bf = b / 255;
1876
+ const max = Math.max(rf, gf, bf), min = Math.min(rf, gf, bf);
1877
+ let h = 0, s = 0;
1878
+ const l = (max + min) / 2;
1879
+ if (max !== min) {
1880
+ const d = max - min;
1881
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
1882
+ if (max === rf)
1883
+ h = ((gf - bf) / d + (gf < bf ? 6 : 0)) * 60;
1884
+ else if (max === gf)
1885
+ h = ((bf - rf) / d + 2) * 60;
1886
+ else
1887
+ h = ((rf - gf) / d + 4) * 60;
1888
+ }
1889
+ return [h, s * 100, l * 100];
1890
+ }
1891
+ /**
1892
+ * Convert HSL to hex color string.
1893
+ */
1894
+ function hslToHex(h, s, l) {
1895
+ s /= 100;
1896
+ l /= 100;
1897
+ const c = (1 - Math.abs(2 * l - 1)) * s;
1898
+ const x = c * (1 - Math.abs((h / 60) % 2 - 1));
1899
+ const m = l - c / 2;
1900
+ let r = 0, g = 0, b = 0;
1901
+ if (h < 60) {
1902
+ r = c;
1903
+ g = x;
1904
+ b = 0;
1905
+ }
1906
+ else if (h < 120) {
1907
+ r = x;
1908
+ g = c;
1909
+ b = 0;
1910
+ }
1911
+ else if (h < 180) {
1912
+ r = 0;
1913
+ g = c;
1914
+ b = x;
1915
+ }
1916
+ else if (h < 240) {
1917
+ r = 0;
1918
+ g = x;
1919
+ b = c;
1920
+ }
1921
+ else if (h < 300) {
1922
+ r = x;
1923
+ g = 0;
1924
+ b = c;
1925
+ }
1926
+ else {
1927
+ r = c;
1928
+ g = 0;
1929
+ b = x;
1930
+ }
1931
+ const toHex = (v) => Math.round((v + m) * 255).toString(16).padStart(2, '0');
1932
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
1933
+ }
1934
+ /**
1935
+ * Cycle palette colors subtly based on frame, mood, and time of day.
1936
+ * Creates a living, shimmering effect using HSL-based color shifting.
1937
+ */
1938
+ export function cyclePalette(basePalette, frame, mood, timeOfDay) {
1939
+ const cycled = {};
1940
+ let colorIndex = 0;
1941
+ for (const [key, hex] of Object.entries(basePalette)) {
1942
+ const [h, s, l] = hexToHsl(hex);
1943
+ // Subtle hue shimmer
1944
+ let newH = h + Math.sin(frame * 0.01 + colorIndex * 0.5) * 5;
1945
+ if (newH < 0)
1946
+ newH += 360;
1947
+ if (newH >= 360)
1948
+ newH -= 360;
1949
+ // Saturation boost for excited/dancing moods
1950
+ let newS = s;
1951
+ if (mood === 'excited' || mood === 'dancing') {
1952
+ newS = Math.min(100, s + 10);
1953
+ }
1954
+ // Lightness modulation by time of day
1955
+ let newL = l;
1956
+ if (timeOfDay === 'night')
1957
+ newL = Math.max(0, l - 10);
1958
+ else if (timeOfDay === 'day')
1959
+ newL = Math.min(100, l + 5);
1960
+ else if (timeOfDay === 'sunset') {
1961
+ // Warm hue shift for sunset
1962
+ newH = (newH + 15) % 360;
1963
+ }
1964
+ cycled[key] = hslToHex(newH, Math.max(0, Math.min(100, newS)), Math.max(0, Math.min(100, newL)));
1965
+ colorIndex++;
1966
+ }
1967
+ return cycled;
1968
+ }
1969
+ /**
1970
+ * Compute animation parameters based on stream context.
1971
+ * Higher engagement = faster, more energetic animations.
1972
+ */
1973
+ export function computeAnimationParams(chatRate, // messages per minute
1974
+ viewerCount, // estimated viewers
1975
+ mood, timeOfDay, streamDuration) {
1976
+ // Base energy from chat rate (0-10 msgs/min maps to 0.2-0.8)
1977
+ let energy = Math.min(0.8, 0.2 + chatRate * 0.06);
1978
+ // Viewer count boost (subtle)
1979
+ energy += Math.min(0.1, viewerCount * 0.005);
1980
+ // Mood modifiers
1981
+ if (mood === 'excited' || mood === 'dancing')
1982
+ energy = Math.min(1, energy + 0.2);
1983
+ else if (mood === 'dreaming')
1984
+ energy = 0.1;
1985
+ else if (mood === 'thinking')
1986
+ energy = Math.max(0.3, energy * 0.7);
1987
+ else if (mood === 'error')
1988
+ energy = Math.min(1, energy + 0.15);
1989
+ // Time of day
1990
+ if (timeOfDay === 'night')
1991
+ energy *= 0.75;
1992
+ else if (timeOfDay === 'dawn')
1993
+ energy *= 0.85;
1994
+ // Stream fatigue — slight decrease over time (max 20% reduction after 3 hours)
1995
+ const fatigueFactor = Math.max(0.8, 1 - (streamDuration / 180) * 0.2);
1996
+ energy *= fatigueFactor;
1997
+ // Clamp
1998
+ energy = Math.max(0.05, Math.min(1, energy));
1999
+ return {
2000
+ blinkRate: 0.1 + energy * 0.3,
2001
+ wobbleFreq: 0.02 + energy * 0.08,
2002
+ wobbleAmp: 1 + energy * 4,
2003
+ glowPulseSpeed: 0.05 + energy * 0.15,
2004
+ breathSpeed: 0.03 + energy * 0.07,
2005
+ energyLevel: energy,
2006
+ };
2007
+ }
1361
2008
  //# sourceMappingURL=render-engine.js.map