266 lines
9.4 KiB
TypeScript
Executable File
266 lines
9.4 KiB
TypeScript
Executable File
// 回响星核 / 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);
|
||
}
|