feat: 实现游戏核心功能与前端界面

- 添加游戏核心系统:战斗、任务、日志、随机数生成等
- 实现前端主界面、HUD、底部导航和各类卡片组件
- 添加用户认证系统与游戏存档持久化
- 配置项目基础架构与开发环境
- 补充文档说明与Docker部署支持
This commit is contained in:
2026-04-28 22:16:58 +08:00
parent 2e1b58bada
commit e783e6e533
124 changed files with 9759 additions and 1 deletions
+246
View File
@@ -0,0 +1,246 @@
import type {
ActionPreview,
EdgeView,
GameContent,
GameState,
GameView,
InventoryEntry,
InventoryViewEntry,
PendingEventView,
PlaceDefinition,
PlaceActionDefinition,
RecipeDefinition,
RecipeView,
TradeOfferView,
} from './types';
import { canAccessShelterStorage, buildSurvivalView } from './systems/survival-system';
import { copyStats, dangerLabel, formatTime } from './systems/shared';
import { getConditionFailure, getCurrentPlace, hasConditions, ensurePlaceRuntime } from './systems/place-system';
import { buildQuestView } from './systems/quest-system';
import { getContainerUsage, getInventoryUsage, getItemCount, getItemCountFromEntries, isItemEquipped } from './systems/inventory-system';
const buildInventoryEntries = (
entries: InventoryEntry[],
state: GameState,
content: GameContent,
): InventoryViewEntry[] =>
entries.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: isItemEquipped(state.player, entry.itemId),
};
});
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 {
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 getRecipeOwnedCount = (state: GameState, recipeInputId: string) => {
const bagCount = getItemCount(state.player, recipeInputId);
if (!canAccessShelterStorage(state)) {
return bagCount;
}
return bagCount + getItemCountFromEntries(state.player.storage, recipeInputId);
};
const buildRecipeView = (state: GameState, content: GameContent, recipe: RecipeDefinition): RecipeView => {
const requirementFailure = recipe.requirements?.find((rule) => !hasConditions(state, [rule]));
const missing = recipe.inputs.find((input) => getRecipeOwnedCount(state, 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: getRecipeOwnedCount(state, 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 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 buildGameView = (state: GameState, content: GameContent): GameView => {
const place = getCurrentPlace(state, content);
const inventory = buildInventoryEntries(state.player.inventory, state, content);
const storage = buildInventoryEntries(state.player.storage, state, content).map((entry) => ({
...entry,
equipped: false,
}));
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,
storage,
storageUsage: getContainerUsage(state.player.storage, content),
storageCapacity: content.storageCapacity,
storageAccessible: canAccessShelterStorage(state),
},
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)),
survival: buildSurvivalView(state),
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(-40).reverse(),
flags: { ...state.world.flags },
gameOver: state.world.gameOver,
victory: state.world.victory,
deathReason: state.world.deathReason,
};
};