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
+128 -636
View File
@@ -1,293 +1,40 @@
import { createRandomSource } from './rng';
import type {
ActionPreview,
ActionResult,
CombatState,
ConditionRule,
EdgeView,
Effect,
EnemyDefinition,
GameAction,
GameContent,
GameState,
GameView,
InventoryEntry,
InventoryViewEntry,
LogEntry,
LogTone,
PendingEventView,
PlaceActionDefinition,
PlaceDefinition,
PlayerState,
QuestDefinition,
QuestRuntimeState,
QuestStep,
QuestView,
RecipeDefinition,
RecipeView,
SaveMeta,
StatLine,
StatusId,
TradeOfferView,
WorldState,
} from './types';
import { applyEffects, applyStatChange } from './systems/effect-system';
import {
addItem,
addItemToStorage,
getItemCount,
getItemCountFromEntries,
isItemEquipped,
removeItem,
removeItemFromEntries,
removeItemFromStorage,
} from './systems/inventory-system';
import { pushLog } from './systems/log-system';
import { ensurePlaceRuntime, getConditionFailure, getCurrentPlace, hasConditions } from './systems/place-system';
import { reconcileQuests } from './systems/quest-system';
import { clamp, cloneGameState } from './systems/shared';
import { advanceTime, canAccessShelterStorage, getStatusPenalty } from './systems/survival-system';
const SAVE_VERSION = 1;
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
const getEdge = (content: GameContent, from: string, to: string) =>
content.edges.find((edge) => edge.from === from && edge.to === to);
const copyStats = (stats: StatLine): StatLine => ({ ...stats });
const cloneGameState = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
const formatTime = (totalMinutes: number) => {
const day = Math.floor(totalMinutes / (24 * 60)) + 1;
const minuteOfDay = totalMinutes % (24 * 60);
const hours = Math.floor(minuteOfDay / 60)
.toString()
.padStart(2, '0');
const minutes = (minuteOfDay % 60).toString().padStart(2, '0');
return `Day ${day} · ${hours}:${minutes}`;
const withUpdatedMeta = (state: GameState) => {
state.meta.updatedAt = new Date().toISOString();
};
const dangerLabel = (danger: number) => {
if (danger <= 2) return '低风险';
if (danger <= 4) return '中风险';
if (danger <= 6) return '高风险';
return '致命风险';
};
const createLogEntry = (state: GameState, message: string, tone: LogTone = 'info'): LogEntry => ({
id: `log_${state.world.logs.length + 1}_${state.world.time.totalMinutes}`,
minute: state.world.time.totalMinutes,
tone,
message,
});
const pushLog = (state: GameState, message: string, tone: LogTone = 'info') => {
state.world.logs.push(createLogEntry(state, message, tone));
if (state.world.logs.length > 120) {
state.world.logs = state.world.logs.slice(-120);
}
};
const getItemCount = (player: PlayerState, itemId: string) =>
player.inventory
.filter((entry) => entry.itemId === itemId)
.reduce((total, entry) => total + entry.count, 0);
const getInventoryUsage = (state: GameState, content: GameContent) =>
state.player.inventory.reduce((total, entry) => {
const item = content.items[entry.itemId];
return total + item.volume * entry.count;
}, 0);
const sortInventory = (inventory: InventoryEntry[]) =>
[...inventory].sort((a, b) => a.itemId.localeCompare(b.itemId));
const removeItem = (player: PlayerState, itemId: string, count: number) => {
let remaining = count;
player.inventory = player.inventory.flatMap((entry) => {
if (entry.itemId !== itemId || remaining <= 0) {
return [entry];
}
if (entry.count <= remaining) {
remaining -= entry.count;
return [];
}
const updated = { ...entry, count: entry.count - remaining };
remaining = 0;
return [updated];
});
return remaining === 0;
};
const addItem = (state: GameState, content: GameContent, itemId: string, count: number) => {
const item = content.items[itemId];
let added = 0;
while (added < count) {
const currentUsage = getInventoryUsage(state, content);
if (currentUsage + item.volume > content.inventoryCapacity) {
break;
}
const stack = state.player.inventory.find(
(entry) => entry.itemId === itemId && entry.count < item.stackLimit,
);
if (stack) {
stack.count += 1;
} else {
state.player.inventory.push({ itemId, count: 1, durability: null });
}
added += 1;
}
state.player.inventory = sortInventory(state.player.inventory);
return added;
};
const hasConditions = (state: GameState, conditions: ConditionRule[] | undefined) => {
if (!conditions || conditions.length === 0) return true;
return conditions.every((condition) => {
switch (condition.kind) {
case 'flag':
return (state.world.flags[condition.flag] ?? false) === (condition.value ?? true);
case 'has-item':
return getItemCount(state.player, condition.itemId) >= condition.count;
case 'quest-state':
return state.player.quests[condition.questId]?.state === condition.state;
case 'place':
return state.player.placeId === condition.placeId;
case 'day-at-least':
return state.world.time.day >= condition.day;
case 'stat-at-most':
return state.player.stats[condition.stat] <= condition.value;
case 'stat-at-least':
return state.player.stats[condition.stat] >= condition.value;
default:
return false;
}
});
};
const getConditionFailure = (condition: ConditionRule | undefined, content: GameContent) => {
if (!condition) return undefined;
switch (condition.kind) {
case 'flag':
return `需要触发世界标记 ${condition.flag}`;
case 'has-item':
return `缺少 ${content.items[condition.itemId]?.name ?? condition.itemId} x${condition.count}`;
case 'quest-state':
return `需要任务条件:${condition.questId}`;
case 'place':
return `需要前往 ${content.places[condition.placeId]?.name ?? condition.placeId}`;
case 'day-at-least':
return `至少生存到 Day ${condition.day}`;
case 'stat-at-most':
return `${condition.stat} 需要不高于 ${condition.value}`;
case 'stat-at-least':
return `${condition.stat} 需要不低于 ${condition.value}`;
default:
return '条件不足';
}
};
const getCurrentPlace = (state: GameState, content: GameContent) => content.places[state.player.placeId];
const ensurePlaceRuntime = (state: GameState, content: GameContent, placeId: string) => {
if (state.world.places[placeId]) {
return state.world.places[placeId];
}
const place = content.places[placeId];
const stocks: Record<string, number> = {};
for (const action of place.actions) {
if (action.stock) {
stocks[action.id] = action.stock.initial;
}
}
state.world.places[placeId] = {
placeId,
heat: 0,
stocks,
lastRefreshMinute: state.world.time.totalMinutes,
visited: placeId === state.player.placeId,
};
return state.world.places[placeId];
};
const applyStatChange = (state: GameState, stat: StatusId, amount: number) => {
const max = state.player.maxStats[stat];
state.player.stats[stat] = clamp(state.player.stats[stat] + amount, 0, max);
};
const applyEffects = (state: GameState, content: GameContent, effects: Effect[]) => {
for (const effect of effects) {
switch (effect.type) {
case 'add-item': {
const added = addItem(state, content, effect.itemId, effect.count);
const item = content.items[effect.itemId];
if (added > 0) {
pushLog(state, `获得 ${item.name} x${added}`, 'good');
}
if (added < effect.count) {
pushLog(state, `${item.name}${effect.count - added} 件因为背包已满而丢失`, 'warn');
}
break;
}
case 'remove-item':
removeItem(state.player, effect.itemId, effect.count);
pushLog(state, `消耗 ${content.items[effect.itemId].name} x${effect.count}`, 'info');
break;
case 'change-stat':
applyStatChange(state, effect.stat, effect.amount);
break;
case 'add-buff': {
const existing = state.player.buffs.find((buff) => buff.buffId === effect.buffId);
if (existing) {
existing.remainingMin = Math.max(existing.remainingMin, effect.durationMin);
existing.stacks += effect.stacks ?? 1;
} else {
state.player.buffs.push({
buffId: effect.buffId,
remainingMin: effect.durationMin,
stacks: effect.stacks ?? 1,
});
}
pushLog(state, `获得状态:${content.buffs[effect.buffId].name}`, 'good');
break;
}
case 'remove-buff':
state.player.buffs = state.player.buffs.filter((buff) => buff.buffId !== effect.buffId);
break;
case 'set-flag':
state.world.flags[effect.flag] = effect.value;
break;
case 'unlock-recipe':
if (!state.player.knownRecipes.includes(effect.recipeId)) {
state.player.knownRecipes.push(effect.recipeId);
pushLog(state, `解锁配方:${content.recipes[effect.recipeId].name}`, 'good');
}
break;
case 'activate-quest':
if (state.player.quests[effect.questId]) {
state.player.quests[effect.questId].state = 'active';
pushLog(state, `任务已激活:${content.quests[effect.questId].title}`, 'good');
}
break;
case 'start-combat':
if (!state.world.activeCombat) {
const enemy = content.enemies[effect.enemyId];
state.world.activeCombat = createCombat(enemy);
pushLog(state, `遭遇敌人:${enemy.name}`, 'combat');
}
break;
case 'log':
pushLog(state, effect.message, effect.tone ?? 'info');
break;
}
}
};
const createCombat = (enemy: EnemyDefinition): CombatState => ({
enemyId: enemy.id,
enemyLife: enemy.life,
distance: Math.max(2, enemy.range),
round: 1,
playerGuarding: false,
enemyGuarding: false,
log: [{ round: 1, message: `${enemy.name} 出现了。` }],
rewardClaimed: false,
});
const getPlayerWeapon = (state: GameState, content: GameContent) => {
const equipped = state.player.equipment.weapon;
if (equipped) return content.items[equipped];
@@ -300,112 +47,6 @@ const getPlayerArmor = (state: GameState, content: GameContent) => {
return undefined;
};
const getStatusPenalty = (state: GameState) => {
let hitPenalty = 0;
let riskBonus = 0;
let armorPenalty = 0;
if (state.player.stats.energy < 30) hitPenalty -= 0.1;
if (state.player.stats.thirst < 30) {
hitPenalty -= 0.08;
riskBonus += 0.1;
}
if (state.player.stats.hunger < 30) hitPenalty -= 0.05;
if (state.player.stats.sanity < 25) hitPenalty -= 0.06;
if (state.player.stats.radiation > 60) armorPenalty -= 1;
return { hitPenalty, riskBonus, armorPenalty };
};
const updateTimeFields = (state: GameState) => {
state.world.time.day = Math.floor(state.world.time.totalMinutes / (24 * 60)) + 1;
state.world.time.minuteOfDay = state.world.time.totalMinutes % (24 * 60);
};
const refreshPlaceStocks = (state: GameState, content: GameContent, deltaMin: number) => {
for (const place of Object.values(content.places)) {
const runtime = ensurePlaceRuntime(state, content, place.id);
runtime.heat = Math.max(0, runtime.heat - deltaMin / 120);
for (const action of place.actions) {
if (!action.stock) continue;
const current = runtime.stocks[action.id] ?? action.stock.initial;
const growth = Math.floor(deltaMin / action.stock.refreshMin) * action.stock.amountPerRefresh;
runtime.stocks[action.id] = clamp(current + growth, 0, action.stock.max);
}
runtime.lastRefreshMinute = state.world.time.totalMinutes;
}
};
const tickBuffs = (state: GameState, content: GameContent, minutes: number) => {
state.player.buffs = state.player.buffs.flatMap((buff) => {
const definition = content.buffs[buff.buffId];
const remaining = buff.remainingMin - minutes;
if (definition.modifiers) {
for (const [stat, value] of Object.entries(definition.modifiers) as [StatusId, number][]) {
applyStatChange(state, stat, value * minutes);
}
}
if (remaining <= 0) {
pushLog(state, `${definition.name} 已结束`, 'info');
return [];
}
return [{ ...buff, remainingMin: remaining }];
});
};
const handleThresholdDamage = (state: GameState) => {
if (state.player.stats.thirst <= 0) {
applyStatChange(state, 'life', -6);
} else if (state.player.stats.thirst < 10) {
applyStatChange(state, 'life', -2);
}
if (state.player.stats.hunger <= 0) {
applyStatChange(state, 'life', -4);
}
if (state.player.stats.sanity <= 0) {
state.world.gameOver = true;
state.world.deathReason = '理智崩溃';
}
if (state.player.stats.life <= 0) {
state.world.gameOver = true;
state.world.deathReason = '生命耗尽';
}
};
const advanceTime = (state: GameState, content: GameContent, minutes: number, reason: string) => {
const place = getCurrentPlace(state, content);
const modifiers = place.modifiers ?? {};
const hungerDecay = 0.03 + (modifiers.hunger ?? 0);
const thirstDecay = 0.05 + (modifiers.thirst ?? 0);
const energyDecay = 0.02 + (modifiers.energy ?? 0);
const sanityDecay = 0.01 + (modifiers.sanity ?? 0);
const radiationGain = Math.max(0, modifiers.radiation ?? 0);
applyStatChange(state, 'hunger', -hungerDecay * minutes);
applyStatChange(state, 'thirst', -thirstDecay * minutes);
applyStatChange(state, 'energy', -energyDecay * minutes);
applyStatChange(state, 'sanity', -sanityDecay * minutes);
applyStatChange(state, 'radiation', radiationGain * minutes);
tickBuffs(state, content, minutes);
state.world.time.totalMinutes += minutes;
updateTimeFields(state);
refreshPlaceStocks(state, content, minutes);
handleThresholdDamage(state);
pushLog(state, `${reason},时间推进 ${minutes} 分钟`, 'info');
};
const getEdge = (content: GameContent, from: string, to: string) =>
content.edges.find((edge) => edge.from === from && edge.to === to);
const rollLoot = (state: GameState, content: GameContent, lootTable: PlaceActionDefinition['lootTable']) => {
if (!lootTable || lootTable.length === 0) return;
const rng = createRandomSource(state.meta.rngState);
@@ -439,50 +80,27 @@ const triggerEventFromPool = (
state.world.pendingEvent = { eventId: selected.id, source };
};
const getQuestStepDone = (state: GameState, step: QuestStep) => {
switch (step.kind) {
case 'reach-place':
return state.player.placeId === step.placeId;
case 'have-item':
return getItemCount(state.player, step.itemId) >= step.count;
case 'flag':
return (state.world.flags[step.flag] ?? false) === (step.value ?? true);
case 'survive-day':
return state.world.time.day >= step.day;
default:
return false;
const getCraftingOwnedCount = (state: GameState, itemId: string) => {
const bagCount = getItemCount(state.player, itemId);
if (!canAccessShelterStorage(state)) {
return bagCount;
}
return bagCount + getItemCountFromEntries(state.player.storage, itemId);
};
const reconcileQuests = (state: GameState, content: GameContent) => {
for (const quest of Object.values(content.quests)) {
const runtime = state.player.quests[quest.id];
const consumeCraftingItems = (state: GameState, itemId: string, count: number) => {
const bagResult = removeItemFromEntries(state.player.inventory, itemId, count);
state.player.inventory = bagResult.inventory;
if (runtime.state === 'locked' && hasConditions(state, quest.autoStart)) {
runtime.state = 'active';
pushLog(state, `接到任务:${quest.title}`, 'good');
}
if (runtime.state !== 'active') continue;
const step = quest.steps[runtime.currentStepIndex];
if (!step) continue;
if (getQuestStepDone(state, step)) {
runtime.currentStepIndex += 1;
pushLog(state, `任务推进:${quest.title} - ${step.text}`, 'good');
if (runtime.currentStepIndex >= quest.steps.length) {
runtime.state = 'completed';
pushLog(state, `任务完成:${quest.title}`, 'good');
applyEffects(state, content, quest.rewards);
}
}
const remaining = count - bagResult.removedCount;
if (remaining <= 0) {
return true;
}
};
const withUpdatedMeta = (state: GameState) => {
state.meta.updatedAt = new Date().toISOString();
const storageResult = removeItemFromEntries(state.player.storage, itemId, remaining);
state.player.storage = storageResult.inventory;
return storageResult.success;
};
const doTravel = (state: GameState, content: GameContent, toPlaceId: string) => {
@@ -512,7 +130,7 @@ const doTravel = (state: GameState, content: GameContent, toPlaceId: string) =>
state.meta.rngState = rng.state;
triggerEventFromPool(state, content, content.places[toPlaceId].arrivalEventPool, 'arrival');
}
}
};
const doPlaceAction = (state: GameState, content: GameContent, actionId: string) => {
const place = getCurrentPlace(state, content);
@@ -612,7 +230,7 @@ const doCraft = (state: GameState, content: GameContent, recipeId: string) => {
return;
}
const missingInput = recipe.inputs.find((input) => getItemCount(state.player, input.itemId) < input.count);
const missingInput = recipe.inputs.find((input) => getCraftingOwnedCount(state, input.itemId) < input.count);
if (missingInput) {
pushLog(
state,
@@ -623,7 +241,7 @@ const doCraft = (state: GameState, content: GameContent, recipeId: string) => {
}
for (const input of recipe.inputs) {
removeItem(state.player, input.itemId, input.count);
consumeCraftingItems(state, input.itemId, input.count);
}
advanceTime(state, content, recipe.timeCostMin, `制作 ${recipe.name}`);
@@ -674,6 +292,63 @@ const doUnequip = (state: GameState, slot: 'weapon' | 'body' | 'tool') => {
state.player.equipment[slot] = undefined;
};
const doStashItem = (state: GameState, content: GameContent, itemId: string, count: number) => {
if (!canAccessShelterStorage(state)) {
pushLog(state, '只有回到避难所才能整理仓储。', 'warn');
return;
}
if (isItemEquipped(state.player, itemId)) {
pushLog(state, '先卸下这件装备,再把它放回仓储。', 'warn');
return;
}
const available = getItemCount(state.player, itemId);
if (available <= 0) {
pushLog(state, '背包里没有这个物品。', 'warn');
return;
}
const moveCount = Math.min(available, count);
const addedCount = addItemToStorage(state, content, itemId, moveCount);
if (addedCount <= 0) {
pushLog(state, '避难所仓储已经装不下更多东西了。', 'warn');
return;
}
removeItem(state.player, itemId, addedCount);
pushLog(state, `已将 ${content.items[itemId].name} x${addedCount} 存入避难所仓储。`, 'good');
if (addedCount < moveCount) {
pushLog(state, '仓储空间不足,其余物资仍保留在背包中。', 'warn');
}
};
const doRetrieveItem = (state: GameState, content: GameContent, itemId: string, count: number) => {
if (!canAccessShelterStorage(state)) {
pushLog(state, '离开避难所后无法直接取回仓储物资。', 'warn');
return;
}
const stored = getItemCountFromEntries(state.player.storage, itemId);
if (stored <= 0) {
pushLog(state, '仓储里没有这件物品。', 'warn');
return;
}
const moveCount = Math.min(stored, count);
const addedCount = addItem(state, content, itemId, moveCount);
if (addedCount <= 0) {
pushLog(state, '背包已经满了,先腾出空间再取回物资。', 'warn');
return;
}
removeItemFromStorage(state, itemId, addedCount);
pushLog(state, `已从避难所仓储取回 ${content.items[itemId].name} x${addedCount}`, 'good');
if (addedCount < moveCount) {
pushLog(state, '背包容量不足,剩余物资仍留在仓储中。', 'warn');
}
};
const doTrade = (state: GameState, content: GameContent, offerId: string) => {
const place = getCurrentPlace(state, content);
const offer = place.tradeOffers?.find((entry) => entry.id === offerId);
@@ -714,7 +389,11 @@ const doRest = (state: GameState, content: GameContent, minutes: number) => {
pushLog(state, '你稍微喘了口气。', 'good');
};
const doCombatMove = (state: GameState, content: GameContent, move: 'attack' | 'defend' | 'advance' | 'retreat' | 'escape') => {
const doCombatMove = (
state: GameState,
content: GameContent,
move: 'attack' | 'defend' | 'advance' | 'retreat' | 'escape',
) => {
if (!state.world.activeCombat) {
pushLog(state, '当前没有战斗。', 'warn');
return;
@@ -767,7 +446,11 @@ const doCombatMove = (state: GameState, content: GameContent, move: 'attack' | '
combat.distance = Math.min(6, combat.distance + 1);
resolved = `你向后拉开身位。距离变为 ${combat.distance}`;
} else if (move === 'escape') {
const escapeChance = clamp(0.38 + state.player.attributes.agi * 0.05 + combat.distance * 0.08 - enemy.escapePressure, 0.1, 0.92);
const escapeChance = clamp(
0.38 + state.player.attributes.agi * 0.05 + combat.distance * 0.08 - enemy.escapePressure,
0.1,
0.92,
);
if (rng.chance(escapeChance)) {
advanceTime(state, content, 12, '撤离战斗');
applyStatChange(state, 'energy', -6);
@@ -798,7 +481,11 @@ const doCombatMove = (state: GameState, content: GameContent, move: 'attack' | '
return;
}
const enemyHitChance = clamp(enemy.hitRate + (enemy.range >= combat.distance ? 0.08 : -0.06) - (armor?.combat?.dodgeBonus ?? 0), 0.2, 0.88);
const enemyHitChance = clamp(
enemy.hitRate + (enemy.range >= combat.distance ? 0.08 : -0.06) - (armor?.combat?.dodgeBonus ?? 0),
0.2,
0.88,
);
if (rng.chance(enemyHitChance)) {
let damage = rng.int(enemy.damageMin, enemy.damageMax);
const armorValue = (armor?.combat?.armor ?? 0) + (combat.playerGuarding ? 3 : 0) + armorPenalty;
@@ -814,129 +501,16 @@ const doCombatMove = (state: GameState, content: GameContent, move: 'attack' | '
}
applyStatChange(state, 'energy', -5);
handleThresholdDamage(state);
combat.round += 1;
combat.playerGuarding = false;
combat.enemyGuarding = rng.chance(0.25);
state.meta.rngState = rng.state;
};
const buildActionPreview = (state: GameState, content: GameContent, action: PlaceActionDefinition): ActionPreview => {
const failed = action.requires?.find((rule) => !hasConditions(state, [rule]));
const runtime = ensurePlaceRuntime(state, content, state.player.placeId);
const remainingStock = action.stock ? runtime.stocks[action.id] ?? action.stock.initial : null;
let disabledReason = failed ? getConditionFailure(failed, content) : undefined;
if (!disabledReason && action.stock && remainingStock !== null && remainingStock <= 0) {
disabledReason = action.onEmptyText ?? '这里已经空了';
if (state.player.stats.life <= 0) {
state.world.gameOver = true;
state.world.deathReason = '生命耗尽';
}
return {
id: action.id,
name: action.name,
kind: action.kind,
desc: action.desc,
timeCostMin: action.timeCostMin,
energyCost: action.energyCost,
risk: action.risk,
rewardHint: action.rewardHint,
disabledReason,
remainingStock,
};
};
const buildRecipeView = (state: GameState, content: GameContent, recipe: RecipeDefinition): RecipeView => {
const requirementFailure = recipe.requirements?.find((rule) => !hasConditions(state, [rule]));
const missing = recipe.inputs.find((input) => getItemCount(state.player, input.itemId) < input.count);
const craftable = state.player.knownRecipes.includes(recipe.id) && !requirementFailure && !missing;
return {
id: recipe.id,
name: recipe.name,
desc: recipe.desc,
timeCostMin: recipe.timeCostMin,
energyCost: recipe.energyCost,
craftable,
disabledReason:
!state.player.knownRecipes.includes(recipe.id)
? '尚未解锁'
: requirementFailure
? getConditionFailure(requirementFailure, content)
: missing
? `缺少 ${content.items[missing.itemId].name}`
: undefined,
inputs: recipe.inputs.map((input) => ({
itemId: input.itemId,
name: content.items[input.itemId].name,
count: input.count,
owned: getItemCount(state.player, input.itemId),
})),
outputs: recipe.outputs.map((output) => ({
itemId: output.itemId,
name: content.items[output.itemId].name,
count: output.count,
})),
};
};
const buildTradeView = (state: GameState, content: GameContent, place: PlaceDefinition): TradeOfferView[] =>
(place.tradeOffers ?? []).map((offer) => {
const missing = offer.costs.find((cost) => getItemCount(state.player, cost.itemId) < cost.count);
return {
id: offer.id,
name: offer.name,
desc: offer.desc,
available: !missing,
disabledReason: missing ? `缺少 ${content.items[missing.itemId].name}` : undefined,
gives: offer.gives.map((entry) => ({
itemId: entry.itemId,
name: content.items[entry.itemId].name,
count: entry.count,
})),
costs: offer.costs.map((entry) => ({
itemId: entry.itemId,
name: content.items[entry.itemId].name,
count: entry.count,
owned: getItemCount(state.player, entry.itemId),
})),
};
});
const buildQuestView = (state: GameState, quest: QuestDefinition): QuestView => {
const runtime = state.player.quests[quest.id];
return {
id: quest.id,
title: quest.title,
desc: quest.desc,
type: quest.type,
state: runtime.state,
currentStepIndex: runtime.currentStepIndex,
steps: quest.steps.map((step, index) => ({
text: step.text,
done: index < runtime.currentStepIndex || getQuestStepDone(state, step),
})),
};
};
const buildPendingEventView = (state: GameState, content: GameContent): PendingEventView | null => {
if (!state.world.pendingEvent) return null;
const event = content.events[state.world.pendingEvent.eventId];
return {
id: event.id,
title: event.title,
text: event.text,
options: event.options.map((option) => {
const failed = option.conditions?.find((rule) => !hasConditions(state, [rule]));
return {
id: option.id,
text: option.text,
disabledReason: failed ? getConditionFailure(failed, content) : undefined,
};
}),
};
state.meta.rngState = rng.state;
};
export const createNewGameState = (
@@ -989,6 +563,11 @@ export const createNewGameState = (
{ itemId: 'shiv', count: 1, durability: null },
{ itemId: 'crowbar', count: 1, durability: null },
],
storage: [
{ itemId: 'field_bandage', count: 1, durability: null },
{ itemId: 'water_purified', count: 1, durability: null },
{ itemId: 'leather_coat', count: 1, durability: null },
],
equipment: {
weapon: 'shiv',
tool: 'crowbar',
@@ -1007,7 +586,7 @@ export const createNewGameState = (
),
};
const world: WorldState = {
const world = {
time: {
totalMinutes: 8 * 60,
day: 1,
@@ -1028,6 +607,7 @@ export const createNewGameState = (
}
pushLog(state, '你在避难点醒来,空气里全是尘土和铁锈味。', 'info');
pushLog(state, '避难所的旧储物柜里还留着一点能用的补给。', 'info');
reconcileQuests(state, content);
return state;
};
@@ -1077,6 +657,12 @@ export const applyGameAction = (
case 'unequip-item':
doUnequip(state, action.slot);
break;
case 'stash-item':
doStashItem(state, content, action.itemId, action.count);
break;
case 'retrieve-item':
doRetrieveItem(state, content, action.itemId, action.count);
break;
case 'trade':
doTrade(state, content, action.offerId);
break;
@@ -1096,98 +682,4 @@ export const applyGameAction = (
return { state, changed: true };
};
export const buildGameView = (state: GameState, content: GameContent): GameView => {
const place = getCurrentPlace(state, content);
const inventory: InventoryViewEntry[] = state.player.inventory.map((entry) => {
const item = content.items[entry.itemId];
return {
...entry,
name: item.name,
type: item.type,
desc: item.desc,
volume: item.volume,
equipSlot: item.equipSlot,
canUse: Boolean(item.effects?.length),
equipped: Object.values(state.player.equipment).includes(entry.itemId),
};
});
const edges: EdgeView[] = content.edges
.filter((edge) => edge.from === state.player.placeId)
.map((edge) => {
const failed = edge.conditions?.find((rule) => !hasConditions(state, [rule]));
return {
from: edge.from,
to: edge.to,
travelTimeMin: edge.travelTimeMin,
risk: edge.risk,
blockedReason: failed ? getConditionFailure(failed, content) : undefined,
};
});
return {
meta: state.meta,
header: {
placeName: place.name,
placeDesc: place.desc,
timeLabel: formatTime(state.world.time.totalMinutes),
riskLabel: dangerLabel(place.dangerLevel),
},
player: {
name: state.player.name,
stats: copyStats(state.player.stats),
maxStats: copyStats(state.player.maxStats),
attributes: { ...state.player.attributes },
equipment: { ...state.player.equipment },
inventory,
inventoryUsage: getInventoryUsage(state, content),
inventoryCapacity: content.inventoryCapacity,
},
map: {
places: Object.values(content.places).map((entry) => ({
id: entry.id,
name: entry.name,
desc: entry.desc,
dangerLevel: entry.dangerLevel,
tags: entry.tags,
visited: state.world.places[entry.id]?.visited ?? false,
current: entry.id === state.player.placeId,
})),
edges,
},
place: {
id: place.id,
name: place.name,
desc: place.desc,
services: place.services,
actions: place.actions.map((action) => buildActionPreview(state, content, action)),
tradeOffers: buildTradeView(state, content, place),
},
recipes: Object.values(content.recipes).map((recipe) => buildRecipeView(state, content, recipe)),
quests: Object.values(content.quests).map((quest) => buildQuestView(state, quest)),
pendingEvent: buildPendingEventView(state, content),
combat: state.world.activeCombat
? {
enemyId: state.world.activeCombat.enemyId,
enemyName: content.enemies[state.world.activeCombat.enemyId].name,
enemyLife: state.world.activeCombat.enemyLife,
enemyMaxLife: content.enemies[state.world.activeCombat.enemyId].life,
distance: state.world.activeCombat.distance,
round: state.world.activeCombat.round,
log: state.world.activeCombat.log.slice(-8),
availableMoves: [
{ id: 'combat', move: 'attack', label: '攻击' },
{ id: 'combat', move: 'defend', label: '防御' },
{ id: 'combat', move: 'advance', label: '前进' },
{ id: 'combat', move: 'retreat', label: '后退' },
{ id: 'combat', move: 'escape', label: '逃跑' },
],
}
: null,
logs: state.world.logs.slice(-18),
flags: { ...state.world.flags },
gameOver: state.world.gameOver,
victory: state.world.victory,
deathReason: state.world.deathReason,
};
};
export { buildGameView } from './view-projection';