Files
echo-nexus/src/lib/game/decode.ts
T

266 lines
9.4 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 — 解码共振谜题逻辑(原创核心玩法)
//
// 谜题规则「谐振序列 / Harmonic Sequence」:
// 1. 一个 rows×cols 的发光节点阵列,每个节点带一种共振色(4 色全息色谱)。
// 2. 中央给出「目标谐振序列」target: ResonanceColor[](长度由 tier 决定)。
// 3. 玩家依次点击节点,重建该序列:
// - 第 1 次点击可任意节点,但该节点颜色须 === target[0]。
// - 第 k 次点击的节点颜色须 === target[k-1],且与上一次点击节点**四邻接**(上下左右)。
// - 每个节点只能用一次(点击后变暗)。
// 4. 步数上限 = stepLimitBase + 解码技术加成;超出则谐振崩塌,可重试(晶体不损耗)。
// 5. 成功 → 释放晶体奖励 + 洞见 + 接触进度 + 可能触发叙事碎片。
//
// 设计意图:把放置游戏的「等待」变成有节奏的空间规划小谜题,兼具策略与爽快感。
import type {
CrystalTier,
DecodeNode,
DecodePuzzle,
ResonanceColor,
} from "./types";
import { DECODE_CONFIG, RESONANCE_COLORS } from "./config";
/** 简单可复现随机(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;
};
}
/**
* 生成一个**保证有解**的解码谜题。
*
* 算法(路径构造法):
* 1. 先随机生成目标序列 target。
* 2. 在网格上**主动构造一条合法路径**:随机起点,每步走向一个未使用的四邻接节点,
* 长度 = target.length。构造成功后,把路径上第 i 个节点颜色**强制设为 target[i]**。
* 3. 其余非路径节点随机填色(偏向目标色,提升视觉丰富度与多解性)。
*
* 这样至少存在一条解(即构造的路径),玩家不会遇到「怎么都过不了」的死局。
* 同时由于其余节点随机,玩家可能发现别的解路径,保留探索乐趣。
*/
export function generatePuzzle(tier: CrystalTier, seed?: number): DecodePuzzle {
const cfg = DECODE_CONFIG[tier];
const s = seed ?? Math.floor(Math.random() * 1e9);
const rng = makeRng(s);
const { rows, cols, targetLen } = cfg;
const total = rows * cols;
// 目标序列:从 4 色中随机选 targetLen 个(允许重复,但保证至少 2 种色以增加策略)
const target: ResonanceColor[] = [];
for (let i = 0; i < targetLen; i++) {
target.push(RESONANCE_COLORS[Math.floor(rng() * RESONANCE_COLORS.length)]);
}
if (new Set(target).size < 2) {
target[target.length - 1] =
RESONANCE_COLORS[(RESONANCE_COLORS.indexOf(target[0]) + 1) % RESONANCE_COLORS.length];
}
// 邻居计算
const neighborsOf = (id: number): number[] => {
const r = Math.floor(id / cols);
const c = id % cols;
const ns: number[] = [];
if (r > 0) ns.push((r - 1) * cols + c);
if (r < rows - 1) ns.push((r + 1) * cols + c);
if (c > 0) ns.push(r * cols + (c - 1));
if (c < cols - 1) ns.push(r * cols + (c + 1));
return ns;
};
// 构造一条保证可解的路径(多次尝试)
let pathIds: number[] | null = null;
for (let attempt = 0; attempt < 300 && !pathIds; attempt++) {
const used = new Set<number>();
const path: number[] = [];
const start = Math.floor(rng() * total);
used.add(start);
path.push(start);
let cur = start;
let ok = true;
for (let i = 1; i < target.length; i++) {
const ns = neighborsOf(cur).filter((n) => !used.has(n));
if (ns.length === 0) {
ok = false;
break;
}
cur = ns[Math.floor(rng() * ns.length)];
used.add(cur);
path.push(cur);
}
if (ok) pathIds = path;
}
// 极端兜底(理论不会触发):线性路径
if (!pathIds) {
pathIds = Array.from({ length: target.length }, (_, i) => i);
}
// 路径节点颜色映射
const pathColor = new Map<number, ResonanceColor>();
pathIds.forEach((id, i) => pathColor.set(id, target[i]));
// 生成网格
const grid: DecodeNode[] = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const id = r * cols + c;
let color: ResonanceColor;
if (pathColor.has(id)) {
color = pathColor.get(id)!;
} else {
// 非路径节点:60% 偏向目标色(提升多解性),40% 随机全色
if (rng() < 0.6) {
color = target[Math.floor(rng() * target.length)];
} else {
color = RESONANCE_COLORS[Math.floor(rng() * RESONANCE_COLORS.length)];
}
}
grid.push({ id, row: r, col: c, color, used: false });
}
}
return {
tier,
grid,
rows,
cols,
target,
path: [],
stepLimit: cfg.stepLimitBase,
seed: s,
};
}
/**
* 精确判断当前局面是否仍可解。
* 从当前路径末端出发,DFS 搜索是否存在一条按 target 剩余顺序、四邻接、不重复的完成路径。
* 用于玩家点击后检测「此路不通」,提示撤销。
*/
export function isSolvable(puzzle: DecodePuzzle): boolean {
const remaining = puzzle.target.slice(puzzle.path.length);
if (remaining.length === 0) return true;
const used = new Set(puzzle.path);
const neighborsOf = (id: number): number[] => {
const node = puzzle.grid.find((n) => n.id === id);
if (!node) return [];
const ns: number[] = [];
if (node.row > 0) ns.push((node.row - 1) * puzzle.cols + node.col);
if (node.row < puzzle.rows - 1) ns.push((node.row + 1) * puzzle.cols + node.col);
if (node.col > 0) ns.push(node.row * puzzle.cols + (node.col - 1));
if (node.col < puzzle.cols - 1) ns.push(node.row * puzzle.cols + (node.col + 1));
return ns;
};
// 若尚未落子,任意颜色匹配 remaining[0] 的未使用节点都可作为起点
const starts: number[] =
puzzle.path.length === 0
? puzzle.grid.filter((n) => !used.has(n.id) && n.color === remaining[0]).map((n) => n.id)
: [puzzle.path[puzzle.path.length - 1]];
const dfs = (curId: number, idx: number): boolean => {
if (idx >= remaining.length) return true;
const nextColor = remaining[idx];
for (const nid of neighborsOf(curId)) {
if (used.has(nid)) continue;
const n = puzzle.grid.find((x) => x.id === nid);
if (!n || n.color !== nextColor) continue;
// 起点情况:第一步 idx=0 时,curId 本身要匹配 nextColor
if (idx === 0 && puzzle.path.length === 0) {
const cur = puzzle.grid.find((x) => x.id === curId);
if (!cur || cur.color !== nextColor) continue;
}
used.add(nid);
if (dfs(nid, idx + 1)) return true;
used.delete(nid);
}
return false;
};
for (const st of starts) {
if (puzzle.path.length === 0) {
// 起点自身需匹配 remaining[0]
const cur = puzzle.grid.find((n) => n.id === st);
if (!cur || cur.color !== remaining[0]) continue;
used.add(st);
if (dfs(st, 1)) return true;
used.delete(st);
} else {
if (dfs(st, 0)) return true;
}
}
return false;
}
/** 两节点是否四邻接 */
export function isAdjacent(a: DecodeNode, b: DecodeNode): boolean {
return Math.abs(a.row - b.row) + Math.abs(a.col - b.col) === 1;
}
/** 尝试点击节点。返回 { ok, finished, failReason } */
export function tryClickNode(
puzzle: DecodePuzzle,
nodeId: number
): { ok: boolean; finished: boolean; failReason?: string } {
const node = puzzle.grid.find((n) => n.id === nodeId);
if (!node) return { ok: false, finished: false, failReason: "无效节点" };
if (node.used) return { ok: false, finished: false, failReason: "节点已使用" };
const stepIdx = puzzle.path.length;
// 颜色须匹配目标序列当前位置
if (node.color !== puzzle.target[stepIdx]) {
return { ok: false, finished: false, failReason: "谐振不匹配" };
}
// 非首步须邻接
if (stepIdx > 0) {
const prev = puzzle.grid.find((n) => n.id === puzzle.path[stepIdx - 1])!;
if (!isAdjacent(prev, node)) {
return { ok: false, finished: false, failReason: "节点不相邻" };
}
}
// 步数上限
if (stepIdx >= puzzle.stepLimit) {
return { ok: false, finished: false, failReason: "步数耗尽" };
}
node.used = true;
puzzle.path.push(nodeId);
const finished = puzzle.path.length >= puzzle.target.length;
return { ok: true, finished };
}
/** 是否已无解 — 复用精确的 isSolvable 判定 */
export function isStuck(puzzle: DecodePuzzle): boolean {
if (puzzle.path.length >= puzzle.target.length) return false;
if (puzzle.path.length >= puzzle.stepLimit) return true;
return !isSolvable(puzzle);
}
/**
* 假设从某节点作为第一步落子,局面是否仍可解。
* 用于在选起点阶段高亮「可行起点」,避免玩家选到死起点。
*/
export function canStartFrom(puzzle: DecodePuzzle, nodeId: number): boolean {
if (puzzle.path.length !== 0) return false;
const node = puzzle.grid.find((n) => n.id === nodeId);
if (!node || node.used) return false;
if (node.color !== puzzle.target[0]) return false;
const cp: DecodePuzzle = JSON.parse(JSON.stringify(puzzle));
const n = cp.grid.find((x) => x.id === nodeId)!;
n.used = true;
cp.path.push(nodeId);
return isSolvable(cp);
}
/** 重置谜题(保留同一晶体,重新挑战) */
export function resetPuzzle(puzzle: DecodePuzzle): DecodePuzzle {
return generatePuzzle(puzzle.tier, puzzle.seed + 1);
}