diff --git a/overlay.html b/overlay.html
index 2354b86..c11bf87 100644
--- a/overlay.html
+++ b/overlay.html
@@ -47,6 +47,12 @@
// Screen-edge slap
wallBounce: 0.42, // velocity retained after wall hit
wallFriction: 0.86, // tangential damping on wall hit
+ slapHeartMinSpeed: 14, // minimum impact speed to spawn hearts
+ slapHeartCooldownMs: 120, // per-point debounce so one slap doesn't spam hearts
+ crackHeartBurstScale: 1.25, // stronger burst for an in-air whip crack
+ heartLifetimeMs: 700,
+ heartRise: 18,
+ heartSize: 22,
// Crack detection
crackSpeed: 340, // tip velocity threshold to trigger crack
@@ -84,6 +90,7 @@
let whipSpawnTime = 0;
let handleAngle = P.baseTargetAngle;
let handleAngVel = 0;
+let hearts = [];
const WHIP_CRACK_SOUNDS = ['sounds/A.mp3', 'sounds/B.mp3', 'sounds/C.mp3', 'sounds/D.mp3', 'sounds/E.mp3'];
@@ -167,6 +174,57 @@
a.play().catch(() => {});
}
+function spawnHeartBurst(x, y, vx, vy, opts = {}) {
+ const now = performance.now();
+ const count = opts.count ?? 3;
+ const scale = opts.scale ?? 1;
+ for (let i = 0; i < count; i++) {
+ const spread = i - 1;
+ hearts.push({
+ x: x + spread * 4.5 * scale,
+ y: y - Math.abs(spread) * 2.5 * scale,
+ vx: vx * 0.07 + spread * 0.32 * scale,
+ vy: -0.95 - Math.abs(vy) * 0.016 - Math.random() * 0.35 * scale,
+ bornAt: now,
+ size: P.heartSize * scale * (0.9 + Math.random() * 0.28),
+ drift: (Math.random() - 0.5) * 0.16,
+ rot: (Math.random() - 0.5) * 0.5,
+ });
+ }
+ if (hearts.length > 60) hearts = hearts.slice(-60);
+}
+
+function updateHearts(now) {
+ hearts = hearts.filter(h => now - h.bornAt < P.heartLifetimeMs);
+ for (const h of hearts) {
+ h.x += h.vx + h.drift;
+ h.y += h.vy;
+ h.vx *= 0.98;
+ h.vy *= 0.98;
+ }
+}
+
+function drawHeart(x, y, size, alpha, rotation) {
+ ctx.save();
+ ctx.translate(x, y);
+ ctx.rotate(rotation);
+ ctx.scale(size / 18, size / 18);
+ ctx.globalAlpha = alpha;
+ ctx.beginPath();
+ ctx.moveTo(0, 6);
+ ctx.bezierCurveTo(0, 0, -9, -1, -9, -7);
+ ctx.bezierCurveTo(-9, -12, -4, -14, 0, -10);
+ ctx.bezierCurveTo(4, -14, 9, -12, 9, -7);
+ ctx.bezierCurveTo(9, -1, 0, 0, 0, 6);
+ ctx.closePath();
+ ctx.fillStyle = '#ff4f87';
+ ctx.fill();
+ ctx.lineWidth = 1.4;
+ ctx.strokeStyle = 'rgba(255,255,255,0.75)';
+ ctx.stroke();
+ ctx.restore();
+}
+
function updateHandleAim() {
if (dropping) return;
const mvx = mouseX - prevMouseX;
@@ -288,6 +346,15 @@
}
if (hit) {
+ const impactSpeed = Math.hypot(vx, vy);
+ const now = performance.now();
+ if (
+ impactSpeed >= P.slapHeartMinSpeed &&
+ now - (p.lastSlapHeartAt || 0) >= P.slapHeartCooldownMs
+ ) {
+ p.lastSlapHeartAt = now;
+ spawnHeartBurst(p.x, p.y, vx, vy);
+ }
p.px = p.x - vx;
p.py = p.y - vy;
}
@@ -361,6 +428,10 @@
const now = Date.now();
if (now - whipSpawnTime >= P.firstCrackGraceMs && now - lastCrackTime > P.crackCooldownMs) {
lastCrackTime = now;
+ spawnHeartBurst(tip.x, tip.y, tip.x - tip.px, tip.y - tip.py, {
+ count: 4,
+ scale: P.crackHeartBurstScale,
+ });
playCrackSound();
window.bridge.whipCrack();
}
@@ -378,12 +449,22 @@
// ── Rendering ───────────────────────────────────────────────────────────────
function draw() {
+ const now = performance.now();
ctx.clearRect(0, 0, W, H);
// Near-invisible fill so the window captures mouse events on Windows
ctx.fillStyle = `rgba(0,0,0,${P.bgAlpha})`;
ctx.fillRect(0, 0, W, H);
+ for (const h of hearts) {
+ const age = now - h.bornAt;
+ const t = clamp(age / P.heartLifetimeMs, 0, 1);
+ const alpha = 1 - t;
+ const rise = t * P.heartRise;
+ const scale = 0.8 + Math.sin(t * Math.PI) * 0.28;
+ drawHeart(h.x, h.y - rise, h.size * scale, alpha, h.rot * (1 - t));
+ }
+
if (!whip) return;
// White: thin halo on full spline, then extra thickness only over handle links.
@@ -429,6 +510,7 @@
// ── Main loop ───────────────────────────────────────────────────────────────
function loop() {
+ updateHearts(performance.now());
update();
draw();
requestAnimationFrame(loop);