From 10c448746328f3e80ebea6cee99ca670249c55f8 Mon Sep 17 00:00:00 2001 From: wuyang <5700876+banisherwy@user.noreply.gitee.com> Date: Tue, 3 Nov 2026 16:45:50 +0800 Subject: [PATCH] Add lightweight offline web game for tablets --- web/.dockerignore | 11 ++ web/.gitignore | 1 + web/Dockerfile | 5 + web/README.md | 61 ++++++++ web/apple-touch-icon.png | Bin 0 -> 7683 bytes web/game.js | 324 +++++++++++++++++++++++++++++++++++++++ web/icon-512.png | Bin 0 -> 21582 bytes web/icon.svg | 1 + web/index.html | 39 +++++ web/manifest.webmanifest | 1 + web/nginx.conf | 17 ++ web/qa.cjs | 102 ++++++++++++ web/style.css | 1 + web/sw.js | 31 ++++ 14 files changed, 594 insertions(+) create mode 100644 web/.dockerignore create mode 100644 web/.gitignore create mode 100644 web/Dockerfile create mode 100644 web/README.md create mode 100644 web/apple-touch-icon.png create mode 100644 web/game.js create mode 100644 web/icon-512.png create mode 100644 web/icon.svg create mode 100644 web/index.html create mode 100644 web/manifest.webmanifest create mode 100644 web/nginx.conf create mode 100644 web/qa.cjs create mode 100644 web/style.css create mode 100644 web/sw.js 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 0000000000000000000000000000000000000000..74a7dc393f21fd0b875a48c3664d89c174609f6b GIT binary patch literal 7683 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 x4 { + '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 0000000000000000000000000000000000000000..dcd850856b8a0937f057cff5ea2cf03c3501408a GIT binary patch literal 21582 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 oG6?{Hot+C{LjwNb+ 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 的“屏幕固定”。

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

+
猫爪游乐场 · PawPlay 网页版 1.0.0
+
+ + + 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))()); + } +});