优化斗地主
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
// Electron 主进程:窗口生命周期、应用菜单、后端服务器地址配置、外链拦截
|
||||
// 加载策略:开发模式(未打包)优先连 Vite 开发服务器(5173),连不上回退到本地 dist 构建产物;
|
||||
// 打包后固定加载 asar 内的 dist。渲染进程通过 preload 注入的 window.desktop 获取服务器地址
|
||||
const { app, BrowserWindow, Menu, Tray, nativeImage, ipcMain, shell, dialog, net } = require('electron')
|
||||
const { app, BrowserWindow, Menu, Tray, nativeImage, ipcMain, shell, dialog } = require('electron')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const updater = require('./update.cjs')
|
||||
|
||||
// 后端服务器默认地址(可在「菜单 → 服务器设置」中修改,保存在用户数据目录)
|
||||
// 生产桌面端默认连线上;本机调试可在菜单里改回 http://127.0.0.1:8080
|
||||
@@ -62,102 +63,12 @@ ipcMain.handle('nlg:set-server-base', (_e, url) => {
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 版本检查:拉取后端 /api/version/latest 与本地 app.getVersion() 比较
|
||||
// 后端返回 { version, download_url, release_notes, force }
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// 语义化版本比较:返回 -1/0/1(a<b / a==b / a>b),非法版本视为 0.0.0
|
||||
function compareVer(a, b) {
|
||||
const pa = String(a || '0').split('.').map((n) => parseInt(n, 10) || 0)
|
||||
const pb = String(b || '0').split('.').map((n) => parseInt(n, 10) || 0)
|
||||
const len = Math.max(pa.length, pb.length)
|
||||
for (let i = 0; i < len; i++) {
|
||||
const va = pa[i] || 0
|
||||
const vb = pb[i] || 0
|
||||
if (va > vb) return 1
|
||||
if (va < vb) return -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// 拉取一次最新版本信息(用 Electron 内置 net,自动走系统代理)
|
||||
function fetchLatestVersion() {
|
||||
return new Promise((resolve) => {
|
||||
const base = readCfg().serverBase.replace(/\/+$/, '')
|
||||
if (!base) return resolve({ ok: false, msg: '未配置服务器地址' })
|
||||
const req = net.request({
|
||||
method: 'GET',
|
||||
url: base + '/api/version/latest',
|
||||
redirect: 'follow',
|
||||
})
|
||||
let body = ''
|
||||
req.on('response', (resp) => {
|
||||
// 3xx 已由 redirect:'follow' 处理;这里只关心 2xx 最终响应
|
||||
resp.on('data', (chunk) => (body += chunk.toString('utf8')))
|
||||
resp.on('end', () => {
|
||||
try {
|
||||
const parsed = JSON.parse(body)
|
||||
// 后端统一返回 { code, msg, data },code=0 表示成功
|
||||
const data = parsed && parsed.data ? parsed.data : parsed
|
||||
resolve({ ok: true, data })
|
||||
} catch {
|
||||
resolve({ ok: false, msg: '解析版本信息失败' })
|
||||
}
|
||||
})
|
||||
})
|
||||
req.on('error', (err) => resolve({ ok: false, msg: '网络错误:' + err.message }))
|
||||
// 超时兜底(10s)
|
||||
setTimeout(() => {
|
||||
try { req.abort() } catch {}
|
||||
resolve({ ok: false, msg: '请求超时' })
|
||||
}, 10000)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
// 弹出更新对话框(silent=true 表示自动检查,已是最新版不弹窗)
|
||||
async function checkUpdate(silent = false) {
|
||||
const local = app.getVersion()
|
||||
const r = await fetchLatestVersion()
|
||||
if (!r.ok) {
|
||||
if (!silent) dialog.showErrorBox('检查更新失败', r.msg || '无法连接服务器')
|
||||
return
|
||||
}
|
||||
const { version, download_url: url, release_notes: notes, force } = r.data || {}
|
||||
if (compareVer(version, local) <= 0) {
|
||||
if (!silent) {
|
||||
dialog.showMessageBox(mainWin, {
|
||||
type: 'info',
|
||||
title: '已是最新版本',
|
||||
message: '当前已是最新版本',
|
||||
detail: `版本 ${local}`,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
const buttons = url ? ['立即下载', force ? '确定' : '稍后提醒'] : [force ? '确定' : '稍后提醒']
|
||||
const { response } = await dialog.showMessageBox(mainWin, {
|
||||
type: force ? 'warning' : 'info',
|
||||
title: '发现新版本',
|
||||
message: `发现新版本 ${version}`,
|
||||
detail: notes || '请下载最新版本安装包覆盖安装。',
|
||||
buttons,
|
||||
defaultId: 0,
|
||||
cancelId: buttons.length - 1,
|
||||
noLink: true,
|
||||
})
|
||||
// 点了「立即下载」(按钮 0 且存在下载链接);相对路径拼服务器基址
|
||||
if (response === 0 && url) {
|
||||
const abs = /^https?:\/\//i.test(url)
|
||||
? url
|
||||
: readCfg().serverBase.replace(/\/+$/, '') + (url.startsWith('/') ? url : '/' + url)
|
||||
shell.openExternal(abs)
|
||||
}
|
||||
}
|
||||
|
||||
// IPC:渲染进程可触发检查更新(预留菜单按钮 / 页面入口)
|
||||
ipcMain.handle('nlg:check-update', (_e, silent) => checkUpdate(silent))
|
||||
// 版本检查 / 应用内下载安装(见 update.cjs)
|
||||
updater.init({
|
||||
getServerBase: () => readCfg().serverBase,
|
||||
getMainWindow: () => mainWin,
|
||||
setQuitting: (v) => { isQuitting = v },
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 服务器设置窗口(极简内嵌页面,深色风格与主站一致)
|
||||
@@ -244,7 +155,7 @@ function createTray() {
|
||||
tray.setToolTip('像素游戏厅')
|
||||
tray.setContextMenu(Menu.buildFromTemplate([
|
||||
{ label: '显示主窗口', click: () => showMainWindow() },
|
||||
{ label: '检查更新…', click: () => checkUpdate(false) },
|
||||
{ label: '检查更新…', click: () => updater.checkUpdate(false) },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: '退出',
|
||||
@@ -268,7 +179,7 @@ function buildMenu() {
|
||||
label: '应用',
|
||||
submenu: [
|
||||
{ label: '服务器设置…', accelerator: 'CmdOrCtrl+,', click: openSettings },
|
||||
{ label: '检查更新…', click: () => checkUpdate(false) },
|
||||
{ label: '检查更新…', click: () => updater.checkUpdate(false) },
|
||||
{ type: 'separator' },
|
||||
{ label: '刷新', role: 'reload' },
|
||||
{ label: '强制刷新(忽略缓存)', role: 'forceReload' },
|
||||
@@ -356,7 +267,7 @@ function createWindow() {
|
||||
if (cfg.winMax) mainWin.maximize()
|
||||
mainWin.show()
|
||||
// 启动后 3 秒静默检查一次更新(已是最新版不弹窗,发现新版本才提示)
|
||||
setTimeout(() => { checkUpdate(true).catch(() => {}) }, 3000)
|
||||
setTimeout(() => { updater.checkUpdate(true).catch(() => {}) }, 3000)
|
||||
})
|
||||
// 点关闭:记住窗口状态并隐藏到托盘(冒烟模式 / 真正退出时直接关)
|
||||
mainWin.on('close', (e) => {
|
||||
|
||||
@@ -12,6 +12,8 @@ contextBridge.exposeInMainWorld('desktop', {
|
||||
setServerBase: (url) => ipcRenderer.invoke('nlg:set-server-base', url),
|
||||
// 检查桌面端新版本(silent=true 时已是最新版不弹窗,用于启动自动检查)
|
||||
checkUpdate: (silent = false) => ipcRenderer.invoke('nlg:check-update', silent),
|
||||
// 取消正在进行的更新下载
|
||||
cancelUpdateDownload: () => ipcRenderer.invoke('nlg:cancel-update-download'),
|
||||
// 运行环境版本信息
|
||||
versions: {
|
||||
electron: process.versions.electron,
|
||||
|
||||
385
electron/update.cjs
Normal file
385
electron/update.cjs
Normal file
@@ -0,0 +1,385 @@
|
||||
// 桌面端检查更新:拉取 /api/version/latest,应用内下载安装包并启动安装程序
|
||||
const { app, BrowserWindow, dialog, net, ipcMain, shell } = require('electron')
|
||||
const { spawn } = require('child_process')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
const DOWNLOAD_TIMEOUT_MS = 15 * 60 * 1000
|
||||
|
||||
let deps = {
|
||||
getServerBase: () => '',
|
||||
getMainWindow: () => null,
|
||||
setQuitting: () => {},
|
||||
}
|
||||
|
||||
let busy = false
|
||||
let abortDownload = null
|
||||
|
||||
function init(next) {
|
||||
deps = { ...deps, ...next }
|
||||
ipcMain.handle('nlg:check-update', (_e, silent) => checkUpdate(!!silent))
|
||||
ipcMain.handle('nlg:cancel-update-download', () => {
|
||||
if (abortDownload) abortDownload()
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function compareVer(a, b) {
|
||||
const pa = String(a || '0').split('.').map((n) => parseInt(n, 10) || 0)
|
||||
const pb = String(b || '0').split('.').map((n) => parseInt(n, 10) || 0)
|
||||
const len = Math.max(pa.length, pb.length)
|
||||
for (let i = 0; i < len; i++) {
|
||||
const va = pa[i] || 0
|
||||
const vb = pb[i] || 0
|
||||
if (va > vb) return 1
|
||||
if (va < vb) return -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function resolveDownloadUrl(url) {
|
||||
const base = String(deps.getServerBase() || '').replace(/\/+$/, '')
|
||||
if (!url) return ''
|
||||
try {
|
||||
const u = new URL(url, base + '/')
|
||||
const m = String(u.pathname || '').match(/\/(?:api\/)?download\/([^/]+)$/i)
|
||||
if (m) {
|
||||
return base + '/api/download/' + encodeURIComponent(decodeURIComponent(m[1]))
|
||||
}
|
||||
return u.href
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
n = Number(n) || 0
|
||||
if (n < 1024) return n + ' B'
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
|
||||
return (n / 1024 / 1024).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
function headerVal(headers, key) {
|
||||
const raw = headers[key] || headers[key.toLowerCase()] || headers[key.toUpperCase()]
|
||||
return Array.isArray(raw) ? raw[0] : (raw || '')
|
||||
}
|
||||
|
||||
function fetchLatestVersion() {
|
||||
return new Promise((resolve) => {
|
||||
const base = String(deps.getServerBase() || '').replace(/\/+$/, '')
|
||||
if (!base) return resolve({ ok: false, msg: '未配置服务器地址' })
|
||||
let settled = false
|
||||
const done = (v) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(v)
|
||||
}
|
||||
const req = net.request({
|
||||
method: 'GET',
|
||||
url: base + '/api/version/latest',
|
||||
redirect: 'follow',
|
||||
})
|
||||
let body = ''
|
||||
req.on('response', (resp) => {
|
||||
const code = resp.statusCode || 0
|
||||
if (code < 200 || code >= 300) {
|
||||
done({ ok: false, msg: '服务器返回 ' + code })
|
||||
return
|
||||
}
|
||||
resp.on('data', (chunk) => { body += chunk.toString('utf8') })
|
||||
resp.on('end', () => {
|
||||
try {
|
||||
const parsed = JSON.parse(body)
|
||||
if (parsed && typeof parsed.code === 'number' && parsed.code !== 0) {
|
||||
done({ ok: false, msg: parsed.msg || '检查更新失败' })
|
||||
return
|
||||
}
|
||||
const data = parsed && parsed.data ? parsed.data : parsed
|
||||
done({ ok: true, data })
|
||||
} catch {
|
||||
done({ ok: false, msg: '解析版本信息失败' })
|
||||
}
|
||||
})
|
||||
})
|
||||
req.on('error', (err) => done({ ok: false, msg: '网络错误:' + err.message }))
|
||||
const timer = setTimeout(() => {
|
||||
try { req.abort() } catch { /* ignore */ }
|
||||
done({ ok: false, msg: '请求超时' })
|
||||
}, 10000)
|
||||
req.on('close', () => clearTimeout(timer))
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
function downloadInstaller(url, dest, onProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
const finish = (err, val) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
abortDownload = null
|
||||
if (err) reject(err)
|
||||
else resolve(val)
|
||||
}
|
||||
const req = net.request({ method: 'GET', url, redirect: 'follow' })
|
||||
abortDownload = () => {
|
||||
try { req.abort() } catch { /* ignore */ }
|
||||
const e = new Error('已取消')
|
||||
e.cancelled = true
|
||||
finish(e)
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
try { req.abort() } catch { /* ignore */ }
|
||||
finish(new Error('下载超时,请检查网络后重试'))
|
||||
}, DOWNLOAD_TIMEOUT_MS)
|
||||
|
||||
req.on('response', (resp) => {
|
||||
const code = resp.statusCode || 0
|
||||
if (code < 200 || code >= 300) {
|
||||
clearTimeout(timer)
|
||||
finish(new Error('下载失败(HTTP ' + code + ')'))
|
||||
return
|
||||
}
|
||||
const ct = String(headerVal(resp.headers, 'content-type'))
|
||||
if (/text\/html/i.test(ct)) {
|
||||
clearTimeout(timer)
|
||||
finish(new Error('服务器返回了网页而不是安装包。请确认后台已上传安装包。'))
|
||||
return
|
||||
}
|
||||
const total = parseInt(headerVal(resp.headers, 'content-length'), 10) || 0
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true })
|
||||
const ws = fs.createWriteStream(dest)
|
||||
let loaded = 0
|
||||
resp.on('data', (chunk) => {
|
||||
loaded += chunk.length
|
||||
if (!ws.write(chunk) && typeof resp.pause === 'function') {
|
||||
resp.pause()
|
||||
ws.once('drain', () => { if (typeof resp.resume === 'function') resp.resume() })
|
||||
}
|
||||
if (onProgress) onProgress(loaded, total)
|
||||
})
|
||||
resp.on('end', () => { ws.end() })
|
||||
resp.on('error', (err) => {
|
||||
clearTimeout(timer)
|
||||
try { ws.destroy() } catch { /* ignore */ }
|
||||
finish(err)
|
||||
})
|
||||
ws.on('finish', () => {
|
||||
clearTimeout(timer)
|
||||
finish(null, { size: loaded, total })
|
||||
})
|
||||
ws.on('error', (err) => {
|
||||
clearTimeout(timer)
|
||||
finish(err)
|
||||
})
|
||||
})
|
||||
req.on('error', (err) => {
|
||||
clearTimeout(timer)
|
||||
finish(err)
|
||||
})
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
function verifyInstaller(filePath, expectSize) {
|
||||
let st
|
||||
try {
|
||||
st = fs.statSync(filePath)
|
||||
} catch {
|
||||
return '安装包不存在'
|
||||
}
|
||||
// 网页被当成 exe 保存时通常只有几 KB;正式安装包约 100MB
|
||||
if (st.size < 64 * 1024) {
|
||||
return '文件过小,不是完整安装包(常见原因:下载地址被网站首页顶替)'
|
||||
}
|
||||
if (expectSize && Math.abs(st.size - expectSize) > 2048) {
|
||||
return `文件大小不符(已下 ${formatBytes(st.size)},期望 ${formatBytes(expectSize)})`
|
||||
}
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
if (ext === '.exe') {
|
||||
const buf = Buffer.alloc(2)
|
||||
const fd = fs.openSync(filePath, 'r')
|
||||
fs.readSync(fd, buf, 0, 2, 0)
|
||||
fs.closeSync(fd)
|
||||
if (buf[0] !== 0x4d || buf[1] !== 0x5a) {
|
||||
return '安装包损坏(不是有效的 Windows 程序)'
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function launchInstaller(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (process.platform === 'win32') {
|
||||
// start 脱离本进程,避免 app.quit() 把安装程序一起带走
|
||||
const cmd = process.env.ComSpec || 'cmd.exe'
|
||||
const child = spawn(cmd, ['/c', 'start', '', filePath], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
})
|
||||
child.once('error', reject)
|
||||
child.unref()
|
||||
setTimeout(resolve, 400)
|
||||
return
|
||||
}
|
||||
shell.openPath(filePath).then((err) => {
|
||||
if (err) reject(new Error(err))
|
||||
else resolve()
|
||||
}).catch(reject)
|
||||
})
|
||||
}
|
||||
|
||||
function openProgressWin(version) {
|
||||
const parent = deps.getMainWindow()
|
||||
const win = new BrowserWindow({
|
||||
width: 440,
|
||||
height: 230,
|
||||
parent: parent && !parent.isDestroyed() ? parent : undefined,
|
||||
modal: !!(parent && !parent.isDestroyed()),
|
||||
resizable: false,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
autoHideMenuBar: true,
|
||||
backgroundColor: '#12142a',
|
||||
title: '正在下载更新',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
},
|
||||
})
|
||||
const html = `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>下载更新</title>
|
||||
<style>
|
||||
body{margin:0;background:#12142a;color:#e8e9f5;font:14px/1.6 "Microsoft YaHei",sans-serif;padding:22px 26px}
|
||||
h3{margin:0 0 10px;font-size:15px}
|
||||
.bar{height:8px;border-radius:99px;background:#262a52;overflow:hidden;margin:12px 0 8px}
|
||||
.bar i{display:block;height:100%;width:0;background:linear-gradient(135deg,#6d6ff0,#9b6df0);transition:width .15s}
|
||||
.msg{font-size:12px;color:#8d90b3;min-height:18px}
|
||||
.row{display:flex;justify-content:flex-end;margin-top:16px}
|
||||
button{border:0;border-radius:8px;padding:7px 18px;font-size:13px;cursor:pointer;background:#262a52;color:#c6c8e8}
|
||||
</style></head><body>
|
||||
<h3>正在下载 ${String(version || '').replace(/[<>]/g, '')}</h3>
|
||||
<div class="bar"><i id="bar"></i></div>
|
||||
<div class="msg" id="msg">正在连接服务器…</div>
|
||||
<div class="row"><button id="cancel">取消</button></div>
|
||||
<script>
|
||||
window.__setProgress = (pct, text) => {
|
||||
document.getElementById('bar').style.width = Math.max(0, Math.min(100, pct)) + '%'
|
||||
if (text) document.getElementById('msg').textContent = text
|
||||
}
|
||||
document.getElementById('cancel').onclick = async () => {
|
||||
try { await window.desktop.cancelUpdateDownload() } catch (e) {}
|
||||
window.close()
|
||||
}
|
||||
</script>
|
||||
</body></html>`
|
||||
win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html))
|
||||
return win
|
||||
}
|
||||
|
||||
function setProgressUI(win, pct, text) {
|
||||
if (!win || win.isDestroyed()) return
|
||||
const js = `window.__setProgress && window.__setProgress(${Number(pct) || 0}, ${JSON.stringify(text)})`
|
||||
win.webContents.executeJavaScript(js).catch(() => {})
|
||||
}
|
||||
|
||||
async function downloadAndInstall({ version, url, size, filename }) {
|
||||
const destName = (filename && path.basename(String(filename))) || ('PixelArcade-Setup-' + version + '.exe')
|
||||
const dest = path.join(app.getPath('temp'), destName)
|
||||
try { fs.unlinkSync(dest) } catch { /* ignore */ }
|
||||
|
||||
const win = openProgressWin(version)
|
||||
win.on('closed', () => {
|
||||
if (abortDownload) abortDownload()
|
||||
})
|
||||
|
||||
try {
|
||||
setProgressUI(win, 0, '正在连接服务器…')
|
||||
const onProg = (loaded, total) => {
|
||||
const t = total || size || 0
|
||||
const pct = t ? Math.min(99, Math.round((loaded / t) * 100)) : 0
|
||||
setProgressUI(win, pct, `已下载 ${formatBytes(loaded)}${t ? ' / ' + formatBytes(t) : ''}`)
|
||||
}
|
||||
let r
|
||||
try {
|
||||
r = await downloadInstaller(url, dest, onProg)
|
||||
} catch (e) {
|
||||
if (e && e.cancelled) throw e
|
||||
const alt = String(url).replace('/api/download/', '/download/')
|
||||
if (alt === url) throw e
|
||||
setProgressUI(win, 0, '正在尝试备用下载地址…')
|
||||
r = await downloadInstaller(alt, dest, onProg)
|
||||
}
|
||||
setProgressUI(win, 99, '正在校验安装包…')
|
||||
const bad = verifyInstaller(dest, size || r.total || 0)
|
||||
if (bad) throw new Error(bad)
|
||||
setProgressUI(win, 100, '下载完成,即将启动安装程序…')
|
||||
await launchInstaller(dest)
|
||||
deps.setQuitting(true)
|
||||
app.quit()
|
||||
} catch (e) {
|
||||
try { if (!win.isDestroyed()) win.close() } catch { /* ignore */ }
|
||||
if (e && e.cancelled) return
|
||||
try { fs.unlinkSync(dest) } catch { /* ignore */ }
|
||||
const parent = deps.getMainWindow()
|
||||
const { response } = await dialog.showMessageBox(parent && !parent.isDestroyed() ? parent : undefined, {
|
||||
type: 'error',
|
||||
title: '下载更新失败',
|
||||
message: '无法下载或安装更新',
|
||||
detail: (e && e.message) || String(e),
|
||||
buttons: ['在浏览器中打开', '确定'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
noLink: true,
|
||||
})
|
||||
if (response === 0 && url) shell.openExternal(url)
|
||||
}
|
||||
}
|
||||
|
||||
async function checkUpdate(silent = false) {
|
||||
if (busy) return
|
||||
const local = app.getVersion()
|
||||
const parent = () => {
|
||||
const w = deps.getMainWindow()
|
||||
return w && !w.isDestroyed() ? w : undefined
|
||||
}
|
||||
const r = await fetchLatestVersion()
|
||||
if (!r.ok) {
|
||||
if (!silent) dialog.showErrorBox('检查更新失败', r.msg || '无法连接服务器')
|
||||
return
|
||||
}
|
||||
const { version, download_url, release_notes: notes, force, size, filename } = r.data || {}
|
||||
if (compareVer(version, local) <= 0) {
|
||||
if (!silent) {
|
||||
dialog.showMessageBox(parent(), {
|
||||
type: 'info',
|
||||
title: '已是最新版本',
|
||||
message: '当前已是最新版本',
|
||||
detail: `版本 ${local}`,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
const url = resolveDownloadUrl(download_url)
|
||||
const buttons = url ? ['立即更新', force ? '确定' : '稍后提醒'] : [force ? '确定' : '稍后提醒']
|
||||
const { response } = await dialog.showMessageBox(parent(), {
|
||||
type: force ? 'warning' : 'info',
|
||||
title: '发现新版本',
|
||||
message: `发现新版本 ${version}`,
|
||||
detail: notes || '将下载安装包并启动安装程序,安装时请先关闭本应用。',
|
||||
buttons,
|
||||
defaultId: 0,
|
||||
cancelId: buttons.length - 1,
|
||||
noLink: true,
|
||||
})
|
||||
if (response === 0 && url) {
|
||||
busy = true
|
||||
try {
|
||||
await downloadAndInstall({ version, url, size, filename })
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { init, checkUpdate, compareVer, resolveDownloadUrl }
|
||||
19
src/App.vue
19
src/App.vue
@@ -74,4 +74,23 @@ body.wide-page .app-main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
/* 饥荒移动端页内横屏:竖屏手机上把整页旋转成横屏布局,不要求用户转手机 */
|
||||
body.wide-page.starve-page-landscape {
|
||||
overflow: hidden;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
body.wide-page.starve-page-landscape .app-shell {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: var(--pl-w, 100vh);
|
||||
height: var(--pl-h, 100vw);
|
||||
min-height: 0;
|
||||
transform: rotate(-90deg) translateX(-100%);
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
body.wide-page.starve-page-landscape .app-main {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
// 斗地主牌桌:三家布局(上家/下家/自己),叫分、出牌、过牌与倒计时
|
||||
// 交互向经典手游看齐:点选/滑动多选手牌、提示轮换候选、出牌动效(UI 为自绘风格)
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useBattleStore } from '../../stores/battle'
|
||||
import { toast } from '../../api/http'
|
||||
import PlayingCard from './PlayingCard.vue'
|
||||
@@ -10,7 +10,10 @@ import PixelAvatar from '../PixelAvatar.vue'
|
||||
const battle = useBattleStore()
|
||||
const selected = ref(new Set())
|
||||
const now = ref(Math.floor(Date.now() / 1000))
|
||||
const handEl = ref(null)
|
||||
const overlapPx = ref(16)
|
||||
let timer = 0
|
||||
let handRo = null
|
||||
|
||||
const st = computed(() => battle.roomState)
|
||||
const ddz = computed(() => st.value?.ddz || {})
|
||||
@@ -57,6 +60,7 @@ function applyDrag(c) {
|
||||
selected.value = s
|
||||
}
|
||||
function handDown(e) {
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return
|
||||
if (!myHand.value.length) return
|
||||
const c = cardFromEvent(e)
|
||||
if (c === null) return
|
||||
@@ -156,24 +160,71 @@ function doPlay() {
|
||||
battle.ddzPlay([...selected.value])
|
||||
selected.value = new Set()
|
||||
}
|
||||
function tableContextMenu(e) {
|
||||
e.preventDefault()
|
||||
if (myAuto.value || st.value?.status !== 'playing') return
|
||||
if (!myTurn.value || ddz.value.phase !== 'playing') return
|
||||
if (!selected.value.size) return
|
||||
doPlay()
|
||||
}
|
||||
function doPass() {
|
||||
battle.ddzPass()
|
||||
selected.value = new Set()
|
||||
}
|
||||
|
||||
function updateHandOverlap() {
|
||||
const el = handEl.value
|
||||
const n = myHand.value.length
|
||||
const defaultOverlap = 16
|
||||
if (!el || n < 2) {
|
||||
overlapPx.value = defaultOverlap
|
||||
return
|
||||
}
|
||||
const cardEl = el.querySelector('.pcard')
|
||||
const cardW = Math.round(cardEl?.getBoundingClientRect().width || 76)
|
||||
const avail = el.clientWidth
|
||||
if (avail < cardW + 8) {
|
||||
overlapPx.value = defaultOverlap
|
||||
return
|
||||
}
|
||||
const minStep = 32
|
||||
const natural = cardW + (n - 1) * (cardW - defaultOverlap)
|
||||
if (natural <= avail) {
|
||||
overlapPx.value = defaultOverlap
|
||||
return
|
||||
}
|
||||
const step = Math.max(minStep, (avail - cardW) / (n - 1))
|
||||
overlapPx.value = cardW - step
|
||||
}
|
||||
function handCardStyle(i) {
|
||||
const style = {}
|
||||
if (i > 0) style.marginLeft = `-${overlapPx.value}px`
|
||||
if (dealing.value) style.animationDelay = `${i * 45}ms`
|
||||
return style
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
timer = setInterval(() => {
|
||||
now.value = Math.floor(Date.now() / 1000)
|
||||
}, 400)
|
||||
handRo = new ResizeObserver(updateHandOverlap)
|
||||
if (handEl.value) handRo.observe(handEl.value)
|
||||
updateHandOverlap()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(timer)
|
||||
clearTimeout(dealTimer)
|
||||
handRo?.disconnect()
|
||||
})
|
||||
|
||||
watch(myHand, async () => {
|
||||
await nextTick()
|
||||
updateHandOverlap()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ddz">
|
||||
<div class="ddz" @contextmenu="tableContextMenu">
|
||||
<!-- 顶部信息:底牌与炸弹数 -->
|
||||
<div class="table-top">
|
||||
<div class="bottom-cards">
|
||||
@@ -285,24 +336,27 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<!-- 手牌:点选切换,按住扫过可滑动多选;发牌时逐张飞入 -->
|
||||
<div
|
||||
ref="handEl"
|
||||
class="hand"
|
||||
@pointerdown="handDown"
|
||||
@pointermove="handMove"
|
||||
@pointerup="handUp"
|
||||
@pointercancel="handUp"
|
||||
>
|
||||
<PlayingCard
|
||||
v-for="(c, i) in myHand"
|
||||
:key="c"
|
||||
:card="c"
|
||||
:data-card="c"
|
||||
:selected="selected.has(c)"
|
||||
:class="{ 'deal-in': dealing }"
|
||||
:style="dealing ? { animationDelay: i * 45 + 'ms' } : null"
|
||||
/>
|
||||
<div class="hand-row">
|
||||
<PlayingCard
|
||||
v-for="(c, i) in myHand"
|
||||
:key="c"
|
||||
:card="c"
|
||||
:data-card="c"
|
||||
:selected="selected.has(c)"
|
||||
:class="{ 'deal-in': dealing }"
|
||||
:style="handCardStyle(i)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="st.status === 'playing'" class="hand-tip text-dim">
|
||||
点击选牌 · 按住扫过可连选 · 轮到自己时点提示可连点换牌
|
||||
点击选牌 · 按住扫过可连选 · 选中后在牌桌任意处右键出牌 · 轮到自己时点提示可连点换牌
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -331,8 +385,8 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
}
|
||||
.card-back.small {
|
||||
width: 34px;
|
||||
height: 48px;
|
||||
width: 42px;
|
||||
height: 58px;
|
||||
border-radius: 6px;
|
||||
background: repeating-linear-gradient(45deg, var(--primary), var(--primary) 4px, var(--primary-2) 4px, var(--primary-2) 8px);
|
||||
border: 1px solid var(--border);
|
||||
@@ -432,7 +486,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.opp-play {
|
||||
margin-top: 8px;
|
||||
min-height: 52px;
|
||||
min-height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -453,10 +507,14 @@ onBeforeUnmount(() => {
|
||||
.mini-cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.mini-cards > * {
|
||||
margin-right: -12px;
|
||||
}
|
||||
.mini-cards > *:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
/* 出牌落桌动效:轻微上抛落下 */
|
||||
.mini-cards.played > * {
|
||||
animation: card-in 0.18s ease-out both;
|
||||
@@ -494,23 +552,36 @@ onBeforeUnmount(() => {
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
/* 手牌区:禁用触摸滚动手势,保证滑动选牌流畅 */
|
||||
/* 手牌区:外层铺满居中,内层按实际牌宽排,避免负 margin 把整排挤偏 */
|
||||
.hand {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 16px;
|
||||
min-height: 80px;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding-top: 26px;
|
||||
min-height: 132px;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
.hand > * {
|
||||
margin-right: -18px;
|
||||
.hand-row {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
.hand-row > * {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.hand-row > *:hover {
|
||||
z-index: 2;
|
||||
}
|
||||
.hand :deep(.pcard.selected) {
|
||||
z-index: 3;
|
||||
}
|
||||
/* 悬停预抬起(仅鼠标设备),选中后完全抬起 */
|
||||
@media (hover: hover) {
|
||||
.hand :deep(.pcard:not(.selected):hover) {
|
||||
transform: translateY(-6px);
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
}
|
||||
/* 发牌动画:逐张从右上飞入(配合 animation-delay 形成发牌节奏) */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
// 扑克牌组件:按编码渲染点数与花色(0-51 普通牌,52 小王,53 大王)
|
||||
// 扑克牌:0-51 普通牌(角标 + 中心花色),52 小王 / 53 大王(原版 JOKER 小丑牌)
|
||||
const props = defineProps({
|
||||
card: { type: Number, required: true },
|
||||
small: { type: Boolean, default: false },
|
||||
@@ -8,14 +8,14 @@ const props = defineProps({
|
||||
const SUITS = ['♠', '♥', '♣', '♦']
|
||||
const RANKS = { 11: 'J', 12: 'Q', 13: 'K', 14: 'A', 15: '2' }
|
||||
|
||||
function isJoker(c) {
|
||||
return c >= 52
|
||||
}
|
||||
function rankOf(c) {
|
||||
if (c === 52) return '小王'
|
||||
if (c === 53) return '大王'
|
||||
const r = Math.floor(c / 4) + 3
|
||||
return RANKS[r] || String(r)
|
||||
}
|
||||
function suitOf(c) {
|
||||
if (c >= 52) return c === 53 ? '🃏' : '🂿'
|
||||
return SUITS[c % 4]
|
||||
}
|
||||
function isRed(c) {
|
||||
@@ -23,58 +23,201 @@ function isRed(c) {
|
||||
if (c === 52) return false
|
||||
return c % 4 === 1 || c % 4 === 3
|
||||
}
|
||||
function labelOf(c) {
|
||||
if (c === 52) return '小王'
|
||||
if (c === 53) return '大王'
|
||||
return `${suitOf(c)}${rankOf(c)}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pcard" :class="{ red: isRed(card), small, selected, joker: card >= 52 }">
|
||||
<span class="rank">{{ rankOf(card) }}</span>
|
||||
<span class="suit">{{ suitOf(card) }}</span>
|
||||
<div
|
||||
class="pcard"
|
||||
:class="{ red: isRed(card), small, selected, joker: isJoker(card) }"
|
||||
:title="labelOf(card)"
|
||||
:aria-label="labelOf(card)"
|
||||
>
|
||||
<template v-if="isJoker(card)">
|
||||
<span class="jk-idx jk-tl">JOKER</span>
|
||||
<!-- 原版扑克小丑:三尖帽 + 铃铛 + 褶领,小王偏黑金、大王偏红彩 -->
|
||||
<svg class="jester" viewBox="0 0 64 80" aria-hidden="true">
|
||||
<path d="M32 8 L56 40 L32 72 L8 40 Z" fill="var(--jk-gold)" opacity="0.16" />
|
||||
<path d="M32 30 L10 16 L20 32 Z" fill="var(--jk-alt)" />
|
||||
<circle cx="10" cy="16" r="4.2" fill="var(--jk-gold)" />
|
||||
<circle cx="10" cy="16" r="1.6" fill="#fff8dc" />
|
||||
<path d="M32 30 L32 6 L40 30 Z" fill="var(--jk-primary)" />
|
||||
<circle cx="32" cy="6" r="4.2" fill="var(--jk-gold)" />
|
||||
<circle cx="32" cy="6" r="1.6" fill="#fff8dc" />
|
||||
<path d="M32 30 L54 16 L44 32 Z" fill="var(--jk-gold)" />
|
||||
<circle cx="54" cy="16" r="4.2" fill="var(--jk-primary)" />
|
||||
<circle cx="54" cy="16" r="1.6" fill="#fff8dc" />
|
||||
<ellipse cx="32" cy="32" rx="13" ry="4.2" fill="var(--jk-primary)" />
|
||||
<circle cx="32" cy="42" r="11.5" fill="#f3d2a4" />
|
||||
<circle cx="25" cy="43" r="2.1" fill="#e38a8c" opacity="0.5" />
|
||||
<circle cx="39" cy="43" r="2.1" fill="#e38a8c" opacity="0.5" />
|
||||
<circle cx="27.2" cy="40" r="1.7" fill="#2a2118" />
|
||||
<circle cx="36.8" cy="40" r="1.7" fill="#2a2118" />
|
||||
<circle cx="27.8" cy="39.4" r="0.55" fill="#fff" />
|
||||
<circle cx="37.4" cy="39.4" r="0.55" fill="#fff" />
|
||||
<path
|
||||
d="M26 46.2 Q32 51 38 46.2"
|
||||
stroke="var(--jk-primary)"
|
||||
fill="none"
|
||||
stroke-width="1.7"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<circle cx="18" cy="54" r="4.4" fill="var(--jk-gold)" />
|
||||
<circle cx="25" cy="56.5" r="4.4" fill="var(--jk-primary)" />
|
||||
<circle cx="32" cy="57.5" r="4.4" fill="var(--jk-gold)" />
|
||||
<circle cx="39" cy="56.5" r="4.4" fill="var(--jk-primary)" />
|
||||
<circle cx="46" cy="54" r="4.4" fill="var(--jk-gold)" />
|
||||
<path d="M32 58 L44 68 L32 78 L20 68 Z" fill="var(--jk-primary)" />
|
||||
<path d="M32 58 L38 68 L32 78 L26 68 Z" fill="var(--jk-gold)" opacity="0.4" />
|
||||
</svg>
|
||||
<span class="jk-idx jk-br">JOKER</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="idx idx-tl">
|
||||
<span class="rank" :class="{ wide: rankOf(card) === '10' }">{{ rankOf(card) }}</span>
|
||||
<span class="suit">{{ suitOf(card) }}</span>
|
||||
</div>
|
||||
<span class="pip">{{ suitOf(card) }}</span>
|
||||
<div class="idx idx-br">
|
||||
<span class="rank" :class="{ wide: rankOf(card) === '10' }">{{ rankOf(card) }}</span>
|
||||
<span class="suit">{{ suitOf(card) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pcard {
|
||||
width: 46px;
|
||||
height: 64px;
|
||||
background: #fdfdfd;
|
||||
border-radius: 6px;
|
||||
--jk-primary: #1c1e26;
|
||||
--jk-gold: #c9a227;
|
||||
--jk-alt: #3d4a6b;
|
||||
position: relative;
|
||||
width: 76px;
|
||||
height: 106px;
|
||||
background: #fffdf8;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #c9ccd6;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.35);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 3px 5px;
|
||||
color: #1c1e26;
|
||||
font-weight: 800;
|
||||
transition: transform 0.12s;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
isolation: isolate;
|
||||
}
|
||||
.pcard::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 2.5px;
|
||||
border: 1px solid rgba(28, 30, 38, 0.08);
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.joker::after {
|
||||
border-color: color-mix(in srgb, var(--jk-primary) 28%, transparent);
|
||||
}
|
||||
.pcard.red {
|
||||
--jk-primary: #c41e3a;
|
||||
--jk-gold: #e8b923;
|
||||
--jk-alt: #1e7a4a;
|
||||
color: #d5303e;
|
||||
}
|
||||
.pcard.selected {
|
||||
transform: translateY(-14px);
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.45), 0 0 0 2px var(--accent);
|
||||
transform: translateY(-22px);
|
||||
box-shadow: 0 8px 14px rgba(0, 0, 0, 0.45), 0 0 0 2px var(--accent);
|
||||
}
|
||||
.pcard.small {
|
||||
width: 34px;
|
||||
height: 48px;
|
||||
font-size: 12px;
|
||||
width: 42px;
|
||||
height: 58px;
|
||||
}
|
||||
.idx {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
z-index: 1;
|
||||
}
|
||||
.idx-tl {
|
||||
top: 5px;
|
||||
left: 6px;
|
||||
}
|
||||
.idx-br {
|
||||
right: 6px;
|
||||
bottom: 5px;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.rank {
|
||||
font-size: 15px;
|
||||
line-height: 1.1;
|
||||
font-size: 18px;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
}
|
||||
.small .rank {
|
||||
font-size: 12px;
|
||||
}
|
||||
.joker .rank {
|
||||
writing-mode: vertical-lr;
|
||||
font-size: 12px;
|
||||
letter-spacing: 2px;
|
||||
.rank.wide {
|
||||
font-size: 14px;
|
||||
letter-spacing: -0.4px;
|
||||
}
|
||||
.suit {
|
||||
font-size: 13px;
|
||||
font-size: 15px;
|
||||
margin-top: -1px;
|
||||
}
|
||||
.small .rank {
|
||||
font-size: 11px;
|
||||
}
|
||||
.small .suit {
|
||||
font-size: 9px;
|
||||
}
|
||||
.pip {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 36px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.small .pip {
|
||||
font-size: 18px;
|
||||
}
|
||||
.joker {
|
||||
color: var(--jk-primary);
|
||||
}
|
||||
.jk-idx {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
font-size: 8px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.4px;
|
||||
writing-mode: vertical-rl;
|
||||
line-height: 1;
|
||||
color: var(--jk-primary);
|
||||
}
|
||||
.jk-tl {
|
||||
top: 3px;
|
||||
left: 2px;
|
||||
}
|
||||
.jk-br {
|
||||
right: 2px;
|
||||
bottom: 3px;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.small .jk-idx {
|
||||
font-size: 5px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.jester {
|
||||
position: absolute;
|
||||
inset: 8px 11px 6px 13px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
pointer-events: none;
|
||||
}
|
||||
.small .jester {
|
||||
inset: 4px 5px 3px 7px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -52,6 +52,7 @@ const cursorMode = ref('normal')
|
||||
const seasonTag = ref({ name: '秋', dayIn: 1, len: 20, tint: '#c98548', code: 'autumn' })
|
||||
const tips = ref([])
|
||||
const hint = ref('')
|
||||
const nearActions = ref([])
|
||||
const craftOpen = ref(false)
|
||||
const craftTab = ref('tools')
|
||||
const showMap = ref(false)
|
||||
@@ -637,12 +638,28 @@ function tryMovePlayer(p, dx, dy) {
|
||||
p.x = Math.max(20, Math.min(WORLD.w - 20, p.x))
|
||||
p.y = Math.max(20, Math.min(WORLD.h - 20, p.y))
|
||||
}
|
||||
function pageLandscapeOn() {
|
||||
return document.body.classList.contains('starve-page-landscape')
|
||||
}
|
||||
// 页内横屏是 CSS rotate(-90deg):屏幕坐标要映回画布本地坐标
|
||||
function eventToWorld(ev) {
|
||||
const cv = canvas.value
|
||||
const rect = cv.getBoundingClientRect()
|
||||
const nx = (ev.clientX - rect.left) / (rect.width || 1)
|
||||
const ny = (ev.clientY - rect.top) / (rect.height || 1)
|
||||
const lx = pageLandscapeOn() ? (1 - ny) * cv.width : nx * cv.width
|
||||
const ly = pageLandscapeOn() ? nx * cv.height : ny * cv.height
|
||||
return { x: lx + camX, y: ly + camY }
|
||||
}
|
||||
function screenDeltaToLocal(dx, dy) {
|
||||
return pageLandscapeOn() ? { x: -dy, y: dx } : { x: dx, y: dy }
|
||||
}
|
||||
// 画布点击:优先怪 > 火 > 实体 > 走路
|
||||
function onCanvasClick(ev) {
|
||||
ev.preventDefault?.()
|
||||
canvas.value?.blur?.()
|
||||
if (!playing.value || !player || player.dead || waitingWorld.value) return
|
||||
const rect = canvas.value.getBoundingClientRect()
|
||||
const wxp = ((ev.clientX - rect.left) / rect.width) * canvas.value.width + camX
|
||||
const wyp = ((ev.clientY - rect.top) / rect.height) * canvas.value.height + camY
|
||||
const { x: wxp, y: wyp } = eventToWorld(ev)
|
||||
let mon = null
|
||||
let md = Infinity
|
||||
monsters.forEach((m) => {
|
||||
@@ -669,9 +686,7 @@ function updateCursor(ev) {
|
||||
cursorMode.value = 'normal'
|
||||
return
|
||||
}
|
||||
const rect = canvas.value.getBoundingClientRect()
|
||||
const wxp = ((ev.clientX - rect.left) / rect.width) * canvas.value.width + camX
|
||||
const wyp = ((ev.clientY - rect.top) / rect.height) * canvas.value.height + camY
|
||||
const { x: wxp, y: wyp } = eventToWorld(ev)
|
||||
let mon = null
|
||||
let md = Infinity
|
||||
monsters.forEach((m) => {
|
||||
@@ -708,6 +723,7 @@ function onCanvasLeave() {
|
||||
}
|
||||
// 设置本地目标:客机同时上报主机
|
||||
function setMyTarget(t) {
|
||||
if (!quickAdvancing) clearQuickQueue()
|
||||
player.target = resolveTarget(t)
|
||||
if (coop && !coop.isHost) coop.send('starve_input', { seat: coop.mySeat, act: { type: 'target', t } })
|
||||
}
|
||||
@@ -726,16 +742,99 @@ function resolveTarget(t) {
|
||||
}
|
||||
return { kind: 'walk', x: t.x, y: t.y }
|
||||
}
|
||||
function canInteractEnt(e) {
|
||||
if (!e || e.stub || e.solid) return false
|
||||
if (e.deco && e.type !== 'stump') return false
|
||||
return !!(e.loot || e.type === 'trap' || (e.pot && (e.state === 'done' || e.state === 'idle')) ||
|
||||
(e.drops && !e.mobile) || e.type === 'stump' || e.struct || modRt.entKinds[e.type]?.interact)
|
||||
}
|
||||
function entActionLabel(e) {
|
||||
if (modRt.entKinds[e.type]) return NAMES[e.type] || '交互'
|
||||
if (e.loot) return `拾取${ITEMS[e.code]?.name || NAMES[e.type] || ''}`
|
||||
if (e.type === 'trap') return e.state === 'caught' ? '收取猎物' : '收回陷阱'
|
||||
if (e.pot) return e.state === 'done' ? '取出料理' : '打开锅'
|
||||
if (e.type === 'stump') return '挖掘树桩'
|
||||
if (e.struct) return NAMES[e.type] || '查看'
|
||||
return `采集${NAMES[e.type] || ''}`
|
||||
}
|
||||
function onActionPrompt(a) {
|
||||
if (!playing.value || !player || player.dead || waitingWorld.value) return
|
||||
if (a.kind === 'ent') setMyTarget({ kind: 'ent', id: a.id })
|
||||
else if (a.kind === 'fire') setMyTarget({ kind: 'fire', id: a.id })
|
||||
else if (a.kind === 'mon') setMyTarget({ kind: 'mon', id: a.id })
|
||||
}
|
||||
let quickQueue = []
|
||||
let quickAdvancing = false
|
||||
function clearQuickQueue() {
|
||||
quickQueue = []
|
||||
}
|
||||
function advanceQuickQueue() {
|
||||
if (quickAdvancing || !player || player.dead) return
|
||||
quickAdvancing = true
|
||||
try {
|
||||
while (quickQueue.length) {
|
||||
const a = quickQueue.shift()
|
||||
if (!resolveTarget(a)) continue
|
||||
setMyTarget(a)
|
||||
return
|
||||
}
|
||||
} finally {
|
||||
quickAdvancing = false
|
||||
}
|
||||
}
|
||||
function doQuickInteract() {
|
||||
if (!playing.value || !player || player.dead || waitingWorld.value) return
|
||||
const list = nearActions.value
|
||||
if (!list.length) {
|
||||
doGather()
|
||||
return
|
||||
}
|
||||
quickQueue = list.map((a) => ({ kind: a.kind, id: a.id }))
|
||||
advanceQuickQueue()
|
||||
}
|
||||
function refreshNearActions() {
|
||||
if (!playing.value || !player || player.dead) {
|
||||
if (nearActions.value.length) nearActions.value = []
|
||||
return
|
||||
}
|
||||
const list = []
|
||||
entities.forEach((e) => {
|
||||
if (!canInteractEnt(e)) return
|
||||
const d = Math.hypot(e.x - player.x, e.y - player.y)
|
||||
if (d > 130) return
|
||||
list.push({ key: 'e' + e.id, kind: 'ent', id: e.id, label: entActionLabel(e), d })
|
||||
})
|
||||
fires.forEach((f) => {
|
||||
const d = Math.hypot(f.x - player.x, f.y - player.y)
|
||||
if (d > 90) return
|
||||
list.push({ key: 'f' + f.fid, kind: 'fire', id: f.fid, label: f.ttl <= 0 ? '重燃火堆' : '添柴', d })
|
||||
})
|
||||
monsters.forEach((m) => {
|
||||
const mk = modRt.mobKinds[m.kind]
|
||||
const d = Math.hypot(m.x - player.x, m.y - player.y)
|
||||
if (mk?.interact) {
|
||||
if (d > 90) return
|
||||
list.push({ key: 'm' + m.id, kind: 'mon', id: m.id, label: mk.name || MON[m.kind]?.name || '交互', d })
|
||||
return
|
||||
}
|
||||
if (m.kind === 'pig' || m.kind === 'beefalo') return
|
||||
if (d > 170) return
|
||||
list.push({ key: 'm' + m.id, kind: 'mon', id: m.id, label: `攻击${MON[m.kind]?.name || ''}`, d })
|
||||
})
|
||||
list.sort((a, b) => a.d - b.d)
|
||||
const next = list.slice(0, 3)
|
||||
const prev = nearActions.value
|
||||
if (prev.length !== next.length || prev.some((p, i) => p.key !== next[i].key || p.label !== next[i].label)) {
|
||||
nearActions.value = next
|
||||
}
|
||||
}
|
||||
// 空格:就近采集(掉落物 / 陷阱 / 好锅 / 资源)
|
||||
function doGather() {
|
||||
if (!player || player.dead) return
|
||||
let best = null
|
||||
let bd = 130
|
||||
entities.forEach((e) => {
|
||||
if (e.stub) return
|
||||
const ok = e.loot || (e.type === 'trap' && e.state === 'caught') || (e.pot && e.state === 'done') ||
|
||||
(e.drops && !e.mobile) || (e.type === 'stump') || modRt.entKinds[e.type]?.interact
|
||||
if (!ok) return
|
||||
if (!canInteractEnt(e)) return
|
||||
const d = Math.hypot(e.x - player.x, e.y - player.y)
|
||||
if (d < bd) { bd = d; best = e }
|
||||
})
|
||||
@@ -1402,8 +1501,9 @@ function joyStart(e) {
|
||||
function joyMove(e) {
|
||||
if (!joyActive.value) return
|
||||
const t = e.touches?.[0] || e
|
||||
let dx = t.clientX - joyBaseX
|
||||
let dy = t.clientY - joyBaseY
|
||||
const local = screenDeltaToLocal(t.clientX - joyBaseX, t.clientY - joyBaseY)
|
||||
let dx = local.x
|
||||
let dy = local.y
|
||||
const max = 42
|
||||
const d = Math.hypot(dx, dy)
|
||||
if (d > max) {
|
||||
@@ -1472,6 +1572,7 @@ function updatePlayers(dt) {
|
||||
}
|
||||
if (dx || dy) {
|
||||
p.target = null
|
||||
if (p === player) clearQuickQueue()
|
||||
const len = Math.hypot(dx, dy) || 1
|
||||
const s = playerSpeed(p)
|
||||
tryMovePlayer(p, (dx / len) * s * dt, (dy / len) * s * dt)
|
||||
@@ -1479,6 +1580,7 @@ function updatePlayers(dt) {
|
||||
p.walking = true
|
||||
} else if (!p.dead) {
|
||||
pursueTarget(p, dt)
|
||||
if (p === player && !p.target) advanceQuickQueue()
|
||||
}
|
||||
if (p.walking) p.walkT += dt
|
||||
})
|
||||
@@ -1764,29 +1866,13 @@ function syncHud(force = false) {
|
||||
updateHint()
|
||||
}
|
||||
function updateHint() {
|
||||
if (!player || player.dead) { hint.value = '' ; return }
|
||||
if (!player || player.dead) { hint.value = ''; return }
|
||||
const parts = []
|
||||
let ne = null
|
||||
let nd = 120
|
||||
entities.forEach((e) => {
|
||||
if (e.stub || (e.deco && e.type !== 'stump') || e.solid) return
|
||||
const d = Math.hypot(e.x - player.x, e.y - player.y)
|
||||
if (d < nd) { nd = d; ne = e }
|
||||
})
|
||||
if (ne) parts.push(modRt.entKinds[ne.type] ? `空格/点击:${NAMES[ne.type] || ''}` : `空格:${ne.loot ? '拾取' : '采集'}${NAMES[ne.type] || ''}`)
|
||||
let nm = null
|
||||
let nmd = 170
|
||||
monsters.forEach((m) => {
|
||||
if (m.kind === 'pig' || m.kind === 'beefalo' || modRt.mobKinds[m.kind]?.friendly) return
|
||||
const d = Math.hypot(m.x - player.x, m.y - player.y)
|
||||
if (d < nmd) { nmd = d; nm = m }
|
||||
})
|
||||
if (nm) parts.push(`F:攻击${MON[nm.kind].name}`)
|
||||
const nf = fires.find((f) => Math.hypot(f.x - player.x, f.y - player.y) < 90)
|
||||
if (nf) parts.push(nf.ttl <= 0 ? '点击火堆重燃' : '点击火焰添柴 · 生食可点击烤制')
|
||||
if (nf && nf.ttl > 0) parts.push('生食可点击烤制')
|
||||
const pig = monsters.find((m) => m.kind === 'pig' && m.loyalT <= 0 && Math.hypot(m.x - player.x, m.y - player.y) < 90)
|
||||
if (pig) parts.push('点肉类可喂食猪人结盟')
|
||||
hint.value = parts.slice(0, 3).join(' ')
|
||||
hint.value = parts.join(' ')
|
||||
}
|
||||
function hudTick(dt) {
|
||||
hudT += dt
|
||||
@@ -1823,6 +1909,7 @@ function update(dt) {
|
||||
charlieFx(dt)
|
||||
updateExplore(dt)
|
||||
updateCamera()
|
||||
refreshNearActions()
|
||||
hudTick(dt)
|
||||
draw()
|
||||
if (coop && coop.isHost) {
|
||||
@@ -1847,6 +1934,7 @@ function clientTick(dt) {
|
||||
const dy = (keys.has('ArrowDown') || keys.has('s') ? 1 : 0) - (keys.has('ArrowUp') || keys.has('w') ? 1 : 0)
|
||||
if (dx || dy) {
|
||||
p.target = null
|
||||
clearQuickQueue()
|
||||
const len = Math.hypot(dx, dy) || 1
|
||||
const s = playerSpeed(p)
|
||||
tryMovePlayer(p, (dx / len) * s * dt, (dy / len) * s * dt)
|
||||
@@ -1863,6 +1951,8 @@ function clientTick(dt) {
|
||||
stepToward(p, tx, ty, dt)
|
||||
} else if (t.kind === 'walk') p.target = null
|
||||
}
|
||||
} else if (!p.dead) {
|
||||
advanceQuickQueue()
|
||||
}
|
||||
if (p.walking) p.walkT += dt
|
||||
moveSendT += dt
|
||||
@@ -1895,6 +1985,7 @@ function clientTick(dt) {
|
||||
charlieFx(dt)
|
||||
updateExplore(dt)
|
||||
updateCamera()
|
||||
refreshNearActions()
|
||||
hudTick(dt)
|
||||
draw()
|
||||
}
|
||||
@@ -3098,8 +3189,9 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
window.addEventListener('resize', fitCanvas)
|
||||
window.addEventListener('nlg:starve-relayout', fitCanvas)
|
||||
fitCanvas()
|
||||
window.addEventListener('starve-touch-mode', onStarveTouchMode)
|
||||
window.addEventListener('starve-touch-mode', onStarveTouchMode)
|
||||
// 调试后门(e2e / 人工验证)
|
||||
window.__starve = {
|
||||
touchModeDebug: true,
|
||||
@@ -3197,7 +3289,8 @@ onBeforeUnmount(() => {
|
||||
loop?.stop()
|
||||
keys?.detach()
|
||||
window.removeEventListener('resize', fitCanvas)
|
||||
window.removeEventListener('starve-touch-mode', onStarveTouchMode)
|
||||
window.removeEventListener('nlg:starve-relayout', fitCanvas)
|
||||
window.removeEventListener('starve-touch-mode', onStarveTouchMode)
|
||||
coop?.offMessage?.()
|
||||
delete window.__starve
|
||||
})
|
||||
@@ -3222,7 +3315,7 @@ function freshClass(f) {
|
||||
|
||||
<template>
|
||||
<div class="starve-wrap" :class="'cur-' + cursorMode">
|
||||
<canvas ref="canvas" class="starve-canvas" @click="onCanvasClick" @mousemove="updateCursor" @mouseleave="onCanvasLeave"></canvas>
|
||||
<canvas ref="canvas" class="starve-canvas" tabindex="-1" @click="onCanvasClick" @mousemove="updateCursor" @mouseleave="onCanvasLeave"></canvas>
|
||||
|
||||
<!-- 手机摇杆(设置里可切换) -->
|
||||
<div
|
||||
@@ -3250,7 +3343,7 @@ function freshClass(f) {
|
||||
</div>
|
||||
|
||||
<!-- 右上:时钟 + 三围 + 体温 -->
|
||||
<div v-show="playing || dead" class="hud-right">
|
||||
<div v-show="playing || dead" class="hud-right" :class="{ compact: isTouchDevice }">
|
||||
<canvas ref="clockCv" width="112" height="112" class="clock"></canvas>
|
||||
<div class="badges">
|
||||
<div class="badge" :class="{ low: hp < 40 }" title="生命">
|
||||
@@ -3364,8 +3457,23 @@ function freshClass(f) {
|
||||
<div v-for="t in tips" :key="t.id" class="tip-line">{{ t.text }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 动作提示条 -->
|
||||
<div v-show="playing && hint" class="hint-bar">{{ hint }}</div>
|
||||
<!-- 动作提示条:最多 3 个最近目标,点条上的字即可交互 -->
|
||||
<div v-show="playing && (nearActions.length || hint)" class="hint-bar">
|
||||
<button
|
||||
v-for="a in nearActions"
|
||||
:key="a.key"
|
||||
type="button"
|
||||
class="hint-act"
|
||||
@click.stop="onActionPrompt(a)"
|
||||
>{{ a.label }}</button>
|
||||
<span v-if="hint" class="hint-extra">{{ hint }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-show="playing && !dead && nearActions.length"
|
||||
type="button"
|
||||
class="quick-act"
|
||||
@click.stop="doQuickInteract"
|
||||
>一键交互</button>
|
||||
|
||||
<!-- 季节横幅 -->
|
||||
<div v-if="seasonBanner" class="season-banner" :style="{ borderColor: seasonTag.tint, color: seasonTag.tint }">{{ seasonBanner }}</div>
|
||||
@@ -3449,6 +3557,11 @@ function freshClass(f) {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: #101418;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
tap-highlight-color: transparent;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
/* 双类选择器抬高优先级,压过 GamePlay 的 .stage canvas { height:auto } 通用规则 */
|
||||
.starve-wrap .starve-canvas {
|
||||
@@ -3457,6 +3570,9 @@ function freshClass(f) {
|
||||
max-width: none;
|
||||
display: block;
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
outline: none;
|
||||
}
|
||||
/* ---- HUD ---- */
|
||||
.hud-right {
|
||||
@@ -3469,6 +3585,12 @@ function freshClass(f) {
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.hud-right.compact {
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
transform: scale(0.72);
|
||||
transform-origin: top right;
|
||||
}
|
||||
.clock {
|
||||
filter: drop-shadow(0 3px 6px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
@@ -3753,13 +3875,55 @@ function freshClass(f) {
|
||||
bottom: 78px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: rgba(16, 12, 8, 0.66);
|
||||
color: #d8c8a8;
|
||||
border-radius: 14px;
|
||||
padding: 3px 14px;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
z-index: 12;
|
||||
}
|
||||
.hint-act {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #d8c8a8;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.hint-act:hover {
|
||||
color: #f4ead2;
|
||||
}
|
||||
.hint-extra {
|
||||
color: #d8c8a8;
|
||||
}
|
||||
.quick-act {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: 92px;
|
||||
z-index: 20;
|
||||
appearance: none;
|
||||
border: 1.4px solid #b89a5c;
|
||||
background: linear-gradient(180deg, #5a4327, #3a2b17);
|
||||
color: #f4ead2;
|
||||
border-radius: 10px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.quick-act:hover {
|
||||
border-color: #e8bc5a;
|
||||
}
|
||||
.season-banner {
|
||||
position: absolute;
|
||||
|
||||
@@ -205,6 +205,6 @@ export const gameRegistry = {
|
||||
coop: true, // 组队联机:进入时可选单人冒险 / 组队联机(2~4 人共享世界)
|
||||
mods: true, // Mod 装载:开局可勾选内置 mod(难度类 mod 影响得分系数)
|
||||
controls:
|
||||
'WASD 移动 · 空格采集 · F 攻击 · 1~9 使用物品栏 · C 合成 · M 地图 · Esc 关面板(鼠标点击同样可用);夜晚完全黑暗会遭查理袭击,务必备火;科学机器/炼金引擎解锁高级配方,烹饪锅可炖官方料理;历经秋冬春夏四季(冬季注意保暖、提防独眼巨鹿),猪人可喂肉结盟;每活一天 +100 分并自动云存档;支持 2~4 人组队联机与 30 款人物皮肤',
|
||||
'WASD 移动 · 空格采集 · F 攻击 · 1~9 使用物品栏 · C 合成 · M 地图 · Esc 关面板(鼠标点击同样可用);夜晚完全黑暗会遭查理袭击,务必备火;科学机器/炼金引擎解锁高级配方,烹饪锅可炖官方料理;历经秋冬春夏四季(冬季注意保暖、提防独眼巨鹿),猪人可喂肉结盟;每活一天 +100 分并自动云存档;支持 2~4 人组队联机与 45 款人物皮肤',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,43 +1,88 @@
|
||||
// 饥荒人物皮肤模块:30 款皮肤绘制参数 + 通用人物矢量绘制 + 离屏立绘预览
|
||||
// 饥荒人物皮肤模块:45 款皮肤绘制参数 + 体型/服装/五官分支矢量绘制 + 离屏立绘预览
|
||||
// code 与后端 game_skins 种子数据一一对应;StarveGame(本人/队友)与 GamePlay 更衣室共用
|
||||
// hair: spiky=尖刺 flat=平刘海 long=披肩长发 curly=蓬卷 crown=短发+金冠
|
||||
// figure: human / bulky / robot / slim / plant
|
||||
// outfit: vest / dress / plaid / coat / robe / armor / suit / stripes / apron / tank
|
||||
// face: default / beard / mustache / robot / mime / glasses
|
||||
// hair: spiky / flat / long / curly / crown / pigtails / braids / none / toque / bun / chef / horns / fuzzy / leafy / fins / scout
|
||||
export const SKINS = [
|
||||
{ code: 'wilson', name: '经典威尔逊', hair: 'spiky', hairColor: '#15100c', skinTone: '#f3dfc2', vestColor: '#a33d3d', shirtColor: '#e8e2d4' },
|
||||
{ code: 'willow', name: '篝火少女', hair: 'long', hairColor: '#8a2f22', skinTone: '#f3dfc2', vestColor: '#43506e', shirtColor: '#dfd8c6' },
|
||||
{ code: 'wendy', name: '暮色少女', hair: 'long', hairColor: '#e8d8a8', skinTone: '#f0e4d0', vestColor: '#3d3a52', shirtColor: '#d8d4e2' },
|
||||
{ code: 'flame', name: '火焰行者', hair: 'spiky', hairColor: '#d8501e', skinTone: '#f3dfc2', vestColor: '#e0662a', shirtColor: '#f4e3c2' },
|
||||
{ code: 'ocean', name: '海风水手', hair: 'flat', hairColor: '#2a3648', skinTone: '#f0dcc0', vestColor: '#2f6d8e', shirtColor: '#dfe8ea' },
|
||||
{ code: 'forest', name: '密林猎手', hair: 'curly', hairColor: '#3f2c18', skinTone: '#e8ceac', vestColor: '#3f6b35', shirtColor: '#d8e0c2' },
|
||||
{ code: 'honey', name: '蜜糖甜心', hair: 'curly', hairColor: '#c98a2e', skinTone: '#f6e6cc', vestColor: '#d8a437', shirtColor: '#f4ead0' },
|
||||
{ code: 'dusk', name: '暮紫魔术师', hair: 'flat', hairColor: '#3a2a52', skinTone: '#ecd8c4', vestColor: '#6a4a9e', shirtColor: '#d8cce8' },
|
||||
{ code: 'mint', name: '薄荷学者', hair: 'flat', hairColor: '#3f5548', skinTone: '#f3dfc2', vestColor: '#57a486', shirtColor: '#e6f4ec' },
|
||||
{ code: 'ash', name: '灰烬流浪者', hair: 'spiky', hairColor: '#6a6a72', skinTone: '#e4d2bc', vestColor: '#7a7a84', shirtColor: '#d8d8dc' },
|
||||
{ code: 'sky', name: '晴空飞行员', hair: 'flat', hairColor: '#4a668c', skinTone: '#f0dcc0', vestColor: '#7fa8d8', shirtColor: '#f0f4fa' },
|
||||
{ code: 'rose', name: '蔷薇淑女', hair: 'long', hairColor: '#7e3348', skinTone: '#f6e2d0', vestColor: '#c96a8a', shirtColor: '#f4dce4' },
|
||||
{ code: 'ember', name: '余烬铁匠', hair: 'spiky', hairColor: '#4a2118', skinTone: '#dcb890', vestColor: '#a84020', shirtColor: '#e2c9a8' },
|
||||
{ code: 'sand', name: '沙丘旅人', hair: 'curly', hairColor: '#a8854e', skinTone: '#e0c09a', vestColor: '#c9a86a', shirtColor: '#f2e8d2' },
|
||||
{ code: 'moss', name: '苔原德鲁伊', hair: 'curly', hairColor: '#4c5a2c', skinTone: '#e8d4b4', vestColor: '#6a7a3d', shirtColor: '#dce2c4' },
|
||||
{ code: 'plum', name: '梅子贵妇', hair: 'long', hairColor: '#52284a', skinTone: '#f0dcc8', vestColor: '#8a4a7e', shirtColor: '#e8d4e4' },
|
||||
{ code: 'rust', name: '铁锈机械师', hair: 'flat', hairColor: '#6a3520', skinTone: '#e4c4a0', vestColor: '#a05a2a', shirtColor: '#d8c2a8' },
|
||||
{ code: 'navy', name: '藏青卫士', hair: 'flat', hairColor: '#1e2a44', skinTone: '#ecd4b8', vestColor: '#2c3e68', shirtColor: '#c9d2e4' },
|
||||
{ code: 'blizzard', name: '暴雪求生者', hair: 'spiky', hairColor: '#dfe6f0', skinTone: '#f6ece0', vestColor: '#a8bcd0', shirtColor: '#f4f8fc' },
|
||||
{ code: 'crow', name: '乌鸦信使', hair: 'long', hairColor: '#14121c', skinTone: '#e8d8c8', vestColor: '#2a2534', shirtColor: '#a8a2b4' },
|
||||
{ code: 'lava', name: '熔岩勇士', hair: 'spiky', hairColor: '#6e1408', skinTone: '#e0b898', vestColor: '#c93a10', shirtColor: '#f2b03c' },
|
||||
{ code: 'glacier', name: '冰川猎人', hair: 'flat', hairColor: '#b8d8e8', skinTone: '#f0e8dc', vestColor: '#7fb8d8', shirtColor: '#eef8fc' },
|
||||
{ code: 'swamp', name: '沼泽行者', hair: 'curly', hairColor: '#2c3a20', skinTone: '#d8c4a0', vestColor: '#4a5c2e', shirtColor: '#a8b088' },
|
||||
{ code: 'sunset', name: '落日吟游者', hair: 'long', hairColor: '#c95a2e', skinTone: '#f2dcc0', vestColor: '#e8823c', shirtColor: '#f8d8a8' },
|
||||
{ code: 'thorn', name: '荆棘骑士', hair: 'spiky', hairColor: '#2a3318', skinTone: '#e8d0ac', vestColor: '#54682c', shirtColor: '#c9d0a8' },
|
||||
{ code: 'shadow', name: '暗影刺客', hair: 'spiky', hairColor: '#0c0a12', skinTone: '#d8c8c0', vestColor: '#1e1828', shirtColor: '#3a3448' },
|
||||
{ code: 'gold', name: '鎏金贵族', hair: 'curly', hairColor: '#c9a11e', skinTone: '#f4e4c8', vestColor: '#e0b83a', shirtColor: '#f8ecc2' },
|
||||
{ code: 'blood', name: '血月狂战士', hair: 'long', hairColor: '#3a0a0e', skinTone: '#e0c0a8', vestColor: '#8e1c24', shirtColor: '#d8a8a0' },
|
||||
{ code: 'void', name: '极夜术士', hair: 'flat', hairColor: '#08060e', skinTone: '#d0c4c8', vestColor: '#141020', shirtColor: '#2e2840' },
|
||||
{ code: 'king', name: '饥荒之王', hair: 'crown', hairColor: '#2a1c10', skinTone: '#f0dcc0', vestColor: '#8e2c3a', shirtColor: '#f2e0b8' },
|
||||
{ code: 'wilson', name: '威尔逊', figure: 'human', outfit: 'vest', face: 'beard', hair: 'spiky', hairColor: '#15100c', skinTone: '#f3dfc2', vestColor: '#a33d3d', shirtColor: '#e8e2d4' },
|
||||
{ code: 'willow', name: '薇洛', figure: 'slim', outfit: 'dress', face: 'default', hair: 'pigtails', hairColor: '#8a2f22', skinTone: '#f3dfc2', vestColor: '#c45a3a', shirtColor: '#d8d2c4' },
|
||||
{ code: 'wendy', name: '温蒂', figure: 'slim', outfit: 'dress', face: 'default', hair: 'braids', hairColor: '#e8d8a8', skinTone: '#f0e4d0', vestColor: '#3d3a52', shirtColor: '#2a2838' },
|
||||
{ code: 'wolfgang', name: '沃尔夫冈', figure: 'bulky', outfit: 'tank', face: 'mustache', hair: 'flat', hairColor: '#3a2a1c', skinTone: '#e8c8a0', vestColor: '#f4eee4', shirtColor: '#f4eee4' },
|
||||
{ code: 'wx78', name: 'WX-78', figure: 'robot', outfit: 'armor', face: 'robot', hair: 'none', hairColor: '#6a7080', skinTone: '#8a92a0', vestColor: '#5a6270', shirtColor: '#3a4048' },
|
||||
{ code: 'woodie', name: '伍迪', figure: 'human', outfit: 'plaid', face: 'beard', hair: 'toque', hairColor: '#3a2414', skinTone: '#e0b888', vestColor: '#8a2c28', shirtColor: '#c45a3a' },
|
||||
{ code: 'wickerbottom', name: '薇克巴顿', figure: 'slim', outfit: 'dress', face: 'glasses', hair: 'bun', hairColor: '#c8c0b0', skinTone: '#eee6d8', vestColor: '#3a4a6e', shirtColor: '#e8e4dc' },
|
||||
{ code: 'wes', name: '韦斯', figure: 'slim', outfit: 'stripes', face: 'mime', hair: 'flat', hairColor: '#1a1410', skinTone: '#f6f2ea', vestColor: '#c43a3a', shirtColor: '#f4f0e8' },
|
||||
{ code: 'maxwell', name: '麦斯威尔', figure: 'slim', outfit: 'suit', face: 'default', hair: 'flat', hairColor: '#141018', skinTone: '#e8e0d4', vestColor: '#1a1420', shirtColor: '#f4f0e8' },
|
||||
{ code: 'wigfrid', name: '薇格弗德', figure: 'bulky', outfit: 'armor', face: 'default', hair: 'braids', hairColor: '#e8c14d', skinTone: '#f0dcc0', vestColor: '#8a6a38', shirtColor: '#c9b080' },
|
||||
{ code: 'webber', name: '韦伯', figure: 'human', outfit: 'vest', face: 'default', hair: 'fuzzy', hairColor: '#6a3a18', skinTone: '#f4e0b8', vestColor: '#c45a3a', shirtColor: '#e8dcc4' },
|
||||
{ code: 'winona', name: '薇诺娜', figure: 'human', outfit: 'apron', face: 'glasses', hair: 'bun', hairColor: '#5a3a20', skinTone: '#e8c8a0', vestColor: '#3a5a8a', shirtColor: '#d8c4a0' },
|
||||
{ code: 'warly', name: '沃利', figure: 'human', outfit: 'apron', face: 'mustache', hair: 'chef', hairColor: '#2a1c14', skinTone: '#e0b888', vestColor: '#e8e2d4', shirtColor: '#3a5a4a' },
|
||||
{ code: 'wortox', name: '沃拓克斯', figure: 'slim', outfit: 'vest', face: 'default', hair: 'horns', hairColor: '#8a1c18', skinTone: '#c93a2a', vestColor: '#2a1c18', shirtColor: '#6a2418' },
|
||||
{ code: 'wormwood', name: '沃姆伍德', figure: 'plant', outfit: 'robe', face: 'default', hair: 'leafy', hairColor: '#4a7a38', skinTone: '#7a9a48', vestColor: '#3a5c28', shirtColor: '#8ab05a' },
|
||||
{ code: 'wurt', name: '沃特', figure: 'slim', outfit: 'vest', face: 'default', hair: 'fins', hairColor: '#3a6a48', skinTone: '#5a9a68', vestColor: '#3a5a40', shirtColor: '#c9b060' },
|
||||
{ code: 'walter', name: '沃尔特', figure: 'human', outfit: 'vest', face: 'default', hair: 'scout', hairColor: '#4a2c14', skinTone: '#e8c8a0', vestColor: '#c45a28', shirtColor: '#e8d8b0' },
|
||||
{ code: 'wanda', name: '旺达', figure: 'slim', outfit: 'dress', face: 'default', hair: 'long', hairColor: '#c43a28', skinTone: '#f0d4b8', vestColor: '#6a2a48', shirtColor: '#e8c8d0' },
|
||||
{ code: 'flame', name: '火焰行者', figure: 'slim', outfit: 'robe', face: 'default', hair: 'spiky', hairColor: '#d8501e', skinTone: '#f3dfc2', vestColor: '#e0662a', shirtColor: '#f4e3c2' },
|
||||
{ code: 'ocean', name: '海风水手', figure: 'human', outfit: 'coat', face: 'default', hair: 'flat', hairColor: '#2a3648', skinTone: '#f0dcc0', vestColor: '#2f6d8e', shirtColor: '#dfe8ea' },
|
||||
{ code: 'forest', name: '密林猎手', figure: 'human', outfit: 'coat', face: 'default', hair: 'curly', hairColor: '#3f2c18', skinTone: '#e8ceac', vestColor: '#3f6b35', shirtColor: '#d8e0c2' },
|
||||
{ code: 'honey', name: '蜜糖甜心', figure: 'slim', outfit: 'dress', face: 'default', hair: 'curly', hairColor: '#c98a2e', skinTone: '#f6e6cc', vestColor: '#d8a437', shirtColor: '#f4ead0' },
|
||||
{ code: 'dusk', name: '暮紫魔术师', figure: 'slim', outfit: 'robe', face: 'default', hair: 'flat', hairColor: '#3a2a52', skinTone: '#ecd8c4', vestColor: '#6a4a9e', shirtColor: '#d8cce8' },
|
||||
{ code: 'mint', name: '薄荷学者', figure: 'slim', outfit: 'robe', face: 'glasses', hair: 'flat', hairColor: '#3f5548', skinTone: '#f3dfc2', vestColor: '#57a486', shirtColor: '#e6f4ec' },
|
||||
{ code: 'ash', name: '灰烬流浪者', figure: 'human', outfit: 'coat', face: 'default', hair: 'spiky', hairColor: '#6a6a72', skinTone: '#e4d2bc', vestColor: '#7a7a84', shirtColor: '#d8d8dc' },
|
||||
{ code: 'sky', name: '晴空飞行员', figure: 'human', outfit: 'coat', face: 'glasses', hair: 'flat', hairColor: '#4a668c', skinTone: '#f0dcc0', vestColor: '#7fa8d8', shirtColor: '#f0f4fa' },
|
||||
{ code: 'rose', name: '蔷薇淑女', figure: 'slim', outfit: 'dress', face: 'default', hair: 'long', hairColor: '#7e3348', skinTone: '#f6e2d0', vestColor: '#c96a8a', shirtColor: '#f4dce4' },
|
||||
{ code: 'ember', name: '余烬铁匠', figure: 'bulky', outfit: 'apron', face: 'default', hair: 'spiky', hairColor: '#4a2118', skinTone: '#dcb890', vestColor: '#a84020', shirtColor: '#e2c9a8' },
|
||||
{ code: 'sand', name: '沙丘旅人', figure: 'human', outfit: 'coat', face: 'default', hair: 'curly', hairColor: '#a8854e', skinTone: '#e0c09a', vestColor: '#c9a86a', shirtColor: '#f2e8d2' },
|
||||
{ code: 'moss', name: '苔原德鲁伊', figure: 'slim', outfit: 'robe', face: 'default', hair: 'curly', hairColor: '#4c5a2c', skinTone: '#e8d4b4', vestColor: '#6a7a3d', shirtColor: '#dce2c4' },
|
||||
{ code: 'plum', name: '梅子贵妇', figure: 'slim', outfit: 'dress', face: 'default', hair: 'long', hairColor: '#52284a', skinTone: '#f0dcc8', vestColor: '#8a4a7e', shirtColor: '#e8d4e4' },
|
||||
{ code: 'rust', name: '铁锈机械师', figure: 'robot', outfit: 'apron', face: 'robot', hair: 'none', hairColor: '#6a3520', skinTone: '#b88860', vestColor: '#a05a2a', shirtColor: '#6a4030' },
|
||||
{ code: 'navy', name: '藏青卫士', figure: 'bulky', outfit: 'armor', face: 'default', hair: 'flat', hairColor: '#1e2a44', skinTone: '#ecd4b8', vestColor: '#2c3e68', shirtColor: '#c9d2e4' },
|
||||
{ code: 'blizzard', name: '暴雪求生者', figure: 'bulky', outfit: 'coat', face: 'default', hair: 'spiky', hairColor: '#dfe6f0', skinTone: '#f6ece0', vestColor: '#a8bcd0', shirtColor: '#f4f8fc' },
|
||||
{ code: 'crow', name: '乌鸦信使', figure: 'slim', outfit: 'coat', face: 'default', hair: 'long', hairColor: '#14121c', skinTone: '#e8d8c8', vestColor: '#2a2534', shirtColor: '#a8a2b4' },
|
||||
{ code: 'lava', name: '熔岩勇士', figure: 'bulky', outfit: 'armor', face: 'default', hair: 'spiky', hairColor: '#6e1408', skinTone: '#e0b898', vestColor: '#c93a10', shirtColor: '#f2b03c' },
|
||||
{ code: 'glacier', name: '冰川猎人', figure: 'slim', outfit: 'coat', face: 'default', hair: 'flat', hairColor: '#b8d8e8', skinTone: '#f0e8dc', vestColor: '#7fb8d8', shirtColor: '#eef8fc' },
|
||||
{ code: 'swamp', name: '沼泽行者', figure: 'slim', outfit: 'robe', face: 'default', hair: 'curly', hairColor: '#2c3a20', skinTone: '#d8c4a0', vestColor: '#4a5c2e', shirtColor: '#a8b088' },
|
||||
{ code: 'sunset', name: '落日吟游者', figure: 'slim', outfit: 'dress', face: 'default', hair: 'long', hairColor: '#c95a2e', skinTone: '#f2dcc0', vestColor: '#e8823c', shirtColor: '#f8d8a8' },
|
||||
{ code: 'thorn', name: '荆棘骑士', figure: 'bulky', outfit: 'armor', face: 'default', hair: 'spiky', hairColor: '#2a3318', skinTone: '#e8d0ac', vestColor: '#54682c', shirtColor: '#c9d0a8' },
|
||||
{ code: 'shadow', name: '暗影刺客', figure: 'slim', outfit: 'suit', face: 'default', hair: 'spiky', hairColor: '#0c0a12', skinTone: '#d8c8c0', vestColor: '#1e1828', shirtColor: '#3a3448' },
|
||||
{ code: 'gold', name: '鎏金贵族', figure: 'slim', outfit: 'suit', face: 'default', hair: 'curly', hairColor: '#c9a11e', skinTone: '#f4e4c8', vestColor: '#e0b83a', shirtColor: '#f8ecc2' },
|
||||
{ code: 'blood', name: '血月狂战士', figure: 'bulky', outfit: 'armor', face: 'default', hair: 'long', hairColor: '#3a0a0e', skinTone: '#e0c0a8', vestColor: '#8e1c24', shirtColor: '#d8a8a0' },
|
||||
{ code: 'void', name: '极夜术士', figure: 'slim', outfit: 'robe', face: 'default', hair: 'flat', hairColor: '#08060e', skinTone: '#d0c4c8', vestColor: '#141020', shirtColor: '#2e2840' },
|
||||
{ code: 'king', name: '饥荒之王', figure: 'human', outfit: 'suit', face: 'beard', hair: 'crown', hairColor: '#2a1c10', skinTone: '#f0dcc0', vestColor: '#8e2c3a', shirtColor: '#f2e0b8' },
|
||||
]
|
||||
|
||||
// 饥荒原版角色顺序(对齐官方选人界面)
|
||||
export const ORIGINAL_SKINS = [
|
||||
'wilson', 'willow', 'wolfgang', 'wendy', 'wx78',
|
||||
'wickerbottom', 'woodie', 'wes', 'maxwell', 'wigfrid',
|
||||
'webber', 'winona', 'warly', 'wortox', 'wormwood',
|
||||
'wurt', 'walter', 'wanda',
|
||||
]
|
||||
const ORIGINAL_SET = new Set(ORIGINAL_SKINS)
|
||||
export function skinGroup(code) {
|
||||
return ORIGINAL_SET.has(code) ? 'original' : 'featured'
|
||||
}
|
||||
|
||||
export function skinByCode(code) {
|
||||
return SKINS.find((s) => s.code === code) || SKINS[0]
|
||||
}
|
||||
|
||||
const ORIG_RANK = Object.fromEntries(ORIGINAL_SKINS.map((c, i) => [c, i]))
|
||||
export function filterSkins(list, tab = 'all') {
|
||||
let out = Array.isArray(list) ? list.slice() : []
|
||||
if (tab === 'original') out = out.filter((s) => ORIGINAL_SET.has(s.code))
|
||||
else if (tab === 'featured') out = out.filter((s) => !ORIGINAL_SET.has(s.code))
|
||||
out.sort((a, b) => {
|
||||
const ao = ORIGINAL_SET.has(a.code)
|
||||
const bo = ORIGINAL_SET.has(b.code)
|
||||
if (ao !== bo) return ao ? -1 : 1
|
||||
if (ao) return (ORIG_RANK[a.code] ?? 99) - (ORIG_RANK[b.code] ?? 99)
|
||||
return 0
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// 通用描边填充
|
||||
function fs(c, fill, stroke = '#1d1409', lw = 2) {
|
||||
if (fill) { c.fillStyle = fill; c.fill() }
|
||||
@@ -119,6 +164,155 @@ function drawHair(c, skin, headY) {
|
||||
c.lineTo(5.5, headY - 9)
|
||||
c.closePath()
|
||||
fs(c, '#e8c14d', '#8e6a14', 1.4)
|
||||
} else if (skin.hair === 'pigtails') {
|
||||
c.beginPath()
|
||||
c.arc(0, headY - 2, 8.6, Math.PI, 0)
|
||||
c.closePath()
|
||||
c.fill()
|
||||
;[-1, 1].forEach((s) => {
|
||||
c.beginPath()
|
||||
c.moveTo(s * 6.5, headY - 1)
|
||||
c.quadraticCurveTo(s * 12, headY + 4, s * 9, headY + 12)
|
||||
c.quadraticCurveTo(s * 7, headY + 6, s * 5, headY + 1)
|
||||
c.closePath()
|
||||
c.fill()
|
||||
c.fillStyle = '#c45a3a'
|
||||
c.beginPath()
|
||||
c.arc(s * 7.2, headY + 1, 1.8, 0, 7)
|
||||
c.fill()
|
||||
c.fillStyle = skin.hairColor
|
||||
})
|
||||
} else if (skin.hair === 'braids') {
|
||||
c.beginPath()
|
||||
c.arc(0, headY - 2.2, 8.5, Math.PI, 0)
|
||||
c.closePath()
|
||||
c.fill()
|
||||
;[-1, 1].forEach((s) => {
|
||||
c.beginPath()
|
||||
c.moveTo(s * 6, headY)
|
||||
c.quadraticCurveTo(s * 9, headY + 6, s * 6.5, headY + 13)
|
||||
c.lineTo(s * 4, headY + 12)
|
||||
c.quadraticCurveTo(s * 6, headY + 5, s * 4.5, headY)
|
||||
c.closePath()
|
||||
c.fill()
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1
|
||||
c.beginPath()
|
||||
c.moveTo(s * 5.2, headY + 2)
|
||||
c.lineTo(s * 6.4, headY + 6)
|
||||
c.lineTo(s * 5, headY + 10)
|
||||
c.stroke()
|
||||
})
|
||||
} else if (skin.hair === 'toque') {
|
||||
c.beginPath()
|
||||
c.arc(0, headY - 1, 8.2, Math.PI * 1.05, -Math.PI * 0.05)
|
||||
c.closePath()
|
||||
c.fill()
|
||||
c.fillStyle = '#8a2c28'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.5
|
||||
c.beginPath()
|
||||
c.ellipse(0, headY - 6, 9.4, 4.2, 0, Math.PI, 0)
|
||||
c.fill()
|
||||
c.stroke()
|
||||
c.beginPath()
|
||||
c.rect(-7.2, headY - 14, 14.4, 8)
|
||||
c.fill()
|
||||
c.stroke()
|
||||
c.fillStyle = '#f4eee4'
|
||||
c.fillRect(-7.2, headY - 8.2, 14.4, 2.2)
|
||||
c.beginPath()
|
||||
c.arc(0, headY - 15.5, 2.4, 0, 7)
|
||||
c.fill()
|
||||
c.stroke()
|
||||
} else if (skin.hair === 'bun') {
|
||||
c.beginPath()
|
||||
c.arc(0, headY - 2, 8.5, Math.PI, 0)
|
||||
c.closePath()
|
||||
c.fill()
|
||||
c.beginPath()
|
||||
c.arc(0, headY - 11.2, 4.1, 0, 7)
|
||||
c.fill()
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.2
|
||||
c.stroke()
|
||||
} else if (skin.hair === 'chef') {
|
||||
c.beginPath()
|
||||
c.arc(0, headY - 1.5, 8.2, Math.PI, 0)
|
||||
c.closePath()
|
||||
c.fill()
|
||||
c.fillStyle = '#f4eee4'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.4
|
||||
c.beginPath()
|
||||
c.ellipse(0, headY - 7.4, 8.6, 2.8, 0, 0, 7)
|
||||
c.fill()
|
||||
c.stroke()
|
||||
c.beginPath()
|
||||
c.ellipse(0, headY - 15.2, 6.4, 7.2, 0, 0, 7)
|
||||
c.fill()
|
||||
c.stroke()
|
||||
} else if (skin.hair === 'horns') {
|
||||
c.beginPath()
|
||||
c.arc(0, headY - 2, 7.6, Math.PI, 0)
|
||||
c.closePath()
|
||||
c.fill()
|
||||
c.fillStyle = '#e8d8c0'
|
||||
;[-1, 1].forEach((s) => {
|
||||
c.beginPath()
|
||||
c.moveTo(s * 5.2, headY - 6)
|
||||
c.quadraticCurveTo(s * 12, headY - 16, s * 3.6, headY - 18)
|
||||
c.quadraticCurveTo(s * 8.2, headY - 10, s * 4.4, headY - 5)
|
||||
c.closePath()
|
||||
c.fill()
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.3
|
||||
c.stroke()
|
||||
})
|
||||
} else if (skin.hair === 'fuzzy') {
|
||||
;[[-7, headY - 4, 5], [0, headY - 8.2, 6], [7, headY - 4, 5], [-3.8, headY - 8, 4.6], [3.8, headY - 8, 4.6]].forEach(([hx, hy, r]) => {
|
||||
c.beginPath()
|
||||
c.arc(hx, hy, r, 0, 7)
|
||||
c.fill()
|
||||
})
|
||||
} else if (skin.hair === 'leafy') {
|
||||
;[[-6.2, headY - 8, 4.4, -0.5], [0, headY - 11.2, 5.2, 0], [6.2, headY - 8, 4.4, 0.5], [-3, headY - 6, 3.6, -0.2], [3, headY - 6, 3.6, 0.2]].forEach(([hx, hy, r, rot]) => {
|
||||
c.beginPath()
|
||||
c.ellipse(hx, hy, r, r * 0.52, rot, 0, 7)
|
||||
c.fill()
|
||||
})
|
||||
} else if (skin.hair === 'fins') {
|
||||
;[-1, 1].forEach((s) => {
|
||||
c.beginPath()
|
||||
c.moveTo(s * 7, headY - 2)
|
||||
c.quadraticCurveTo(s * 13.5, headY - 7, s * 8.2, headY + 6)
|
||||
c.quadraticCurveTo(s * 6, headY + 1, s * 6.4, headY)
|
||||
c.closePath()
|
||||
c.fill()
|
||||
})
|
||||
} else if (skin.hair === 'scout') {
|
||||
c.beginPath()
|
||||
c.arc(0, headY - 2, 8.3, Math.PI, 0)
|
||||
c.closePath()
|
||||
c.fill()
|
||||
c.fillStyle = '#c45a28'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.4
|
||||
c.beginPath()
|
||||
c.ellipse(0, headY - 6.4, 10.2, 3.1, 0, Math.PI, 0)
|
||||
c.fill()
|
||||
c.stroke()
|
||||
c.beginPath()
|
||||
c.rect(-6.6, headY - 13.2, 13.2, 7.2)
|
||||
c.fill()
|
||||
c.stroke()
|
||||
c.fillStyle = '#e8c14d'
|
||||
c.beginPath()
|
||||
c.moveTo(0, headY - 11.4)
|
||||
c.lineTo(2.1, headY - 8.2)
|
||||
c.lineTo(-2.1, headY - 8.2)
|
||||
c.closePath()
|
||||
c.fill()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +470,301 @@ function drawToolEnhanced(c, toolCode, toolAng = 0) {
|
||||
c.restore()
|
||||
}
|
||||
|
||||
function figScale(skin) {
|
||||
if (skin.figure === 'bulky') return { body: 1.28, head: 1.08, leg: 5.6, slim: 0 }
|
||||
if (skin.figure === 'slim') return { body: 0.86, head: 0.94, leg: 3.6, slim: 1 }
|
||||
if (skin.figure === 'robot') return { body: 1.06, head: 1, leg: 4.2, slim: 0 }
|
||||
if (skin.figure === 'plant') return { body: 0.92, head: 1.04, leg: 3.4, slim: 1 }
|
||||
return { body: 1, head: 1, leg: 4.4, slim: 0 }
|
||||
}
|
||||
|
||||
function drawLegs(c, skin, walkSwing) {
|
||||
const f = figScale(skin)
|
||||
const metal = skin.figure === 'robot'
|
||||
c.strokeStyle = metal ? '#5a6270' : '#2b2b33'
|
||||
c.lineWidth = f.leg
|
||||
c.beginPath()
|
||||
c.moveTo(-3 * f.body, 2)
|
||||
c.lineTo(-3 * f.body + walkSwing * 4.5, 15)
|
||||
c.moveTo(3 * f.body, 2)
|
||||
c.lineTo(3 * f.body - walkSwing * 4.5, 15)
|
||||
c.stroke()
|
||||
c.fillStyle = metal ? '#3a4048' : '#1d1409'
|
||||
c.beginPath()
|
||||
c.ellipse(-3 * f.body + walkSwing * 4.5, 16, metal ? 3.8 : 3.4, 2, 0, 0, 7)
|
||||
c.ellipse(3 * f.body - walkSwing * 4.5, 16, metal ? 3.8 : 3.4, 2, 0, 0, 7)
|
||||
c.fill()
|
||||
}
|
||||
|
||||
function drawOutfit(c, skin, breathe) {
|
||||
const f = figScale(skin)
|
||||
const b = breathe
|
||||
const w = 6 * f.body
|
||||
const outfit = skin.outfit || 'vest'
|
||||
if (outfit === 'dress') {
|
||||
c.beginPath()
|
||||
c.moveTo(-w * 0.85, -14 + b)
|
||||
c.lineTo(w * 0.85, -14 + b)
|
||||
c.lineTo(w * 1.55, 8)
|
||||
c.lineTo(-w * 1.55, 8)
|
||||
c.closePath()
|
||||
fs(c, skin.vestColor, '#1d1409', 1.7)
|
||||
c.beginPath()
|
||||
c.moveTo(-w * 0.7, -14 + b)
|
||||
c.lineTo(w * 0.7, -14 + b)
|
||||
c.lineTo(w * 0.75, -4)
|
||||
c.lineTo(-w * 0.75, -4)
|
||||
c.closePath()
|
||||
fs(c, skin.shirtColor, '#1d1409', 1.4)
|
||||
return
|
||||
}
|
||||
if (outfit === 'robe') {
|
||||
c.beginPath()
|
||||
c.moveTo(-w * 0.9, -15 + b)
|
||||
c.lineTo(w * 0.9, -15 + b)
|
||||
c.lineTo(w * 1.7, 9)
|
||||
c.quadraticCurveTo(0, 12, -w * 1.7, 9)
|
||||
c.closePath()
|
||||
fs(c, skin.vestColor, '#1d1409', 1.7)
|
||||
c.beginPath()
|
||||
c.moveTo(-2, -14 + b)
|
||||
c.lineTo(2, -14 + b)
|
||||
c.lineTo(3, 8)
|
||||
c.lineTo(-3, 8)
|
||||
c.closePath()
|
||||
fs(c, skin.shirtColor, '#1d1409', 1.2)
|
||||
return
|
||||
}
|
||||
if (outfit === 'coat') {
|
||||
c.beginPath()
|
||||
c.moveTo(-w * 1.15, -14 + b)
|
||||
c.lineTo(w * 1.15, -14 + b)
|
||||
c.lineTo(w * 1.35, 8)
|
||||
c.lineTo(-w * 1.35, 8)
|
||||
c.closePath()
|
||||
fs(c, skin.vestColor, '#1d1409', 1.7)
|
||||
c.beginPath()
|
||||
c.moveTo(-2.2, -13 + b)
|
||||
c.lineTo(2.2, -13 + b)
|
||||
c.lineTo(2.6, 7)
|
||||
c.lineTo(-2.6, 7)
|
||||
c.closePath()
|
||||
fs(c, skin.shirtColor, '#1d1409', 1.3)
|
||||
return
|
||||
}
|
||||
if (outfit === 'armor') {
|
||||
c.beginPath()
|
||||
c.moveTo(-w, -14 + b)
|
||||
c.lineTo(w, -14 + b)
|
||||
c.lineTo(w * 1.15, 5)
|
||||
c.lineTo(-w * 1.15, 5)
|
||||
c.closePath()
|
||||
fs(c, skin.shirtColor, '#1d1409', 1.7)
|
||||
c.beginPath()
|
||||
c.moveTo(-w * 1.35, -13 + b)
|
||||
c.lineTo(-w * 0.15, -12 + b)
|
||||
c.lineTo(-w * 0.25, 2)
|
||||
c.lineTo(-w * 1.4, 3)
|
||||
c.closePath()
|
||||
fs(c, skin.vestColor, '#1d1409', 1.6)
|
||||
c.beginPath()
|
||||
c.moveTo(w * 1.35, -13 + b)
|
||||
c.lineTo(w * 0.15, -12 + b)
|
||||
c.lineTo(w * 0.25, 2)
|
||||
c.lineTo(w * 1.4, 3)
|
||||
c.closePath()
|
||||
fs(c, skin.vestColor, '#1d1409', 1.6)
|
||||
return
|
||||
}
|
||||
if (outfit === 'suit') {
|
||||
c.beginPath()
|
||||
c.moveTo(-w, -14 + b)
|
||||
c.lineTo(w, -14 + b)
|
||||
c.lineTo(w * 1.05, 5)
|
||||
c.lineTo(-w * 1.05, 5)
|
||||
c.closePath()
|
||||
fs(c, skin.vestColor, '#1d1409', 1.7)
|
||||
c.beginPath()
|
||||
c.moveTo(-1.6, -13 + b)
|
||||
c.lineTo(1.6, -13 + b)
|
||||
c.lineTo(2, 5)
|
||||
c.lineTo(-2, 5)
|
||||
c.closePath()
|
||||
fs(c, skin.shirtColor, '#1d1409', 1.2)
|
||||
c.strokeStyle = '#e8c14d'
|
||||
c.lineWidth = 1.2
|
||||
c.beginPath()
|
||||
c.moveTo(0, -10 + b)
|
||||
c.lineTo(0, 4)
|
||||
c.stroke()
|
||||
return
|
||||
}
|
||||
if (outfit === 'plaid') {
|
||||
c.beginPath()
|
||||
c.moveTo(-w, -14 + b)
|
||||
c.lineTo(w, -14 + b)
|
||||
c.lineTo(w * 1.05, 4)
|
||||
c.lineTo(-w * 1.05, 4)
|
||||
c.closePath()
|
||||
fs(c, skin.shirtColor, '#1d1409', 1.7)
|
||||
c.strokeStyle = skin.vestColor
|
||||
c.lineWidth = 1.1
|
||||
for (let i = -5; i <= 5; i += 2.6) {
|
||||
c.beginPath(); c.moveTo(i, -13 + b); c.lineTo(i + 0.6, 4); c.stroke()
|
||||
}
|
||||
for (let y = -12; y <= 3; y += 3) {
|
||||
c.beginPath(); c.moveTo(-w, y + b); c.lineTo(w, y + b); c.stroke()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (outfit === 'stripes') {
|
||||
c.beginPath()
|
||||
c.moveTo(-w, -14 + b)
|
||||
c.lineTo(w, -14 + b)
|
||||
c.lineTo(w * 1.05, 4)
|
||||
c.lineTo(-w * 1.05, 4)
|
||||
c.closePath()
|
||||
fs(c, skin.shirtColor, '#1d1409', 1.7)
|
||||
c.strokeStyle = skin.vestColor
|
||||
c.lineWidth = 2
|
||||
for (let y = -12; y <= 3; y += 3.2) {
|
||||
c.beginPath(); c.moveTo(-w + 0.5, y + b); c.lineTo(w - 0.5, y + b); c.stroke()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (outfit === 'apron') {
|
||||
c.beginPath()
|
||||
c.moveTo(-w, -14 + b)
|
||||
c.lineTo(w, -14 + b)
|
||||
c.lineTo(w * 1.05, 4)
|
||||
c.lineTo(-w * 1.05, 4)
|
||||
c.closePath()
|
||||
fs(c, skin.shirtColor, '#1d1409', 1.6)
|
||||
c.beginPath()
|
||||
c.moveTo(-w * 0.85, -8 + b)
|
||||
c.lineTo(w * 0.85, -8 + b)
|
||||
c.lineTo(w * 0.95, 6)
|
||||
c.lineTo(-w * 0.95, 6)
|
||||
c.closePath()
|
||||
fs(c, skin.vestColor, '#1d1409', 1.4)
|
||||
return
|
||||
}
|
||||
if (outfit === 'tank') {
|
||||
c.beginPath()
|
||||
c.moveTo(-w * 1.15, -12 + b)
|
||||
c.lineTo(w * 1.15, -12 + b)
|
||||
c.lineTo(w * 1.25, 5)
|
||||
c.lineTo(-w * 1.25, 5)
|
||||
c.closePath()
|
||||
fs(c, skin.shirtColor, '#1d1409', 1.7)
|
||||
c.strokeStyle = 'rgba(29,20,9,0.25)'
|
||||
c.lineWidth = 1.2
|
||||
c.beginPath(); c.arc(-3.2 * f.body, -6 + b, 2.4, 0, 7); c.stroke()
|
||||
c.beginPath(); c.arc(3.2 * f.body, -6 + b, 2.4, 0, 7); c.stroke()
|
||||
return
|
||||
}
|
||||
c.beginPath()
|
||||
c.moveTo(-w, -14 + b)
|
||||
c.lineTo(w, -14 + b)
|
||||
c.lineTo(w * 1.12, 4)
|
||||
c.lineTo(-w * 1.12, 4)
|
||||
c.closePath()
|
||||
fs(c, skin.shirtColor, '#1d1409', 1.8)
|
||||
c.beginPath()
|
||||
c.moveTo(-w * 1.08, -14 + b)
|
||||
c.lineTo(-2, -13 + b)
|
||||
c.lineTo(-2.6, 4)
|
||||
c.lineTo(-w * 1.12, 4)
|
||||
c.closePath()
|
||||
fs(c, skin.vestColor, '#1d1409', 1.6)
|
||||
c.beginPath()
|
||||
c.moveTo(w * 1.08, -14 + b)
|
||||
c.lineTo(2, -13 + b)
|
||||
c.lineTo(2.6, 4)
|
||||
c.lineTo(w * 1.12, 4)
|
||||
c.closePath()
|
||||
fs(c, skin.vestColor, '#1d1409', 1.6)
|
||||
}
|
||||
|
||||
function drawHead(c, skin, headY) {
|
||||
const f = figScale(skin)
|
||||
if (skin.figure === 'robot') {
|
||||
c.beginPath()
|
||||
c.rect(-8.2, headY - 8.4, 16.4, 16.2)
|
||||
fs(c, skin.skinTone, '#1d1409', 1.8)
|
||||
c.fillStyle = skin.vestColor
|
||||
c.fillRect(-8.2, headY - 2, 16.4, 2)
|
||||
return
|
||||
}
|
||||
if (skin.figure === 'plant') {
|
||||
c.beginPath()
|
||||
c.ellipse(0, headY, 7.4 * f.head, 9.2 * f.head, 0, 0, 7)
|
||||
fs(c, skin.skinTone, '#1d1409', 1.8)
|
||||
return
|
||||
}
|
||||
c.beginPath()
|
||||
c.arc(0, headY, 8.4 * f.head, 0, 7)
|
||||
fs(c, skin.skinTone, '#1d1409', 1.8)
|
||||
}
|
||||
|
||||
function drawFace(c, skin, headY) {
|
||||
const face = skin.face || 'default'
|
||||
if (face === 'robot') {
|
||||
c.fillStyle = '#7fe0ff'
|
||||
c.beginPath(); c.arc(-3.2, headY - 1.4, 2.1, 0, 7); c.fill()
|
||||
c.beginPath(); c.arc(3.2, headY - 1.4, 2.1, 0, 7); c.fill()
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.2
|
||||
c.stroke()
|
||||
c.fillStyle = '#1d1409'
|
||||
c.fillRect(-3.6, headY + 3.2, 7.2, 1.6)
|
||||
c.strokeStyle = skin.hairColor
|
||||
c.lineWidth = 1.6
|
||||
c.beginPath(); c.moveTo(-2, headY - 8.6); c.lineTo(-2, headY - 15); c.stroke()
|
||||
c.beginPath(); c.moveTo(2, headY - 8.6); c.lineTo(2, headY - 13.5); c.stroke()
|
||||
c.fillStyle = '#7fe0ff'
|
||||
c.beginPath(); c.arc(-2, headY - 15.4, 1.5, 0, 7); c.fill()
|
||||
return
|
||||
}
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.8
|
||||
c.beginPath()
|
||||
c.moveTo(2.4, headY - 2)
|
||||
c.lineTo(2.4, headY + 0.6)
|
||||
c.moveTo(6.4, headY - 2)
|
||||
c.lineTo(6.4, headY + 0.6)
|
||||
c.stroke()
|
||||
c.lineWidth = 1.4
|
||||
c.beginPath()
|
||||
c.arc(4.6, headY + 3.4, 2, 0.3, Math.PI - 0.5)
|
||||
c.stroke()
|
||||
if (face === 'beard') {
|
||||
c.fillStyle = skin.hairColor
|
||||
c.beginPath()
|
||||
c.moveTo(-5.5, headY + 3)
|
||||
c.quadraticCurveTo(0, headY + 12, 5.5, headY + 3)
|
||||
c.quadraticCurveTo(0, headY + 6.5, -5.5, headY + 3)
|
||||
c.closePath()
|
||||
c.fill()
|
||||
} else if (face === 'mustache') {
|
||||
c.fillStyle = skin.hairColor
|
||||
c.beginPath()
|
||||
c.ellipse(-2.2, headY + 3.6, 3.2, 1.3, -0.25, 0, 7)
|
||||
c.ellipse(2.2, headY + 3.6, 3.2, 1.3, 0.25, 0, 7)
|
||||
c.fill()
|
||||
} else if (face === 'mime') {
|
||||
c.fillStyle = '#d8524e'
|
||||
c.beginPath(); c.arc(-3.6, headY + 1.6, 1.6, 0, 7); c.fill()
|
||||
c.beginPath(); c.arc(3.6, headY + 1.6, 1.6, 0, 7); c.fill()
|
||||
} else if (face === 'glasses') {
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.4
|
||||
c.beginPath(); c.arc(2.6, headY - 0.6, 2.6, 0, 7); c.stroke()
|
||||
c.beginPath(); c.arc(6.2, headY - 0.6, 2.6, 0, 7); c.stroke()
|
||||
c.beginPath(); c.moveTo(5.2, headY - 0.6); c.lineTo(3.6, headY - 0.6); c.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
// 皮肤差异化头饰/面饰:同一发型也靠头饰/面纹区分
|
||||
function drawSkinAccessory(c, skin, headY) {
|
||||
const code = skin.code || ''
|
||||
@@ -383,16 +872,66 @@ function drawSkinAccessory(c, skin, headY) {
|
||||
c.strokeStyle = '#8e1c24'; c.lineWidth = 1.5
|
||||
c.beginPath(); c.moveTo(-6.5, headY - 2); c.lineTo(-3.5, headY + 2); c.stroke()
|
||||
c.beginPath(); c.moveTo(-3, headY - 2); c.lineTo(-6, headY + 2); c.stroke()
|
||||
} else if (code === 'willow' || code === 'wendy') {
|
||||
// 发饰
|
||||
c.fillStyle = code === 'willow' ? '#8a2f22' : '#c96a8a'
|
||||
} else if (code === 'willow') {
|
||||
c.fillStyle = '#c45a3a'
|
||||
c.strokeStyle = '#1d1409'; c.lineWidth = 1.1
|
||||
c.beginPath(); c.arc(-6.8, headY - 3, 2.2, 0, 7); c.fill(); c.stroke()
|
||||
c.beginPath(); c.rect(6.2, headY + 1, 2.2, 5); c.fill(); c.stroke()
|
||||
c.beginPath()
|
||||
c.moveTo(7.3, headY + 1)
|
||||
c.quadraticCurveTo(10, headY - 3, 7.3, headY - 1)
|
||||
c.quadraticCurveTo(5, headY - 3, 7.3, headY + 1)
|
||||
fs(c, '#f0923c', '#b5541e', 1)
|
||||
} else if (code === 'wendy') {
|
||||
c.fillStyle = '#c96a8a'
|
||||
c.strokeStyle = '#1d1409'; c.lineWidth = 1.1
|
||||
c.beginPath(); c.arc(-6.4, headY - 4, 2.4, 0, 7); c.fill(); c.stroke()
|
||||
c.fillStyle = '#6a7a3d'
|
||||
c.beginPath(); c.ellipse(-6.4, headY - 1.2, 1.6, 0.8, 0.4, 0, 7); c.fill()
|
||||
} else if (code === 'king') {
|
||||
// 金冠已在发型中体现;这里补红披肩扣
|
||||
c.fillStyle = '#e8c14d'
|
||||
c.strokeStyle = '#8e6a14'; c.lineWidth = 1
|
||||
c.beginPath(); c.arc(0, -14, 1.6, 0, 7); c.fill(); c.stroke()
|
||||
} else if (code === 'maxwell') {
|
||||
c.fillStyle = '#141018'
|
||||
c.strokeStyle = '#1d1409'; c.lineWidth = 1.4
|
||||
c.beginPath(); c.rect(-8.2, headY - 10, 16.4, 3.2); c.fill(); c.stroke()
|
||||
c.beginPath(); c.rect(-5.6, headY - 18.4, 11.2, 8.6); c.fill(); c.stroke()
|
||||
c.fillStyle = '#c43a3a'; c.fillRect(-8.2, headY - 8.4, 16.4, 1.4)
|
||||
} else if (code === 'wigfrid') {
|
||||
c.fillStyle = '#c9b080'
|
||||
c.strokeStyle = '#1d1409'; c.lineWidth = 1.4
|
||||
c.beginPath(); c.ellipse(0, headY - 7, 9.6, 3.5, 0, Math.PI, 0); c.fill(); c.stroke()
|
||||
c.fillStyle = '#e8d8c0'
|
||||
;[-1, 1].forEach((s) => {
|
||||
c.beginPath()
|
||||
c.moveTo(s * 7, headY - 8)
|
||||
c.quadraticCurveTo(s * 14, headY - 18, s * 5, headY - 16)
|
||||
c.quadraticCurveTo(s * 9, headY - 10, s * 6, headY - 7)
|
||||
c.closePath()
|
||||
c.fill(); c.stroke()
|
||||
})
|
||||
} else if (code === 'webber') {
|
||||
c.fillStyle = '#1d1409'
|
||||
;[[-4.8, headY - 3.4], [-1.6, headY - 4.6], [1.6, headY - 4.6], [4.8, headY - 3.4]].forEach(([ex, ey]) => {
|
||||
c.beginPath(); c.arc(ex, ey, 1.15, 0, 7); c.fill()
|
||||
})
|
||||
} else if (code === 'wanda') {
|
||||
c.fillStyle = '#e8c14d'
|
||||
c.strokeStyle = '#8e6a14'; c.lineWidth = 1.2
|
||||
c.beginPath(); c.arc(8.2, -8, 3.3, 0, 7); c.fill(); c.stroke()
|
||||
c.strokeStyle = '#1d1409'; c.lineWidth = 1
|
||||
c.beginPath(); c.moveTo(8.2, -8); c.lineTo(8.2, -9.8); c.moveTo(8.2, -8); c.lineTo(9.6, -7.2); c.stroke()
|
||||
} else if (code === 'wortox') {
|
||||
c.fillStyle = '#f0e060'
|
||||
c.beginPath(); c.arc(-3.2, headY - 1.6, 1.85, 0, 7); c.fill()
|
||||
c.beginPath(); c.arc(3.2, headY - 1.6, 1.85, 0, 7); c.fill()
|
||||
c.fillStyle = '#1d1409'
|
||||
c.beginPath(); c.arc(-3.2, headY - 1.6, 0.7, 0, 7); c.fill()
|
||||
c.beginPath(); c.arc(3.2, headY - 1.6, 0.7, 0, 7); c.fill()
|
||||
} else if (code === 'wurt') {
|
||||
c.fillStyle = '#3a6a48'
|
||||
c.beginPath(); c.ellipse(0, headY + 4.4, 3.4, 1.6, 0, 0, 7); c.fill()
|
||||
}
|
||||
c.restore()
|
||||
}
|
||||
@@ -435,94 +974,40 @@ export function drawPlayerFig(c, skin, pose = {}) {
|
||||
return
|
||||
}
|
||||
const walkSwing = walking ? Math.sin(walkT * 11) : 0
|
||||
// 腿(深灰裤)
|
||||
c.strokeStyle = '#2b2b33'
|
||||
c.lineWidth = 4.4
|
||||
const f = figScale(skin)
|
||||
drawLegs(c, skin, walkSwing)
|
||||
drawOutfit(c, skin, breathe)
|
||||
c.strokeStyle = skin.figure === 'robot' ? '#5a6270' : skin.shirtColor
|
||||
c.lineWidth = skin.figure === 'bulky' ? 4.6 : 3.6
|
||||
c.beginPath()
|
||||
c.moveTo(-3, 2)
|
||||
c.lineTo(-3 + walkSwing * 4.5, 15)
|
||||
c.moveTo(3, 2)
|
||||
c.lineTo(3 - walkSwing * 4.5, 15)
|
||||
c.moveTo(-5 * f.body, -11 + breathe)
|
||||
c.lineTo(-7 * f.body - walkSwing * 3, -1 + Math.abs(walkSwing))
|
||||
c.stroke()
|
||||
// 鞋
|
||||
c.fillStyle = '#1d1409'
|
||||
c.beginPath()
|
||||
c.ellipse(-3 + walkSwing * 4.5, 16, 3.4, 2, 0, 0, 7)
|
||||
c.ellipse(3 - walkSwing * 4.5, 16, 3.4, 2, 0, 0, 7)
|
||||
c.fill()
|
||||
// 身体:衬衫 + 两片马甲
|
||||
c.beginPath()
|
||||
c.moveTo(-6, -14 + breathe)
|
||||
c.lineTo(6, -14 + breathe)
|
||||
c.lineTo(7, 4)
|
||||
c.lineTo(-7, 4)
|
||||
c.closePath()
|
||||
fs(c, skin.shirtColor, '#1d1409', 1.8)
|
||||
c.beginPath()
|
||||
c.moveTo(-6.6, -14 + breathe)
|
||||
c.lineTo(-2, -13 + breathe)
|
||||
c.lineTo(-2.6, 4)
|
||||
c.lineTo(-7, 4)
|
||||
c.closePath()
|
||||
fs(c, skin.vestColor, '#1d1409', 1.6)
|
||||
c.beginPath()
|
||||
c.moveTo(6.6, -14 + breathe)
|
||||
c.lineTo(2, -13 + breathe)
|
||||
c.lineTo(2.6, 4)
|
||||
c.lineTo(7, 4)
|
||||
c.closePath()
|
||||
fs(c, skin.vestColor, '#1d1409', 1.6)
|
||||
// 后臂(随走路摆)
|
||||
c.strokeStyle = skin.shirtColor
|
||||
c.lineWidth = 3.6
|
||||
c.beginPath()
|
||||
c.moveTo(-5, -11 + breathe)
|
||||
c.lineTo(-7 - walkSwing * 3, -1 + Math.abs(walkSwing))
|
||||
c.stroke()
|
||||
// 头
|
||||
const headY = -22 + breathe
|
||||
c.beginPath()
|
||||
c.arc(0, headY, 8.4, 0, 7)
|
||||
fs(c, skin.skinTone, '#1d1409', 1.8)
|
||||
// 发型
|
||||
drawHair(c, skin, headY)
|
||||
// 头饰/面饰差异化
|
||||
drawSkinAccessory(c, skin, headY)
|
||||
// 眼睛(竖点)+ 嘴
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.8
|
||||
c.beginPath()
|
||||
c.moveTo(2.4, headY - 2)
|
||||
c.lineTo(2.4, headY + 0.6)
|
||||
c.moveTo(6.4, headY - 2)
|
||||
c.lineTo(6.4, headY + 0.6)
|
||||
c.stroke()
|
||||
c.lineWidth = 1.4
|
||||
c.beginPath()
|
||||
c.arc(4.6, headY + 3.4, 2, 0.3, Math.PI - 0.5)
|
||||
c.stroke()
|
||||
// 前臂 + 手持工具:挥击动作由 swing(1→0) 驱动,act 决定挥法
|
||||
const headY = (skin.figure === 'bulky' ? -24 : -22) + breathe
|
||||
drawHead(c, skin, headY)
|
||||
if (skin.hair && skin.hair !== 'none') drawHair(c, skin, headY)
|
||||
drawFace(c, skin, headY)
|
||||
drawSkinAccessory(c, skin, headY)
|
||||
let swingAng = -0.35 + walkSwing * 0.32
|
||||
let thrust = 0
|
||||
if (swing > 0) {
|
||||
const t = 1 - swing
|
||||
if (act === 'mine' || act === 'dig') swingAng = -2.7 + t * 3.2 // 高举下砸/下铲
|
||||
else if (act === 'attack') { swingAng = -1.05; thrust = Math.sin(t * Math.PI) * 8 } // 平持突刺
|
||||
else if (act === 'eat') swingAng = -0.4 - Math.sin(t * Math.PI) * 1.95 // 抬手到嘴边
|
||||
else swingAng = -2.1 + t * 2.7 // 砍树/通用横劈
|
||||
if (act === 'mine' || act === 'dig') swingAng = -2.7 + t * 3.2
|
||||
else if (act === 'attack') { swingAng = -1.05; thrust = Math.sin(t * Math.PI) * 8 }
|
||||
else if (act === 'eat') swingAng = -0.4 - Math.sin(t * Math.PI) * 1.95
|
||||
else swingAng = -2.1 + t * 2.7
|
||||
}
|
||||
// 工具在手中的基础角度:挥击时跟手,平时按动作/待机姿态微调
|
||||
const toolAng = swing > 0 ? 0 : act === 'eat' ? 0.9 : act === 'attack' ? 0 : act === 'chop' ? -0.25 : (act === 'mine' || act === 'dig') ? -0.5 : -0.15
|
||||
const toolAng = swing > 0 ? 0 : act === 'eat' ? 0.9 : act === 'attack' ? 0 : act === 'chop' ? -0.25 : (act === 'mine' || act === 'dig') ? -0.5 : -0.15
|
||||
c.save()
|
||||
c.translate(5 + thrust, -11 + breathe)
|
||||
c.translate(5 * f.body + thrust, -11 + breathe)
|
||||
c.rotate(swingAng)
|
||||
c.strokeStyle = skin.shirtColor
|
||||
c.lineWidth = 3.6
|
||||
c.strokeStyle = skin.figure === 'robot' ? '#5a6270' : skin.shirtColor
|
||||
c.lineWidth = skin.figure === 'bulky' ? 4.6 : 3.6
|
||||
c.beginPath()
|
||||
c.moveTo(0, 0)
|
||||
c.lineTo(0, 11)
|
||||
c.stroke()
|
||||
drawToolEnhanced(c, toolCode, toolAng)
|
||||
drawToolEnhanced(c, toolCode, toolAng)
|
||||
c.restore()
|
||||
c.restore()
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import http, { toast } from '../api/http'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import { useStarveCoopStore } from '../stores/starveCoop'
|
||||
import { gameRegistry } from '../games'
|
||||
import { renderSkinPreview } from '../games/starveSkins'
|
||||
import { renderSkinPreview, filterSkins } from '../games/starveSkins'
|
||||
import { modMeta } from '../games/starve/mods/registry'
|
||||
import LevelSelect from '../components/LevelSelect.vue'
|
||||
import GameIcon from '../components/GameIcon.vue'
|
||||
@@ -52,6 +52,8 @@ const coopable = !!meta?.coop
|
||||
const coopStore = useStarveCoopStore()
|
||||
const coopStage = ref('mode') // 开始面板子状态:mode 模式选择 / solo 单人 / skins 更衣室 / room 联机房间
|
||||
const skins = ref([]) // 后端皮肤列表 [{code,name,price,owned}]
|
||||
const skinTab = ref('all') // 更衣室筛选:all 全部 / original 饥荒原版 / featured 特色角色
|
||||
const filteredSkins = computed(() => filterSkins(skins.value, skinTab.value))
|
||||
const mySkin = ref(localStorage.getItem('skin_' + code) || 'wilson') // 我选中的皮肤(记本地)
|
||||
const buyingSkin = ref('') // 正在购买的皮肤(防连点)
|
||||
const joinCode = ref('') // 加入房间的邀请码输入
|
||||
@@ -71,20 +73,33 @@ function saveTouchMode(mode) {
|
||||
window.dispatchEvent(new CustomEvent('starve-touch-mode', { detail: mode }))
|
||||
}
|
||||
const isMobile = computed(() => typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0))
|
||||
const isPortrait = ref(false)
|
||||
let portraitMq = null
|
||||
function updateOrientation() {
|
||||
isPortrait.value = isMobile.value && !!portraitMq?.matches
|
||||
}
|
||||
async function enterLandscape() {
|
||||
try {
|
||||
if (document.fullscreenElement) await document.exitFullscreen()
|
||||
await document.documentElement.requestFullscreen?.()
|
||||
} catch (e) {}
|
||||
try {
|
||||
await screen.orientation?.lock?.('landscape')
|
||||
} catch (e) {}
|
||||
updateOrientation()
|
||||
const pageLandscape = ref(false)
|
||||
function syncPageLandscape() {
|
||||
if (typeof window === 'undefined') return
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
const portrait = vh > vw
|
||||
const phoneLike = Math.min(vw, vh) <= 820
|
||||
const on = floatHead.value && isMobile.value && phoneLike && portrait
|
||||
const nextW = `${vh}px`
|
||||
const nextH = `${vw}px`
|
||||
const sizeChanged = on && (
|
||||
document.documentElement.style.getPropertyValue('--pl-w') !== nextW ||
|
||||
document.documentElement.style.getPropertyValue('--pl-h') !== nextH
|
||||
)
|
||||
const changed = on !== pageLandscape.value
|
||||
pageLandscape.value = on
|
||||
document.body.classList.toggle('starve-page-landscape', on)
|
||||
if (on) {
|
||||
document.documentElement.style.setProperty('--pl-w', nextW)
|
||||
document.documentElement.style.setProperty('--pl-h', nextH)
|
||||
} else {
|
||||
document.documentElement.style.removeProperty('--pl-w')
|
||||
document.documentElement.style.removeProperty('--pl-h')
|
||||
}
|
||||
if (changed || sizeChanged) {
|
||||
requestAnimationFrame(() => window.dispatchEvent(new Event('nlg:starve-relayout')))
|
||||
}
|
||||
}
|
||||
function openManual() {
|
||||
showManual.value = true
|
||||
@@ -597,27 +612,33 @@ watch(() => coopStore.roomState?.code, (code) => {
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// play-head 通过吸顶按钮手动展开/收起,不再跟随鼠标自动弹出
|
||||
// 宽屏游戏(如饥荒):给 body 打标解除全局 1200px 限宽
|
||||
if (meta?.wide) document.body.classList.add('wide-page')
|
||||
load()
|
||||
portraitMq = window.matchMedia('(orientation: portrait)')
|
||||
window.addEventListener('nlg:open-settings', openSettings)
|
||||
portraitMq.addEventListener('change', updateOrientation)
|
||||
updateOrientation()
|
||||
window.addEventListener('nlg:open-manual', openManual)
|
||||
window.addEventListener('nlg:open-settings', openSettings)
|
||||
window.addEventListener('nlg:open-manual', openManual)
|
||||
if (floatHead.value) {
|
||||
syncPageLandscape()
|
||||
window.addEventListener('resize', syncPageLandscape)
|
||||
window.addEventListener('orientationchange', syncPageLandscape)
|
||||
window.visualViewport?.addEventListener('resize', syncPageLandscape)
|
||||
}
|
||||
if (coopable) {
|
||||
window.addEventListener('nlg:pending-join', tryPendingJoin)
|
||||
tryPendingJoin()
|
||||
}
|
||||
portraitMq?.removeEventListener('change', updateOrientation)
|
||||
window.removeEventListener('nlg:open-settings', openSettings)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.body.classList.remove('wide-page')
|
||||
window.removeEventListener('nlg:open-manual', openManual)
|
||||
document.body.classList.remove('starve-page-landscape')
|
||||
document.documentElement.style.removeProperty('--pl-w')
|
||||
document.documentElement.style.removeProperty('--pl-h')
|
||||
window.removeEventListener('nlg:open-manual', openManual)
|
||||
window.removeEventListener('nlg:open-settings', openSettings)
|
||||
window.removeEventListener('nlg:pending-join', tryPendingJoin)
|
||||
// play-head 展开状态由组件自行管理
|
||||
window.removeEventListener('resize', syncPageLandscape)
|
||||
window.removeEventListener('orientationchange', syncPageLandscape)
|
||||
window.visualViewport?.removeEventListener('resize', syncPageLandscape)
|
||||
clearInterval(inviteTickTimer)
|
||||
gameRef.value?.stop()
|
||||
// 离开游玩页断开联机连接(房间自动退出)
|
||||
@@ -684,7 +705,7 @@ onBeforeUnmount(() => {
|
||||
<button class="btn btn-ghost btn-sm" @click="openSettings">⚙️ 设置</button>
|
||||
</div>
|
||||
<button class="btn btn-ghost" @click="coopStage = 'skins'">
|
||||
更衣室 · 30 款人物皮肤
|
||||
更衣室 · {{ skins.length || 45 }} 款人物皮肤
|
||||
</button>
|
||||
</template>
|
||||
<!-- 2. 单人面板(继续存档 / 新的冒险) -->
|
||||
@@ -727,13 +748,18 @@ onBeforeUnmount(() => {
|
||||
<button v-else class="btn btn-lg" @click="startGame()">新的冒险</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="coopStage = 'mode'">← 返回</button>
|
||||
</template>
|
||||
<!-- 3. 更衣室:30 款皮肤立绘网格(已解锁可穿、未解锁积分购买) -->
|
||||
<!-- 3. 更衣室:分类 Tab + 立绘网格(已解锁可穿、未解锁积分购买) -->
|
||||
<template v-else-if="coopStage === 'skins'">
|
||||
<h3>更衣室</h3>
|
||||
<p class="text-dim" style="font-size: 12px">点击已解锁的皮肤穿上;未解锁的用积分购买 · 余额 {{ userStore.user?.points ?? 0 }}</p>
|
||||
<div class="skin-grid">
|
||||
<div class="skin-tabs">
|
||||
<button class="skin-tab" :class="{ on: skinTab === 'all' }" @click="skinTab = 'all'">全部</button>
|
||||
<button class="skin-tab" :class="{ on: skinTab === 'original' }" @click="skinTab = 'original'">饥荒原版</button>
|
||||
<button class="skin-tab" :class="{ on: skinTab === 'featured' }" @click="skinTab = 'featured'">特色角色</button>
|
||||
</div>
|
||||
<div class="skin-grid" :class="{ original: skinTab === 'original' }">
|
||||
<div
|
||||
v-for="s in skins"
|
||||
v-for="s in filteredSkins"
|
||||
:key="s.code"
|
||||
class="skin-cell"
|
||||
:class="{ on: mySkin === s.code, locked: !s.owned }"
|
||||
@@ -1012,15 +1038,6 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="floatHead && isPortrait" class="landscape-tip">
|
||||
<div class="landscape-card">
|
||||
<div class="landscape-icon">📱↻</div>
|
||||
<h3>请横屏游玩</h3>
|
||||
<p class="text-dim">年糕求生建议横屏操作,点击按钮自动进入横屏。</p>
|
||||
<button class="btn" @click="enterLandscape">进入横屏</button>
|
||||
<button class="btn btn-ghost" @click="isPortrait = false">暂不</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 未拥有提示 -->
|
||||
@@ -1422,7 +1439,31 @@ onBeforeUnmount(() => {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #9a9ac4);
|
||||
}
|
||||
/* ---- 更衣室:皮肤网格 ---- */
|
||||
/* ---- 更衣室:分类 Tab + 皮肤网格 ---- */
|
||||
.skin-tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
.skin-tab {
|
||||
padding: 5px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1.5px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.skin-tab:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.skin-tab.on {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
}
|
||||
.skin-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
||||
@@ -1432,6 +1473,10 @@ onBeforeUnmount(() => {
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
}
|
||||
.skin-grid.original {
|
||||
grid-template-columns: repeat(5, minmax(72px, 1fr));
|
||||
width: min(640px, 92%);
|
||||
}
|
||||
.skin-cell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -1837,31 +1882,6 @@ onBeforeUnmount(() => {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ---- 移动端横屏提示 ---- */
|
||||
.landscape-tip {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 400;
|
||||
background: rgba(8, 5, 4, 0.82);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.landscape-card {
|
||||
background: linear-gradient(180deg, #2b2115, #17120b);
|
||||
border: 2px solid #b89a5c;
|
||||
border-radius: 18px;
|
||||
padding: 26px 30px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.landscape-icon {
|
||||
font-size: 54px;
|
||||
}
|
||||
/* ---- 年糕求生 UI:游戏内弹窗/面板统一手绘木纸风格 ---- */
|
||||
.play-wrap.starve-ui .modal-mask {
|
||||
background: rgba(10, 7, 4, 0.62);
|
||||
@@ -1913,6 +1933,25 @@ onBeforeUnmount(() => {
|
||||
.play-wrap.starve-ui .mode-card:hover {
|
||||
border-color: #e8bc5a;
|
||||
}
|
||||
.play-wrap.starve-ui .skin-tab {
|
||||
border-radius: 0;
|
||||
border: 1.6px solid #6f5b38;
|
||||
background: linear-gradient(180deg, #3a2b17, #241a10);
|
||||
color: #f4ead2;
|
||||
font-family: "Ma Shan Zheng", "KaiTi", serif;
|
||||
letter-spacing: 1px;
|
||||
box-shadow: inset 0 0 0 2px #1d1409;
|
||||
}
|
||||
.play-wrap.starve-ui .skin-tab:hover {
|
||||
border-color: #e8bc5a;
|
||||
color: #fff2c0;
|
||||
}
|
||||
.play-wrap.starve-ui .skin-tab.on {
|
||||
border-color: #e8bc5a;
|
||||
color: #f4d68a;
|
||||
background: linear-gradient(180deg, #5a4327, #2b1d10);
|
||||
box-shadow: inset 0 0 0 2px #1d1409, 0 0 10px rgba(232, 188, 90, 0.35);
|
||||
}
|
||||
.play-wrap.starve-ui .join-row,
|
||||
.play-wrap.starve-ui .overlay.room-entry .ov-btns {
|
||||
background: rgba(42, 32, 18, 0.9);
|
||||
|
||||
@@ -105,7 +105,8 @@ onMounted(load)
|
||||
<div class="panel intro">
|
||||
<p>
|
||||
上传 Electron 安装包(如 <code>PixelArcade-Setup-1.0.1.exe</code>),系统从<strong>文件名</strong>解析版本号,
|
||||
写入公开接口 <code>/api/version/latest</code>,桌面端「检查更新」会引导下载。磁盘上<strong>只保留最近 {{ data.keep }} 个</strong>安装包。
|
||||
写入公开接口 <code>/api/version/latest</code>,桌面端「检查更新」会在应用内下载并启动安装程序。
|
||||
安装包地址为 <code>/api/download/…</code>(走已有 API 反代)。磁盘上<strong>只保留最近 {{ data.keep }} 个</strong>安装包。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user