werewolf(pwa): 离线 PWA — 自定义 SW 预缓存 + 全屏进度条,牌图 21M→2.8M
deploy werewolf / build-and-deploy (push) Successful in 1m1s
deploy werewolf / build-and-deploy (push) Successful in 1m1s
- vite-plugin-pwa(injectManifest)自定义 SW:install 逐个抓取并向页面广播进度, cache-first 服务,导航离线回退 index.html,缓存版本随清单哈希自动淘汰旧缓存 - 全屏 modal 进度条(src/pwa.ts),反映首屏预缓存真实下载进度 - 牌图 mozjpeg 压缩 + 限长边 900px,每张 ≤200K(21.2MB→2.8MB) - 生成 PWA 图标 + manifest + apple-touch meta,index.html 接入 - 新增脚本:npm run gen:icons / compress:images
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
// 把 public/werewolf 下的牌图压到每张 <= 200KB(原地覆盖)。
|
||||
// 策略:最长边限 900px,mozjpeg 质量从高到低递减,直到达标。
|
||||
// 已经 <= 目标的文件跳过,避免重复编码反复掉质量。
|
||||
// 运行: npm run compress:images
|
||||
import sharp from 'sharp'
|
||||
import { readdir, stat, writeFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve, join } from 'node:path'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const root = resolve(here, '../public/werewolf')
|
||||
|
||||
const TARGET = 200 * 1024
|
||||
const MAX_EDGE = 900
|
||||
const QUALITIES = [82, 76, 70, 64, 58, 52, 46]
|
||||
|
||||
async function listJpgs(dir) {
|
||||
const out = []
|
||||
for (const name of await readdir(dir, { withFileTypes: true })) {
|
||||
const p = join(dir, name.name)
|
||||
if (name.isDirectory()) out.push(...(await listJpgs(p)))
|
||||
else if (/\.jpe?g$/i.test(name.name)) out.push(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function compress(file) {
|
||||
const before = (await stat(file)).size
|
||||
if (before <= TARGET) return { file, skipped: true, before }
|
||||
let chosen = null
|
||||
for (const quality of QUALITIES) {
|
||||
const buf = await sharp(file)
|
||||
.rotate() // 按 EXIF 摆正
|
||||
.resize({ width: MAX_EDGE, height: MAX_EDGE, fit: 'inside', withoutEnlargement: true })
|
||||
.jpeg({ quality, mozjpeg: true })
|
||||
.toBuffer()
|
||||
chosen = { buf, quality }
|
||||
if (buf.length <= TARGET) break
|
||||
}
|
||||
await writeFile(file, chosen.buf)
|
||||
return { file, before, after: chosen.buf.length, quality: chosen.quality }
|
||||
}
|
||||
|
||||
const files = await listJpgs(root)
|
||||
let totalBefore = 0
|
||||
let totalAfter = 0
|
||||
for (const f of files) {
|
||||
const r = await compress(f)
|
||||
const rel = f.slice(root.length + 1)
|
||||
if (r.skipped) {
|
||||
totalBefore += r.before
|
||||
totalAfter += r.before
|
||||
console.log(`skip ${rel} (${(r.before / 1024) | 0}K)`)
|
||||
} else {
|
||||
totalBefore += r.before
|
||||
totalAfter += r.after
|
||||
console.log(`ok ${rel} ${(r.before / 1024) | 0}K -> ${(r.after / 1024) | 0}K q${r.quality}`)
|
||||
}
|
||||
}
|
||||
console.log(`\n${files.length} files: ${(totalBefore / 1024 / 1024).toFixed(1)}MB -> ${(totalAfter / 1024 / 1024).toFixed(1)}MB`)
|
||||
@@ -0,0 +1,46 @@
|
||||
// 从 public/werewolf/back.jpg 生成 PWA 图标。
|
||||
// 运行: npm run gen:icons
|
||||
import sharp from 'sharp'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const pub = resolve(here, '../public')
|
||||
const src = resolve(pub, 'werewolf/back.jpg')
|
||||
|
||||
// 卡背是竖图,狼头菱形大致在水平居中、垂直 ~46% 处。
|
||||
// 裁一个聚焦狼头 logo 的方形区域作为图标基底(按实际尺寸夹紧,避免越界)。
|
||||
const meta = await sharp(src).metadata()
|
||||
const SIDE = Math.min(meta.width, Math.round(meta.width * 0.82), meta.height)
|
||||
const left = Math.round(Math.max(0, Math.min(meta.width - SIDE, meta.width / 2 - SIDE / 2)))
|
||||
const top = Math.round(Math.max(0, Math.min(meta.height - SIDE, meta.height * 0.46 - SIDE / 2)))
|
||||
const square = await sharp(src)
|
||||
.extract({ left, top, width: SIDE, height: SIDE })
|
||||
.toBuffer()
|
||||
|
||||
const RED = { r: 0xc0, g: 0x39, b: 0x2b, alpha: 1 } // 贴近卡面红,用于 maskable 安全区留白
|
||||
|
||||
async function out(name, size, { maskable = false } = {}) {
|
||||
await mkdir(pub, { recursive: true })
|
||||
const target = resolve(pub, name)
|
||||
if (maskable) {
|
||||
// maskable: 内容缩到 ~80%,四周用卡红留白,保证安全区不被裁切
|
||||
const inner = Math.round(size * 0.8)
|
||||
const fg = await sharp(square).resize(inner, inner).toBuffer()
|
||||
await sharp({ create: { width: size, height: size, channels: 4, background: RED } })
|
||||
.composite([{ input: fg, gravity: 'center' }])
|
||||
.png()
|
||||
.toFile(target)
|
||||
} else {
|
||||
await sharp(square).resize(size, size).png().toFile(target)
|
||||
}
|
||||
console.log('wrote', name)
|
||||
}
|
||||
|
||||
await out('pwa-192x192.png', 192)
|
||||
await out('pwa-512x512.png', 512)
|
||||
await out('maskable-icon-512x512.png', 512, { maskable: true })
|
||||
await out('apple-touch-icon-180x180.png', 180)
|
||||
await out('favicon-48x48.png', 48)
|
||||
console.log('done')
|
||||
Reference in New Issue
Block a user