feat: 实现避难所仓储系统与生存状态HUD改进
- 新增避难所仓储功能,支持物品在随身背包与仓储间转移 - 添加生存状态图标资源与状态阈值告警系统 - 重构HUD界面,优化状态显示与操作流程 - 扩展游戏核心系统,增加仓储相关动作和校验 - 更新文档说明仓储规则与实现细节
@@ -2,10 +2,6 @@ FROM node:20-bookworm-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
|
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];
|
||||
|
||||
@@ -93,6 +93,12 @@ Buff 必须具备可解释来源与可清除/可缓解路径:
|
||||
- 背包容量 `bagCapacity`:体积上限
|
||||
- 容器(箱子/仓库)容量 `storageCapacity`:体积上限
|
||||
|
||||
当前实现采用:
|
||||
|
||||
- `Field Bag`:在线行动时唯一权威随身背包
|
||||
- `Shelter Storage`:避难所内的长期仓储层
|
||||
- 主 HUD 只摘要显示随身容量,完整整理在独立背包弹层完成
|
||||
|
||||
设计理由:
|
||||
|
||||
- 体积比“格子”更适合废土资源管理的“取舍”表达
|
||||
@@ -108,6 +114,12 @@ Buff 必须具备可解释来源与可清除/可缓解路径:
|
||||
- 基地可修理(消耗材料 + 时间)
|
||||
- 野外可应急修理(更高成本/更低效率)
|
||||
|
||||
### 3.3.4 仓储访问规则
|
||||
|
||||
- 仓储不是全地图随时可开,而是地点约束能力
|
||||
- 当前版本仅在 `home / Shelter-7` 允许取放仓储
|
||||
- 配方在避难所内可以读取仓储材料,避免“物资明明囤着却不能做”的违和感
|
||||
|
||||
## 3.4 制作、烹饪、锻造与科研
|
||||
|
||||
### 3.4.1 配方数据结构(通用)
|
||||
@@ -328,4 +340,3 @@ stateDiagram-v2
|
||||
- 风险定价:高风险区域的补给更贵、材料更便宜
|
||||
|
||||
经济更完整设计见:[05-数值与经济.md](./05-%E6%95%B0%E5%80%BC%E4%B8%8E%E7%BB%8F%E6%B5%8E.md)
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ flowchart TB
|
||||
- 容量计算(体积/堆叠)
|
||||
- 移入移出、消耗、掉落
|
||||
- 装备槽与耐久更新
|
||||
- 随身背包与避难所仓储之间的转移
|
||||
|
||||
边界:
|
||||
|
||||
@@ -99,7 +100,15 @@ flowchart TB
|
||||
- 监听世界事件(到达地点、获得物品、完成战斗等)
|
||||
- 驱动任务事件池与奖励发放
|
||||
|
||||
### 8.3.7 ContentService(内容服务)
|
||||
### 8.3.7 ViewProjection(视图投影层)
|
||||
|
||||
职责:
|
||||
|
||||
- 将 `GameState` 投影成 HUD 可直接消费的 `GameView`
|
||||
- 产出生存阈值告警、仓储可访问性、战斗与事件视图
|
||||
- 保证 UI 不需要反向猜测领域规则
|
||||
|
||||
### 8.3.8 ContentService(内容服务)
|
||||
|
||||
职责:
|
||||
|
||||
@@ -107,6 +116,17 @@ flowchart TB
|
||||
- 提供按 ID 与按标签检索能力
|
||||
- 提供校验:唯一性、引用完整性、字段类型、循环依赖检查
|
||||
|
||||
### 8.3.9 当前代码映射
|
||||
|
||||
当前仓库中已经开始按模块边界落地:
|
||||
|
||||
- `packages/game-core/src/systems/inventory-system.ts`
|
||||
- `packages/game-core/src/systems/place-system.ts`
|
||||
- `packages/game-core/src/systems/effect-system.ts`
|
||||
- `packages/game-core/src/systems/survival-system.ts`
|
||||
- `packages/game-core/src/systems/quest-system.ts`
|
||||
- `packages/game-core/src/view-projection.ts`
|
||||
|
||||
## 8.4 数据与配置组织(建议)
|
||||
|
||||
### 8.4.1 文件结构建议
|
||||
@@ -182,4 +202,3 @@ flowchart TB
|
||||
- 存档迁移
|
||||
- 内容校验:
|
||||
- CI 中运行 Schema 校验与引用完整性检查
|
||||
|
||||
|
||||
@@ -94,8 +94,10 @@ UI 组件 <- Query Cache <- 最新 GameView
|
||||
- `apps/web/public/generated/ui/character-preview.png`:角色立绘
|
||||
- `apps/web/public/generated/ui/item-atlas.png`:物资图集原图
|
||||
- `apps/web/public/generated/ui/equipment-atlas.png`:装备图集原图
|
||||
- `apps/web/public/generated/ui/status-atlas.png`:生存状态图集原图
|
||||
- `apps/web/public/generated/ui/items/*`:切分后的物资图标
|
||||
- `apps/web/public/generated/ui/equipment/*`:切分后的装备槽素材
|
||||
- `apps/web/public/generated/ui/status/*`:切分后的生存状态图标
|
||||
|
||||
对应映射入口:
|
||||
|
||||
@@ -105,6 +107,7 @@ UI 组件 <- Query Cache <- 最新 GameView
|
||||
|
||||
- 地点图使用整图背景,服务于中央场景和左侧路线卡。
|
||||
- 物资与装备图使用 atlas + 裁切结果,服务于快捷栏、库存卡、角色槽位。
|
||||
- 生存状态图使用 atlas + 裁切结果,服务于状态效果卡和后续中断提示。
|
||||
- 所有引用都走 `uiAssets.ts`,避免组件里散落硬编码路径。
|
||||
|
||||
## 12.6 HUD 信息职责
|
||||
@@ -138,7 +141,7 @@ UI 组件 <- Query Cache <- 最新 GameView
|
||||
- 角色立绘与槽位矩阵
|
||||
- 属性与状态效果
|
||||
- 快捷栏
|
||||
- 完整库存管理
|
||||
- 完整库存与避难所仓储管理
|
||||
- 任务与休整
|
||||
|
||||
### 12.6.5 BottomDock
|
||||
|
||||
@@ -95,7 +95,7 @@ TinyWaste Online 不是网页式信息展示项目,而是一款:
|
||||
|
||||
中频系统改用面板弹出:
|
||||
|
||||
- 背包
|
||||
- 背包 / 避难所仓储
|
||||
- 蓝图
|
||||
- 任务
|
||||
- 完整日志
|
||||
@@ -151,6 +151,12 @@ TinyWaste Online 不是网页式信息展示项目,而是一款:
|
||||
|
||||
当前项目已经有 `Quick Slots + Inventory` 雏形,下一步建议补 `Shelter Storage`。
|
||||
|
||||
当前实现进度:
|
||||
|
||||
- 已经落地 `Field Bag + Shelter Storage` 双层库存
|
||||
- 仅位于 `home / Shelter-7` 时可访问避难所仓储
|
||||
- 避难所内制作会同时读取背包与仓储材料,避免资源可见却不可用
|
||||
|
||||
### 13.6.3 物品状态
|
||||
|
||||
每个背包物品后续建议支持:
|
||||
@@ -275,17 +281,20 @@ TinyWaste Online 不是网页式信息展示项目,而是一款:
|
||||
|
||||
## 13.11 当前代码层面的下一批重点
|
||||
|
||||
1. 把 `engine.ts` 继续拆成子模块:
|
||||
- `time-system`
|
||||
1. 当前已拆出的运行时模块:
|
||||
- `inventory-system`
|
||||
- `combat-system`
|
||||
- `event-system`
|
||||
- `place-system`
|
||||
- `effect-system`
|
||||
- `survival-system`
|
||||
- `quest-system`
|
||||
- `view-projection`
|
||||
2. 为状态阈值、背包容量、战斗结算分别补单元测试
|
||||
3. 引入基地仓库与背包转移逻辑
|
||||
4. 为背包、任务、蓝图面板补筛选与分类
|
||||
5. 为状态系统引入更明确的阶段化惩罚定义
|
||||
2. 下一轮继续拆出:
|
||||
- `combat-system`
|
||||
- `event-system`
|
||||
3. 为状态阈值、背包容量、战斗结算分别补单元测试
|
||||
4. 继续强化基地仓储:分类、批量整理、仓储专属配方
|
||||
5. 为背包、任务、蓝图面板补筛选与分类
|
||||
6. 为状态系统引入更明确的阶段化惩罚定义
|
||||
|
||||
## 13.12 结论
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ describe('TinyWaste gameplay loop', () => {
|
||||
|
||||
expect(state.player.placeId).toBe('home');
|
||||
expect(state.player.inventory.some((entry) => entry.itemId === 'shiv')).toBe(true);
|
||||
expect(state.player.storage.some((entry) => entry.itemId === 'leather_coat')).toBe(true);
|
||||
expect(state.player.quests.q_tutorial_water.state).toBe('active');
|
||||
});
|
||||
|
||||
@@ -42,4 +43,48 @@ describe('TinyWaste gameplay loop', () => {
|
||||
expect(view.header.placeName).toBe('近郊土路');
|
||||
expect(view.map.places.find((place) => place.current)?.id).toBe('roadside');
|
||||
});
|
||||
|
||||
it('moves items between field bag and shelter storage while at home', () => {
|
||||
const state = createNewGameState(gameContent, '测试幸存者');
|
||||
const initialBagWater = state.player.inventory.find((entry) => entry.itemId === 'water_dirty')?.count ?? 0;
|
||||
const initialStorageWater =
|
||||
state.player.storage.find((entry) => entry.itemId === 'water_dirty')?.count ?? 0;
|
||||
|
||||
const stashed = applyGameAction(state, gameContent, {
|
||||
type: 'stash-item',
|
||||
itemId: 'water_dirty',
|
||||
count: 1,
|
||||
});
|
||||
|
||||
const stashView = buildGameView(stashed.state, gameContent);
|
||||
expect(stashView.player.storageAccessible).toBe(true);
|
||||
expect(
|
||||
stashed.state.player.inventory.find((entry) => entry.itemId === 'water_dirty')?.count ?? 0,
|
||||
).toBe(initialBagWater - 1);
|
||||
expect(
|
||||
stashed.state.player.storage.find((entry) => entry.itemId === 'water_dirty')?.count ?? 0,
|
||||
).toBe(initialStorageWater + 1);
|
||||
|
||||
const retrieved = applyGameAction(stashed.state, gameContent, {
|
||||
type: 'retrieve-item',
|
||||
itemId: 'water_dirty',
|
||||
count: 1,
|
||||
});
|
||||
|
||||
expect(
|
||||
retrieved.state.player.inventory.find((entry) => entry.itemId === 'water_dirty')?.count ?? 0,
|
||||
).toBe(initialBagWater);
|
||||
});
|
||||
|
||||
it('builds survival alerts from threshold-based status states', () => {
|
||||
const state = createNewGameState(gameContent, '测试幸存者');
|
||||
state.player.stats.thirst = 20;
|
||||
state.player.stats.energy = 18;
|
||||
|
||||
const view = buildGameView(state, gameContent);
|
||||
|
||||
expect(view.survival.alerts.some((entry) => entry.stat === 'thirst')).toBe(true);
|
||||
expect(view.survival.alerts.some((entry) => entry.stat === 'energy')).toBe(true);
|
||||
expect(view.survival.headline).toContain('告警');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { GameContent } from '@tinywaste/game-core';
|
||||
export const gameContent: GameContent = {
|
||||
version: '0.1.0',
|
||||
inventoryCapacity: 36,
|
||||
storageCapacity: 84,
|
||||
items: {
|
||||
water_dirty: {
|
||||
id: 'water_dirty',
|
||||
|
||||
@@ -1,293 +1,40 @@
|
||||
import { createRandomSource } from './rng';
|
||||
import type {
|
||||
ActionPreview,
|
||||
ActionResult,
|
||||
CombatState,
|
||||
ConditionRule,
|
||||
EdgeView,
|
||||
Effect,
|
||||
EnemyDefinition,
|
||||
GameAction,
|
||||
GameContent,
|
||||
GameState,
|
||||
GameView,
|
||||
InventoryEntry,
|
||||
InventoryViewEntry,
|
||||
LogEntry,
|
||||
LogTone,
|
||||
PendingEventView,
|
||||
PlaceActionDefinition,
|
||||
PlaceDefinition,
|
||||
PlayerState,
|
||||
QuestDefinition,
|
||||
QuestRuntimeState,
|
||||
QuestStep,
|
||||
QuestView,
|
||||
RecipeDefinition,
|
||||
RecipeView,
|
||||
SaveMeta,
|
||||
StatLine,
|
||||
StatusId,
|
||||
TradeOfferView,
|
||||
WorldState,
|
||||
} from './types';
|
||||
import { applyEffects, applyStatChange } from './systems/effect-system';
|
||||
import {
|
||||
addItem,
|
||||
addItemToStorage,
|
||||
getItemCount,
|
||||
getItemCountFromEntries,
|
||||
isItemEquipped,
|
||||
removeItem,
|
||||
removeItemFromEntries,
|
||||
removeItemFromStorage,
|
||||
} from './systems/inventory-system';
|
||||
import { pushLog } from './systems/log-system';
|
||||
import { ensurePlaceRuntime, getConditionFailure, getCurrentPlace, hasConditions } from './systems/place-system';
|
||||
import { reconcileQuests } from './systems/quest-system';
|
||||
import { clamp, cloneGameState } from './systems/shared';
|
||||
import { advanceTime, canAccessShelterStorage, getStatusPenalty } from './systems/survival-system';
|
||||
|
||||
const SAVE_VERSION = 1;
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
|
||||
const getEdge = (content: GameContent, from: string, to: string) =>
|
||||
content.edges.find((edge) => edge.from === from && edge.to === to);
|
||||
|
||||
const copyStats = (stats: StatLine): StatLine => ({ ...stats });
|
||||
const cloneGameState = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
|
||||
|
||||
const formatTime = (totalMinutes: number) => {
|
||||
const day = Math.floor(totalMinutes / (24 * 60)) + 1;
|
||||
const minuteOfDay = totalMinutes % (24 * 60);
|
||||
const hours = Math.floor(minuteOfDay / 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
const minutes = (minuteOfDay % 60).toString().padStart(2, '0');
|
||||
return `Day ${day} · ${hours}:${minutes}`;
|
||||
const withUpdatedMeta = (state: GameState) => {
|
||||
state.meta.updatedAt = new Date().toISOString();
|
||||
};
|
||||
|
||||
const dangerLabel = (danger: number) => {
|
||||
if (danger <= 2) return '低风险';
|
||||
if (danger <= 4) return '中风险';
|
||||
if (danger <= 6) return '高风险';
|
||||
return '致命风险';
|
||||
};
|
||||
|
||||
const createLogEntry = (state: GameState, message: string, tone: LogTone = 'info'): LogEntry => ({
|
||||
id: `log_${state.world.logs.length + 1}_${state.world.time.totalMinutes}`,
|
||||
minute: state.world.time.totalMinutes,
|
||||
tone,
|
||||
message,
|
||||
});
|
||||
|
||||
const pushLog = (state: GameState, message: string, tone: LogTone = 'info') => {
|
||||
state.world.logs.push(createLogEntry(state, message, tone));
|
||||
if (state.world.logs.length > 120) {
|
||||
state.world.logs = state.world.logs.slice(-120);
|
||||
}
|
||||
};
|
||||
|
||||
const getItemCount = (player: PlayerState, itemId: string) =>
|
||||
player.inventory
|
||||
.filter((entry) => entry.itemId === itemId)
|
||||
.reduce((total, entry) => total + entry.count, 0);
|
||||
|
||||
const getInventoryUsage = (state: GameState, content: GameContent) =>
|
||||
state.player.inventory.reduce((total, entry) => {
|
||||
const item = content.items[entry.itemId];
|
||||
return total + item.volume * entry.count;
|
||||
}, 0);
|
||||
|
||||
const sortInventory = (inventory: InventoryEntry[]) =>
|
||||
[...inventory].sort((a, b) => a.itemId.localeCompare(b.itemId));
|
||||
|
||||
const removeItem = (player: PlayerState, itemId: string, count: number) => {
|
||||
let remaining = count;
|
||||
player.inventory = player.inventory.flatMap((entry) => {
|
||||
if (entry.itemId !== itemId || remaining <= 0) {
|
||||
return [entry];
|
||||
}
|
||||
|
||||
if (entry.count <= remaining) {
|
||||
remaining -= entry.count;
|
||||
return [];
|
||||
}
|
||||
|
||||
const updated = { ...entry, count: entry.count - remaining };
|
||||
remaining = 0;
|
||||
return [updated];
|
||||
});
|
||||
|
||||
return remaining === 0;
|
||||
};
|
||||
|
||||
const addItem = (state: GameState, content: GameContent, itemId: string, count: number) => {
|
||||
const item = content.items[itemId];
|
||||
let added = 0;
|
||||
|
||||
while (added < count) {
|
||||
const currentUsage = getInventoryUsage(state, content);
|
||||
if (currentUsage + item.volume > content.inventoryCapacity) {
|
||||
break;
|
||||
}
|
||||
|
||||
const stack = state.player.inventory.find(
|
||||
(entry) => entry.itemId === itemId && entry.count < item.stackLimit,
|
||||
);
|
||||
|
||||
if (stack) {
|
||||
stack.count += 1;
|
||||
} else {
|
||||
state.player.inventory.push({ itemId, count: 1, durability: null });
|
||||
}
|
||||
|
||||
added += 1;
|
||||
}
|
||||
|
||||
state.player.inventory = sortInventory(state.player.inventory);
|
||||
return added;
|
||||
};
|
||||
|
||||
const hasConditions = (state: GameState, conditions: ConditionRule[] | undefined) => {
|
||||
if (!conditions || conditions.length === 0) return true;
|
||||
return conditions.every((condition) => {
|
||||
switch (condition.kind) {
|
||||
case 'flag':
|
||||
return (state.world.flags[condition.flag] ?? false) === (condition.value ?? true);
|
||||
case 'has-item':
|
||||
return getItemCount(state.player, condition.itemId) >= condition.count;
|
||||
case 'quest-state':
|
||||
return state.player.quests[condition.questId]?.state === condition.state;
|
||||
case 'place':
|
||||
return state.player.placeId === condition.placeId;
|
||||
case 'day-at-least':
|
||||
return state.world.time.day >= condition.day;
|
||||
case 'stat-at-most':
|
||||
return state.player.stats[condition.stat] <= condition.value;
|
||||
case 'stat-at-least':
|
||||
return state.player.stats[condition.stat] >= condition.value;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getConditionFailure = (condition: ConditionRule | undefined, content: GameContent) => {
|
||||
if (!condition) return undefined;
|
||||
|
||||
switch (condition.kind) {
|
||||
case 'flag':
|
||||
return `需要触发世界标记 ${condition.flag}`;
|
||||
case 'has-item':
|
||||
return `缺少 ${content.items[condition.itemId]?.name ?? condition.itemId} x${condition.count}`;
|
||||
case 'quest-state':
|
||||
return `需要任务条件:${condition.questId}`;
|
||||
case 'place':
|
||||
return `需要前往 ${content.places[condition.placeId]?.name ?? condition.placeId}`;
|
||||
case 'day-at-least':
|
||||
return `至少生存到 Day ${condition.day}`;
|
||||
case 'stat-at-most':
|
||||
return `${condition.stat} 需要不高于 ${condition.value}`;
|
||||
case 'stat-at-least':
|
||||
return `${condition.stat} 需要不低于 ${condition.value}`;
|
||||
default:
|
||||
return '条件不足';
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentPlace = (state: GameState, content: GameContent) => content.places[state.player.placeId];
|
||||
|
||||
const ensurePlaceRuntime = (state: GameState, content: GameContent, placeId: string) => {
|
||||
if (state.world.places[placeId]) {
|
||||
return state.world.places[placeId];
|
||||
}
|
||||
|
||||
const place = content.places[placeId];
|
||||
const stocks: Record<string, number> = {};
|
||||
for (const action of place.actions) {
|
||||
if (action.stock) {
|
||||
stocks[action.id] = action.stock.initial;
|
||||
}
|
||||
}
|
||||
|
||||
state.world.places[placeId] = {
|
||||
placeId,
|
||||
heat: 0,
|
||||
stocks,
|
||||
lastRefreshMinute: state.world.time.totalMinutes,
|
||||
visited: placeId === state.player.placeId,
|
||||
};
|
||||
|
||||
return state.world.places[placeId];
|
||||
};
|
||||
|
||||
const applyStatChange = (state: GameState, stat: StatusId, amount: number) => {
|
||||
const max = state.player.maxStats[stat];
|
||||
state.player.stats[stat] = clamp(state.player.stats[stat] + amount, 0, max);
|
||||
};
|
||||
|
||||
const applyEffects = (state: GameState, content: GameContent, effects: Effect[]) => {
|
||||
for (const effect of effects) {
|
||||
switch (effect.type) {
|
||||
case 'add-item': {
|
||||
const added = addItem(state, content, effect.itemId, effect.count);
|
||||
const item = content.items[effect.itemId];
|
||||
if (added > 0) {
|
||||
pushLog(state, `获得 ${item.name} x${added}`, 'good');
|
||||
}
|
||||
if (added < effect.count) {
|
||||
pushLog(state, `${item.name} 有 ${effect.count - added} 件因为背包已满而丢失`, 'warn');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'remove-item':
|
||||
removeItem(state.player, effect.itemId, effect.count);
|
||||
pushLog(state, `消耗 ${content.items[effect.itemId].name} x${effect.count}`, 'info');
|
||||
break;
|
||||
case 'change-stat':
|
||||
applyStatChange(state, effect.stat, effect.amount);
|
||||
break;
|
||||
case 'add-buff': {
|
||||
const existing = state.player.buffs.find((buff) => buff.buffId === effect.buffId);
|
||||
if (existing) {
|
||||
existing.remainingMin = Math.max(existing.remainingMin, effect.durationMin);
|
||||
existing.stacks += effect.stacks ?? 1;
|
||||
} else {
|
||||
state.player.buffs.push({
|
||||
buffId: effect.buffId,
|
||||
remainingMin: effect.durationMin,
|
||||
stacks: effect.stacks ?? 1,
|
||||
});
|
||||
}
|
||||
pushLog(state, `获得状态:${content.buffs[effect.buffId].name}`, 'good');
|
||||
break;
|
||||
}
|
||||
case 'remove-buff':
|
||||
state.player.buffs = state.player.buffs.filter((buff) => buff.buffId !== effect.buffId);
|
||||
break;
|
||||
case 'set-flag':
|
||||
state.world.flags[effect.flag] = effect.value;
|
||||
break;
|
||||
case 'unlock-recipe':
|
||||
if (!state.player.knownRecipes.includes(effect.recipeId)) {
|
||||
state.player.knownRecipes.push(effect.recipeId);
|
||||
pushLog(state, `解锁配方:${content.recipes[effect.recipeId].name}`, 'good');
|
||||
}
|
||||
break;
|
||||
case 'activate-quest':
|
||||
if (state.player.quests[effect.questId]) {
|
||||
state.player.quests[effect.questId].state = 'active';
|
||||
pushLog(state, `任务已激活:${content.quests[effect.questId].title}`, 'good');
|
||||
}
|
||||
break;
|
||||
case 'start-combat':
|
||||
if (!state.world.activeCombat) {
|
||||
const enemy = content.enemies[effect.enemyId];
|
||||
state.world.activeCombat = createCombat(enemy);
|
||||
pushLog(state, `遭遇敌人:${enemy.name}`, 'combat');
|
||||
}
|
||||
break;
|
||||
case 'log':
|
||||
pushLog(state, effect.message, effect.tone ?? 'info');
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const createCombat = (enemy: EnemyDefinition): CombatState => ({
|
||||
enemyId: enemy.id,
|
||||
enemyLife: enemy.life,
|
||||
distance: Math.max(2, enemy.range),
|
||||
round: 1,
|
||||
playerGuarding: false,
|
||||
enemyGuarding: false,
|
||||
log: [{ round: 1, message: `${enemy.name} 出现了。` }],
|
||||
rewardClaimed: false,
|
||||
});
|
||||
|
||||
const getPlayerWeapon = (state: GameState, content: GameContent) => {
|
||||
const equipped = state.player.equipment.weapon;
|
||||
if (equipped) return content.items[equipped];
|
||||
@@ -300,112 +47,6 @@ const getPlayerArmor = (state: GameState, content: GameContent) => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getStatusPenalty = (state: GameState) => {
|
||||
let hitPenalty = 0;
|
||||
let riskBonus = 0;
|
||||
let armorPenalty = 0;
|
||||
|
||||
if (state.player.stats.energy < 30) hitPenalty -= 0.1;
|
||||
if (state.player.stats.thirst < 30) {
|
||||
hitPenalty -= 0.08;
|
||||
riskBonus += 0.1;
|
||||
}
|
||||
if (state.player.stats.hunger < 30) hitPenalty -= 0.05;
|
||||
if (state.player.stats.sanity < 25) hitPenalty -= 0.06;
|
||||
if (state.player.stats.radiation > 60) armorPenalty -= 1;
|
||||
|
||||
return { hitPenalty, riskBonus, armorPenalty };
|
||||
};
|
||||
|
||||
const updateTimeFields = (state: GameState) => {
|
||||
state.world.time.day = Math.floor(state.world.time.totalMinutes / (24 * 60)) + 1;
|
||||
state.world.time.minuteOfDay = state.world.time.totalMinutes % (24 * 60);
|
||||
};
|
||||
|
||||
const refreshPlaceStocks = (state: GameState, content: GameContent, deltaMin: number) => {
|
||||
for (const place of Object.values(content.places)) {
|
||||
const runtime = ensurePlaceRuntime(state, content, place.id);
|
||||
runtime.heat = Math.max(0, runtime.heat - deltaMin / 120);
|
||||
|
||||
for (const action of place.actions) {
|
||||
if (!action.stock) continue;
|
||||
const current = runtime.stocks[action.id] ?? action.stock.initial;
|
||||
const growth = Math.floor(deltaMin / action.stock.refreshMin) * action.stock.amountPerRefresh;
|
||||
runtime.stocks[action.id] = clamp(current + growth, 0, action.stock.max);
|
||||
}
|
||||
|
||||
runtime.lastRefreshMinute = state.world.time.totalMinutes;
|
||||
}
|
||||
};
|
||||
|
||||
const tickBuffs = (state: GameState, content: GameContent, minutes: number) => {
|
||||
state.player.buffs = state.player.buffs.flatMap((buff) => {
|
||||
const definition = content.buffs[buff.buffId];
|
||||
const remaining = buff.remainingMin - minutes;
|
||||
if (definition.modifiers) {
|
||||
for (const [stat, value] of Object.entries(definition.modifiers) as [StatusId, number][]) {
|
||||
applyStatChange(state, stat, value * minutes);
|
||||
}
|
||||
}
|
||||
|
||||
if (remaining <= 0) {
|
||||
pushLog(state, `${definition.name} 已结束`, 'info');
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ ...buff, remainingMin: remaining }];
|
||||
});
|
||||
};
|
||||
|
||||
const handleThresholdDamage = (state: GameState) => {
|
||||
if (state.player.stats.thirst <= 0) {
|
||||
applyStatChange(state, 'life', -6);
|
||||
} else if (state.player.stats.thirst < 10) {
|
||||
applyStatChange(state, 'life', -2);
|
||||
}
|
||||
|
||||
if (state.player.stats.hunger <= 0) {
|
||||
applyStatChange(state, 'life', -4);
|
||||
}
|
||||
|
||||
if (state.player.stats.sanity <= 0) {
|
||||
state.world.gameOver = true;
|
||||
state.world.deathReason = '理智崩溃';
|
||||
}
|
||||
|
||||
if (state.player.stats.life <= 0) {
|
||||
state.world.gameOver = true;
|
||||
state.world.deathReason = '生命耗尽';
|
||||
}
|
||||
};
|
||||
|
||||
const advanceTime = (state: GameState, content: GameContent, minutes: number, reason: string) => {
|
||||
const place = getCurrentPlace(state, content);
|
||||
const modifiers = place.modifiers ?? {};
|
||||
const hungerDecay = 0.03 + (modifiers.hunger ?? 0);
|
||||
const thirstDecay = 0.05 + (modifiers.thirst ?? 0);
|
||||
const energyDecay = 0.02 + (modifiers.energy ?? 0);
|
||||
const sanityDecay = 0.01 + (modifiers.sanity ?? 0);
|
||||
const radiationGain = Math.max(0, modifiers.radiation ?? 0);
|
||||
|
||||
applyStatChange(state, 'hunger', -hungerDecay * minutes);
|
||||
applyStatChange(state, 'thirst', -thirstDecay * minutes);
|
||||
applyStatChange(state, 'energy', -energyDecay * minutes);
|
||||
applyStatChange(state, 'sanity', -sanityDecay * minutes);
|
||||
applyStatChange(state, 'radiation', radiationGain * minutes);
|
||||
tickBuffs(state, content, minutes);
|
||||
|
||||
state.world.time.totalMinutes += minutes;
|
||||
updateTimeFields(state);
|
||||
refreshPlaceStocks(state, content, minutes);
|
||||
handleThresholdDamage(state);
|
||||
|
||||
pushLog(state, `${reason},时间推进 ${minutes} 分钟`, 'info');
|
||||
};
|
||||
|
||||
const getEdge = (content: GameContent, from: string, to: string) =>
|
||||
content.edges.find((edge) => edge.from === from && edge.to === to);
|
||||
|
||||
const rollLoot = (state: GameState, content: GameContent, lootTable: PlaceActionDefinition['lootTable']) => {
|
||||
if (!lootTable || lootTable.length === 0) return;
|
||||
const rng = createRandomSource(state.meta.rngState);
|
||||
@@ -439,50 +80,27 @@ const triggerEventFromPool = (
|
||||
state.world.pendingEvent = { eventId: selected.id, source };
|
||||
};
|
||||
|
||||
const getQuestStepDone = (state: GameState, step: QuestStep) => {
|
||||
switch (step.kind) {
|
||||
case 'reach-place':
|
||||
return state.player.placeId === step.placeId;
|
||||
case 'have-item':
|
||||
return getItemCount(state.player, step.itemId) >= step.count;
|
||||
case 'flag':
|
||||
return (state.world.flags[step.flag] ?? false) === (step.value ?? true);
|
||||
case 'survive-day':
|
||||
return state.world.time.day >= step.day;
|
||||
default:
|
||||
return false;
|
||||
const getCraftingOwnedCount = (state: GameState, itemId: string) => {
|
||||
const bagCount = getItemCount(state.player, itemId);
|
||||
if (!canAccessShelterStorage(state)) {
|
||||
return bagCount;
|
||||
}
|
||||
|
||||
return bagCount + getItemCountFromEntries(state.player.storage, itemId);
|
||||
};
|
||||
|
||||
const reconcileQuests = (state: GameState, content: GameContent) => {
|
||||
for (const quest of Object.values(content.quests)) {
|
||||
const runtime = state.player.quests[quest.id];
|
||||
const consumeCraftingItems = (state: GameState, itemId: string, count: number) => {
|
||||
const bagResult = removeItemFromEntries(state.player.inventory, itemId, count);
|
||||
state.player.inventory = bagResult.inventory;
|
||||
|
||||
if (runtime.state === 'locked' && hasConditions(state, quest.autoStart)) {
|
||||
runtime.state = 'active';
|
||||
pushLog(state, `接到任务:${quest.title}`, 'good');
|
||||
}
|
||||
|
||||
if (runtime.state !== 'active') continue;
|
||||
|
||||
const step = quest.steps[runtime.currentStepIndex];
|
||||
if (!step) continue;
|
||||
|
||||
if (getQuestStepDone(state, step)) {
|
||||
runtime.currentStepIndex += 1;
|
||||
pushLog(state, `任务推进:${quest.title} - ${step.text}`, 'good');
|
||||
|
||||
if (runtime.currentStepIndex >= quest.steps.length) {
|
||||
runtime.state = 'completed';
|
||||
pushLog(state, `任务完成:${quest.title}`, 'good');
|
||||
applyEffects(state, content, quest.rewards);
|
||||
}
|
||||
}
|
||||
const remaining = count - bagResult.removedCount;
|
||||
if (remaining <= 0) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const withUpdatedMeta = (state: GameState) => {
|
||||
state.meta.updatedAt = new Date().toISOString();
|
||||
const storageResult = removeItemFromEntries(state.player.storage, itemId, remaining);
|
||||
state.player.storage = storageResult.inventory;
|
||||
return storageResult.success;
|
||||
};
|
||||
|
||||
const doTravel = (state: GameState, content: GameContent, toPlaceId: string) => {
|
||||
@@ -512,7 +130,7 @@ const doTravel = (state: GameState, content: GameContent, toPlaceId: string) =>
|
||||
state.meta.rngState = rng.state;
|
||||
triggerEventFromPool(state, content, content.places[toPlaceId].arrivalEventPool, 'arrival');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const doPlaceAction = (state: GameState, content: GameContent, actionId: string) => {
|
||||
const place = getCurrentPlace(state, content);
|
||||
@@ -612,7 +230,7 @@ const doCraft = (state: GameState, content: GameContent, recipeId: string) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const missingInput = recipe.inputs.find((input) => getItemCount(state.player, input.itemId) < input.count);
|
||||
const missingInput = recipe.inputs.find((input) => getCraftingOwnedCount(state, input.itemId) < input.count);
|
||||
if (missingInput) {
|
||||
pushLog(
|
||||
state,
|
||||
@@ -623,7 +241,7 @@ const doCraft = (state: GameState, content: GameContent, recipeId: string) => {
|
||||
}
|
||||
|
||||
for (const input of recipe.inputs) {
|
||||
removeItem(state.player, input.itemId, input.count);
|
||||
consumeCraftingItems(state, input.itemId, input.count);
|
||||
}
|
||||
|
||||
advanceTime(state, content, recipe.timeCostMin, `制作 ${recipe.name}`);
|
||||
@@ -674,6 +292,63 @@ const doUnequip = (state: GameState, slot: 'weapon' | 'body' | 'tool') => {
|
||||
state.player.equipment[slot] = undefined;
|
||||
};
|
||||
|
||||
const doStashItem = (state: GameState, content: GameContent, itemId: string, count: number) => {
|
||||
if (!canAccessShelterStorage(state)) {
|
||||
pushLog(state, '只有回到避难所才能整理仓储。', 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isItemEquipped(state.player, itemId)) {
|
||||
pushLog(state, '先卸下这件装备,再把它放回仓储。', 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
const available = getItemCount(state.player, itemId);
|
||||
if (available <= 0) {
|
||||
pushLog(state, '背包里没有这个物品。', 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
const moveCount = Math.min(available, count);
|
||||
const addedCount = addItemToStorage(state, content, itemId, moveCount);
|
||||
if (addedCount <= 0) {
|
||||
pushLog(state, '避难所仓储已经装不下更多东西了。', 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
removeItem(state.player, itemId, addedCount);
|
||||
pushLog(state, `已将 ${content.items[itemId].name} x${addedCount} 存入避难所仓储。`, 'good');
|
||||
if (addedCount < moveCount) {
|
||||
pushLog(state, '仓储空间不足,其余物资仍保留在背包中。', 'warn');
|
||||
}
|
||||
};
|
||||
|
||||
const doRetrieveItem = (state: GameState, content: GameContent, itemId: string, count: number) => {
|
||||
if (!canAccessShelterStorage(state)) {
|
||||
pushLog(state, '离开避难所后无法直接取回仓储物资。', 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
const stored = getItemCountFromEntries(state.player.storage, itemId);
|
||||
if (stored <= 0) {
|
||||
pushLog(state, '仓储里没有这件物品。', 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
const moveCount = Math.min(stored, count);
|
||||
const addedCount = addItem(state, content, itemId, moveCount);
|
||||
if (addedCount <= 0) {
|
||||
pushLog(state, '背包已经满了,先腾出空间再取回物资。', 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
removeItemFromStorage(state, itemId, addedCount);
|
||||
pushLog(state, `已从避难所仓储取回 ${content.items[itemId].name} x${addedCount}。`, 'good');
|
||||
if (addedCount < moveCount) {
|
||||
pushLog(state, '背包容量不足,剩余物资仍留在仓储中。', 'warn');
|
||||
}
|
||||
};
|
||||
|
||||
const doTrade = (state: GameState, content: GameContent, offerId: string) => {
|
||||
const place = getCurrentPlace(state, content);
|
||||
const offer = place.tradeOffers?.find((entry) => entry.id === offerId);
|
||||
@@ -714,7 +389,11 @@ const doRest = (state: GameState, content: GameContent, minutes: number) => {
|
||||
pushLog(state, '你稍微喘了口气。', 'good');
|
||||
};
|
||||
|
||||
const doCombatMove = (state: GameState, content: GameContent, move: 'attack' | 'defend' | 'advance' | 'retreat' | 'escape') => {
|
||||
const doCombatMove = (
|
||||
state: GameState,
|
||||
content: GameContent,
|
||||
move: 'attack' | 'defend' | 'advance' | 'retreat' | 'escape',
|
||||
) => {
|
||||
if (!state.world.activeCombat) {
|
||||
pushLog(state, '当前没有战斗。', 'warn');
|
||||
return;
|
||||
@@ -767,7 +446,11 @@ const doCombatMove = (state: GameState, content: GameContent, move: 'attack' | '
|
||||
combat.distance = Math.min(6, combat.distance + 1);
|
||||
resolved = `你向后拉开身位。距离变为 ${combat.distance}。`;
|
||||
} else if (move === 'escape') {
|
||||
const escapeChance = clamp(0.38 + state.player.attributes.agi * 0.05 + combat.distance * 0.08 - enemy.escapePressure, 0.1, 0.92);
|
||||
const escapeChance = clamp(
|
||||
0.38 + state.player.attributes.agi * 0.05 + combat.distance * 0.08 - enemy.escapePressure,
|
||||
0.1,
|
||||
0.92,
|
||||
);
|
||||
if (rng.chance(escapeChance)) {
|
||||
advanceTime(state, content, 12, '撤离战斗');
|
||||
applyStatChange(state, 'energy', -6);
|
||||
@@ -798,7 +481,11 @@ const doCombatMove = (state: GameState, content: GameContent, move: 'attack' | '
|
||||
return;
|
||||
}
|
||||
|
||||
const enemyHitChance = clamp(enemy.hitRate + (enemy.range >= combat.distance ? 0.08 : -0.06) - (armor?.combat?.dodgeBonus ?? 0), 0.2, 0.88);
|
||||
const enemyHitChance = clamp(
|
||||
enemy.hitRate + (enemy.range >= combat.distance ? 0.08 : -0.06) - (armor?.combat?.dodgeBonus ?? 0),
|
||||
0.2,
|
||||
0.88,
|
||||
);
|
||||
if (rng.chance(enemyHitChance)) {
|
||||
let damage = rng.int(enemy.damageMin, enemy.damageMax);
|
||||
const armorValue = (armor?.combat?.armor ?? 0) + (combat.playerGuarding ? 3 : 0) + armorPenalty;
|
||||
@@ -814,129 +501,16 @@ const doCombatMove = (state: GameState, content: GameContent, move: 'attack' | '
|
||||
}
|
||||
|
||||
applyStatChange(state, 'energy', -5);
|
||||
handleThresholdDamage(state);
|
||||
combat.round += 1;
|
||||
combat.playerGuarding = false;
|
||||
combat.enemyGuarding = rng.chance(0.25);
|
||||
|
||||
state.meta.rngState = rng.state;
|
||||
};
|
||||
|
||||
const buildActionPreview = (state: GameState, content: GameContent, action: PlaceActionDefinition): ActionPreview => {
|
||||
const failed = action.requires?.find((rule) => !hasConditions(state, [rule]));
|
||||
const runtime = ensurePlaceRuntime(state, content, state.player.placeId);
|
||||
const remainingStock = action.stock ? runtime.stocks[action.id] ?? action.stock.initial : null;
|
||||
|
||||
let disabledReason = failed ? getConditionFailure(failed, content) : undefined;
|
||||
if (!disabledReason && action.stock && remainingStock !== null && remainingStock <= 0) {
|
||||
disabledReason = action.onEmptyText ?? '这里已经空了';
|
||||
if (state.player.stats.life <= 0) {
|
||||
state.world.gameOver = true;
|
||||
state.world.deathReason = '生命耗尽';
|
||||
}
|
||||
|
||||
return {
|
||||
id: action.id,
|
||||
name: action.name,
|
||||
kind: action.kind,
|
||||
desc: action.desc,
|
||||
timeCostMin: action.timeCostMin,
|
||||
energyCost: action.energyCost,
|
||||
risk: action.risk,
|
||||
rewardHint: action.rewardHint,
|
||||
disabledReason,
|
||||
remainingStock,
|
||||
};
|
||||
};
|
||||
|
||||
const buildRecipeView = (state: GameState, content: GameContent, recipe: RecipeDefinition): RecipeView => {
|
||||
const requirementFailure = recipe.requirements?.find((rule) => !hasConditions(state, [rule]));
|
||||
const missing = recipe.inputs.find((input) => getItemCount(state.player, input.itemId) < input.count);
|
||||
const craftable = state.player.knownRecipes.includes(recipe.id) && !requirementFailure && !missing;
|
||||
|
||||
return {
|
||||
id: recipe.id,
|
||||
name: recipe.name,
|
||||
desc: recipe.desc,
|
||||
timeCostMin: recipe.timeCostMin,
|
||||
energyCost: recipe.energyCost,
|
||||
craftable,
|
||||
disabledReason:
|
||||
!state.player.knownRecipes.includes(recipe.id)
|
||||
? '尚未解锁'
|
||||
: requirementFailure
|
||||
? getConditionFailure(requirementFailure, content)
|
||||
: missing
|
||||
? `缺少 ${content.items[missing.itemId].name}`
|
||||
: undefined,
|
||||
inputs: recipe.inputs.map((input) => ({
|
||||
itemId: input.itemId,
|
||||
name: content.items[input.itemId].name,
|
||||
count: input.count,
|
||||
owned: getItemCount(state.player, input.itemId),
|
||||
})),
|
||||
outputs: recipe.outputs.map((output) => ({
|
||||
itemId: output.itemId,
|
||||
name: content.items[output.itemId].name,
|
||||
count: output.count,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const buildTradeView = (state: GameState, content: GameContent, place: PlaceDefinition): TradeOfferView[] =>
|
||||
(place.tradeOffers ?? []).map((offer) => {
|
||||
const missing = offer.costs.find((cost) => getItemCount(state.player, cost.itemId) < cost.count);
|
||||
|
||||
return {
|
||||
id: offer.id,
|
||||
name: offer.name,
|
||||
desc: offer.desc,
|
||||
available: !missing,
|
||||
disabledReason: missing ? `缺少 ${content.items[missing.itemId].name}` : undefined,
|
||||
gives: offer.gives.map((entry) => ({
|
||||
itemId: entry.itemId,
|
||||
name: content.items[entry.itemId].name,
|
||||
count: entry.count,
|
||||
})),
|
||||
costs: offer.costs.map((entry) => ({
|
||||
itemId: entry.itemId,
|
||||
name: content.items[entry.itemId].name,
|
||||
count: entry.count,
|
||||
owned: getItemCount(state.player, entry.itemId),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const buildQuestView = (state: GameState, quest: QuestDefinition): QuestView => {
|
||||
const runtime = state.player.quests[quest.id];
|
||||
return {
|
||||
id: quest.id,
|
||||
title: quest.title,
|
||||
desc: quest.desc,
|
||||
type: quest.type,
|
||||
state: runtime.state,
|
||||
currentStepIndex: runtime.currentStepIndex,
|
||||
steps: quest.steps.map((step, index) => ({
|
||||
text: step.text,
|
||||
done: index < runtime.currentStepIndex || getQuestStepDone(state, step),
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const buildPendingEventView = (state: GameState, content: GameContent): PendingEventView | null => {
|
||||
if (!state.world.pendingEvent) return null;
|
||||
const event = content.events[state.world.pendingEvent.eventId];
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
text: event.text,
|
||||
options: event.options.map((option) => {
|
||||
const failed = option.conditions?.find((rule) => !hasConditions(state, [rule]));
|
||||
return {
|
||||
id: option.id,
|
||||
text: option.text,
|
||||
disabledReason: failed ? getConditionFailure(failed, content) : undefined,
|
||||
};
|
||||
}),
|
||||
};
|
||||
state.meta.rngState = rng.state;
|
||||
};
|
||||
|
||||
export const createNewGameState = (
|
||||
@@ -989,6 +563,11 @@ export const createNewGameState = (
|
||||
{ itemId: 'shiv', count: 1, durability: null },
|
||||
{ itemId: 'crowbar', count: 1, durability: null },
|
||||
],
|
||||
storage: [
|
||||
{ itemId: 'field_bandage', count: 1, durability: null },
|
||||
{ itemId: 'water_purified', count: 1, durability: null },
|
||||
{ itemId: 'leather_coat', count: 1, durability: null },
|
||||
],
|
||||
equipment: {
|
||||
weapon: 'shiv',
|
||||
tool: 'crowbar',
|
||||
@@ -1007,7 +586,7 @@ export const createNewGameState = (
|
||||
),
|
||||
};
|
||||
|
||||
const world: WorldState = {
|
||||
const world = {
|
||||
time: {
|
||||
totalMinutes: 8 * 60,
|
||||
day: 1,
|
||||
@@ -1028,6 +607,7 @@ export const createNewGameState = (
|
||||
}
|
||||
|
||||
pushLog(state, '你在避难点醒来,空气里全是尘土和铁锈味。', 'info');
|
||||
pushLog(state, '避难所的旧储物柜里还留着一点能用的补给。', 'info');
|
||||
reconcileQuests(state, content);
|
||||
return state;
|
||||
};
|
||||
@@ -1077,6 +657,12 @@ export const applyGameAction = (
|
||||
case 'unequip-item':
|
||||
doUnequip(state, action.slot);
|
||||
break;
|
||||
case 'stash-item':
|
||||
doStashItem(state, content, action.itemId, action.count);
|
||||
break;
|
||||
case 'retrieve-item':
|
||||
doRetrieveItem(state, content, action.itemId, action.count);
|
||||
break;
|
||||
case 'trade':
|
||||
doTrade(state, content, action.offerId);
|
||||
break;
|
||||
@@ -1096,98 +682,4 @@ export const applyGameAction = (
|
||||
return { state, changed: true };
|
||||
};
|
||||
|
||||
export const buildGameView = (state: GameState, content: GameContent): GameView => {
|
||||
const place = getCurrentPlace(state, content);
|
||||
const inventory: InventoryViewEntry[] = state.player.inventory.map((entry) => {
|
||||
const item = content.items[entry.itemId];
|
||||
return {
|
||||
...entry,
|
||||
name: item.name,
|
||||
type: item.type,
|
||||
desc: item.desc,
|
||||
volume: item.volume,
|
||||
equipSlot: item.equipSlot,
|
||||
canUse: Boolean(item.effects?.length),
|
||||
equipped: Object.values(state.player.equipment).includes(entry.itemId),
|
||||
};
|
||||
});
|
||||
|
||||
const edges: EdgeView[] = content.edges
|
||||
.filter((edge) => edge.from === state.player.placeId)
|
||||
.map((edge) => {
|
||||
const failed = edge.conditions?.find((rule) => !hasConditions(state, [rule]));
|
||||
return {
|
||||
from: edge.from,
|
||||
to: edge.to,
|
||||
travelTimeMin: edge.travelTimeMin,
|
||||
risk: edge.risk,
|
||||
blockedReason: failed ? getConditionFailure(failed, content) : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
meta: state.meta,
|
||||
header: {
|
||||
placeName: place.name,
|
||||
placeDesc: place.desc,
|
||||
timeLabel: formatTime(state.world.time.totalMinutes),
|
||||
riskLabel: dangerLabel(place.dangerLevel),
|
||||
},
|
||||
player: {
|
||||
name: state.player.name,
|
||||
stats: copyStats(state.player.stats),
|
||||
maxStats: copyStats(state.player.maxStats),
|
||||
attributes: { ...state.player.attributes },
|
||||
equipment: { ...state.player.equipment },
|
||||
inventory,
|
||||
inventoryUsage: getInventoryUsage(state, content),
|
||||
inventoryCapacity: content.inventoryCapacity,
|
||||
},
|
||||
map: {
|
||||
places: Object.values(content.places).map((entry) => ({
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
desc: entry.desc,
|
||||
dangerLevel: entry.dangerLevel,
|
||||
tags: entry.tags,
|
||||
visited: state.world.places[entry.id]?.visited ?? false,
|
||||
current: entry.id === state.player.placeId,
|
||||
})),
|
||||
edges,
|
||||
},
|
||||
place: {
|
||||
id: place.id,
|
||||
name: place.name,
|
||||
desc: place.desc,
|
||||
services: place.services,
|
||||
actions: place.actions.map((action) => buildActionPreview(state, content, action)),
|
||||
tradeOffers: buildTradeView(state, content, place),
|
||||
},
|
||||
recipes: Object.values(content.recipes).map((recipe) => buildRecipeView(state, content, recipe)),
|
||||
quests: Object.values(content.quests).map((quest) => buildQuestView(state, quest)),
|
||||
pendingEvent: buildPendingEventView(state, content),
|
||||
combat: state.world.activeCombat
|
||||
? {
|
||||
enemyId: state.world.activeCombat.enemyId,
|
||||
enemyName: content.enemies[state.world.activeCombat.enemyId].name,
|
||||
enemyLife: state.world.activeCombat.enemyLife,
|
||||
enemyMaxLife: content.enemies[state.world.activeCombat.enemyId].life,
|
||||
distance: state.world.activeCombat.distance,
|
||||
round: state.world.activeCombat.round,
|
||||
log: state.world.activeCombat.log.slice(-8),
|
||||
availableMoves: [
|
||||
{ id: 'combat', move: 'attack', label: '攻击' },
|
||||
{ id: 'combat', move: 'defend', label: '防御' },
|
||||
{ id: 'combat', move: 'advance', label: '前进' },
|
||||
{ id: 'combat', move: 'retreat', label: '后退' },
|
||||
{ id: 'combat', move: 'escape', label: '逃跑' },
|
||||
],
|
||||
}
|
||||
: null,
|
||||
logs: state.world.logs.slice(-18),
|
||||
flags: { ...state.world.flags },
|
||||
gameOver: state.world.gameOver,
|
||||
victory: state.world.victory,
|
||||
deathReason: state.world.deathReason,
|
||||
};
|
||||
};
|
||||
export { buildGameView } from './view-projection';
|
||||
|
||||