Add lightweight offline web game for tablets

This commit is contained in:
wuyang
2026-11-03 16:45:50 +08:00
parent 4a99cd94a7
commit 10c4487463
14 changed files with 594 additions and 0 deletions
+11
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
.qa/
+5
View File
@@ -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
+61
View File
@@ -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 访问日志,实际部署时应按托管配置说明。
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

+324
View File
@@ -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();
})();
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="112" fill="#0b3943"/><circle cx="256" cy="260" r="177" fill="#124e56"/><g fill="#f4d7a2"><ellipse cx="163" cy="206" rx="33" ry="44" transform="rotate(-26 163 206)"/><ellipse cx="241" cy="159" rx="34" ry="45" transform="rotate(-6 241 159)"/><ellipse cx="322" cy="181" rx="33" ry="44" transform="rotate(21 322 181)"/><ellipse cx="368" cy="247" rx="29" ry="39" transform="rotate(38 368 247)"/><path d="M158 319c6-44 54-98 94-94 40 3 81 52 88 94 11 58-48 43-87 40-40-3-103 20-95-40"/></g><circle cx="353" cy="114" r="14" fill="#ed8e6f"/></svg>

After

Width:  |  Height:  |  Size: 637 B

+39
View File
@@ -0,0 +1,39 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0b3943">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="description" content="鱼池、红点和羽毛,三个会回应爪子的触摸小游戏。免费,无广告。">
<link rel="manifest" href="./manifest.webmanifest">
<link rel="icon" href="./icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="./apple-touch-icon.png">
<link rel="stylesheet" href="./style.css?v=1.0.0">
<title>猫爪游乐场 · PawPlay</title>
<script defer src="./game.js?v=1.0.0"></script>
</head>
<body data-mode="fish">
<canvas id="pond" aria-label="猫咪触摸游戏:鱼池。轻触屏幕有反馈,长按角落爪印两秒打开家长设置。"></canvas>
<button class="paw left" aria-label="长按两秒打开家长设置" title="长按两秒打开家长设置"><svg viewBox="0 0 64 64" aria-hidden="true"><ellipse cx="17" cy="21" rx="7" ry="10" transform="rotate(-24 17 21)"/><ellipse cx="32" cy="14" rx="7" ry="10"/><ellipse cx="47" cy="21" rx="7" ry="10" transform="rotate(24 47 21)"/><path d="M15 43c0-11 10-19 17-19s17 8 17 19c0 12-11 7-17 7s-17 5-17-7"/></svg></button>
<button class="paw right" aria-label="长按两秒打开家长设置" title="长按两秒打开家长设置"><svg viewBox="0 0 64 64" aria-hidden="true"><ellipse cx="17" cy="21" rx="7" ry="10" transform="rotate(-24 17 21)"/><ellipse cx="32" cy="14" rx="7" ry="10"/><ellipse cx="47" cy="21" rx="7" ry="10" transform="rotate(24 47 21)"/><path d="M15 43c0-11 10-19 17-19s17 8 17 19c0 12-11 7-17 7s-17 5-17-7"/></svg></button>
<aside id="hint" role="status"><span>猫爪游乐场</span>轻触屏幕一起玩 · 长按角落爪印 2 秒进入设置</aside>
<dialog id="settings" aria-labelledby="settings-title">
<div class="eyebrow">PAWPLAY / 家长设置</div>
<h1 id="settings-title">今天,玩哪一个?</h1>
<p class="intro">陪它轻轻玩一会儿。</p>
<div class="modes" role="group" aria-label="选择游戏">
<button data-scene="fish" aria-pressed="true"><span class="mode-icon fish-icon">≈</span><strong>鱼池</strong><small>游一游,扑一下</small></button>
<button data-scene="laser" aria-pressed="false"><span class="mode-icon laser-icon">●</span><strong>红点</strong><small>停一停,再追上</small></button>
<button data-scene="feather" aria-pressed="false"><span class="mode-icon feather-icon">⌁</span><strong>羽毛</strong><small>爪子带起一阵风</small></button>
</div>
<div class="setting-row"><div><strong>轻柔音效</strong><small>首次触摸后开启声音</small></div><button id="sound" role="switch" aria-checked="true" aria-label="轻柔音效"><span></span></button></div>
<div class="setting-row"><div><strong>离线游玩</strong><small id="offline" role="status">正在准备离线内容…</small></div><span class="status-dot" id="offline-dot"></span></div>
<div class="actions"><button id="fullscreen">进入全屏</button><button id="resume" class="primary">继续玩</button></div>
<details><summary>玩法与添加到主屏幕</summary><p>屏幕放平,贴好保护膜,由人陪着玩。猫咪不感兴趣时,换个模式或换成实体玩具。屏幕互动不能替代日常陪伴。</p><p>iPad:用 Safari 打开,点“分享”→“添加到主屏幕”。完成离线准备后,可断网重新打开。系统清理缓存后,需要联网加载一次。</p><p>网页无法屏蔽系统手势。需要防止误退出时,可由家长开启 iPad 的“引导式访问”或 Android 的“屏幕固定”。</p><p>免费 · 无广告 · 无账号 · 设置仅保存在这台设备。网页托管服务可能保留常规访问日志。</p></details>
<footer>猫爪游乐场 · PawPlay <span>网页版 1.0.0</span></footer>
</dialog>
<noscript><div class="noscript">请开启 JavaScript 后游玩猫爪游乐场。</div></noscript>
</body>
</html>
+1
View File
@@ -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"}]}
+17
View File
@@ -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; }
}
+102
View File
@@ -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;});
+1
View File
@@ -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}}
+31
View File
@@ -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))());
}
});