优化斗地主

This commit is contained in:
李琦
2026-08-18 12:29:50 +08:00
parent 2e995a09dc
commit bc73abbd64
11 changed files with 1587 additions and 367 deletions

View File

@@ -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/1a<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) => {

View File

@@ -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
View 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 }

View File

@@ -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>

View File

@@ -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 形成发牌节奏) */

View File

@@ -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>

View File

@@ -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;

View File

@@ -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 款人物皮肤',
},
}

View File

@@ -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()
}

View File

@@ -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);

View File

@@ -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>