diff --git a/plugins/zhu-zhi-liao-toy/README.md b/plugins/zhu-zhi-liao-toy/README.md new file mode 100644 index 00000000..2631421f --- /dev/null +++ b/plugins/zhu-zhi-liao-toy/README.md @@ -0,0 +1,22 @@ +## 插件介绍 + +- 竹知了桌宠是一个基于 ZTools 的桌面玩具插件,仿照传统玩具「竹知了」(竹蝉/拉线蝉)。 +- 启动后,鼠标指针会变成一把小竹签,下面用线拴着一只竹蝉(会振翅的小竹筒)。 + +## 玩法 + +- **长按左键晃动**:按住鼠标左键,像甩真玩具一样画圈/晃动,竹蝉就绕着竹签甩起来,转得越快叫得越响——"哇——哇——"。 +- **松开左键**:竹蝉靠惯性继续转几下,余音渐歇。 +- **右键点击**:退出玩具,恢复普通鼠标。 + +## 说明 + +- 竹蝉发出的是「哇哇」声,用 Web Audio 合成(锯齿波 + 调幅 + 扫频带通 + 膜腔共振峰),零外部依赖。 +- 玩具窗口铺满光标所在的整个屏幕(跨多屏自动跟随),竹蝉画在光标位置,怎么甩都不会出界。 +- 玩耍时隐藏系统光标、点击会被玩具捕获;右键退出后一切恢复正常。 +- 感谢开源项目:https://github.com/imsai-sh/zhuzhiliao + +## 打个广告 +- 作者软件下载站:[工具灯塔](https://gongjudengta.top):https://gongjudengta.top +- 手机壳定制,有需要的欢迎扫码查看 +- ![手机壳定制](https://origin.picgo.net/2026/07/22/gh_0d44d93f5082_258-15d0e28c721266ee0.jpg) diff --git a/plugins/zhu-zhi-liao-toy/logo.png b/plugins/zhu-zhi-liao-toy/logo.png new file mode 100644 index 00000000..db3b6d80 Binary files /dev/null and b/plugins/zhu-zhi-liao-toy/logo.png differ diff --git a/plugins/zhu-zhi-liao-toy/plugin.json b/plugins/zhu-zhi-liao-toy/plugin.json new file mode 100644 index 00000000..0817dd70 --- /dev/null +++ b/plugins/zhu-zhi-liao-toy/plugin.json @@ -0,0 +1,18 @@ +{ + "name": "zhu-zhi-liao-toy", + "title": "竹知了桌宠", + "description": "鼠标指针变成竹知了,长按左键晃动就哇哇叫,右键退出", + "author": "Cozy", + "version": "1.1.0", + "preload": "preload.js", + "logo": "logo.png", + "features": [ + { + "code": "start-toy", + "explain": "启动竹知了桌面玩具", + "cmds": [ "知了"], + "mainHide": true + } + ], + "platform": ["win32", "darwin", "linux"] +} diff --git a/plugins/zhu-zhi-liao-toy/preload.js b/plugins/zhu-zhi-liao-toy/preload.js new file mode 100644 index 00000000..9d8c9063 --- /dev/null +++ b/plugins/zhu-zhi-liao-toy/preload.js @@ -0,0 +1,80 @@ +/* 竹知了桌宠 —— ZTools 插件主进程入口 + 注意:本文件是插件的 preload 脚本,运行在渲染进程里。preload 中 require('electron') + 只能拿到 ipcRenderer 等渲染端模块,ipcMain / screen 是主进程模块,在这里是 undefined。 + 因此这里一律使用 ztools 提供的屏幕 API(getCursorScreenPoint / getDisplayNearestPoint / + getPrimaryDisplay),它们经主进程转发,macOS / Windows 上都安全。注意 macOS 上不要调用 + ztools.screenToDipPoint / dipToScreenPoint,那两个在 macOS 上会触发主进程崩溃。 */ +var toyWindow = null; +var IS_MAC = typeof process !== 'undefined' && process.platform === 'darwin'; + +function displayBoundsAtCursor() { + try { + var p = ztools.getCursorScreenPoint(); + var d = ztools.getDisplayNearestPoint(p); + if (d && d.bounds) return d.bounds; + } catch (e) {} + try { + var d = ztools.getPrimaryDisplay(); + if (d && d.bounds) return d.bounds; + } catch (e) {} + return null; +} + +window.exports = { + "start-toy": { + mode: "none", + args: { + enter: function () { + if (toyWindow && !toyWindow.isDestroyed()) { + ztools.outPlugin(); + return; + } + ztools.hideMainWindow(); + + var w = 380; + var h = 380; + var x = 0; + var y = 0; + var b = displayBoundsAtCursor(); + if (b) { + x = b.x; + y = b.y; + w = b.width; + h = b.height; + } + + toyWindow = ztools.createBrowserWindow("toy/index.html", { + x: x, + y: y, + width: w, + height: h, + frame: false, + transparent: true, + alwaysOnTop: true, + skipTaskbar: true, + resizable: false, // 透明窗口在部分平台(尤其 mac)设 resizable:true 会失效 + focusable: true, // 确保窗口能接收鼠标事件(否则 mac 上无法点击/拖动) + hasShadow: false, + backgroundColor: "#00000000", + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + backgroundThrottling: false, + }, + }, function () { + // mac 上必须先 show 再 focus 窗口才能拿到焦点/鼠标事件(参见 ZTools 修复) + if (IS_MAC && toyWindow && !toyWindow.isDestroyed()) { + try { toyWindow.show(); } catch (e) {} + try { toyWindow.focus(); } catch (e) {} + } + }); + + /* 确保不忽略鼠标事件、可聚焦(若 ztools 内部默认置了 focusable:false 会在此被纠正) */ + try { toyWindow.setIgnoreMouseEvents(false); } catch (e) {} + try { toyWindow.setFocusable(true); } catch (e) {} + + ztools.outPlugin(); + }, + }, + }, +}; diff --git a/plugins/zhu-zhi-liao-toy/toy/index.html b/plugins/zhu-zhi-liao-toy/toy/index.html new file mode 100644 index 00000000..3cd11760 --- /dev/null +++ b/plugins/zhu-zhi-liao-toy/toy/index.html @@ -0,0 +1,30 @@ + + + + +竹知了 + + + + + + + diff --git a/plugins/zhu-zhi-liao-toy/toy/toy.js b/plugins/zhu-zhi-liao-toy/toy/toy.js new file mode 100644 index 00000000..1e1dc167 --- /dev/null +++ b/plugins/zhu-zhi-liao-toy/toy/toy.js @@ -0,0 +1,588 @@ +/* ============================================================ + 竹知了桌宠 —— 鼠标指针变成玩具(全屏版) + 仿 https://github.com/imsai-sh/zhuzhiliao + 玩法:长按左键晃动甩起来就哇哇叫,转得越快叫得越响;右键退出 + 窗口铺满光标所在显示器,竹蝉画在光标位置,甩动不会出界 + 声学:Web Audio 合成链(锯齿波 → 调幅 → 扫频带通 → 膜腔共振峰) + ============================================================ */ +'use strict'; + +if (!('roundRect' in CanvasRenderingContext2D.prototype)) { + CanvasRenderingContext2D.prototype.roundRect = function (x, y, w, h, r) { + r = Math.min(Math.abs(+r) || 0, w / 2, h / 2); + this.moveTo(x + r, y); + this.arcTo(x + w, y, x + w, y + h, r); + this.arcTo(x + w, y + h, x, y + h, r); + this.arcTo(x, y + h, x, y, r); + this.arcTo(x, y, x + r, y, r); + this.closePath(); + return this; + }; +} + +var TAU = Math.PI * 2; +function clamp(v, a, b) { return v < a ? a : v > b ? b : v; } +var FONT = '"Songti SC","Noto Serif SC","STSong","SimSun",serif'; + +var cv = document.getElementById('cv'); +var ctx = cv.getContext('2d'); +var W = 0, H = 0, DPR = 1; + +/* ---------- 玩具参数 ---------- */ +var ROPE_LEN = 150; +var ROPE_K = 2600; +var ROPE_D = 14; +var GRAV = 1150; +var AIR_DRAG = 0.35; + +/* ---------- 状态 ---------- */ +var stick = { x: 0, y: 0 }; // 竹签杆梢(锚点 = 光标位置) +var tube = { x: 0, y: 0, vx: 0, vy: 0 }; // 竹蝉(绳系质点) +var held = false; // 是否按住左键 +var theta = 0, prevTheta = 0, omega = 0; +var ropeDist = ROPE_LEN, taut = 0; +var rps = 0, drive = 0, active = 0; +var interacted = false; +var hintAlpha = 1; +var stickGrad = null, tubeGrad = null; +var winX = 0, winY = 0; + +/* ---------- 光标跟踪(DIP 坐标) ---------- + ztools.getCursorScreenPoint / getDisplayNearestPoint / getPrimaryDisplay 经主进程 + Electron screen 提供,macOS / Windows 上都安全。唯一要在 Windows 上才调用的是 + screenToDipPoint(把物理像素换算成 DIP),macOS 上调用它会触发主进程崩溃,所以必须跳过。 */ +var Z = window.ztools; +var IS_WIN = (typeof process !== 'undefined' && process.platform === 'win32') || + /win/i.test(navigator.platform || ''); + +function getCursor() { + if (Z && Z.getCursorScreenPoint) { + try { + var p = Z.getCursorScreenPoint(); + if (IS_WIN && Z.screenToDipPoint) p = Z.screenToDipPoint(p); + return p; + } catch (e) {} + } + return null; +} + +/* 让窗口铺满光标所在的显示器;跨屏时自动跟随。 + pt 为当前光标点(DIP)。getDisplayNearestPoint 是同步 IPC,做 250ms 节流, + 显示器切换不频繁,避免每帧同步调用拖慢渲染。 */ +var lastDispCheckAt = -999999; +function syncWindow(pt) { + if (!pt) return; + var b = null; + var now = performance.now(); + if (Z && Z.getDisplayNearestPoint && now - lastDispCheckAt > 250) { + lastDispCheckAt = now; + try { + var d = Z.getDisplayNearestPoint({ x: pt.x, y: pt.y }); + if (d && d.bounds) b = d.bounds; + } catch (e) {} + } + if (!b && Z && Z.getPrimaryDisplay) { + try { + var p = Z.getPrimaryDisplay(); + if (p && p.bounds) b = p.bounds; + } catch (e) {} + } + if (!b) return; + var need = Math.abs(window.innerWidth - b.width) > 1 || + Math.abs(window.innerHeight - b.height) > 1 || + Math.abs(winX - b.x) > 1 || + Math.abs(winY - b.y) > 1; + if (need) { + winX = Math.round(b.x); + winY = Math.round(b.y); + try { + window.moveTo(winX, winY); + window.resizeTo(b.width, b.height); + } catch (e) {} + } +} + +function resize() { + if (!(window.innerWidth > 0 && window.innerHeight > 0)) return; + DPR = Math.min(1.5, window.devicePixelRatio || 1); + W = window.innerWidth; H = window.innerHeight; + cv.width = Math.round(W * DPR); cv.height = Math.round(H * DPR); + cv.style.width = W + 'px'; cv.style.height = H + 'px'; + ctx.setTransform(DPR, 0, 0, DPR, 0, 0); + var minDim = Math.min(W, H); + ROPE_LEN = clamp(minDim * 0.22, 120, 240); + if (!interacted) { + stick.x = W * 0.5; stick.y = H * 0.42; + tube.x = stick.x + 8; + tube.y = stick.y + ROPE_LEN * 0.9; + tube.vx = 0; tube.vy = 0; + prevTheta = Math.atan2(tube.y - stick.y, tube.x - stick.x); + } else { + stick.x = clamp(stick.x, 0, W); stick.y = clamp(stick.y, 0, H); + } +} + +/* ============================================================ + 声音 —— Web Audio 合成链 + ============================================================ */ +var AC = null, au = null; + +function ensureAudio() { + if (AC) { + if (AC.state !== 'running') AC.resume().catch(function () {}); + return; + } + var Ctx = window.AudioContext || window.webkitAudioContext; + if (!Ctx) return; + AC = new Ctx(); + AC.resume().catch(function () {}); + + var master = AC.createGain(); master.gain.value = 0; + var comp = AC.createDynamicsCompressor(); + comp.threshold.value = -18; comp.ratio.value = 8; + comp.attack.value = 0.004; comp.release.value = 0.18; + master.connect(comp); comp.connect(AC.destination); + au = { master: master, mode: 'synth' }; + startSynthVoice(); +} + +function startSynthVoice() { + if (!AC || !au) return; + var t = AC.currentTime; + + var osc = AC.createOscillator(); + osc.type = 'sawtooth'; + osc.frequency.value = 70; + var shaper = AC.createWaveShaper(); + { + var n = 1024, curve = new Float32Array(n); + for (var i = 0; i < n; i++) { + var x = i / (n - 1) * 2 - 1; + curve[i] = Math.tanh(x * 3.2); + } + shaper.curve = curve; + shaper.oversample = '2x'; + } + osc.connect(shaper); + + var am = AC.createGain(); am.gain.value = 0.62; + var lfo = AC.createOscillator(); + lfo.type = 'sine'; lfo.frequency.value = 30; + var lfoAmt = AC.createGain(); lfoAmt.gain.value = 0.34; + lfo.connect(lfoAmt); lfoAmt.connect(am.gain); + shaper.connect(am); + + var nBuf = AC.createBuffer(1, AC.sampleRate * 2, AC.sampleRate); + var nd = nBuf.getChannelData(0); + for (i = 0; i < nd.length; i++) nd[i] = Math.random() * 2 - 1; + var noise = AC.createBufferSource(); + noise.buffer = nBuf; noise.loop = true; + var nFil = AC.createBiquadFilter(); + nFil.type = 'bandpass'; nFil.frequency.value = 2500; nFil.Q.value = 0.7; + var nGain = AC.createGain(); nGain.gain.value = 0; + noise.connect(nFil); nFil.connect(nGain); + + var bus = AC.createGain(); bus.gain.value = 0.9; + am.connect(bus); nGain.connect(bus); + var wah = AC.createBiquadFilter(); + wah.type = 'bandpass'; wah.frequency.value = 900; wah.Q.value = 2.2; + bus.connect(wah); + + var sum = AC.createGain(); sum.gain.value = 1; + var formant = function (freq, q, g) { + var f = AC.createBiquadFilter(); + f.type = 'bandpass'; f.frequency.value = freq; f.Q.value = q; + var fg = AC.createGain(); fg.gain.value = g; + wah.connect(f); f.connect(fg); fg.connect(sum); + }; + formant(1050, 9, 0.9); + formant(2150, 11, 0.6); + formant(3350, 13, 0.4); + var bleed = AC.createGain(); bleed.gain.value = 0.07; + wah.connect(bleed); bleed.connect(sum); + + var hp = AC.createBiquadFilter(); + hp.type = 'highpass'; hp.frequency.value = 360; + sum.connect(hp); hp.connect(au.master); + + osc.start(t); lfo.start(t); noise.start(t); + Object.assign(au, { osc: osc, lfo: lfo, nGain: nGain, wah: wah }); + au.mode = 'synth'; +} + +function updateAudio() { + if (!AC || AC.state !== 'running' || !au || au.mode !== 'synth') return; + var t = AC.currentTime; + au.master.gain.setTargetAtTime(0.85 * Math.pow(active, 1.3), t, 0.07); + + var f0 = clamp(55 + rps * 17, 50, 195); + au.osc.frequency.setTargetAtTime(f0, t, 0.06); + au.osc.detune.setTargetAtTime(46 * Math.sin(theta + 0.9) * clamp(active * 1.6, 0, 1), t, 0.03); + au.lfo.frequency.setTargetAtTime(24 + rps * 4.5, t, 0.1); + var wf = 760 + 520 * active + (430 + 330 * active) * Math.sin(theta - 0.7); + au.wah.frequency.setTargetAtTime(Math.max(320, wf), t, 0.025); + au.nGain.gain.setTargetAtTime((0.03 + 0.17 * active) * clamp(drive * 4, 0, 1), t, 0.08); +} + +/* ============================================================ + 物理 —— 绳系质点(重力 + 只拉不推的弹性绳 + 空气阻力) + ============================================================ */ +function physStep(h) { + var dx = tube.x - stick.x, dy = tube.y - stick.y; + var d = Math.hypot(dx, dy) || 1e-6; + var ux = dx / d, uy = dy / d; + var ax = 0, ay = GRAV; + if (d > ROPE_LEN) { + var vrad = tube.vx * ux + tube.vy * uy; + var f = -ROPE_K * (d - ROPE_LEN) - ROPE_D * vrad; + ax += f * ux; ay += f * uy; + } + ax -= AIR_DRAG * tube.vx; + ay -= AIR_DRAG * tube.vy; + tube.vx += ax * h; tube.vy += ay * h; + tube.x += tube.vx * h; tube.y += tube.vy * h; +} + +function update(dt) { + var acc = dt; + var h = 1 / 240; + while (acc > 1e-6) { var s = Math.min(h, acc); physStep(s); acc -= s; } + + // 角速度(发声核心):绳方向的转动快慢 + theta = Math.atan2(tube.y - stick.y, tube.x - stick.x); + var dth = theta - prevTheta; + while (dth > Math.PI) dth -= TAU; + while (dth < -Math.PI) dth += TAU; + omega += (dth / dt - omega) * Math.min(1, dt * 9); + prevTheta = theta; + rps = Math.abs(omega) / TAU; + + // 甩出的圈数累计 + if (held && active > 0.3) { + // 每满一圈记一哇(保留计数逻辑,后续可用) + } + + ropeDist = Math.hypot(tube.x - stick.x, tube.y - stick.y); + taut = clamp((ropeDist / ROPE_LEN - 0.88) / 0.12, 0, 1); + var raw = clamp((rps - 1.1) / 2.6, 0, 1); + var gate = held || active > 0.05; + drive = gate ? raw : 0; + var tgt = Math.pow(drive, 1.25) * taut; + active += (tgt - active) * Math.min(1, dt * (tgt > active ? 10 : 3.2)); + + if (interacted && hintAlpha > 0) hintAlpha = Math.max(0, hintAlpha - dt * 0.6); + + updateAudio(); + spawnFx(dt); +} + +/* ============================================================ + 粒子:轨迹残影 + 声波涟漪 + 飘出的「哇」 + ============================================================ */ +var ripples = []; +var cries = []; +var trail = []; +var rippleTimer = 0, cryTimer = 0; + +function spawnFx(dt) { + trail.unshift({ x: tube.x, y: tube.y }); + if (trail.length > 16) trail.pop(); + + rippleTimer -= dt; cryTimer -= dt; + if (active > 0.15 && rippleTimer <= 0) { + rippleTimer = 0.09; + ripples.push({ x: tube.x, y: tube.y, r: 16, age: 0, life: 0.7, str: active }); + } + if (active > 0.3 && cryTimer <= 0 && cries.length < 36) { + cryTimer = 0.22; + var ux = (tube.x - stick.x) / (ropeDist || 1); + var uy = (tube.y - stick.y) / (ropeDist || 1); + var sgn = omega >= 0 ? 1 : -1; + var tx = -uy * sgn, ty = ux * sgn; + var sp = 90 + Math.random() * 60; + cries.push({ + x: tube.x + ux * 14, y: tube.y + uy * 14, + vx: tx * sp + ux * 30, vy: ty * sp + uy * 30 - 20, + age: 0, life: 1.1, + size: 20 + 30 * active, + rot: (Math.random() - 0.5) * 0.8 + }); + } + for (var i = ripples.length - 1; i >= 0; i--) { + var p = ripples[i]; + p.age += dt; p.r += (200 + 160 * p.str) * dt; + if (p.age >= p.life) ripples.splice(i, 1); + } + for (i = cries.length - 1; i >= 0; i--) { + p = cries[i]; + p.age += dt; + p.x += p.vx * dt; p.y += p.vy * dt; + p.vx *= (1 - dt * 1.2); p.vy = p.vy * (1 - dt * 1.2) - 26 * dt; + if (p.age >= p.life) cries.splice(i, 1); + } +} + +/* ============================================================ + 绘制 —— 竹签 + 线 + 竹蝉 + ============================================================ */ +function drawToy(now) { + var dx = tube.x - stick.x, dy = tube.y - stick.y; + var d = Math.hypot(dx, dy) || 1e-6; + var ux = dx / d, uy = dy / d; + + // 线:松则垂,紧则直 + ctx.strokeStyle = 'rgba(216,74,53,0.92)'; + ctx.lineWidth = 1.6; + ctx.beginPath(); + ctx.moveTo(stick.x, stick.y); + if (d < ROPE_LEN * 0.97) { + var sag = (ROPE_LEN - d) * 0.55; + ctx.quadraticCurveTo((stick.x + tube.x) / 2, (stick.y + tube.y) / 2 + sag, tube.x, tube.y); + } else { + ctx.lineTo(tube.x, tube.y); + } + ctx.stroke(); + + // 甩杆:细竹签,杆梢串两颗红珠 + var sa = 1.15; + var sdx = Math.cos(sa), sdy = Math.sin(sa); + ctx.save(); + ctx.translate(stick.x, stick.y); + if (!stickGrad) { + stickGrad = ctx.createLinearGradient(0, 0, sdx * 88, sdy * 88); + stickGrad.addColorStop(0, '#e2cd9a'); stickGrad.addColorStop(1, '#a8894f'); + } + ctx.strokeStyle = stickGrad; + ctx.lineWidth = 5; ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(sdx * 8, sdy * 8); + ctx.lineTo(sdx * 88, sdy * 88); + ctx.stroke(); + ctx.strokeStyle = 'rgba(255,240,200,0.35)'; + ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.moveTo(sdx * 12 - sdy * 1.4, sdy * 12 + sdx * 1.4); + ctx.lineTo(sdx * 76 - sdy * 1.4, sdy * 76 + sdx * 1.4); + ctx.stroke(); + var bead = function (bx, by, r) { + ctx.fillStyle = '#c23324'; + ctx.beginPath(); ctx.arc(bx, by, r, 0, TAU); ctx.fill(); + ctx.fillStyle = 'rgba(255,200,180,0.55)'; + ctx.beginPath(); ctx.arc(bx - r * 0.3, by - r * 0.35, r * 0.32, 0, TAU); ctx.fill(); + }; + bead(sdx * 14, sdy * 14, 4.6); + bead(sdx * 4, sdy * 4, 6); + ctx.restore(); + + // 竹蝉:膜端(头)朝线,筒身沿绳方向甩出 + ctx.save(); + ctx.translate(tube.x, tube.y); + ctx.rotate(Math.atan2(uy, ux) - Math.PI / 2); + ctx.scale(1.5, 1.5); + + if (!tubeGrad) { + tubeGrad = ctx.createLinearGradient(-12, 0, 12, 0); + tubeGrad.addColorStop(0, '#b7a271'); + tubeGrad.addColorStop(0.3, '#eadfb8'); + tubeGrad.addColorStop(0.62, '#dfd0a2'); + tubeGrad.addColorStop(1, '#9b8452'); + } + ctx.fillStyle = tubeGrad; + ctx.beginPath(); + ctx.roundRect(-12, 4, 24, 46, 6); + ctx.fill(); + + // 尾端开口 + ctx.fillStyle = '#4c3d24'; + ctx.beginPath(); ctx.ellipse(0, 49, 11, 4.2, 0, 0, TAU); ctx.fill(); + ctx.strokeStyle = 'rgba(234,223,184,0.6)'; ctx.lineWidth = 1.2; + ctx.beginPath(); ctx.ellipse(0, 49, 11, 4.2, 0, 0.05, Math.PI - 0.05); ctx.stroke(); + + // 竹蝉翼:转得越快离心张得越开,高频振翅 + var spread = 0.30 + active * 0.38; + var flutter = active * Math.sin(now * 46) * 0.22; + var wing = function (side) { + ctx.save(); + ctx.translate(side * 9, 13); + ctx.rotate(side * (spread + flutter)); + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.bezierCurveTo(side * 13, 9, side * 14, 30, side * 5, 42); + ctx.bezierCurveTo(side * -1, 33, side * -4, 12, 0, 0); + ctx.fillStyle = 'rgba(243,235,211,0.88)'; + ctx.fill(); + ctx.strokeStyle = 'rgba(150,120,70,0.55)'; + ctx.lineWidth = 1; + ctx.stroke(); + ctx.strokeStyle = 'rgba(150,120,70,0.45)'; + ctx.lineWidth = 0.8; + ctx.beginPath(); + ctx.moveTo(side * 2, 4); ctx.quadraticCurveTo(side * 9, 18, side * 5, 38); + ctx.moveTo(side * 1, 6); ctx.quadraticCurveTo(side * 3, 20, side * 1.5, 34); + ctx.stroke(); + ctx.restore(); + }; + wing(-1); wing(1); + + // 红箍(蝉头) + ctx.fillStyle = '#cf3b2a'; + ctx.beginPath(); + ctx.roundRect(-12.5, 2, 25, 10, 4); + ctx.fill(); + + // 膜面 + 发声时透光 + ctx.fillStyle = '#f6eed8'; + ctx.beginPath(); ctx.ellipse(0, 3, 10.5, 4.2, 0, 0, TAU); ctx.fill(); + if (active > 0.05) { + ctx.globalAlpha = active * 0.85; + ctx.shadowColor = '#ffcf8e'; ctx.shadowBlur = 18 * active; + ctx.fillStyle = '#ffe9bd'; + ctx.beginPath(); ctx.ellipse(0, 3, 8.5, 3.4, 0, 0, TAU); ctx.fill(); + ctx.shadowBlur = 0; ctx.globalAlpha = 1; + } + + // 一双黑眼睛 + var eye = function (side) { + ctx.fillStyle = '#17130c'; + ctx.beginPath(); ctx.arc(side * 8.5, 7.5, 2.4, 0, TAU); ctx.fill(); + ctx.fillStyle = 'rgba(255,255,255,0.75)'; + ctx.beginPath(); ctx.arc(side * 8.5 - 0.7, 6.8, 0.7, 0, TAU); ctx.fill(); + }; + eye(-1); eye(1); + + // 膜心线结 + ctx.fillStyle = '#d84a35'; + ctx.beginPath(); ctx.arc(0, 2, 2, 0, TAU); ctx.fill(); + + ctx.restore(); +} + +function draw(now, dt) { + ctx.clearRect(0, 0, W, H); + + // 运动残影 + var spNorm = clamp(rps / 4, 0, 1); + if (spNorm > 0.05 && trail.length > 2) { + ctx.fillStyle = '#ffb36b'; + for (var i = 1; i < trail.length; i++) { + var f = 1 - i / trail.length; + ctx.globalAlpha = f * 0.3 * spNorm; + ctx.beginPath(); + ctx.arc(trail[i].x, trail[i].y, 1.5 + 3.5 * f, 0, TAU); + ctx.fill(); + } + ctx.globalAlpha = 1; + } + + drawToy(now); + + // 声波涟漪 + for (i = 0; i < ripples.length; i++) { + var p = ripples[i]; + var t = p.age / p.life; + ctx.globalAlpha = (1 - t) * 0.35 * p.str; + ctx.strokeStyle = '#ffcf9e'; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, TAU); ctx.stroke(); + } + ctx.globalAlpha = 1; + + // 飘出的「哇」 + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + for (i = 0; i < cries.length; i++) { + p = cries[i]; + t = p.age / p.life; + ctx.save(); + ctx.translate(p.x, p.y); + ctx.rotate(p.rot * t); + ctx.globalAlpha = (1 - t) * (1 - t) * 0.95; + ctx.font = (p.size * (1 + 0.5 * t)).toFixed(1) + 'px ' + FONT; + ctx.shadowColor = 'rgba(255,150,60,0.9)'; + ctx.shadowBlur = 18 * (1 - t); + ctx.fillStyle = '#ffb066'; + ctx.fillText('哇', 0, 0); + ctx.shadowBlur = 0; + ctx.restore(); + } + ctx.globalAlpha = 1; + + // 提示文字(首次交互后淡出) + if (hintAlpha > 0) { + ctx.globalAlpha = hintAlpha * (0.5 + 0.5 * Math.sin(now * 3)); + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.font = '13px ' + FONT; + ctx.shadowColor = 'rgba(0,0,0,0.6)'; ctx.shadowBlur = 4; + ctx.fillStyle = '#f0e6d2'; + ctx.fillText('长按左键甩起来 · 右键退出', W * 0.5, H * 0.12); + ctx.shadowBlur = 0; + ctx.globalAlpha = 1; + } +} + +/* ============================================================ + 输入 + ============================================================ */ +function exit() { + try { window.close(); } catch (e) {} + try { if (Z && Z.outPlugin) Z.outPlugin(true); } catch (e) {} +} + +document.addEventListener('mousedown', function (e) { + if (e.button === 2) { exit(); return; } + if (e.button === 0) { + held = true; + interacted = true; + ensureAudio(); + } +}); + +document.addEventListener('mouseup', function (e) { + if (e.button === 0) held = false; +}); + +document.addEventListener('contextmenu', function (e) { + e.preventDefault(); + exit(); +}); + +document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') exit(); +}); + +window.addEventListener('blur', function () { + held = false; +}); + +/* ============================================================ + 主循环 + ============================================================ */ +var lastNow = 0; +function frame(now) { + if (window.innerWidth > 0 && (window.innerWidth !== W || window.innerHeight !== H)) resize(); + var dt = Math.min(0.05, (now - lastNow) / 1000) || 0.016; + lastNow = now; + + var pt = getCursor(); + syncWindow(pt); + if (pt) { + // 用窗口真实屏幕位置换算窗口内坐标(macOS 上窗口会被菜单栏/安全区约束, + // 实际 screenY 可能比请求的 bounds.y 大,必须读真实值,否则竹蝉会偏离光标) + var sx = 0, sy = 0; + try { sx = window.screenX || 0; sy = window.screenY || 0; } catch (e) {} + stick.x = pt.x - sx; + stick.y = pt.y - sy; + } + + update(dt); + draw(now / 1000, dt); + requestAnimationFrame(frame); +} + +try { + winX = window.screenX || 0; + winY = window.screenY || 0; +} catch (e) {} +resize(); +requestAnimationFrame(function (now) { + lastNow = now; + requestAnimationFrame(frame); +});