feat: 实现避难所仓储系统与生存状态HUD改进
- 新增避难所仓储功能,支持物品在随身背包与仓储间转移 - 添加生存状态图标资源与状态阈值告警系统 - 重构HUD界面,优化状态显示与操作流程 - 扩展游戏核心系统,增加仓储相关动作和校验 - 更新文档说明仓储规则与实现细节
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 370 KiB |
|
After Width: | Height: | Size: 382 KiB |
|
After Width: | Height: | Size: 392 KiB |
|
After Width: | Height: | Size: 404 KiB |
|
After Width: | Height: | Size: 393 KiB |
|
After Width: | Height: | Size: 395 KiB |
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||