Files
echo-nexus/src/lib/game/expedition.ts
T
Super_Z 44b94f9325 v0.9 工单修复: 4 bug + 4 玩法改进
Gitea Issue #2 修复:
Bug:
1. 探险只能一次 → 能量恢复 45→20s, 初始能量 3→5, 上限 5→8
   新增 lastExpeditionSummary 显示上次结果
   新增 dismissExpedition action
2. 元素偏移/遮挡 → 标签页 grid-cols-8 → 响应式 grid-cols-4/sm:8
3. 成就UI遮挡 → max-h 440→520, min-h 提高
4. 提示框遮挡 → toast top-0→top-14 避开 header, TOAST_LIMIT 1→3

玩法改进:
1. 中央晶体球 → 下方新增 4 指标收益面板(产能/脉冲/连击/仓库)
2. 技术树 12→20 节点 (每分支 3→5 级)
3. 成就解锁 → 全屏居中特效通知 (framer-motion 弹窗)
4. 叙事融入 → 新建 narrative.ts, 脉冲/技术树注入世界观文本
2026-06-24 00:41:16 +00:00

411 lines
14 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 回响星核 / Echo Nexus — 遗迹探险肉鸽系统 (Expedition)
//
// 玩家消耗能量进入程序化生成的遗迹节点路径,沿路触发事件,
// 到达终点 boss 获取大奖。失败保留已获奖励但探险结束。
import type {
Expedition,
ExpeditionNode,
ExpeditionNodeType,
ExpeditionResult,
GameState,
} from "./types";
import { constellationBonuses } from "./constellation";
/** 简单可复现随机(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*scale), floor, 0.95)
* v0.8.1 平衡:scale 8→4floor 0.25→0.35,让基础探险力也能有合理胜率 */
combatDifficultyScale: 4,
combatWinRateFloor: 0.35,
/** 能量恢复基础间隔(秒),可被技术/属性缩短
* v0.9 工单修复:45→20,解决「探险只能一次」体验问题 */
energyRegenSec: 20,
/** 能量恢复最短间隔(秒,技术+属性全满时) */
energyRegenMinSec: 8,
};
/** 节点类型权重(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 ? 3 + 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;
// 探险分支技术加成(v0.9 扩充:exp_4 +12exp_5 +25
const exp1 = state.tech?.exp_1 ?? 0;
const exp2 = state.tech?.exp_2 ?? 0;
const exp3 = state.tech?.exp_3 ?? 0;
const exp4 = state.tech?.exp_4 ?? 0;
const exp5 = state.tech?.exp_5 ?? 0;
power += exp1 * 3 + exp2 * 5 + exp3 * 8 + exp4 * 12 + exp5 * 25;
// 飞升蓝图加成
power *= 1 + (state.blueprints?.length ?? 0) * 0.08;
// 飞升周目加成
power *= 1 + (state.ascensions ?? 0) * 0.15;
// 星图「信标矩阵」加成
const cm = constellationBonuses(state.constellation ?? []);
power *= cm.expeditionPowerMult;
return Math.round(power);
}
/** 计算探险最大生命 */
export function computeExpeditionHp(state: GameState): number {
let hp = EXPEDITION_CONFIG.baseHp;
hp += (state.tech?.exp_2 ?? 0) * 20;
// v0.9 扩充:exp_4 +30exp_5 +50
hp += (state.tech?.exp_4 ?? 0) * 30;
hp += (state.tech?.exp_5 ?? 0) * 50;
hp += (state.ascensions ?? 0) * 10;
// 星图「维生护盾」加成
const cm = constellationBonuses(state.constellation ?? []);
hp = Math.round(hp * cm.expeditionHpMult);
return hp;
}
/** 战斗胜率
* v0.8.1 平衡:scale 4 + floor 0.35,让基础探险力 10 对 BOSS(diff 3-5) 有 33-45% 胜率,
* 配合技术/属性后可达 55-70%,告别"BOSS 必败"体验 */
export function combatWinRate(power: number, difficulty: number): number {
const scale = EXPEDITION_CONFIG.combatDifficultyScale;
const floor = EXPEDITION_CONFIG.combatWinRateFloor;
return Math.max(floor, 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 };
}
/** 计算实际能量恢复间隔(秒),由技术树 + 角色属性缩短
* v0.8.1exp_2 解锁 -30%exp_3 解锁 -20%,探索力属性 -最高30%,下限 12s
* v0.9 扩充:exp_4 加速 25%(×0.75),exp_5 加速 30%(×0.70),下限 8s */
export function computeEnergyRegenInterval(state: GameState): number {
const base = EXPEDITION_CONFIG.energyRegenSec;
let mult = 1;
// 技术:遗迹图谱(exp_2)+ 维度信标(exp_3)+ 虚空航标(exp_4+ 维度跃迁引擎(exp_5
if (state.tech?.exp_2) mult *= 0.7;
if (state.tech?.exp_3) mult *= 0.8;
if (state.tech?.exp_4) mult *= 0.75;
if (state.tech?.exp_5) mult *= 0.7;
// 角色属性:探索力(exploration)每点缩短少量,高探索力最高 -30%
const exploration = state.attributes?.exploration ?? 0;
const explorationBonus = exploration >= 50
? 0.15 + (Math.min(100, exploration) - 50) * 0.003 // 50点15%100点30%
: exploration * 0.003;
mult *= 1 - explorationBonus;
return Math.max(EXPEDITION_CONFIG.energyRegenMinSec, base * mult);
}
/** 计算能量恢复(基于时间,支持动态间隔) */
export function computeEnergyRegen(
lastTick: number,
now: number,
current: number,
max: number,
intervalSec: number = EXPEDITION_CONFIG.energyRegenSec
): { energy: number; lastTick: number } {
const interval = intervalSec * 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,
};
}