// 回响星核 / 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*8), 0.25, 0.95) */ combatDifficultyScale: 8, /** 能量恢复间隔(秒),每 interval 恢复 1 点 */ energyRegenSec: 45, }; /** 节点类型权重(boss 固定末位,其余按权重随机) */ const NODE_WEIGHTS: Record = { 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; // 星图「信标矩阵」加成 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; hp += (state.ascensions ?? 0) * 10; // 星图「维生护盾」加成 const cm = constellationBonuses(state.constellation ?? []); hp = Math.round(hp * cm.expeditionHpMult); 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, }; }