feat: 实现游戏核心功能与前端界面
- 添加游戏核心系统:战斗、任务、日志、随机数生成等 - 实现前端主界面、HUD、底部导航和各类卡片组件 - 添加用户认证系统与游戏存档持久化 - 配置项目基础架构与开发环境 - 补充文档说明与Docker部署支持
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@tinywaste/content",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsup src/index.ts --format esm --dts --watch --out-dir dist",
|
||||
"build": "tsup src/index.ts --format esm --dts --clean --out-dir dist",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tinywaste/game-core": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { applyGameAction, buildGameView, createNewGameState } from '@tinywaste/game-core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { gameContent } from './index';
|
||||
|
||||
describe('TinyWaste gameplay loop', () => {
|
||||
it('creates a new survivor at the shelter with a starter loadout', () => {
|
||||
const state = createNewGameState(gameContent, '测试幸存者');
|
||||
|
||||
expect(state.player.placeId).toBe('home');
|
||||
expect(state.player.inventory.some((entry) => entry.itemId === 'shiv')).toBe(true);
|
||||
expect(state.player.quests.q_tutorial_water.state).toBe('active');
|
||||
});
|
||||
|
||||
it('collects rain water and advances time when performing the base scavenging action', () => {
|
||||
const state = createNewGameState(gameContent, '测试幸存者');
|
||||
const initialWater = state.player.inventory.find((entry) => entry.itemId === 'water_dirty')?.count ?? 0;
|
||||
const initialMinutes = state.world.time.totalMinutes;
|
||||
|
||||
const result = applyGameAction(state, gameContent, {
|
||||
type: 'perform-action',
|
||||
actionId: 'home_rain_barrel',
|
||||
});
|
||||
|
||||
const currentWater = result.state.player.inventory.find((entry) => entry.itemId === 'water_dirty')?.count ?? 0;
|
||||
|
||||
expect(result.state.world.time.totalMinutes).toBeGreaterThan(initialMinutes);
|
||||
expect(currentWater).toBeGreaterThan(initialWater);
|
||||
expect(result.state.world.logs.at(-1)?.message).toContain('浑水');
|
||||
});
|
||||
|
||||
it('updates the current place after travel and exposes the new location in the game view', () => {
|
||||
const state = createNewGameState(gameContent, '测试幸存者');
|
||||
const initialView = buildGameView(state, gameContent);
|
||||
|
||||
const result = applyGameAction(state, gameContent, {
|
||||
type: 'travel',
|
||||
toPlaceId: initialView.map.edges[0]?.to ?? 'roadside',
|
||||
});
|
||||
const view = buildGameView(result.state, gameContent);
|
||||
|
||||
expect(result.state.player.placeId).toBe('roadside');
|
||||
expect(view.header.placeName).toBe('近郊土路');
|
||||
expect(view.map.places.find((place) => place.current)?.id).toBe('roadside');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@tinywaste/game-core",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsup src/index.ts --format esm --dts --watch --out-dir dist",
|
||||
"build": "tsup src/index.ts --format esm --dts --clean --out-dir dist",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.1.12"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
export * from './engine';
|
||||
export * from './rng';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createRandomSource } from './rng';
|
||||
|
||||
describe('createRandomSource', () => {
|
||||
it('produces deterministic sequences for the same seed', () => {
|
||||
const a = createRandomSource(42);
|
||||
const b = createRandomSource(42);
|
||||
|
||||
expect([a.next(), a.next(), a.int(1, 10), a.chance(0.5)]).toEqual([
|
||||
b.next(),
|
||||
b.next(),
|
||||
b.int(1, 10),
|
||||
b.chance(0.5),
|
||||
]);
|
||||
});
|
||||
|
||||
it('always picks from the provided values', () => {
|
||||
const rng = createRandomSource(7);
|
||||
const options = ['home', 'roadside', 'market'] as const;
|
||||
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
expect(options).toContain(rng.pick(options));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
export interface RandomSource {
|
||||
state: number;
|
||||
next(): number;
|
||||
int(min: number, max: number): number;
|
||||
chance(probability: number): boolean;
|
||||
pick<T>(values: T[]): T;
|
||||
}
|
||||
|
||||
export const createRandomSource = (seed: number): RandomSource => {
|
||||
let state = seed >>> 0;
|
||||
|
||||
const next = () => {
|
||||
state += 0x6d2b79f5;
|
||||
let t = state;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
|
||||
return {
|
||||
get state() {
|
||||
return state >>> 0;
|
||||
},
|
||||
next,
|
||||
int(min, max) {
|
||||
return Math.floor(next() * (max - min + 1)) + min;
|
||||
},
|
||||
chance(probability) {
|
||||
return next() <= probability;
|
||||
},
|
||||
pick(values) {
|
||||
return values[Math.floor(next() * values.length)]!;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { CombatState, EnemyDefinition } from '../types';
|
||||
|
||||
export 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,
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Effect, GameContent, GameState, StatusId } from '../types';
|
||||
import { createCombat } from './combat-factory';
|
||||
import { addItem, removeItem } from './inventory-system';
|
||||
import { pushLog } from './log-system';
|
||||
import { clamp } from './shared';
|
||||
|
||||
export 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);
|
||||
};
|
||||
|
||||
export 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { GameContent, GameState, InventoryEntry, PlayerState } from '../types';
|
||||
|
||||
export const sortInventory = (inventory: InventoryEntry[]) =>
|
||||
[...inventory].sort((a, b) => a.itemId.localeCompare(b.itemId));
|
||||
|
||||
export const getItemCountFromEntries = (inventory: InventoryEntry[], itemId: string) =>
|
||||
inventory
|
||||
.filter((entry) => entry.itemId === itemId)
|
||||
.reduce((total, entry) => total + entry.count, 0);
|
||||
|
||||
export const getItemCount = (player: PlayerState, itemId: string) => getItemCountFromEntries(player.inventory, itemId);
|
||||
|
||||
export const getContainerUsage = (inventory: InventoryEntry[], content: GameContent) =>
|
||||
inventory.reduce((total, entry) => {
|
||||
const item = content.items[entry.itemId];
|
||||
return total + item.volume * entry.count;
|
||||
}, 0);
|
||||
|
||||
export const getInventoryUsage = (state: GameState, content: GameContent) =>
|
||||
getContainerUsage(state.player.inventory, content);
|
||||
|
||||
export const getStorageUsage = (state: GameState, content: GameContent) =>
|
||||
getContainerUsage(state.player.storage, content);
|
||||
|
||||
export const removeItemFromEntries = (inventory: InventoryEntry[], itemId: string, count: number) => {
|
||||
let remaining = count;
|
||||
const nextInventory = 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 {
|
||||
inventory: sortInventory(nextInventory),
|
||||
removedCount: count - remaining,
|
||||
success: remaining === 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const removeItem = (player: PlayerState, itemId: string, count: number) => {
|
||||
const result = removeItemFromEntries(player.inventory, itemId, count);
|
||||
player.inventory = result.inventory;
|
||||
return result.success;
|
||||
};
|
||||
|
||||
export const removeItemFromStorage = (state: GameState, itemId: string, count: number) => {
|
||||
const result = removeItemFromEntries(state.player.storage, itemId, count);
|
||||
state.player.storage = result.inventory;
|
||||
return result.success;
|
||||
};
|
||||
|
||||
export const addItemToEntries = (
|
||||
inventory: InventoryEntry[],
|
||||
content: GameContent,
|
||||
itemId: string,
|
||||
count: number,
|
||||
capacity: number,
|
||||
) => {
|
||||
const item = content.items[itemId];
|
||||
const nextInventory = inventory.map((entry) => ({ ...entry }));
|
||||
let addedCount = 0;
|
||||
|
||||
while (addedCount < count) {
|
||||
const currentUsage = getContainerUsage(nextInventory, content);
|
||||
if (currentUsage + item.volume > capacity) {
|
||||
break;
|
||||
}
|
||||
|
||||
const stack = nextInventory.find(
|
||||
(entry) => entry.itemId === itemId && entry.count < item.stackLimit && entry.durability == null,
|
||||
);
|
||||
|
||||
if (stack) {
|
||||
stack.count += 1;
|
||||
} else {
|
||||
nextInventory.push({ itemId, count: 1, durability: null });
|
||||
}
|
||||
|
||||
addedCount += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
inventory: sortInventory(nextInventory),
|
||||
addedCount,
|
||||
};
|
||||
};
|
||||
|
||||
export const addItem = (state: GameState, content: GameContent, itemId: string, count: number) => {
|
||||
const result = addItemToEntries(
|
||||
state.player.inventory,
|
||||
content,
|
||||
itemId,
|
||||
count,
|
||||
content.inventoryCapacity,
|
||||
);
|
||||
state.player.inventory = result.inventory;
|
||||
return result.addedCount;
|
||||
};
|
||||
|
||||
export const addItemToStorage = (state: GameState, content: GameContent, itemId: string, count: number) => {
|
||||
const result = addItemToEntries(
|
||||
state.player.storage,
|
||||
content,
|
||||
itemId,
|
||||
count,
|
||||
content.storageCapacity,
|
||||
);
|
||||
state.player.storage = result.inventory;
|
||||
return result.addedCount;
|
||||
};
|
||||
|
||||
export const isItemEquipped = (player: PlayerState, itemId: string) =>
|
||||
Object.values(player.equipment).includes(itemId);
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { GameState, LogEntry, LogTone } from '../types';
|
||||
|
||||
export 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,
|
||||
});
|
||||
|
||||
export 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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ConditionRule, GameContent, GameState } from '../types';
|
||||
import { getItemCount } from './inventory-system';
|
||||
|
||||
export 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;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export 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 '条件不足';
|
||||
}
|
||||
};
|
||||
|
||||
export const getCurrentPlace = (state: GameState, content: GameContent) => content.places[state.player.placeId];
|
||||
|
||||
export 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];
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { GameContent, GameState, QuestDefinition, QuestStep } from '../types';
|
||||
import { applyEffects } from './effect-system';
|
||||
import { getItemCount } from './inventory-system';
|
||||
import { pushLog } from './log-system';
|
||||
import { hasConditions } from './place-system';
|
||||
|
||||
export 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;
|
||||
}
|
||||
};
|
||||
|
||||
export const buildQuestView = (state: GameState, quest: QuestDefinition) => {
|
||||
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),
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
export const reconcileQuests = (state: GameState, content: GameContent) => {
|
||||
for (const quest of Object.values(content.quests)) {
|
||||
const runtime = state.player.quests[quest.id];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { StatLine } from '../types';
|
||||
|
||||
export const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
|
||||
|
||||
export const copyStats = (stats: StatLine): StatLine => ({ ...stats });
|
||||
|
||||
export const cloneGameState = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
|
||||
|
||||
export 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}`;
|
||||
};
|
||||
|
||||
export const dangerLabel = (danger: number) => {
|
||||
if (danger <= 2) return '低风险';
|
||||
if (danger <= 4) return '中风险';
|
||||
if (danger <= 6) return '高风险';
|
||||
return '致命风险';
|
||||
};
|
||||
@@ -0,0 +1,356 @@
|
||||
import { statusIds, type GameContent, type GameState, type StatusId, type StatusTier, type SurvivalStateView } from '../types';
|
||||
import { applyStatChange } from './effect-system';
|
||||
import { pushLog } from './log-system';
|
||||
import { ensurePlaceRuntime, getCurrentPlace } from './place-system';
|
||||
import { clamp } from './shared';
|
||||
|
||||
const SURVIVAL_COPY: Record<
|
||||
StatusId,
|
||||
Record<StatusTier, Omit<SurvivalStateView, 'stat' | 'label' | 'tier'>>
|
||||
> = {
|
||||
life: {
|
||||
safe: {
|
||||
summary: '状态稳定',
|
||||
impact: '可以承受一次中等失误。',
|
||||
recovery: '保持补给与休整节奏。',
|
||||
},
|
||||
strained: {
|
||||
summary: '轻度创伤',
|
||||
impact: '高风险行动会更快把你推入重伤区。',
|
||||
recovery: '用绷带或短休稳住生命线。',
|
||||
},
|
||||
critical: {
|
||||
summary: '重度创伤',
|
||||
impact: '战斗、防御和撤离都会连锁恶化。',
|
||||
recovery: '立刻治疗,避免继续深入。',
|
||||
},
|
||||
collapsed: {
|
||||
summary: '生命耗尽',
|
||||
impact: '当前幸存者已经无法继续行动。',
|
||||
recovery: '需要立刻回滚到有效治疗前的状态。',
|
||||
},
|
||||
},
|
||||
hunger: {
|
||||
safe: {
|
||||
summary: '热量充足',
|
||||
impact: '制作和远行还在舒适区。',
|
||||
recovery: '维持稳定食物循环。',
|
||||
},
|
||||
strained: {
|
||||
summary: '饥饿累积',
|
||||
impact: '精力恢复和行动效率开始下降。',
|
||||
recovery: '尽快补充主食或高热量食品。',
|
||||
},
|
||||
critical: {
|
||||
summary: '严重饥饿',
|
||||
impact: '连续行动会快速拖垮精力与命中。',
|
||||
recovery: '优先回营或立刻进食。',
|
||||
},
|
||||
collapsed: {
|
||||
summary: '饥饿崩溃',
|
||||
impact: '身体开始直接消耗生命维持运转。',
|
||||
recovery: '必须立刻进食并停止冒险。',
|
||||
},
|
||||
},
|
||||
thirst: {
|
||||
safe: {
|
||||
summary: '水分稳定',
|
||||
impact: '出行窗口仍然充裕。',
|
||||
recovery: '保持饮水储备在快捷栏。',
|
||||
},
|
||||
strained: {
|
||||
summary: '轻度脱水',
|
||||
impact: '路途风险和判断失误开始上升。',
|
||||
recovery: '优先喝水或返回避难所。',
|
||||
},
|
||||
critical: {
|
||||
summary: '严重脱水',
|
||||
impact: '探索容错快速缩窄,生命会被持续挤压。',
|
||||
recovery: '立刻饮水,避免继续移动。',
|
||||
},
|
||||
collapsed: {
|
||||
summary: '脱水崩溃',
|
||||
impact: '生命会在每次时间推进中直接流失。',
|
||||
recovery: '必须第一时间补水。',
|
||||
},
|
||||
},
|
||||
energy: {
|
||||
safe: {
|
||||
summary: '体能稳定',
|
||||
impact: '还能支持一轮完整行动。',
|
||||
recovery: '保持休整与行动节奏平衡。',
|
||||
},
|
||||
strained: {
|
||||
summary: '疲劳显现',
|
||||
impact: '命中与远行收益开始下降。',
|
||||
recovery: '安排短休,减少连续高压行动。',
|
||||
},
|
||||
critical: {
|
||||
summary: '极度疲劳',
|
||||
impact: '下一次遭遇很可能直接变成失控局面。',
|
||||
recovery: '优先休息,而不是继续搜刮。',
|
||||
},
|
||||
collapsed: {
|
||||
summary: '体能枯竭',
|
||||
impact: '会反向拖低理智并伤及生命。',
|
||||
recovery: '必须停下恢复。',
|
||||
},
|
||||
},
|
||||
sanity: {
|
||||
safe: {
|
||||
summary: '精神稳定',
|
||||
impact: '事件选择还在可控范围内。',
|
||||
recovery: '维持安全节奏与安定补给。',
|
||||
},
|
||||
strained: {
|
||||
summary: '精神波动',
|
||||
impact: '高压事件更容易滚向坏结局。',
|
||||
recovery: '休息、热食与安定环境都有帮助。',
|
||||
},
|
||||
critical: {
|
||||
summary: '理智滑坡',
|
||||
impact: '事件风险和战斗容错明显下降。',
|
||||
recovery: '尽快离开高压地点并恢复精神状态。',
|
||||
},
|
||||
collapsed: {
|
||||
summary: '理智崩溃',
|
||||
impact: '当前存档进入强制失败边界。',
|
||||
recovery: '必须在崩溃前完成恢复。',
|
||||
},
|
||||
},
|
||||
radiation: {
|
||||
safe: {
|
||||
summary: '污染可控',
|
||||
impact: '身体还能处理当前辐射剂量。',
|
||||
recovery: '保持净水与抗辐射药物储备。',
|
||||
},
|
||||
strained: {
|
||||
summary: '污染累积',
|
||||
impact: '长期收益开始被健康成本侵蚀。',
|
||||
recovery: '尽快净化、回避热区。',
|
||||
},
|
||||
critical: {
|
||||
summary: '高辐射负载',
|
||||
impact: '生命与治疗效率会持续受损。',
|
||||
recovery: '立刻服药并退出污染区域。',
|
||||
},
|
||||
collapsed: {
|
||||
summary: '辐射失控',
|
||||
impact: '身体正在快速失守。',
|
||||
recovery: '必须马上清辐与撤离。',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const STATUS_LABELS: Record<StatusId, string> = {
|
||||
life: '生命',
|
||||
hunger: '饥饿',
|
||||
thirst: '口渴',
|
||||
energy: '精力',
|
||||
sanity: '理智',
|
||||
radiation: '辐射',
|
||||
};
|
||||
|
||||
const STATUS_SEVERITY: Record<StatusTier, number> = {
|
||||
safe: 0,
|
||||
strained: 1,
|
||||
critical: 2,
|
||||
collapsed: 3,
|
||||
};
|
||||
|
||||
export const getStatusTier = (stat: StatusId, value: number, maxValue: number): StatusTier => {
|
||||
const ratio = maxValue <= 0 ? 0 : value / maxValue;
|
||||
|
||||
if (stat === 'radiation') {
|
||||
if (ratio >= 1) return 'collapsed';
|
||||
if (ratio >= 0.82) return 'critical';
|
||||
if (ratio >= 0.42) return 'strained';
|
||||
return 'safe';
|
||||
}
|
||||
|
||||
if (ratio <= 0) return 'collapsed';
|
||||
if (ratio <= 0.32) return 'critical';
|
||||
if (ratio <= 0.6) return 'strained';
|
||||
return 'safe';
|
||||
};
|
||||
|
||||
export const describeStatus = (
|
||||
stat: StatusId,
|
||||
value: number,
|
||||
maxValue: number,
|
||||
): SurvivalStateView => {
|
||||
const tier = getStatusTier(stat, value, maxValue);
|
||||
const copy = SURVIVAL_COPY[stat][tier];
|
||||
|
||||
return {
|
||||
stat,
|
||||
label: STATUS_LABELS[stat],
|
||||
tier,
|
||||
summary: copy.summary,
|
||||
impact: copy.impact,
|
||||
recovery: copy.recovery,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildSurvivalView = (state: GameState) => {
|
||||
const statuses = statusIds.map((stat) =>
|
||||
describeStatus(stat, state.player.stats[stat], state.player.maxStats[stat]),
|
||||
);
|
||||
const alerts = statuses
|
||||
.filter((entry) => entry.tier !== 'safe')
|
||||
.sort((left, right) => {
|
||||
const severityDelta = STATUS_SEVERITY[right.tier] - STATUS_SEVERITY[left.tier];
|
||||
if (severityDelta !== 0) return severityDelta;
|
||||
return statusIds.indexOf(left.stat) - statusIds.indexOf(right.stat);
|
||||
});
|
||||
|
||||
return {
|
||||
headline: alerts.length
|
||||
? `${alerts[0].label}告警:${alerts[0].summary}`
|
||||
: '生存状态稳定,适合继续行动。',
|
||||
statuses,
|
||||
alerts,
|
||||
};
|
||||
};
|
||||
|
||||
const tierPenalty = (tier: StatusTier, values: Record<Exclude<StatusTier, 'safe'>, number>) => {
|
||||
if (tier === 'safe') return 0;
|
||||
return values[tier];
|
||||
};
|
||||
|
||||
export const getStatusPenalty = (state: GameState) => {
|
||||
const thirstTier = getStatusTier('thirst', state.player.stats.thirst, state.player.maxStats.thirst);
|
||||
const hungerTier = getStatusTier('hunger', state.player.stats.hunger, state.player.maxStats.hunger);
|
||||
const energyTier = getStatusTier('energy', state.player.stats.energy, state.player.maxStats.energy);
|
||||
const sanityTier = getStatusTier('sanity', state.player.stats.sanity, state.player.maxStats.sanity);
|
||||
const radiationTier = getStatusTier(
|
||||
'radiation',
|
||||
state.player.stats.radiation,
|
||||
state.player.maxStats.radiation,
|
||||
);
|
||||
|
||||
return {
|
||||
hitPenalty:
|
||||
tierPenalty(energyTier, { strained: -0.05, critical: -0.11, collapsed: -0.18 }) +
|
||||
tierPenalty(thirstTier, { strained: -0.04, critical: -0.1, collapsed: -0.16 }) +
|
||||
tierPenalty(hungerTier, { strained: -0.02, critical: -0.06, collapsed: -0.1 }) +
|
||||
tierPenalty(sanityTier, { strained: -0.02, critical: -0.08, collapsed: -0.14 }),
|
||||
riskBonus:
|
||||
tierPenalty(thirstTier, { strained: 0.04, critical: 0.1, collapsed: 0.18 }) +
|
||||
tierPenalty(sanityTier, { strained: 0.02, critical: 0.08, collapsed: 0.16 }),
|
||||
armorPenalty:
|
||||
tierPenalty(radiationTier, { strained: 0, critical: -1, collapsed: -2 }),
|
||||
};
|
||||
};
|
||||
|
||||
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) => {
|
||||
const thirstTier = getStatusTier('thirst', state.player.stats.thirst, state.player.maxStats.thirst);
|
||||
const hungerTier = getStatusTier('hunger', state.player.stats.hunger, state.player.maxStats.hunger);
|
||||
const energyTier = getStatusTier('energy', state.player.stats.energy, state.player.maxStats.energy);
|
||||
const sanityTier = getStatusTier('sanity', state.player.stats.sanity, state.player.maxStats.sanity);
|
||||
const radiationTier = getStatusTier(
|
||||
'radiation',
|
||||
state.player.stats.radiation,
|
||||
state.player.maxStats.radiation,
|
||||
);
|
||||
|
||||
if (thirstTier === 'collapsed') {
|
||||
applyStatChange(state, 'life', -6);
|
||||
} else if (thirstTier === 'critical') {
|
||||
applyStatChange(state, 'life', -2);
|
||||
}
|
||||
|
||||
if (hungerTier === 'collapsed') {
|
||||
applyStatChange(state, 'life', -4);
|
||||
} else if (hungerTier === 'critical') {
|
||||
applyStatChange(state, 'energy', -2);
|
||||
}
|
||||
|
||||
if (energyTier === 'collapsed') {
|
||||
applyStatChange(state, 'life', -1);
|
||||
applyStatChange(state, 'sanity', -2);
|
||||
}
|
||||
|
||||
if (radiationTier === 'collapsed') {
|
||||
applyStatChange(state, 'life', -4);
|
||||
applyStatChange(state, 'sanity', -2);
|
||||
} else if (radiationTier === 'critical') {
|
||||
applyStatChange(state, 'life', -1);
|
||||
}
|
||||
|
||||
if (sanityTier === 'collapsed') {
|
||||
state.world.gameOver = true;
|
||||
state.world.deathReason = '理智崩溃';
|
||||
}
|
||||
|
||||
if (state.player.stats.life <= 0) {
|
||||
state.world.gameOver = true;
|
||||
state.world.deathReason = '生命耗尽';
|
||||
}
|
||||
};
|
||||
|
||||
export 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');
|
||||
};
|
||||
|
||||
export const canAccessShelterStorage = (state: GameState) => state.player.placeId === 'home';
|
||||
@@ -0,0 +1,519 @@
|
||||
export const statusIds = [
|
||||
'life',
|
||||
'hunger',
|
||||
'thirst',
|
||||
'energy',
|
||||
'sanity',
|
||||
'radiation',
|
||||
] as const;
|
||||
|
||||
export type StatusId = (typeof statusIds)[number];
|
||||
export type StatusTier = 'safe' | 'strained' | 'critical' | 'collapsed';
|
||||
export type ItemType =
|
||||
| 'food'
|
||||
| 'water'
|
||||
| 'material'
|
||||
| 'tool'
|
||||
| 'weapon'
|
||||
| 'armor'
|
||||
| 'ammo'
|
||||
| 'quest'
|
||||
| 'medicine';
|
||||
|
||||
export type EquipSlot = 'weapon' | 'body' | 'tool';
|
||||
export type ServiceId = 'craft' | 'rest' | 'trade' | 'heal' | 'info';
|
||||
export type ActionKind = 'resource' | 'scavenge' | 'investigate' | 'rest' | 'service';
|
||||
export type QuestType = 'main' | 'side' | 'tutorial';
|
||||
export type QuestState = 'locked' | 'active' | 'completed';
|
||||
export type EventTrigger = 'action' | 'travel' | 'arrive';
|
||||
export type LogTone = 'info' | 'good' | 'warn' | 'bad' | 'combat';
|
||||
|
||||
export interface StatLine {
|
||||
life: number;
|
||||
hunger: number;
|
||||
thirst: number;
|
||||
energy: number;
|
||||
sanity: number;
|
||||
radiation: number;
|
||||
}
|
||||
|
||||
export interface Attributes {
|
||||
str: number;
|
||||
agi: number;
|
||||
int: number;
|
||||
per: number;
|
||||
luck: number;
|
||||
}
|
||||
|
||||
export interface CombatStats {
|
||||
damageMin: number;
|
||||
damageMax: number;
|
||||
range: number;
|
||||
hitBonus: number;
|
||||
armor?: number;
|
||||
dodgeBonus?: number;
|
||||
radiationResist?: number;
|
||||
critChance?: number;
|
||||
critMult?: number;
|
||||
}
|
||||
|
||||
export interface ItemDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
type: ItemType;
|
||||
tags?: string[];
|
||||
stackLimit: number;
|
||||
volume: number;
|
||||
baseValue: number;
|
||||
equipSlot?: EquipSlot;
|
||||
combat?: CombatStats;
|
||||
effects?: Effect[];
|
||||
}
|
||||
|
||||
export interface InventoryEntry {
|
||||
itemId: string;
|
||||
count: number;
|
||||
durability?: number | null;
|
||||
}
|
||||
|
||||
export interface BuffDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
durationMin: number;
|
||||
modifiers?: Partial<Record<StatusId, number>>;
|
||||
hitBonus?: number;
|
||||
armorBonus?: number;
|
||||
}
|
||||
|
||||
export interface ActiveBuff {
|
||||
buffId: string;
|
||||
remainingMin: number;
|
||||
stacks: number;
|
||||
}
|
||||
|
||||
export type ConditionRule =
|
||||
| { kind: 'flag'; flag: string; value?: boolean }
|
||||
| { kind: 'has-item'; itemId: string; count: number }
|
||||
| { kind: 'quest-state'; questId: string; state: QuestState }
|
||||
| { kind: 'place'; placeId: string }
|
||||
| { kind: 'day-at-least'; day: number }
|
||||
| { kind: 'stat-at-most'; stat: StatusId; value: number }
|
||||
| { kind: 'stat-at-least'; stat: StatusId; value: number };
|
||||
|
||||
export type Effect =
|
||||
| { type: 'add-item'; itemId: string; count: number }
|
||||
| { type: 'remove-item'; itemId: string; count: number }
|
||||
| { type: 'change-stat'; stat: StatusId; amount: number }
|
||||
| { type: 'add-buff'; buffId: string; durationMin: number; stacks?: number }
|
||||
| { type: 'remove-buff'; buffId: string }
|
||||
| { type: 'set-flag'; flag: string; value: boolean }
|
||||
| { type: 'unlock-recipe'; recipeId: string }
|
||||
| { type: 'activate-quest'; questId: string }
|
||||
| { type: 'start-combat'; enemyId: string }
|
||||
| { type: 'log'; message: string; tone?: LogTone };
|
||||
|
||||
export interface LootEntry {
|
||||
itemId: string;
|
||||
min: number;
|
||||
max: number;
|
||||
chance?: number;
|
||||
}
|
||||
|
||||
export interface PlaceActionDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: ActionKind;
|
||||
desc: string;
|
||||
timeCostMin: number;
|
||||
energyCost: number;
|
||||
risk: number;
|
||||
rewardHint: string;
|
||||
requires?: ConditionRule[];
|
||||
stock?: {
|
||||
initial: number;
|
||||
max: number;
|
||||
refreshMin: number;
|
||||
amountPerRefresh: number;
|
||||
};
|
||||
lootTable?: LootEntry[];
|
||||
guaranteedEffects?: Effect[];
|
||||
eventPool?: string[];
|
||||
onEmptyText?: string;
|
||||
}
|
||||
|
||||
export interface TradeOfferDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
gives: { itemId: string; count: number }[];
|
||||
costs: { itemId: string; count: number }[];
|
||||
}
|
||||
|
||||
export interface PlaceDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
tags: string[];
|
||||
dangerLevel: number;
|
||||
services: ServiceId[];
|
||||
modifiers?: Partial<Record<StatusId, number>>;
|
||||
actions: PlaceActionDefinition[];
|
||||
arrivalEventPool?: string[];
|
||||
tradeOffers?: TradeOfferDefinition[];
|
||||
}
|
||||
|
||||
export interface MapEdge {
|
||||
from: string;
|
||||
to: string;
|
||||
travelTimeMin: number;
|
||||
risk: number;
|
||||
conditions?: ConditionRule[];
|
||||
eventPool?: string[];
|
||||
}
|
||||
|
||||
export interface SkillCheck {
|
||||
attribute: keyof Attributes;
|
||||
difficulty: number;
|
||||
}
|
||||
|
||||
export interface EventOptionDefinition {
|
||||
id: string;
|
||||
text: string;
|
||||
conditions?: ConditionRule[];
|
||||
timeCostMin?: number;
|
||||
check?: SkillCheck;
|
||||
successEffects: Effect[];
|
||||
failureEffects?: Effect[];
|
||||
successText?: string;
|
||||
failureText?: string;
|
||||
}
|
||||
|
||||
export interface EventDefinition {
|
||||
id: string;
|
||||
title: string;
|
||||
text: string;
|
||||
trigger: EventTrigger;
|
||||
options: EventOptionDefinition[];
|
||||
conditions?: ConditionRule[];
|
||||
}
|
||||
|
||||
export interface EnemyDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
life: number;
|
||||
damageMin: number;
|
||||
damageMax: number;
|
||||
armor: number;
|
||||
range: number;
|
||||
hitRate: number;
|
||||
escapePressure: number;
|
||||
lootTable: LootEntry[];
|
||||
}
|
||||
|
||||
export type QuestStep =
|
||||
| { id: string; text: string; kind: 'reach-place'; placeId: string }
|
||||
| { id: string; text: string; kind: 'have-item'; itemId: string; count: number }
|
||||
| { id: string; text: string; kind: 'flag'; flag: string; value?: boolean }
|
||||
| { id: string; text: string; kind: 'survive-day'; day: number };
|
||||
|
||||
export interface QuestDefinition {
|
||||
id: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
type: QuestType;
|
||||
autoStart?: ConditionRule[];
|
||||
steps: QuestStep[];
|
||||
rewards: Effect[];
|
||||
}
|
||||
|
||||
export interface QuestRuntimeState {
|
||||
questId: string;
|
||||
state: QuestState;
|
||||
currentStepIndex: number;
|
||||
}
|
||||
|
||||
export interface CombatLogEntry {
|
||||
round: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface CombatState {
|
||||
enemyId: string;
|
||||
enemyLife: number;
|
||||
distance: number;
|
||||
round: number;
|
||||
playerGuarding: boolean;
|
||||
enemyGuarding: boolean;
|
||||
log: CombatLogEntry[];
|
||||
rewardClaimed: boolean;
|
||||
}
|
||||
|
||||
export interface PendingEvent {
|
||||
eventId: string;
|
||||
source: 'travel' | 'place' | 'arrival';
|
||||
}
|
||||
|
||||
export interface PlaceRuntimeState {
|
||||
placeId: string;
|
||||
heat: number;
|
||||
stocks: Record<string, number>;
|
||||
lastRefreshMinute: number;
|
||||
visited: boolean;
|
||||
}
|
||||
|
||||
export interface WorldTime {
|
||||
totalMinutes: number;
|
||||
day: number;
|
||||
minuteOfDay: number;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
id: string;
|
||||
minute: number;
|
||||
tone: LogTone;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PlayerState {
|
||||
name: string;
|
||||
placeId: string;
|
||||
stats: StatLine;
|
||||
maxStats: StatLine;
|
||||
attributes: Attributes;
|
||||
inventory: InventoryEntry[];
|
||||
storage: InventoryEntry[];
|
||||
equipment: Partial<Record<EquipSlot, string>>;
|
||||
buffs: ActiveBuff[];
|
||||
knownRecipes: string[];
|
||||
quests: Record<string, QuestRuntimeState>;
|
||||
}
|
||||
|
||||
export interface WorldState {
|
||||
time: WorldTime;
|
||||
flags: Record<string, boolean>;
|
||||
places: Record<string, PlaceRuntimeState>;
|
||||
logs: LogEntry[];
|
||||
pendingEvent: PendingEvent | null;
|
||||
activeCombat: CombatState | null;
|
||||
gameOver: boolean;
|
||||
victory: boolean;
|
||||
deathReason?: string;
|
||||
}
|
||||
|
||||
export interface SaveMeta {
|
||||
saveVersion: number;
|
||||
contentVersion: string;
|
||||
seed: number;
|
||||
rngState: number;
|
||||
difficulty: 'standard';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
meta: SaveMeta;
|
||||
player: PlayerState;
|
||||
world: WorldState;
|
||||
}
|
||||
|
||||
export interface GameContent {
|
||||
version: string;
|
||||
inventoryCapacity: number;
|
||||
storageCapacity: number;
|
||||
items: Record<string, ItemDefinition>;
|
||||
buffs: Record<string, BuffDefinition>;
|
||||
recipes: Record<string, RecipeDefinition>;
|
||||
places: Record<string, PlaceDefinition>;
|
||||
edges: MapEdge[];
|
||||
events: Record<string, EventDefinition>;
|
||||
enemies: Record<string, EnemyDefinition>;
|
||||
quests: Record<string, QuestDefinition>;
|
||||
travelEventPool: string[];
|
||||
}
|
||||
|
||||
export interface RecipeDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
workbenchType: ServiceId | 'hand';
|
||||
requirements?: ConditionRule[];
|
||||
inputs: { itemId: string; count: number }[];
|
||||
outputs: { itemId: string; count: number }[];
|
||||
timeCostMin: number;
|
||||
energyCost: number;
|
||||
sideEffects?: Effect[];
|
||||
}
|
||||
|
||||
export type GameAction =
|
||||
| { type: 'travel'; toPlaceId: string }
|
||||
| { type: 'perform-action'; actionId: string }
|
||||
| { type: 'select-event-option'; optionId: string }
|
||||
| { type: 'combat'; move: 'attack' | 'defend' | 'advance' | 'retreat' | 'escape' }
|
||||
| { type: 'craft'; recipeId: string }
|
||||
| { type: 'use-item'; itemId: string }
|
||||
| { type: 'equip-item'; itemId: string }
|
||||
| { type: 'unequip-item'; slot: EquipSlot }
|
||||
| { type: 'stash-item'; itemId: string; count: number }
|
||||
| { type: 'retrieve-item'; itemId: string; count: number }
|
||||
| { type: 'trade'; offerId: string }
|
||||
| { type: 'rest'; minutes: number };
|
||||
|
||||
export interface ActionResult {
|
||||
state: GameState;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
export interface ActionPreview {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: ActionKind;
|
||||
desc: string;
|
||||
timeCostMin: number;
|
||||
energyCost: number;
|
||||
risk: number;
|
||||
rewardHint: string;
|
||||
disabledReason?: string;
|
||||
remainingStock?: number | null;
|
||||
}
|
||||
|
||||
export interface InventoryViewEntry extends InventoryEntry {
|
||||
name: string;
|
||||
type: ItemType;
|
||||
desc: string;
|
||||
volume: number;
|
||||
equipSlot?: EquipSlot;
|
||||
canUse: boolean;
|
||||
equipped: boolean;
|
||||
}
|
||||
|
||||
export interface SurvivalStateView {
|
||||
stat: StatusId;
|
||||
label: string;
|
||||
tier: StatusTier;
|
||||
summary: string;
|
||||
impact: string;
|
||||
recovery: string;
|
||||
}
|
||||
|
||||
export interface SurvivalView {
|
||||
headline: string;
|
||||
statuses: SurvivalStateView[];
|
||||
alerts: SurvivalStateView[];
|
||||
}
|
||||
|
||||
export interface RecipeView {
|
||||
id: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
timeCostMin: number;
|
||||
energyCost: number;
|
||||
craftable: boolean;
|
||||
disabledReason?: string;
|
||||
inputs: { itemId: string; name: string; count: number; owned: number }[];
|
||||
outputs: { itemId: string; name: string; count: number }[];
|
||||
}
|
||||
|
||||
export interface TradeOfferView {
|
||||
id: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
available: boolean;
|
||||
disabledReason?: string;
|
||||
gives: { itemId: string; name: string; count: number }[];
|
||||
costs: { itemId: string; name: string; count: number; owned: number }[];
|
||||
}
|
||||
|
||||
export interface QuestView {
|
||||
id: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
type: QuestType;
|
||||
state: QuestState;
|
||||
currentStepIndex: number;
|
||||
steps: { text: string; done: boolean }[];
|
||||
}
|
||||
|
||||
export interface CombatView {
|
||||
enemyId: string;
|
||||
enemyName: string;
|
||||
enemyLife: number;
|
||||
enemyMaxLife: number;
|
||||
distance: number;
|
||||
round: number;
|
||||
log: CombatLogEntry[];
|
||||
availableMoves: { id: GameAction['type']; move?: string; label: string }[];
|
||||
}
|
||||
|
||||
export interface PendingEventView {
|
||||
id: string;
|
||||
title: string;
|
||||
text: string;
|
||||
options: { id: string; text: string; disabledReason?: string }[];
|
||||
}
|
||||
|
||||
export interface MapPlaceView {
|
||||
id: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
dangerLevel: number;
|
||||
tags: string[];
|
||||
visited: boolean;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
export interface EdgeView {
|
||||
from: string;
|
||||
to: string;
|
||||
travelTimeMin: number;
|
||||
risk: number;
|
||||
blockedReason?: string;
|
||||
}
|
||||
|
||||
export interface GameView {
|
||||
meta: SaveMeta;
|
||||
header: {
|
||||
placeName: string;
|
||||
placeDesc: string;
|
||||
timeLabel: string;
|
||||
riskLabel: string;
|
||||
};
|
||||
player: {
|
||||
name: string;
|
||||
stats: StatLine;
|
||||
maxStats: StatLine;
|
||||
attributes: Attributes;
|
||||
equipment: Partial<Record<EquipSlot, string>>;
|
||||
inventory: InventoryViewEntry[];
|
||||
inventoryUsage: number;
|
||||
inventoryCapacity: number;
|
||||
storage: InventoryViewEntry[];
|
||||
storageUsage: number;
|
||||
storageCapacity: number;
|
||||
storageAccessible: boolean;
|
||||
};
|
||||
map: {
|
||||
places: MapPlaceView[];
|
||||
edges: EdgeView[];
|
||||
};
|
||||
place: {
|
||||
id: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
services: ServiceId[];
|
||||
actions: ActionPreview[];
|
||||
tradeOffers: TradeOfferView[];
|
||||
};
|
||||
recipes: RecipeView[];
|
||||
quests: QuestView[];
|
||||
survival: SurvivalView;
|
||||
pendingEvent: PendingEventView | null;
|
||||
combat: CombatView | null;
|
||||
logs: LogEntry[];
|
||||
flags: Record<string, boolean>;
|
||||
gameOver: boolean;
|
||||
victory: boolean;
|
||||
deathReason?: string;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user