Sync source to CNB / Sync Git ref (push) Skipped
主页原先通过隐藏 iframe + postMessage 与皮肤站网页通信,登录态、 玩家列表、运势等数据都要绕一圈从网页里取。改为启动器直接调用皮肤站 HTTP 接口: - 新增 helpers/skin-site-api.ts:请求封装(tauriFetch 绕开 CORS)、 JWT 本地存取、认证与玩家/皮肤接口。 - 新增 helpers/skin-site-render.ts:从皮肤图裁出头像,以及把图片转 成 data URL。 - 重写 composables/skin-site-session.ts:登录态由本地 JWT 决定, 玩家列表改为请求接口后逐个拉取皮肤并渲染头像;运势、皮肤上传同样 走接口。旧的 iframe 相关导出保留为兼容空实现。 - 删除仅测试 postMessage 协议的 skin-site-session.test.ts。 下载凭据仍然返回皮肤站 JWT:Rust 侧 hosted_set_session 期望的就是它, instance-player 与远程公告的鉴权方式保持不变。
87 lines
2.7 KiB
TypeScript
87 lines
2.7 KiB
TypeScript
/**
|
||
* Renders a player's skin into a base64 head image, mirroring the skin site's
|
||
* own head cropper (front face at 8,8 plus the hat overlay at 40,8).
|
||
*
|
||
* The skin image is loaded through the Tauri HTTP client first so cross-origin
|
||
* textures do not taint the canvas, then drawn once as a data URL.
|
||
*/
|
||
const HEAD_BUFFER_SIZE = 18
|
||
const HEAD_FACE = { sx: 8, sy: 8, sw: 8, sh: 8, dx: 1, dy: 1, dw: 16, dh: 16 }
|
||
const HEAD_HAT = { sx: 40, sy: 8, sw: 8, sh: 8, dx: 0, dy: 0, dw: 18, dh: 18 }
|
||
|
||
let buffer: HTMLCanvasElement | null = null
|
||
|
||
function getBuffer(): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } {
|
||
if (!buffer) buffer = document.createElement('canvas')
|
||
const ctx = buffer.getContext('2d')
|
||
if (!ctx) throw new Error('无法创建皮肤渲染画布')
|
||
return { canvas: buffer, ctx }
|
||
}
|
||
|
||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||
return new Promise((resolve, reject) => {
|
||
const img = new Image()
|
||
img.crossOrigin = 'anonymous'
|
||
img.onload = () => resolve(img)
|
||
img.onerror = () => reject(new Error('无法加载皮肤图片'))
|
||
img.src = src
|
||
})
|
||
}
|
||
|
||
/** Crop the head (and hat overlay) out of a skin image URL. Returns a data URL. */
|
||
export async function renderSkinHead(skinUrl: string, scale = 2): Promise<string> {
|
||
const img = await loadImage(skinUrl)
|
||
const { canvas, ctx } = getBuffer()
|
||
|
||
canvas.width = HEAD_BUFFER_SIZE
|
||
canvas.height = HEAD_BUFFER_SIZE
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||
ctx.imageSmoothingEnabled = false
|
||
|
||
ctx.drawImage(
|
||
img,
|
||
HEAD_FACE.sx,
|
||
HEAD_FACE.sy,
|
||
HEAD_FACE.sw,
|
||
HEAD_FACE.sh,
|
||
HEAD_FACE.dx,
|
||
HEAD_FACE.dy,
|
||
HEAD_FACE.dw,
|
||
HEAD_FACE.dh,
|
||
)
|
||
ctx.globalCompositeOperation = 'source-over'
|
||
ctx.drawImage(
|
||
img,
|
||
HEAD_HAT.sx,
|
||
HEAD_HAT.sy,
|
||
HEAD_HAT.sw,
|
||
HEAD_HAT.sh,
|
||
HEAD_HAT.dx,
|
||
HEAD_HAT.dy,
|
||
HEAD_HAT.dw,
|
||
HEAD_HAT.dh,
|
||
)
|
||
|
||
if (scale <= 1) return canvas.toDataURL('image/png')
|
||
|
||
const small = await createImageBitmap(canvas)
|
||
canvas.width = Math.floor(HEAD_BUFFER_SIZE * scale)
|
||
canvas.height = Math.floor(HEAD_BUFFER_SIZE * scale)
|
||
ctx.imageSmoothingEnabled = false
|
||
ctx.drawImage(small, 0, 0, HEAD_BUFFER_SIZE, HEAD_BUFFER_SIZE, 0, 0, canvas.width, canvas.height)
|
||
return canvas.toDataURL('image/png')
|
||
}
|
||
|
||
/** Fetch an image URL as a data URL (used to keep full skin textures local). */
|
||
export async function fetchImageAsDataUrl(url: string): Promise<string> {
|
||
const response = await fetch(url)
|
||
if (!response.ok) throw new Error(`无法加载图片(HTTP ${response.status})`)
|
||
const blob = await response.blob()
|
||
return await new Promise<string>((resolve, reject) => {
|
||
const reader = new FileReader()
|
||
reader.onload = () => resolve(String(reader.result ?? ''))
|
||
reader.onerror = () => reject(new Error('无法读取图片数据'))
|
||
reader.readAsDataURL(blob)
|
||
})
|
||
}
|