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 @@ + + + + + + + + + + + + + + 猫爪游乐场 · PawPlay + + + + + + + + +
PAWPLAY / 家长设置
+

今天,玩哪一个?

+

陪它轻轻玩一会儿。

+
+ + + +
+
轻柔音效首次触摸后开启声音
+
离线游玩正在准备离线内容…
+
+
玩法与添加到主屏幕

屏幕放平,贴好保护膜,由人陪着玩。猫咪不感兴趣时,换个模式或换成实体玩具。屏幕互动不能替代日常陪伴。

iPad:用 Safari 打开,点“分享”→“添加到主屏幕”。完成离线准备后,可断网重新打开。系统清理缓存后,需要联网加载一次。

网页无法屏蔽系统手势。需要防止误退出时,可由家长开启 iPad 的“引导式访问”或 Android 的“屏幕固定”。

免费 · 无广告 · 无账号 · 设置仅保存在这台设备。网页托管服务可能保留常规访问日志。

+ +
+ + + diff --git a/web/manifest.webmanifest b/web/manifest.webmanifest new file mode 100644 index 0000000..575cc60 --- /dev/null +++ b/web/manifest.webmanifest @@ -0,0 +1 @@ +{"id":"./","name":"猫爪游乐场 · PawPlay","short_name":"猫爪游乐场","description":"鱼池、红点、羽毛,三个会回应爪子的触摸小游戏。","lang":"zh-CN","start_url":"./","scope":"./","display":"standalone","background_color":"#0b3943","theme_color":"#0b3943","icons":[{"src":"./icon.svg","sizes":"any","type":"image/svg+xml","purpose":"any"},{"src":"./apple-touch-icon.png","sizes":"192x192","type":"image/png","purpose":"any"},{"src":"./icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]} diff --git a/web/nginx.conf b/web/nginx.conf new file mode 100644 index 0000000..cdff611 --- /dev/null +++ b/web/nginx.conf @@ -0,0 +1,17 @@ +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + index index.html; + server_tokens off; + gzip on; + gzip_types text/css application/javascript application/manifest+json image/svg+xml; + add_header Cache-Control 'no-cache' always; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always; + location = /healthz { access_log off; default_type text/plain; return 200 'ok'; } + location = /sw.js { try_files $uri =404; } + location = /index.html { try_files $uri =404; } + location / { try_files $uri $uri/ =404; } +} diff --git a/web/qa.cjs b/web/qa.cjs new file mode 100644 index 0000000..986ef41 --- /dev/null +++ b/web/qa.cjs @@ -0,0 +1,102 @@ +// Self-contained browser QA. The temporary HTTP source is shut down for the +// offline test: WebKit's emulated offline mode can fail before SW dispatch. +const { chromium, webkit } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const zlib = require('node:zlib'); +const http = require('node:http'); +const output = path.join(__dirname, '.qa'); +fs.mkdirSync(output, {recursive:true}); + +async function openSettings(page, side = 'left') { + const button = page.locator('.paw.' + side); + const box = await button.boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.waitForTimeout(2150); + await page.mouse.up(); + await page.waitForFunction(() => document.querySelector('#settings').open); +} + +async function verify(browserType, name, launchOptions) { + const assets = new Set(['index.html','game.js','style.css','manifest.webmanifest','sw.js','icon.svg','apple-touch-icon.png','icon-512.png']); + const types = {'.html':'text/html','.js':'application/javascript','.css':'text/css','.svg':'image/svg+xml','.png':'image/png','.webmanifest':'application/manifest+json'}; + const source = http.createServer((request, response) => { + const file = new URL(request.url, 'http://local').pathname.slice(1) || 'index.html'; + if (!assets.has(file)) { response.writeHead(404); return response.end(); } + response.setHeader('Content-Type', types[path.extname(file)] || 'application/octet-stream'); + response.end(fs.readFileSync(path.join(__dirname,file))); + }); + await new Promise(resolve => source.listen(0, '127.0.0.1', resolve)); + const base = `http://127.0.0.1:${source.address().port}/`; + const stopSource = async () => { source.closeAllConnections(); await new Promise(resolve => source.close(resolve)); }; + const browser = await browserType.launch({headless:true, ...launchOptions}); + const context = await browser.newContext({viewport:{width:1024,height:768},deviceScaleFactor:2,hasTouch:true}); + const page = await context.newPage(); + const errors = [], external = []; + context.on('page', p => p.on('pageerror', e => errors.push(e.message))); + page.on('pageerror', e => errors.push(e.message)); + context.on('request', r => {if(!r.url().startsWith(new URL(base).origin)) external.push(r.url());}); + try { + await page.goto(base); + await page.waitForFunction(() => document.querySelector('#offline').textContent.includes('已准备好'), {timeout:30000}); + await page.waitForFunction(() => !!navigator.serviceWorker.controller); + await page.locator('.paw.left').click(); + assert.equal(await page.locator('#settings').evaluate(e => e.open), false, 'single tap must not open settings'); + await openSettings(page); + for (const scene of ['laser', 'feather', 'fish']) { + await page.locator(`[data-scene=${scene}]`).click(); + assert.equal(await page.locator('body').getAttribute('data-mode'), scene); + assert.equal(await page.locator(`[data-scene=${scene}]`).getAttribute('aria-pressed'), 'true'); + } + await page.locator('#sound').click(); + assert.equal(await page.locator('#sound').getAttribute('aria-checked'), 'false'); + await page.locator('#resume').click(); + await page.setViewportSize({width:390,height:844}); + await page.touchscreen.tap(195, 300); + assert.equal(await page.evaluate(() => document.documentElement.scrollHeight > innerHeight || document.documentElement.scrollWidth > innerWidth), false, 'portrait must not scroll'); + await page.screenshot({path:path.join(output, name+'-portrait.png')}); + await openSettings(page, 'right'); + await page.locator('[data-scene=feather]').click(); + await page.locator('#resume').click(); + await page.reload(); + assert.equal(await page.locator('body').getAttribute('data-mode'), 'feather', 'mode preference persists'); + assert.equal(await page.locator('#sound').getAttribute('aria-checked'), 'false', 'sound preference persists'); + await stopSource(); + await page.close(); + const offlinePage = await context.newPage(); + await offlinePage.goto(base, {waitUntil:'load'}); + assert.equal(await offlinePage.title(), '猫爪游乐场 · PawPlay'); + assert.equal(await offlinePage.locator('body').getAttribute('data-mode'), 'feather'); + await openSettings(offlinePage); + await offlinePage.locator('[data-scene=laser]').click(); + await offlinePage.locator('#resume').click(); + await offlinePage.setViewportSize({width:844,height:390}); + assert.equal(await offlinePage.evaluate(() => document.documentElement.scrollHeight > innerHeight || document.documentElement.scrollWidth > innerWidth), false, 'landscape must not scroll'); + await offlinePage.screenshot({path:path.join(output, name+'-offline-landscape.png')}); + if (name === 'chromium') { + const cdp = await context.newCDPSession(offlinePage); + await offlinePage.evaluate(() => {window.touchPointers=[];document.querySelector('#pond').addEventListener('pointerdown',e=>window.touchPointers.push(e.pointerId));}); + await cdp.send('Input.dispatchTouchEvent', {type:'touchStart',touchPoints:[{x:250,y:150,id:1},{x:500,y:200,id:2}]}); + await cdp.send('Input.dispatchTouchEvent', {type:'touchMove',touchPoints:[{x:270,y:170,id:1},{x:520,y:210,id:2}]}); + await cdp.send('Input.dispatchTouchEvent', {type:'touchEnd',touchPoints:[]}); + assert.equal(await offlinePage.evaluate(() => new Set(window.touchPointers).size), 2, 'two simultaneous touches reach the canvas'); + } + assert.deepEqual(errors, [], 'no page errors'); + assert.deepEqual(external, [], 'no external requests'); + return {browser:name,offline_reopen:true,menu_hold:true,short_tap_guard:true,three_modes:true,portrait_landscape_no_scroll:true,preferences:true,multi_touch:name==='chromium'?'two simultaneous pointers passed':'single touch passed; simultaneous touch not exercised',errors}; + } finally { await browser.close(); if(source.listening) await stopSource(); } +} + +(async () => { + const files = ['index.html','style.css','game.js','sw.js','manifest.webmanifest','icon.svg','apple-touch-icon.png','icon-512.png']; + const sizes = files.map(name => {const b=fs.readFileSync(path.join(__dirname,name));return {name,bytes:b.length,gzip_bytes:zlib.gzipSync(b).length};}); + const total = sizes.reduce((n,f)=>n+f.bytes,0), gzip=sizes.reduce((n,f)=>n+f.gzip_bytes,0); + assert(total < 500*1024); assert(gzip < 200*1024); + const results = [await verify(chromium,'chromium',{executablePath:process.env.CHROME_BIN || '/usr/bin/google-chrome'})]; + results.push(await verify(webkit,'webkit', process.env.WEBKIT_BIN ? {executablePath:process.env.WEBKIT_BIN} : {})); + const result = {checked_at:new Date().toISOString(),sizes,total_bytes:total,gzip_bytes:gzip,results,limits:'Desktop browser engines only. No iPad hardware, standalone-mode, Guided Access, screen-lock, or real-cat validation.'}; + fs.writeFileSync(path.join(output,'result.json'),JSON.stringify(result,null,2)+'\n'); + console.log(JSON.stringify(result,null,2)); +})().catch(e=>{console.error(e);process.exitCode=1;}); diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..2f8777e --- /dev/null +++ b/web/style.css @@ -0,0 +1 @@ +:root{color-scheme:light;font-family:ui-rounded,"PingFang SC","Microsoft YaHei",system-ui,sans-serif;color:#352b29;background:#0b3943;-webkit-tap-highlight-color:transparent}*{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;overflow:hidden;overscroll-behavior:none}body{position:fixed;inset:0;touch-action:manipulation;user-select:none;-webkit-user-select:none}canvas{display:block;width:100%;height:100%;touch-action:none}.paw{position:fixed;top:max(12px,env(safe-area-inset-top));width:60px;height:60px;padding:17px;border:1px solid #ffffff15;border-radius:50%;background:#ffffff08;color:#d8f3e66b;touch-action:none;cursor:pointer;overflow:hidden}.paw.left{left:max(12px,env(safe-area-inset-left))}.paw.right{right:max(12px,env(safe-area-inset-right))}.paw svg{display:block;width:100%;height:100%;fill:currentColor}.paw[data-holding]{color:#fff;border-color:#ffffffaa;background:#ffffff25}.paw[data-holding]:after{content:"";position:absolute;bottom:0;left:0;width:100%;height:100%;background:#ffffff30;transform-origin:bottom;animation:hold 2s linear}@keyframes hold{from{transform:scaleY(0)}to{transform:scaleY(1)}}body[data-mode=feather] .paw{color:#805d3b88;border-color:#805d3b22;background:#ffffff40}button{font:inherit;cursor:pointer}button:focus-visible,summary:focus-visible{outline:3px solid #e7a773;outline-offset:4px}#hint{position:fixed;bottom:max(28px,env(safe-area-inset-bottom));left:50%;transform:translateX(-50%);max-width:calc(100% - 40px);width:max-content;border:1px solid #ffffff20;background:#09242bce;color:#f4f5e9cc;border-radius:20px;padding:13px 24px;text-align:center;font-size:12px;line-height:1.7;pointer-events:none;transition:opacity .8s}#hint span{display:block;font-size:15px;font-weight:600;letter-spacing:2px;color:#fff}#hint.hidden{opacity:0}dialog{position:fixed;margin:auto;border:0;border-radius:28px;background:#fff9ef;color:#352b29;padding:32px;width:min(540px,calc(100% - 28px));max-height:calc(100dvh - max(28px,env(safe-area-inset-top)) - max(28px,env(safe-area-inset-bottom)));overflow-y:auto;overscroll-behavior:contain;box-shadow:0 24px 100px #0005;touch-action:pan-y;user-select:text;-webkit-user-select:text}dialog::backdrop{background:#06151d9c;backdrop-filter:blur(8px)}.eyebrow{font-size:10px;color:#947d68;font-weight:700;letter-spacing:2px}h1{font-size:28px;margin:14px 0 6px;letter-spacing:-1px}.intro{font-size:13px;color:#9a8576;margin:0 0 25px}.modes{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.modes button{border:1px solid #e8dece;border-radius:18px;padding:15px 3px 16px;background:#fffdf7;color:#665346;min-width:0}.modes button[aria-pressed=true]{border:2px solid #387e74;background:#eaf3e9;padding:14px 2px 15px}.mode-icon{display:grid;place-items:center;width:44px;height:44px;margin:0 auto 10px;border-radius:50%;font-size:32px}.fish-icon{background:#d7eee5;color:#327a70}.laser-icon{background:#fae2dc;color:#eb6857;font-size:23px}.feather-icon{background:#f5e9c8;color:#a87625}.modes strong,.modes small{display:block}.modes strong{font-size:15px}.modes small{font-size:10px;color:#998675;margin-top:7px}.setting-row{display:flex;justify-content:space-between;align-items:center;gap:15px;padding:18px 0;border-bottom:1px solid #e9e1d3}.setting-row strong{font-size:13px;font-weight:600}.setting-row small{display:block;font-size:11px;color:#998675;margin-top:5px}#sound{width:44px;height:26px;border:0;background:#3e8274;border-radius:20px;padding:3px;flex-shrink:0}#sound span{display:block;width:20px;height:20px;background:#fff;border-radius:50%;transform:translateX(18px);transition:transform .2s}#sound[aria-checked=false]{background:#c7bfb1}#sound[aria-checked=false] span{transform:translateX(0)}.status-dot{height:8px;width:8px;border-radius:50%;background:#c5ad85;margin-right:16px}.status-dot.ready{background:#518e70}.actions{display:flex;gap:10px;margin:24px 0 19px}.actions button{width:50%;padding:13px 8px;border-radius:14px;background:transparent;border:1px solid #dbcfbc;font-size:14px;color:#78604c}.actions button.primary{background:#387e74;color:#fff;border-color:#387e74}.actions button:disabled{opacity:.6;cursor:default}details{font-size:11px;line-height:1.85;color:#998675}summary{cursor:pointer;color:#78604c}footer{display:flex;justify-content:space-between;gap:12px;color:#ad9d8c;font-size:10px;border-top:1px solid #e9e1d3;padding-top:18px;margin-top:20px}.noscript{position:fixed;inset:20px;color:white}body[data-mode=feather] #hint{background:#6f523bd9}@media(max-width:400px){dialog{padding:24px 20px}.modes{gap:7px}h1{font-size:25px}}@media(max-height:520px){dialog{padding:22px 28px}h1{font-size:23px}.intro{margin-bottom:12px}.modes button{padding:9px 3px}.modes button[aria-pressed=true]{padding:8px 2px}.mode-icon{width:30px;height:30px;font-size:23px;margin-bottom:5px}.setting-row{padding:12px 0}.actions{margin:15px 0}footer{margin-top:12px;padding-top:12px}} diff --git a/web/sw.js b/web/sw.js new file mode 100644 index 0000000..c84abdd --- /dev/null +++ b/web/sw.js @@ -0,0 +1,31 @@ +const VERSION = 'pawplay-web-1.0.0'; +const ROOT = new URL('./', self.location.href).href; +const SHELL = new URL('index.html', ROOT).href; +const ASSETS = ['index.html', 'game.js?v=1.0.0', 'style.css?v=1.0.0', 'manifest.webmanifest', 'icon.svg', 'apple-touch-icon.png', 'icon-512.png'].map(path => new URL(path, ROOT).href); +self.addEventListener('install', event => { + event.waitUntil((async () => { + const cache = await caches.open(VERSION); + try { await cache.addAll(ASSETS); } + catch (error) { await caches.delete(VERSION); throw error; } + })()); +}); +// A new version waits until existing game windows close, keeping one complete +// shell/version per session. Never force-activate over an in-progress game. +self.addEventListener('activate', event => { + event.waitUntil((async () => { + for (const key of await caches.keys()) { + if (key.startsWith('pawplay-web-') && key !== VERSION) await caches.delete(key); + } + await self.clients.claim(); + })()); +}); +self.addEventListener('fetch', event => { + if (event.request.method !== 'GET') return; + const url = new URL(event.request.url); + if (!url.href.startsWith(ROOT)) return; + if (event.request.mode === 'navigate') { + event.respondWith((async () => (await (await caches.open(VERSION)).match(SHELL)) || fetch(event.request))()); + } else if (ASSETS.includes(url.href)) { + event.respondWith((async () => (await (await caches.open(VERSION)).match(event.request)) || fetch(event.request))()); + } +});