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

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

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

+596 -2
View File
@@ -267,6 +267,18 @@ input,
background: rgba(215, 138, 51, 0.12); 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 { .hud-chip.muted {
color: var(--muted); color: var(--muted);
} }
@@ -1072,11 +1084,33 @@ input,
padding: 0.75rem; 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 { .effect-card p {
margin: 0.35rem 0 0; margin: 0.35rem 0 0;
font-size: 0.78rem; font-size: 0.78rem;
} }
.effect-card small {
display: block;
margin-top: 0.45rem;
color: var(--muted);
line-height: 1.45;
}
.effect-card.tone-good { .effect-card.tone-good {
border-color: rgba(141, 194, 106, 0.24); border-color: rgba(141, 194, 106, 0.24);
} }
@@ -1538,7 +1572,7 @@ input,
.overlay-summary-grid { .overlay-summary-grid {
display: grid; display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
gap: 0.55rem; gap: 0.55rem;
} }
@@ -1577,6 +1611,13 @@ input,
grid-template-columns: minmax(0, 1.4fr) 320px; 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 { .mission-overlay-grid {
grid-template-columns: minmax(0, 1.2fr) 340px; grid-template-columns: minmax(0, 1.2fr) 340px;
} }
@@ -1659,6 +1700,471 @@ input,
background: rgba(215, 138, 51, 0.26); 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) { @media (max-width: 1580px) {
.top-hud { .top-hud {
grid-template-columns: 250px 104px minmax(0, 1fr); grid-template-columns: 250px 104px minmax(0, 1fr);
@@ -1670,12 +2176,28 @@ input,
} }
.game-grid { .game-grid {
grid-template-columns: 286px minmax(0, 1fr) 372px; grid-template-columns: 248px minmax(0, 1fr) 330px;
} }
.loadout-matrix { .loadout-matrix {
grid-template-columns: 112px minmax(0, 1fr) 112px; 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) { @media (max-width: 1340px) {
@@ -1713,6 +2235,45 @@ input,
.command-dock { .command-dock {
align-items: start; 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) { @media (max-width: 860px) {
@@ -1733,6 +2294,35 @@ input,
grid-template-columns: 1fr; 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 { .quick-slot-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
@@ -1760,4 +2350,8 @@ input,
.new-game-card { .new-game-card {
min-height: 420px; min-height: 420px;
} }
.expedition-route-card {
grid-template-columns: 1fr;
}
} }
@@ -1,22 +1,27 @@
import type { OverlayPanel } from '../types'; import type { OverlayPanel } from '../types';
import type { GameView } from '@tinywaste/game-core'; import type { GameAction, GameView } from '@tinywaste/game-core';
import { getActiveQuestCount, getSupplyCounts } from '../hudModel'; import { getActiveQuestCount, getCurrentPlace, getDominantAlert, getSupplyCounts } from '../hudModel';
export function BottomDock({ export function BottomDock({
activePanel, activePanel,
onOpenPanel, onOpenPanel,
onSendAction,
view, view,
}: { }: {
activePanel: OverlayPanel | null; activePanel: OverlayPanel | null;
onOpenPanel: (panel: OverlayPanel) => void; onOpenPanel: (panel: OverlayPanel) => void;
onSendAction: (action: GameAction) => void;
view: GameView; view: GameView;
}) { }) {
const supplyCounts = getSupplyCounts(view); const supplyCounts = getSupplyCounts(view);
const activeQuestCount = getActiveQuestCount(view); const activeQuestCount = getActiveQuestCount(view);
const currentPlace = getCurrentPlace(view);
const homeRoute = view.map.edges.find((edge) => edge.to === 'home');
const dominantAlert = getDominantAlert(view);
return ( return (
<footer className="command-dock"> <footer className="command-dock tactical-dock">
<div className="operator-card"> <div className="operator-card tactical-operator">
<div className="operator-emblem">{view.player.name.slice(0, 1)}</div> <div className="operator-emblem">{view.player.name.slice(0, 1)}</div>
<div className="operator-copy"> <div className="operator-copy">
<span>ACTIVE SURVIVOR</span> <span>ACTIVE SURVIVOR</span>
@@ -26,7 +31,7 @@ export function BottomDock({
<div className="dock-nav-groups"> <div className="dock-nav-groups">
<div className="dock-tabs"> <div className="dock-tabs">
<button className="dock-tab active">Shelter</button> <button className="dock-tab active">{currentPlace?.name ?? 'Expedition'}</button>
<button <button
className={`dock-tab ${activePanel === 'blueprints' ? 'active' : ''}`} className={`dock-tab ${activePanel === 'blueprints' ? 'active' : ''}`}
onClick={() => onOpenPanel('blueprints')} onClick={() => onOpenPanel('blueprints')}
@@ -54,13 +59,28 @@ export function BottomDock({
</div> </div>
<div className="dock-metrics"> <div className="dock-metrics">
<span>{dominantAlert ? dominantAlert.label : '状态稳定'}</span>
<span> {supplyCounts.water}</span> <span> {supplyCounts.water}</span>
<span> {supplyCounts.food}</span> <span> {supplyCounts.food}</span>
<span> {supplyCounts.medicine}</span> <span> {supplyCounts.medicine}</span>
<span> {supplyCounts.material}</span>
<span> {activeQuestCount}</span> <span> {activeQuestCount}</span>
</div> </div>
</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> </footer>
); );
} }
@@ -1,49 +1,10 @@
import type { GameAction, GameView } from '@tinywaste/game-core'; import type { GameAction, GameView, InventoryViewEntry } from '@tinywaste/game-core';
import { getObjectiveRows } from '../hudModel'; import { getLatestLog, getPrimaryObjective, getQuickUseItems } from '../hudModel';
import { getPlaceHeroStyle } from '../uiAssets'; import { getPlaceHeroStyle } from '../uiAssets';
import type { OverlayPanel } from '../types'; import type { OverlayPanel } from '../types';
import { EmptyState } from './EmptyState';
import { PanelHeader } from '../../shared/components/PanelHeader';
function ObjectivePanel({ view }: { view: GameView }) { function pickRecoveryItem(items: InventoryViewEntry[], type: 'water' | 'food' | 'medicine') {
const objectives = getObjectiveRows(view); return items.find((item) => item.type === type);
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>
);
} }
export function CommandCenter({ export function CommandCenter({
@@ -59,138 +20,210 @@ export function CommandCenter({
onSendAction: (action: GameAction) => void; onSendAction: (action: GameAction) => void;
view: GameView; 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', id: 'inventory',
name: '短休 30m', title: '整理补给',
kind: 'system', detail: '打开背包与避难所仓储',
desc: '不离开当前扇区,快速回收一段精力窗口。', meta: 'inventory',
meta: ['30 分钟', '恢复向', '低风险'], onClick: () => onOpenPanel('inventory'),
onClick: () => onSendAction({ type: 'rest', minutes: 30 }),
}, },
{ {
id: 'utility-blueprints', id: 'blueprints',
name: '打开蓝图', title: '检查蓝图',
kind: 'system', detail: '查看可制作项与材料缺口',
desc: '在独立系统面板中检查配方、缺口与制作链。', meta: 'crafting',
meta: ['模式切换', '制作', '即时'],
onClick: () => onOpenPanel('blueprints'), onClick: () => onOpenPanel('blueprints'),
}, },
{ ].slice(0, 5);
id: 'utility-logs',
name: '复盘日志',
kind: 'system',
desc: '在独立日志面板中回放最近行动、事件与消耗。',
meta: ['模式切换', '日志', '回放'],
onClick: () => onOpenPanel('logs'),
},
];
return ( return (
<section className="panel panel-command"> <section className="panel panel-command theater-panel">
<div className="viewport-stage" style={getPlaceHeroStyle(view.place.id)}> <div className="theater-scene" style={getPlaceHeroStyle(view.place.id)}>
<div className="viewport-copy"> <div className="theater-scene-copy">
<p className="eyebrow">Live Visual Feed</p> <p className="eyebrow">Operation theater</p>
<h2>{view.header.placeName}</h2> <h2>{view.header.placeName}</h2>
<p>{view.header.placeDesc}</p> <p>{view.header.placeDesc}</p>
</div> </div>
<div className="viewport-flags">
<span>{view.pendingEvent ? '事件待处理' : '无待定事件'}</span> <div className="theater-scene-side">
<span>{view.combat ? `战斗回合 ${view.combat.round}` : `行动 ${view.place.actions.length}`}</span> <article className="scene-focus-card">
<span>{view.place.tradeOffers.length ? `可交易 ${view.place.tradeOffers.length}` : '无交易站'}</span> <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>
</div> </div>
{gameError ? <p className="error-copy command-error">{gameError}</p> : null} <div className="theater-deck">
<section className="primary-operation-card">
<div className="command-surface"> <div className="primary-operation-copy">
<div className="command-tabs command-tabs-static"> <span>{primaryOperation.kicker}</span>
<button className="active">Actions</button> <h3>{primaryOperation.title}</h3>
<button onClick={() => onOpenPanel('logs')}>Logs</button> <p>{primaryOperation.description}</p>
<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> </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> </div>
</section> </section>
); );
@@ -48,7 +48,7 @@ export function GameHud({
<LoadoutPanel isWorking={isWorking} onOpenPanel={setActivePanel} onSendAction={onSendAction} view={view} /> <LoadoutPanel isWorking={isWorking} onOpenPanel={setActivePanel} onSendAction={onSendAction} view={view} />
</main> </main>
<BottomDock activePanel={activePanel} onOpenPanel={setActivePanel} view={view} /> <BottomDock activePanel={activePanel} onOpenPanel={setActivePanel} onSendAction={onSendAction} view={view} />
<SystemOverlay <SystemOverlay
isWorking={isWorking} isWorking={isWorking}
@@ -1,7 +1,12 @@
import type { GameAction, GameView } from '@tinywaste/game-core'; import type { GameAction, GameView } from '@tinywaste/game-core';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { CHARACTER_PREVIEW_ART, getItemArt } from '../uiAssets'; import { getItemArt } from '../uiAssets';
import { getAttributeRows, getLoadoutEntries, getStatusEffects, getVisibleInventory } from '../hudModel'; import {
getLoadoutEntries,
getQuickUseItems,
getStatusEffects,
getSurvivorMetrics,
} from '../hudModel';
import type { OverlayPanel } from '../types'; import type { OverlayPanel } from '../types';
import { AssetThumb } from './AssetThumb'; import { AssetThumb } from './AssetThumb';
import { PanelHeader } from '../../shared/components/PanelHeader'; import { PanelHeader } from '../../shared/components/PanelHeader';
@@ -18,141 +23,144 @@ export function LoadoutPanel({
view: GameView; view: GameView;
}) { }) {
const loadoutEntries = useMemo(() => getLoadoutEntries(view), [view]); const loadoutEntries = useMemo(() => getLoadoutEntries(view), [view]);
const attributeRows = useMemo(() => getAttributeRows(view), [view]);
const statusEffects = useMemo(() => getStatusEffects(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 ( return (
<section className="panel panel-loadout"> <section className="panel panel-loadout survivor-panel">
<div className="panel-tabs"> <div className="panel-tabs survivor-tabs">
<button className="active">Character</button> <button className="active">Character</button>
<button onClick={() => onOpenPanel('inventory')}>Inventory</button> <button onClick={() => onOpenPanel('inventory')}>Inventory</button>
<button onClick={() => onOpenPanel('missions')}>Missions</button> <button onClick={() => onOpenPanel('missions')}>Missions</button>
</div> </div>
<div className="loadout-scroll"> <div className="survivor-head">
<PanelHeader <div>
title="Character Matrix" <p className="eyebrow">Survivor rig</p>
subtitle={`${view.player.name} · 容量 ${view.player.inventoryUsage}/${view.player.inventoryCapacity}`} <strong>{view.player.name}</strong>
/>
<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> </div>
<div className="survivor-head-chips">
<div className="character-meta-grid"> <span className={`hud-chip ${view.player.storageAccessible ? 'safe' : 'muted'}`}>
<section className="subpanel attribute-panel"> {view.player.storageAccessible ? '仓储在线' : '野外行动'}
<PanelHeader title="Attributes" subtitle="当前成长属性直接来自在线存档" compact /> </span>
<div className="attribute-grid"> <span className="hud-chip muted">
{attributeRows.map((entry) => ( {view.player.inventoryUsage}/{view.player.inventoryCapacity}
<article key={entry.label} className="attribute-card"> </span>
<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> </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> </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> </section>
); );
} }
@@ -1,5 +1,5 @@
import type { GameAction, GameView } from '@tinywaste/game-core'; 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 { getPlaceThumbStyle } from '../uiAssets';
import { PanelHeader } from '../../shared/components/PanelHeader'; import { PanelHeader } from '../../shared/components/PanelHeader';
@@ -14,28 +14,36 @@ export function RoutePanel({
}) { }) {
const currentPlace = getCurrentPlace(view); const currentPlace = getCurrentPlace(view);
const activeQuestCount = getActiveQuestCount(view); const activeQuestCount = getActiveQuestCount(view);
const expedition = getExpeditionStatus(view);
return ( return (
<section className="panel panel-route"> <section className="panel panel-route expedition-panel">
<PanelHeader title="Current Location" subtitle="路线、风险、耗时全部固定在当前视口内" /> <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> <span className="intel-kicker">CURRENT SECTOR</span>
<h2>{currentPlace?.name ?? view.header.placeName}</h2>
<p>{view.header.placeDesc}</p> <p>{view.header.placeDesc}</p>
<div className="route-summary-meta"> <div className="expedition-status-grid">
<div> <article className="expedition-status-card">
<small></small> <small></small>
<strong>{view.header.riskLabel}</strong> <strong>{expedition.readiness}%</strong>
</div> </article>
<div> <article className="expedition-status-card">
<small></small> <small></small>
<strong>{view.place.services.length || 1} </strong> <strong>{view.player.inventoryUsage}/{view.player.inventoryCapacity}</strong>
</div> </article>
<div> <article className="expedition-status-card">
<small></small> <small></small>
<strong>{activeQuestCount}</strong> <strong>{expedition.threatCount || activeQuestCount}</strong>
</div> </article>
</div> </div>
<div className="intel-meta"> <div className="intel-meta">
{(currentPlace?.tags ?? []).map((tag) => ( {(currentPlace?.tags ?? []).map((tag) => (
@@ -46,7 +54,9 @@ export function RoutePanel({
</div> </div>
</article> </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) => { {view.map.edges.map((edge) => {
const destination = edgeDestination(view, edge); const destination = edgeDestination(view, edge);
if (!destination) return null; if (!destination) return null;
@@ -57,7 +67,7 @@ export function RoutePanel({
return ( return (
<button <button
key={`${edge.from}-${edge.to}`} 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} disabled={Boolean(edge.blockedReason) || isWorking}
onClick={() => onSendAction({ type: 'travel', toPlaceId: edge.to })} onClick={() => onSendAction({ type: 'travel', toPlaceId: edge.to })}
> >
@@ -82,11 +92,12 @@ export function RoutePanel({
</button> </button>
); );
})} })}
</div> </div>
</section>
<section className="world-map-panel"> <section className="world-map-panel expedition-map-panel">
<PanelHeader title="World Map" subtitle="节点式世界图用于压缩表达地理推进" compact /> <PanelHeader title="Known sectors" subtitle="压缩后的节点情报" compact />
<div className="map-node-grid"> <div className="map-node-grid expedition-map-grid">
{view.map.places.map((place) => ( {view.map.places.map((place) => (
<article key={place.id} className={`map-node-card ${place.current ? 'current' : ''}`}> <article key={place.id} className={`map-node-card ${place.current ? 'current' : ''}`}>
<strong>{place.name}</strong> <strong>{place.name}</strong>
@@ -36,6 +36,12 @@ function OverlayHeaderStats({ view }: { view: GameView }) {
<span></span> <span></span>
<strong>{activeQuestCount}</strong> <strong>{activeQuestCount}</strong>
</article> </article>
<article className="overlay-summary-card">
<span></span>
<strong>
{view.player.storageUsage}/{view.player.storageCapacity}
</strong>
</article>
</div> </div>
); );
} }
@@ -56,21 +62,73 @@ function InventoryOverlay({
<OverlayHeaderStats view={view} /> <OverlayHeaderStats view={view} />
<div className="system-overlay-grid inventory-overlay-grid"> <div className="system-overlay-grid inventory-overlay-grid">
<section className="subpanel overlay-main-panel"> <div className="overlay-main-stack">
<PanelHeader title="Inventory Ledger" subtitle="完整物资、装备与使用入口" compact /> <section className="subpanel overlay-main-panel">
<div className="inventory-list overlay-list"> <PanelHeader title="Field Bag" subtitle="随身背包,服务当前决策与战斗" compact />
{view.player.inventory.map((item) => ( <div className="inventory-list overlay-list">
<InventoryCard {view.player.inventory.map((item) => (
key={item.itemId} <InventoryCard
busy={isWorking} key={item.itemId}
item={item} busy={isWorking}
onEquip={() => onSendAction({ type: 'equip-item', itemId: item.itemId })} extraActions={
onUnequip={() => onSendAction({ type: 'unequip-item', slot: item.equipSlot! })} view.player.storageAccessible && !item.equipped
onUse={() => onSendAction({ type: 'use-item', itemId: item.itemId })} ? [
/> {
))} label: '存入仓储',
</div> onClick: () =>
</section> 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"> <div className="overlay-side-stack">
<section className="subpanel overlay-side-panel"> <section className="subpanel overlay-side-panel">
@@ -89,15 +147,15 @@ function InventoryOverlay({
</section> </section>
<section className="subpanel overlay-side-panel"> <section className="subpanel overlay-side-panel">
<PanelHeader title="Bag Rules" subtitle="背包系统当前规则摘要" compact /> <PanelHeader title="Storage Rules" subtitle="随身背包与基地仓储的分层规则" compact />
<div className="overlay-note-stack"> <div className="overlay-note-stack">
<article className="overlay-note-card"> <article className="overlay-note-card">
<strong></strong> <strong></strong>
<p></p> <p>线</p>
</article> </article>
<article className="overlay-note-card"> <article className="overlay-note-card">
<strong>使</strong> <strong>使</strong>
<p></p> <p></p>
</article> </article>
</div> </div>
</section> </section>
@@ -1,5 +1,5 @@
import type { GameView } from '@tinywaste/game-core'; 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 { STAT_LABELS, STAT_ORDER } from '../uiAssets';
import { StatMeter } from './StatMeter'; import { StatMeter } from './StatMeter';
@@ -16,6 +16,13 @@ export function TopHud({
}) { }) {
const { dayLabel, clockLabel } = getTimeLabels(view.header.timeLabel); const { dayLabel, clockLabel } = getTimeLabels(view.header.timeLabel);
const alertCount = getAlertCount(view); const alertCount = getAlertCount(view);
const dominantAlert = getDominantAlert(view);
const alertToneClass =
dominantAlert?.tier === 'strained'
? 'warn'
: dominantAlert && dominantAlert.tier !== 'safe'
? 'danger'
: 'safe';
return ( return (
<header className="top-hud"> <header className="top-hud">
@@ -46,6 +53,9 @@ export function TopHud({
<div className="top-actions"> <div className="top-actions">
<span className="hud-chip safe">{view.header.riskLabel}</span> <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"> {accountName}</span>
<span className={`hud-chip ${alertCount ? 'accent' : ''}`}> <span className={`hud-chip ${alertCount ? 'accent' : ''}`}>
{alertCount ? `警报 ${alertCount}` : '链路稳定'} {alertCount ? `警报 ${alertCount}` : '链路稳定'}
@@ -9,12 +9,16 @@ export function InventoryCard({
onUse, onUse,
onEquip, onEquip,
onUnequip, onUnequip,
extraActions,
footerText,
}: { }: {
item: InventoryViewEntry; item: InventoryViewEntry;
busy: boolean; busy: boolean;
onUse: () => void; onUse?: () => void;
onEquip: () => void; onEquip?: () => void;
onUnequip: () => void; onUnequip?: () => void;
extraActions?: { label: string; onClick: () => void; tone?: 'primary' | 'secondary' }[];
footerText?: string;
}) { }) {
const art = getItemArt(item.itemId, item.type); const art = getItemArt(item.itemId, item.type);
@@ -27,17 +31,15 @@ export function InventoryCard({
<span>x{item.count}</span> <span>x{item.count}</span>
</div> </div>
<p>{item.desc}</p> <p>{item.desc}</p>
<small> <small>{footerText ?? `${item.type} · 体积 ${item.volume} · ${getInventoryStateLabel(item)}`}</small>
{item.type} · {item.volume} · {getInventoryStateLabel(item)}
</small>
</div> </div>
<div className="inventory-actions"> <div className="inventory-actions">
{item.canUse ? ( {item.canUse && onUse ? (
<button className="small-button" onClick={onUse} disabled={busy}> <button className="small-button" onClick={onUse} disabled={busy}>
使 使
</button> </button>
) : null} ) : null}
{item.equipSlot ? ( {item.equipSlot && onEquip && onUnequip ? (
<button <button
className="small-button secondary" className="small-button secondary"
onClick={item.equipped ? onUnequip : onEquip} onClick={item.equipped ? onUnequip : onEquip}
@@ -46,6 +48,16 @@ export function InventoryCard({
{item.equipped ? '卸下' : '装备'} {item.equipped ? '卸下' : '装备'}
</button> </button>
) : null} ) : 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> </div>
</article> </article>
); );
+110 -23
View File
@@ -1,5 +1,5 @@
import type { EdgeView, GameView, InventoryViewEntry, QuestView } from '@tinywaste/game-core'; import type { EdgeView, GameView, InventoryViewEntry, QuestView } from '@tinywaste/game-core';
import { getEquipmentArt, getItemArt } from './uiAssets'; import { getEquipmentArt, getItemArt, getStatusArt } from './uiAssets';
export interface EquipmentPreviewEntry { export interface EquipmentPreviewEntry {
id: string; id: string;
@@ -30,14 +30,45 @@ export function getAlertCount(view: GameView) {
return Number(Boolean(view.pendingEvent)) + Number(Boolean(view.combat)); 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) { export function getCurrentPlace(view: GameView) {
return view.map.places.find((place) => place.current) ?? null; 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) { export function getVisibleInventory(view: GameView, limit = 6) {
return view.player.inventory.slice(0, limit); 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) { export function getObjectiveRows(view: GameView) {
return view.quests return view.quests
.filter((quest) => quest.state === 'active' || quest.state === 'completed') .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) { export function getAttributeRows(view: GameView) {
const { attributes } = view.player; const { attributes } = view.player;
@@ -68,35 +103,87 @@ export function getAttributeRows(view: GameView) {
} }
export function getStatusEffects(view: GameView) { export function getStatusEffects(view: GameView) {
const { stats, maxStats } = view.player; const effects = view.survival.alerts.map((entry) => ({
const effects: { id: string; label: string; detail: string; tone: 'good' | 'warn' | 'danger' }[] = []; id: entry.stat,
label: `${entry.label} · ${entry.summary}`,
if (stats.life / maxStats.life <= 0.55) { detail: entry.impact,
effects.push({ id: 'life', label: '创伤', detail: '生命处于低位', tone: 'danger' }); recovery: entry.recovery,
} art: getStatusArt(entry.stat),
if (stats.thirst / maxStats.thirst <= 0.5) { tone:
effects.push({ id: 'thirst', label: '脱水', detail: '口渴已压缩行动余量', tone: 'warn' }); entry.tier === 'strained' ? ('warn' as const) : entry.tier === 'safe' ? ('good' as const) : ('danger' as const),
} }));
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' });
}
if (!effects.length) { 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); 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) { export function getSupplyCounts(view: GameView) {
const counts = { const counts = {
water: 0, water: 0,
+13
View File
@@ -27,6 +27,15 @@ export const STAT_LABELS: Record<string, string> = {
export const STAT_ORDER = ['life', 'hunger', 'thirst', 'energy', 'sanity', 'radiation'] as const; 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> = { const ITEM_ART: Record<string, string> = {
water_dirty: `${GENERATED_UI_ROOT}/items/water-dirty.png`, water_dirty: `${GENERATED_UI_ROOT}/items/water-dirty.png`,
water_purified: `${GENERATED_UI_ROOT}/items/water-purified.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); 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') { export function getEquipmentArt(itemId?: string, slot?: EquipSlot | 'head' | 'back' | 'kit') {
if (itemId && ITEM_TO_EQUIPMENT_ART[itemId]) { if (itemId && ITEM_TO_EQUIPMENT_ART[itemId]) {
return ITEM_TO_EQUIPMENT_ART[itemId]; return ITEM_TO_EQUIPMENT_ART[itemId];
+12 -1
View File
@@ -93,6 +93,12 @@ Buff 必须具备可解释来源与可清除/可缓解路径:
- 背包容量 `bagCapacity`:体积上限 - 背包容量 `bagCapacity`:体积上限
- 容器(箱子/仓库)容量 `storageCapacity`:体积上限 - 容器(箱子/仓库)容量 `storageCapacity`:体积上限
当前实现采用:
- `Field Bag`:在线行动时唯一权威随身背包
- `Shelter Storage`:避难所内的长期仓储层
- 主 HUD 只摘要显示随身容量,完整整理在独立背包弹层完成
设计理由: 设计理由:
- 体积比“格子”更适合废土资源管理的“取舍”表达 - 体积比“格子”更适合废土资源管理的“取舍”表达
@@ -108,6 +114,12 @@ Buff 必须具备可解释来源与可清除/可缓解路径:
- 基地可修理(消耗材料 + 时间) - 基地可修理(消耗材料 + 时间)
- 野外可应急修理(更高成本/更低效率) - 野外可应急修理(更高成本/更低效率)
### 3.3.4 仓储访问规则
- 仓储不是全地图随时可开,而是地点约束能力
- 当前版本仅在 `home / Shelter-7` 允许取放仓储
- 配方在避难所内可以读取仓储材料,避免“物资明明囤着却不能做”的违和感
## 3.4 制作、烹饪、锻造与科研 ## 3.4 制作、烹饪、锻造与科研
### 3.4.1 配方数据结构(通用) ### 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) 经济更完整设计见:[05-数值与经济.md](./05-%E6%95%B0%E5%80%BC%E4%B8%8E%E7%BB%8F%E6%B5%8E.md)
+21 -2
View File
@@ -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 与按标签检索能力 - 提供按 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 数据与配置组织(建议)
### 8.4.1 文件结构建议 ### 8.4.1 文件结构建议
@@ -182,4 +202,3 @@ flowchart TB
- 存档迁移 - 存档迁移
- 内容校验: - 内容校验:
- CI 中运行 Schema 校验与引用完整性检查 - CI 中运行 Schema 校验与引用完整性检查
+4 -1
View File
@@ -94,8 +94,10 @@ UI 组件 <- Query Cache <- 最新 GameView
- `apps/web/public/generated/ui/character-preview.png`:角色立绘 - `apps/web/public/generated/ui/character-preview.png`:角色立绘
- `apps/web/public/generated/ui/item-atlas.png`:物资图集原图 - `apps/web/public/generated/ui/item-atlas.png`:物资图集原图
- `apps/web/public/generated/ui/equipment-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/items/*`:切分后的物资图标
- `apps/web/public/generated/ui/equipment/*`:切分后的装备槽素材 - `apps/web/public/generated/ui/equipment/*`:切分后的装备槽素材
- `apps/web/public/generated/ui/status/*`:切分后的生存状态图标
对应映射入口: 对应映射入口:
@@ -105,6 +107,7 @@ UI 组件 <- Query Cache <- 最新 GameView
- 地点图使用整图背景,服务于中央场景和左侧路线卡。 - 地点图使用整图背景,服务于中央场景和左侧路线卡。
- 物资与装备图使用 atlas + 裁切结果,服务于快捷栏、库存卡、角色槽位。 - 物资与装备图使用 atlas + 裁切结果,服务于快捷栏、库存卡、角色槽位。
- 生存状态图使用 atlas + 裁切结果,服务于状态效果卡和后续中断提示。
- 所有引用都走 `uiAssets.ts`,避免组件里散落硬编码路径。 - 所有引用都走 `uiAssets.ts`,避免组件里散落硬编码路径。
## 12.6 HUD 信息职责 ## 12.6 HUD 信息职责
@@ -138,7 +141,7 @@ UI 组件 <- Query Cache <- 最新 GameView
- 角色立绘与槽位矩阵 - 角色立绘与槽位矩阵
- 属性与状态效果 - 属性与状态效果
- 快捷栏 - 快捷栏
- 完整库存管理 - 完整库存与避难所仓储管理
- 任务与休整 - 任务与休整
### 12.6.5 BottomDock ### 12.6.5 BottomDock
@@ -95,7 +95,7 @@ TinyWaste Online 不是网页式信息展示项目,而是一款:
中频系统改用面板弹出: 中频系统改用面板弹出:
- 背包 - 背包 / 避难所仓储
- 蓝图 - 蓝图
- 任务 - 任务
- 完整日志 - 完整日志
@@ -151,6 +151,12 @@ TinyWaste Online 不是网页式信息展示项目,而是一款:
当前项目已经有 `Quick Slots + Inventory` 雏形,下一步建议补 `Shelter Storage` 当前项目已经有 `Quick Slots + Inventory` 雏形,下一步建议补 `Shelter Storage`
当前实现进度:
- 已经落地 `Field Bag + Shelter Storage` 双层库存
- 仅位于 `home / Shelter-7` 时可访问避难所仓储
- 避难所内制作会同时读取背包与仓储材料,避免资源可见却不可用
### 13.6.3 物品状态 ### 13.6.3 物品状态
每个背包物品后续建议支持: 每个背包物品后续建议支持:
@@ -275,17 +281,20 @@ TinyWaste Online 不是网页式信息展示项目,而是一款:
## 13.11 当前代码层面的下一批重点 ## 13.11 当前代码层面的下一批重点
1. `engine.ts` 继续拆成子模块: 1. 当前已拆出的运行时模块:
- `time-system`
- `inventory-system` - `inventory-system`
- `combat-system` - `place-system`
- `event-system` - `effect-system`
- `survival-system`
- `quest-system` - `quest-system`
- `view-projection` - `view-projection`
2. 为状态阈值、背包容量、战斗结算分别补单元测试 2. 下一轮继续拆出:
3. 引入基地仓库与背包转移逻辑 - `combat-system`
4. 为背包、任务、蓝图面板补筛选与分类 - `event-system`
5. 为状态系统引入更明确的阶段化惩罚定义 3. 为状态阈值、背包容量、战斗结算分别补单元测试
4. 继续强化基地仓储:分类、批量整理、仓储专属配方
5. 为背包、任务、蓝图面板补筛选与分类
6. 为状态系统引入更明确的阶段化惩罚定义
## 13.12 结论 ## 13.12 结论
+45
View File
@@ -8,6 +8,7 @@ describe('TinyWaste gameplay loop', () => {
expect(state.player.placeId).toBe('home'); expect(state.player.placeId).toBe('home');
expect(state.player.inventory.some((entry) => entry.itemId === 'shiv')).toBe(true); 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'); 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.header.placeName).toBe('近郊土路');
expect(view.map.places.find((place) => place.current)?.id).toBe('roadside'); 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('告警');
});
}); });
+1
View File
@@ -3,6 +3,7 @@ import type { GameContent } from '@tinywaste/game-core';
export const gameContent: GameContent = { export const gameContent: GameContent = {
version: '0.1.0', version: '0.1.0',
inventoryCapacity: 36, inventoryCapacity: 36,
storageCapacity: 84,
items: { items: {
water_dirty: { water_dirty: {
id: 'water_dirty', id: 'water_dirty',
+128 -636
View File
@@ -1,293 +1,40 @@
import { createRandomSource } from './rng'; import { createRandomSource } from './rng';
import type { import type {
ActionPreview,
ActionResult, ActionResult,
CombatState,
ConditionRule,
EdgeView,
Effect,
EnemyDefinition,
GameAction, GameAction,
GameContent, GameContent,
GameState, GameState,
GameView,
InventoryEntry,
InventoryViewEntry,
LogEntry,
LogTone,
PendingEventView,
PlaceActionDefinition, PlaceActionDefinition,
PlaceDefinition,
PlayerState, PlayerState,
QuestDefinition,
QuestRuntimeState, QuestRuntimeState,
QuestStep,
QuestView,
RecipeDefinition,
RecipeView,
SaveMeta, SaveMeta,
StatLine,
StatusId,
TradeOfferView,
WorldState,
} from './types'; } 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 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 withUpdatedMeta = (state: GameState) => {
const cloneGameState = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T; state.meta.updatedAt = new Date().toISOString();
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 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 getPlayerWeapon = (state: GameState, content: GameContent) => {
const equipped = state.player.equipment.weapon; const equipped = state.player.equipment.weapon;
if (equipped) return content.items[equipped]; if (equipped) return content.items[equipped];
@@ -300,112 +47,6 @@ const getPlayerArmor = (state: GameState, content: GameContent) => {
return undefined; 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']) => { const rollLoot = (state: GameState, content: GameContent, lootTable: PlaceActionDefinition['lootTable']) => {
if (!lootTable || lootTable.length === 0) return; if (!lootTable || lootTable.length === 0) return;
const rng = createRandomSource(state.meta.rngState); const rng = createRandomSource(state.meta.rngState);
@@ -439,50 +80,27 @@ const triggerEventFromPool = (
state.world.pendingEvent = { eventId: selected.id, source }; state.world.pendingEvent = { eventId: selected.id, source };
}; };
const getQuestStepDone = (state: GameState, step: QuestStep) => { const getCraftingOwnedCount = (state: GameState, itemId: string) => {
switch (step.kind) { const bagCount = getItemCount(state.player, itemId);
case 'reach-place': if (!canAccessShelterStorage(state)) {
return state.player.placeId === step.placeId; return bagCount;
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;
} }
return bagCount + getItemCountFromEntries(state.player.storage, itemId);
}; };
const reconcileQuests = (state: GameState, content: GameContent) => { const consumeCraftingItems = (state: GameState, itemId: string, count: number) => {
for (const quest of Object.values(content.quests)) { const bagResult = removeItemFromEntries(state.player.inventory, itemId, count);
const runtime = state.player.quests[quest.id]; state.player.inventory = bagResult.inventory;
if (runtime.state === 'locked' && hasConditions(state, quest.autoStart)) { const remaining = count - bagResult.removedCount;
runtime.state = 'active'; if (remaining <= 0) {
pushLog(state, `接到任务:${quest.title}`, 'good'); return true;
}
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 withUpdatedMeta = (state: GameState) => { const storageResult = removeItemFromEntries(state.player.storage, itemId, remaining);
state.meta.updatedAt = new Date().toISOString(); state.player.storage = storageResult.inventory;
return storageResult.success;
}; };
const doTravel = (state: GameState, content: GameContent, toPlaceId: string) => { 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; state.meta.rngState = rng.state;
triggerEventFromPool(state, content, content.places[toPlaceId].arrivalEventPool, 'arrival'); triggerEventFromPool(state, content, content.places[toPlaceId].arrivalEventPool, 'arrival');
} }
} };
const doPlaceAction = (state: GameState, content: GameContent, actionId: string) => { const doPlaceAction = (state: GameState, content: GameContent, actionId: string) => {
const place = getCurrentPlace(state, content); const place = getCurrentPlace(state, content);
@@ -612,7 +230,7 @@ const doCraft = (state: GameState, content: GameContent, recipeId: string) => {
return; 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) { if (missingInput) {
pushLog( pushLog(
state, state,
@@ -623,7 +241,7 @@ const doCraft = (state: GameState, content: GameContent, recipeId: string) => {
} }
for (const input of recipe.inputs) { 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}`); advanceTime(state, content, recipe.timeCostMin, `制作 ${recipe.name}`);
@@ -674,6 +292,63 @@ const doUnequip = (state: GameState, slot: 'weapon' | 'body' | 'tool') => {
state.player.equipment[slot] = undefined; 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 doTrade = (state: GameState, content: GameContent, offerId: string) => {
const place = getCurrentPlace(state, content); const place = getCurrentPlace(state, content);
const offer = place.tradeOffers?.find((entry) => entry.id === offerId); const offer = place.tradeOffers?.find((entry) => entry.id === offerId);
@@ -714,7 +389,11 @@ const doRest = (state: GameState, content: GameContent, minutes: number) => {
pushLog(state, '你稍微喘了口气。', 'good'); 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) { if (!state.world.activeCombat) {
pushLog(state, '当前没有战斗。', 'warn'); pushLog(state, '当前没有战斗。', 'warn');
return; return;
@@ -767,7 +446,11 @@ const doCombatMove = (state: GameState, content: GameContent, move: 'attack' | '
combat.distance = Math.min(6, combat.distance + 1); combat.distance = Math.min(6, combat.distance + 1);
resolved = `你向后拉开身位。距离变为 ${combat.distance}`; resolved = `你向后拉开身位。距离变为 ${combat.distance}`;
} else if (move === 'escape') { } 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)) { if (rng.chance(escapeChance)) {
advanceTime(state, content, 12, '撤离战斗'); advanceTime(state, content, 12, '撤离战斗');
applyStatChange(state, 'energy', -6); applyStatChange(state, 'energy', -6);
@@ -798,7 +481,11 @@ const doCombatMove = (state: GameState, content: GameContent, move: 'attack' | '
return; 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)) { if (rng.chance(enemyHitChance)) {
let damage = rng.int(enemy.damageMin, enemy.damageMax); let damage = rng.int(enemy.damageMin, enemy.damageMax);
const armorValue = (armor?.combat?.armor ?? 0) + (combat.playerGuarding ? 3 : 0) + armorPenalty; 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); applyStatChange(state, 'energy', -5);
handleThresholdDamage(state);
combat.round += 1; combat.round += 1;
combat.playerGuarding = false; combat.playerGuarding = false;
combat.enemyGuarding = rng.chance(0.25); combat.enemyGuarding = rng.chance(0.25);
state.meta.rngState = rng.state; if (state.player.stats.life <= 0) {
}; state.world.gameOver = true;
state.world.deathReason = '生命耗尽';
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 ?? '这里已经空了';
} }
return { state.meta.rngState = rng.state;
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,
};
}),
};
}; };
export const createNewGameState = ( export const createNewGameState = (
@@ -989,6 +563,11 @@ export const createNewGameState = (
{ itemId: 'shiv', count: 1, durability: null }, { itemId: 'shiv', count: 1, durability: null },
{ itemId: 'crowbar', 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: { equipment: {
weapon: 'shiv', weapon: 'shiv',
tool: 'crowbar', tool: 'crowbar',
@@ -1007,7 +586,7 @@ export const createNewGameState = (
), ),
}; };
const world: WorldState = { const world = {
time: { time: {
totalMinutes: 8 * 60, totalMinutes: 8 * 60,
day: 1, day: 1,
@@ -1028,6 +607,7 @@ export const createNewGameState = (
} }
pushLog(state, '你在避难点醒来,空气里全是尘土和铁锈味。', 'info'); pushLog(state, '你在避难点醒来,空气里全是尘土和铁锈味。', 'info');
pushLog(state, '避难所的旧储物柜里还留着一点能用的补给。', 'info');
reconcileQuests(state, content); reconcileQuests(state, content);
return state; return state;
}; };
@@ -1077,6 +657,12 @@ export const applyGameAction = (
case 'unequip-item': case 'unequip-item':
doUnequip(state, action.slot); doUnequip(state, action.slot);
break; 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': case 'trade':
doTrade(state, content, action.offerId); doTrade(state, content, action.offerId);
break; break;
@@ -1096,98 +682,4 @@ export const applyGameAction = (
return { state, changed: true }; return { state, changed: true };
}; };
export const buildGameView = (state: GameState, content: GameContent): GameView => { export { buildGameView } from './view-projection';
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,
};
};