diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..de609f8 --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,11 @@ +* +!Dockerfile +!nginx.conf +!index.html +!style.css +!game.js +!sw.js +!manifest.webmanifest +!icon.svg +!apple-touch-icon.png +!icon-512.png diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..cd01ad1 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1 @@ +.qa/ diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..4e506cb --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,5 @@ +FROM nginx:1.27-alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --chmod=0644 index.html style.css game.js sw.js manifest.webmanifest icon.svg apple-touch-icon.png icon-512.png /usr/share/nginx/html/ +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1 diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..49abd7f --- /dev/null +++ b/web/README.md @@ -0,0 +1,61 @@ +# PawPlay 网页版 + +原生 Canvas 2D 实现的鱼池、红点、羽毛触摸游戏。免费、无广告、无账号,不依赖外部字体、图片、音视频文件或第三方 SDK。静态文件可直接部署。 + +## 本地运行 + +在仓库根目录执行: + +```bash +python3 -m http.server 18731 --bind 127.0.0.1 --directory web +``` + +打开 `http://127.0.0.1:18731/`。正式部署需 HTTPS;通过平板访问局域网明文 HTTP 时,Service Worker 不可用。 + +## 玩法 + +- 轻触或拖动屏幕产生水波、粒子或风。支持多指。 +- 长按任一上角爪印两秒打开家长面板;移动过远、释放或切换后台会取消长按。键盘用户可聚焦爪印后按住空格或 Enter 两秒。 +- 家长面板切换场景、音效,继续游玩或进入全屏。设置只在当前设备本地保存;存储不可用时仍可玩。 +- 首次用户交互解锁音效;切换后台暂停动画和音频。支持时请求屏幕常亮;浏览器拒绝时遵循系统休眠设置。 +- 在 iPad Safari 使用“分享 → 添加到主屏幕”可获得更完整的显示区域。系统手势由设备控制,网页无法屏蔽。 + +## 离线与更新 + +家长面板显示“已准备好,可断网重新打开”后,应用壳和三个模式已缓存。没有缓存完成就离线,或系统主动清理网站数据时,需要重新联网加载。 + +`sw.js` 一次完整缓存当前版本的所有资源,安装失败时删除本次不完整缓存。新 Worker 不强制替换正在游玩的旧 Worker;关闭旧游戏窗口后,新版本才会激活。导航和静态资源只从同一版本的缓存读取,不做接口或跨站运行时缓存。 + +发布新资源时同时修改 `sw.js` 的 `VERSION`、资源查询版本,以及 `index.html` 中 JS/CSS 的查询版本。验证新缓存完成后再发布;不要只替换同一版本下的某一个资源。 + +## Docker + +```bash +docker build -t pawplay-web ./web +docker run --rm -p 18731:8080 pawplay-web +``` + +容器监听 `8080`,健康检查为 `/healthz`。镜像只复制游戏资源,不含测试截图、源码目录、密钥或运营数据。Nginx 对 JS/CSS/SVG 启用 gzip;首页和 Worker 不使用长期 HTTP 缓存。 + +## 浏览器验证 + +使用已安装的 Playwright 运行(脚本自行启动并关闭临时 HTTP 服务): + +```bash +node web/qa.cjs +``` + +如 Playwright 未在模块搜索路径内,用 `PLAYWRIGHT_MODULE` 指向已安装的模块目录;`CHROME_BIN`、`WEBKIT_BIN` 可指定兼容的浏览器。模块版本必须匹配浏览器缓存版本。结果和截图保存在被 Git 忽略的 `web/.qa/`。 + +脚本验证短按防误触、两秒长按、三模式切换、设置持久化、横竖屏无滚动、关闭 HTTP 源站后关闭页面并重新打开,以及 Chromium 双指输入。检查无外部请求和页面异常,并统计实际资源字节数、gzip 字节数。WebKit 的自动化离线开关可能在 Worker 接管请求之前报内部错误,因此以真实关闭源站验证缓存读取。 + + +本轮验证:Chromium 与 WebKit 均通过上述菜单、横竖屏、设置持久化和源站关闭后重开测试;Chromium 另通过双指输入。完整游戏资源合计 62,166 字节(约 61 KiB),逐文件 gzip 合计约 40 KiB;其中 PNG 在正式 Nginx 中不额外 gzip,实际传输会有少量差异。HTTP 头和 TLS 握手开销不计入此数。 + +验证范围仅为桌面 Chromium/WebKit 引擎。没有 iPad 真机数据;不能据此声称已通过 iPad Safari、主屏幕模式、真实音频输出、引导式访问、屏幕休眠或真实猫咪试玩验收。 + +## 实现边界 + +场景复用 Android 版的配色和交互规律,以 JavaScript/Canvas 重写绘制与物理逻辑,并非运行 APK。画布像素比最多 2,总像素数最多约 400 万;粒子最多 160、水波最多 35,以限制高分辨率设备开销。不作所有设备稳定 60fps 的承诺。 + +网页没有应用层行为统计或账号标识。托管服务可能保留常规 HTTP 访问日志,实际部署时应按托管配置说明。 diff --git a/web/apple-touch-icon.png b/web/apple-touch-icon.png new file mode 100644 index 0000000..74a7dc3 Binary files /dev/null and b/web/apple-touch-icon.png differ diff --git a/web/game.js b/web/game.js new file mode 100644 index 0000000..1ae61cd --- /dev/null +++ b/web/game.js @@ -0,0 +1,324 @@ +(() => { + '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(); +})(); diff --git a/web/icon-512.png b/web/icon-512.png new file mode 100644 index 0000000..dcd8508 Binary files /dev/null and b/web/icon-512.png differ diff --git a/web/icon.svg b/web/icon.svg new file mode 100644 index 0000000..6ba193a --- /dev/null +++ b/web/icon.svg @@ -0,0 +1 @@ + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..07ac9fa --- /dev/null +++ b/web/index.html @@ -0,0 +1,39 @@ + + +
+ + + + + + + + + + +