Add lightweight offline web game for tablets
This commit is contained in:
+324
@@ -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();
|
||||
})();
|
||||
Reference in New Issue
Block a user