feat(labs): visual polish wave — LabFX foundation + 33 sims juiced up
ФУНДАМЕНТ (4 новых файла): - _fx_core.js: LabFX namespace, glow.drawGlow, glow.pulse, haptic, shake - _fx_particles.js: пул 1500 объектов, 6 shapes (dot/spark/ring/smoke/splash/dust) - _fx_motion.js: tween + 12 easings + critically-damped spring - _fx_sound.js: 9 procedural synth-звуков (click/tick/whoosh/chime/fizz/spark/bounce/pour/drone), Web Audio API - Sound toggle в шапке lab.html с localStorage-persist UX МИКРО (CSS + JS): - Button states: hover scale+brightness, active scale-down, disabled grayscale - Slider polish: custom thumb с тенью, filled-track gradient, hover/active - Focus rings через :focus-visible - Tooltip system .tt-host data-tt= с 400ms hover, fade-in - Marching ants для selection - Loading skeleton с shimmer - Empty state .sim-empty-* паттерн - Toast: progress bar внизу, icons по типу - Cursor states utility classes - View Transitions API для smooth sim-switch, fallback на CSS fade PHASE 2 — визуальные эффекты для 33 симуляций: Physics motion: projectile (launch whoosh + landing splash/shake/haptic + target chime), pendulum (max-extension tick + bob glow), collision (bounce + sparks + shake), angrybirds (whoosh/bounce/fizz/chime + confetti), newton (rocket flame trail + scene transitions), forcesandbox (spring glow + impact sparks) Physics fields: emfield (field-lines glow + particle trail + lightning при high field + Gauss-drag haptic + rod motion sparks), circuit (energized-wire glow ∝ current + LED bloom + short-circuit shake/spark + heat shimmer smoke + place/erase/switch sounds), opticsbench (beam glow на всех режимах + caustics dust у фокуса + TIR/Brewster one-shot sounds) Thermo+waves: waves (mode-switch whoosh + Mach-cone particles + spectrum harmonic chime + waveform glow), hydrostatics (pour sound + splash при погружении + valve click), isoprocess (PV-trail dust), heatengine (drone цикла + hot/cold reservoir smoke/dust + phase-change ticks), radioactive (Geiger tick throttle + decay sparks + half-life chime + α/β/γ glow) Chemistry: chemsandbox (pour splash + fizz bubbles/dust/spark по типу реакции + shake при горении), equilibrium/electrolysis/reactions/titration/flask/ionexchange/redox (pour/fizz/spark/chime по событиям, частицы при ключевых моментах), stoichiometry (fizz bubbles + recipe-change click) Math+geom: geometry (tool-click + object-create tick + locus glow + challenge confetti via LabFX + haptic), triangle (vertex-drop tick + special-point glow), stereo (figure-change whoosh + cross-section chime, no particles — Three.js), trigcircle (drag-haptic + pitch∝angle tick + sin/cos glow), graph/graphtransform/quadratic (slider-tick throttled + curve glow + discriminant-cross chime), probability (bounce + finish-chime burst), normaldist (pulsing shade + bell glow) Bio+misc: celldivision (phase-change whoosh + prophase dust + anaphase poles spark + cytokinesis chime), photosynthesis (photon tick + ATP chime + glucose sparkle), bohratom (electron-jump chime ∝ levels + photon spark), orbitals/crystal (mode-change whoosh — Three.js, sound only), logic (gate-place + wire-connect + LED bloom + HIGH wire flowing dots + preset chime), gas/brownian/diffusion/states (preset whoosh, throttled tick по событиям) Все LabFX вызовы обёрнуты в if (window.LabFX) guard — graceful degradation если фундамент не загружен. 47 JS файлов прошли syntax check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1111,3 +1111,369 @@
|
||||
background: rgba(155,93,229,0.18);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
UX MICRO-INTERACTIONS — appended block
|
||||
All additions are scoped to lab.html UI; individual sim JS files untouched.
|
||||
════════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── 1. BUTTON STATES ────────────────────────────────────────────────────────
|
||||
Snappy hover/active/disabled for all interactive controls in the lab shell.
|
||||
Uses cubic-bezier(0.16,1,0.3,1) — fast-out-slow-in for a spring-like feel.
|
||||
─────────────────────────────────────────────────────────────────────────── */
|
||||
.zoom-btn,
|
||||
.proj-preset-chip,
|
||||
.geo-tool-btn,
|
||||
.sb-tool-btn {
|
||||
transition: transform .12s cubic-bezier(0.16,1,0.3,1),
|
||||
filter .12s cubic-bezier(0.16,1,0.3,1),
|
||||
border-color .12s, color .12s, background .12s,
|
||||
box-shadow .12s;
|
||||
}
|
||||
.zoom-btn:hover,
|
||||
.proj-preset-chip:hover,
|
||||
.geo-tool-btn:hover,
|
||||
.sb-tool-btn:hover {
|
||||
transform: scale(1.025);
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.zoom-btn:active,
|
||||
.proj-preset-chip:active,
|
||||
.geo-tool-btn:active,
|
||||
.sb-tool-btn:active {
|
||||
transform: scale(0.96);
|
||||
filter: brightness(0.92);
|
||||
}
|
||||
.zoom-btn:disabled,
|
||||
.proj-preset-chip:disabled,
|
||||
.geo-tool-btn:disabled,
|
||||
.sb-tool-btn:disabled,
|
||||
.zoom-btn[disabled],
|
||||
.proj-preset-chip[disabled],
|
||||
.geo-tool-btn[disabled],
|
||||
.sb-tool-btn[disabled] {
|
||||
opacity: .45;
|
||||
filter: grayscale(.6);
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* .sim-card already has transition; add scale micro-interaction */
|
||||
.sim-card:not(.soon):hover { transform: translateY(-2px) scale(1.012); }
|
||||
.sim-card:not(.soon):active { transform: scale(0.985); filter: brightness(0.97); }
|
||||
|
||||
/* ── 2. SLIDER POLISH ────────────────────────────────────────────────────────
|
||||
Custom track + thumb for all range inputs inside .proj-panel.
|
||||
Track: 4px, filled-gradient from left to thumb position via background-size trick.
|
||||
Thumb: 16px circle, cyan/violet, shadow, scale on hover/active.
|
||||
─────────────────────────────────────────────────────────────────────────── */
|
||||
.proj-panel input[type=range],
|
||||
.param-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 4px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--violet) 0%,
|
||||
var(--violet) var(--sl-pct, 50%),
|
||||
rgba(255,255,255,.15) var(--sl-pct, 50%),
|
||||
rgba(255,255,255,.15) 100%
|
||||
);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: background .12s;
|
||||
}
|
||||
.proj-panel input[type=range]::-webkit-slider-thumb,
|
||||
.param-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 16px; height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--violet);
|
||||
box-shadow: 0 0 0 3px rgba(155,93,229,.25), 0 2px 6px rgba(0,0,0,.35);
|
||||
cursor: pointer;
|
||||
transition: transform .12s cubic-bezier(0.16,1,0.3,1),
|
||||
box-shadow .12s;
|
||||
}
|
||||
.proj-panel input[type=range]:hover::-webkit-slider-thumb,
|
||||
.param-slider:hover::-webkit-slider-thumb {
|
||||
transform: scale(1.15);
|
||||
box-shadow: 0 0 0 4px rgba(155,93,229,.3), 0 3px 8px rgba(0,0,0,.4);
|
||||
}
|
||||
.proj-panel input[type=range]:active::-webkit-slider-thumb,
|
||||
.param-slider:active::-webkit-slider-thumb {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
.proj-panel input[type=range]::-moz-range-thumb,
|
||||
.param-slider::-moz-range-thumb {
|
||||
width: 16px; height: 16px;
|
||||
border-radius: 50%; border: none;
|
||||
background: var(--violet);
|
||||
box-shadow: 0 0 0 3px rgba(155,93,229,.25), 0 2px 6px rgba(0,0,0,.35);
|
||||
cursor: pointer;
|
||||
transition: transform .12s cubic-bezier(0.16,1,0.3,1);
|
||||
}
|
||||
.proj-panel input[type=range]:hover::-moz-range-thumb,
|
||||
.param-slider:hover::-moz-range-thumb { transform: scale(1.15); }
|
||||
.proj-panel input[type=range]:active::-moz-range-thumb,
|
||||
.param-slider:active::-moz-range-thumb { transform: scale(0.95); }
|
||||
|
||||
/* ── 3. FOCUS RINGS (accessibility) ─────────────────────────────────────────
|
||||
:focus-visible only — mouse clicks don't show the ring.
|
||||
─────────────────────────────────────────────────────────────────────────── */
|
||||
.zoom-btn:focus-visible,
|
||||
.proj-preset-chip:focus-visible,
|
||||
.geo-tool-btn:focus-visible,
|
||||
.sb-tool-btn:focus-visible,
|
||||
.sim-card:focus-visible,
|
||||
.lab-filter:focus-visible,
|
||||
.gp-btn:focus-visible,
|
||||
.sim-back:focus-visible,
|
||||
.proj-launch-btn:focus-visible,
|
||||
.proj-reset-btn:focus-visible,
|
||||
.param-slider:focus-visible,
|
||||
.proj-panel input[type=range]:focus-visible {
|
||||
outline: 2px solid var(--violet);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── 4. TOOLTIP SYSTEM (CSS-only, zero JS) ───────────────────────────────────
|
||||
Usage: add class="tt-host" data-tt="Your tooltip text" to any element.
|
||||
Tooltip appears above the element after a 400ms delay on hover.
|
||||
Arrow points downward. Dark overlay with white text, 8px radius.
|
||||
─────────────────────────────────────────────────────────────────────────── */
|
||||
.tt-host {
|
||||
position: relative;
|
||||
}
|
||||
.tt-host::after {
|
||||
content: attr(data-tt);
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(4px);
|
||||
white-space: nowrap;
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
background: rgba(20,20,30,.95);
|
||||
color: #fff;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255,255,255,.1);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,.4);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity .15s ease, transform .15s ease;
|
||||
transition-delay: 0s;
|
||||
z-index: 9000;
|
||||
}
|
||||
.tt-host:hover::after {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
transition-delay: 400ms;
|
||||
}
|
||||
/* arrow */
|
||||
.tt-host::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: calc(100% + 3px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(4px);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: rgba(20,20,30,.95);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity .15s ease, transform .15s ease;
|
||||
transition-delay: 0s;
|
||||
z-index: 9001;
|
||||
}
|
||||
.tt-host:hover::before {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
transition-delay: 400ms;
|
||||
}
|
||||
|
||||
/* ── 5. SIM FADE TRANSITION ──────────────────────────────────────────────────
|
||||
.sim-fading is toggled by lab-glue.js around openSim() calls.
|
||||
The sim body wrap fades out (150ms) then new sim fades in (200ms).
|
||||
─────────────────────────────────────────────────────────────────────────── */
|
||||
#lab-sim {
|
||||
transition: opacity .2s ease;
|
||||
}
|
||||
#lab-sim.sim-fading {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── 6. MARCHING ANTS SELECTION ──────────────────────────────────────────────
|
||||
Opt-in: apply class="sim-selected-ants" to any SVG stroke element.
|
||||
Downstream sims (geometry, circuit, logic) can use this.
|
||||
─────────────────────────────────────────────────────────────────────────── */
|
||||
@keyframes ant-march {
|
||||
to { stroke-dashoffset: -28; }
|
||||
}
|
||||
.sim-selected-ants {
|
||||
stroke-dasharray: 4 3;
|
||||
animation: ant-march 800ms linear infinite;
|
||||
}
|
||||
|
||||
/* ── 7. LOADING SKELETON ─────────────────────────────────────────────────────
|
||||
Opt-in: apply .sim-loading-skel to any container while a sim initialises.
|
||||
Shimmer moves left-to-right. Remove the class when the sim is ready.
|
||||
─────────────────────────────────────────────────────────────────────────── */
|
||||
@keyframes skel-shimmer {
|
||||
0% { background-position: -400px 0; }
|
||||
100% { background-position: 400px 0; }
|
||||
}
|
||||
.sim-loading-skel {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255,255,255,.04) 25%,
|
||||
rgba(255,255,255,.10) 50%,
|
||||
rgba(255,255,255,.04) 75%
|
||||
);
|
||||
background-size: 800px 100%;
|
||||
animation: skel-shimmer 1.4s ease-in-out infinite;
|
||||
border-radius: 8px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
/* ── 8. EMPTY STATE PATTERN ──────────────────────────────────────────────────
|
||||
Usage:
|
||||
<div class="sim-empty-state">
|
||||
<svg class="sim-empty-icon" ...>...</svg>
|
||||
<div class="sim-empty-title">Title</div>
|
||||
<div class="sim-empty-desc">Description text</div>
|
||||
<button class="sim-empty-cta">Action</button> <!-- optional -->
|
||||
</div>
|
||||
─────────────────────────────────────────────────────────────────────────── */
|
||||
.sim-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
color: var(--text-3);
|
||||
opacity: .72;
|
||||
}
|
||||
.sim-empty-icon {
|
||||
width: 48px; height: 48px;
|
||||
stroke: var(--text-3);
|
||||
stroke-width: 1.4;
|
||||
fill: none;
|
||||
opacity: .6;
|
||||
display: block;
|
||||
}
|
||||
.sim-empty-title {
|
||||
font-family: 'Unbounded', sans-serif;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.sim-empty-desc {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
max-width: 280px;
|
||||
}
|
||||
.sim-empty-cta {
|
||||
margin-top: 4px;
|
||||
padding: 8px 20px;
|
||||
border-radius: 99px;
|
||||
border: 1.5px solid var(--violet);
|
||||
background: transparent;
|
||||
color: var(--violet);
|
||||
font-family: 'Manrope', sans-serif;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.sim-empty-cta:hover {
|
||||
background: var(--violet);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── 9. TOAST UPGRADE — progress bar ────────────────────────────────────────
|
||||
The existing lsToast already slides in from right and stacks.
|
||||
This adds a thin auto-dismiss progress bar at the bottom of each toast.
|
||||
JS in lab-glue.js injects .ls-toast-progress with --toast-dur CSS var.
|
||||
─────────────────────────────────────────────────────────────────────────── */
|
||||
@keyframes toast-progress {
|
||||
from { transform: scaleX(1); }
|
||||
to { transform: scaleX(0); }
|
||||
}
|
||||
.ls-toast {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ls-toast-bar {
|
||||
position: absolute;
|
||||
bottom: 0; left: 0;
|
||||
height: 3px;
|
||||
width: 100%;
|
||||
background: rgba(255,255,255,.45);
|
||||
transform-origin: left center;
|
||||
animation: toast-progress var(--toast-dur, 3.5s) linear forwards;
|
||||
border-radius: 0 0 14px 14px;
|
||||
}
|
||||
|
||||
/* ── 10. CURSOR STATES ───────────────────────────────────────────────────────
|
||||
Default canvas cursor for projector-style sims.
|
||||
Sims can toggle .cur-* classes on .proj-canvas-outer or canvas itself.
|
||||
─────────────────────────────────────────────────────────────────────────── */
|
||||
.proj-canvas-outer canvas { cursor: crosshair; }
|
||||
|
||||
.cur-grab { cursor: grab !important; }
|
||||
.cur-grabbing { cursor: grabbing !important; }
|
||||
.cur-cell { cursor: cell !important; }
|
||||
.cur-pointer { cursor: pointer !important; }
|
||||
.cur-cross { cursor: crosshair !important; }
|
||||
.cur-move { cursor: move !important; }
|
||||
|
||||
/* Draggable canvas elements within sims */
|
||||
[draggable="true"] canvas,
|
||||
canvas[data-draggable] { cursor: grab; }
|
||||
[draggable="true"] canvas:active,
|
||||
canvas[data-draggable]:active { cursor: grabbing; }
|
||||
|
||||
/* ── 11. VIEW TRANSITIONS ────────────────────────────────────────────────────
|
||||
If browser supports View Transitions API, openSim is wrapped in
|
||||
document.startViewTransition() in lab-glue.js (see below comment).
|
||||
::view-transition-* rules control the cross-fade animation.
|
||||
Fallback: manual .sim-fading class (rule 5 above).
|
||||
─────────────────────────────────────────────────────────────────────────── */
|
||||
@supports (view-transition-name: none) {
|
||||
@view-transition { navigation: none; }
|
||||
|
||||
::view-transition-old(sim-view),
|
||||
::view-transition-new(sim-view) {
|
||||
animation-duration: 200ms;
|
||||
animation-timing-function: ease;
|
||||
}
|
||||
::view-transition-old(sim-view) {
|
||||
animation-name: sim-vt-out;
|
||||
}
|
||||
::view-transition-new(sim-view) {
|
||||
animation-name: sim-vt-in;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sim-vt-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
@keyframes sim-vt-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Assign view-transition-name so only the sim area morphs */
|
||||
#lab-sim {
|
||||
view-transition-name: sim-view;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
'use strict';
|
||||
(function(global) {
|
||||
|
||||
/* ── namespace (cooperative init) ── */
|
||||
global.LabFX = global.LabFX || {};
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
GLOW — Canvas 2D bloom helper
|
||||
───────────────────────────────────────────── */
|
||||
global.LabFX.glow = {
|
||||
/**
|
||||
* Wraps drawFn with shadowBlur to create a soft glow.
|
||||
* For layers > 1 repeats drawFn with increasing intensity.
|
||||
*/
|
||||
drawGlow: function(ctx, drawFn, opts) {
|
||||
opts = opts || {};
|
||||
var color = opts.color || '#fff';
|
||||
var intensity = opts.intensity != null ? opts.intensity : 12;
|
||||
var layers = opts.layers || 1;
|
||||
|
||||
ctx.save();
|
||||
ctx.shadowColor = color;
|
||||
for (var i = 0; i < layers; i++) {
|
||||
ctx.shadowBlur = intensity * (i + 1);
|
||||
drawFn(ctx);
|
||||
}
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns a 0..1 value that pulses over time.
|
||||
* @param {number} t - current timestamp (ms), e.g. performance.now()
|
||||
* @param {number} period - full period in ms (default 1000)
|
||||
*/
|
||||
pulse: function(t, period) {
|
||||
period = period || 1000;
|
||||
return (Math.sin((t / period) * Math.PI * 2) + 1) / 2;
|
||||
}
|
||||
};
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
HAPTIC — mobile vibration
|
||||
───────────────────────────────────────────── */
|
||||
/**
|
||||
* @param {number|Array<number>} pattern - ms duration or [on,off,on,…]
|
||||
*/
|
||||
global.LabFX.haptic = function(pattern) {
|
||||
if (!navigator.vibrate) return;
|
||||
navigator.vibrate(pattern);
|
||||
};
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
SHAKE — animate translate on a DOM element
|
||||
───────────────────────────────────────────── */
|
||||
/**
|
||||
* @param {Element} elementOrCanvas
|
||||
* @param {object} opts
|
||||
* @param {number} opts.intensity - max translate px (default 5)
|
||||
* @param {number} opts.durMs - duration ms (default 200)
|
||||
*/
|
||||
global.LabFX.shake = function(elementOrCanvas, opts) {
|
||||
opts = opts || {};
|
||||
var intensity = opts.intensity != null ? opts.intensity : 5;
|
||||
var durMs = opts.durMs != null ? opts.durMs : 200;
|
||||
|
||||
var el = elementOrCanvas;
|
||||
var start = performance.now();
|
||||
var origTransform = el.style.transform || '';
|
||||
|
||||
function step(now) {
|
||||
var elapsed = now - start;
|
||||
var progress = Math.min(elapsed / durMs, 1);
|
||||
var decay = 1 - progress;
|
||||
var mag = intensity * decay;
|
||||
var dx = (Math.random() * 2 - 1) * mag;
|
||||
var dy = (Math.random() * 2 - 1) * mag;
|
||||
|
||||
el.style.transform = origTransform + ' translate(' + dx.toFixed(1) + 'px,' + dy.toFixed(1) + 'px)';
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(step);
|
||||
} else {
|
||||
el.style.transform = origTransform;
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
})(window);
|
||||
@@ -0,0 +1,153 @@
|
||||
'use strict';
|
||||
(function(global) {
|
||||
|
||||
global.LabFX = global.LabFX || {};
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
EASINGS — standard Penner equations
|
||||
───────────────────────────────────────────── */
|
||||
var easings = {
|
||||
linear: function(t) { return t; },
|
||||
|
||||
easeInQuad: function(t) { return t * t; },
|
||||
easeOutQuad: function(t) { return t * (2 - t); },
|
||||
easeInOutQuad: function(t) { return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; },
|
||||
|
||||
easeInCubic: function(t) { return t * t * t; },
|
||||
easeOutCubic: function(t) { var u = t - 1; return u * u * u + 1; },
|
||||
easeInOutCubic: function(t) {
|
||||
return t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1;
|
||||
},
|
||||
|
||||
easeInOutQuint: function(t) {
|
||||
return t < 0.5 ? 16 * t * t * t * t * t : 1 + 16 * (--t) * t * t * t * t;
|
||||
},
|
||||
|
||||
easeOutBack: function(t) {
|
||||
var c1 = 1.70158;
|
||||
var c3 = c1 + 1;
|
||||
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
|
||||
},
|
||||
|
||||
easeOutElastic: function(t) {
|
||||
if (t === 0 || t === 1) return t;
|
||||
var c4 = (2 * Math.PI) / 3;
|
||||
return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
|
||||
},
|
||||
|
||||
easeInExpo: function(t) { return t === 0 ? 0 : Math.pow(2, 10 * t - 10); },
|
||||
easeOutExpo: function(t) { return t === 1 ? 1 : 1 - Math.pow(2, -10 * t); }
|
||||
};
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
TWEEN
|
||||
───────────────────────────────────────────── */
|
||||
/**
|
||||
* Tween a numeric value from `from` to `to` over `durMs` milliseconds.
|
||||
*
|
||||
* @param {number} from
|
||||
* @param {number} to
|
||||
* @param {number} durMs
|
||||
* @param {function|string} easing - easing fn or key in LabFX.motion.easings
|
||||
* @param {function} onUpdate - called with current value each frame
|
||||
* @param {function} [onDone]
|
||||
* @returns {{ cancel: function, running: boolean }}
|
||||
*/
|
||||
function tween(from, to, durMs, easing, onUpdate, onDone) {
|
||||
var easeFn = (typeof easing === 'function')
|
||||
? easing
|
||||
: (easings[easing] || easings.linear);
|
||||
|
||||
var start = null;
|
||||
var handle = { running: true, cancel: null };
|
||||
var rafId;
|
||||
|
||||
function step(now) {
|
||||
if (!handle.running) return;
|
||||
if (start === null) start = now;
|
||||
var elapsed = now - start;
|
||||
var progress = Math.min(elapsed / durMs, 1);
|
||||
var value = from + (to - from) * easeFn(progress);
|
||||
|
||||
onUpdate(value);
|
||||
|
||||
if (progress < 1) {
|
||||
rafId = requestAnimationFrame(step);
|
||||
} else {
|
||||
handle.running = false;
|
||||
if (typeof onDone === 'function') onDone();
|
||||
}
|
||||
}
|
||||
|
||||
handle.cancel = function() {
|
||||
handle.running = false;
|
||||
cancelAnimationFrame(rafId);
|
||||
};
|
||||
|
||||
rafId = requestAnimationFrame(step);
|
||||
return handle;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
SPRING (critically-damped simulation)
|
||||
───────────────────────────────────────────── */
|
||||
/**
|
||||
* Returns a spring factory.
|
||||
* Usage: LabFX.motion.spring(170, 26)(from, to, onUpdate, onDone) → handle
|
||||
*
|
||||
* @param {number} stiffness (default 170)
|
||||
* @param {number} damping (default 26)
|
||||
*/
|
||||
function springFactory(stiffness, damping) {
|
||||
stiffness = stiffness != null ? stiffness : 170;
|
||||
damping = damping != null ? damping : 26;
|
||||
|
||||
return function(from, to, onUpdate, onDone) {
|
||||
var pos = from;
|
||||
var vel = 0;
|
||||
var prev = null;
|
||||
var handle = { running: true, cancel: null };
|
||||
var rafId;
|
||||
|
||||
function step(now) {
|
||||
if (!handle.running) return;
|
||||
if (prev === null) { prev = now; }
|
||||
var dt = Math.min((now - prev) / 1000, 0.05); /* cap at 50ms */
|
||||
prev = now;
|
||||
|
||||
var force = -stiffness * (pos - to) - damping * vel;
|
||||
vel += force * dt;
|
||||
pos += vel * dt;
|
||||
|
||||
onUpdate(pos);
|
||||
|
||||
var settled = Math.abs(pos - to) < 0.01 && Math.abs(vel) < 0.01;
|
||||
if (!settled) {
|
||||
rafId = requestAnimationFrame(step);
|
||||
} else {
|
||||
onUpdate(to);
|
||||
handle.running = false;
|
||||
if (typeof onDone === 'function') onDone();
|
||||
}
|
||||
}
|
||||
|
||||
handle.cancel = function() {
|
||||
handle.running = false;
|
||||
cancelAnimationFrame(rafId);
|
||||
};
|
||||
|
||||
rafId = requestAnimationFrame(step);
|
||||
return handle;
|
||||
};
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
EXPORT
|
||||
───────────────────────────────────────────── */
|
||||
global.LabFX.motion = {
|
||||
tween: tween,
|
||||
spring: springFactory,
|
||||
easings: easings
|
||||
};
|
||||
|
||||
})(window);
|
||||
@@ -0,0 +1,225 @@
|
||||
'use strict';
|
||||
(function(global) {
|
||||
|
||||
global.LabFX = global.LabFX || {};
|
||||
|
||||
/* ── pool ── */
|
||||
var POOL_SIZE = 1500;
|
||||
var pool = [];
|
||||
|
||||
(function buildPool() {
|
||||
for (var i = 0; i < POOL_SIZE; i++) {
|
||||
pool.push({
|
||||
alive: false,
|
||||
ctx: null,
|
||||
x: 0, y: 0,
|
||||
vx: 0, vy: 0,
|
||||
ax: 0, ay: 0,
|
||||
life: 1000,
|
||||
age: 0,
|
||||
color: '#fff',
|
||||
size: 3,
|
||||
baseSize: 3,
|
||||
shape: 'dot',
|
||||
glow: false,
|
||||
fade: true,
|
||||
sizeFade: true,
|
||||
/* spark: previous position for motion-blur line */
|
||||
px: 0, py: 0
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
/* grab a dead particle from pool; returns null if pool exhausted */
|
||||
function acquire() {
|
||||
for (var i = 0; i < POOL_SIZE; i++) {
|
||||
if (!pool[i].alive) return pool[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ── helpers ── */
|
||||
function pickColor(color) {
|
||||
if (Array.isArray(color)) {
|
||||
return color[Math.floor(Math.random() * color.length)];
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
PUBLIC API
|
||||
───────────────────────────────────────────── */
|
||||
global.LabFX.particles = {
|
||||
|
||||
/**
|
||||
* Spawn N particles at canvas coords (x, y).
|
||||
*/
|
||||
emit: function(opts) {
|
||||
opts = opts || {};
|
||||
var ctx = opts.ctx;
|
||||
var x = opts.x || 0;
|
||||
var y = opts.y || 0;
|
||||
var count = opts.count != null ? opts.count : 10;
|
||||
var color = opts.color != null ? opts.color : '#fff';
|
||||
var speed = opts.speed != null ? opts.speed : 60;
|
||||
var spread = opts.spread != null ? opts.spread : Math.PI * 2;
|
||||
var angle = opts.angle != null ? opts.angle : 0;
|
||||
var gravity = opts.gravity != null ? opts.gravity : 0;
|
||||
var life = opts.life != null ? opts.life : 1000;
|
||||
var fade = opts.fade != null ? opts.fade : true;
|
||||
var glow = opts.glow != null ? opts.glow : false;
|
||||
var shape = opts.shape != null ? opts.shape : 'dot';
|
||||
var size = opts.size != null ? opts.size : 3;
|
||||
var sizeFade = opts.sizeFade != null ? opts.sizeFade : true;
|
||||
|
||||
for (var i = 0; i < count; i++) {
|
||||
var p = acquire();
|
||||
if (!p) break;
|
||||
|
||||
var dir = angle + (Math.random() - 0.5) * spread;
|
||||
var spd = speed * (0.5 + Math.random() * 0.5);
|
||||
|
||||
p.alive = true;
|
||||
p.ctx = ctx;
|
||||
p.x = x;
|
||||
p.y = y;
|
||||
p.px = x;
|
||||
p.py = y;
|
||||
p.vx = Math.cos(dir) * spd;
|
||||
p.vy = Math.sin(dir) * spd;
|
||||
p.ax = 0;
|
||||
p.ay = gravity;
|
||||
p.life = life;
|
||||
p.age = 0;
|
||||
p.color = pickColor(color);
|
||||
p.size = size * (0.7 + Math.random() * 0.6);
|
||||
p.baseSize = p.size;
|
||||
p.shape = shape;
|
||||
p.glow = glow;
|
||||
p.fade = fade;
|
||||
p.sizeFade = sizeFade;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Advance all alive particles by dt seconds.
|
||||
* Call from your sim RAF loop.
|
||||
*/
|
||||
update: function(dt) {
|
||||
for (var i = 0; i < POOL_SIZE; i++) {
|
||||
var p = pool[i];
|
||||
if (!p.alive) continue;
|
||||
|
||||
p.age += dt * 1000; /* convert s → ms */
|
||||
|
||||
if (p.age >= p.life) {
|
||||
p.alive = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
p.px = p.x;
|
||||
p.py = p.y;
|
||||
|
||||
p.vx += p.ax * dt;
|
||||
p.vy += p.ay * dt;
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Draw all live particles that belong to ctx.
|
||||
* Call after update, inside your sim's draw/render fn.
|
||||
*/
|
||||
draw: function(ctx) {
|
||||
for (var i = 0; i < POOL_SIZE; i++) {
|
||||
var p = pool[i];
|
||||
if (!p.alive || p.ctx !== ctx) continue;
|
||||
|
||||
var t = p.age / p.life; /* 0..1 */
|
||||
var alpha = p.fade ? (1 - t) : 1;
|
||||
var sz = p.sizeFade ? p.baseSize * (1 - t * 0.8) : p.baseSize;
|
||||
|
||||
ctx.save();
|
||||
|
||||
if (p.glow) {
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
}
|
||||
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.strokeStyle = p.color;
|
||||
|
||||
switch (p.shape) {
|
||||
case 'spark': {
|
||||
/* line from old to current pos — motion blur */
|
||||
var len = Math.max(2, sz * 3);
|
||||
var dx = p.x - p.px;
|
||||
var dy = p.y - p.py;
|
||||
var d = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
var nx = dx / d * len;
|
||||
var ny = dy / d * len;
|
||||
ctx.lineWidth = Math.max(0.5, sz * 0.4);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x - nx, p.y - ny);
|
||||
ctx.lineTo(p.x, p.y);
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ring': {
|
||||
var r = p.baseSize * (1 + t * 3);
|
||||
ctx.lineWidth = Math.max(0.5, sz * 0.5);
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'smoke': {
|
||||
var smokeAlpha = alpha * 0.25;
|
||||
ctx.globalAlpha = smokeAlpha;
|
||||
var smokeR = sz * (2 + t * 3);
|
||||
var grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, smokeR);
|
||||
grad.addColorStop(0, p.color);
|
||||
grad.addColorStop(1, 'transparent');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, smokeR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'dust': {
|
||||
ctx.globalAlpha = alpha * 0.6;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, Math.max(0.5, sz * 0.5), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'splash':
|
||||
/* same as dot but gravity param makes it droop — handled in update */
|
||||
/* fall-through */
|
||||
case 'dot':
|
||||
default: {
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, Math.max(0.5, sz), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
},
|
||||
|
||||
/** Remove all particles */
|
||||
clear: function() {
|
||||
for (var i = 0; i < POOL_SIZE; i++) {
|
||||
pool[i].alive = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
})(window);
|
||||
@@ -0,0 +1,332 @@
|
||||
'use strict';
|
||||
(function(global) {
|
||||
|
||||
global.LabFX = global.LabFX || {};
|
||||
|
||||
/* ── AudioContext lazy init ── */
|
||||
var _ctx = null;
|
||||
var _masterGain = null;
|
||||
var _enabled = (function() {
|
||||
var stored = localStorage.getItem('labfx-sound');
|
||||
return stored === null ? true : stored === 'true';
|
||||
})();
|
||||
|
||||
function getCtx() {
|
||||
if (!_ctx) {
|
||||
_ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
_masterGain = _ctx.createGain();
|
||||
_masterGain.gain.value = _enabled ? 1 : 0;
|
||||
_masterGain.connect(_ctx.destination);
|
||||
}
|
||||
if (_ctx.state === 'suspended') {
|
||||
_ctx.resume();
|
||||
}
|
||||
return _ctx;
|
||||
}
|
||||
|
||||
function master() {
|
||||
getCtx();
|
||||
return _masterGain;
|
||||
}
|
||||
|
||||
/* ── helper: connect chain to master ── */
|
||||
function chain(nodes) {
|
||||
/* nodes: array of AudioNode — each is connected to the next, last → master */
|
||||
for (var i = 0; i < nodes.length - 1; i++) {
|
||||
nodes[i].connect(nodes[i + 1]);
|
||||
}
|
||||
nodes[nodes.length - 1].connect(master());
|
||||
}
|
||||
|
||||
/* ── helper: create gain ── */
|
||||
function mkGain(value) {
|
||||
var g = getCtx().createGain();
|
||||
g.gain.value = value;
|
||||
return g;
|
||||
}
|
||||
|
||||
/* ── helper: create oscillator ── */
|
||||
function mkOsc(type, freq) {
|
||||
var o = getCtx().createOscillator();
|
||||
o.type = type;
|
||||
o.frequency.value = freq;
|
||||
return o;
|
||||
}
|
||||
|
||||
/* ── helper: create biquad ── */
|
||||
function mkFilter(type, freq, Q) {
|
||||
var f = getCtx().createBiquadFilter();
|
||||
f.type = type;
|
||||
f.frequency.value = freq;
|
||||
if (Q != null) f.Q.value = Q;
|
||||
return f;
|
||||
}
|
||||
|
||||
/* ── helper: noise buffer (white) ── */
|
||||
function makeNoise(color) {
|
||||
/* color: 'white' | 'pink' | 'brown' */
|
||||
var ac = getCtx();
|
||||
var length = ac.sampleRate * 2;
|
||||
var buffer = ac.createBuffer(1, length, ac.sampleRate);
|
||||
var data = buffer.getChannelData(0);
|
||||
var b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0;
|
||||
|
||||
for (var i = 0; i < length; i++) {
|
||||
var white = Math.random() * 2 - 1;
|
||||
if (color === 'pink') {
|
||||
b0 = 0.99886 * b0 + white * 0.0555179;
|
||||
b1 = 0.99332 * b1 + white * 0.0750759;
|
||||
b2 = 0.96900 * b2 + white * 0.1538520;
|
||||
b3 = 0.86650 * b3 + white * 0.3104856;
|
||||
b4 = 0.55000 * b4 + white * 0.5329522;
|
||||
b5 = -0.7616 * b5 - white * 0.0168980;
|
||||
data[i] = (b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362) * 0.11;
|
||||
b6 = white * 0.115926;
|
||||
} else if (color === 'brown') {
|
||||
b0 = (b0 + 0.02 * white) / 1.02;
|
||||
data[i] = b0 * 3.5;
|
||||
} else {
|
||||
data[i] = white;
|
||||
}
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/* ── noise source ── */
|
||||
function noiseSource(color) {
|
||||
var ac = getCtx();
|
||||
var src = ac.createBufferSource();
|
||||
src.buffer = makeNoise(color || 'white');
|
||||
src.loop = true;
|
||||
return src;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
SYNTH RECIPES
|
||||
───────────────────────────────────────────── */
|
||||
var recipes = {
|
||||
|
||||
click: function(opts) {
|
||||
/* short filtered noise burst, 30ms */
|
||||
var ac = getCtx();
|
||||
var now = ac.currentTime;
|
||||
var vol = (opts.volume || 0.5) * 0.6;
|
||||
var src = noiseSource('white');
|
||||
var flt = mkFilter('bandpass', 1200 * (opts.pitch || 1), 8);
|
||||
var g = mkGain(vol);
|
||||
g.gain.setValueAtTime(vol, now);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.030);
|
||||
chain([src, flt, g]);
|
||||
src.start(now);
|
||||
src.stop(now + 0.035);
|
||||
},
|
||||
|
||||
tick: function(opts) {
|
||||
/* Geiger tick: brown noise + very short envelope, 15ms */
|
||||
var ac = getCtx();
|
||||
var now = ac.currentTime;
|
||||
var vol = (opts.volume || 0.5) * 0.4;
|
||||
var src = noiseSource('brown');
|
||||
var flt = mkFilter('highpass', 800, 1);
|
||||
var g = mkGain(vol);
|
||||
g.gain.setValueAtTime(vol, now);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.015);
|
||||
chain([src, flt, g]);
|
||||
src.start(now);
|
||||
src.stop(now + 0.02);
|
||||
},
|
||||
|
||||
whoosh: function(opts) {
|
||||
/* descending swoosh: filtered noise sweep 800→200 Hz, 250ms */
|
||||
var ac = getCtx();
|
||||
var now = ac.currentTime;
|
||||
var vol = (opts.volume || 0.5) * 0.7;
|
||||
var src = noiseSource('pink');
|
||||
var flt = mkFilter('bandpass', 800 * (opts.pitch || 1), 3);
|
||||
var g = mkGain(vol);
|
||||
flt.frequency.setValueAtTime(800 * (opts.pitch || 1), now);
|
||||
flt.frequency.exponentialRampToValueAtTime(200 * (opts.pitch || 1), now + 0.25);
|
||||
g.gain.setValueAtTime(0.0001, now);
|
||||
g.gain.linearRampToValueAtTime(vol, now + 0.03);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.25);
|
||||
chain([src, flt, g]);
|
||||
src.start(now);
|
||||
src.stop(now + 0.28);
|
||||
},
|
||||
|
||||
chime: function(opts) {
|
||||
/* bright bell: additive sines 880+1320+1760 Hz, exp decay, 600ms */
|
||||
var ac = getCtx();
|
||||
var now = ac.currentTime;
|
||||
var vol = (opts.volume || 0.5);
|
||||
var pitch = opts.pitch || 1;
|
||||
var freqs = [880, 1320, 1760];
|
||||
var pan = opts.pan || 0;
|
||||
|
||||
freqs.forEach(function(f, idx) {
|
||||
var osc = mkOsc('sine', f * pitch);
|
||||
var g = mkGain(vol * (0.5 / (idx + 1)));
|
||||
var panner = ac.createStereoPanner ? ac.createStereoPanner() : null;
|
||||
g.gain.setValueAtTime(vol * (0.5 / (idx + 1)), now);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.6);
|
||||
if (panner) {
|
||||
panner.pan.value = pan;
|
||||
chain([osc, g, panner]);
|
||||
} else {
|
||||
chain([osc, g]);
|
||||
}
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.65);
|
||||
});
|
||||
},
|
||||
|
||||
fizz: function(opts) {
|
||||
/* pink noise, 300ms, low-pass sweep */
|
||||
var ac = getCtx();
|
||||
var now = ac.currentTime;
|
||||
var vol = (opts.volume || 0.5) * 0.6;
|
||||
var src = noiseSource('pink');
|
||||
var flt = mkFilter('lowpass', 3000 * (opts.pitch || 1), 1);
|
||||
var g = mkGain(vol);
|
||||
flt.frequency.setValueAtTime(3000 * (opts.pitch || 1), now);
|
||||
flt.frequency.exponentialRampToValueAtTime(400 * (opts.pitch || 1), now + 0.3);
|
||||
g.gain.setValueAtTime(vol, now);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.3);
|
||||
chain([src, flt, g]);
|
||||
src.start(now);
|
||||
src.stop(now + 0.35);
|
||||
},
|
||||
|
||||
spark: function(opts) {
|
||||
/* white noise + triangle 2000Hz, 50ms */
|
||||
var ac = getCtx();
|
||||
var now = ac.currentTime;
|
||||
var vol = (opts.volume || 0.5) * 0.7;
|
||||
var src = noiseSource('white');
|
||||
var osc = mkOsc('triangle', 2000 * (opts.pitch || 1));
|
||||
var gn = mkGain(vol * 0.5);
|
||||
var go = mkGain(vol * 0.5);
|
||||
var mix = mkGain(1);
|
||||
gn.gain.setValueAtTime(vol * 0.5, now);
|
||||
gn.gain.exponentialRampToValueAtTime(0.0001, now + 0.05);
|
||||
go.gain.setValueAtTime(vol * 0.5, now);
|
||||
go.gain.exponentialRampToValueAtTime(0.0001, now + 0.05);
|
||||
src.connect(gn); gn.connect(mix);
|
||||
osc.connect(go); go.connect(mix);
|
||||
mix.connect(master());
|
||||
src.start(now); osc.start(now);
|
||||
src.stop(now + 0.06); osc.stop(now + 0.06);
|
||||
},
|
||||
|
||||
bounce: function(opts) {
|
||||
/* sine sweep 600→300 Hz, 100ms */
|
||||
var ac = getCtx();
|
||||
var now = ac.currentTime;
|
||||
var vol = (opts.volume || 0.5) * 0.6;
|
||||
var osc = mkOsc('sine', 600 * (opts.pitch || 1));
|
||||
var g = mkGain(vol);
|
||||
osc.frequency.setValueAtTime(600 * (opts.pitch || 1), now);
|
||||
osc.frequency.exponentialRampToValueAtTime(300 * (opts.pitch || 1), now + 0.1);
|
||||
g.gain.setValueAtTime(vol, now);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.1);
|
||||
chain([osc, g]);
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.12);
|
||||
},
|
||||
|
||||
pour: function(opts) {
|
||||
/* filtered noise with random pitch wobble, 200ms+ */
|
||||
var ac = getCtx();
|
||||
var now = ac.currentTime;
|
||||
var vol = (opts.volume || 0.5) * 0.5;
|
||||
var src = noiseSource('pink');
|
||||
var flt = mkFilter('bandpass', 400 * (opts.pitch || 1), 2);
|
||||
var g = mkGain(vol);
|
||||
/* wobble frequency */
|
||||
var steps = 8;
|
||||
for (var i = 0; i <= steps; i++) {
|
||||
var t = now + (i / steps) * 0.2;
|
||||
var freq = (350 + Math.random() * 150) * (opts.pitch || 1);
|
||||
flt.frequency.setValueAtTime(freq, t);
|
||||
}
|
||||
g.gain.setValueAtTime(0.0001, now);
|
||||
g.gain.linearRampToValueAtTime(vol, now + 0.04);
|
||||
g.gain.setValueAtTime(vol, now + 0.18);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.22);
|
||||
chain([src, flt, g]);
|
||||
src.start(now);
|
||||
src.stop(now + 0.25);
|
||||
}
|
||||
};
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
DRONE — sustained sound (separate lifecycle)
|
||||
───────────────────────────────────────────── */
|
||||
var _droneNodes = {};
|
||||
|
||||
var droneRecipes = {
|
||||
drone: function(ac, vol) {
|
||||
var sine = mkOsc('sine', 80);
|
||||
var saw = mkOsc('sawtooth', 160);
|
||||
var gs = mkGain(vol * 0.5);
|
||||
var gw = mkGain(vol * 0.25);
|
||||
var mix = mkGain(0.05);
|
||||
sine.connect(gs); gs.connect(mix);
|
||||
saw.connect(gw); gw.connect(mix);
|
||||
mix.connect(master());
|
||||
sine.start();
|
||||
saw.start();
|
||||
return { nodes: [sine, saw, gs, gw, mix] };
|
||||
}
|
||||
};
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
PUBLIC API
|
||||
───────────────────────────────────────────── */
|
||||
global.LabFX.sound = {
|
||||
|
||||
play: function(name, opts) {
|
||||
if (!_enabled) return;
|
||||
opts = opts || {};
|
||||
getCtx();
|
||||
var recipe = recipes[name];
|
||||
if (recipe) {
|
||||
try { recipe(opts); } catch(e) { /* silent */ }
|
||||
}
|
||||
},
|
||||
|
||||
startDrone: function(name) {
|
||||
if (_droneNodes[name]) return _droneNodes[name]; /* already running */
|
||||
var recipe = droneRecipes[name];
|
||||
if (!recipe) return null;
|
||||
var ac = getCtx();
|
||||
var vol = 0.5;
|
||||
var handle = recipe(ac, vol);
|
||||
_droneNodes[name] = handle;
|
||||
|
||||
handle.stop = function() {
|
||||
handle.nodes.forEach(function(n) {
|
||||
try { n.stop(); } catch(e) {}
|
||||
try { n.disconnect(); } catch(e) {}
|
||||
});
|
||||
delete _droneNodes[name];
|
||||
};
|
||||
|
||||
return handle;
|
||||
},
|
||||
|
||||
setEnabled: function(bool) {
|
||||
_enabled = !!bool;
|
||||
localStorage.setItem('labfx-sound', _enabled ? 'true' : 'false');
|
||||
if (_masterGain) {
|
||||
_masterGain.gain.cancelScheduledValues(0);
|
||||
_masterGain.gain.value = _enabled ? 1 : 0;
|
||||
}
|
||||
},
|
||||
|
||||
isEnabled: function() {
|
||||
return _enabled;
|
||||
}
|
||||
};
|
||||
|
||||
})(window);
|
||||
@@ -174,6 +174,7 @@ class AngryBirdsSim {
|
||||
const tick = (ts) => {
|
||||
const dt = this._lastTs ? Math.min((ts - this._lastTs) / 1000, 0.05) : 0.016;
|
||||
this._lastTs = ts;
|
||||
if (window.LabFX) LabFX.particles.update(dt);
|
||||
this._update(dt);
|
||||
this.draw();
|
||||
this._raf = requestAnimationFrame(tick);
|
||||
@@ -198,6 +199,8 @@ class AngryBirdsSim {
|
||||
this._drawQueue();
|
||||
this._drawHUD();
|
||||
if (this.state === 'win' || this.state === 'lose') this._drawOverlay();
|
||||
/* LabFX: particles overlay */
|
||||
if (window.LabFX) LabFX.particles.draw(this.ctx);
|
||||
}
|
||||
|
||||
info() {
|
||||
@@ -319,6 +322,8 @@ class AngryBirdsSim {
|
||||
this.bird.launched = true;
|
||||
this.state = 'flying';
|
||||
this._emit('launch', this.bird.x, this.bird.y, this.bird.color, 8);
|
||||
/* LabFX: launch sound */
|
||||
if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 0.8 });
|
||||
if (this.onUpdate) this.onUpdate(this.info());
|
||||
}
|
||||
|
||||
@@ -482,10 +487,28 @@ class AngryBirdsSim {
|
||||
b.hp -= dmg;
|
||||
this.score += Math.max(0, Math.floor(dmg * 3));
|
||||
this._emit('hit', cx, cy, AB_MATS[b.mat]?.debris || '#888', 5);
|
||||
/* LabFX: bird-block collision */
|
||||
if (window.LabFX) {
|
||||
LabFX.sound.play('bounce');
|
||||
LabFX.particles.emit({
|
||||
ctx: this.ctx, x: cx, y: cy,
|
||||
count: 15, color: '#FFD166', speed: 80,
|
||||
spread: Math.PI * 2, life: 400, shape: 'spark',
|
||||
});
|
||||
}
|
||||
if (b.hp <= 0) {
|
||||
b.destroyed = true;
|
||||
this.score += 500;
|
||||
this._emit('destroy', b.x + b.w / 2, b.y + b.h / 2, AB_MATS[b.mat]?.debris || '#888', 15);
|
||||
/* LabFX: block destroyed */
|
||||
if (window.LabFX) {
|
||||
LabFX.sound.play('fizz');
|
||||
LabFX.particles.emit({
|
||||
ctx: this.ctx, x: b.x + b.w / 2, y: b.y + b.h / 2,
|
||||
count: 25, color: AB_MATS[b.mat]?.debris || '#888',
|
||||
speed: 100, spread: Math.PI * 2, life: 700, shape: 'splash',
|
||||
});
|
||||
}
|
||||
}
|
||||
bird.x += nx * (bird.r - dist + 1);
|
||||
bird.y += ny * (bird.r - dist + 1);
|
||||
@@ -519,6 +542,16 @@ class AngryBirdsSim {
|
||||
p.destroyed = true;
|
||||
this.score += 5000;
|
||||
this._emit('destroy', p.x, p.y, '#4ade80', 20);
|
||||
/* LabFX: pig defeated */
|
||||
if (window.LabFX) {
|
||||
LabFX.sound.play('chime');
|
||||
LabFX.particles.emit({
|
||||
ctx: this.ctx, x: p.x, y: p.y,
|
||||
count: 30, color: ['#06D6E0', '#FFD166', '#EF476F'],
|
||||
speed: 130, spread: Math.PI * 2, life: 800,
|
||||
glow: true, shape: 'spark',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this._emit('hit', p.x, p.y, '#86efac', 5);
|
||||
}
|
||||
|
||||
@@ -89,6 +89,10 @@ class BohrAtomSim {
|
||||
isEmission,
|
||||
};
|
||||
|
||||
if (window.LabFX) {
|
||||
LabFX.sound.play('chime', { pitch: 1.0 + (from + to) * 0.1, volume: 0.3 });
|
||||
}
|
||||
|
||||
if (!this.playing) { this.playing = true; this._lastTs = null; this._tick(); }
|
||||
this._emit();
|
||||
}
|
||||
@@ -222,6 +226,11 @@ class BohrAtomSim {
|
||||
vx: Math.cos(pa) * 120, vy: Math.sin(pa) * 120,
|
||||
color: this._trans.color, t: 0, maxT: 1.2,
|
||||
});
|
||||
if (window.LabFX) {
|
||||
LabFX.particles.emit({ ctx: this.ctx, x: ex, y: ey, count: 6,
|
||||
color: this._trans.color, speed: 35, spread: Math.PI * 2, angle: 0,
|
||||
gravity: 0, life: 600, fade: true, glow: true, shape: 'spark', size: 3, sizeFade: true });
|
||||
}
|
||||
}
|
||||
this._trans = null;
|
||||
this._emit();
|
||||
@@ -236,6 +245,7 @@ class BohrAtomSim {
|
||||
}
|
||||
this._photons = this._photons.filter(p => p.t < p.maxT);
|
||||
|
||||
if (window.LabFX) LabFX.particles.update(dt * 1000);
|
||||
this.draw();
|
||||
this._tick();
|
||||
});
|
||||
@@ -280,6 +290,7 @@ class BohrAtomSim {
|
||||
this._drawEnergyDiagram(ctx, panelX, W, H - specH);
|
||||
this._drawSpectrumBar(ctx, W, H, specH);
|
||||
this._drawPhotons(ctx);
|
||||
if (window.LabFX) LabFX.particles.draw(ctx);
|
||||
}
|
||||
|
||||
/* ── atom (left 65%) ───────────────────────── */
|
||||
|
||||
@@ -20,6 +20,7 @@ class BrownianSim {
|
||||
this._raf = null;
|
||||
this.onUpdate = null;
|
||||
this._dpr = 1;
|
||||
this._fxLastT = 0;
|
||||
|
||||
// v2
|
||||
this._msdHistory = []; // [{step, msd}]
|
||||
@@ -93,8 +94,11 @@ class BrownianSim {
|
||||
stop() { if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } }
|
||||
|
||||
// ── simulation ──────────────────────────────────────────────────────────────
|
||||
_loop() {
|
||||
_loop(now) {
|
||||
const dt = this._fxLastT ? Math.min(now - this._fxLastT, 80) : 16;
|
||||
this._fxLastT = now;
|
||||
this._step(); this._step(); this._step();
|
||||
if (window.LabFX) LabFX.particles.update(dt);
|
||||
this.draw();
|
||||
this._raf = requestAnimationFrame(this._loop.bind(this));
|
||||
}
|
||||
@@ -305,6 +309,7 @@ class BrownianSim {
|
||||
|
||||
// Hover tooltip
|
||||
if (this._hover) this._drawBigTooltip(ctx, W, H);
|
||||
if (window.LabFX) LabFX.particles.draw(ctx);
|
||||
}
|
||||
|
||||
_drawMsdChart(ctx, W, H) {
|
||||
|
||||
@@ -110,7 +110,11 @@ class CellDivisionSim {
|
||||
if (!this._raf) this._draw();
|
||||
}
|
||||
|
||||
setMode(mode) { this.mode = mode; this.reset(); }
|
||||
setMode(mode) {
|
||||
this.mode = mode;
|
||||
if (window.LabFX) LabFX.sound.play('click', { pitch: 1.2 });
|
||||
this.reset();
|
||||
}
|
||||
setSpeed(s) { this._speed = s; }
|
||||
|
||||
nextPhase() {
|
||||
@@ -118,6 +122,7 @@ class CellDivisionSim {
|
||||
this._phaseIdx = (this._phaseIdx + 1) % phases.length;
|
||||
this._phaseT = 0;
|
||||
this._particles = [];
|
||||
if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.0 + this._phaseIdx * 0.05, volume: 0.3 });
|
||||
this._emitUpdate();
|
||||
if (!this._raf) this._draw();
|
||||
}
|
||||
@@ -127,6 +132,7 @@ class CellDivisionSim {
|
||||
this._phaseIdx = (this._phaseIdx - 1 + phases.length) % phases.length;
|
||||
this._phaseT = 0;
|
||||
this._particles = [];
|
||||
if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.0 + this._phaseIdx * 0.05, volume: 0.3 });
|
||||
this._emitUpdate();
|
||||
if (!this._raf) this._draw();
|
||||
}
|
||||
@@ -136,6 +142,7 @@ class CellDivisionSim {
|
||||
this._phaseIdx = Math.max(0, Math.min(phases.length - 1, idx));
|
||||
this._phaseT = 0;
|
||||
this._particles = [];
|
||||
if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.0 + this._phaseIdx * 0.05, volume: 0.3 });
|
||||
this._emitUpdate();
|
||||
if (!this._raf) this._draw();
|
||||
}
|
||||
@@ -254,9 +261,48 @@ class CellDivisionSim {
|
||||
this._phaseT > 0.34 && this._phaseT < 0.38 && this._particles.length < 5) {
|
||||
const cellR = Math.min(this.W, this.H) * 0.37;
|
||||
this._spawnEnvelopeParticles(this.W / 2, this.H / 2, cellR * 0.46);
|
||||
// dust particles from LabFX around condensing chromosomes
|
||||
if (window.LabFX) {
|
||||
const cx = this.W / 2, cy = this.H / 2;
|
||||
LabFX.particles.emit({ ctx: this.ctx, x: cx, y: cy, count: 6, color: '#9B5DE5',
|
||||
speed: 18, spread: Math.PI * 2, angle: 0, gravity: 0, life: 700, fade: true,
|
||||
glow: false, shape: 'dust', size: 3, sizeFade: true });
|
||||
}
|
||||
}
|
||||
|
||||
// anaphase: tick sound at start of separation + pole sparks
|
||||
if ((phase.id === 'anaphase' || phase.id === 'anaphase1' || phase.id === 'anaphase2') &&
|
||||
this._phaseT > 0.05 && this._phaseT < 0.10 && !this._anaphaseTickDone) {
|
||||
this._anaphaseTickDone = true;
|
||||
if (window.LabFX) {
|
||||
LabFX.sound.play('tick', { pitch: 1.3 });
|
||||
const cx = this.W / 2, cellR = Math.min(this.W, this.H) * 0.37;
|
||||
const poleY = cellR * 0.7 * this._phaseT;
|
||||
for (const sy of [-1, 1]) {
|
||||
LabFX.particles.emit({ ctx: this.ctx, x: cx, y: this.H / 2 + sy * poleY,
|
||||
count: 5, color: '#FFD166', speed: 22, spread: Math.PI * 2, angle: 0,
|
||||
gravity: 0, life: 500, fade: true, glow: true, shape: 'spark', size: 3, sizeFade: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (phase.id !== 'anaphase' && phase.id !== 'anaphase1' && phase.id !== 'anaphase2') {
|
||||
this._anaphaseTickDone = false;
|
||||
}
|
||||
|
||||
if (this._phaseT >= 1) {
|
||||
// cytokinesis complete
|
||||
if (phase.id === 'cytokinesis') {
|
||||
if (window.LabFX) {
|
||||
LabFX.sound.play('chime');
|
||||
const cx = this.W / 2, cy = this.H / 2;
|
||||
const cellR = Math.min(this.W, this.H) * 0.37;
|
||||
for (const sy of [-1, 1]) {
|
||||
LabFX.particles.emit({ ctx: this.ctx, x: cx, y: cy + sy * cellR * 0.5,
|
||||
count: 10, color: '#22d399', speed: 30, spread: Math.PI * 2, angle: 0,
|
||||
gravity: 0, life: 800, fade: true, glow: true, shape: 'ring', size: 5, sizeFade: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
this._phaseT = 0;
|
||||
this._phaseIdx = (this._phaseIdx + 1) % phases.length;
|
||||
this._particles = [];
|
||||
@@ -269,6 +315,7 @@ class CellDivisionSim {
|
||||
p.life -= p.decay; return p.life > 0;
|
||||
});
|
||||
|
||||
if (window.LabFX) LabFX.particles.update(dt);
|
||||
this._emitUpdate();
|
||||
this._draw();
|
||||
}
|
||||
@@ -316,6 +363,7 @@ class CellDivisionSim {
|
||||
this._drawOverlay(phase);
|
||||
this._drawProgressBar();
|
||||
this._drawHint();
|
||||
if (window.LabFX) LabFX.particles.draw(this.ctx);
|
||||
}
|
||||
|
||||
/* ── Cell / nucleus ─────────────────────────────────────────── */
|
||||
|
||||
@@ -417,6 +417,13 @@ class ChemSandboxSim {
|
||||
this.mixContents.push(formula);
|
||||
const sub = ChemSandboxSim.SUBSTANCES[formula];
|
||||
|
||||
if (window.LabFX) {
|
||||
LabFX.sound.play('pour');
|
||||
const { cx, nt, nw } = this._g;
|
||||
LabFX.particles.emit({ ctx: this.ctx, x: cx, y: nt - 10, count: 15, color: sub.color,
|
||||
speed: 60, spread: 1.8, angle: Math.PI / 2, gravity: 300, life: 800, shape: 'splash' });
|
||||
}
|
||||
|
||||
// pour animation
|
||||
this._pouring = true;
|
||||
this._pourColor = sub.color;
|
||||
@@ -552,6 +559,33 @@ class ChemSandboxSim {
|
||||
this._spawnSteam(fx.violent ? 25 : 12);
|
||||
if (fx.violent) this._spawnSparks(35);
|
||||
}
|
||||
|
||||
if (window.LabFX) {
|
||||
const { cx, cy } = this._g;
|
||||
if (fx.violent) {
|
||||
// Combustion-like: flame sparks + shake
|
||||
LabFX.sound.play('fizz');
|
||||
LabFX.particles.emit({ ctx: this.ctx, x: cx, y: cy - 20, count: 30,
|
||||
color: '#FFA500', speed: 90, spread: 2.5, angle: -Math.PI / 2,
|
||||
gravity: -100, life: 600, shape: 'spark', glow: true });
|
||||
LabFX.shake(this.canvas, { intensity: 3, durMs: 300 });
|
||||
} else if (fx.gas) {
|
||||
// Gas evolution: fizz + rising bubbles
|
||||
LabFX.sound.play('fizz');
|
||||
LabFX.particles.emit({ ctx: this.ctx, x: cx, y: cy, count: 10,
|
||||
color: '#FFFFFF', speed: 40, spread: 1.2, angle: -Math.PI / 2,
|
||||
gravity: -80, life: 1200, shape: 'ring' });
|
||||
} else if (fx.precip) {
|
||||
// Precipitate: settling dust
|
||||
LabFX.sound.play('fizz');
|
||||
LabFX.particles.emit({ ctx: this.ctx, x: cx, y: cy - 10, count: 12,
|
||||
color: fx.precip.c || '#888888', speed: 20, spread: 1.5, angle: Math.PI / 2,
|
||||
gravity: 60, life: 1500, shape: 'dust' });
|
||||
} else {
|
||||
LabFX.sound.play('fizz');
|
||||
}
|
||||
}
|
||||
|
||||
this._fireInfo();
|
||||
}
|
||||
|
||||
@@ -648,6 +682,8 @@ class ChemSandboxSim {
|
||||
this._wave3 += dt * 0.88;
|
||||
this._glowPulse += dt * 3.2;
|
||||
|
||||
if (window.LabFX) LabFX.particles.update(dt);
|
||||
|
||||
if (this._animPhase === 1) {
|
||||
this._reacTimer += dt;
|
||||
if (this._reacTimer > 5.0) this._animPhase = 2;
|
||||
@@ -784,6 +820,7 @@ class ChemSandboxSim {
|
||||
this._drawDragGhost();
|
||||
if (this.mixContents.length === 0 && !this.lastReaction && !this._quizMode) this._drawHint();
|
||||
if (this._quizMode) this._drawQuizOverlay();
|
||||
if (window.LabFX) LabFX.particles.draw(ctx);
|
||||
}
|
||||
|
||||
_drawBackground() {
|
||||
@@ -1541,6 +1578,7 @@ class ChemSandboxSim {
|
||||
|
||||
handleContextMenu(e) {
|
||||
e.preventDefault();
|
||||
if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 0.7, volume: 0.3 });
|
||||
this.reset();
|
||||
}
|
||||
|
||||
@@ -1718,7 +1756,7 @@ class ChemSandboxSim {
|
||||
}
|
||||
|
||||
function chemSandPreset(name) { if (chemSandSim) { chemSandSim.preset(name); _chemSandBuildReagents(chemSandSim.filterCat); } }
|
||||
function chemSandReset() { if (chemSandSim) { chemSandSim.reset(); _chemSandBuildReagents(chemSandSim.filterCat); } }
|
||||
function chemSandReset() { if (chemSandSim) { if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 0.7, volume: 0.3 }); chemSandSim.reset(); _chemSandBuildReagents(chemSandSim.filterCat); } }
|
||||
function chemSandResetReaction() { if (chemSandSim) { chemSandSim.resetReaction(); _chemSandBuildReagents(chemSandSim.filterCat); } }
|
||||
|
||||
function chemSandConcChange() {
|
||||
@@ -1736,6 +1774,7 @@ class ChemSandboxSim {
|
||||
if (chemSandSim.mixContents.includes(formula)) {
|
||||
chemSandSim.removeFromMix(formula);
|
||||
} else {
|
||||
if (window.LabFX) LabFX.sound.play('click', { pitch: 1.2 });
|
||||
chemSandSim.addToMix(formula);
|
||||
}
|
||||
_chemSandBuildReagents(chemSandSim.filterCat);
|
||||
|
||||
+79
-30
@@ -337,6 +337,30 @@ class CircuitSim {
|
||||
const speed = Math.min(Math.abs(c._I) * 0.6, 8) / (this.CELL * _compLen(c));
|
||||
c._t = ((c._t || 0) + speed * dt * 60) % 1;
|
||||
}
|
||||
|
||||
if (window.LabFX) LabFX.particles.update(dt);
|
||||
|
||||
// heat shimmer: emit smoke above hottest resistor every 5 frames
|
||||
if (window.LabFX && this._heatmapOn && this._solution?.solved) {
|
||||
if (!this._smokeFrame) this._smokeFrame = 0;
|
||||
this._smokeFrame++;
|
||||
if (this._smokeFrame % 5 === 0) {
|
||||
const dissipators = this.components.filter(c => c.type === 'resistor' || c.type === 'lamp');
|
||||
if (dissipators.length) {
|
||||
let hottest = dissipators[0], hotP = 0;
|
||||
for (const c of dissipators) {
|
||||
const P = Math.abs((c._I ?? 0) ** 2 * this._compR(c));
|
||||
if (P > hotP) { hotP = P; hottest = c; }
|
||||
}
|
||||
if (hotP > 0.1) {
|
||||
const p1 = this._nodePixel(hottest.x1, hottest.y1), p2 = this._nodePixel(hottest.x2, hottest.y2);
|
||||
const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2;
|
||||
LabFX.particles.emit({ ctx: this.ctx, x: mx, y: my - 10, count: 1, color: 'rgba(255,180,100,0.15)', speed: 8, spread: 0.5, angle: -Math.PI / 2, gravity: -50, life: 1500, shape: 'smoke', size: 8, fade: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.draw();
|
||||
if (this._oscPanel && this._oscPanel.offsetParent !== null) {
|
||||
this.drawOscilloscope(this._oscPanel);
|
||||
@@ -467,7 +491,12 @@ class CircuitSim {
|
||||
/* ─── Component draw methods ───────────────────────────────────────────── */
|
||||
|
||||
_drawWire(ctx, c, p1, p2, hasI) {
|
||||
this._drawWireLine(ctx, p1, p2, c._v1, c._v2, 3, hasI);
|
||||
if (window.LabFX && hasI && Math.abs(c._I) > 0) {
|
||||
const intensity = Math.min(20, 6 + Math.abs(c._I) * 2);
|
||||
LabFX.glow.drawGlow(ctx, () => this._drawWireLine(ctx, p1, p2, c._v1, c._v2, 3, hasI), { color: '#06D6E0', intensity });
|
||||
} else {
|
||||
this._drawWireLine(ctx, p1, p2, c._v1, c._v2, 3, hasI);
|
||||
}
|
||||
}
|
||||
|
||||
_drawResistor(ctx, c, p1, p2, mx, my, hasI) {
|
||||
@@ -769,35 +798,41 @@ class CircuitSim {
|
||||
const on=(this._diodeR.get(c.id)??1e9)<1;
|
||||
const col=c.ledColor||this.ledColor;
|
||||
|
||||
if (on) {
|
||||
const grd=ctx.createRadialGradient(mx,my,0,mx,my,38);
|
||||
grd.addColorStop(0,col+'50'); grd.addColorStop(1,col+'00');
|
||||
ctx.fillStyle=grd;
|
||||
ctx.beginPath(); ctx.arc(mx,my,38,0,Math.PI*2); ctx.fill();
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(mx,my); ctx.rotate(Math.atan2(dy,dx));
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-tw,-th); ctx.lineTo(-tw,th); ctx.lineTo(tw,0); ctx.closePath();
|
||||
ctx.fillStyle=on?col+'55':col+'18'; ctx.fill();
|
||||
ctx.strokeStyle=on?col:col+'90'; ctx.lineWidth=1.5; ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(tw,-th); ctx.lineTo(tw,th); ctx.stroke();
|
||||
|
||||
if (on) {
|
||||
ctx.strokeStyle=col; ctx.lineWidth=1.2;
|
||||
for (let s=-1;s<=1;s+=2) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tw+3, s*5);
|
||||
ctx.lineTo(tw+11, s*5-s*6);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tw+11,s*5-s*6); ctx.lineTo(tw+8,s*5-s*6);
|
||||
ctx.moveTo(tw+11,s*5-s*6); ctx.lineTo(tw+11,s*5-s*3);
|
||||
ctx.stroke();
|
||||
const drawLEDBody = () => {
|
||||
if (on) {
|
||||
const grd=ctx.createRadialGradient(mx,my,0,mx,my,38);
|
||||
grd.addColorStop(0,col+'50'); grd.addColorStop(1,col+'00');
|
||||
ctx.fillStyle=grd;
|
||||
ctx.beginPath(); ctx.arc(mx,my,38,0,Math.PI*2); ctx.fill();
|
||||
}
|
||||
ctx.save();
|
||||
ctx.translate(mx,my); ctx.rotate(Math.atan2(dy,dx));
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-tw,-th); ctx.lineTo(-tw,th); ctx.lineTo(tw,0); ctx.closePath();
|
||||
ctx.fillStyle=on?col+'55':col+'18'; ctx.fill();
|
||||
ctx.strokeStyle=on?col:col+'90'; ctx.lineWidth=1.5; ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(tw,-th); ctx.lineTo(tw,th); ctx.stroke();
|
||||
if (on) {
|
||||
ctx.strokeStyle=col; ctx.lineWidth=1.2;
|
||||
for (let s=-1;s<=1;s+=2) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tw+3, s*5);
|
||||
ctx.lineTo(tw+11, s*5-s*6);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tw+11,s*5-s*6); ctx.lineTo(tw+8,s*5-s*6);
|
||||
ctx.moveTo(tw+11,s*5-s*6); ctx.lineTo(tw+11,s*5-s*3);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
if (window.LabFX && on) {
|
||||
LabFX.glow.drawGlow(ctx, drawLEDBody, { color: col, intensity: 18, layers: 2 });
|
||||
} else {
|
||||
drawLEDBody();
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
ctx.font='9px Manrope,sans-serif'; ctx.fillStyle=col+'cc';
|
||||
ctx.textAlign='center'; ctx.textBaseline='top';
|
||||
@@ -985,6 +1020,17 @@ class CircuitSim {
|
||||
ctx.textAlign='center'; ctx.textBaseline='middle';
|
||||
ctx.fillText('Короткое замыкание!', this.W/2, this.H/2);
|
||||
ctx.textBaseline='alphabetic';
|
||||
if (window.LabFX) {
|
||||
const now4 = performance.now();
|
||||
if (!this._shortFXt || now4 - this._shortFXt > 600) {
|
||||
this._shortFXt = now4;
|
||||
const p1=this._nodePixel(batt.x1,batt.y1), p2=this._nodePixel(batt.x2,batt.y2);
|
||||
const sx=(p1.x+p2.x)/2, sy=(p1.y+p2.y)/2;
|
||||
LabFX.particles.emit({ ctx, x: sx, y: sy, count: 12, color: '#EF476F', speed: 80, spread: Math.PI*2, life: 300, shape: 'spark', size: 3, glow: true });
|
||||
LabFX.sound.play('spark');
|
||||
LabFX.shake(this.canvas, { intensity: 5, durMs: 200 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Hint ─────────────────────────────────────────────────────────────── */
|
||||
@@ -1145,6 +1191,7 @@ class CircuitSim {
|
||||
if (this._drawing&&this._ghostEnd) this._drawGhost(ctx);
|
||||
this._drawTooltip(ctx);
|
||||
if (this.components.length===0) this._drawHint(ctx);
|
||||
if (window.LabFX) LabFX.particles.draw(ctx);
|
||||
}
|
||||
|
||||
/* ─── Events ───────────────────────────────────────────────────────────── */
|
||||
@@ -1196,7 +1243,7 @@ class CircuitSim {
|
||||
const p=pos(e), g=snap(p), hi=hitComp(p);
|
||||
|
||||
if (this.addMode==='erase') {
|
||||
if (hi>=0) { this._pushHistory(); this.components.splice(hi,1); this._solve(); this.draw(); }
|
||||
if (hi>=0) { this._pushHistory(); this.components.splice(hi,1); this._solve(); this.draw(); if (window.LabFX) LabFX.sound.play('fizz', { volume: 0.3 }); }
|
||||
return;
|
||||
}
|
||||
if (hi>=0) {
|
||||
@@ -1266,13 +1313,14 @@ class CircuitSim {
|
||||
cvs.addEventListener('contextmenu', e=>{
|
||||
e.preventDefault();
|
||||
const p=pos(e), i=hitComp(p);
|
||||
if (i>=0) { this._pushHistory(); this.components.splice(i,1); this._selected=null; this._solve(); this.draw(); }
|
||||
if (i>=0) { this._pushHistory(); this.components.splice(i,1); this._selected=null; this._solve(); this.draw(); if (window.LabFX) LabFX.sound.play('fizz', { volume: 0.3 }); }
|
||||
});
|
||||
|
||||
cvs.addEventListener('dblclick', e=>{
|
||||
const p=pos(e), i=hitComp(p);
|
||||
if (i>=0&&this.components[i].type==='switch') {
|
||||
this._pushHistory(); this.components[i].open=!this.components[i].open; this._solve(); this.draw();
|
||||
if (window.LabFX) LabFX.sound.play('click', { pitch: 1.5 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1327,6 +1375,7 @@ class CircuitSim {
|
||||
: undefined;
|
||||
const L_value = type==='inductor' ? this.L_value : undefined;
|
||||
this._add(type,x1,y1,x2,y2,value,L_value);
|
||||
if (window.LabFX) LabFX.sound.play('click', { pitch: 0.9 });
|
||||
this._solve(); this.draw();
|
||||
if (this.onUpdate) this.onUpdate(this.info());
|
||||
}
|
||||
|
||||
@@ -188,8 +188,10 @@ class CollisionSim {
|
||||
this._raf = requestAnimationFrame(ts => {
|
||||
if (!this.playing) return;
|
||||
if (this._lastTs === null) this._lastTs = ts;
|
||||
const dt = Math.min((ts - this._lastTs) / 1000, 0.05) * this.speed;
|
||||
const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05);
|
||||
this._lastTs = ts;
|
||||
const dt = rawDt * this.speed;
|
||||
if (window.LabFX) LabFX.particles.update(rawDt);
|
||||
this._step(dt);
|
||||
this.draw();
|
||||
this._emit();
|
||||
@@ -361,6 +363,18 @@ class CollisionSim {
|
||||
|
||||
this._impactPt = { x: ix, y: iy, ts: now, intensity };
|
||||
|
||||
/* LabFX: collision effects */
|
||||
if (window.LabFX) {
|
||||
LabFX.sound.play('bounce');
|
||||
LabFX.particles.emit({
|
||||
ctx: this.ctx, x: ix, y: iy,
|
||||
count: 12, color: '#FFF', speed: 90,
|
||||
spread: Math.PI * 2, life: 400,
|
||||
glow: true, shape: 'spark', size: 2,
|
||||
});
|
||||
LabFX.shake(this.c, { intensity: 3, durMs: 150 });
|
||||
}
|
||||
|
||||
/* 4 expanding rings (different radii, colors, lifetimes) */
|
||||
const ringDefs = [
|
||||
{ life:1400, col:'255,255,255', maxR:160 },
|
||||
@@ -913,6 +927,9 @@ class CollisionSim {
|
||||
if (this._hoverBall) {
|
||||
this._drawBallTooltip(ctx, this._hoverBall, W, H);
|
||||
}
|
||||
|
||||
/* LabFX: particles overlay */
|
||||
if (window.LabFX) LabFX.particles.draw(this.ctx);
|
||||
}
|
||||
|
||||
/* ── hover inspector ── */
|
||||
|
||||
@@ -88,6 +88,7 @@ class CrystalSim {
|
||||
/* ── public ── */
|
||||
setLattice(type) {
|
||||
this._lattice = type;
|
||||
if (window.LabFX) LabFX.sound.play('chime', { pitch: 1.2, volume: 0.3 });
|
||||
this._buildLattice(type);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ class DiffusionSim {
|
||||
this._poreH = 40; // gap height in pixels
|
||||
this._heatmap = null; // cached density heatmap
|
||||
this._hmTick = 0;
|
||||
|
||||
// LabFX
|
||||
this._fxLastT = 0;
|
||||
this._fxEquilDone = false; // equilibrium chime played once
|
||||
}
|
||||
|
||||
// ── public API ──────────────────────────────────────────────────────────────
|
||||
@@ -77,6 +81,8 @@ class DiffusionSim {
|
||||
} else {
|
||||
this.partitionOn = !this.partitionOn;
|
||||
}
|
||||
if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 0.7, volume: 0.3 });
|
||||
this._fxEquilDone = false; // allow equilibrium chime again after partition change
|
||||
}
|
||||
|
||||
togglePore() {
|
||||
@@ -103,8 +109,11 @@ class DiffusionSim {
|
||||
stop() { if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } }
|
||||
|
||||
// ── simulation ──────────────────────────────────────────────────────────────
|
||||
_loop() {
|
||||
_loop(now) {
|
||||
const dt = this._fxLastT ? Math.min(now - this._fxLastT, 80) : 16;
|
||||
this._fxLastT = now;
|
||||
this._step(); this._step();
|
||||
if (window.LabFX) LabFX.particles.update(dt);
|
||||
this.draw();
|
||||
this._raf = requestAnimationFrame(this._loop.bind(this));
|
||||
}
|
||||
@@ -189,6 +198,15 @@ class DiffusionSim {
|
||||
|
||||
this._steps++;
|
||||
if (this._steps % 30 === 0 && this.onUpdate) this.onUpdate(this.info());
|
||||
|
||||
// Equilibrium detection: mixed >= 45% and partition is off
|
||||
if (!this.partitionOn && !this._fxEquilDone && this._steps % 60 === 0 && window.LabFX) {
|
||||
const info = this.info();
|
||||
if (+info.mixed >= 45) {
|
||||
this._fxEquilDone = true;
|
||||
LabFX.sound.play('chime', { pitch: 1.0, volume: 0.3 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_updateHeatmap() {
|
||||
@@ -263,6 +281,7 @@ class DiffusionSim {
|
||||
|
||||
// Stats overlay (top-left)
|
||||
this._drawStats(ctx);
|
||||
if (window.LabFX) LabFX.particles.draw(ctx);
|
||||
}
|
||||
|
||||
_drawHeatmap(ctx) {
|
||||
|
||||
@@ -207,8 +207,10 @@ class ElectrolysisSim {
|
||||
if (!this.playing) return;
|
||||
this._raf = requestAnimationFrame(ts => {
|
||||
if (!this._lastTs) this._lastTs = ts;
|
||||
const dt = Math.min((ts - this._lastTs) / 1000, 0.05) * this.speed;
|
||||
const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05);
|
||||
const dt = rawDt * this.speed;
|
||||
this._lastTs = ts;
|
||||
if (window.LabFX) LabFX.particles.update(rawDt);
|
||||
this._step(dt); this.draw(); this._emit(); this._tick();
|
||||
});
|
||||
}
|
||||
@@ -231,6 +233,9 @@ class ElectrolysisSim {
|
||||
|
||||
// Ion drift + thermal jitter
|
||||
const drift = I * 0.45;
|
||||
this._fxIonTrailAcc = (this._fxIonTrailAcc || 0) + dt;
|
||||
const doTrail = this._fxIonTrailAcc >= 0.1;
|
||||
if (doTrail) this._fxIonTrailAcc = 0;
|
||||
for (const ion of this._ions) {
|
||||
ion.vx += (ion.charge > 0 ? -drift : drift) * dt + (Math.random() - 0.5) * 0.18;
|
||||
ion.vy += (Math.random() - 0.5) * 0.14;
|
||||
@@ -239,6 +244,12 @@ class ElectrolysisSim {
|
||||
ion.x += ion.vx; ion.y += ion.vy;
|
||||
ion.x = Math.max(cx + 4, Math.min(cx + cw - 4, ion.x));
|
||||
ion.y = Math.max(cy + 4, Math.min(cy + ch - 4, ion.y));
|
||||
// LabFX: ion trail dot
|
||||
if (window.LabFX && doTrail && Math.random() < 0.3) {
|
||||
LabFX.particles.emit({ ctx: this.ctx, x: ion.x, y: ion.y, count: 1,
|
||||
color: '#FFD166', speed: 4, spread: 3.14, angle: 0,
|
||||
gravity: 0, life: 300, shape: 'dot', glow: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Ions reaching electrodes → discharge + bubbles
|
||||
@@ -253,6 +264,14 @@ class ElectrolysisSim {
|
||||
elec.cathode.x + elec.cathode.w + 2 + Math.random() * 4,
|
||||
elec.cathode.y + Math.random() * elec.cathode.h,
|
||||
el.cathodeBubColor);
|
||||
// LabFX: rising ring bubble from cathode
|
||||
if (window.LabFX) {
|
||||
LabFX.particles.emit({ ctx: this.ctx,
|
||||
x: elec.cathode.x + elec.cathode.w + 3,
|
||||
y: elec.cathode.y + Math.random() * elec.cathode.h,
|
||||
count: 1, color: '#FFFFFF', speed: 20, spread: 0.4, angle: -Math.PI / 2,
|
||||
gravity: -60, life: 1500, shape: 'ring' });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ion.charge < 0 && ion.x >= elec.anode.x - 5) {
|
||||
@@ -263,9 +282,25 @@ class ElectrolysisSim {
|
||||
elec.anode.x - 2 - Math.random() * 4,
|
||||
elec.anode.y + Math.random() * elec.anode.h,
|
||||
el.anodeBubColor);
|
||||
// LabFX: rising ring bubble from anode
|
||||
if (window.LabFX) {
|
||||
LabFX.particles.emit({ ctx: this.ctx,
|
||||
x: elec.anode.x - 3,
|
||||
y: elec.anode.y + Math.random() * elec.anode.h,
|
||||
count: 1, color: '#FFFFFF', speed: 20, spread: 0.4, angle: -Math.PI / 2,
|
||||
gravity: -60, life: 1500, shape: 'ring' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Periodic fizz sound when current is flowing
|
||||
if (window.LabFX && I > 0.01) {
|
||||
this._fxFizzAcc = (this._fxFizzAcc || 0) + dt;
|
||||
if (this._fxFizzAcc >= 2.0) {
|
||||
this._fxFizzAcc = 0;
|
||||
LabFX.sound.play('fizz', { volume: 0.2 });
|
||||
}
|
||||
}
|
||||
this._ions = this._ions.filter((_, i) => !rm.has(i));
|
||||
|
||||
// Replenish ions to keep count ~15 each
|
||||
@@ -308,6 +343,7 @@ class ElectrolysisSim {
|
||||
this._drawIons();
|
||||
this._drawLabels();
|
||||
this._drawInfoPanel();
|
||||
if (window.LabFX) LabFX.particles.draw(this.ctx);
|
||||
}
|
||||
|
||||
_drawCellBody() {
|
||||
@@ -559,7 +595,10 @@ if (typeof module !== 'undefined') module.exports = ElectrolysisSim;
|
||||
|
||||
function elecParam(name, val) {
|
||||
const v = parseFloat(val);
|
||||
if (name === 'voltage') document.getElementById('elec-V-val').textContent = v;
|
||||
if (name === 'voltage') {
|
||||
document.getElementById('elec-V-val').textContent = v;
|
||||
if (window.LabFX) LabFX.sound.play('spark', { volume: 0.3 });
|
||||
}
|
||||
if (elecSim) elecSim.setParams({ [name]: v });
|
||||
}
|
||||
|
||||
|
||||
+84
-10
@@ -181,6 +181,11 @@ class EMFieldSim {
|
||||
this.sources.push({ kind: 'charge', id: this._nextId++, x, y, q });
|
||||
this._invalidateAll();
|
||||
this.draw();
|
||||
if (window.LabFX) {
|
||||
LabFX.sound.play('spark', { pitch: 1.1 });
|
||||
const col = q > 0 ? '#EF476F' : '#4CC9F0';
|
||||
LabFX.particles.emit({ ctx: this.ctx, x, y, count: 8, color: col, speed: 60, spread: Math.PI * 2, life: 400, shape: 'spark', size: 3, glow: true });
|
||||
}
|
||||
if (this.onUpdate) this.onUpdate(this.info());
|
||||
}
|
||||
|
||||
@@ -191,6 +196,11 @@ class EMFieldSim {
|
||||
this.sources.push({ kind, id: this._nextId++, x, y, I });
|
||||
this._invalidateAll();
|
||||
this.draw();
|
||||
if (window.LabFX) {
|
||||
LabFX.sound.play('spark', { pitch: 1.1 });
|
||||
const col = I > 0 ? '#06D6E0' : '#F15BB5';
|
||||
LabFX.particles.emit({ ctx: this.ctx, x, y, count: 8, color: col, speed: 60, spread: Math.PI * 2, life: 400, shape: 'spark', size: 3, glow: true });
|
||||
}
|
||||
if (this.onUpdate) this.onUpdate(this.info());
|
||||
}
|
||||
|
||||
@@ -318,6 +328,14 @@ class EMFieldSim {
|
||||
if (minY < margin) { const d = margin - minY; rod.y1 += d; rod.y2 += d; }
|
||||
if (maxY > this.H - margin) { const d = maxY - (this.H - margin); rod.y1 -= d; rod.y2 -= d; }
|
||||
|
||||
if (window.LabFX) {
|
||||
LabFX.particles.update(dt);
|
||||
const v2 = Math.hypot(rod.vx, rod.vy);
|
||||
if (v2 > 30) {
|
||||
LabFX.particles.emit({ ctx: this.ctx, x: rod.x1, y: rod.y1, count: 1, color: '#f59e0b', speed: 20, spread: Math.PI * 2, life: 200, shape: 'spark', size: 2, glow: true });
|
||||
LabFX.particles.emit({ ctx: this.ctx, x: rod.x2, y: rod.y2, count: 1, color: '#f59e0b', speed: 20, spread: Math.PI * 2, life: 200, shape: 'spark', size: 2, glow: true });
|
||||
}
|
||||
}
|
||||
this.draw();
|
||||
if (this.onUpdate) this.onUpdate(this.info());
|
||||
rod._raf = requestAnimationFrame(() => this._tickRod());
|
||||
@@ -389,8 +407,11 @@ class EMFieldSim {
|
||||
_tickParticle() {
|
||||
if (!this.particleOn || !this._particle) return;
|
||||
const now = performance.now();
|
||||
const rawDt = Math.min((now - this._pLast) * 0.001, 0.05); // seconds
|
||||
const dt = Math.min((now - this._pLast) * 0.06, 2.5);
|
||||
this._pLast = now;
|
||||
if (!this._pFrame) this._pFrame = 0;
|
||||
this._pFrame++;
|
||||
|
||||
const p = this._particle;
|
||||
|
||||
@@ -433,6 +454,12 @@ class EMFieldSim {
|
||||
p.trail.push({ x: p.x, y: p.y });
|
||||
if (p.trail.length > 350) p.trail.shift();
|
||||
|
||||
if (window.LabFX && this._pFrame % 2 === 0) {
|
||||
const trailCol = p.q > 0 ? '#FFD166' : '#4CC9F0';
|
||||
LabFX.particles.emit({ ctx: this.ctx, x: p.x, y: p.y, count: 1, color: trailCol, life: 400, shape: 'dot', size: 2, glow: true });
|
||||
}
|
||||
|
||||
if (window.LabFX) LabFX.particles.update(rawDt);
|
||||
this.draw();
|
||||
this._pRaf = requestAnimationFrame(() => this._tickParticle());
|
||||
}
|
||||
@@ -792,6 +819,11 @@ class EMFieldSim {
|
||||
|
||||
if (this._gauss._dragging) {
|
||||
this._gauss.x = p.x; this._gauss.y = p.y;
|
||||
const now2 = performance.now();
|
||||
if (!this._gaussHapticT || now2 - this._gaussHapticT > 100) {
|
||||
this._gaussHapticT = now2;
|
||||
if (window.LabFX) LabFX.haptic(5);
|
||||
}
|
||||
this.draw(); return;
|
||||
}
|
||||
|
||||
@@ -970,6 +1002,28 @@ class EMFieldSim {
|
||||
/* sources */
|
||||
this._drawSources(ctx);
|
||||
|
||||
/* high-field lightning FX */
|
||||
if (window.LabFX && this.sources.length >= 2) {
|
||||
const now3 = performance.now();
|
||||
if (!this._lightningT) this._lightningT = 0;
|
||||
if (now3 - this._lightningT > 500) {
|
||||
// sample max field at center
|
||||
const cx = this.W / 2, cy = this.H / 2;
|
||||
const em = this._eField(cx, cy), bm = this._bField(cx, cy);
|
||||
const maxField = Math.max(em.mag, bm.mag);
|
||||
if (maxField > 30000) {
|
||||
this._lightningT = now3;
|
||||
const i1 = Math.floor(Math.random() * this.sources.length);
|
||||
let i2 = Math.floor(Math.random() * this.sources.length);
|
||||
if (i2 === i1) i2 = (i1 + 1) % this.sources.length;
|
||||
const s1 = this.sources[i1], s2 = this.sources[i2];
|
||||
const lx = (s1.x + s2.x) / 2, ly = (s1.y + s2.y) / 2;
|
||||
LabFX.particles.emit({ ctx: this.ctx, x: lx, y: ly, count: 5, color: '#FFFFFF', speed: 30, spread: Math.PI * 2, life: 80, shape: 'spark', glow: true });
|
||||
LabFX.sound.play('spark', { volume: 0.2 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* cursor readout */
|
||||
if (this._mousePos) {
|
||||
if (this._cursorE && this.mode !== 'B' && hasE) this._drawCursorE(ctx);
|
||||
@@ -977,6 +1031,8 @@ class EMFieldSim {
|
||||
}
|
||||
|
||||
if (this.sources.length === 0) this._drawHint(ctx);
|
||||
|
||||
if (window.LabFX) LabFX.particles.draw(ctx);
|
||||
}
|
||||
|
||||
/* ── grid ── */
|
||||
@@ -1125,7 +1181,9 @@ class EMFieldSim {
|
||||
/* ── E vectors ── */
|
||||
_drawVectorsE(ctx) {
|
||||
const GRID = 45;
|
||||
const _pulse = (window.LabFX) ? (0.7 + 0.3 * LabFX.glow.pulse(performance.now(), 2000)) : 1;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = _pulse;
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
||||
ctx.lineWidth = 1;
|
||||
@@ -1195,10 +1253,17 @@ class EMFieldSim {
|
||||
grad.addColorStop(0, 'rgba(255,255,255,0.75)');
|
||||
grad.addColorStop(0.5, 'rgba(255,255,255,0.35)');
|
||||
grad.addColorStop(1, 'rgba(255,255,255,0.0)');
|
||||
ctx.strokeStyle = grad;
|
||||
ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]);
|
||||
for (let k = 1; k < pts.length; k++) ctx.lineTo(pts[k][0], pts[k][1]);
|
||||
ctx.stroke();
|
||||
const drawStroke = () => {
|
||||
ctx.strokeStyle = grad;
|
||||
ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]);
|
||||
for (let k = 1; k < pts.length; k++) ctx.lineTo(pts[k][0], pts[k][1]);
|
||||
ctx.stroke();
|
||||
};
|
||||
if (window.LabFX) {
|
||||
LabFX.glow.drawGlow(ctx, drawStroke, { color: '#06D6E0', intensity: 6 });
|
||||
} else {
|
||||
drawStroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
@@ -1266,11 +1331,19 @@ class EMFieldSim {
|
||||
pts.push({ x, y });
|
||||
}
|
||||
if (pts.length < 3) continue;
|
||||
ctx.shadowColor = `rgba(${col},0.5)`; ctx.shadowBlur = 7;
|
||||
ctx.strokeStyle = `rgba(${col},0.65)`; ctx.lineWidth = 1.6;
|
||||
ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y);
|
||||
for (let pi = 1; pi < pts.length; pi++) ctx.lineTo(pts[pi].x, pts[pi].y);
|
||||
ctx.stroke();
|
||||
const drawBLine = () => {
|
||||
ctx.shadowColor = `rgba(${col},0.5)`; ctx.shadowBlur = 7;
|
||||
ctx.strokeStyle = `rgba(${col},0.65)`; ctx.lineWidth = 1.6;
|
||||
ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y);
|
||||
for (let pi = 1; pi < pts.length; pi++) ctx.lineTo(pts[pi].x, pts[pi].y);
|
||||
ctx.stroke();
|
||||
};
|
||||
if (window.LabFX) {
|
||||
const bGlowCol = src.I > 0 ? '#06D6E0' : '#9B5DE5';
|
||||
LabFX.glow.drawGlow(ctx, drawBLine, { color: bGlowCol, intensity: 6 });
|
||||
} else {
|
||||
drawBLine();
|
||||
}
|
||||
this._drawBArrows(ctx, pts, col);
|
||||
}
|
||||
}
|
||||
@@ -1298,6 +1371,7 @@ class EMFieldSim {
|
||||
/* ── B vector field ── */
|
||||
_drawVectorsB(ctx) {
|
||||
const step = 42;
|
||||
const _pulse = (window.LabFX) ? (0.7 + 0.3 * LabFX.glow.pulse(performance.now(), 2000)) : 1;
|
||||
ctx.save();
|
||||
for (let px = step*0.5; px < this.W; px += step) {
|
||||
for (let py = step*0.5; py < this.H; py += step) {
|
||||
@@ -1306,7 +1380,7 @@ class EMFieldSim {
|
||||
const t = Math.min(1, Math.log10(1 + mag * 0.006) / 1.4);
|
||||
const len = 8 + t * 14;
|
||||
const nx = bx / mag, ny = by / mag;
|
||||
const alp = 0.28 + t * 0.6;
|
||||
const alp = (0.28 + t * 0.6) * _pulse;
|
||||
ctx.save();
|
||||
ctx.translate(px, py); ctx.rotate(Math.atan2(ny, nx));
|
||||
ctx.globalAlpha = alp;
|
||||
|
||||
@@ -127,7 +127,9 @@ class EquilibriumSim {
|
||||
|
||||
_tick() {
|
||||
if (!this.playing) return;
|
||||
this._raf = requestAnimationFrame(() => {
|
||||
this._raf = requestAnimationFrame((ts) => {
|
||||
const dt = 0.016; // ~60fps fixed step for LabFX
|
||||
if (window.LabFX) LabFX.particles.update(dt);
|
||||
for (let i = 0; i < 3; i++) this._step();
|
||||
this.draw();
|
||||
this._tick();
|
||||
@@ -256,7 +258,25 @@ class EquilibriumSim {
|
||||
}
|
||||
}
|
||||
|
||||
if (toRemove.size) this.particles = this.particles.filter(p => !toRemove.has(p.id));
|
||||
if (toRemove.size) {
|
||||
// LabFX: throttled tick sound + spark on each collision
|
||||
if (window.LabFX && toRemove.size > 0) {
|
||||
const now = performance.now();
|
||||
if (!this._lastFxTick || now - this._lastFxTick > 200) {
|
||||
this._lastFxTick = now;
|
||||
LabFX.sound.play('tick', { volume: 0.1 });
|
||||
}
|
||||
for (const id of toRemove) {
|
||||
const hit = this.particles.find(p => p.id === id);
|
||||
if (hit) {
|
||||
LabFX.particles.emit({ ctx: this.ctx, x: hit.x, y: hit.y, count: 2,
|
||||
color: '#FFD166', speed: 30, spread: 3.14, angle: 0,
|
||||
gravity: 0, life: 250, shape: 'spark', glow: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
this.particles = this.particles.filter(p => !toRemove.has(p.id));
|
||||
}
|
||||
for (const p of toAdd) this.particles.push(p);
|
||||
this.flashes = this.flashes.filter(f => ++f.t < f.maxT);
|
||||
|
||||
@@ -328,6 +348,8 @@ class EquilibriumSim {
|
||||
ctx.font = "bold 11px 'Manrope', system-ui, sans-serif";
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('A + B \u21CC C + D', simW / 2, H - 12);
|
||||
|
||||
if (window.LabFX) LabFX.particles.draw(ctx);
|
||||
}
|
||||
|
||||
_drawParticle(ctx, p) {
|
||||
@@ -497,10 +519,12 @@ if (typeof module !== 'undefined') module.exports = EquilibriumSim;
|
||||
const ids = { T: 'eq-T-val', Ea_f: 'eq-Eaf-val', Ea_r: 'eq-Ear-val' };
|
||||
const el = document.getElementById(ids[name]);
|
||||
if (el) el.textContent = v;
|
||||
if (name === 'T' && window.LabFX) LabFX.sound.play('whoosh', { pitch: v / 300, volume: 0.3 });
|
||||
if (eqSim) eqSim.setParams({ [name]: v });
|
||||
}
|
||||
|
||||
function eqPreset(name) {
|
||||
if (window.LabFX) LabFX.sound.play('pour', { volume: 0.3 });
|
||||
if (eqSim) { eqSim.preset(name); eqSim.play(); }
|
||||
const defs = { default: [300,50,55], exothermic: [280,35,65], endothermic: [350,65,35], excess_A: [300,50,55] };
|
||||
const d = defs[name] || defs.default;
|
||||
|
||||
@@ -185,6 +185,15 @@ class FlaskSim {
|
||||
this._h2 = 0;
|
||||
this._bubTmr = 0;
|
||||
this._boomCD = 0;
|
||||
|
||||
if (window.LabFX) {
|
||||
LabFX.sound.play('bounce', { pitch: 0.6 });
|
||||
// Brief delay then fizz as metal hits acid
|
||||
setTimeout(() => { if (window.LabFX) LabFX.sound.play('fizz'); }, 350);
|
||||
LabFX.particles.emit({ ctx: this.ctx, x: cx, y: nt - 5, count: 6,
|
||||
color: '#FFFFFF', speed: 25, spread: 1.6, angle: -Math.PI / 2,
|
||||
gravity: -60, life: 1500, shape: 'ring' });
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
@@ -210,7 +219,7 @@ class FlaskSim {
|
||||
this.draw();
|
||||
}
|
||||
|
||||
togglePause() { this._paused = !this._paused; }
|
||||
togglePause() { this._paused = !this._paused; if (window.LabFX) LabFX.sound.play('click'); }
|
||||
toggleFlame() { this._flameOn = !this._flameOn; }
|
||||
setMetal(t) { this.metalType = t; }
|
||||
setAcid(t) { this.acidType = t; this.reset(); }
|
||||
@@ -230,6 +239,7 @@ class FlaskSim {
|
||||
_tick(now) {
|
||||
const dt = Math.min((now - this._last) / 1000, 0.05);
|
||||
this._last = now;
|
||||
if (window.LabFX) LabFX.particles.update(dt);
|
||||
if (!this._paused) {
|
||||
this._wave += dt * 1.7;
|
||||
this._wave2 += dt * 2.3;
|
||||
@@ -503,6 +513,12 @@ class FlaskSim {
|
||||
r: 3 + Math.random() * 5, col: i < 30 ? '#FFD166' : '#FF6B35', life: 1,
|
||||
});
|
||||
}
|
||||
if (window.LabFX) {
|
||||
LabFX.sound.play('whoosh', { pitch: 1.5 });
|
||||
LabFX.particles.emit({ ctx: this.ctx, x: cx, y: nt - 10, count: 20,
|
||||
color: '#FFA500', speed: 80, spread: 2.0, angle: -Math.PI / 2,
|
||||
gravity: -100, life: 300, shape: 'spark', glow: true });
|
||||
}
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
@@ -549,6 +565,7 @@ class FlaskSim {
|
||||
this._drawH2Bar(ctx);
|
||||
this._drawInfoPanel(ctx);
|
||||
if (!this._metal || this._metal.mass <= 0.01) this._drawHint(ctx);
|
||||
if (window.LabFX) LabFX.particles.draw(ctx);
|
||||
}
|
||||
|
||||
/* ── Тень/отражение колбы на столе ── */
|
||||
|
||||
@@ -178,6 +178,8 @@ class ForceSandboxSim {
|
||||
forces: [],
|
||||
};
|
||||
this.bodies.push(body);
|
||||
/* LabFX: spawn sound */
|
||||
if (window.LabFX) LabFX.sound.play('click', { pitch: 1.3 });
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -602,10 +604,11 @@ class ForceSandboxSim {
|
||||
/* ── Tick ─────────────────────────────────────────────────── */
|
||||
|
||||
_tick(now) {
|
||||
let dt = Math.min((now - this._last) / 1000, 0.05);
|
||||
let rawDt = Math.min((now - this._last) / 1000, 0.05);
|
||||
this._last = now;
|
||||
if (window.LabFX) LabFX.particles.update(rawDt);
|
||||
if (this._paused) { this.draw(); return; }
|
||||
dt *= this.timeScale;
|
||||
const dt = rawDt * this.timeScale;
|
||||
this._simTime += dt;
|
||||
this._step(dt);
|
||||
this.draw();
|
||||
@@ -1183,6 +1186,15 @@ class ForceSandboxSim {
|
||||
if (!a.pinned) this._applyImpulse(a, -J, nx, ny, rAx, rAy);
|
||||
if (!b.pinned) this._applyImpulse(b, J, nx, ny, rBx, rBy);
|
||||
const keAfter = 0.5 * a.mass * (a.vx * a.vx + a.vy * a.vy) + 0.5 * b.mass * (b.vx * b.vx + b.vy * b.vy);
|
||||
/* LabFX: body collision */
|
||||
if (window.LabFX && J > 0.5) {
|
||||
LabFX.sound.play('bounce');
|
||||
LabFX.particles.emit({
|
||||
ctx: this.ctx, x: cx, y: cy,
|
||||
count: 8, color: '#FFF', speed: 60,
|
||||
spread: Math.PI * 2, life: 300, shape: 'spark', glow: true,
|
||||
});
|
||||
}
|
||||
this._energyLoss += Math.max(0, keBefore - keAfter) / (S * S);
|
||||
|
||||
// Трение между телами
|
||||
@@ -1393,7 +1405,11 @@ class ForceSandboxSim {
|
||||
if (this.ramp) this._drawRamp(ctx);
|
||||
if (this.showTrail) this._drawTrails(ctx);
|
||||
this._drawRopes(ctx);
|
||||
this._drawSprings(ctx);
|
||||
if (window.LabFX && this.springs.length > 0) {
|
||||
LabFX.glow.drawGlow(ctx, () => this._drawSprings(ctx), { color: '#9B5DE5', intensity: 4 });
|
||||
} else {
|
||||
this._drawSprings(ctx);
|
||||
}
|
||||
this._drawBodies(ctx);
|
||||
if (this.showForces) this._drawForceArrows(ctx);
|
||||
if (this.showVelocity) this._drawVelocities(ctx);
|
||||
@@ -1402,6 +1418,8 @@ class ForceSandboxSim {
|
||||
if (this.showEnergy) this._drawEnergyBar(ctx);
|
||||
if (this._ghostPos && !this._drag && !this._hovered && this.tool !== 'erase') this._drawGhost(ctx);
|
||||
if (this.bodies.length === 0) this._drawHint(ctx);
|
||||
/* LabFX: particles overlay */
|
||||
if (window.LabFX) LabFX.particles.draw(this.ctx);
|
||||
}
|
||||
|
||||
_drawBg(ctx, W, H) {
|
||||
|
||||
+24
-1
@@ -24,6 +24,10 @@ class GasSim {
|
||||
this._hover = null; // hovered particle
|
||||
this._pistonDrag = false;
|
||||
|
||||
// LabFX throttle
|
||||
this._fxPressureTimer = 0;
|
||||
this._fxLastT = 0;
|
||||
|
||||
canvas.addEventListener('mousemove', e => this._onMouseMove(e));
|
||||
canvas.addEventListener('mouseleave', () => { this._hover = null; this._pistonDrag = false; });
|
||||
canvas.addEventListener('mousedown', e => this._onMouseDown(e));
|
||||
@@ -116,9 +120,21 @@ class GasSim {
|
||||
stop() { cancelAnimationFrame(this._raf); this._raf = null; }
|
||||
|
||||
// ── simulation ──────────────────────────────────────────────────────────────
|
||||
_loop() {
|
||||
_loop(now) {
|
||||
const dt = this._fxLastT ? Math.min(now - this._fxLastT, 80) : 16;
|
||||
this._fxLastT = now;
|
||||
this._step();
|
||||
this._step();
|
||||
if (window.LabFX) {
|
||||
LabFX.particles.update(dt);
|
||||
// throttled pressure tick sound (~every 150ms, proportional to pressure)
|
||||
this._fxPressureTimer += dt;
|
||||
if (this._fxPressureTimer >= 150) {
|
||||
this._fxPressureTimer = 0;
|
||||
const P = parseFloat(this.info().P);
|
||||
if (P > 5) LabFX.sound.play('tick', { volume: 0.05 });
|
||||
}
|
||||
}
|
||||
this.draw();
|
||||
this._raf = requestAnimationFrame(this._loop);
|
||||
}
|
||||
@@ -318,6 +334,8 @@ class GasSim {
|
||||
|
||||
// Histogram
|
||||
this._drawHistogram(ctx, W, H);
|
||||
|
||||
if (window.LabFX) LabFX.particles.draw(ctx);
|
||||
}
|
||||
|
||||
_drawPiston(ctx, pistonX, H) {
|
||||
@@ -517,6 +535,7 @@ class GasSim {
|
||||
}
|
||||
|
||||
function molReset() {
|
||||
if (window.LabFX) LabFX.sound.play('click');
|
||||
if (_molMode === 'gas' && gasSim) {
|
||||
gasSim.reset();
|
||||
document.getElementById('sl-gPiston').value = 100;
|
||||
@@ -620,6 +639,10 @@ class GasSim {
|
||||
function statesPreset(t) {
|
||||
document.getElementById('sl-stT').value = Math.round(t * 100);
|
||||
document.getElementById('st-T').textContent = t.toFixed(2);
|
||||
if (window.LabFX) {
|
||||
const stateIdx = t < 0.2 ? 0 : t < 0.5 ? 1 : 2;
|
||||
LabFX.sound.play('whoosh', { pitch: [0.7, 1.0, 1.3][stateIdx], volume: 0.3 });
|
||||
}
|
||||
if (statesSim) statesSim.setT(t);
|
||||
}
|
||||
|
||||
|
||||
@@ -644,6 +644,8 @@ class GeoSim {
|
||||
if (this._pendingCircRef) this._drawLineRefHighlight(ctx, this._pendingCircRef);
|
||||
// Индикатор снапа
|
||||
if (this._snapPt) this._drawSnapIndicator(ctx);
|
||||
// LabFX particles
|
||||
if (window.LabFX) LabFX.particles.draw(ctx);
|
||||
}
|
||||
|
||||
_drawBg(ctx, W, H) {
|
||||
@@ -1437,20 +1439,28 @@ class GeoSim {
|
||||
_drawLocus(ctx, obj) {
|
||||
const pts = obj.samples;
|
||||
if (!pts || pts.length < 2) return;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = obj.style && obj.style.color ? obj.style.color : '#F59E0B';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.globalAlpha = 0.65;
|
||||
ctx.setLineDash([]);
|
||||
ctx.beginPath();
|
||||
const first = this.vp.toCanvas(pts[0].x, pts[0].y);
|
||||
ctx.moveTo(first.x, first.y);
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const p = this.vp.toCanvas(pts[i].x, pts[i].y);
|
||||
ctx.lineTo(p.x, p.y);
|
||||
const locusColor = obj.style && obj.style.color ? obj.style.color : '#F59E0B';
|
||||
const drawPolyline = () => {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = locusColor;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.globalAlpha = 0.65;
|
||||
ctx.setLineDash([]);
|
||||
ctx.beginPath();
|
||||
const first = this.vp.toCanvas(pts[0].x, pts[0].y);
|
||||
ctx.moveTo(first.x, first.y);
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const p = this.vp.toCanvas(pts[i].x, pts[i].y);
|
||||
ctx.lineTo(p.x, p.y);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
};
|
||||
if (window.LabFX) {
|
||||
LabFX.glow.drawGlow(ctx, drawPolyline, { color: '#F59E0B', intensity: 8 });
|
||||
} else {
|
||||
drawPolyline();
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/* ── Предпросмотр (строящийся объект) ─────────────────────── */
|
||||
@@ -1801,6 +1811,7 @@ class GeoSim {
|
||||
this._pending = [];
|
||||
this._preview = null;
|
||||
if (this.onUpdate) this.onUpdate(this.getStats());
|
||||
if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.4, volume: 0.3 });
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1816,6 +1827,7 @@ class GeoSim {
|
||||
this._pending = [];
|
||||
this._preview = null;
|
||||
if (this.onUpdate) this.onUpdate(this.getStats());
|
||||
if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.4, volume: 0.3 });
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -2777,6 +2789,12 @@ class GeoSim {
|
||||
const pt = this.eng.add({ type:'point', x:m.x, y:m.y, label:this._nextLabel(),
|
||||
style:{color:'#9B5DE5', size:5} });
|
||||
if (this.onUpdate) this.onUpdate(this.getStats());
|
||||
if (window.LabFX) {
|
||||
LabFX.sound.play('tick', { pitch: 1.4, volume: 0.3 });
|
||||
const cp = this.vp.toCanvas(m.x, m.y);
|
||||
LabFX.particles.emit({ ctx: this.ctx, x: cp.x, y: cp.y, count: 4, color: '#9B5DE5', shape: 'dust', life: 400, speed: 35, spread: Math.PI * 2, gravity: 0, glow: true });
|
||||
if (this._fxFrames !== undefined) this._fxFrames = 60;
|
||||
}
|
||||
return pt;
|
||||
}
|
||||
|
||||
@@ -3079,6 +3097,7 @@ class GeoSim {
|
||||
document.querySelectorAll('.geo-tool-btn').forEach(b => b.classList.remove('active'));
|
||||
if (btnEl) btnEl.classList.add('active');
|
||||
_geoShowHint(name);
|
||||
if (window.LabFX) LabFX.sound.play('click', { pitch: 1.1 });
|
||||
}
|
||||
|
||||
const _GEO_PHASE_HINTS = {
|
||||
@@ -3177,6 +3196,7 @@ class GeoSim {
|
||||
const prev = hint.textContent;
|
||||
hint.textContent = msg;
|
||||
hint.style.color = '#f87171';
|
||||
if (window.LabFX) LabFX.sound.play('fizz', { pitch: 0.5, volume: 0.2 });
|
||||
setTimeout(() => {
|
||||
hint.textContent = prev;
|
||||
hint.style.color = '';
|
||||
@@ -3235,6 +3255,19 @@ class GeoSim {
|
||||
const el = document.getElementById('geo-tog-' + p);
|
||||
if (el) el.classList.toggle('on', !!geomSim[p]);
|
||||
});
|
||||
|
||||
// LabFX particle RAF loop
|
||||
if (!geomSim._fxRaf && window.LabFX) {
|
||||
let _fxLast = performance.now();
|
||||
geomSim._fxFrames = 0;
|
||||
const _fxLoop = (now) => {
|
||||
const dt = (now - _fxLast) / 1000; _fxLast = now;
|
||||
LabFX.particles.update(dt);
|
||||
if (geomSim._fxFrames > 0) { geomSim._fxFrames--; geomSim.render(); }
|
||||
geomSim._fxRaf = requestAnimationFrame(_fxLoop);
|
||||
};
|
||||
geomSim._fxRaf = requestAnimationFrame(_fxLoop);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -3655,49 +3688,62 @@ class GeoSim {
|
||||
outer.appendChild(label);
|
||||
setTimeout(() => label.remove(), 2400);
|
||||
|
||||
// Confetti particles on canvas
|
||||
if (!geomSim) return;
|
||||
const canvas = geomSim.canvas;
|
||||
const ctx = geomSim.ctx;
|
||||
const W = canvas.width, H = canvas.height;
|
||||
const particles = [];
|
||||
const colors = ['#4ADE80', '#34D399', '#A78BFA', '#60A5FA', '#FBBF24', '#F472B6'];
|
||||
for (let i = 0; i < 60; i++) {
|
||||
particles.push({
|
||||
x: W / 2 + (Math.random() - 0.5) * W * 0.4,
|
||||
y: H / 2 + (Math.random() - 0.5) * H * 0.3,
|
||||
vx: (Math.random() - 0.5) * 5,
|
||||
vy: (Math.random() - 0.6) * 6,
|
||||
r: 3 + Math.random() * 4,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
alpha: 1,
|
||||
rot: Math.random() * Math.PI * 2,
|
||||
rotV: (Math.random() - 0.5) * 0.3,
|
||||
});
|
||||
}
|
||||
|
||||
let frame = 0;
|
||||
const maxFrames = 60;
|
||||
function burst() {
|
||||
if (frame >= maxFrames) { geomSim.render(); return; }
|
||||
geomSim.render();
|
||||
for (const p of particles) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = p.alpha;
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.rot);
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.fillRect(-p.r / 2, -p.r / 2, p.r, p.r * 1.6);
|
||||
ctx.restore();
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.vy += 0.18; // gravity
|
||||
p.alpha -= 1 / maxFrames;
|
||||
p.rot += p.rotV;
|
||||
if (window.LabFX) {
|
||||
// Migrate to LabFX particles
|
||||
const ctx = geomSim.ctx;
|
||||
const W = geomSim.vp.W, H = geomSim.vp.H;
|
||||
const confettiColors = ['#4ADE80', '#34D399', '#A78BFA', '#60A5FA', '#FBBF24', '#F472B6'];
|
||||
confettiColors.forEach(color => {
|
||||
LabFX.particles.emit({ ctx, x: W / 2, y: H / 2, count: 10, color, shape: 'spark', spread: Math.PI * 2, life: 1600, speed: 180, gravity: 200, glow: true });
|
||||
});
|
||||
LabFX.sound.play('chime');
|
||||
LabFX.haptic([15, 30, 15, 30, 15]);
|
||||
if (geomSim._fxFrames !== undefined) geomSim._fxFrames = 120;
|
||||
} else {
|
||||
// Fallback: original confetti
|
||||
const canvas = geomSim.canvas;
|
||||
const ctx = geomSim.ctx;
|
||||
const W = canvas.width, H = canvas.height;
|
||||
const particles = [];
|
||||
const colors = ['#4ADE80', '#34D399', '#A78BFA', '#60A5FA', '#FBBF24', '#F472B6'];
|
||||
for (let i = 0; i < 60; i++) {
|
||||
particles.push({
|
||||
x: W / 2 + (Math.random() - 0.5) * W * 0.4,
|
||||
y: H / 2 + (Math.random() - 0.5) * H * 0.3,
|
||||
vx: (Math.random() - 0.5) * 5,
|
||||
vy: (Math.random() - 0.6) * 6,
|
||||
r: 3 + Math.random() * 4,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
alpha: 1,
|
||||
rot: Math.random() * Math.PI * 2,
|
||||
rotV: (Math.random() - 0.5) * 0.3,
|
||||
});
|
||||
}
|
||||
let frame = 0;
|
||||
const maxFrames = 60;
|
||||
function burst() {
|
||||
if (frame >= maxFrames) { geomSim.render(); return; }
|
||||
geomSim.render();
|
||||
for (const p of particles) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = p.alpha;
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.rot);
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.fillRect(-p.r / 2, -p.r / 2, p.r, p.r * 1.6);
|
||||
ctx.restore();
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.vy += 0.18;
|
||||
p.alpha -= 1 / maxFrames;
|
||||
p.rot += p.rotV;
|
||||
}
|
||||
frame++;
|
||||
requestAnimationFrame(burst);
|
||||
}
|
||||
frame++;
|
||||
requestAnimationFrame(burst);
|
||||
}
|
||||
requestAnimationFrame(burst);
|
||||
}
|
||||
|
||||
|
||||
+33
-17
@@ -47,8 +47,10 @@ class GraphSim {
|
||||
try {
|
||||
const fn = this._compile(expr);
|
||||
fn(0); fn(1); fn(-1); fn(Math.PI); // smoke-test
|
||||
const wasNull = !this.fns[idx];
|
||||
this.fns[idx] = { color, fn };
|
||||
this.draw();
|
||||
if (wasNull && window.LabFX) LabFX.sound.play('chime', { pitch: 1.5, volume: 0.3 });
|
||||
return null;
|
||||
} catch {
|
||||
this.fns[idx] = null;
|
||||
@@ -369,28 +371,36 @@ class GraphSim {
|
||||
const dx = (x1 - x0) / steps;
|
||||
const maxJmp = (H / this.scl) * 2; // discontinuity threshold (math units)
|
||||
|
||||
c.strokeStyle = color;
|
||||
c.lineWidth = 2.5;
|
||||
c.lineJoin = 'round';
|
||||
c.beginPath();
|
||||
const drawPath = () => {
|
||||
c.strokeStyle = color;
|
||||
c.lineWidth = 2.5;
|
||||
c.lineJoin = 'round';
|
||||
c.beginPath();
|
||||
|
||||
let pen = false, pyPrev = null;
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const mx = x0 + i * dx;
|
||||
let my;
|
||||
try { my = fn(mx); } catch { pen = false; pyPrev = null; continue; }
|
||||
if (!isFinite(my) || isNaN(my)) { pen = false; pyPrev = null; continue; }
|
||||
let pen = false, pyPrev = null;
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const mx = x0 + i * dx;
|
||||
let my;
|
||||
try { my = fn(mx); } catch { pen = false; pyPrev = null; continue; }
|
||||
if (!isFinite(my) || isNaN(my)) { pen = false; pyPrev = null; continue; }
|
||||
|
||||
// discontinuity guard
|
||||
if (pen && pyPrev !== null && Math.abs(my - pyPrev) > maxJmp) {
|
||||
pen = false;
|
||||
// discontinuity guard
|
||||
if (pen && pyPrev !== null && Math.abs(my - pyPrev) > maxJmp) {
|
||||
pen = false;
|
||||
}
|
||||
|
||||
const [px, py] = this._toPx(mx, my);
|
||||
pen ? c.lineTo(px, py) : c.moveTo(px, py);
|
||||
pen = true; pyPrev = my;
|
||||
}
|
||||
c.stroke();
|
||||
};
|
||||
|
||||
const [px, py] = this._toPx(mx, my);
|
||||
pen ? c.lineTo(px, py) : c.moveTo(px, py);
|
||||
pen = true; pyPrev = my;
|
||||
if (window.LabFX) {
|
||||
LabFX.glow.drawGlow(c, drawPath, { color, intensity: 4 });
|
||||
} else {
|
||||
drawPath();
|
||||
}
|
||||
c.stroke();
|
||||
}
|
||||
|
||||
/* ── hover crosshair ───────────────────────── */
|
||||
@@ -579,9 +589,15 @@ class GraphSim {
|
||||
|
||||
/* debounced formula update */
|
||||
const _debounce = {};
|
||||
let _graphSoundTs = 0;
|
||||
function updateFn(idx) {
|
||||
clearTimeout(_debounce[idx]);
|
||||
renderPreview(idx); // instant preview
|
||||
const now = performance.now();
|
||||
if (window.LabFX && now - _graphSoundTs > 80) {
|
||||
_graphSoundTs = now;
|
||||
LabFX.sound.play('tick', { pitch: 1.0, volume: 0.1 });
|
||||
}
|
||||
_debounce[idx] = setTimeout(() => {
|
||||
if (!gSim) return;
|
||||
const raw = document.getElementById('fn' + idx).value;
|
||||
|
||||
@@ -141,7 +141,13 @@ class GraphTransformSim {
|
||||
this._drawGrid(ctx, W, H);
|
||||
this._drawAxes(ctx, W, H);
|
||||
this._drawCurve(ctx, W, H, x => this._fBase(x), 'rgba(255,255,255,0.18)', 2); // original faded
|
||||
this._drawCurve(ctx, W, H, x => this._fTransformed(x), '#9B5DE5', 2.5); // transformed bold
|
||||
if (window.LabFX) {
|
||||
LabFX.glow.drawGlow(ctx, () => {
|
||||
this._drawCurve(ctx, W, H, x => this._fTransformed(x), '#9B5DE5', 2.5);
|
||||
}, { color: '#9B5DE5', intensity: 4 });
|
||||
} else {
|
||||
this._drawCurve(ctx, W, H, x => this._fTransformed(x), '#9B5DE5', 2.5);
|
||||
}
|
||||
this._drawEquation(ctx, W, H);
|
||||
if (this.hx !== null) this._drawHover(ctx, W, H);
|
||||
}
|
||||
@@ -374,10 +380,16 @@ class GraphTransformSim {
|
||||
}));
|
||||
}
|
||||
|
||||
let _gtSoundTs = 0;
|
||||
function gtParam(name, val) {
|
||||
const v = parseFloat(val);
|
||||
document.getElementById('gt-' + name + '-val').textContent = v % 1 === 0 ? v : v.toFixed(1);
|
||||
if (gtSim) gtSim.setParams({ [name]: v });
|
||||
const now = performance.now();
|
||||
if (window.LabFX && now - _gtSoundTs > 80) {
|
||||
_gtSoundTs = now;
|
||||
LabFX.sound.play('tick', { volume: 0.1 });
|
||||
}
|
||||
}
|
||||
|
||||
function gtBase(name, btn) {
|
||||
@@ -392,6 +404,7 @@ class GraphTransformSim {
|
||||
document.getElementById('sl-gt-b').value = b; document.getElementById('gt-b-val').textContent = b;
|
||||
document.getElementById('sl-gt-c').value = c; document.getElementById('gt-c-val').textContent = c;
|
||||
if (gtSim) gtSim.setParams({ a, k, b, c });
|
||||
if (window.LabFX) LabFX.sound.play('whoosh', { volume: 0.3 });
|
||||
}
|
||||
|
||||
function _gtUpdateUI(info) {
|
||||
|
||||
@@ -63,8 +63,25 @@ class HeatEngineSim {
|
||||
setTc(v) { this.Tc = Math.max(200, Math.min(this.Th - 10, +v)); this._recompute(); }
|
||||
setCR(v) { this.cr = Math.max(2, Math.min(20, +v)); this._recompute(); }
|
||||
|
||||
start() { if (!this._running) { this._running = true; this._loop(); } }
|
||||
pause() { this._running = false; cancelAnimationFrame(this._raf); }
|
||||
start() {
|
||||
if (!this._running) {
|
||||
this._running = true;
|
||||
this._lastPhase = null;
|
||||
this._fxDroneHandle = null;
|
||||
if (window.LabFX) {
|
||||
this._fxDroneHandle = LabFX.sound.startDrone('drone');
|
||||
}
|
||||
this._loop();
|
||||
}
|
||||
}
|
||||
pause() {
|
||||
this._running = false;
|
||||
cancelAnimationFrame(this._raf);
|
||||
if (window.LabFX && this._fxDroneHandle) {
|
||||
try { this._fxDroneHandle.stop(); } catch {}
|
||||
this._fxDroneHandle = null;
|
||||
}
|
||||
}
|
||||
stop() { this.pause(); this._t = 0; this._drawPv(); this._drawPiston(); }
|
||||
step() { this._t = (this._t + this._speed * 10) % 1; this._drawPv(); this._drawPiston(); }
|
||||
reset() { this.stop(); this._recompute(); }
|
||||
@@ -286,11 +303,64 @@ class HeatEngineSim {
|
||||
if (!this._running) return;
|
||||
this._t = (this._t + this._speed) % 1;
|
||||
this._updateParticles();
|
||||
this._fxUpdate();
|
||||
this._drawPv();
|
||||
this._drawPiston();
|
||||
this._raf = requestAnimationFrame(() => this._loop());
|
||||
}
|
||||
|
||||
_fxUpdate() {
|
||||
if (!window.LabFX) return;
|
||||
const st = this._stateAt(this._t);
|
||||
if (!st) return;
|
||||
|
||||
const pisCtx = this._pisCtx;
|
||||
const W = this._pis.offsetWidth || 300;
|
||||
const H = this._pis.offsetHeight || 300;
|
||||
|
||||
/* phase change tick */
|
||||
if (this._lastPhase !== null && st.phase !== this._lastPhase) {
|
||||
LabFX.sound.play('tick', { pitch: 0.8, volume: 0.2 });
|
||||
/* BDC / TDC bounce — detect segment boundaries (u≈0) */
|
||||
LabFX.sound.play('bounce', { pitch: 0.5, volume: 0.15 });
|
||||
}
|
||||
this._lastPhase = st.phase;
|
||||
|
||||
/* compute piston position for particle emit */
|
||||
const ns = this._nodes;
|
||||
const Vmin = Math.min(...ns.map(n => n.V));
|
||||
const Vmax = Math.max(...ns.map(n => n.V));
|
||||
const Vfrac = (st.V - Vmin) / (Vmax - Vmin || 1);
|
||||
const cylX = W * 0.2, cylW = W * 0.6;
|
||||
const cylTop = H * 0.12, cylBot = H * 0.92;
|
||||
const cylH = cylBot - cylTop;
|
||||
const pistonY = cylTop + cylH * (1 - Vfrac);
|
||||
const resX = W * 0.05, resW = W * 0.12;
|
||||
|
||||
const isHot = st.phase === 'isotherm_hot' || st.phase === 'isochoric_hot' || st.phase === 'isobar_hot';
|
||||
const isCold = st.phase === 'isotherm_cold' || st.phase === 'isochoric_cold' || st.phase === 'isobar_cold';
|
||||
|
||||
if (isHot) {
|
||||
/* red smoke upward from hot reservoir */
|
||||
LabFX.particles.emit({
|
||||
ctx: pisCtx, x: resX + resW / 2, y: pistonY - 5,
|
||||
count: 1, color: 'rgba(255,80,40,0.3)', speed: 15,
|
||||
spread: 0.6, angle: -Math.PI / 2, gravity: -50,
|
||||
life: 1500, shape: 'smoke', size: 6,
|
||||
});
|
||||
} else if (isCold) {
|
||||
/* blue dust downward from cold reservoir */
|
||||
LabFX.particles.emit({
|
||||
ctx: pisCtx, x: resX + resW / 2, y: pistonY + 10,
|
||||
count: 1, color: 'rgba(100,150,255,0.3)', speed: 10,
|
||||
spread: 0.6, angle: Math.PI / 2, gravity: 30,
|
||||
life: 800, shape: 'dust', size: 4,
|
||||
});
|
||||
}
|
||||
|
||||
LabFX.particles.update(this._speed);
|
||||
}
|
||||
|
||||
/* ── interpolated state ──────────────────────────── */
|
||||
|
||||
_stateAt(t) {
|
||||
@@ -824,6 +894,19 @@ class HeatEngineSim {
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('T = ' + Math.round(st.T) + ' K', W * 0.75, cylTop + 16);
|
||||
ctx.fillText('V = ' + this._fmtSci(st.V) + ' m³', W * 0.75, cylTop + 30);
|
||||
|
||||
/* adiabatic shimmer on insulation lines */
|
||||
const isAdia2 = phase === 'adiabat_exp' || phase === 'adiabat_comp';
|
||||
if (isAdia2 && window.LabFX) {
|
||||
const pulse = LabFX.glow.pulse(performance.now() / 1000, 0.6);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.1 + pulse * 0.25;
|
||||
ctx.fillStyle = 'rgba(255,200,0,0.6)';
|
||||
ctx.fillRect(resX, cylTop, resW, cylH);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
if (window.LabFX) LabFX.particles.draw(ctx);
|
||||
}
|
||||
|
||||
_drawHeatArrows(ctx, x0, y0, x1, col, rightward) {
|
||||
|
||||
@@ -86,8 +86,28 @@ class HydroSim {
|
||||
this._vesselShapes = Array.from({ length: n }, (_, i) => ['rect','wide','narrow','trapezoid'][i % 4]);
|
||||
this._recalcVessels(); this._notify();
|
||||
}
|
||||
setValve(open) { this._valveOpen = open; this._recalcVessels(); this._notify(); }
|
||||
addLiquid() { this._liquidFrac = Math.min(0.85, this._liquidFrac + 0.05); this._recalcVessels(); }
|
||||
setValve(open) {
|
||||
this._valveOpen = open;
|
||||
this._recalcVessels();
|
||||
if (window.LabFX) LabFX.sound.play('click', { pitch: 0.7 });
|
||||
this._notify();
|
||||
}
|
||||
addLiquid() {
|
||||
this._liquidFrac = Math.min(0.85, this._liquidFrac + 0.05);
|
||||
this._recalcVessels();
|
||||
if (window.LabFX) {
|
||||
LabFX.sound.play('pour');
|
||||
/* splash at approximate pour point (top-center of tank) */
|
||||
const W = this.W || 600, H = this.H || 400;
|
||||
const pourX = W * 0.27, pourY = H * 0.10;
|
||||
LabFX.particles.emit({
|
||||
ctx: this.ctx, x: pourX, y: pourY,
|
||||
count: 12, color: HydroSim.LIQUIDS[this.liquidKey].color,
|
||||
speed: 60, spread: Math.PI / 1.5, angle: Math.PI / 2,
|
||||
gravity: 150, life: 500, shape: 'splash', size: 3,
|
||||
});
|
||||
}
|
||||
}
|
||||
removeLiquid() { this._liquidFrac = Math.max(0.05, this._liquidFrac - 0.05); this._recalcVessels(); }
|
||||
setBodyShape(s){ this._bodyShape = s; if (this.mode === 'archimedes') this._archReset(); }
|
||||
addBody() { this._archAddBody(); }
|
||||
@@ -153,7 +173,10 @@ class HydroSim {
|
||||
_loop(t) {
|
||||
if (!this._running) return;
|
||||
this._raf = requestAnimationFrame(ts => this._loop(ts));
|
||||
const dt = this._loopLast ? Math.min((t - this._loopLast) / 1000, 0.05) : 0.016;
|
||||
this._loopLast = t;
|
||||
this._t = t;
|
||||
if (window.LabFX) LabFX.particles.update(dt);
|
||||
this._update(t);
|
||||
this._draw(t);
|
||||
if (t - this._lastNotify > 120) { this._lastNotify = t; this._notify(); }
|
||||
@@ -182,6 +205,7 @@ class HydroSim {
|
||||
case 'communicating': this._drawCommunicating(t); break;
|
||||
case 'archimedes': this._drawArchimedes(t); break;
|
||||
}
|
||||
if (window.LabFX) LabFX.particles.draw(this.ctx);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
@@ -727,6 +751,17 @@ class HydroSim {
|
||||
mat: this.materialKey, submergedFrac: sinks ? 1 : eqFrac,
|
||||
wobble: 0, volume,
|
||||
});
|
||||
|
||||
if (window.LabFX) {
|
||||
const liq2 = HydroSim.LIQUIDS[this.liquidKey];
|
||||
LabFX.sound.play('bounce', { pitch: 0.5 });
|
||||
LabFX.particles.emit({
|
||||
ctx: this.ctx, x, y: waterlineY,
|
||||
count: 20, color: liq2.color, speed: 80,
|
||||
spread: Math.PI / 1.5, angle: -Math.PI / 2,
|
||||
gravity: 200, life: 600, shape: 'splash', size: 3,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_archClear() { this._bodies = []; }
|
||||
@@ -1410,6 +1445,7 @@ class HydroSim {
|
||||
|
||||
function hydroToggleSurface() {
|
||||
if (!hydroSim) return;
|
||||
if (window.LabFX) LabFX.sound.play('chime', { pitch: 1.4, volume: 0.3 });
|
||||
const next = hydroSim._stMode === 'capillary' ? 'drop' : 'capillary';
|
||||
hydroSim._stMode = next;
|
||||
const label = next === 'capillary' ? '\u041A\u0430\u043F\u0438\u043B\u043B\u044F\u0440\u044B' : '\u041A\u0430\u043F\u043B\u044F';
|
||||
|
||||
@@ -163,6 +163,16 @@ class IonExSim {
|
||||
start() {
|
||||
if (this._phase !== 'idle') this.reset();
|
||||
this._phase = 'mixing'; this._prog = 0;
|
||||
if (window.LabFX) {
|
||||
LabFX.sound.play('pour');
|
||||
const { W, H } = this;
|
||||
LabFX.particles.emit({ ctx: this.ctx, x: W * 0.3, y: H * 0.4, count: 10,
|
||||
color: '#4CC9F0', speed: 40, spread: 2.0, angle: 0,
|
||||
gravity: 120, life: 700, shape: 'splash' });
|
||||
LabFX.particles.emit({ ctx: this.ctx, x: W * 0.7, y: H * 0.4, count: 10,
|
||||
color: '#EF476F', speed: 40, spread: 2.0, angle: 0,
|
||||
gravity: 120, life: 700, shape: 'splash' });
|
||||
}
|
||||
if (this._raf) return;
|
||||
this._last = performance.now();
|
||||
const loop = t => { this._raf = requestAnimationFrame(loop); this._tick(t); };
|
||||
@@ -176,6 +186,7 @@ class IonExSim {
|
||||
_tick(t) {
|
||||
const dt = Math.min((t - this._last) / 1000, 0.05);
|
||||
this._last = t; this._t += dt;
|
||||
if (window.LabFX) LabFX.particles.update(dt);
|
||||
const { W, H } = this;
|
||||
const rxn = IonExSim.RXN[this.rxnId];
|
||||
|
||||
@@ -224,6 +235,16 @@ class IonExSim {
|
||||
if (p.y >= H * 0.78 && !p.settled) {
|
||||
p.y = H * 0.78; p.vy = 0; p.settled = true;
|
||||
this._precip.push({ x: p.x, y: p.y, r: p.r, id: p.id });
|
||||
if (window.LabFX) {
|
||||
const now2 = performance.now();
|
||||
if (!this._fxFizzLast || now2 - this._fxFizzLast > 800) {
|
||||
this._fxFizzLast = now2;
|
||||
LabFX.sound.play('fizz');
|
||||
}
|
||||
LabFX.particles.emit({ ctx: this.ctx, x: p.x, y: H * 0.78, count: 5,
|
||||
color: '#888888', speed: 15, spread: 1.8, angle: -Math.PI / 2,
|
||||
gravity: 30, life: 1200, shape: 'dust' });
|
||||
}
|
||||
}
|
||||
} else if (rxn.type === 'gas') {
|
||||
p.vy = Math.max(p.vy - 0.08, -4);
|
||||
@@ -305,6 +326,7 @@ class IonExSim {
|
||||
this._drawPairs(ctx, rxn);
|
||||
this._drawPrecipitate(ctx, rxn);
|
||||
this._drawPanel(ctx, W, H, rxn);
|
||||
if (window.LabFX) LabFX.particles.draw(ctx);
|
||||
}
|
||||
|
||||
_drawTwoBeakers(ctx, W, H, rxn) {
|
||||
|
||||
@@ -54,7 +54,13 @@ class IsoprocessSim {
|
||||
this.W = w; this.H = h;
|
||||
}
|
||||
|
||||
setProcess(p) { this.process = p; this.draw(); this._emit(); }
|
||||
setProcess(p) {
|
||||
this.process = p;
|
||||
if (window.LabFX) LabFX.sound.play('click');
|
||||
this._dustFrame = 0;
|
||||
this.draw();
|
||||
this._emit();
|
||||
}
|
||||
setGamma(g) { this.gamma = +g; this.draw(); this._emit(); }
|
||||
getParams() { return { P1: this.P1, V1: this.V1, process: this.process }; }
|
||||
setParams({ P1, V1 } = {}) {
|
||||
@@ -158,6 +164,30 @@ class IsoprocessSim {
|
||||
this._drawActiveCurve(ctx);
|
||||
this._drawPoints(ctx);
|
||||
this._drawInfoBox(ctx);
|
||||
/* emit dust particle along PV curve every ~5 draw calls */
|
||||
if (window.LabFX) {
|
||||
this._dustFrame = (this._dustFrame || 0) + 1;
|
||||
if (this._dustFrame % 5 === 0) {
|
||||
const { P2, V2 } = this._state2();
|
||||
/* interpolated point at 50% of curve for dust */
|
||||
const u = ((this._dustFrame / 5) % 10) / 10;
|
||||
let px, py;
|
||||
switch (this.process) {
|
||||
case 'isothermal': { const v = this.V1 + u * (V2 - this.V1); px = this._vx(v); py = this._py(this.P1 * this.V1 / v); break; }
|
||||
case 'isochoric': { px = this._vx(this.V1); py = this._py(this.P1 + u * (P2 - this.P1)); break; }
|
||||
case 'isobaric': { const v2 = this.V1 + u * (V2 - this.V1); px = this._vx(v2); py = this._py(this.P1); break; }
|
||||
case 'adiabatic': { const v3 = this.V1 + u * (V2 - this.V1); px = this._vx(v3); py = this._py(this.P1 * Math.pow(this.V1 / v3, this.gamma)); break; }
|
||||
default: px = this._vx(this.V1); py = this._py(this.P1);
|
||||
}
|
||||
LabFX.particles.emit({
|
||||
ctx, x: px, y: py,
|
||||
count: 1, color: '#06D6E0', speed: 8,
|
||||
spread: Math.PI * 2, angle: 0, gravity: 0,
|
||||
life: 300, shape: 'dust', size: 2,
|
||||
});
|
||||
}
|
||||
LabFX.particles.draw(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
_drawGrid(ctx) {
|
||||
@@ -488,6 +518,7 @@ class IsoprocessSim {
|
||||
document.querySelectorAll('.iso-proc-btn').forEach(b => b.classList.remove('active'));
|
||||
if (el) el.classList.add('active');
|
||||
if (isoSim) isoSim.setProcess(proc);
|
||||
/* sound already emitted inside setProcess */
|
||||
}
|
||||
|
||||
function isoGamma(g, el) {
|
||||
|
||||
@@ -966,6 +966,35 @@
|
||||
}
|
||||
};
|
||||
|
||||
/* ─── Sim Fade Transition + View Transitions API ─────────────────────────
|
||||
Wraps openSim with a fade-out (150ms) → swap → fade-in (200ms) sequence.
|
||||
If document.startViewTransition is available it is used for GPU-composited
|
||||
cross-fade; otherwise the manual .sim-fading CSS class is toggled.
|
||||
The hash-router wrap above runs synchronously during the transition so URL
|
||||
updates are not delayed.
|
||||
──────────────────────────────────────────────────────────────────────── */
|
||||
var _hashRouterOpenSim = openSim; // reference after hash-router wrap
|
||||
openSim = function(id) {
|
||||
var labSim = document.getElementById('lab-sim');
|
||||
if (!labSim) { _hashRouterOpenSim(id); return; }
|
||||
|
||||
function _doSwitch() {
|
||||
labSim.classList.add('sim-fading');
|
||||
setTimeout(function() {
|
||||
_hashRouterOpenSim(id);
|
||||
labSim.classList.remove('sim-fading');
|
||||
}, 150);
|
||||
}
|
||||
|
||||
if (typeof document.startViewTransition === 'function' && !_embedMode) {
|
||||
document.startViewTransition(function() {
|
||||
_hashRouterOpenSim(id);
|
||||
});
|
||||
} else {
|
||||
_doSwitch();
|
||||
}
|
||||
};
|
||||
|
||||
// Intercept closeSim to clear hash when returning to home grid
|
||||
var _origCloseSim = closeSim;
|
||||
closeSim = function() {
|
||||
|
||||
@@ -66,9 +66,11 @@ class LogicSim {
|
||||
|
||||
this._raf = null;
|
||||
this._clockRaf = null;
|
||||
this._fxLastT = 0;
|
||||
|
||||
this._bindEvents();
|
||||
this._startClock();
|
||||
this._startDrawLoop();
|
||||
}
|
||||
|
||||
/* ── port pixel positions ── */
|
||||
@@ -147,6 +149,7 @@ class LogicSim {
|
||||
if (!def) return;
|
||||
this._pushHistory();
|
||||
const g = this._addGate(type, this._snap(x), this._snap(y));
|
||||
if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.4, volume: 0.3 });
|
||||
this._propagate();
|
||||
this._updatePanels();
|
||||
this.draw();
|
||||
@@ -204,6 +207,7 @@ class LogicSim {
|
||||
if (!exists) {
|
||||
this._pushHistory();
|
||||
this._wires.push({ from: { gateId: this._wireStart.gateId, port: this._wireStart.port }, to: { gateId: hitP.gateId, port: hitP.port } });
|
||||
if (window.LabFX) LabFX.sound.play('click', { pitch: 1.3 });
|
||||
this._propagate();
|
||||
this._updatePanels();
|
||||
}
|
||||
@@ -219,6 +223,12 @@ class LogicSim {
|
||||
if (g && (g.type === 'INPUT')) {
|
||||
this._pushHistory();
|
||||
g.value = g.value ? 0 : 1;
|
||||
if (window.LabFX) {
|
||||
LabFX.sound.play('bounce', { pitch: 1.2 });
|
||||
LabFX.particles.emit({ ctx: this._ctx, x: g.x, y: g.y, count: 5, color: '#00ff88',
|
||||
speed: 20, spread: Math.PI * 2, angle: 0, gravity: 0, life: 400, fade: true,
|
||||
glow: true, shape: 'spark', size: 3, sizeFade: true });
|
||||
}
|
||||
this._propagate();
|
||||
this._updatePanels();
|
||||
this.draw();
|
||||
@@ -252,6 +262,7 @@ class LogicSim {
|
||||
this._pushHistory();
|
||||
this._wires = this._wires.filter(w => w.from.gateId !== g.id && w.to.gateId !== g.id);
|
||||
this._gates = this._gates.filter(gg => gg.id !== g.id);
|
||||
if (window.LabFX) LabFX.sound.play('fizz', { volume: 0.2 });
|
||||
this._propagate();
|
||||
this._updatePanels();
|
||||
this.draw();
|
||||
@@ -341,6 +352,7 @@ class LogicSim {
|
||||
const tick = (now) => {
|
||||
this._clockRaf = requestAnimationFrame(tick);
|
||||
const dt = (now - last) / 1000;
|
||||
const dtMs = now - (last || now);
|
||||
last = now;
|
||||
let changed = false;
|
||||
this._gates.forEach(g => {
|
||||
@@ -349,6 +361,7 @@ class LogicSim {
|
||||
const newVal = g._phase % 1 < 0.5 ? 1 : 0;
|
||||
if (newVal !== g.value) { g.value = newVal; changed = true; }
|
||||
});
|
||||
if (window.LabFX && dtMs > 0) LabFX.particles.update(dtMs);
|
||||
if (changed) {
|
||||
this._propagate();
|
||||
this._updatePanels();
|
||||
@@ -435,6 +448,8 @@ class LogicSim {
|
||||
|
||||
// gates
|
||||
this._gates.forEach(g => this._drawGate(ctx, g));
|
||||
|
||||
if (window.LabFX) LabFX.particles.draw(ctx);
|
||||
}
|
||||
|
||||
_drawWire(ctx, w) {
|
||||
@@ -454,6 +469,36 @@ class LogicSim {
|
||||
ctx.strokeStyle = val ? '#00ff88' : 'rgba(255,255,255,0.25)';
|
||||
ctx.lineWidth = val ? 2.2 : 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// Wire HIGH: animated dot flowing along path
|
||||
if (val && window.LabFX) {
|
||||
const frac = ((performance.now() * 0.001) % 1);
|
||||
// interpolate along L-route: seg1 p1→(mx,p1.y), seg2 (mx,p1.y)→(mx,p2.y), seg3 (mx,p2.y)→p2
|
||||
const seg1 = Math.abs(mx - p1.x);
|
||||
const seg2 = Math.abs(p2.y - p1.y);
|
||||
const seg3 = Math.abs(p2.x - mx);
|
||||
const total = seg1 + seg2 + seg3 || 1;
|
||||
const dist = frac * total;
|
||||
let dx, dy;
|
||||
if (dist <= seg1) {
|
||||
dx = p1.x + (mx - p1.x) * (dist / (seg1 || 1));
|
||||
dy = p1.y;
|
||||
} else if (dist <= seg1 + seg2) {
|
||||
dx = mx;
|
||||
dy = p1.y + (p2.y - p1.y) * ((dist - seg1) / (seg2 || 1));
|
||||
} else {
|
||||
dx = mx + (p2.x - mx) * ((dist - seg1 - seg2) / (seg3 || 1));
|
||||
dy = p2.y;
|
||||
}
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(dx, dy, 3.5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#06D6E0';
|
||||
ctx.shadowColor = '#06D6E0';
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
_drawGate(ctx, g) {
|
||||
@@ -461,8 +506,17 @@ class LogicSim {
|
||||
const hw = def.w / 2, hh = def.h / 2;
|
||||
const x = g.x, y = g.y;
|
||||
|
||||
// gate body
|
||||
// OUTPUT LED glow via LabFX
|
||||
const isHigh = g.value === 1;
|
||||
if (g.type === 'OUTPUT' && isHigh && window.LabFX) {
|
||||
LabFX.glow.drawGlow(ctx, () => {
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x - hw, y - hh, def.w, def.h, 6);
|
||||
ctx.fill();
|
||||
}, { color: '#00FF80', intensity: 18, layers: 2 });
|
||||
}
|
||||
|
||||
// gate body
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x - hw, y - hh, def.w, def.h, 6);
|
||||
let fill = 'rgba(30,30,60,0.9)';
|
||||
@@ -618,6 +672,7 @@ class LogicSim {
|
||||
this._gates = [];
|
||||
this._wires = [];
|
||||
this._nextId = 1;
|
||||
if (window.LabFX) LabFX.sound.play('chime', { pitch: 1.0, volume: 0.3 });
|
||||
const add = (type, x, y) => this._addGate(type, x, y);
|
||||
const wire = (a, ap, b, bp) => this._wires.push({ from: { gateId: a.id, port: ap }, to: { gateId: b.id, port: bp } });
|
||||
|
||||
@@ -762,6 +817,20 @@ class LogicSim {
|
||||
this.draw();
|
||||
}
|
||||
|
||||
/* ── Continuous draw loop for wire animations ── */
|
||||
_startDrawLoop() {
|
||||
const loop = () => {
|
||||
this._raf = requestAnimationFrame(loop);
|
||||
// Only redraw if any wire is HIGH (animated dot)
|
||||
const anyHigh = this._wires.some(w => {
|
||||
const g = this._gateById(w.from.gateId);
|
||||
return g && g.value === 1;
|
||||
});
|
||||
if (anyHigh) this.draw();
|
||||
};
|
||||
this._raf = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
/* ── Destroy ── */
|
||||
destroy() {
|
||||
if (this._clockRaf) cancelAnimationFrame(this._clockRaf);
|
||||
|
||||
@@ -149,8 +149,8 @@ class NewtonSim {
|
||||
|
||||
/* ── Публичный API ───────────────────────────────────────── */
|
||||
|
||||
setLaw(n) { this.law = n; this.scene = 'A'; this._resetAll(); if (this.onModeChange) this.onModeChange(); }
|
||||
setScene(s) { this.scene = s; this._resetAll(); }
|
||||
setLaw(n) { this.law = n; this.scene = 'A'; this._resetAll(); if (window.LabFX) LabFX.sound.play('click'); if (this.onModeChange) this.onModeChange(); }
|
||||
setScene(s) { this.scene = s; this._resetAll(); if (window.LabFX) LabFX.sound.play('click'); }
|
||||
setMu(v) { this.mu = v; }
|
||||
setMass1(v) { this.mass1 = v; this._reset3B(); }
|
||||
setMass2(v) { this.mass2 = v; this._reset3B(); }
|
||||
@@ -215,6 +215,7 @@ class NewtonSim {
|
||||
_tick(now) {
|
||||
const dt = Math.min((now - this._last) / 1000, 0.05);
|
||||
this._last = now;
|
||||
if (window.LabFX) LabFX.particles.update(dt);
|
||||
if (!this._paused) {
|
||||
if (this.law === 1 && this.scene === 'A') this._step1A(dt);
|
||||
else if (this.law === 1) this._step1B(dt);
|
||||
@@ -448,6 +449,18 @@ class NewtonSim {
|
||||
p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt * 1.6;
|
||||
}
|
||||
s.particles = s.particles.filter(p => p.life > 0 && p.y < H + 20);
|
||||
|
||||
/* LabFX: rocket flame trail at nozzle */
|
||||
if (window.LabFX) {
|
||||
const nozzleX = W * 0.5;
|
||||
const nozzleY = s.ry + 22;
|
||||
LabFX.particles.emit({
|
||||
ctx: this.ctx, x: nozzleX, y: nozzleY,
|
||||
count: 3, color: ['#FFD166', '#FF6B35', '#EF476F'],
|
||||
speed: 80, spread: Math.PI / 6, angle: Math.PI / 2,
|
||||
gravity: -50, life: 400, glow: true, shape: 'spark',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Мышь ─────────────────────────────────────────────────── */
|
||||
@@ -497,6 +510,9 @@ class NewtonSim {
|
||||
else if (this.scene === 'A') this._drawL3A(ctx);
|
||||
else if (this.scene === 'B') this._drawL3B(ctx);
|
||||
else this._drawL3C(ctx);
|
||||
|
||||
/* LabFX: particles overlay */
|
||||
if (window.LabFX) LabFX.particles.draw(this.ctx);
|
||||
}
|
||||
|
||||
/* ── Закон I — Сцена A ───────────────────────────────────── */
|
||||
@@ -659,9 +675,20 @@ class NewtonSim {
|
||||
this._block(ctx, bx, by, BW, BH, '#EF476F', `${this.mass1} кг`);
|
||||
|
||||
/* Сила F */
|
||||
const _fTipX = bx + BW / 2 + 48 + this.force * 0.9;
|
||||
const _fTipY = by;
|
||||
this._arrow(ctx, bx + BW / 2, by,
|
||||
bx + BW / 2 + 48 + this.force * 0.9, by,
|
||||
_fTipX, _fTipY,
|
||||
'#EF476F', `F = ${this.force} Н`, 2.5);
|
||||
/* LabFX: spark at force arrow tip (scene II A, running) */
|
||||
if (window.LabFX && this._2.running && Math.random() < 0.25) {
|
||||
LabFX.particles.emit({
|
||||
ctx, x: _fTipX, y: _fTipY,
|
||||
count: 5, color: '#FFD166', speed: 40,
|
||||
spread: Math.PI / 2, angle: 0, life: 200,
|
||||
glow: true, shape: 'spark',
|
||||
});
|
||||
}
|
||||
|
||||
/* Ускорение a */
|
||||
const aLen = 32 + a1 * 5;
|
||||
|
||||
@@ -190,11 +190,13 @@ class NormalDistSim {
|
||||
const xp = x => this._xToP(x, xMin, xMax, PL, pw);
|
||||
const yp = y => this._yToP(y, yMax, PT, ph);
|
||||
|
||||
// Filled area with gradient
|
||||
// Filled area with gradient (pulsing alpha when LabFX available)
|
||||
const _pulseA = window.LabFX ? (0.10 + LabFX.glow.pulse(performance.now(), 3000) * 0.12) : 0.10;
|
||||
const _pulseB = window.LabFX ? (0.22 + LabFX.glow.pulse(performance.now(), 3000) * 0.16) : 0.30;
|
||||
const grd = ctx.createLinearGradient(xp(lo), 0, xp(hi), 0);
|
||||
grd.addColorStop(0, 'rgba(155,93,229,0.10)');
|
||||
grd.addColorStop(0.5, 'rgba(155,93,229,0.30)');
|
||||
grd.addColorStop(1, 'rgba(155,93,229,0.10)');
|
||||
grd.addColorStop(0, `rgba(155,93,229,${_pulseA.toFixed(3)})`);
|
||||
grd.addColorStop(0.5, `rgba(155,93,229,${_pulseB.toFixed(3)})`);
|
||||
grd.addColorStop(1, `rgba(155,93,229,${_pulseA.toFixed(3)})`);
|
||||
ctx.fillStyle = grd;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(xp(lo), bottom);
|
||||
@@ -221,23 +223,31 @@ class NormalDistSim {
|
||||
const xp = x => this._xToP(x, xMin, xMax, PL, pw);
|
||||
const yp = y => this._yToP(y, yMax, PT, ph);
|
||||
|
||||
// Glow layer
|
||||
ctx.strokeStyle = 'rgba(155,93,229,0.1)'; ctx.lineWidth = 10; ctx.lineJoin = 'round';
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const x = xMin + i * dx;
|
||||
i === 0 ? ctx.moveTo(xp(x), yp(this._pdf(x))) : ctx.lineTo(xp(x), yp(this._pdf(x)));
|
||||
}
|
||||
ctx.stroke();
|
||||
const drawBell = () => {
|
||||
// Glow layer
|
||||
ctx.strokeStyle = 'rgba(155,93,229,0.1)'; ctx.lineWidth = 10; ctx.lineJoin = 'round';
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const x = xMin + i * dx;
|
||||
i === 0 ? ctx.moveTo(xp(x), yp(this._pdf(x))) : ctx.lineTo(xp(x), yp(this._pdf(x)));
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Main curve
|
||||
ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 2.5; ctx.lineJoin = 'round';
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const x = xMin + i * dx;
|
||||
i === 0 ? ctx.moveTo(xp(x), yp(this._pdf(x))) : ctx.lineTo(xp(x), yp(this._pdf(x)));
|
||||
// Main curve
|
||||
ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 2.5; ctx.lineJoin = 'round';
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const x = xMin + i * dx;
|
||||
i === 0 ? ctx.moveTo(xp(x), yp(this._pdf(x))) : ctx.lineTo(xp(x), yp(this._pdf(x)));
|
||||
}
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
if (window.LabFX) {
|
||||
LabFX.glow.drawGlow(ctx, drawBell, { color: '#9B5DE5', intensity: 4 });
|
||||
} else {
|
||||
drawBell();
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// μ marker
|
||||
const muPx = xp(this.mu);
|
||||
@@ -396,6 +406,7 @@ class NormalDistSim {
|
||||
/* ─── lab UI init ─────────────────────────────────── */
|
||||
var ndSim = null;
|
||||
|
||||
let _ndPulseRaf = null;
|
||||
function _openNormalDist() {
|
||||
document.getElementById('sim-topbar-title').textContent = 'Нормальное распределение';
|
||||
_simShow('sim-normaldist');
|
||||
@@ -409,14 +420,32 @@ class NormalDistSim {
|
||||
ndSim.fit();
|
||||
ndSim.draw();
|
||||
ndSim._emit();
|
||||
// Pulsing loop for shade area animation
|
||||
if (!_ndPulseRaf && window.LabFX) {
|
||||
let _ndLast = performance.now();
|
||||
const pulse = (now) => {
|
||||
const dt = (now - _ndLast) / 1000; _ndLast = now;
|
||||
LabFX.particles.update(dt);
|
||||
if (ndSim && ndSim.shade !== 'none') ndSim.draw();
|
||||
_ndPulseRaf = requestAnimationFrame(pulse);
|
||||
};
|
||||
_ndPulseRaf = requestAnimationFrame(pulse);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
let _ndSoundTs = 0;
|
||||
function ndParam(name, val) {
|
||||
const v = parseFloat(val);
|
||||
const elId = name === 'mu' ? 'nd-mu-val' : 'nd-sigma-val';
|
||||
document.getElementById(elId).textContent = v % 1 === 0 ? v : v.toFixed(1);
|
||||
if (ndSim) ndSim.setParams({ [name]: v });
|
||||
const now = performance.now();
|
||||
if (window.LabFX && now - _ndSoundTs > 80) {
|
||||
_ndSoundTs = now;
|
||||
const sigma = name === 'sigma' ? v : (ndSim ? ndSim.sigma : 1);
|
||||
LabFX.sound.play('tick', { pitch: 0.7 + sigma * 0.3, volume: 0.1 });
|
||||
}
|
||||
}
|
||||
|
||||
function ndShade(mode, btn) {
|
||||
|
||||
@@ -121,6 +121,17 @@ class ThinLensSim {
|
||||
}
|
||||
|
||||
this._drawLabels(ctx, lensX, axisY, d, f, dPrime, hPrime);
|
||||
|
||||
// Lens caustics: emit dust near focal point when image exists
|
||||
if (window.LabFX && dPrime !== null && isFinite(dPrime) && dPrime > 0) {
|
||||
const imgX = lensX + dPrime;
|
||||
if (!this._causticFrame) this._causticFrame = 0;
|
||||
this._causticFrame++;
|
||||
if (this._causticFrame % 4 === 0) {
|
||||
LabFX.particles.emit({ ctx, x: imgX + (Math.random() - 0.5) * 10, y: axisY + (Math.random() - 0.5) * 10, count: 3, color: '#FFD166', speed: 8, spread: Math.PI * 2, life: 500, shape: 'dust', glow: true });
|
||||
}
|
||||
}
|
||||
if (window.LabFX) { LabFX.particles.update(1 / 60); LabFX.particles.draw(ctx); }
|
||||
}
|
||||
|
||||
_drawLens(ctx, lx, ay, f) {
|
||||
@@ -193,9 +204,13 @@ class ThinLensSim {
|
||||
const hasImage = dPrime !== null && isFinite(dPrime);
|
||||
const isVirtual = hasImage && dPrime < 0;
|
||||
ctx.lineWidth = 1.5;
|
||||
const _doGlow = (color, fn) => {
|
||||
if (window.LabFX) LabFX.glow.drawGlow(ctx, fn, { color, intensity: 8 });
|
||||
else fn();
|
||||
};
|
||||
|
||||
// Ray 1: parallel to axis
|
||||
{
|
||||
_doGlow(colors[0], () => {
|
||||
ctx.strokeStyle = colors[0]; ctx.setLineDash([]);
|
||||
ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, objY); ctx.stroke();
|
||||
if (hasImage) {
|
||||
@@ -211,10 +226,10 @@ class ThinLensSim {
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Ray 2: through center
|
||||
{
|
||||
_doGlow(colors[1], () => {
|
||||
ctx.strokeStyle = colors[1]; ctx.setLineDash([]);
|
||||
const slope = (objY - ay) / (objX - lx);
|
||||
const farX = lx + 350, farY = ay + slope * 350;
|
||||
@@ -225,10 +240,10 @@ class ThinLensSim {
|
||||
ctx.beginPath(); ctx.moveTo(lx, ay); ctx.lineTo(backX, backY); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Ray 3: through F
|
||||
{
|
||||
_doGlow(colors[2], () => {
|
||||
ctx.strokeStyle = colors[2]; ctx.setLineDash([]);
|
||||
const fx = lx - f, slope = (objY - ay) / (objX - fx);
|
||||
const hitY = objY + slope * (lx - objX);
|
||||
@@ -240,7 +255,7 @@ class ThinLensSim {
|
||||
ctx.beginPath(); ctx.moveTo(lx, hitY); ctx.lineTo(lx + dPrime, ay - hPrime); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_extendRay(ctx, x1, y1, x2, y2, color) {
|
||||
@@ -298,7 +313,7 @@ class ThinLensSim {
|
||||
else if (this._drag === 'focus') this.f = Math.max(-200, Math.min(200, lx - mx));
|
||||
this.draw(); this._emit();
|
||||
};
|
||||
const onUp = () => { this._drag = null; };
|
||||
const onUp = () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = null; };
|
||||
cv.addEventListener('mousedown', onDown);
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
@@ -622,6 +637,17 @@ class MirrorSim {
|
||||
if (this._showPhotons && this._photons.length) this._drawPhotons(ctx);
|
||||
this._drawTooltip(ctx, mx, ay, f, dPrime, hPrime);
|
||||
if (step >= 0) this._drawStepOverlay(ctx, step);
|
||||
|
||||
// Mirror caustics near focal point when real image exists
|
||||
if (window.LabFX && dPrime !== null && isFinite(dPrime) && dPrime > 0) {
|
||||
const focX = mx - dPrime, focY = ay - (this._pointMode ? 0 : hPrime);
|
||||
if (!this._mCausticFrame) this._mCausticFrame = 0;
|
||||
this._mCausticFrame++;
|
||||
if (this._mCausticFrame % 4 === 0) {
|
||||
LabFX.particles.emit({ ctx, x: focX + (Math.random()-0.5)*10, y: focY + (Math.random()-0.5)*10, count: 2, color: '#FFD166', speed: 6, spread: Math.PI*2, life: 500, shape: 'dust', glow: true });
|
||||
}
|
||||
}
|
||||
if (window.LabFX) { LabFX.particles.update(1/60); LabFX.particles.draw(ctx); }
|
||||
}
|
||||
|
||||
_drawGrid(ctx) {
|
||||
@@ -718,6 +744,7 @@ class MirrorSim {
|
||||
_oneRay(ctx, mx, ox, oy, hitY, color, alpha, hasImg, isReal, imgX, imgY) {
|
||||
if (hitY === null || !isFinite(hitY) || hitY < -this.H || hitY > 2*this.H) return;
|
||||
ctx.save(); ctx.globalAlpha = alpha;
|
||||
if (window.LabFX && alpha > 0.5) { ctx.shadowColor = color; ctx.shadowBlur = 8; }
|
||||
ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([]);
|
||||
ctx.beginPath(); ctx.moveTo(ox, oy); ctx.lineTo(mx, hitY); ctx.stroke();
|
||||
if (!hasImg) { ctx.restore(); return; }
|
||||
@@ -1062,7 +1089,7 @@ class MirrorSim {
|
||||
this.draw(); this._emit();
|
||||
} else if (!this._photonRaf && !this._playing) { this.draw(); }
|
||||
});
|
||||
window.addEventListener('mouseup', () => { this._drag = null; });
|
||||
window.addEventListener('mouseup', () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = null; });
|
||||
cv.addEventListener('mousemove', e => {
|
||||
if (this._drag) { cv.style.cursor='grabbing'; return; }
|
||||
const {px,py}=getPos(e);
|
||||
@@ -1192,6 +1219,24 @@ class RefractionSim {
|
||||
const grad = ctx.createRadialGradient(handleX, handleY, 0, handleX, handleY, 10);
|
||||
grad.addColorStop(0, 'rgba(155,93,229,0.4)'); grad.addColorStop(1, 'rgba(155,93,229,0)');
|
||||
ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(handleX, handleY, 10, 0, Math.PI * 2); ctx.fill();
|
||||
|
||||
// TIR one-shot sound
|
||||
if (window.LabFX) {
|
||||
if (isTIR && !this._wasTIR) {
|
||||
LabFX.sound.play('spark', { volume: 0.2 });
|
||||
}
|
||||
this._wasTIR = isTIR;
|
||||
|
||||
// Brewster angle: R ≈ 0 (reflected intensity near zero for s-pol)
|
||||
const _isBrew = !isTIR && R < 0.005 && this.angle > 0;
|
||||
if (_isBrew && !this._wasBrewster) {
|
||||
LabFX.sound.play('chime', { pitch: 1.5, volume: 0.3 });
|
||||
}
|
||||
this._wasBrewster = _isBrew;
|
||||
|
||||
LabFX.particles.update(1 / 60);
|
||||
LabFX.particles.draw(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
_drawMainRays(ctx, hitX, hitY, midY, theta1Rad, sinTheta2, isTIR, R, rayLen) {
|
||||
@@ -1245,11 +1290,15 @@ class RefractionSim {
|
||||
}
|
||||
|
||||
_drawRay(ctx, x1, y1, x2, y2, color, width) {
|
||||
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round';
|
||||
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
|
||||
ctx.save(); ctx.shadowColor = color; ctx.shadowBlur = 8; ctx.globalAlpha = 0.3;
|
||||
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
|
||||
ctx.restore();
|
||||
const drawFn = () => {
|
||||
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round';
|
||||
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
|
||||
ctx.save(); ctx.shadowColor = color; ctx.shadowBlur = 8; ctx.globalAlpha = 0.3;
|
||||
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
|
||||
ctx.restore();
|
||||
};
|
||||
if (window.LabFX) LabFX.glow.drawGlow(ctx, drawFn, { color, intensity: 8 });
|
||||
else drawFn();
|
||||
}
|
||||
|
||||
_drawArrowhead(ctx, x, y, angle, color) {
|
||||
@@ -1341,7 +1390,7 @@ class RefractionSim {
|
||||
this.angle = angleFromMouse(mx, my);
|
||||
this.draw(); this._emit();
|
||||
};
|
||||
const onUp = () => { this._drag = false; };
|
||||
const onUp = () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = false; };
|
||||
cv.addEventListener('mousedown', onDown);
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
@@ -1397,6 +1446,7 @@ function _obApplyState(st) {
|
||||
|
||||
/* Switch between modes — mirrors emSwitchMode pattern */
|
||||
function obSwitchMode(mode, silent) {
|
||||
if (!silent && window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.3, volume: 0.3 });
|
||||
_obMode = mode;
|
||||
|
||||
/* tab button styling */
|
||||
|
||||
@@ -88,6 +88,7 @@ class OrbitalsSim {
|
||||
/* ── public ── */
|
||||
setMode(mode) {
|
||||
this._mode = mode;
|
||||
if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.1, volume: 0.3 });
|
||||
this._buildOrbital(mode);
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ class PendulumSim {
|
||||
this._tSim = 0;
|
||||
this._clearTrail();
|
||||
this._eHistory = [];
|
||||
if (window.LabFX) LabFX.sound.play('click');
|
||||
this.draw();
|
||||
this._emit();
|
||||
}
|
||||
@@ -116,9 +117,17 @@ class PendulumSim {
|
||||
this._lastTs = ts;
|
||||
const dt = rawDt * this.speed;
|
||||
|
||||
const prevOmega = this.omega;
|
||||
this._step(dt);
|
||||
this._tSim += dt;
|
||||
|
||||
if (window.LabFX) LabFX.particles.update(rawDt);
|
||||
|
||||
/* LabFX: tick sound at maximum extension (velocity sign flip) */
|
||||
if (window.LabFX && prevOmega !== 0 && Math.sign(this.omega) !== Math.sign(prevOmega)) {
|
||||
LabFX.sound.play('tick', { pitch: 1.2, volume: 0.2 });
|
||||
}
|
||||
|
||||
// trail
|
||||
const { bx, by } = this._bobPos();
|
||||
this._trail.push({ x: bx, y: by });
|
||||
@@ -195,16 +204,23 @@ class PendulumSim {
|
||||
|
||||
// bob
|
||||
const bobR = 18;
|
||||
ctx.fillStyle = '#9B5DE5';
|
||||
ctx.beginPath(); ctx.arc(bx, by, bobR, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.lineWidth = 2; ctx.stroke();
|
||||
const _drawBob = () => {
|
||||
ctx.fillStyle = '#9B5DE5';
|
||||
ctx.beginPath(); ctx.arc(bx, by, bobR, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.lineWidth = 2; ctx.stroke();
|
||||
|
||||
// glow
|
||||
const grad = ctx.createRadialGradient(bx, by, 0, bx, by, bobR * 2);
|
||||
grad.addColorStop(0, 'rgba(155,93,229,0.25)');
|
||||
grad.addColorStop(1, 'rgba(155,93,229,0)');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath(); ctx.arc(bx, by, bobR * 2, 0, Math.PI * 2); ctx.fill();
|
||||
// glow
|
||||
const grad = ctx.createRadialGradient(bx, by, 0, bx, by, bobR * 2);
|
||||
grad.addColorStop(0, 'rgba(155,93,229,0.25)');
|
||||
grad.addColorStop(1, 'rgba(155,93,229,0)');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath(); ctx.arc(bx, by, bobR * 2, 0, Math.PI * 2); ctx.fill();
|
||||
};
|
||||
if (window.LabFX) {
|
||||
LabFX.glow.drawGlow(ctx, _drawBob, { color: '#9B5DE5', intensity: 8 });
|
||||
} else {
|
||||
_drawBob();
|
||||
}
|
||||
|
||||
// angle arc
|
||||
if (Math.abs(this.theta) > 0.02) {
|
||||
@@ -233,6 +249,9 @@ class PendulumSim {
|
||||
|
||||
// energy chart
|
||||
this._drawEnergyChart(ctx, W, H);
|
||||
|
||||
/* LabFX: particles overlay */
|
||||
if (window.LabFX) LabFX.particles.draw(ctx);
|
||||
}
|
||||
|
||||
_drawTrail(ctx) {
|
||||
|
||||
@@ -62,6 +62,11 @@ class PhotosynthesisSim {
|
||||
this._krebsAngle = 0;
|
||||
this._etcOffset = 0;
|
||||
|
||||
// LabFX throttle timers
|
||||
this._fxPhotonThrottle = 0;
|
||||
this._fxAtpSound = 0;
|
||||
this._fxGlucoseSound = 0;
|
||||
|
||||
// layout (computed in fit)
|
||||
this._layout = {};
|
||||
|
||||
@@ -109,6 +114,7 @@ class PhotosynthesisSim {
|
||||
|
||||
setMode(mode) {
|
||||
this.mode = mode;
|
||||
if (window.LabFX) LabFX.sound.play('whoosh', { volume: 0.3 });
|
||||
this.reset();
|
||||
}
|
||||
|
||||
@@ -168,6 +174,7 @@ class PhotosynthesisSim {
|
||||
this._updateResp(dt);
|
||||
}
|
||||
|
||||
if (window.LabFX) LabFX.particles.update(dt);
|
||||
this._updateParticles(dt);
|
||||
this._draw();
|
||||
this._emitUpdate();
|
||||
@@ -186,6 +193,12 @@ class PhotosynthesisSim {
|
||||
while (this._photonTimer > photonInterval) {
|
||||
this._photonTimer -= photonInterval;
|
||||
this._spawnPhoton();
|
||||
// throttled photon absorption sound (~5/sec max)
|
||||
this._fxPhotonThrottle += photonInterval;
|
||||
if (this._fxPhotonThrottle >= 200 && window.LabFX) {
|
||||
LabFX.sound.play('tick', { pitch: 1.8, volume: 0.1 });
|
||||
this._fxPhotonThrottle = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// H2O splitting (thylakoid)
|
||||
@@ -202,18 +215,40 @@ class PhotosynthesisSim {
|
||||
if (CO > 0.05) this._spawnCO2();
|
||||
}
|
||||
|
||||
// ATP from thylakoid <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> stroma
|
||||
// ATP from thylakoid → stroma
|
||||
this._atpTimer += dt;
|
||||
if (this._atpTimer > 400 / (rate + 0.1)) {
|
||||
this._atpTimer = 0;
|
||||
if (L > 0.05) this._spawnATP();
|
||||
if (L > 0.05) {
|
||||
this._spawnATP();
|
||||
// subtle chime on ATP formation (throttled)
|
||||
this._fxAtpSound += 400 / (rate + 0.1);
|
||||
if (this._fxAtpSound >= 1200 && window.LabFX) {
|
||||
LabFX.sound.play('chime', { pitch: 1.2, volume: 0.15 });
|
||||
this._fxAtpSound = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// G3P output
|
||||
this._glucoseTimer += dt;
|
||||
if (this._glucoseTimer > 800 / (rate * CO + 0.1)) {
|
||||
this._glucoseTimer = 0;
|
||||
if (L > 0.1 && CO > 0.05) this._spawnG3P();
|
||||
if (L > 0.1 && CO > 0.05) {
|
||||
this._spawnG3P();
|
||||
// Calvin cycle complete — glucose sparkle + chime (throttled)
|
||||
this._fxGlucoseSound = (this._fxGlucoseSound || 0) + 1;
|
||||
if (this._fxGlucoseSound >= 3 && window.LabFX) {
|
||||
this._fxGlucoseSound = 0;
|
||||
LabFX.sound.play('chime', { pitch: 0.8, volume: 0.3 });
|
||||
const L2 = this._layout;
|
||||
if (L2.cx) {
|
||||
LabFX.particles.emit({ ctx: this.ctx, x: L2.cx, y: L2.cy - (L2.thylH || 0) * 0.8,
|
||||
count: 8, color: '#FFD166', speed: 28, spread: Math.PI * 2, angle: 0,
|
||||
gravity: -8, life: 700, fade: true, glow: true, shape: 'spark', size: 3, sizeFade: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calvin cycle rotation
|
||||
@@ -435,6 +470,7 @@ class PhotosynthesisSim {
|
||||
|
||||
this._drawParticles();
|
||||
this._drawEquation();
|
||||
if (window.LabFX) LabFX.particles.draw(this.ctx);
|
||||
}
|
||||
|
||||
/* ── Chloroplast ──────────────────────────────────────────── */
|
||||
|
||||
@@ -178,21 +178,36 @@ class ProbabilitySim {
|
||||
|
||||
_tick() {
|
||||
if (!this.playing) return;
|
||||
this._raf = requestAnimationFrame(() => {
|
||||
this._raf = requestAnimationFrame(now => {
|
||||
const dt = (now - (this._lastTickTs || now)) / 1000;
|
||||
this._lastTickTs = now;
|
||||
if (window.LabFX) LabFX.particles.update(dt);
|
||||
|
||||
let added = 0;
|
||||
for (let i = 0; i < this.speed; i++) {
|
||||
if (!this._addTrial()) break;
|
||||
added++;
|
||||
}
|
||||
this._animT += 0.15;
|
||||
if (added > 0) this._shakeT = 1;
|
||||
else this._shakeT *= 0.9;
|
||||
if (added > 0) {
|
||||
this._shakeT = 1;
|
||||
if (window.LabFX && now - (this._lastBounceSoundTs || 0) > 120) {
|
||||
this._lastBounceSoundTs = now;
|
||||
LabFX.sound.play('bounce', { pitch: 1.0 + Math.random() * 0.3 });
|
||||
}
|
||||
} else this._shakeT *= 0.9;
|
||||
|
||||
this.draw();
|
||||
this._emit();
|
||||
|
||||
if (this.results.length >= this.trials) {
|
||||
this.pause();
|
||||
if (window.LabFX) {
|
||||
LabFX.sound.play('chime');
|
||||
const ctx = this.ctx;
|
||||
const W = this.W, H = this.H;
|
||||
LabFX.particles.emit({ ctx, x: W / 2, y: H * 0.4, count: 40, color: '#9B5DE5', shape: 'spark', spread: Math.PI * 2, life: 1400, speed: 120, gravity: 200, glow: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._tick();
|
||||
@@ -216,6 +231,7 @@ class ProbabilitySim {
|
||||
this._drawHistogram(ctx, 0, vizH, W, histH);
|
||||
this._drawConvergence(ctx, 0, vizH + histH, W, convH);
|
||||
this._drawStats(ctx, W, H);
|
||||
if (window.LabFX) LabFX.particles.draw(ctx);
|
||||
}
|
||||
|
||||
/* ── top visual: coin or dice ──────────────── */
|
||||
@@ -591,6 +607,7 @@ if (typeof module !== 'undefined') module.exports = ProbabilitySim;
|
||||
document.querySelectorAll('.prob-mode-btn').forEach(b => b.classList.remove('active'));
|
||||
if (btn) btn.classList.add('active');
|
||||
if (probSim) { probSim.setParams({ mode }); probSim.reset(); probSim.play(); }
|
||||
if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.2, volume: 0.3 });
|
||||
}
|
||||
|
||||
function probPreset(mode, trials) {
|
||||
|
||||
@@ -140,6 +140,21 @@ class ProjectileSim {
|
||||
this._lastTs = null;
|
||||
/* reset p2 at launch so both start simultaneously */
|
||||
if (this.dualMode) { this._p2.t = 0; this._p2.trail = []; }
|
||||
/* LabFX: launch effects */
|
||||
if (window.LabFX) {
|
||||
const _vp = this._viewParams;
|
||||
const _H = _vp ? _vp.H : (this._ch || this.c.height);
|
||||
const _PL = _vp ? _vp.PL : 54, _PB = _vp ? _vp.PB : 44;
|
||||
const launchX = _vp ? _PL : 54;
|
||||
const launchY = _vp ? _H - _PB - (this.h0 || 0) * (_H - _PB - (_vp.PT || 26)) / _vp.yMax : _H - 44;
|
||||
LabFX.sound.play('whoosh');
|
||||
LabFX.particles.emit({
|
||||
ctx: this.ctx, x: launchX, y: launchY,
|
||||
count: 18, color: '#FFD166', speed: 120,
|
||||
spread: Math.PI / 3, angle: -Math.PI / 3,
|
||||
life: 500, glow: true, shape: 'spark',
|
||||
});
|
||||
}
|
||||
this._tick();
|
||||
}
|
||||
|
||||
@@ -228,6 +243,19 @@ class ProjectileSim {
|
||||
tgt.hit = true;
|
||||
tgt.flashTs = performance.now();
|
||||
this._emitTargets();
|
||||
/* LabFX: target hit effects */
|
||||
if (window.LabFX) {
|
||||
const hx = this.tx ? this.tx(st.x) : st.x;
|
||||
const hy = this.ty ? this.ty(st.y) : st.y;
|
||||
LabFX.sound.play('chime');
|
||||
LabFX.particles.emit({
|
||||
ctx: this.ctx, x: hx, y: hy,
|
||||
count: 40, color: ['#FFD700', '#FFA500', '#FF6B35'],
|
||||
speed: 140, spread: Math.PI * 2, life: 900,
|
||||
glow: true, shape: 'spark',
|
||||
});
|
||||
LabFX.haptic([15, 30, 15]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -722,6 +750,8 @@ class ProjectileSim {
|
||||
const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05);
|
||||
this._lastTs = ts;
|
||||
|
||||
if (window.LabFX) LabFX.particles.update(rawDt);
|
||||
|
||||
this._launchFlash = Math.max(0, this._launchFlash - rawDt * 2.5);
|
||||
|
||||
const prevT = this.t;
|
||||
@@ -765,6 +795,26 @@ class ProjectileSim {
|
||||
const spd = 40 + Math.random() * 80;
|
||||
return { ang, spd, mx: end.x };
|
||||
});
|
||||
/* LabFX: landing effects */
|
||||
if (window.LabFX) {
|
||||
const _vp = this._viewParams;
|
||||
const _W = _vp ? _vp.W : (this._cw || this.c.width);
|
||||
const _H = _vp ? _vp.H : (this._ch || this.c.height);
|
||||
const _PL = _vp ? _vp.PL : 54, _PB = _vp ? _vp.PB : 44;
|
||||
const _scX = _vp ? (_W - _PL - (_vp.PR || 20)) / _vp.xMax : 1;
|
||||
const _scY = _vp ? (_H - _PB - (_vp.PT || 26)) / _vp.yMax : 1;
|
||||
const landX = _vp ? _PL + end.x * _scX : 54;
|
||||
const landY = _vp ? _H - _PB : _H - 44;
|
||||
LabFX.sound.play('bounce', { pitch: 0.6 });
|
||||
LabFX.particles.emit({
|
||||
ctx: this.ctx, x: landX, y: landY,
|
||||
count: 30, color: '#8B7355', speed: 80,
|
||||
spread: Math.PI, angle: -Math.PI / 2,
|
||||
gravity: 200, life: 1200, shape: 'splash',
|
||||
});
|
||||
LabFX.shake(this.c, { intensity: 4, durMs: 200 });
|
||||
LabFX.haptic(15);
|
||||
}
|
||||
this._tickFX();
|
||||
}
|
||||
|
||||
@@ -854,6 +904,22 @@ class ProjectileSim {
|
||||
/* ── 2.5. Wind streaks ── */
|
||||
if (this.wind !== 0) {
|
||||
this._drawWind(ctx, PL, PT, pw, gy - PT);
|
||||
/* LabFX: wind dust particles */
|
||||
if (window.LabFX && this.playing) {
|
||||
const dir = this.wind > 0 ? 1 : -1;
|
||||
const dustCount = Math.floor(3 + Math.random() * 3);
|
||||
for (let _d = 0; _d < dustCount; _d++) {
|
||||
const dustX = dir > 0 ? PL : PL + pw;
|
||||
const dustY = PT + Math.random() * (gy - PT);
|
||||
LabFX.particles.emit({
|
||||
ctx, x: dustX, y: dustY,
|
||||
count: 1, color: 'rgba(255,255,255,0.3)',
|
||||
speed: 0, spread: 0, angle: 0,
|
||||
life: 1500, shape: 'dust', gravity: 0,
|
||||
_vx: this.wind * 5, _vy: -10,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 3. Ground ── */
|
||||
@@ -1336,6 +1402,9 @@ class ProjectileSim {
|
||||
if (!this.playing && this._hover) {
|
||||
this._drawInspector(ctx, tpx, tpy, PL, gy, W, H, PB, PT);
|
||||
}
|
||||
|
||||
/* LabFX: particles overlay */
|
||||
if (window.LabFX) LabFX.particles.draw(this.ctx);
|
||||
}
|
||||
|
||||
/* ── hover inspector ── */
|
||||
|
||||
@@ -14,6 +14,7 @@ class QuadraticSim {
|
||||
this.a = 1;
|
||||
this.b = 0;
|
||||
this.c = -1;
|
||||
this._lastDSign = Math.sign(1 * 1 * 1 - 4 * 1 * (-1)); // track discriminant sign
|
||||
|
||||
/* view */
|
||||
this.ox = 0;
|
||||
@@ -100,6 +101,14 @@ class QuadraticSim {
|
||||
|
||||
_emit() {
|
||||
if (this.onUpdate) this.onUpdate(this.info());
|
||||
if (window.LabFX) {
|
||||
const D = this.b * this.b - 4 * this.a * this.c;
|
||||
const sign = Math.sign(D);
|
||||
if (sign !== this._lastDSign) {
|
||||
this._lastDSign = sign;
|
||||
LabFX.sound.play('chime', { pitch: 1.3 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── coordinate transforms ─────────────────────────── */
|
||||
@@ -310,10 +319,18 @@ class QuadraticSim {
|
||||
const [rpx, rpy] = this._toPx(rx, 0);
|
||||
if (rpx < -20 || rpx > W + 20) continue;
|
||||
|
||||
// root dot
|
||||
ctx.fillStyle = '#EF476F';
|
||||
ctx.beginPath(); ctx.arc(rpx, rpy, 5.5, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 1.5; ctx.stroke();
|
||||
// root dot with glow
|
||||
if (window.LabFX) {
|
||||
LabFX.glow.drawGlow(ctx, () => {
|
||||
ctx.fillStyle = '#EF476F';
|
||||
ctx.beginPath(); ctx.arc(rpx, rpy, 5.5, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 1.5; ctx.stroke();
|
||||
}, { color: '#F59E0B', intensity: 8 });
|
||||
} else {
|
||||
ctx.fillStyle = '#EF476F';
|
||||
ctx.beginPath(); ctx.arc(rpx, rpy, 5.5, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 1.5; ctx.stroke();
|
||||
}
|
||||
|
||||
// label
|
||||
ctx.fillStyle = '#EF476F';
|
||||
@@ -450,10 +467,16 @@ class QuadraticSim {
|
||||
}));
|
||||
}
|
||||
|
||||
let _quadSoundTs = 0;
|
||||
function quadParam(name, val) {
|
||||
const v = parseFloat(val);
|
||||
document.getElementById('quad-' + name + '-val').textContent = v % 1 === 0 ? v : v.toFixed(1);
|
||||
if (quadSim) quadSim.setParams({ [name]: v });
|
||||
const now = performance.now();
|
||||
if (window.LabFX && now - _quadSoundTs > 80) {
|
||||
_quadSoundTs = now;
|
||||
LabFX.sound.play('tick', { volume: 0.1 });
|
||||
}
|
||||
}
|
||||
|
||||
function quadPreset(a, b, c) {
|
||||
|
||||
@@ -120,6 +120,8 @@ class RadioactiveSim {
|
||||
);
|
||||
this.simTime = 0;
|
||||
this.history = [];
|
||||
this._lastHalfLifeMark = 0;
|
||||
this._fxDecayCount = 0;
|
||||
}
|
||||
|
||||
/* ══════════════ public API ══════════════ */
|
||||
@@ -265,6 +267,16 @@ class RadioactiveSim {
|
||||
: decayType.startsWith('β') ? 'β'
|
||||
: 'γ';
|
||||
this._flashes.push({ x: p.x, y: p.y, t: 0, maxT: 0.35, sym });
|
||||
// LabFX decay effects (throttled)
|
||||
if (window.LabFX) {
|
||||
this._fxFrameDecays = (this._fxFrameDecays || 0) + 1;
|
||||
LabFX.particles.emit({
|
||||
ctx: this.ctx, x: p.x, y: p.y,
|
||||
count: 6, color: '#FFD700', speed: 60,
|
||||
spread: Math.PI * 2, angle: 0, gravity: 0,
|
||||
life: 300, shape: 'spark', glow: true, size: 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// age flash on particle itself
|
||||
@@ -284,6 +296,32 @@ class RadioactiveSim {
|
||||
|
||||
this.simTime += dt;
|
||||
|
||||
// LabFX half-life crossing + throttled tick sound
|
||||
if (window.LabFX) {
|
||||
/* throttle tick: play at most once per frame, only if ≤10 decays/s effective */
|
||||
const frameDecays = this._fxFrameDecays || 0;
|
||||
this._fxFrameDecays = 0;
|
||||
if (frameDecays > 0) {
|
||||
const decaysPerSec = frameDecays / Math.max(wallDt, 0.001);
|
||||
const N = Math.max(1, Math.round(decaysPerSec / 10));
|
||||
if (Math.random() < 1 / N) {
|
||||
LabFX.sound.play('tick', { pitch: 0.8 + Math.random() * 0.4, volume: 0.08 });
|
||||
}
|
||||
}
|
||||
LabFX.particles.update(wallDt);
|
||||
const T0 = this.steps[0].T_half;
|
||||
if (T0 !== Infinity) {
|
||||
const halfLifesElapsed = Math.floor(this.simTime / T0);
|
||||
if (halfLifesElapsed > (this._lastHalfLifeMark || 0)) {
|
||||
this._lastHalfLifeMark = halfLifesElapsed;
|
||||
LabFX.sound.play('chime', {
|
||||
pitch: 0.6 + halfLifesElapsed * 0.1,
|
||||
volume: 0.3,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// record history every ~2 ticks (≈30ms)
|
||||
const last = this.history[this.history.length - 1];
|
||||
if (!last || this.simTime - last.t > this.steps[0].T_half * 0.005 || this.history.length < 5) {
|
||||
@@ -342,11 +380,23 @@ class RadioactiveSim {
|
||||
ctx.fillStyle = `rgba(255,255,200,${alpha * 0.45})`;
|
||||
ctx.fill();
|
||||
|
||||
ctx.font = `bold ${Math.round(8 + alpha * 4)}px Manrope,sans-serif`;
|
||||
ctx.fillStyle = `rgba(255,255,180,${alpha})`;
|
||||
const symSize = Math.round(8 + alpha * 4);
|
||||
ctx.font = `bold ${symSize}px Manrope,sans-serif`;
|
||||
const symColor = `rgba(255,255,180,${alpha})`;
|
||||
ctx.fillStyle = symColor;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(fl.sym, fl.x, fl.y - r - 4);
|
||||
if (window.LabFX) {
|
||||
LabFX.glow.drawGlow(ctx, () => {
|
||||
ctx.font = `bold ${symSize}px Manrope,sans-serif`;
|
||||
ctx.fillStyle = symColor;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(fl.sym, fl.x, fl.y - r - 4);
|
||||
}, { color: '#FFFFFF', intensity: 6 });
|
||||
} else {
|
||||
ctx.fillText(fl.sym, fl.x, fl.y - r - 4);
|
||||
}
|
||||
}
|
||||
|
||||
// draw particles
|
||||
@@ -374,6 +424,8 @@ class RadioactiveSim {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.75)';
|
||||
ctx.fillText(s.name, lx + 15, y);
|
||||
}
|
||||
|
||||
if (window.LabFX) LabFX.particles.draw(ctx);
|
||||
}
|
||||
|
||||
_drawGraph() {
|
||||
@@ -549,6 +601,7 @@ function radioactiveN0(val) {
|
||||
|
||||
function radioactivePlay() {
|
||||
if (!radioactiveSim) return;
|
||||
if (window.LabFX) LabFX.sound.play('click');
|
||||
if (radioactiveSim.playing) {
|
||||
radioactiveSim.pause();
|
||||
document.getElementById('rd-play-btn').textContent = 'Старт';
|
||||
@@ -560,6 +613,9 @@ function radioactivePlay() {
|
||||
|
||||
function radioactiveReset() {
|
||||
if (!radioactiveSim) return;
|
||||
if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 0.5, volume: 0.3 });
|
||||
radioactiveSim._lastHalfLifeMark = 0;
|
||||
radioactiveSim._fxDecayCount = 0;
|
||||
radioactiveSim.reset();
|
||||
document.getElementById('rd-play-btn').textContent = 'Старт';
|
||||
}
|
||||
|
||||
@@ -95,8 +95,12 @@ class ReactionSim {
|
||||
|
||||
start() {
|
||||
if (this._raf) return;
|
||||
const loop = () => {
|
||||
this._lastTs = performance.now();
|
||||
const loop = (ts) => {
|
||||
this._raf = requestAnimationFrame(loop);
|
||||
const dt = Math.min((ts - this._lastTs) / 1000, 0.05);
|
||||
this._lastTs = ts;
|
||||
if (window.LabFX) LabFX.particles.update(dt);
|
||||
for (let i = 0; i < 3; i++) this._step();
|
||||
this.draw();
|
||||
};
|
||||
@@ -317,6 +321,19 @@ class ReactionSim {
|
||||
|
||||
this._totalReactions++;
|
||||
this._recentReactions++;
|
||||
|
||||
// LabFX: flash spark + throttled tick sound at collision point
|
||||
if (window.LabFX) {
|
||||
const now = performance.now();
|
||||
if (!this._fxLastTick || now - this._fxLastTick > 200) {
|
||||
this._fxLastTick = now;
|
||||
LabFX.sound.play('tick', { volume: 0.1 });
|
||||
}
|
||||
LabFX.particles.emit({ ctx: this.ctx, x: cx, y: cy, count: 3,
|
||||
color: '#FFD166', speed: 45, spread: 3.14, angle: 0,
|
||||
gravity: 0, life: 200, shape: 'spark', glow: true });
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -380,6 +397,8 @@ class ReactionSim {
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('Все молекулы прореагировали — нажмите Сброс', W / 2, H / 2);
|
||||
}
|
||||
|
||||
if (window.LabFX) LabFX.particles.draw(ctx);
|
||||
}
|
||||
|
||||
_drawParticle(ctx, p) {
|
||||
@@ -687,6 +706,7 @@ class ReactionSim {
|
||||
}
|
||||
|
||||
function chemReset() {
|
||||
if (window.LabFX) LabFX.sound.play('whoosh', { volume: 0.3 });
|
||||
if (_chemMode === 'kinetics' && reacSim) reacSim.reset();
|
||||
if (_chemMode === 'flask' && flaskSim) flaskSim.reset();
|
||||
if (_chemMode === 'redox') redoxReset();
|
||||
@@ -716,6 +736,7 @@ class ReactionSim {
|
||||
}
|
||||
|
||||
function reacMode(mode, el) {
|
||||
if (window.LabFX) LabFX.sound.play('click');
|
||||
if (reacSim) reacSim.setMode(mode);
|
||||
document.querySelectorAll('.reac-mode-btn').forEach(b => b.classList.remove('active'));
|
||||
if (el) el.classList.add('active');
|
||||
@@ -827,6 +848,7 @@ class ReactionSim {
|
||||
}
|
||||
|
||||
function redoxReset() {
|
||||
if (window.LabFX) LabFX.sound.play('whoosh', { volume: 0.3 });
|
||||
if (rdxSim) rdxSim.reset();
|
||||
}
|
||||
|
||||
@@ -858,6 +880,7 @@ class ReactionSim {
|
||||
}
|
||||
|
||||
function ionexReset() {
|
||||
if (window.LabFX) LabFX.sound.play('whoosh', { volume: 0.3 });
|
||||
if (ioxSim) ioxSim.reset();
|
||||
}
|
||||
|
||||
|
||||
@@ -152,6 +152,7 @@ class RedoxSim {
|
||||
start() {
|
||||
if (this._phase !== 'idle') this.reset();
|
||||
this._phase = 'mixing'; this._prog = 0;
|
||||
if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 0.8 });
|
||||
if (this._raf) return;
|
||||
this._last = performance.now();
|
||||
const loop = t => { this._raf = requestAnimationFrame(loop); this._tick(t); };
|
||||
@@ -165,6 +166,7 @@ class RedoxSim {
|
||||
_tick(t) {
|
||||
const dt = Math.min((t - this._last) / 1000, 0.05);
|
||||
this._last = t; this._t += dt;
|
||||
if (window.LabFX) LabFX.particles.update(dt);
|
||||
const { W, H } = this;
|
||||
const rxn = RedoxSim.RXN[this.rxnId];
|
||||
|
||||
@@ -282,6 +284,12 @@ class RedoxSim {
|
||||
mx, my, x: rp.x, y: rp.y,
|
||||
t: 0, spd: 0.65 + Math.random() * 0.45, alpha: 0,
|
||||
});
|
||||
// LabFX: electron transfer spark at the midpoint
|
||||
if (window.LabFX) {
|
||||
LabFX.particles.emit({ ctx: this.ctx, x: mx, y: my, count: 2,
|
||||
color: '#06D6E0', speed: 30, spread: 3.14, angle: 0,
|
||||
gravity: 0, life: 200, shape: 'spark', glow: true });
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Рендеринг ──────────────────────────────────────────────────── */
|
||||
@@ -341,6 +349,7 @@ class RedoxSim {
|
||||
if (rxn.precip) this._drawPrecip(ctx, rxn);
|
||||
if (rxn.gas) this._drawGas(ctx, rxn);
|
||||
this._drawPanel(ctx, W, H, rxn);
|
||||
if (window.LabFX) LabFX.particles.draw(this.ctx);
|
||||
}
|
||||
|
||||
_drawBeaker(ctx, W, H) {
|
||||
|
||||
@@ -22,6 +22,8 @@ class StatesSim {
|
||||
this._raf = null;
|
||||
this._stepCount = 0;
|
||||
this._loop = this._loop.bind(this);
|
||||
this._fxLastT = 0;
|
||||
this._fxTickTimer = 0; // throttle temperature slider tick sound
|
||||
this._wallImpulse = 0;
|
||||
this._pressureSmooth = 0;
|
||||
this._energyHistory = [];
|
||||
@@ -98,6 +100,15 @@ class StatesSim {
|
||||
const f = Math.min(4, Math.sqrt(this.T / old));
|
||||
for (const p of this.particles) { p.vx *= f; p.vy *= f; }
|
||||
}
|
||||
// throttled tick sound for temperature slider (pitch ∝ T)
|
||||
if (window.LabFX) {
|
||||
const nowMs = performance.now();
|
||||
if (nowMs - this._fxTickTimer > 120) {
|
||||
this._fxTickTimer = nowMs;
|
||||
const pitch = 0.7 + this.T * 0.9;
|
||||
LabFX.sound.play('tick', { pitch: Math.min(2.5, pitch), volume: 0.12 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setN(n) {
|
||||
@@ -110,8 +121,11 @@ class StatesSim {
|
||||
stop() { cancelAnimationFrame(this._raf); this._raf = null; }
|
||||
|
||||
// ── simulation ────────────────────────────────────────────────────────────
|
||||
_loop() {
|
||||
_loop(now) {
|
||||
const dt = this._fxLastT ? Math.min(now - this._fxLastT, 80) : 16;
|
||||
this._fxLastT = now;
|
||||
for (let i = 0; i < 5; i++) this._stepPhysics();
|
||||
if (window.LabFX) LabFX.particles.update(dt);
|
||||
this.draw();
|
||||
this._raf = requestAnimationFrame(this._loop);
|
||||
}
|
||||
@@ -391,6 +405,7 @@ class StatesSim {
|
||||
|
||||
// hover inspector (may extend into chart area)
|
||||
if (this._hover) this._drawInspector(ctx, this._hover, speeds, maxSpd, W, H);
|
||||
if (window.LabFX) LabFX.particles.draw(ctx);
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1832,11 +1832,13 @@ class StereoSim {
|
||||
|
||||
this._section3PPicks.push(bestPos);
|
||||
this._drawSection3P();
|
||||
if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.5, volume: 0.3 });
|
||||
|
||||
if (this._section3PPicks.length === 3) {
|
||||
this._computeSection3P();
|
||||
this._drawSection3P();
|
||||
this._notify();
|
||||
if (window.LabFX) LabFX.sound.play('chime', { pitch: 1.1 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2101,6 +2103,7 @@ class StereoSim {
|
||||
});
|
||||
|
||||
this._rebuildPointVisuals();
|
||||
if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.5, volume: 0.3 });
|
||||
|
||||
// If custom section mode and ≥3 points, update section
|
||||
if (this.showSection && this.sectionType === 'custom') this._updateSection();
|
||||
@@ -3328,6 +3331,7 @@ class StereoSim {
|
||||
stereoSim.setFigure(type);
|
||||
_stereoShowParams(type);
|
||||
_stereoUpdateFormulas();
|
||||
if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.2, volume: 0.3 });
|
||||
// reset toggles and tool buttons
|
||||
document.getElementById('sect-toggle').classList.remove('active');
|
||||
document.getElementById('stereo-unfold-btn').classList.remove('active');
|
||||
|
||||
@@ -199,6 +199,9 @@ class StoichSim {
|
||||
|
||||
/* ── Выбрать рецепт ─────────────────────────────────────────────── */
|
||||
_setRecipe(idx) {
|
||||
if (this._recipeIdx !== undefined && this._recipeIdx !== idx && window.LabFX) {
|
||||
LabFX.sound.play('click', { pitch: 1.3 });
|
||||
}
|
||||
this._recipeIdx = idx;
|
||||
const r = StoichSim.RECIPES[idx];
|
||||
|
||||
@@ -465,7 +468,14 @@ class StoichSim {
|
||||
};
|
||||
});
|
||||
|
||||
const prevLimitIdx = this._computed ? this._computed.limitIdx : -1;
|
||||
this._computed = { limitIdx, limitVal, ratios, reactantQ, productQ };
|
||||
|
||||
// LabFX: haptic + tick when limiting reagent changes
|
||||
if (window.LabFX && prevLimitIdx !== limitIdx) {
|
||||
LabFX.haptic(20);
|
||||
LabFX.sound.play('tick', { pitch: 0.8, volume: 0.3 });
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Правая панель: пошаговый расчёт ───────────────────────────── */
|
||||
@@ -772,10 +782,29 @@ class StoichSim {
|
||||
this._animT = 0;
|
||||
const dur = 1200; // ms
|
||||
const start = performance.now();
|
||||
let lastTs = start;
|
||||
|
||||
// LabFX: fizz sound + bubble particles at reactant boxes
|
||||
if (window.LabFX && this._ctx) {
|
||||
LabFX.sound.play('fizz');
|
||||
const W = this._canvas.offsetWidth || 300;
|
||||
const H = this._canvas.offsetHeight || 200;
|
||||
const r = StoichSim.RECIPES[this._recipeIdx];
|
||||
r.reactants.forEach((re, i) => {
|
||||
const x = (W / (r.reactants.length + 1)) * (i + 1);
|
||||
LabFX.particles.emit({ ctx: this._ctx, x, y: H * 0.4, count: 8,
|
||||
color: re.color || '#FFFFFF', speed: 35, spread: 2.5, angle: -Math.PI / 2,
|
||||
gravity: -50, life: 800, shape: 'ring' });
|
||||
});
|
||||
}
|
||||
|
||||
const tick = (now) => {
|
||||
const dt = (now - lastTs) / 1000;
|
||||
lastTs = now;
|
||||
if (window.LabFX) LabFX.particles.update(dt);
|
||||
this._animT = Math.min(1, (now - start) / dur);
|
||||
this._draw();
|
||||
if (window.LabFX && this._ctx) LabFX.particles.draw(this._ctx);
|
||||
if (this._animT < 1) {
|
||||
this._raf = requestAnimationFrame(tick);
|
||||
} else {
|
||||
|
||||
@@ -229,6 +229,7 @@ class TitrationSim {
|
||||
const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05);
|
||||
this._lastTs = ts;
|
||||
const dt = rawDt * this.speed;
|
||||
if (window.LabFX) LabFX.particles.update(rawDt);
|
||||
|
||||
this._wave += rawDt * 2.0;
|
||||
|
||||
@@ -246,10 +247,31 @@ class TitrationSim {
|
||||
for (const d of this._drops) { d.vy += 480 * dt; d.y += d.vy * dt; }
|
||||
const surfY = this.H * 0.72;
|
||||
/* spawn splashes when drops hit surface */
|
||||
const eqVol = this._eqVolume();
|
||||
const wasNearEq = this._fxNearEq;
|
||||
this._fxNearEq = Math.abs(this.baseAdded - eqVol) < eqVol * 0.05;
|
||||
if (!wasNearEq && this._fxNearEq && window.LabFX) {
|
||||
// Equivalence point crossed
|
||||
LabFX.sound.play('chime', { pitch: 1.3 });
|
||||
}
|
||||
for (const d of this._drops) {
|
||||
if (d.y >= surfY && !d.hit) {
|
||||
d.hit = true;
|
||||
const bx = d.x;
|
||||
// LabFX: pour sound + splash particles (throttled to ~3/sec)
|
||||
if (window.LabFX) {
|
||||
const now = performance.now();
|
||||
if (!this._fxDropSnd || now - this._fxDropSnd > 330) {
|
||||
this._fxDropSnd = now;
|
||||
LabFX.sound.play('pour', { volume: 0.4, pitch: 1.2 });
|
||||
LabFX.haptic(5);
|
||||
}
|
||||
const curPH = this._calcPH(this.baseAdded);
|
||||
const col = this._indicatorColor(curPH);
|
||||
LabFX.particles.emit({ ctx: this.ctx, x: bx, y: surfY, count: 4,
|
||||
color: col, speed: 25, spread: 2.2, angle: -Math.PI / 2,
|
||||
gravity: 200, life: 600, shape: 'splash' });
|
||||
}
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const a = -Math.PI * 0.5 + (Math.random() - 0.5) * Math.PI;
|
||||
const s = 15 + Math.random() * 25;
|
||||
@@ -315,6 +337,7 @@ class TitrationSim {
|
||||
this._drawParticles(ctx);
|
||||
this._drawOverlay(ctx);
|
||||
this._drawPHCurve(ctx, simW, W, H);
|
||||
if (window.LabFX) LabFX.particles.draw(ctx);
|
||||
}
|
||||
|
||||
/* ── Lab stand ──────────────────────────────────────────── */
|
||||
|
||||
@@ -29,7 +29,27 @@ class TriangleSim {
|
||||
};
|
||||
|
||||
this.onUpdate = null; // cb(stats)
|
||||
this._lastHapticTs = 0;
|
||||
this._lastRafTs = 0;
|
||||
this._bindEvents();
|
||||
this._startParticleLoop();
|
||||
}
|
||||
|
||||
_startParticleLoop() {
|
||||
let last = performance.now();
|
||||
let fxFrames = 0; // countdown frames to keep drawing after last emit
|
||||
const loop = (now) => {
|
||||
const dt = (now - last) / 1000;
|
||||
last = now;
|
||||
if (window.LabFX) {
|
||||
LabFX.particles.update(dt);
|
||||
if (fxFrames > 0) { fxFrames--; this.draw(); }
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
};
|
||||
// expose a trigger so emit events bump the counter
|
||||
this._fxActivate = () => { fxFrames = 60; };
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
/* ── sizing ── */
|
||||
@@ -70,6 +90,7 @@ class TriangleSim {
|
||||
this._initPts();
|
||||
this.draw();
|
||||
if (this.onUpdate) this.onUpdate(this.stats());
|
||||
if (window.LabFX) LabFX.sound.play('whoosh', { volume: 0.3 });
|
||||
}
|
||||
|
||||
/* ── pointer events ── */
|
||||
@@ -97,6 +118,11 @@ class TriangleSim {
|
||||
this._clampPts();
|
||||
this.draw();
|
||||
if (this.onUpdate) this.onUpdate(this.stats());
|
||||
const now = performance.now();
|
||||
if (window.LabFX && now - (this._lastHapticTs || 0) > 100) {
|
||||
this._lastHapticTs = now;
|
||||
LabFX.haptic(5);
|
||||
}
|
||||
};
|
||||
|
||||
c.addEventListener('mousedown', e => {
|
||||
@@ -115,6 +141,14 @@ class TriangleSim {
|
||||
});
|
||||
|
||||
c.addEventListener('mouseup', () => {
|
||||
if (this._dragging !== null && this.pts) {
|
||||
const p = this.pts[this._dragging];
|
||||
if (window.LabFX) {
|
||||
LabFX.sound.play('tick', { pitch: 1.2, volume: 0.2 });
|
||||
LabFX.particles.emit({ ctx: this.ctx, x: p.x, y: p.y, count: 4, color: '#9B5DE5', shape: 'dust', life: 300, speed: 40, spread: Math.PI * 2, gravity: 0 });
|
||||
if (this._fxActivate) this._fxActivate();
|
||||
}
|
||||
}
|
||||
this._dragging = null;
|
||||
c.style.cursor = this._hovered !== null ? 'grab' : 'default';
|
||||
});
|
||||
@@ -298,6 +332,7 @@ class TriangleSim {
|
||||
this._drawVertices(ctx);
|
||||
this._drawSideLabels(ctx);
|
||||
this._drawAngleLabels(ctx);
|
||||
if (window.LabFX) LabFX.particles.draw(ctx);
|
||||
}
|
||||
|
||||
/* helpers */
|
||||
@@ -506,8 +541,14 @@ class TriangleSim {
|
||||
// midpoint ticks
|
||||
[mA, mB, mC].forEach(m => this._dot(ctx, m.x, m.y, 4, '#22d55e'));
|
||||
|
||||
// centroid
|
||||
this._dot(ctx, G.x, G.y, 7, '#22d55e');
|
||||
// centroid with glow
|
||||
if (window.LabFX) {
|
||||
LabFX.glow.drawGlow(ctx, () => {
|
||||
this._dot(ctx, G.x, G.y, 7, '#22d55e');
|
||||
}, { color: '#F59E0B', intensity: 6 });
|
||||
} else {
|
||||
this._dot(ctx, G.x, G.y, 7, '#22d55e');
|
||||
}
|
||||
this._label(ctx, 'G', G.x + 14, G.y - 8, '#22d55e', 13);
|
||||
|
||||
ctx.restore();
|
||||
@@ -549,7 +590,13 @@ class TriangleSim {
|
||||
[fA, fB, fC].forEach(f => this._dot(ctx, f.x, f.y, 4, '#f59e0b'));
|
||||
|
||||
if (H) {
|
||||
this._dot(ctx, H.x, H.y, 7, '#f59e0b');
|
||||
if (window.LabFX) {
|
||||
LabFX.glow.drawGlow(ctx, () => {
|
||||
this._dot(ctx, H.x, H.y, 7, '#f59e0b');
|
||||
}, { color: '#F59E0B', intensity: 6 });
|
||||
} else {
|
||||
this._dot(ctx, H.x, H.y, 7, '#f59e0b');
|
||||
}
|
||||
this._label(ctx, 'H', H.x + 14, H.y - 8, '#f59e0b', 13);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
@@ -98,6 +98,7 @@ class TrigCircleSim {
|
||||
this._drawCircle(c);
|
||||
if (this.showGraph) { this._drawDivider(c); this._drawGraph(c); }
|
||||
this._drawParticles(c);
|
||||
if (window.LabFX) LabFX.particles.draw(c);
|
||||
|
||||
c.restore();
|
||||
this._fireUpdate();
|
||||
@@ -320,14 +321,22 @@ class TrigCircleSim {
|
||||
/* ═══ trig segments ═══ */
|
||||
|
||||
if (this.showCos) {
|
||||
this._glowLine(c, cx, cy, projX, cy, _TC.cos, 4);
|
||||
if (window.LabFX) {
|
||||
LabFX.glow.drawGlow(c, () => { this._glowLine(c, cx, cy, projX, cy, _TC.cos, 4); }, { color: '#06D6E0', intensity: 4 });
|
||||
} else {
|
||||
this._glowLine(c, cx, cy, projX, cy, _TC.cos, 4);
|
||||
}
|
||||
c.font = 'bold 12px Manrope,sans-serif'; c.fillStyle = _TC.cos;
|
||||
c.textAlign = 'center'; c.textBaseline = sinA >= 0 ? 'top' : 'bottom';
|
||||
c.fillText('cos', (cx + projX) / 2, cy + (sinA >= 0 ? 12 : -12));
|
||||
}
|
||||
|
||||
if (this.showSin) {
|
||||
this._glowLine(c, projX, cy, px, py, _TC.sin, 4);
|
||||
if (window.LabFX) {
|
||||
LabFX.glow.drawGlow(c, () => { this._glowLine(c, projX, cy, px, py, _TC.sin, 4); }, { color: '#06D6E0', intensity: 4 });
|
||||
} else {
|
||||
this._glowLine(c, projX, cy, px, py, _TC.sin, 4);
|
||||
}
|
||||
c.font = 'bold 12px Manrope,sans-serif'; c.fillStyle = _TC.sin;
|
||||
c.textAlign = cosA >= 0 ? 'left' : 'right'; c.textBaseline = 'middle';
|
||||
c.fillText('sin', projX + (cosA >= 0 ? 9 : -9), (cy + py) / 2);
|
||||
@@ -866,10 +875,18 @@ class TrigCircleSim {
|
||||
cv.style.cursor = 'grabbing';
|
||||
});
|
||||
|
||||
this._lastDragSoundTs = 0;
|
||||
cv.addEventListener('mousemove', e => {
|
||||
if (this._drag) {
|
||||
const na = snapAngle(e); checkSnap(na);
|
||||
this.angle = na; this.draw();
|
||||
const now = performance.now();
|
||||
if (window.LabFX && now - this._lastDragSoundTs > 100) {
|
||||
this._lastDragSoundTs = now;
|
||||
const pitch = 0.8 + (this.angle / (2 * Math.PI)) * 0.8;
|
||||
LabFX.sound.play('tick', { pitch, volume: 0.05 });
|
||||
LabFX.haptic(5);
|
||||
}
|
||||
} else {
|
||||
const h = hit(e);
|
||||
if (h !== this._hover) { this._hover = h; this.draw(); }
|
||||
@@ -954,6 +971,7 @@ class TrigCircleSim {
|
||||
const loop = now => {
|
||||
const dt = (now-last)/1000; last = now;
|
||||
this._idlePulse += dt * 1.5;
|
||||
if (window.LabFX) LabFX.particles.update(dt);
|
||||
/* update particles */
|
||||
if (this._particles.length > 0 || (!this._drag && !this.animating)) this.draw();
|
||||
this._idleRaf = requestAnimationFrame(loop);
|
||||
@@ -1008,6 +1026,7 @@ if (typeof window !== 'undefined') window.TrigCircleSim = TrigCircleSim;
|
||||
function trigReset() {
|
||||
if (!trigSim) return;
|
||||
trigSim.setAngle(Math.PI / 4);
|
||||
if (window.LabFX) LabFX.sound.play('click');
|
||||
}
|
||||
|
||||
function _trigUpdateUI(s) {
|
||||
|
||||
+89
-19
@@ -78,6 +78,7 @@ class WavesSim {
|
||||
if (mode === 'doppler') this._dopInit();
|
||||
if (mode === 'spectrum' && !this._specComponents.length)
|
||||
this._specComponents = [{ f: 5, A: 60 }, { f: 10, A: 30 }];
|
||||
if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.2, volume: 0.3 });
|
||||
this.draw();
|
||||
this._emit();
|
||||
}
|
||||
@@ -135,6 +136,21 @@ class WavesSim {
|
||||
const dt = Math.min((ts - this._last) / 1000, 0.05) * this._speed;
|
||||
this._t += dt;
|
||||
if (this._mode === 'doppler') this._dopStep(dt);
|
||||
if (window.LabFX) LabFX.particles.update(dt);
|
||||
/* beats: play tick at envelope peaks (throttle to beat period) */
|
||||
if (this._mode === 'beats') {
|
||||
const fBeat = Math.abs(this._beatsF1 - this._beatsF2);
|
||||
if (fBeat > 0) {
|
||||
const TBeat = 1 / fBeat;
|
||||
const beatPhase = this._t % TBeat;
|
||||
if (!this._lastBeatTick || this._t - this._lastBeatTick >= TBeat * 0.95) {
|
||||
if (beatPhase < dt * this._speed + 0.02) {
|
||||
if (window.LabFX) LabFX.sound.play('tick', { pitch: 0.5, volume: 0.15 });
|
||||
this._lastBeatTick = this._t;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this._last = ts;
|
||||
this._raf = requestAnimationFrame(t => this._tick(t));
|
||||
@@ -161,6 +177,7 @@ class WavesSim {
|
||||
else if (this._mode === 'doppler') this._dopplerDraw(ctx, W, H);
|
||||
else if (this._mode === 'beats') this._beatsDraw(ctx, W, H);
|
||||
else if (this._mode === 'spectrum') this._spectrumDraw(ctx, W, H);
|
||||
if (window.LabFX) LabFX.particles.draw(ctx);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════
|
||||
@@ -185,18 +202,24 @@ class WavesSim {
|
||||
const y = x => A * Math.sin(om * t - k * (x - PL) + phi);
|
||||
|
||||
/* волновая кривая */
|
||||
ctx.save();
|
||||
ctx.shadowColor = WavesSim.V;
|
||||
ctx.shadowBlur = 16;
|
||||
ctx.strokeStyle = WavesSim.V;
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.beginPath();
|
||||
for (let x = PL; x <= PL + cw; x += 1) {
|
||||
const py = cy + y(x);
|
||||
x === PL ? ctx.moveTo(x, py) : ctx.lineTo(x, py);
|
||||
const _drawTransvWave = () => {
|
||||
ctx.strokeStyle = WavesSim.V;
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.beginPath();
|
||||
for (let x = PL; x <= PL + cw; x += 1) {
|
||||
const py = cy + y(x);
|
||||
x === PL ? ctx.moveTo(x, py) : ctx.lineTo(x, py);
|
||||
}
|
||||
ctx.stroke();
|
||||
};
|
||||
if (window.LabFX) {
|
||||
LabFX.glow.drawGlow(ctx, _drawTransvWave, { color: WavesSim.V, intensity: 5 });
|
||||
} else {
|
||||
ctx.save();
|
||||
ctx.shadowColor = WavesSim.V; ctx.shadowBlur = 16;
|
||||
_drawTransvWave();
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
/* частицы */
|
||||
const step = Math.max(12, Math.floor(lam / 10));
|
||||
@@ -488,6 +511,32 @@ class WavesSim {
|
||||
ring.age += dt;
|
||||
return ring.r < maxR;
|
||||
});
|
||||
|
||||
/* LabFX: Mach shock particles when vs >= c */
|
||||
const mach = vsPx / c_px;
|
||||
if (window.LabFX) {
|
||||
if (mach >= 1.0 && !this._dopWasMach) {
|
||||
this._dopWasMach = true;
|
||||
LabFX.sound.play('spark', { volume: 0.4 });
|
||||
LabFX.particles.emit({
|
||||
ctx: this._ctx, x: this._dopSrcX, y: this._dopSrcY,
|
||||
count: 20, color: '#FF6B35', speed: 120,
|
||||
spread: Math.PI * 2, angle: 0, gravity: 80,
|
||||
life: 600, shape: 'spark', glow: true, size: 3,
|
||||
});
|
||||
} else if (mach < 1.0) {
|
||||
this._dopWasMach = false;
|
||||
}
|
||||
|
||||
/* haptic while dragging src (throttle 100ms) */
|
||||
if (this._dopDrag === 'src') {
|
||||
const now2 = performance.now();
|
||||
if (!this._dopHapticLast || now2 - this._dopHapticLast >= 100) {
|
||||
LabFX.haptic(5);
|
||||
this._dopHapticLast = now2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_dopplerDraw(ctx, W, H) {
|
||||
@@ -806,16 +855,24 @@ class WavesSim {
|
||||
══════════════════════════════════════ */
|
||||
|
||||
_waveLine(ctx, PL, cw, cy, fn, color, lw, alpha, glow) {
|
||||
const drawFn = () => {
|
||||
ctx.strokeStyle = color; ctx.lineWidth = lw;
|
||||
ctx.beginPath();
|
||||
for (let x = PL; x <= PL + cw; x += 1) {
|
||||
const py = cy + fn(x);
|
||||
x === PL ? ctx.moveTo(x, py) : ctx.lineTo(x, py);
|
||||
}
|
||||
ctx.stroke();
|
||||
};
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
if (glow) { ctx.shadowColor = color; ctx.shadowBlur = 16; }
|
||||
ctx.strokeStyle = color; ctx.lineWidth = lw;
|
||||
ctx.beginPath();
|
||||
for (let x = PL; x <= PL + cw; x += 1) {
|
||||
const py = cy + fn(x);
|
||||
x === PL ? ctx.moveTo(x, py) : ctx.lineTo(x, py);
|
||||
if (glow && window.LabFX) {
|
||||
LabFX.glow.drawGlow(ctx, drawFn, { color, intensity: 5 });
|
||||
} else {
|
||||
if (glow) { ctx.shadowColor = color; ctx.shadowBlur = 16; }
|
||||
drawFn();
|
||||
}
|
||||
ctx.stroke(); ctx.restore();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
_grid(ctx, PL, PR, PT, PB, W, H) {
|
||||
@@ -847,8 +904,21 @@ class WavesSim {
|
||||
specAddComponent() {
|
||||
const f = this._specNewF;
|
||||
const A = 60;
|
||||
if (this._specComponents.length < 12)
|
||||
if (this._specComponents.length < 12) {
|
||||
this._specComponents.push({ f, A });
|
||||
if (window.LabFX) {
|
||||
LabFX.sound.play('chime', { pitch: Math.pow(2, f / 12), volume: 0.3 });
|
||||
/* emit dust at approximate peak position on spectrum */
|
||||
const peakX = this._W ? (this._W * 0.08) + (f / 50) * (this._W * 0.84) : 200;
|
||||
const peakY = this._H ? this._H * 0.3 : 100;
|
||||
LabFX.particles.emit({
|
||||
ctx: this._ctx, x: peakX, y: peakY,
|
||||
count: 8, color: '#FFD166', speed: 30,
|
||||
spread: Math.PI, angle: -Math.PI / 2, gravity: 50,
|
||||
life: 400, shape: 'dust', size: 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.draw();
|
||||
}
|
||||
|
||||
|
||||
@@ -342,6 +342,22 @@
|
||||
<button class="zoom-btn" id="theory-toggle" onclick="toggleTheory()" title="Теория и формулы" style="margin-left:auto">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z"/><path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- sound toggle -->
|
||||
<button class="zoom-btn" id="labfx-sound-btn" onclick="(function(){var e=window.LabFX&&window.LabFX.sound;if(!e)return;e.setEnabled(!e.isEnabled());document.getElementById('labfx-sound-btn').setAttribute('aria-pressed',e.isEnabled());document.getElementById('labfx-sound-icon-on').style.display=e.isEnabled()?'':'none';document.getElementById('labfx-sound-icon-off').style.display=e.isEnabled()?'none':'';})()" title="Звук симуляций" style="position:relative" aria-pressed="true">
|
||||
<!-- speaker on -->
|
||||
<svg id="labfx-sound-icon-on" class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
|
||||
</svg>
|
||||
<!-- speaker off (muted) -->
|
||||
<svg id="labfx-sound-icon-off" class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none">
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
||||
<line x1="23" y1="9" x2="17" y2="15"/>
|
||||
<line x1="17" y1="9" x2="23" y2="15"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── GRAPH sim body ── -->
|
||||
@@ -3940,6 +3956,10 @@
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.149.0/build/three.min.js"></script>
|
||||
<script src="/js/labs/_fx_core.js"></script>
|
||||
<script src="/js/labs/_fx_particles.js"></script>
|
||||
<script src="/js/labs/_fx_motion.js"></script>
|
||||
<script src="/js/labs/_fx_sound.js"></script>
|
||||
<script src="/js/labs/graph.js"></script>
|
||||
<script src="/js/labs/emfield.js"></script>
|
||||
<script src="/js/labs/triangle.js"></script>
|
||||
@@ -3989,5 +4009,19 @@
|
||||
<script src="/js/labs/geometry.js"></script>
|
||||
<script src="/js/labs/logic.js"></script>
|
||||
<script src="/js/labs/heatengine.js"></script>
|
||||
<script>
|
||||
/* Sync sound toggle button icon with localStorage state on load */
|
||||
(function() {
|
||||
var stored = localStorage.getItem('labfx-sound');
|
||||
var on = stored === null ? true : stored === 'true';
|
||||
var iconOn = document.getElementById('labfx-sound-icon-on');
|
||||
var iconOff = document.getElementById('labfx-sound-icon-off');
|
||||
var btn = document.getElementById('labfx-sound-btn');
|
||||
if (!iconOn || !iconOff || !btn) return;
|
||||
iconOn.style.display = on ? '' : 'none';
|
||||
iconOff.style.display = on ? 'none' : '';
|
||||
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -372,6 +372,11 @@ function lsToast(message, type = 'info', duration = 3500) {
|
||||
el.setAttribute('role', type === 'error' ? 'alert' : 'status');
|
||||
el.innerHTML = `<span class="ls-toast-icon">${lsIcon(_tIcons[type] || 'info', 18)}</span><span class="ls-toast-msg"></span><button class="ls-toast-close" aria-label="Закрыть уведомление" onclick="this.closest('.ls-toast').remove()">${lsIcon('x-close', 14)}</button>`;
|
||||
el.querySelector('.ls-toast-msg').textContent = message;
|
||||
// Progress bar — thin line at bottom that drains over the toast duration
|
||||
const bar = document.createElement('span');
|
||||
bar.className = 'ls-toast-bar';
|
||||
bar.style.setProperty('--toast-dur', (duration / 1000).toFixed(2) + 's');
|
||||
el.appendChild(bar);
|
||||
wrap.appendChild(el);
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => el.classList.add('show')));
|
||||
|
||||
@@ -380,7 +385,7 @@ function lsToast(message, type = 'info', duration = 3500) {
|
||||
setTimeout(() => el.remove(), 320);
|
||||
};
|
||||
const timer = setTimeout(hide, duration);
|
||||
el.querySelector('.ls-toast-close').addEventListener('click', () => clearTimeout(timer));
|
||||
el.querySelector('.ls-toast-close').addEventListener('click', () => { clearTimeout(timer); hide(); });
|
||||
}
|
||||
|
||||
/* ── State helpers: единый паттерн loading/empty/error ───────────────────
|
||||
|
||||
Reference in New Issue
Block a user