优化斗地主

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

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