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

- 添加游戏核心系统:战斗、任务、日志、随机数生成等
- 实现前端主界面、HUD、底部导航和各类卡片组件
- 添加用户认证系统与游戏存档持久化
- 配置项目基础架构与开发环境
- 补充文档说明与Docker部署支持
This commit is contained in:
2026-04-28 22:16:58 +08:00
parent 2e1b58bada
commit e783e6e533
124 changed files with 9759 additions and 1 deletions
+24
View File
@@ -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:*"
}
}
+45
View File
@@ -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
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src"]
}
+24
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
export * from './engine';
export * from './rng';
export * from './types';
+25
View File
@@ -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));
}
});
});
+35
View File
@@ -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);
}
}
}
};
+24
View File
@@ -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';
+519
View File
@@ -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;
}
+246
View File
@@ -0,0 +1,246 @@
import type {
ActionPreview,
EdgeView,
GameContent,
GameState,
GameView,
InventoryEntry,
InventoryViewEntry,
PendingEventView,
PlaceDefinition,
PlaceActionDefinition,
RecipeDefinition,
RecipeView,
TradeOfferView,
} from './types';
import { canAccessShelterStorage, buildSurvivalView } from './systems/survival-system';
import { copyStats, dangerLabel, formatTime } from './systems/shared';
import { getConditionFailure, getCurrentPlace, hasConditions, ensurePlaceRuntime } from './systems/place-system';
import { buildQuestView } from './systems/quest-system';
import { getContainerUsage, getInventoryUsage, getItemCount, getItemCountFromEntries, isItemEquipped } from './systems/inventory-system';
const buildInventoryEntries = (
entries: InventoryEntry[],
state: GameState,
content: GameContent,
): InventoryViewEntry[] =>
entries.map((entry) => {
const item = content.items[entry.itemId];
return {
...entry,
name: item.name,
type: item.type,
desc: item.desc,
volume: item.volume,
equipSlot: item.equipSlot,
canUse: Boolean(item.effects?.length),
equipped: isItemEquipped(state.player, entry.itemId),
};
});
const buildActionPreview = (
state: GameState,
content: GameContent,
action: PlaceActionDefinition,
): ActionPreview => {
const failed = action.requires?.find((rule) => !hasConditions(state, [rule]));
const runtime = ensurePlaceRuntime(state, content, state.player.placeId);
const remainingStock = action.stock ? runtime.stocks[action.id] ?? action.stock.initial : null;
let disabledReason = failed ? getConditionFailure(failed, content) : undefined;
if (!disabledReason && action.stock && remainingStock !== null && remainingStock <= 0) {
disabledReason = action.onEmptyText ?? '这里已经空了';
}
return {
id: action.id,
name: action.name,
kind: action.kind,
desc: action.desc,
timeCostMin: action.timeCostMin,
energyCost: action.energyCost,
risk: action.risk,
rewardHint: action.rewardHint,
disabledReason,
remainingStock,
};
};
const getRecipeOwnedCount = (state: GameState, recipeInputId: string) => {
const bagCount = getItemCount(state.player, recipeInputId);
if (!canAccessShelterStorage(state)) {
return bagCount;
}
return bagCount + getItemCountFromEntries(state.player.storage, recipeInputId);
};
const buildRecipeView = (state: GameState, content: GameContent, recipe: RecipeDefinition): RecipeView => {
const requirementFailure = recipe.requirements?.find((rule) => !hasConditions(state, [rule]));
const missing = recipe.inputs.find((input) => getRecipeOwnedCount(state, input.itemId) < input.count);
const craftable = state.player.knownRecipes.includes(recipe.id) && !requirementFailure && !missing;
return {
id: recipe.id,
name: recipe.name,
desc: recipe.desc,
timeCostMin: recipe.timeCostMin,
energyCost: recipe.energyCost,
craftable,
disabledReason:
!state.player.knownRecipes.includes(recipe.id)
? '尚未解锁'
: requirementFailure
? getConditionFailure(requirementFailure, content)
: missing
? `缺少 ${content.items[missing.itemId].name}`
: undefined,
inputs: recipe.inputs.map((input) => ({
itemId: input.itemId,
name: content.items[input.itemId].name,
count: input.count,
owned: getRecipeOwnedCount(state, input.itemId),
})),
outputs: recipe.outputs.map((output) => ({
itemId: output.itemId,
name: content.items[output.itemId].name,
count: output.count,
})),
};
};
const buildTradeView = (state: GameState, content: GameContent, place: PlaceDefinition): TradeOfferView[] =>
(place.tradeOffers ?? []).map((offer) => {
const missing = offer.costs.find((cost) => getItemCount(state.player, cost.itemId) < cost.count);
return {
id: offer.id,
name: offer.name,
desc: offer.desc,
available: !missing,
disabledReason: missing ? `缺少 ${content.items[missing.itemId].name}` : undefined,
gives: offer.gives.map((entry) => ({
itemId: entry.itemId,
name: content.items[entry.itemId].name,
count: entry.count,
})),
costs: offer.costs.map((entry) => ({
itemId: entry.itemId,
name: content.items[entry.itemId].name,
count: entry.count,
owned: getItemCount(state.player, entry.itemId),
})),
};
});
const buildPendingEventView = (state: GameState, content: GameContent): PendingEventView | null => {
if (!state.world.pendingEvent) return null;
const event = content.events[state.world.pendingEvent.eventId];
return {
id: event.id,
title: event.title,
text: event.text,
options: event.options.map((option) => {
const failed = option.conditions?.find((rule) => !hasConditions(state, [rule]));
return {
id: option.id,
text: option.text,
disabledReason: failed ? getConditionFailure(failed, content) : undefined,
};
}),
};
};
export const buildGameView = (state: GameState, content: GameContent): GameView => {
const place = getCurrentPlace(state, content);
const inventory = buildInventoryEntries(state.player.inventory, state, content);
const storage = buildInventoryEntries(state.player.storage, state, content).map((entry) => ({
...entry,
equipped: false,
}));
const edges: EdgeView[] = content.edges
.filter((edge) => edge.from === state.player.placeId)
.map((edge) => {
const failed = edge.conditions?.find((rule) => !hasConditions(state, [rule]));
return {
from: edge.from,
to: edge.to,
travelTimeMin: edge.travelTimeMin,
risk: edge.risk,
blockedReason: failed ? getConditionFailure(failed, content) : undefined,
};
});
return {
meta: state.meta,
header: {
placeName: place.name,
placeDesc: place.desc,
timeLabel: formatTime(state.world.time.totalMinutes),
riskLabel: dangerLabel(place.dangerLevel),
},
player: {
name: state.player.name,
stats: copyStats(state.player.stats),
maxStats: copyStats(state.player.maxStats),
attributes: { ...state.player.attributes },
equipment: { ...state.player.equipment },
inventory,
inventoryUsage: getInventoryUsage(state, content),
inventoryCapacity: content.inventoryCapacity,
storage,
storageUsage: getContainerUsage(state.player.storage, content),
storageCapacity: content.storageCapacity,
storageAccessible: canAccessShelterStorage(state),
},
map: {
places: Object.values(content.places).map((entry) => ({
id: entry.id,
name: entry.name,
desc: entry.desc,
dangerLevel: entry.dangerLevel,
tags: entry.tags,
visited: state.world.places[entry.id]?.visited ?? false,
current: entry.id === state.player.placeId,
})),
edges,
},
place: {
id: place.id,
name: place.name,
desc: place.desc,
services: place.services,
actions: place.actions.map((action) => buildActionPreview(state, content, action)),
tradeOffers: buildTradeView(state, content, place),
},
recipes: Object.values(content.recipes).map((recipe) => buildRecipeView(state, content, recipe)),
quests: Object.values(content.quests).map((quest) => buildQuestView(state, quest)),
survival: buildSurvivalView(state),
pendingEvent: buildPendingEventView(state, content),
combat: state.world.activeCombat
? {
enemyId: state.world.activeCombat.enemyId,
enemyName: content.enemies[state.world.activeCombat.enemyId].name,
enemyLife: state.world.activeCombat.enemyLife,
enemyMaxLife: content.enemies[state.world.activeCombat.enemyId].life,
distance: state.world.activeCombat.distance,
round: state.world.activeCombat.round,
log: state.world.activeCombat.log.slice(-8),
availableMoves: [
{ id: 'combat', move: 'attack', label: '攻击' },
{ id: 'combat', move: 'defend', label: '防御' },
{ id: 'combat', move: 'advance', label: '逼近' },
{ id: 'combat', move: 'retreat', label: '后撤' },
{ id: 'combat', move: 'escape', label: '脱离' },
],
}
: null,
logs: state.world.logs.slice(-40).reverse(),
flags: { ...state.world.flags },
gameOver: state.world.gameOver,
victory: state.world.victory,
deathReason: state.world.deathReason,
};
};
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src"]
}