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, 脉冲/技术树注入世界观文本
411 lines
14 KiB
TypeScript
Executable File
411 lines
14 KiB
TypeScript
Executable File
// 回响星核 / 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→4,floor 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 +12,exp_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 +30,exp_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.1:exp_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,
|
||
};
|
||
}
|