feat: 实现避难所仓储系统与生存状态HUD改进

- 新增避难所仓储功能,支持物品在随身背包与仓储间转移
- 添加生存状态图标资源与状态阈值告警系统
- 重构HUD界面,优化状态显示与操作流程
- 扩展游戏核心系统,增加仓储相关动作和校验
- 更新文档说明仓储规则与实现细节
This commit is contained in:
2026-04-28 22:58:49 +08:00
parent e783e6e533
commit 6234a50bdb
27 changed files with 1455 additions and 1024 deletions
+10 -1
View File
@@ -16,10 +16,19 @@ export const gameActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('use-item'), itemId: z.string().min(1) }),
z.object({ type: z.literal('equip-item'), itemId: z.string().min(1) }),
z.object({ type: z.literal('unequip-item'), slot: z.enum(['weapon', 'body', 'tool']) }),
z.object({
type: z.literal('stash-item'),
itemId: z.string().min(1),
count: z.number().int().min(1).max(99),
}),
z.object({
type: z.literal('retrieve-item'),
itemId: z.string().min(1),
count: z.number().int().min(1).max(99),
}),
z.object({ type: z.literal('trade'), offerId: z.string().min(1) }),
z.object({ type: z.literal('rest'), minutes: z.number().int().min(10).max(240) }),
]);
export type CreateGameInput = z.infer<typeof createGameSchema>;
export type GameActionInput = z.infer<typeof gameActionSchema>;
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

+596 -2
View File
@@ -267,6 +267,18 @@ input,
background: rgba(215, 138, 51, 0.12);
}
.hud-chip.warn {
color: #f1c27b;
border-color: rgba(224, 180, 102, 0.34);
background: rgba(224, 180, 102, 0.12);
}
.hud-chip.danger {
color: #f0a08d;
border-color: rgba(225, 109, 76, 0.34);
background: rgba(225, 109, 76, 0.12);
}
.hud-chip.muted {
color: var(--muted);
}
@@ -1072,11 +1084,33 @@ input,
padding: 0.75rem;
}
.effect-card-head {
display: flex;
align-items: center;
gap: 0.65rem;
}
.status-effect-icon {
width: 42px;
height: 42px;
object-fit: cover;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(0, 0, 0, 0.36);
}
.effect-card p {
margin: 0.35rem 0 0;
font-size: 0.78rem;
}
.effect-card small {
display: block;
margin-top: 0.45rem;
color: var(--muted);
line-height: 1.45;
}
.effect-card.tone-good {
border-color: rgba(141, 194, 106, 0.24);
}
@@ -1538,7 +1572,7 @@ input,
.overlay-summary-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
gap: 0.55rem;
}
@@ -1577,6 +1611,13 @@ input,
grid-template-columns: minmax(0, 1.4fr) 320px;
}
.overlay-main-stack {
min-height: 0;
display: grid;
gap: 0.75rem;
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
}
.mission-overlay-grid {
grid-template-columns: minmax(0, 1.2fr) 340px;
}
@@ -1659,6 +1700,471 @@ input,
background: rgba(215, 138, 51, 0.26);
}
.game-grid {
grid-template-columns: 272px minmax(0, 1fr) 352px;
}
.panel-route.expedition-panel,
.panel-command.theater-panel,
.panel-loadout.survivor-panel {
padding: 0.85rem;
}
.panel-route.expedition-panel {
grid-template-rows: auto auto minmax(0, 1fr) minmax(0, 0.78fr);
}
.expedition-head,
.survivor-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.expedition-head h2,
.survivor-head strong {
margin: 0.3rem 0 0;
font-size: 1.28rem;
line-height: 1;
letter-spacing: -0.03em;
}
.expedition-core {
display: grid;
gap: 0.8rem;
padding: 0.95rem;
border-radius: 18px;
border: 1px solid rgba(215, 138, 51, 0.2);
background:
linear-gradient(180deg, rgba(215, 138, 51, 0.08), rgba(255, 255, 255, 0)),
rgba(10, 8, 7, 0.68);
}
.expedition-core p {
margin: 0;
}
.expedition-status-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
}
.expedition-status-card {
padding: 0.7rem;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
}
.expedition-status-card small {
display: block;
margin-bottom: 0.25rem;
color: var(--muted);
font-size: 0.66rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.route-stack,
.expedition-map-panel,
.survivor-supply-panel,
.survivor-actions-panel {
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
}
.expedition-route-list,
.expedition-map-grid {
align-content: start;
}
.expedition-route-card {
grid-template-columns: 76px minmax(0, 1fr) 56px;
padding: 0.55rem;
}
.expedition-route-card .route-card-thumb {
min-height: 96px;
}
.expedition-route-card .route-card-copy strong {
font-size: 1rem;
}
.expedition-route-card .route-card-state {
min-width: 0;
align-items: flex-start;
gap: 0.45rem;
}
.expedition-map-grid {
grid-template-columns: 1fr;
}
.theater-panel {
grid-template-rows: 294px auto auto;
}
.theater-scene {
position: relative;
overflow: hidden;
border-radius: 22px;
border: 1px solid rgba(215, 138, 51, 0.2);
}
.theater-scene::after {
content: '';
position: absolute;
inset: 0;
background:
linear-gradient(90deg, rgba(6, 5, 4, 0.1), rgba(6, 5, 4, 0.72) 74%),
linear-gradient(180deg, rgba(6, 5, 4, 0.06), rgba(6, 5, 4, 0.78));
}
.theater-scene-copy,
.theater-scene-side {
position: absolute;
z-index: 1;
}
.theater-scene-copy {
left: 1rem;
right: 21rem;
bottom: 1rem;
display: grid;
gap: 0.45rem;
}
.theater-scene-copy h2 {
margin: 0;
font-size: clamp(2.6rem, 4vw, 4.3rem);
line-height: 0.9;
letter-spacing: -0.06em;
}
.theater-scene-copy p:last-child {
margin: 0;
max-width: 35rem;
color: rgba(239, 226, 206, 0.8);
}
.theater-scene-side {
top: 1rem;
right: 1rem;
width: 300px;
display: grid;
gap: 0.55rem;
}
.scene-focus-card {
padding: 0.85rem;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(7, 6, 5, 0.58);
backdrop-filter: blur(14px);
}
.scene-focus-card.error {
border-color: rgba(225, 109, 76, 0.28);
}
.scene-focus-card span,
.primary-operation-copy span,
.intel-focus-card span,
.survivor-metric-card span {
display: block;
margin-bottom: 0.28rem;
color: var(--muted);
font-size: 0.68rem;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.scene-focus-card strong,
.intel-focus-card strong {
display: block;
font-size: 0.98rem;
}
.scene-focus-card p,
.intel-focus-card p {
margin: 0.3rem 0 0;
color: rgba(239, 226, 206, 0.8);
line-height: 1.45;
}
.theater-deck {
display: grid;
grid-template-columns: minmax(0, 1.15fr) 316px;
gap: 0.75rem;
}
.primary-operation-card {
display: grid;
gap: 0.8rem;
padding: 1rem;
border-radius: 20px;
border: 1px solid rgba(215, 138, 51, 0.26);
background:
radial-gradient(circle at top right, rgba(215, 138, 51, 0.14), transparent 28%),
linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0)),
rgba(11, 9, 8, 0.86);
}
.primary-operation-copy h3 {
margin: 0;
font-size: clamp(1.7rem, 2.8vw, 2.5rem);
line-height: 0.94;
letter-spacing: -0.04em;
}
.primary-operation-copy p,
.secondary-operation-card p {
margin: 0.4rem 0 0;
color: rgba(239, 226, 206, 0.78);
line-height: 1.45;
}
.operation-chip-row {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.operation-chip-row span {
display: inline-flex;
align-items: center;
min-height: 1.9rem;
padding: 0 0.7rem;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
font-size: 0.72rem;
color: rgba(239, 226, 206, 0.82);
}
.primary-operation-card small {
color: #f0c27a;
font-size: 0.78rem;
}
.operation-commit-button {
width: min(100%, 220px);
}
.secondary-operation-stack {
display: grid;
gap: 0.55rem;
}
.secondary-operation-card {
min-height: 0;
display: grid;
gap: 0.3rem;
text-align: left;
padding: 0.85rem;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0)),
rgba(10, 8, 7, 0.6);
}
.secondary-operation-card strong {
font-size: 0.98rem;
}
.secondary-operation-card small {
color: var(--muted);
font-size: 0.72rem;
}
.theater-intel-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
}
.intel-focus-card {
min-height: 142px;
padding: 0.9rem;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(10, 8, 7, 0.52);
}
.intel-focus-card.system {
display: grid;
grid-template-rows: auto auto 1fr;
}
.intel-system-links {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
align-content: end;
margin-top: 0.7rem;
}
.survivor-panel {
grid-template-rows: auto auto auto auto auto;
}
.survivor-head-chips {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.45rem;
}
.survivor-rig {
display: grid;
grid-template-columns: 110px minmax(0, 1fr) 110px;
gap: 0.65rem;
min-height: 328px;
}
.rig-column {
display: grid;
gap: 0.55rem;
}
.rig-slot {
padding: 0.65rem;
}
.rig-figure {
min-height: 328px;
}
.rig-figure-overlay {
right: 0.85rem;
left: 0.85rem;
bottom: 4.85rem;
text-align: left;
}
.rig-figure-overlay strong {
display: block;
margin-top: 0.25rem;
font-size: 0.92rem;
}
.survivor-metrics-strip {
position: absolute;
left: 0.85rem;
right: 0.85rem;
bottom: 0.85rem;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.45rem;
}
.survivor-metric-card {
padding: 0.6rem;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(7, 6, 5, 0.58);
}
.survivor-metric-card strong {
font-size: 1.1rem;
}
.survivor-warning-strip {
display: grid;
gap: 0.5rem;
}
.survivor-warning-card {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
gap: 0.65rem;
align-items: start;
padding: 0.75rem;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(10, 8, 7, 0.48);
}
.survivor-warning-card strong {
display: block;
font-size: 0.94rem;
}
.survivor-warning-card p {
margin: 0.26rem 0 0;
color: rgba(239, 226, 206, 0.78);
font-size: 0.78rem;
line-height: 1.42;
}
.survivor-supply-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.5rem;
}
.survivor-supply-card {
min-height: 120px;
display: grid;
gap: 0.45rem;
align-content: start;
text-align: left;
padding: 0.6rem;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0)),
rgba(10, 8, 7, 0.54);
}
.survivor-supply-card .asset-thumb {
width: 100%;
min-width: 0;
}
.survivor-supply-copy span {
display: block;
margin-top: 0.22rem;
color: #f1c27b;
}
.survivor-supply-copy small {
display: block;
margin-top: 0.22rem;
color: var(--muted);
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.survivor-supply-card.locked {
place-items: center;
border-style: dashed;
}
.tactical-dock {
grid-template-columns: 208px minmax(0, 1fr) 196px;
}
.tactical-operator {
min-width: 0;
}
.dock-commit {
display: flex;
justify-content: flex-end;
}
.dock-commit-button {
width: 100%;
min-height: 48px;
}
@media (max-width: 1580px) {
.top-hud {
grid-template-columns: 250px 104px minmax(0, 1fr);
@@ -1670,12 +2176,28 @@ input,
}
.game-grid {
grid-template-columns: 286px minmax(0, 1fr) 372px;
grid-template-columns: 248px minmax(0, 1fr) 330px;
}
.loadout-matrix {
grid-template-columns: 112px minmax(0, 1fr) 112px;
}
.theater-deck {
grid-template-columns: minmax(0, 1fr) 280px;
}
.theater-scene-copy {
right: 18rem;
}
.theater-scene-side {
width: 260px;
}
.survivor-rig {
grid-template-columns: 96px minmax(0, 1fr) 96px;
}
}
@media (max-width: 1340px) {
@@ -1713,6 +2235,45 @@ input,
.command-dock {
align-items: start;
}
.theater-panel,
.panel-route.expedition-panel,
.survivor-panel {
grid-template-rows: auto;
}
.theater-scene {
min-height: 320px;
}
.theater-scene-copy,
.theater-scene-side {
position: absolute;
}
.theater-scene-copy {
right: 1rem;
}
.theater-scene-side {
width: 260px;
}
.theater-deck,
.theater-intel-grid,
.survivor-rig,
.tactical-dock {
grid-template-columns: 1fr;
}
.rig-column {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.survivor-head,
.expedition-head {
align-items: flex-start;
}
}
@media (max-width: 860px) {
@@ -1733,6 +2294,35 @@ input,
grid-template-columns: 1fr;
}
.theater-scene {
min-height: 360px;
}
.theater-scene-copy,
.theater-scene-side {
position: static;
width: auto;
}
.theater-scene {
display: grid;
align-content: end;
gap: 0.7rem;
padding: 1rem;
}
.theater-scene::after {
inset: 0;
}
.expedition-status-grid,
.theater-intel-grid,
.survivor-metrics-strip,
.rig-column,
.survivor-supply-grid {
grid-template-columns: 1fr;
}
.quick-slot-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -1760,4 +2350,8 @@ input,
.new-game-card {
min-height: 420px;
}
.expedition-route-card {
grid-template-columns: 1fr;
}
}
@@ -1,22 +1,27 @@
import type { OverlayPanel } from '../types';
import type { GameView } from '@tinywaste/game-core';
import { getActiveQuestCount, getSupplyCounts } from '../hudModel';
import type { GameAction, GameView } from '@tinywaste/game-core';
import { getActiveQuestCount, getCurrentPlace, getDominantAlert, getSupplyCounts } from '../hudModel';
export function BottomDock({
activePanel,
onOpenPanel,
onSendAction,
view,
}: {
activePanel: OverlayPanel | null;
onOpenPanel: (panel: OverlayPanel) => void;
onSendAction: (action: GameAction) => void;
view: GameView;
}) {
const supplyCounts = getSupplyCounts(view);
const activeQuestCount = getActiveQuestCount(view);
const currentPlace = getCurrentPlace(view);
const homeRoute = view.map.edges.find((edge) => edge.to === 'home');
const dominantAlert = getDominantAlert(view);
return (
<footer className="command-dock">
<div className="operator-card">
<footer className="command-dock tactical-dock">
<div className="operator-card tactical-operator">
<div className="operator-emblem">{view.player.name.slice(0, 1)}</div>
<div className="operator-copy">
<span>ACTIVE SURVIVOR</span>
@@ -26,7 +31,7 @@ export function BottomDock({
<div className="dock-nav-groups">
<div className="dock-tabs">
<button className="dock-tab active">Shelter</button>
<button className="dock-tab active">{currentPlace?.name ?? 'Expedition'}</button>
<button
className={`dock-tab ${activePanel === 'blueprints' ? 'active' : ''}`}
onClick={() => onOpenPanel('blueprints')}
@@ -54,13 +59,28 @@ export function BottomDock({
</div>
<div className="dock-metrics">
<span>{dominantAlert ? dominantAlert.label : '状态稳定'}</span>
<span> {supplyCounts.water}</span>
<span> {supplyCounts.food}</span>
<span> {supplyCounts.medicine}</span>
<span> {supplyCounts.material}</span>
<span> {activeQuestCount}</span>
</div>
</div>
<div className="dock-commit">
{homeRoute ? (
<button
className="primary-button dock-commit-button"
onClick={() => onSendAction({ type: 'travel', toPlaceId: homeRoute.to })}
>
</button>
) : (
<button className="primary-button dock-commit-button" onClick={() => onOpenPanel('inventory')}>
</button>
)}
</div>
</footer>
);
}
@@ -1,49 +1,10 @@
import type { GameAction, GameView } from '@tinywaste/game-core';
import { getObjectiveRows } from '../hudModel';
import type { GameAction, GameView, InventoryViewEntry } from '@tinywaste/game-core';
import { getLatestLog, getPrimaryObjective, getQuickUseItems } from '../hudModel';
import { getPlaceHeroStyle } from '../uiAssets';
import type { OverlayPanel } from '../types';
import { EmptyState } from './EmptyState';
import { PanelHeader } from '../../shared/components/PanelHeader';
function ObjectivePanel({ view }: { view: GameView }) {
const objectives = getObjectiveRows(view);
return (
<section className="subpanel objective-panel">
<PanelHeader title="Current Objectives" subtitle="主线与支线推进聚合显示" compact />
<div className="objective-list">
{objectives.length ? (
objectives.map((objective) => (
<article key={objective.id} className={`objective-card ${objective.done ? 'done' : ''}`}>
<div>
<strong>{objective.title}</strong>
<p>{objective.detail}</p>
</div>
<span>{objective.progress}</span>
</article>
))
) : (
<EmptyState text="当前没有活动目标,继续探索可以解锁新线索。" />
)}
</div>
</section>
);
}
function LogPanel({ view }: { view: GameView }) {
return (
<section className="subpanel log-panel">
<PanelHeader title="Live Log" subtitle="只滚动日志层,不推动页面滚动" compact />
<div className="log-list">
{view.logs.map((entry) => (
<article key={entry.id} className={`log-entry tone-${entry.tone}`}>
<span>{entry.minute}m</span>
<p>{entry.message}</p>
</article>
))}
</div>
</section>
);
function pickRecoveryItem(items: InventoryViewEntry[], type: 'water' | 'food' | 'medicine') {
return items.find((item) => item.type === type);
}
export function CommandCenter({
@@ -59,138 +20,210 @@ export function CommandCenter({
onSendAction: (action: GameAction) => void;
view: GameView;
}) {
const utilityCards = [
const quickUseItems = getQuickUseItems(view);
const primaryObjective = getPrimaryObjective(view);
const latestLog = getLatestLog(view);
const primaryAlert = view.survival.alerts[0] ?? null;
const availableActions = view.place.actions.filter((action) => !action.disabledReason);
const firstUtilityAction = availableActions[0] ?? null;
const primaryOperation = (() => {
if (primaryAlert?.stat === 'thirst') {
const water = pickRecoveryItem(quickUseItems, 'water');
if (water) {
return {
kicker: 'Recover now',
title: `饮用 ${water.name}`,
description: primaryAlert.recovery,
hint: '先把出行窗口拉回来,再考虑继续搜刮。',
chips: [`携带 x${water.count}`, '即时恢复', '口渴优先'],
actionLabel: '立即饮水',
onClick: () => onSendAction({ type: 'use-item', itemId: water.itemId }),
};
}
}
if (primaryAlert?.stat === 'hunger') {
const food = pickRecoveryItem(quickUseItems, 'food');
if (food) {
return {
kicker: 'Recover now',
title: `吃掉 ${food.name}`,
description: primaryAlert.recovery,
hint: '热量恢复后,连续行动的容错会更高。',
chips: [`携带 x${food.count}`, '即时恢复', '饥饿优先'],
actionLabel: '立即进食',
onClick: () => onSendAction({ type: 'use-item', itemId: food.itemId }),
};
}
}
if (primaryAlert && (primaryAlert.stat === 'life' || primaryAlert.stat === 'radiation')) {
const medicine = pickRecoveryItem(quickUseItems, 'medicine');
if (medicine) {
return {
kicker: 'Stabilize',
title: `使用 ${medicine.name}`,
description: primaryAlert.recovery,
hint: '先把身体拉回稳定区,再决定是否继续推进。',
chips: [`携带 x${medicine.count}`, '医疗补给', primaryAlert.label],
actionLabel: '立即处理',
onClick: () => onSendAction({ type: 'use-item', itemId: medicine.itemId }),
};
}
}
if (primaryAlert?.stat === 'energy' && view.place.services.includes('rest')) {
return {
kicker: 'Recover now',
title: '短休 30 分钟',
description: primaryAlert.recovery,
hint: '把精力先抬到安全线,再出门会更稳。',
chips: ['30 分钟', '精力恢复', '低风险'],
actionLabel: '立即休整',
onClick: () => onSendAction({ type: 'rest', minutes: 30 }),
};
}
if (firstUtilityAction) {
return {
kicker: 'Next move',
title: firstUtilityAction.name,
description: firstUtilityAction.desc,
hint: firstUtilityAction.rewardHint,
chips: [
`${firstUtilityAction.timeCostMin} 分钟`,
`精力 -${firstUtilityAction.energyCost}`,
`风险 ${Math.round(firstUtilityAction.risk * 100)}%`,
],
actionLabel: '执行行动',
onClick: () => onSendAction({ type: 'perform-action', actionId: firstUtilityAction.id }),
};
}
return {
kicker: 'System focus',
title: '打开背包面板',
description: '当前没有可直接执行的扇区行为,先整理资源和配装。',
hint: '库存与仓储都在系统面板里管理。',
chips: ['系统面板', '整理补给', '继续规划'],
actionLabel: '打开背包',
onClick: () => onOpenPanel('inventory'),
};
})();
const secondaryOperations = [
...view.place.actions
.filter((action) => !action.disabledReason && action.id !== firstUtilityAction?.id)
.slice(0, 3)
.map((action) => ({
id: action.id,
title: action.name,
detail: action.rewardHint,
meta: `${action.timeCostMin}m · 风险 ${Math.round(action.risk * 100)}%`,
onClick: () => onSendAction({ type: 'perform-action', actionId: action.id }),
})),
{
id: 'utility-rest',
name: '短休 30m',
kind: 'system',
desc: '不离开当前扇区,快速回收一段精力窗口。',
meta: ['30 分钟', '恢复向', '低风险'],
onClick: () => onSendAction({ type: 'rest', minutes: 30 }),
id: 'inventory',
title: '整理补给',
detail: '打开背包与避难所仓储',
meta: 'inventory',
onClick: () => onOpenPanel('inventory'),
},
{
id: 'utility-blueprints',
name: '打开蓝图',
kind: 'system',
desc: '在独立系统面板中检查配方、缺口与制作链。',
meta: ['模式切换', '制作', '即时'],
id: 'blueprints',
title: '检查蓝图',
detail: '查看可制作项与材料缺口',
meta: 'crafting',
onClick: () => onOpenPanel('blueprints'),
},
{
id: 'utility-logs',
name: '复盘日志',
kind: 'system',
desc: '在独立日志面板中回放最近行动、事件与消耗。',
meta: ['模式切换', '日志', '回放'],
onClick: () => onOpenPanel('logs'),
},
];
].slice(0, 5);
return (
<section className="panel panel-command">
<div className="viewport-stage" style={getPlaceHeroStyle(view.place.id)}>
<div className="viewport-copy">
<p className="eyebrow">Live Visual Feed</p>
<section className="panel panel-command theater-panel">
<div className="theater-scene" style={getPlaceHeroStyle(view.place.id)}>
<div className="theater-scene-copy">
<p className="eyebrow">Operation theater</p>
<h2>{view.header.placeName}</h2>
<p>{view.header.placeDesc}</p>
</div>
<div className="viewport-flags">
<span>{view.pendingEvent ? '事件待处理' : '无待定事件'}</span>
<span>{view.combat ? `战斗回合 ${view.combat.round}` : `行动 ${view.place.actions.length}`}</span>
<span>{view.place.tradeOffers.length ? `可交易 ${view.place.tradeOffers.length}` : '无交易站'}</span>
<div className="theater-scene-side">
<article className="scene-focus-card">
<span></span>
<strong>{primaryObjective?.title ?? view.survival.headline}</strong>
<p>{primaryObjective?.detail ?? '先处理生存威胁,再推进任务。'}</p>
</article>
<article className={`scene-focus-card ${gameError ? 'error' : ''}`}>
<span></span>
<strong>{primaryAlert ? primaryAlert.summary : '链路稳定'}</strong>
<p>{gameError ?? latestLog?.message ?? '最新日志会在这里同步关键结果。'}</p>
</article>
</div>
</div>
{gameError ? <p className="error-copy command-error">{gameError}</p> : null}
<div className="command-surface">
<div className="command-tabs command-tabs-static">
<button className="active">Actions</button>
<button onClick={() => onOpenPanel('logs')}>Logs</button>
<button onClick={() => onOpenPanel('blueprints')}>Blueprints</button>
</div>
<div className="command-stage">
<section className="subpanel stage-main">
<PanelHeader title="Action Matrix" subtitle="服务端即时结算扇区行为" compact />
<div className="action-grid">
{view.place.actions.map((action) => (
<button
key={action.id}
className="action-card"
disabled={Boolean(action.disabledReason) || isWorking}
onClick={() => onSendAction({ type: 'perform-action', actionId: action.id })}
>
<div className="action-card-top">
<strong>{action.name}</strong>
<span className="action-kind">{action.kind}</span>
</div>
<p>{action.desc}</p>
<div className="action-card-meta">
<span>{action.timeCostMin} </span>
<span> -{action.energyCost}</span>
<span> {Math.round(action.risk * 100)}%</span>
</div>
{action.remainingStock !== null && action.remainingStock !== undefined ? (
<small>{action.remainingStock}</small>
) : null}
{action.disabledReason ? <em>{action.disabledReason}</em> : <small>{action.rewardHint}</small>}
</button>
))}
{utilityCards.map((card) => (
<button key={card.id} className="action-card utility" disabled={isWorking} onClick={card.onClick}>
<div className="action-card-top">
<strong>{card.name}</strong>
<span className="action-kind">{card.kind}</span>
</div>
<p>{card.desc}</p>
<div className="action-card-meta">
{card.meta.map((entry) => (
<span key={entry}>{entry}</span>
))}
</div>
<small></small>
</button>
))}
</div>
<div className="trade-block">
<PanelHeader title="Trade Terminal" subtitle="当前地点的即时物资交换" compact />
{view.place.tradeOffers.length ? (
<div className="trade-list compact">
{view.place.tradeOffers.map((offer) => (
<button
key={offer.id}
className="trade-card"
disabled={!offer.available || isWorking}
onClick={() => onSendAction({ type: 'trade', offerId: offer.id })}
>
<strong>{offer.name}</strong>
<p>{offer.desc}</p>
<small>
{offer.costs
.map((entry) => `${entry.name} x${entry.count} (持有 ${entry.owned})`)
.join(' · ')}
</small>
<small>
{offer.gives.map((entry) => `${entry.name} x${entry.count}`).join(' · ')}
</small>
{offer.disabledReason ? <em>{offer.disabledReason}</em> : null}
</button>
))}
</div>
) : (
<EmptyState text="当前扇区没有交易站点。" />
)}
</div>
</section>
<div className="command-side-column">
<ObjectivePanel view={view} />
<LogPanel view={view} />
<div className="theater-deck">
<section className="primary-operation-card">
<div className="primary-operation-copy">
<span>{primaryOperation.kicker}</span>
<h3>{primaryOperation.title}</h3>
<p>{primaryOperation.description}</p>
</div>
</div>
<div className="operation-chip-row">
{primaryOperation.chips.map((chip) => (
<span key={chip}>{chip}</span>
))}
</div>
<small>{primaryOperation.hint}</small>
<button className="primary-button operation-commit-button" disabled={isWorking} onClick={primaryOperation.onClick}>
{primaryOperation.actionLabel}
</button>
</section>
<section className="secondary-operation-stack">
{secondaryOperations.map((operation) => (
<button
key={operation.id}
className="secondary-operation-card"
disabled={isWorking}
onClick={operation.onClick}
>
<strong>{operation.title}</strong>
<p>{operation.detail}</p>
<small>{operation.meta}</small>
</button>
))}
</section>
</div>
<div className="theater-intel-grid">
<article className="intel-focus-card">
<span>Mission thread</span>
<strong>{primaryObjective?.title ?? '暂无关键目标'}</strong>
<p>{primaryObjective?.detail ?? '继续探索以触发新的委托与线索。'}</p>
</article>
<article className="intel-focus-card">
<span>Recent log</span>
<strong>{latestLog ? `${latestLog.minute}m` : '日志空闲'}</strong>
<p>{latestLog?.message ?? '还没有新的结构化战报。'}</p>
</article>
<article className="intel-focus-card system">
<span>Systems</span>
<strong></strong>
<div className="intel-system-links">
<button className="small-button secondary" onClick={() => onOpenPanel('logs')}>
</button>
<button className="small-button secondary" onClick={() => onOpenPanel('missions')}>
</button>
<button className="small-button secondary" onClick={() => onOpenPanel('blueprints')}>
</button>
</div>
</article>
</div>
</section>
);
@@ -48,7 +48,7 @@ export function GameHud({
<LoadoutPanel isWorking={isWorking} onOpenPanel={setActivePanel} onSendAction={onSendAction} view={view} />
</main>
<BottomDock activePanel={activePanel} onOpenPanel={setActivePanel} view={view} />
<BottomDock activePanel={activePanel} onOpenPanel={setActivePanel} onSendAction={onSendAction} view={view} />
<SystemOverlay
isWorking={isWorking}
@@ -1,7 +1,12 @@
import type { GameAction, GameView } from '@tinywaste/game-core';
import { useMemo } from 'react';
import { CHARACTER_PREVIEW_ART, getItemArt } from '../uiAssets';
import { getAttributeRows, getLoadoutEntries, getStatusEffects, getVisibleInventory } from '../hudModel';
import { getItemArt } from '../uiAssets';
import {
getLoadoutEntries,
getQuickUseItems,
getStatusEffects,
getSurvivorMetrics,
} from '../hudModel';
import type { OverlayPanel } from '../types';
import { AssetThumb } from './AssetThumb';
import { PanelHeader } from '../../shared/components/PanelHeader';
@@ -18,141 +23,144 @@ export function LoadoutPanel({
view: GameView;
}) {
const loadoutEntries = useMemo(() => getLoadoutEntries(view), [view]);
const attributeRows = useMemo(() => getAttributeRows(view), [view]);
const statusEffects = useMemo(() => getStatusEffects(view), [view]);
const quickSlots = useMemo(() => getVisibleInventory(view), [view]);
const quickUseItems = useMemo(() => getQuickUseItems(view, 5), [view]);
const survivorMetrics = useMemo(() => getSurvivorMetrics(view), [view]);
const equipmentEntries = useMemo(
() => [...loadoutEntries.left, ...loadoutEntries.right],
[loadoutEntries.left, loadoutEntries.right],
);
const storagePreview = useMemo(() => view.player.storage.slice(0, 5), [view.player.storage]);
return (
<section className="panel panel-loadout">
<div className="panel-tabs">
<section className="panel panel-loadout survivor-panel">
<div className="panel-tabs survivor-tabs">
<button className="active">Character</button>
<button onClick={() => onOpenPanel('inventory')}>Inventory</button>
<button onClick={() => onOpenPanel('missions')}>Missions</button>
</div>
<div className="loadout-scroll">
<PanelHeader
title="Character Matrix"
subtitle={`${view.player.name} · 容量 ${view.player.inventoryUsage}/${view.player.inventoryCapacity}`}
/>
<div className="loadout-matrix">
<div className="slot-column">
{loadoutEntries.left.map((entry) => (
<article key={entry.id} className={`loadout-slot ${entry.isEquipped ? 'equipped' : ''}`}>
<AssetThumb src={entry.art} label={entry.title} className="slot-thumb" />
<div className="slot-copy">
<span>{entry.label}</span>
<strong>{entry.title}</strong>
<small>{entry.subtitle}</small>
</div>
</article>
))}
</div>
<div className="character-figure">
<img src={CHARACTER_PREVIEW_ART} alt="幸存者角色立绘" />
<div className="character-figure-overlay">
<span>ACTIVE SURVIVOR</span>
<strong>{view.player.name}</strong>
</div>
</div>
<div className="slot-column">
{loadoutEntries.right.map((entry) => (
<article key={entry.id} className={`loadout-slot ${entry.isEquipped ? 'equipped' : ''}`}>
<AssetThumb src={entry.art} label={entry.title} className="slot-thumb" />
<div className="slot-copy">
<span>{entry.label}</span>
<strong>{entry.title}</strong>
<small>{entry.subtitle}</small>
</div>
</article>
))}
</div>
<div className="survivor-head">
<div>
<p className="eyebrow">Survivor rig</p>
<strong>{view.player.name}</strong>
</div>
<div className="character-meta-grid">
<section className="subpanel attribute-panel">
<PanelHeader title="Attributes" subtitle="当前成长属性直接来自在线存档" compact />
<div className="attribute-grid">
{attributeRows.map((entry) => (
<article key={entry.label} className="attribute-card">
<span>{entry.label}</span>
<strong>{entry.value}</strong>
<small>{entry.name}</small>
</article>
))}
</div>
</section>
<section className="subpanel effects-panel">
<PanelHeader title="Status Effects" subtitle="从生存状态阈值导出的风险反馈" compact />
<div className="effects-grid">
{statusEffects.map((effect) => (
<article key={effect.id} className={`effect-card tone-${effect.tone}`}>
<strong>{effect.label}</strong>
<p>{effect.detail}</p>
</article>
))}
</div>
</section>
<div className="survivor-head-chips">
<span className={`hud-chip ${view.player.storageAccessible ? 'safe' : 'muted'}`}>
{view.player.storageAccessible ? '仓储在线' : '野外行动'}
</span>
<span className="hud-chip muted">
{view.player.inventoryUsage}/{view.player.inventoryCapacity}
</span>
</div>
<section className="subpanel quick-slot-panel">
<PanelHeader title="Quick Slots" subtitle="高频补给和工具直接图像化呈现" compact />
<div className="quick-slot-grid">
{quickSlots.map((item) => (
<article key={`quick-${item.itemId}`} className="quick-slot">
<AssetThumb src={getItemArt(item.itemId, item.type)} label={item.name} />
<div className="quick-slot-copy">
<strong>{item.name}</strong>
<span>x{item.count}</span>
</div>
</article>
))}
{Array.from({ length: Math.max(0, 8 - quickSlots.length) }).map((_, index) => (
<article key={`locked-${index}`} className="quick-slot locked">
<div className="quick-slot-lock">LOCK</div>
</article>
))}
</div>
</section>
<section className="subpanel right-utility-panel">
<PanelHeader title="Field Panels" subtitle="中频系统改为游戏面板弹出" compact />
<div className="panel-link-grid">
<button className="secondary-button panel-link-button" onClick={() => onOpenPanel('inventory')}>
</button>
<button className="secondary-button panel-link-button" onClick={() => onOpenPanel('missions')}>
</button>
<button className="secondary-button panel-link-button" onClick={() => onOpenPanel('blueprints')}>
</button>
<button className="secondary-button panel-link-button" onClick={() => onOpenPanel('logs')}>
</button>
</div>
</section>
<section className="subpanel rest-panel">
<PanelHeader title="Recovery Actions" subtitle="原地休整仍保留在主 HUD 中" compact />
<div className="rest-actions">
{[30, 60, 120].map((minutes) => (
<button
key={minutes}
className="rest-button"
disabled={isWorking}
onClick={() => onSendAction({ type: 'rest', minutes })}
>
{minutes}
</button>
))}
</div>
</section>
</div>
<section className="subpanel equipment-grid-panel">
<PanelHeader title="Equipment" subtitle="去掉人物展示后,右侧改为纯功能 HUD 终端" compact />
<div className="survivor-equipment-grid">
{equipmentEntries.map((entry) => (
<article key={entry.id} className={`loadout-slot equipment-module ${entry.isEquipped ? 'equipped' : ''}`}>
<AssetThumb src={entry.art} label={entry.title} className="slot-thumb" />
<div className="slot-copy">
<span>{entry.label}</span>
<strong>{entry.title}</strong>
<small>{entry.subtitle}</small>
</div>
</article>
))}
</div>
</section>
<section className="survivor-metrics-row">
{survivorMetrics.map((metric) => (
<article key={metric.id} className="survivor-metric-card">
<span>{metric.label}</span>
<strong>{metric.value}</strong>
</article>
))}
</section>
<section className="survivor-warning-strip">
{statusEffects.map((effect) => (
<article key={effect.id} className={`survivor-warning-card tone-${effect.tone}`}>
{effect.art ? <img className="status-effect-icon" src={effect.art} alt={effect.label} /> : null}
<div>
<strong>{effect.label}</strong>
<p>{effect.detail}</p>
</div>
</article>
))}
</section>
<section className="subpanel quick-slot-panel survivor-supply-panel">
<PanelHeader title="Quick supplies" subtitle="高频补给直接可点,不再只是展示" compact />
<div className="survivor-supply-grid">
{quickUseItems.map((item) => (
<button
key={`quick-use-${item.itemId}`}
className="survivor-supply-card"
disabled={isWorking}
onClick={() => onSendAction({ type: 'use-item', itemId: item.itemId })}
>
<AssetThumb src={getItemArt(item.itemId, item.type)} label={item.name} />
<div className="survivor-supply-copy">
<strong>{item.name}</strong>
<span>x{item.count}</span>
<small>{item.type}</small>
</div>
</button>
))}
{Array.from({ length: Math.max(0, 5 - quickUseItems.length) }).map((_, index) => (
<article key={`locked-${index}`} className="survivor-supply-card locked">
<div className="quick-slot-lock">EMPTY</div>
</article>
))}
</div>
</section>
<section className="subpanel survivor-stash-panel">
<PanelHeader
title="Stash snapshot"
subtitle={
view.player.storageAccessible
? `避难所仓储 ${view.player.storageUsage}/${view.player.storageCapacity}`
: '离开避难所后只保留摘要'
}
compact
/>
<div className="survivor-stash-grid">
{storagePreview.map((item) => (
<article key={`stash-${item.itemId}`} className="survivor-stash-card">
<AssetThumb src={getItemArt(item.itemId, item.type)} label={item.name} />
<div className="survivor-stash-copy">
<strong>{item.name}</strong>
<span>x{item.count}</span>
</div>
</article>
))}
<button className="secondary-button survivor-stash-open" onClick={() => onOpenPanel('inventory')}>
</button>
</div>
</section>
<section className="subpanel survivor-actions-panel">
<PanelHeader title="Field terminals" subtitle="中频系统从这里切换,主屏只保留决策所需" compact />
<div className="panel-link-grid">
<button className="secondary-button panel-link-button" onClick={() => onOpenPanel('inventory')}>
/
</button>
<button className="secondary-button panel-link-button" onClick={() => onOpenPanel('blueprints')}>
</button>
<button className="secondary-button panel-link-button" onClick={() => onOpenPanel('missions')}>
线
</button>
<button className="secondary-button panel-link-button" onClick={() => onOpenPanel('logs')}>
</button>
</div>
</section>
</section>
);
}
@@ -1,5 +1,5 @@
import type { GameAction, GameView } from '@tinywaste/game-core';
import { edgeDestination, getActiveQuestCount, getCurrentPlace, getRiskDots, getRouteRiskTier } from '../hudModel';
import { edgeDestination, getActiveQuestCount, getCurrentPlace, getExpeditionStatus, getRiskDots, getRouteRiskTier } from '../hudModel';
import { getPlaceThumbStyle } from '../uiAssets';
import { PanelHeader } from '../../shared/components/PanelHeader';
@@ -14,28 +14,36 @@ export function RoutePanel({
}) {
const currentPlace = getCurrentPlace(view);
const activeQuestCount = getActiveQuestCount(view);
const expedition = getExpeditionStatus(view);
return (
<section className="panel panel-route">
<PanelHeader title="Current Location" subtitle="路线、风险、耗时全部固定在当前视口内" />
<section className="panel panel-route expedition-panel">
<header className="expedition-head">
<div>
<p className="eyebrow">Expedition rail</p>
<h2>{currentPlace?.name ?? view.header.placeName}</h2>
</div>
<span className={`hud-chip ${expedition.tone === 'good' ? 'safe' : expedition.tone}`}>
{view.header.riskLabel}
</span>
</header>
<article className="route-summary-card">
<article className="expedition-core">
<span className="intel-kicker">CURRENT SECTOR</span>
<h2>{currentPlace?.name ?? view.header.placeName}</h2>
<p>{view.header.placeDesc}</p>
<div className="route-summary-meta">
<div>
<small></small>
<strong>{view.header.riskLabel}</strong>
</div>
<div>
<small></small>
<strong>{view.place.services.length || 1} </strong>
</div>
<div>
<small></small>
<strong>{activeQuestCount}</strong>
</div>
<div className="expedition-status-grid">
<article className="expedition-status-card">
<small></small>
<strong>{expedition.readiness}%</strong>
</article>
<article className="expedition-status-card">
<small></small>
<strong>{view.player.inventoryUsage}/{view.player.inventoryCapacity}</strong>
</article>
<article className="expedition-status-card">
<small></small>
<strong>{expedition.threatCount || activeQuestCount}</strong>
</article>
</div>
<div className="intel-meta">
{(currentPlace?.tags ?? []).map((tag) => (
@@ -46,7 +54,9 @@ export function RoutePanel({
</div>
</article>
<div className="route-list">
<section className="route-stack">
<PanelHeader title="Routes" subtitle="选择下一段远行目标" compact />
<div className="route-list expedition-route-list">
{view.map.edges.map((edge) => {
const destination = edgeDestination(view, edge);
if (!destination) return null;
@@ -57,7 +67,7 @@ export function RoutePanel({
return (
<button
key={`${edge.from}-${edge.to}`}
className={`route-card tone-${riskTier.tone}`}
className={`route-card expedition-route-card tone-${riskTier.tone}`}
disabled={Boolean(edge.blockedReason) || isWorking}
onClick={() => onSendAction({ type: 'travel', toPlaceId: edge.to })}
>
@@ -82,11 +92,12 @@ export function RoutePanel({
</button>
);
})}
</div>
</div>
</section>
<section className="world-map-panel">
<PanelHeader title="World Map" subtitle="节点式世界图用于压缩表达地理推进" compact />
<div className="map-node-grid">
<section className="world-map-panel expedition-map-panel">
<PanelHeader title="Known sectors" subtitle="压缩后的节点情报" compact />
<div className="map-node-grid expedition-map-grid">
{view.map.places.map((place) => (
<article key={place.id} className={`map-node-card ${place.current ? 'current' : ''}`}>
<strong>{place.name}</strong>
@@ -36,6 +36,12 @@ function OverlayHeaderStats({ view }: { view: GameView }) {
<span></span>
<strong>{activeQuestCount}</strong>
</article>
<article className="overlay-summary-card">
<span></span>
<strong>
{view.player.storageUsage}/{view.player.storageCapacity}
</strong>
</article>
</div>
);
}
@@ -56,21 +62,73 @@ function InventoryOverlay({
<OverlayHeaderStats view={view} />
<div className="system-overlay-grid inventory-overlay-grid">
<section className="subpanel overlay-main-panel">
<PanelHeader title="Inventory Ledger" subtitle="完整物资、装备与使用入口" compact />
<div className="inventory-list overlay-list">
{view.player.inventory.map((item) => (
<InventoryCard
key={item.itemId}
busy={isWorking}
item={item}
onEquip={() => onSendAction({ type: 'equip-item', itemId: item.itemId })}
onUnequip={() => onSendAction({ type: 'unequip-item', slot: item.equipSlot! })}
onUse={() => onSendAction({ type: 'use-item', itemId: item.itemId })}
/>
))}
</div>
</section>
<div className="overlay-main-stack">
<section className="subpanel overlay-main-panel">
<PanelHeader title="Field Bag" subtitle="随身背包,服务当前决策与战斗" compact />
<div className="inventory-list overlay-list">
{view.player.inventory.map((item) => (
<InventoryCard
key={item.itemId}
busy={isWorking}
extraActions={
view.player.storageAccessible && !item.equipped
? [
{
label: '存入仓储',
onClick: () =>
onSendAction({ type: 'stash-item', itemId: item.itemId, count: item.count }),
tone: 'secondary',
},
]
: undefined
}
item={item}
onEquip={() => onSendAction({ type: 'equip-item', itemId: item.itemId })}
onUnequip={() => onSendAction({ type: 'unequip-item', slot: item.equipSlot! })}
onUse={() => onSendAction({ type: 'use-item', itemId: item.itemId })}
/>
))}
</div>
</section>
<section className="subpanel overlay-main-panel">
<PanelHeader
title="Shelter Storage"
subtitle={
view.player.storageAccessible
? `避难所仓储 ${view.player.storageUsage}/${view.player.storageCapacity}`
: '当前不在避难所,仓储已锁定'
}
compact
/>
{view.player.storageAccessible ? (
view.player.storage.length ? (
<div className="inventory-list overlay-list">
{view.player.storage.map((item) => (
<InventoryCard
key={`storage-${item.itemId}`}
busy={isWorking}
extraActions={[
{
label: '取回背包',
onClick: () =>
onSendAction({ type: 'retrieve-item', itemId: item.itemId, count: item.count }),
tone: 'primary',
},
]}
footerText={`${item.type} · 体积 ${item.volume} · 避难所仓储`}
item={item}
/>
))}
</div>
) : (
<EmptyState text="仓储目前是空的。把低频物资和备用装备放回去,可以减轻出行背包压力。" />
)
) : (
<EmptyState text="只有回到 Shelter-7 后,才能访问避难所仓储和长期积累的物资。" />
)}
</section>
</div>
<div className="overlay-side-stack">
<section className="subpanel overlay-side-panel">
@@ -89,15 +147,15 @@ function InventoryOverlay({
</section>
<section className="subpanel overlay-side-panel">
<PanelHeader title="Bag Rules" subtitle="背包系统当前规则摘要" compact />
<PanelHeader title="Storage Rules" subtitle="随身背包与基地仓储的分层规则" compact />
<div className="overlay-note-stack">
<article className="overlay-note-card">
<strong></strong>
<p></p>
<p>线</p>
</article>
<article className="overlay-note-card">
<strong>使</strong>
<p></p>
<p></p>
</article>
</div>
</section>
@@ -1,5 +1,5 @@
import type { GameView } from '@tinywaste/game-core';
import { getAlertCount, getTimeLabels } from '../hudModel';
import { getAlertCount, getDominantAlert, getTimeLabels } from '../hudModel';
import { STAT_LABELS, STAT_ORDER } from '../uiAssets';
import { StatMeter } from './StatMeter';
@@ -16,6 +16,13 @@ export function TopHud({
}) {
const { dayLabel, clockLabel } = getTimeLabels(view.header.timeLabel);
const alertCount = getAlertCount(view);
const dominantAlert = getDominantAlert(view);
const alertToneClass =
dominantAlert?.tier === 'strained'
? 'warn'
: dominantAlert && dominantAlert.tier !== 'safe'
? 'danger'
: 'safe';
return (
<header className="top-hud">
@@ -46,6 +53,9 @@ export function TopHud({
<div className="top-actions">
<span className="hud-chip safe">{view.header.riskLabel}</span>
<span className={`hud-chip ${alertToneClass}`}>
{dominantAlert ? `${dominantAlert.label} ${dominantAlert.summary}` : '状态稳定'}
</span>
<span className="hud-chip"> {accountName}</span>
<span className={`hud-chip ${alertCount ? 'accent' : ''}`}>
{alertCount ? `警报 ${alertCount}` : '链路稳定'}
@@ -9,12 +9,16 @@ export function InventoryCard({
onUse,
onEquip,
onUnequip,
extraActions,
footerText,
}: {
item: InventoryViewEntry;
busy: boolean;
onUse: () => void;
onEquip: () => void;
onUnequip: () => void;
onUse?: () => void;
onEquip?: () => void;
onUnequip?: () => void;
extraActions?: { label: string; onClick: () => void; tone?: 'primary' | 'secondary' }[];
footerText?: string;
}) {
const art = getItemArt(item.itemId, item.type);
@@ -27,17 +31,15 @@ export function InventoryCard({
<span>x{item.count}</span>
</div>
<p>{item.desc}</p>
<small>
{item.type} · {item.volume} · {getInventoryStateLabel(item)}
</small>
<small>{footerText ?? `${item.type} · 体积 ${item.volume} · ${getInventoryStateLabel(item)}`}</small>
</div>
<div className="inventory-actions">
{item.canUse ? (
{item.canUse && onUse ? (
<button className="small-button" onClick={onUse} disabled={busy}>
使
</button>
) : null}
{item.equipSlot ? (
{item.equipSlot && onEquip && onUnequip ? (
<button
className="small-button secondary"
onClick={item.equipped ? onUnequip : onEquip}
@@ -46,6 +48,16 @@ export function InventoryCard({
{item.equipped ? '卸下' : '装备'}
</button>
) : null}
{extraActions?.map((action) => (
<button
key={`${item.itemId}-${action.label}`}
className={action.tone === 'primary' ? 'small-button' : 'small-button secondary'}
onClick={action.onClick}
disabled={busy}
>
{action.label}
</button>
))}
</div>
</article>
);
+110 -23
View File
@@ -1,5 +1,5 @@
import type { EdgeView, GameView, InventoryViewEntry, QuestView } from '@tinywaste/game-core';
import { getEquipmentArt, getItemArt } from './uiAssets';
import { getEquipmentArt, getItemArt, getStatusArt } from './uiAssets';
export interface EquipmentPreviewEntry {
id: string;
@@ -30,14 +30,45 @@ export function getAlertCount(view: GameView) {
return Number(Boolean(view.pendingEvent)) + Number(Boolean(view.combat));
}
export function getDominantAlert(view: GameView) {
return view.survival.alerts[0] ?? null;
}
export function getCurrentPlace(view: GameView) {
return view.map.places.find((place) => place.current) ?? null;
}
export function getLatestLog(view: GameView) {
return view.logs[0] ?? null;
}
export function getVisibleInventory(view: GameView, limit = 6) {
return view.player.inventory.slice(0, limit);
}
export function getQuickUseItems(view: GameView, limit = 6) {
const priority = {
water: 0,
food: 1,
medicine: 2,
tool: 3,
material: 4,
weapon: 5,
armor: 6,
ammo: 7,
quest: 8,
} as const;
return [...view.player.inventory]
.filter((item) => item.canUse)
.sort((left, right) => {
const priorityDelta = priority[left.type] - priority[right.type];
if (priorityDelta !== 0) return priorityDelta;
return right.count - left.count;
})
.slice(0, limit);
}
export function getObjectiveRows(view: GameView) {
return view.quests
.filter((quest) => quest.state === 'active' || quest.state === 'completed')
@@ -55,6 +86,10 @@ export function getObjectiveRows(view: GameView) {
});
}
export function getPrimaryObjective(view: GameView) {
return getObjectiveRows(view)[0] ?? null;
}
export function getAttributeRows(view: GameView) {
const { attributes } = view.player;
@@ -68,35 +103,87 @@ export function getAttributeRows(view: GameView) {
}
export function getStatusEffects(view: GameView) {
const { stats, maxStats } = view.player;
const effects: { id: string; label: string; detail: string; tone: 'good' | 'warn' | 'danger' }[] = [];
if (stats.life / maxStats.life <= 0.55) {
effects.push({ id: 'life', label: '创伤', detail: '生命处于低位', tone: 'danger' });
}
if (stats.thirst / maxStats.thirst <= 0.5) {
effects.push({ id: 'thirst', label: '脱水', detail: '口渴已压缩行动余量', tone: 'warn' });
}
if (stats.hunger / maxStats.hunger <= 0.5) {
effects.push({ id: 'hunger', label: '饥饿', detail: '需要补充稳定热量', tone: 'warn' });
}
if (stats.energy / maxStats.energy <= 0.45) {
effects.push({ id: 'energy', label: '疲劳', detail: '精力不足,适合休整', tone: 'warn' });
}
if (stats.sanity / maxStats.sanity <= 0.45) {
effects.push({ id: 'sanity', label: '精神波动', detail: '理智偏低,事件风险提升', tone: 'danger' });
}
if (stats.radiation / maxStats.radiation >= 0.32) {
effects.push({ id: 'radiation', label: '污染累积', detail: '辐射正在侵蚀身体', tone: 'danger' });
}
const effects = view.survival.alerts.map((entry) => ({
id: entry.stat,
label: `${entry.label} · ${entry.summary}`,
detail: entry.impact,
recovery: entry.recovery,
art: getStatusArt(entry.stat),
tone:
entry.tier === 'strained' ? ('warn' as const) : entry.tier === 'safe' ? ('good' as const) : ('danger' as const),
}));
if (!effects.length) {
effects.push({ id: 'stable', label: '稳定', detail: '当前幸存者状态平稳', tone: 'good' });
return [
{
id: 'stable',
label: '稳定',
detail: '当前幸存者状态平稳,可以继续规划下一步行动。',
recovery: '保持补给循环与节奏即可。',
art: getStatusArt('life'),
tone: 'good' as const,
},
];
}
return effects.slice(0, 4);
}
export function getExpeditionStatus(view: GameView) {
const { stats, maxStats } = view.player;
const readiness = Math.round(
(stats.life / maxStats.life) * 34 +
(stats.energy / maxStats.energy) * 28 +
(stats.thirst / maxStats.thirst) * 20 +
(stats.hunger / maxStats.hunger) * 18,
);
const loadPercent = Math.round((view.player.inventoryUsage / view.player.inventoryCapacity) * 100);
const threatCount = view.survival.alerts.length;
return {
readiness,
loadPercent,
threatCount,
tone: readiness >= 74 && threatCount === 0 ? 'good' : readiness >= 48 ? 'warn' : 'danger',
};
}
export function getSurvivorMetrics(view: GameView) {
const protection = Math.min(
100,
Math.round(
(view.player.equipment.body ? 34 : 12) +
view.player.attributes.str * 5 +
(view.player.stats.life / view.player.maxStats.life) * 30,
),
);
const stealth = Math.min(
100,
Math.round(
view.player.attributes.agi * 8 +
view.player.attributes.per * 5 +
(view.player.stats.sanity / view.player.maxStats.sanity) * 20,
),
);
const mobility = Math.max(
0,
Math.min(
100,
Math.round(
view.player.attributes.agi * 7 +
(view.player.stats.energy / view.player.maxStats.energy) * 42 -
(view.player.inventoryUsage / view.player.inventoryCapacity) * 18,
),
),
);
return [
{ id: 'protection', label: '防护', value: protection },
{ id: 'stealth', label: '潜行', value: stealth },
{ id: 'mobility', label: '机动', value: mobility },
];
}
export function getSupplyCounts(view: GameView) {
const counts = {
water: 0,
+13
View File
@@ -27,6 +27,15 @@ export const STAT_LABELS: Record<string, string> = {
export const STAT_ORDER = ['life', 'hunger', 'thirst', 'energy', 'sanity', 'radiation'] as const;
const STATUS_ART: Record<string, string> = {
life: `${GENERATED_UI_ROOT}/status/life.png`,
hunger: `${GENERATED_UI_ROOT}/status/hunger.png`,
thirst: `${GENERATED_UI_ROOT}/status/thirst.png`,
energy: `${GENERATED_UI_ROOT}/status/energy.png`,
sanity: `${GENERATED_UI_ROOT}/status/sanity.png`,
radiation: `${GENERATED_UI_ROOT}/status/radiation.png`,
};
const ITEM_ART: Record<string, string> = {
water_dirty: `${GENERATED_UI_ROOT}/items/water-dirty.png`,
water_purified: `${GENERATED_UI_ROOT}/items/water-purified.png`,
@@ -88,6 +97,10 @@ export function getItemArt(itemId: string, itemType?: string) {
return ITEM_ART[itemId] ?? (itemType ? ITEM_TYPE_FALLBACK_ART[itemType] : undefined);
}
export function getStatusArt(statusId: string) {
return STATUS_ART[statusId];
}
export function getEquipmentArt(itemId?: string, slot?: EquipSlot | 'head' | 'back' | 'kit') {
if (itemId && ITEM_TO_EQUIPMENT_ART[itemId]) {
return ITEM_TO_EQUIPMENT_ART[itemId];