feat: 实现避难所仓储系统与生存状态HUD改进
- 新增避难所仓储功能,支持物品在随身背包与仓储间转移 - 添加生存状态图标资源与状态阈值告警系统 - 重构HUD界面,优化状态显示与操作流程 - 扩展游戏核心系统,增加仓储相关动作和校验 - 更新文档说明仓储规则与实现细节
This commit is contained in:
@@ -8,6 +8,7 @@ describe('TinyWaste gameplay loop', () => {
|
||||
|
||||
expect(state.player.placeId).toBe('home');
|
||||
expect(state.player.inventory.some((entry) => entry.itemId === 'shiv')).toBe(true);
|
||||
expect(state.player.storage.some((entry) => entry.itemId === 'leather_coat')).toBe(true);
|
||||
expect(state.player.quests.q_tutorial_water.state).toBe('active');
|
||||
});
|
||||
|
||||
@@ -42,4 +43,48 @@ describe('TinyWaste gameplay loop', () => {
|
||||
expect(view.header.placeName).toBe('近郊土路');
|
||||
expect(view.map.places.find((place) => place.current)?.id).toBe('roadside');
|
||||
});
|
||||
|
||||
it('moves items between field bag and shelter storage while at home', () => {
|
||||
const state = createNewGameState(gameContent, '测试幸存者');
|
||||
const initialBagWater = state.player.inventory.find((entry) => entry.itemId === 'water_dirty')?.count ?? 0;
|
||||
const initialStorageWater =
|
||||
state.player.storage.find((entry) => entry.itemId === 'water_dirty')?.count ?? 0;
|
||||
|
||||
const stashed = applyGameAction(state, gameContent, {
|
||||
type: 'stash-item',
|
||||
itemId: 'water_dirty',
|
||||
count: 1,
|
||||
});
|
||||
|
||||
const stashView = buildGameView(stashed.state, gameContent);
|
||||
expect(stashView.player.storageAccessible).toBe(true);
|
||||
expect(
|
||||
stashed.state.player.inventory.find((entry) => entry.itemId === 'water_dirty')?.count ?? 0,
|
||||
).toBe(initialBagWater - 1);
|
||||
expect(
|
||||
stashed.state.player.storage.find((entry) => entry.itemId === 'water_dirty')?.count ?? 0,
|
||||
).toBe(initialStorageWater + 1);
|
||||
|
||||
const retrieved = applyGameAction(stashed.state, gameContent, {
|
||||
type: 'retrieve-item',
|
||||
itemId: 'water_dirty',
|
||||
count: 1,
|
||||
});
|
||||
|
||||
expect(
|
||||
retrieved.state.player.inventory.find((entry) => entry.itemId === 'water_dirty')?.count ?? 0,
|
||||
).toBe(initialBagWater);
|
||||
});
|
||||
|
||||
it('builds survival alerts from threshold-based status states', () => {
|
||||
const state = createNewGameState(gameContent, '测试幸存者');
|
||||
state.player.stats.thirst = 20;
|
||||
state.player.stats.energy = 18;
|
||||
|
||||
const view = buildGameView(state, gameContent);
|
||||
|
||||
expect(view.survival.alerts.some((entry) => entry.stat === 'thirst')).toBe(true);
|
||||
expect(view.survival.alerts.some((entry) => entry.stat === 'energy')).toBe(true);
|
||||
expect(view.survival.headline).toContain('告警');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { GameContent } from '@tinywaste/game-core';
|
||||
export const gameContent: GameContent = {
|
||||
version: '0.1.0',
|
||||
inventoryCapacity: 36,
|
||||
storageCapacity: 84,
|
||||
items: {
|
||||
water_dirty: {
|
||||
id: 'water_dirty',
|
||||
|
||||
+128
-636
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user