6f5491c0-6c94-4f8e-8ad3-136d04ab862f

This commit is contained in:
2026-06-23 13:04:15 +00:00
parent f73b96026c
commit 674775be0e
9 changed files with 1007 additions and 26 deletions
+10 -5
View File
@@ -10,7 +10,7 @@ import type {
export const INITIAL_STATE = {
crystals: 0,
insights: 0,
energy: 10,
energy: 3,
contact: 0,
crystalsPerSec: 0.4,
crystalCap: 50,
@@ -24,6 +24,11 @@ export const INITIAL_STATE = {
blueprints: [] as string[],
pendingCrystals: [],
activePuzzle: null,
activeExpedition: null,
expeditionLog: [],
energyMax: 5,
lastEnergyTick: Date.now(),
totalExpeditions: 0,
theme: "dark" as const,
soundOn: true,
};
@@ -147,13 +152,13 @@ export const TECH_TREE: TechNode[] = [
cost: 200,
effect: { kind: "autoDecode", value: 1 },
},
// 探险分支(v0.1 仅产能效果,探险系统留 v0.2
// 探险分支(v0.2:探险力/生命/能量上限/产能
{
id: "exp_1",
branch: "expedition",
level: 1,
name: "远征推进器",
desc: "晶体/秒 +1.2(探险预备)",
desc: "探险力 +3晶体/秒 +1.2",
cost: 12,
effect: { kind: "crystalsPerSec", value: 1.2 },
},
@@ -162,7 +167,7 @@ export const TECH_TREE: TechNode[] = [
branch: "expedition",
level: 2,
name: "遗迹图谱",
desc: "仓库上限 +200,晶体/秒 +2",
desc: "探险力 +5,探险生命 +20",
cost: 60,
effect: { kind: "crystalCap", value: 200 },
},
@@ -171,7 +176,7 @@ export const TECH_TREE: TechNode[] = [
branch: "expedition",
level: 3,
name: "维度信标",
desc: "接触进度转化率 +50%",
desc: "探险力 +8接触进度转化率 +50%",
cost: 180,
effect: { kind: "contactRate", value: 0.5 },
},
+8 -2
View File
@@ -104,8 +104,8 @@ export function performPrestige(state: GameState): GameState {
...Array.from({ length: newBp }, (_, i) => `bp_${Date.now()}_${i}`),
].slice(0, PRESTIGE.maxBlueprints);
// 保留:fragments, totalDecoded(累计), ascensions+1, blueprints, theme/sound
// 重置:资源、技术、产能、pendingCrystals、activePuzzle、contact、lastTick
// 保留:fragments, totalDecoded(累计), ascensions+1, blueprints, theme/sound, expeditionLog
// 重置:资源、技术、产能、pendingCrystals、activePuzzle、activeExpedition、contact、lastTick、energy
const fresh = createInitialState();
const stats = recomputeStats({ tech: {}, blueprints });
return {
@@ -118,6 +118,9 @@ export function performPrestige(state: GameState): GameState {
soundOn: state.soundOn,
createdAt: state.createdAt,
lastTick: Date.now(),
lastEnergyTick: Date.now(),
expeditionLog: state.expeditionLog,
totalExpeditions: state.totalExpeditions,
...stats,
};
}
@@ -130,6 +133,9 @@ export function createInitialState(): GameState {
fragments: {},
pendingCrystals: [],
activePuzzle: null,
activeExpedition: null,
expeditionLog: [],
lastEnergyTick: Date.now(),
createdAt: Date.now(),
lastTick: Date.now(),
} as GameState;
+369
View File
@@ -0,0 +1,369 @@
// 回响星核 / Echo Nexus — 遗迹探险肉鸽系统 (Expedition)
//
// 玩家消耗能量进入程序化生成的遗迹节点路径,沿路触发事件,
// 到达终点 boss 获取大奖。失败保留已获奖励但探险结束。
import type {
Expedition,
ExpeditionNode,
ExpeditionNodeType,
ExpeditionResult,
GameState,
} from "./types";
/** 简单可复现随机(mulberry32 */
function makeRng(seed: number) {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** 探险配置 */
export const EXPEDITION_CONFIG = {
/** 进入探险消耗能量 */
energyCost: 1,
/** 路径节点数范围 */
minNodes: 5,
maxNodes: 7,
/** boss 固定在最后一个节点 */
/** 初始生命 */
baseHp: 100,
/** 探险力基础 */
basePower: 10,
/** 战斗:胜率 = clamp(power / (power + difficulty*8), 0.25, 0.95) */
combatDifficultyScale: 8,
/** 能量恢复间隔(秒),每 interval 恢复 1 点 */
energyRegenSec: 45,
};
/** 节点类型权重(boss 固定末位,其余按权重随机) */
const NODE_WEIGHTS: Record<ExpeditionNodeType, number> = {
combat: 0.3,
treasure: 0.25,
choice: 0.2,
puzzle: 0.1,
rest: 0.15,
boss: 0,
};
/** 节点事件文案池 */
const NODE_FLAVOR: Record<
ExpeditionNodeType,
{ titles: string[]; descs: string[] }
> = {
combat: {
titles: ["守卫残影", "虚空巡游者", "腐化无人机", "以太守门人"],
descs: [
"一道扭曲的残影拦住去路,它是被遗忘的守卫。",
"虚空巡游者在走廊游荡,感知到了你的存在。",
"腐化的自治无人机识别为入侵者,启动攻击协议。",
"以太守门人挡在门前,唯有将其击溃方可通过。",
],
},
treasure: {
titles: ["遗失货舱", "晶体宝库", "遗物储藏室", "谐振宝箱"],
descs: [
"一扇半开的舱门后,散落着未及带走的晶体。",
"宝库的能量护盾早已失效,宝藏唾手可得。",
"储藏室里的容器还亮着微光,里面是珍贵的洞见核心。",
"一只谐振宝箱静静等候,似乎在等故人归来。",
],
},
choice: {
titles: ["分岔甬道", "低语祭坛", "未知装置", "回响之井"],
descs: [
"甬道在此分岔,两条路都传来不同的低语。",
"祭坛上浮动着两团光,你必须选择其一。",
"未知装置闪烁着两个按钮,你不知道后果。",
"回响之井倒映出两种可能的未来。",
],
},
puzzle: {
titles: ["封印之门", "谐振锁", "记忆机关", "维度栅栏"],
descs: [
"一道封印之门需要正确的谐振才能开启。",
"谐振锁闪烁着复杂的图案,解之有大奖。",
"记忆机关考验着你的解码技艺。",
"维度栅栏以谜题为钥,智者得通行。",
],
},
rest: {
titles: ["休憩节点", "能量泉眼", "静默之厅", "回响营地"],
descs: [
"一处宁静的节点,可在此恢复生命。",
"能量泉眼涌出暖流,抚慰你的探险队。",
"静默之厅中,时间仿佛静止,伤痛缓缓消散。",
"前人留下的营地,尚有补给可用。",
],
},
boss: {
titles: ["以太回响体", "维度守护者", "飞升残念", "终焉之核"],
descs: [
"路径尽头,以太回响体静静伫立——它是这个遗迹意志的化身。",
"维度守护者挡在最后,唯有超越它方能触及终焉。",
"飞升残念凝聚成形,它是以太族离去前留下的最后一道试炼。",
"终焉之核悬浮于大厅中央,接触它即完成本次探险。",
],
},
};
/** 随机选一个节点类型(boss 除外) */
function rollNodeType(rng: () => number): ExpeditionNodeType {
const types = Object.keys(NODE_WEIGHTS) as ExpeditionNodeType[];
const total = types.reduce((s, t) => s + NODE_WEIGHTS[t], 0);
let r = rng() * total;
for (const t of types) {
r -= NODE_WEIGHTS[t];
if (r <= 0) return t;
}
return "combat";
}
/** 生成一次探险 */
export function generateExpedition(
seed: number,
power: number,
hp: number
): Expedition {
const rng = makeRng(seed);
const nodeCount =
EXPEDITION_CONFIG.minNodes +
Math.floor(rng() * (EXPEDITION_CONFIG.maxNodes - EXPEDITION_CONFIG.minNodes + 1));
const nodes: ExpeditionNode[] = [];
for (let i = 0; i < nodeCount; i++) {
const isBoss = i === nodeCount - 1;
const type: ExpeditionNodeType = isBoss ? "boss" : rollNodeType(rng);
const flavor = NODE_FLAVOR[type];
const fi = Math.floor(rng() * flavor.titles.length);
nodes.push({
id: i,
type,
title: flavor.titles[fi],
desc: flavor.descs[fi],
cleared: false,
difficulty: isBoss ? 5 + Math.floor(rng() * 3) : 1 + Math.floor(rng() * 4),
position: i,
});
}
// 第一个节点固定为 treasure 或 rest(友好开局)
if (nodes[0].type === "combat" || nodes[0].type === "boss") {
nodes[0].type = rng() < 0.5 ? "treasure" : "rest";
const f = NODE_FLAVOR[nodes[0].type];
nodes[0].title = f.titles[0];
nodes[0].desc = f.descs[0];
nodes[0].difficulty = 1;
}
return {
id: `exp_${seed}_${Date.now()}`,
nodes,
currentNode: 0,
finished: false,
rewards: { crystals: 0, insights: 0, contact: 0, fragments: [] },
power,
hp,
maxHp: hp,
seed,
startedAt: Date.now(),
};
}
/** 计算探险力(由技术树 + 飞升蓝图) */
export function computeExpeditionPower(state: GameState): number {
let power = EXPEDITION_CONFIG.basePower;
// 探险分支技术加成
const exp1 = state.tech?.exp_1 ?? 0;
const exp2 = state.tech?.exp_2 ?? 0;
const exp3 = state.tech?.exp_3 ?? 0;
power += exp1 * 3 + exp2 * 5 + exp3 * 8;
// 飞升蓝图加成
power *= 1 + (state.blueprints?.length ?? 0) * 0.08;
// 飞升周目加成
power *= 1 + (state.ascensions ?? 0) * 0.15;
return Math.round(power);
}
/** 计算探险最大生命 */
export function computeExpeditionHp(state: GameState): number {
let hp = EXPEDITION_CONFIG.baseHp;
hp += (state.tech?.exp_2 ?? 0) * 20;
hp += (state.ascensions ?? 0) * 10;
return hp;
}
/** 战斗胜率 */
export function combatWinRate(power: number, difficulty: number): number {
const scale = EXPEDITION_CONFIG.combatDifficultyScale;
return Math.max(0.25, Math.min(0.95, power / (power + difficulty * scale)));
}
/** 结算当前节点(自动结算,返回结果与日志) */
export function resolveNode(
expedition: Expedition,
rng: () => number = Math.random
): ExpeditionResult {
const node = expedition.nodes[expedition.currentNode];
if (!node) return { log: "无节点", ended: true, endReason: "abort" };
if (node.cleared) {
return advanceExpedition(expedition);
}
const power = expedition.power;
const diff = node.difficulty;
switch (node.type) {
case "combat": {
const winRate = combatWinRate(power, diff);
const won = rng() < winRate;
if (won) {
const crystals = 8 + diff * 6 + Math.floor(rng() * 10);
const insights = 1 + diff + Math.floor(rng() * 2);
node.cleared = true;
return {
log: `击败「${node.title}」,缴获 ${crystals} 晶体、${insights} 洞见。`,
crystals,
insights,
ended: false,
};
}
const hpLoss = 20 + diff * 10 + Math.floor(rng() * 15);
const newHp = expedition.hp - hpLoss;
if (newHp <= 0) {
node.cleared = true;
return {
log: `${node.title}」过于强大,探险队全灭…(生命 -${expedition.hp}`,
hpDelta: -expedition.hp,
ended: true,
endReason: "defeat",
};
}
// 战败但存活,仍可前进(节点算通过但无奖励)
node.cleared = true;
return {
log: `${node.title}」击退了探险队,损失 ${hpLoss} 生命,狼狈通过。`,
hpDelta: -hpLoss,
ended: false,
};
}
case "treasure": {
const crystals = 10 + diff * 8 + Math.floor(rng() * 15);
const insights = 2 + diff + Math.floor(rng() * 3);
node.cleared = true;
return {
log: `在「${node.title}」中发现 ${crystals} 晶体、${insights} 洞见。`,
crystals,
insights,
ended: false,
};
}
case "rest": {
const heal = 15 + diff * 8 + Math.floor(rng() * 10);
node.cleared = true;
return {
log: `在「${node.title}」休整,恢复 ${heal} 生命。`,
hpDelta: heal,
ended: false,
};
}
case "puzzle": {
// 简化:探险力越高越可能解出
const solveRate = Math.min(0.9, 0.4 + power * 0.01);
const solved = rng() < solveRate;
if (solved) {
const crystals = 20 + diff * 12;
const insights = 5 + diff * 3;
const contact = 1 + diff * 0.5;
node.cleared = true;
return {
log: `解开「${node.title}」,获得 ${crystals} 晶体、${insights} 洞见、${contact.toFixed(1)} 接触进度。`,
crystals,
insights,
contact,
ended: false,
};
}
node.cleared = true;
return {
log: `${node.title}」未能解开,但探险队平安通过。`,
ended: false,
};
}
case "choice": {
// 抉择节点:随机给一个好结果或一个坏结果
const good = rng() < 0.55;
if (good) {
const crystals = 6 + diff * 5 + Math.floor(rng() * 8);
node.cleared = true;
return {
log: `在「${node.title}」做出了明智的选择,获得 ${crystals} 晶体。`,
crystals,
ended: false,
};
}
const hpLoss = 8 + diff * 5;
node.cleared = true;
return {
log: `在「${node.title}」的选择带来了代价,损失 ${hpLoss} 生命。`,
hpDelta: -hpLoss,
ended: false,
};
}
case "boss": {
// boss:必定战斗,高难度
const winRate = combatWinRate(power, diff);
const won = rng() < winRate;
node.cleared = true;
if (won) {
const crystals = 60 + diff * 25;
const insights = 15 + diff * 6;
const contact = 5 + diff * 1.5;
// boss 必给一个碎片(如果还有未解锁的)
return {
log: `✦ 击败「${node.title}」!获得 ${crystals} 晶体、${insights} 洞见、${contact.toFixed(1)} 接触进度。`,
crystals,
insights,
contact,
ended: true,
endReason: "victory",
};
}
return {
log: `${node.title}」的力量超出想象,探险队全军覆没…`,
hpDelta: -expedition.hp,
ended: true,
endReason: "defeat",
};
}
}
}
/** 前进到下一节点 */
export function advanceExpedition(expedition: Expedition): ExpeditionResult {
if (expedition.currentNode >= expedition.nodes.length - 1) {
return { log: "探险已完成", ended: true, endReason: "victory" };
}
expedition.currentNode++;
return { log: `前进至节点 ${expedition.currentNode + 1}`, ended: false };
}
/** 计算能量恢复(基于时间) */
export function computeEnergyRegen(
lastTick: number,
now: number,
current: number,
max: number
): { energy: number; lastTick: number } {
const interval = EXPEDITION_CONFIG.energyRegenSec * 1000;
const elapsed = now - lastTick;
const gained = Math.floor(elapsed / interval);
if (gained <= 0) return { energy: current, lastTick };
return {
energy: Math.min(max, current + gained),
lastTick: lastTick + gained * interval,
};
}
+85
View File
@@ -118,9 +118,94 @@ export interface GameState {
pendingCrystals: Crystal[];
activePuzzle: DecodePuzzle | null;
// 探险
activeExpedition: Expedition | null;
expeditionLog: ExpeditionLogEntry[];
energyMax: number;
lastEnergyTick: number;
totalExpeditions: number;
// 元
lastTick: number;
createdAt: number;
theme: "dark" | "light";
soundOn: boolean;
}
// ============ 遗迹探险 (Expedition) 系统 ============
/** 探险节点类型 */
export type ExpeditionNodeType =
| "combat"
| "treasure"
| "choice"
| "puzzle"
| "boss"
| "rest";
/** 探险节点 */
export interface ExpeditionNode {
id: number;
type: ExpeditionNodeType;
/** 事件描述 */
title: string;
desc: string;
/** 该节点是否已被访问 */
cleared: boolean;
/** 战斗/解谜的难度(影响胜率/奖励) */
difficulty: number;
/** 节点在路径上的位置索引 */
position: number;
}
/** 探险状态 */
export interface Expedition {
id: string;
/** 节点路径(线性,position 0..n */
nodes: ExpeditionNode[];
/** 当前所在节点索引 */
currentNode: number;
/** 是否已完成(到达 boss 并结算) */
finished: boolean;
/** 本次探险累计奖励 */
rewards: {
crystals: number;
insights: number;
contact: number;
fragments: string[];
};
/** 探险力(由技术+飞升计算) */
power: number;
/** 剩余生命(战斗失败扣减,归零则探险失败结束) */
hp: number;
maxHp: number;
/** 种子(可复现) */
seed: number;
startedAt: number;
}
/** 探险日志条目 */
export interface ExpeditionLogEntry {
expeditionId: string;
nodeType: ExpeditionNodeType;
result: string;
rewards: string;
timestamp: number;
}
/** 探险事件结算结果 */
export interface ExpeditionResult {
/** 日志描述 */
log: string;
/** 奖励 */
crystals?: number;
insights?: number;
contact?: number;
fragments?: string[];
/** 生命变化 */
hpDelta?: number;
/** 是否探险结束(失败或完成) */
ended: boolean;
/** 结束原因 */
endReason?: "victory" | "defeat" | "abort";
}