(() => { 'use strict'; const canvas = document.querySelector('#pond'); const ctx = canvas.getContext('2d', { alpha: false }); const panel = document.querySelector('#settings'); const hint = document.querySelector('#hint'); const TAU = Math.PI * 2; const colors = ['#ff7a59', '#ffc857', '#fff1dc', '#66e0d4', '#ff9fb2', '#7fb7ff', '#ffa45b']; const random = (min, max) => min + Math.random() * (max - min); const clamp = (x, min, max) => Math.min(max, Math.max(min, x)); const readSetting = key => { try { return localStorage.getItem(key); } catch { return null; } }; const saveSetting = (key, value) => { try { localStorage.setItem(key, value); } catch { /* Private browsing/storage pressure may prevent persistence. */ } }; let mode = readSetting('pawplay-mode') || 'fish'; if (!['fish', 'laser', 'feather'].includes(mode)) mode = 'fish'; let soundEnabled = readSetting('pawplay-sound') !== 'off'; let audio, lastSound = -1, wakeLock; let width, height, unit, time = 0, previous = 0, raf = 0; let background, grain = [], fish = [], feathers = [], dot; let particles = [], ripples = [], touches = [], trails = []; let lastMoveFx = 0; const pointers = new Map(); function circle(x, y, r, fill) { ctx.fillStyle = fill; ctx.beginPath(); ctx.arc(x, y, Math.max(.1, r), 0, TAU); ctx.fill(); } function unlockSound() { if (!soundEnabled) return; try { const Audio = window.AudioContext || window.webkitAudioContext; if (!Audio) return; if (!audio) audio = new Audio(); if (audio.state === 'suspended') audio.resume().catch(() => {}); } catch { /* Continue silently when audio is unavailable. */ } } function playSound(kind = mode) { if (!soundEnabled || !audio || audio.state !== 'running') return; const now = audio.currentTime; if (now - lastSound < .1) return; lastSound = now; const oscillator = audio.createOscillator(); const gain = audio.createGain(); oscillator.type = kind === 'feather' ? 'sine' : 'triangle'; const frequency = kind === 'fish' ? 370 : kind === 'laser' ? 680 : 220; oscillator.frequency.setValueAtTime(frequency, now); oscillator.frequency.exponentialRampToValueAtTime(frequency * .55, now + .17); gain.gain.setValueAtTime(.0001, now); gain.gain.exponentialRampToValueAtTime(.025, now + .015); gain.gain.exponentialRampToValueAtTime(.0001, now + .2); oscillator.connect(gain); gain.connect(audio.destination); oscillator.start(now); oscillator.stop(now + .22); oscillator.onended = () => { oscillator.disconnect(); gain.disconnect(); }; } async function keepAwake() { if (!('wakeLock' in navigator) || document.hidden || wakeLock) return; try { wakeLock = await navigator.wakeLock.request('screen'); wakeLock.addEventListener('release', () => { wakeLock = null; }); } catch { /* Screen sleep settings remain controlled by the device. */ } } function initialize() { const box = canvas.getBoundingClientRect(); width = Math.max(1, box.width); height = Math.max(1, box.height); const dpr = Math.min(window.devicePixelRatio || 1, 2, Math.sqrt(4000000 / (width * height))); canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); unit = clamp(Math.min(width, height) / 700, .65, 1.35); const light = mode === 'feather'; if (mode === 'laser') { background = ctx.createRadialGradient(width * .54, height * .42, 0, width * .5, height * .5, Math.max(width, height) * .8); background.addColorStop(0, '#2a2033'); background.addColorStop(.55, '#17111f'); background.addColorStop(1, '#08060d'); } else { background = ctx.createLinearGradient(0, 0, width * .35, height); background.addColorStop(0, light ? '#fffaee' : '#0c4653'); background.addColorStop(.58, light ? '#f7e8d2' : '#0a3742'); background.addColorStop(1, light ? '#edd5b8' : '#061e27'); } grain = Array.from({ length: light ? 110 : 70 }, () => ({ x: random(0, width), y: random(0, height), r: random(.6, 2) * unit, phase: random(0, TAU) })); const count = width > height ? 7 : 6; fish = Array.from({ length: count }, (_, i) => ({ x: width * (.15 + .7 * (i % 3) / 2), y: height * (.2 + .6 * Math.floor(i / 3) / Math.ceil(count / 3)), size: random(34, 46) * unit, color: colors[i], angle: random(0, TAU), vx: 0, vy: 0, tx: random(width * .12, width * .88), ty: random(height * .12, height * .88), retarget: random(1, 4), phase: random(0, TAU), boost: 0, hidden: 0, })); feathers = ['#147b76', '#d4543f', '#c08628'].map((color, i) => ({ x: width * (.24 + i * .26), y: height * (.38 + i * .09), size: (64 + i * 7) * unit, angle: (-32 + i * 22) / 180 * Math.PI, vx: 0, vy: 0, spin: 0, phase: random(0, TAU), color })); dot = { x: width / 2, y: height / 2, vx: 0, vy: 0, tx: width * .3, ty: height * .3, pause: 0, retarget: 2 }; particles = []; ripples = []; touches = []; trails = []; document.body.dataset.mode = mode; canvas.setAttribute('aria-label', `猫咪触摸游戏:${{ fish: '鱼池', laser: '红点', feather: '羽毛' }[mode]}。轻触屏幕有反馈,长按角落爪印两秒打开家长设置。`); } function effect(x, y, color, count = 8) { ripples.push({ x, y, age: 0, color }); if (ripples.length > 35) ripples.shift(); for (let i = 0; i < count; i++) { const angle = random(0, TAU), speed = random(30, 180) * unit; particles.push({ x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life: random(.5, 1.1), age: 0, size: random(2, 5) * unit, color, star: i % 3 === 0 }); } if (particles.length > 160) particles.splice(0, particles.length - 160); } function interact(x, y, down) { touches.push({ x, y, age: 0 }); if (touches.length > 12) touches.shift(); if (down || time - lastMoveFx > .075) { effect(x, y, mode === 'feather' ? '#a67b53' : mode === 'laser' ? '#ffb4a6' : '#bff7f1', down ? 7 : 1); lastMoveFx = time; } if (down) { playSound(); if (mode === 'fish') { const caught = fish.find(f => f.hidden <= 0 && Math.hypot(f.x - x, f.y - y) < f.size * 1.9); if (caught) { caught.hidden = random(1.1, 1.8); effect(caught.x, caught.y, caught.color, 24); } } else if (mode === 'laser' && Math.hypot(dot.x - x, dot.y - y) < 75 * unit) { effect(dot.x, dot.y, '#ffd7ce', 20); dot.x = random(width * .15, width * .85); dot.y = random(height * .15, height * .85); dot.vx = dot.vy = 0; dot.pause = .06; } } } function update(dt) { for (const p of particles) { p.age += dt; p.x += p.vx * dt; p.y += p.vy * dt; p.vx *= 1 - dt * .8; p.vy += (mode === 'feather' ? 32 : -24) * dt * unit; } particles = particles.filter(p => p.age < p.life); ripples.forEach(p => { p.age += dt; }); ripples = ripples.filter(p => p.age < .9); touches.forEach(p => { p.age += dt; }); touches = touches.filter(p => p.age < .65); if (mode === 'fish') { for (const f of fish) { if (f.hidden > 0) { f.hidden -= dt; if (f.hidden <= 0) { f.x = -f.size; f.y = random(height * .15, height * .85); f.vx = 100 * unit; f.tx = width / 2; } continue; } f.retarget -= dt; if (f.retarget <= 0 || Math.hypot(f.x - f.tx, f.y - f.ty) < 40 * unit) { f.tx = random(width * .12, width * .88); f.ty = random(height * .12, height * .88); f.retarget = random(2.2, 5.5); } if (Math.random() < dt * .13) f.boost = 1; f.boost = Math.max(0, f.boost - dt * .8); const a = Math.atan2(f.ty - f.y, f.tx - f.x), speed = (55 + 22 * Math.sin(f.phase)) * unit * (1 + f.boost * 1.7); f.vx += (Math.cos(a) * speed - f.vx) * dt * 1.3; f.vy += (Math.sin(a) * speed - f.vy) * dt * 1.3; for (const t of touches) { const dx = f.x - t.x, dy = f.y - t.y, d = Math.hypot(dx, dy); if (d > 1 && d < 190 * unit) { const force = 1100 * unit * (1 - d / (190 * unit)); f.vx += dx / d * force * dt; f.vy += dy / d * force * dt; } } for (const other of fish) { if (other === f || other.hidden > 0) continue; const dx = f.x - other.x, dy = f.y - other.y, d = Math.hypot(dx, dy), margin = (f.size + other.size) * 1.5; if (d > 1 && d < margin) { f.vx += dx / d * (margin - d) * dt * 1.8; f.vy += dy / d * (margin - d) * dt * 1.8; } } const speedNow = Math.hypot(f.vx, f.vy), maxSpeed = 320 * unit; if (speedNow > maxSpeed) { f.vx *= maxSpeed / speedNow; f.vy *= maxSpeed / speedNow; } f.x += f.vx * dt; f.y += f.vy * dt; if (f.x < -f.size || f.x > width + f.size || f.y < -f.size || f.y > height + f.size) { f.tx = width / 2; f.ty = height / 2; f.retarget = 2; } const wanted = Math.atan2(f.vy, f.vx), difference = Math.atan2(Math.sin(wanted - f.angle), Math.cos(wanted - f.angle)); f.angle += difference * Math.min(1, dt * 5); f.phase += dt * 4; } } else if (mode === 'feather') { feathers.forEach((f, i) => { f.vx += (Math.sin(time * .47 + f.phase) * 9 * unit - f.vx) * dt * .9; f.vy += ((Math.cos(time * .38 + f.phase * 1.3) * 6 - 2) * unit - f.vy) * dt * .9; for (const t of touches) { const dx = f.x - t.x, dy = f.y - t.y, d = Math.hypot(dx, dy); if (d > 1 && d < 320 * unit) { const force = (1 - d / (320 * unit)) * 950 * unit; f.vx += dx / d * force * dt; f.vy += (dy / d * force - 90 * unit) * dt; f.spin += Math.sin(i * 3 + t.x) * dt * 2; } } f.x += f.vx * dt; f.y += f.vy * dt; f.angle += (f.spin + Math.sin(time * .4 + f.phase) * .04) * dt; f.spin *= 1 - dt * .55; const margin = Math.min(f.size, Math.min(width, height) * .2); if (f.x < margin || f.x > width - margin) { f.x = clamp(f.x, margin, width - margin); f.vx *= -.7; } if (f.y < margin || f.y > height - margin) { f.y = clamp(f.y, margin, height - margin); f.vy *= -.7; } }); } else { trails.forEach(p => { p.age += dt; }); trails = trails.filter(p => p.age < .34); trails.push({ x: dot.x, y: dot.y, age: 0 }); if (trails.length > 26) trails.shift(); dot.pause -= dt; if (dot.pause <= 0) { let vx = (dot.tx - dot.x) * 3.6 + Math.sin(time * 3.4) * 40 * unit; let vy = (dot.ty - dot.y) * 3.6 + Math.cos(time * 3.4) * 40 * unit; const speed = Math.hypot(vx, vy), maxSpeed = 720 * unit; if (speed > maxSpeed) { vx *= maxSpeed / speed; vy *= maxSpeed / speed; } dot.vx += (vx - dot.vx) * dt * 6.5; dot.vy += (vy - dot.vy) * dt * 6.5; dot.x += dot.vx * dt; dot.y += dot.vy * dt; dot.retarget -= dt; if (Math.hypot(dot.tx - dot.x, dot.ty - dot.y) < 22 * unit || dot.retarget <= 0) { dot.tx = random(width * .12, width * .88); dot.ty = random(height * .12, height * .88); dot.pause = random(.2, .75); dot.retarget = random(1.2, 3.4); } } } } function drawFish(f) { if (f.hidden > 0) return; ctx.save(); ctx.translate(f.x, f.y); ctx.rotate(f.angle); const s = f.size, tail = Math.sin(f.phase) * s * .17; ctx.shadowColor = '#0005'; ctx.shadowBlur = 10 * unit; ctx.shadowOffsetY = 8 * unit; ctx.fillStyle = f.color; ctx.beginPath(); ctx.moveTo(-s * .7, 0); ctx.quadraticCurveTo(-s * 1.8, -s * .55 + tail, -s * 1.65, tail); ctx.quadraticCurveTo(-s * 1.8, s * .55 + tail, -s * .7, 0); ctx.fill(); ctx.beginPath(); ctx.ellipse(0, 0, s, s * .43, 0, 0, TAU); ctx.fill(); ctx.shadowColor = 'transparent'; ctx.save(); ctx.beginPath(); ctx.ellipse(0, 0, s, s * .43, 0, 0, TAU); ctx.clip(); ctx.fillStyle = '#fff4'; ctx.beginPath(); ctx.ellipse(-s * .2, -s * .15, s * .65, s * .17, -.1, 0, TAU); ctx.fill(); for (let i = 0; i < 3; i++) { ctx.fillStyle = i % 2 ? '#fff8' : '#8f433b65'; ctx.beginPath(); ctx.ellipse((i - 1) * s * .45, (i % 2 ? 1 : -1) * s * .1, s * .25, s * .24, .3, 0, TAU); ctx.fill(); } ctx.restore(); ctx.fillStyle = f.color + 'bb'; for (const side of [-1, 1]) { ctx.beginPath(); ctx.moveTo(s * .1, side * s * .28); ctx.quadraticCurveTo(-s * .15, side * s * .95, -s * .45, side * s * .5); ctx.closePath(); ctx.fill(); } circle(s * .69, -s * .2, s * .085, '#172d32'); circle(s * .71, -s * .22, s * .026, '#fff'); circle(s * .69, s * .2, s * .075, '#172d32'); circle(s * .71, s * .18, s * .024, '#fff'); ctx.restore(); } function drawFeather(f) { ctx.save(); ctx.translate(f.x, f.y); ctx.rotate(f.angle); const h = f.size * 1.38, w = f.size * .4; ctx.shadowColor = '#6b4a3220'; ctx.shadowBlur = 9; ctx.shadowOffsetX = 8 * unit; ctx.shadowOffsetY = 10 * unit; ctx.beginPath(); ctx.moveTo(0, -h); ctx.bezierCurveTo(-w * 1.5, -h * .5, -w, h * .5, 0, h * .8); ctx.bezierCurveTo(w * .9, h * .4, w * 1.3, -h * .55, 0, -h); ctx.fillStyle = f.color; ctx.fill(); ctx.shadowColor = 'transparent'; ctx.save(); ctx.clip(); ctx.lineWidth = .8 * unit; for (let i = 0; i < 25; i++) { const y = -h + i * h * 1.8 / 25; ctx.strokeStyle = i % 2 ? '#fff7' : '#fff3'; ctx.beginPath(); ctx.moveTo(-w * 1.4, y + h * .12); ctx.lineTo(0, y); ctx.lineTo(w * 1.4, y + h * .16); ctx.stroke(); } ctx.restore(); ctx.strokeStyle = '#fff5dc'; ctx.lineWidth = 2.5 * unit; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(0, -h * .95); ctx.quadraticCurveTo(-w * .07, h * .1, 0, h * 1.1); ctx.stroke(); ctx.restore(); } function draw() { ctx.fillStyle = background; ctx.fillRect(0, 0, width, height); for (const g of grain) circle(g.x + Math.sin(time * .2 + g.phase) * 6 * unit, (g.y - time * 5 * unit + height * 1000) % height, g.r, mode === 'feather' ? '#b29a7424' : '#d4f7df20'); if (mode === 'fish') { ctx.lineWidth = 13 * unit; ctx.strokeStyle = '#a5f2de09'; for (let i = 0; i < 5; i++) { ctx.beginPath(); for (let x = -40; x <= width + 40; x += 30) { const y = height * i / 4 + Math.sin(x / (160 * unit) + time * .27 + i) * 38 * unit; if (x === -40) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); } for (const f of fish) drawFish(f); } else if (mode === 'feather') { const sun = ctx.createRadialGradient(width * .8, 0, 0, width * .8, 0, Math.max(width, height) * .7); sun.addColorStop(0, '#fff7d866'); sun.addColorStop(1, '#fff7d800'); ctx.fillStyle = sun; ctx.fillRect(0, 0, width, height); for (const f of feathers) drawFeather(f); } else { ctx.lineWidth = 1; ctx.strokeStyle = '#ffe4c80b'; for (let x = -height; x < width + height; x += 58 * unit) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x - height, height); ctx.stroke(); } for (const p of trails) { ctx.globalAlpha = (1 - p.age / .34) * .1; circle(p.x, p.y, 25 * unit, '#ff5545'); } ctx.globalAlpha = 1; const radius = 110 * unit; const glow = ctx.createRadialGradient(dot.x, dot.y, 0, dot.x, dot.y, radius); glow.addColorStop(0, '#ff564988'); glow.addColorStop(.3, '#ff3a2e44'); glow.addColorStop(1, '#ff2a2000'); circle(dot.x, dot.y, radius, glow); circle(dot.x, dot.y, 17 * unit, '#ff352a'); circle(dot.x, dot.y, 10 * unit, '#ffd7ce'); circle(dot.x - 2.5 * unit, dot.y - 2.5 * unit, 4.6 * unit, '#fff'); } for (const p of ripples) { ctx.globalAlpha = (1 - p.age / .9) * .65; ctx.strokeStyle = p.color; ctx.lineWidth = 1.7 * unit; ctx.beginPath(); ctx.arc(p.x, p.y, (10 + p.age * 100) * unit, 0, TAU); ctx.stroke(); } for (const p of particles) { ctx.globalAlpha = 1 - p.age / p.life; if (p.star) { ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.age * 2); ctx.fillStyle = p.color; ctx.beginPath(); for (let i = 0; i < 8; i++) { const a = i * Math.PI / 4, r = p.size * (i % 2 ? .4 : 1.4); ctx.lineTo(Math.cos(a) * r, Math.sin(a) * r); } ctx.closePath(); ctx.fill(); ctx.restore(); } else circle(p.x, p.y, p.size, p.color); } ctx.globalAlpha = 1; } function frame(timestamp) { if (previous && timestamp - previous < 15.8) { raf = requestAnimationFrame(frame); return; } const dt = Math.min((timestamp - previous) / 1000 || 1 / 60, .035); previous = timestamp; time += dt; if (!panel.open) update(dt); draw(); raf = requestAnimationFrame(frame); } canvas.addEventListener('pointerdown', event => { event.preventDefault(); unlockSound(); keepAwake(); pointers.set(event.pointerId, true); canvas.setPointerCapture(event.pointerId); interact(event.clientX, event.clientY, true); }); canvas.addEventListener('pointermove', event => { if (!pointers.has(event.pointerId)) return; event.preventDefault(); interact(event.clientX, event.clientY, false); }); for (const name of ['pointerup', 'pointercancel', 'lostpointercapture']) canvas.addEventListener(name, event => { pointers.delete(event.pointerId); }); canvas.addEventListener('contextmenu', event => event.preventDefault()); let hold; function cancelHold() { if (!hold) return; clearTimeout(hold.timer); hold.button.removeAttribute('data-holding'); hold = null; } function startHold(button, event) { cancelHold(); const x = event.clientX, y = event.clientY; hold = { button, x, y, timer: setTimeout(() => { cancelHold(); pointers.clear(); panel.showModal(); document.querySelector('#resume').focus(); }, 2000) }; button.setAttribute('data-holding', 'true'); } document.querySelectorAll('.paw').forEach(button => { button.addEventListener('pointerdown', event => { event.preventDefault(); unlockSound(); keepAwake(); if (event.isPrimary === false) { cancelHold(); return; } button.setPointerCapture(event.pointerId); startHold(button, event); }); button.addEventListener('pointermove', event => { if (hold && Math.hypot(event.clientX - hold.x, event.clientY - hold.y) > 16) cancelHold(); }); for (const name of ['pointerup', 'pointercancel', 'lostpointercapture', 'blur']) button.addEventListener(name, cancelHold); button.addEventListener('keydown', event => { if ([' ', 'Enter'].includes(event.key) && !event.repeat) { event.preventDefault(); startHold(button, {}); } }); button.addEventListener('keyup', cancelHold); button.addEventListener('contextmenu', event => event.preventDefault()); }); window.addEventListener('blur', () => { cancelHold(); pointers.clear(); }); function syncControls() { document.querySelectorAll('[data-scene]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.scene === mode))); document.querySelector('#sound').setAttribute('aria-checked', String(soundEnabled)); } document.querySelectorAll('[data-scene]').forEach(button => button.addEventListener('click', () => { mode = button.dataset.scene; saveSetting('pawplay-mode', mode); initialize(); syncControls(); unlockSound(); playSound(); })); document.querySelector('#sound').addEventListener('click', () => { soundEnabled = !soundEnabled; saveSetting('pawplay-sound', soundEnabled ? 'on' : 'off'); syncControls(); if (soundEnabled) { unlockSound(); playSound(); } else if (audio) audio.suspend().catch(() => {}); }); document.querySelector('#resume').addEventListener('click', () => { panel.close(); previous = performance.now(); unlockSound(); keepAwake(); }); const fullscreen = document.querySelector('#fullscreen'); if (!document.documentElement.requestFullscreen) { fullscreen.textContent = '主屏幕模式更沉浸'; fullscreen.disabled = true; } else fullscreen.addEventListener('click', async () => { try { if (document.fullscreenElement) await document.exitFullscreen(); else await document.documentElement.requestFullscreen(); } catch { fullscreen.textContent = '请添加到主屏幕'; } }); document.addEventListener('fullscreenchange', () => { fullscreen.textContent = document.fullscreenElement ? '退出全屏' : '进入全屏'; }); document.addEventListener('visibilitychange', () => { cancelHold(); pointers.clear(); if (document.hidden) { cancelAnimationFrame(raf); raf = 0; if (audio) audio.suspend().catch(() => {}); } else { previous = performance.now(); if (!raf) raf = requestAnimationFrame(frame); if (audio && soundEnabled) audio.resume().catch(() => {}); keepAwake(); } }); window.addEventListener('resize', initialize); window.addEventListener('pagehide', () => { cancelHold(); pointers.clear(); }); syncControls(); initialize(); raf = requestAnimationFrame(frame); setTimeout(() => hint.classList.add('hidden'), 6500); async function prepareOffline() { const status = document.querySelector('#offline'); if (!('serviceWorker' in navigator) || !window.isSecureContext) { status.textContent = '此浏览器需联网游玩'; return; } try { await navigator.serviceWorker.register('./sw.js'); await Promise.race([navigator.serviceWorker.ready, new Promise((_, reject) => setTimeout(() => reject(new Error('offline setup timeout')), 30000))]); status.textContent = '已准备好,可断网重新打开'; document.querySelector('#offline-dot').classList.add('ready'); } catch { status.textContent = '尚未缓存完成,请联网重试'; } } prepareOffline(); })();