四阶魔方
目录

一,四阶魔方的定位
以任意一个角块为基准,都可以确定另外七个角块的位置和方向,即确定了整个魔方的颜色。
实际上,对于任意阶魔方都同理,整个魔方的颜色是固定的。
但是,每个块的位置和颜色是否唯一确定呢?不一定。
二,降阶法的原理性思考
1,中心块合并
四阶魔方有24个中心块,合并为6组,每组4个中心块。
思考问题一:对于每一组4个中心块,是否可区分?结合方式是否唯一?
答案很明显,4个中心块的任意2个都无法直接区分,结合方式也不唯一。
思考问题二:4个中心块的任意结合方式,是否都正确?
据我猜测,只要中心块都对了,四阶魔方降阶之后就能直接复原。否则,一定是中心块不对。
这是四阶魔方的核心问题,与四阶魔方最后能否直接复原有直接对应关系。具体是怎么对应的,需要区分4个中心块,观察最后的规律。
注意:在合并中心块时,要让6个面的相对位置正确,如果不记得,可以用8个角快定位。
2,棱块合并
四阶魔方有24个棱块,两两合并变成12个棱块。
思考问题三:对于每一组2个棱块,2个面的颜色相同,那么是否可区分?结合方式是否唯一?
答案很明显,2个棱块可以轻易地区分,结合方式也是唯一的。
3,降阶之后的复原
在《三阶魔方》一文中,我提到如果将魔方拆开,随机拼起来,按照层先法复原,七步分别为:做好底层十字、复原底层、复原中层棱块、做好顶面十字、复原顶面、调整顶角位置、调整顶棱位置。每一步能成功的概率依次是1,1,1,1/2,1/3,1,1/2。
同样地,对于四阶魔方,第1,2,3,5,6步也是一定没问题的。
对于第4,7步:做好顶面十字、调整顶棱位置,就不一定能成功了。
会遇到2种三阶魔方不会遇到的情况,我取名为单棱翻转、双棱对换,对应的也就有两个公式。
据我猜测,这2步的成功概率都是1/2,而且是独立的。
这2个概率,应该是能直接体现在中心块的位置上的。
三,中心块的位置综述
中心块的位置一共有3种信息:
(1)6个面的颜色
虽然6个面的相对位置是确定的,但是每个面在魔方的哪个位置呢?是否固定
答案无法2种情况,要么是像二阶魔方一样不是固定的,以任意方式执魔方,再以上白下黄左绿右蓝前红后橘来设定六个面,都是可以复原的,要么是像三阶空心魔方一样是固定的,每个面的中心块的颜色无法确定,只有最后复原不了的时候才知道,做个调整即可。
(2)每个面4个中心块的相对位置
这个几乎可以确定,肯定是有关系的。
(3)每个面4个中心块的转向
在三阶魔方中,中心块的转向(即四个朝向)是不可感知的,但是在三阶图案魔方A中,中心块的方向随机选择的话只有1/2的概率是正确的,这个数值倒是吻合我的猜想。
中心块的位置有这3个维度的信息,那么影响最终能否复原的,按照我的猜想是1/2 * 1/2
如果真是这个概率的话,就有3种情况:
(1)3个维度是独立的,概率分别是1,1/2,1/2
(2)有一个维度是独立的,概率是1/2,另外两个维度的组合概率是1/2
(3)三个维度都是不独立的,限定任何一个的条件下,另外两个的概率都是1/2
为了研究具体的对应关系,我买了贴纸来辅助研究。
首先,拿到一个复原的四阶魔方,然后在每个面的四个中心块上标号1,2,3,4

数字1 2 3 4就确定了4个中心块的相对位置,至于转向,根据4个数字的位置也可以描述,如上图白色中心块的转向可以描述为白——红,以此类推,其他面是红——黄,橙——黄,绿——黄,蓝——黄,黄——橙
于是,每个面4个中心块的相对位置和转向就可以记录了,但是6个面的颜色到底在哪个位置就不好记录了。
所以我决定先研究这个到底有没有影响。
四,六个面的颜色
在三阶魔方中,我已经研究并指出,如果中心块做四元轮换,就会变成双棱对换,

这让我有理由相信,四阶魔方的6个面的颜色位置也有这个影响。
首先做个四元轮换,注意,不是把中间两层转90度就完了,而是通过中心块交换的方式完成四元轮换,
顺便再把中间两层转90度,使得每个面的中心块的相对位置和转向都不变:

再合并棱,然后按照三阶魔方复原的时候注意各个面的朝向不要变,最后的结果果然是双棱交换:

既然出现了这个状态,无论这个状态是如何形成的,我们都可以肯定的说,6个面的颜色位置确实可以导致双棱交换。
那么,我们只要把通过中心块交换的方式完成四元轮换的这个过程进行固化,就得到了四阶魔方解决这种情况的公式。
PS:这个维度不一定是导致双棱对换的唯一原因,其他维度还需要一一研究才能得到结论。
我的公式分为2步:
(1)先做中心块的四元轮换,此时会有4个棱没有合并,有8个棱是从来没有拆散过的
(2)再按照正常思路合并棱并继续复原
其中,四元轮换可以分解为3个步骤,每个步骤都是交换相邻两个面的中心块。

如果四阶魔方的中心块位置这个概念很难理解(类似于四维空间,很难想象具体是什么样子),
那么,在交换相邻两面的中心块的时候,其他四个面的中心块都是不变的,这样就比较容易理解到,中心块的位置这个概念真的存在,它和直接把中间2层转动90度真的不一样。
然而,我又发现了似乎不对的地方,因为我采用了更好的方式,更简单的步骤来完成,通过中心块交换的方式完成四元轮换,而且操作完不需要合并棱。
这个交换方法显然是存在的,而且很简单的几步就完成了。
然后我发现这样的四元轮换之后,魔方是可以复原的!
这就和上面的情况矛盾了,仔细分析之后,我认为关键在于2种交换两个面的中心块的方法存在本质差异。
一种是真四元轮换,一种是伪四元轮换,六个面的位置并没有换。
至此,我们基本上得到了,调整双棱对换的情况的公式,接下来要做的就是固化交换两个面的中心块的方法:
以复原的魔方来展示:

首先我们知道,只需要4步就可以交换2个中心块和另外2个中心块:
(简单描述就是RUUR’)

此时如果再用左右对称的另外四步(L‘UUL),就可以把红色中心块都弄到上面,蓝色都弄到前面,但是这种操作组合起来的是伪四元轮换。
此时应该把上面和前面都旋转180度:

然后再把上面的RUUR’四步重复一遍:

这样就完成了交换两个面的中心块,这个过程对另外四个面的中心块没有任何影响。
可以简述为:RUUR’ UUFF RUUR’
然而,我发现如果直接把这个过程重复3遍,也可以达到交换中心块的目的,而且棱一点都不乱,非常整齐:

可以简述为:RUUR’ UUFF RUUR’ RUUR’ UUFF RUUR’ RUUR’ UUFF RUUR’
其中RUUR’ RUUR’是可以直接去掉的,于是简化为RUUR’ UUFF UUFF UUFF RUUR’ ,其中RR'都是2层,UF都是单层,这就是我自创的换中心块的公式。
把这个在不同位置重复三次即可完成四元轮换,四元轮换完不需要合并棱直接复原魔方。
至此,我们得到了我自创的双棱对换的公式:换中心块公式*3
五,中心块的相对位置和转向解耦
中心块的这3个维度,不考虑各个维度对整个魔方结构的影响,单就感知层面来讲,六个面的颜色这个维度是独立的,而每个面4个中心块的相对位置和转向这2个维度是强耦合的。
换句话说,从1234变成1342,可以理解为2次交换2个相邻的中心块,也可以理解为先转动中心块,再交换相邻的2个中心块。

在有色图案魔方中,我讨论了关于中心块的转向的问题,也得到了结论:随机转向有1/2的概率是正确的。
然而,这并不影响我们复原魔方。
也就是说,三阶图案魔方A达不到六个面的颜色全部OK,5个中心块的转向都对,只有1个中心块的转向不对,这种状态,
但是四阶魔方或许可以达到,如果可达,那么这种状态也是属于最终复原状态之一。
那我们是不是控制另外2个维度,先单独研究这个维度呢?
据我理解,做不到,因为中心块的转向在三阶魔方的复原过程中是时时刻刻都在变化的,可以说这是一个存在却又极端不可控的维度。
不过,这也让我突然醒悟,中心块的转向并不是随意指定的,而是由棱块和角块的位置和方向控制的(可能是两个一起控制,也可能只是其中一个控制,这个不是我们研究的重点,不需要关心)。
也就是说,我们在表述中心块的相对位置时,并不允许随意单独转动一个面90度。
那么问题来了,转向有1/2的概率是正确的,到底是以当前转向来表述中心块的位置,还是转90度之后才是正确的呢?
很遗憾,这个是无法感知的,这个维度蜷缩了,只能到最后复原的时候才能感知。
所以我们可以固定除顶面之外的五个面的中心块的转向,这样,复原到顶层的时候,顶层中心块的转向也就随之确定了,以这个转向为基础,就可以表述顶层四个中心块的位置了。
这样,关于这2个维度的强耦合的应对方法,就这样定下来了。
六,中心块的相对位置——我的猜想
首先,我们在保持六个面的颜色不变的前提下,让某一个面的4个中心块位置发生变化。
如图:

除黄色面之外的5个面,中心块的相对位置都是正确的,之所以选黄色面,是因为前面设定的转向是有规律的,取黄色或者白色为顶面比较方便。
合并棱:

然后以黄色为顶面进行复原,同时保持其他5个面的中心块的朝向正确。

结果居然复原了,黄色面按照同等朝向,4个中心块分别是1423
按照这个思路,4个数字的排列有24种,不需要全部收集,只需要收集一部分数据,肯定就能得到规律。
实际上我已经对规律有所猜想了,应该和三阶魔方的角块类似,交换2个相邻的角块的位置就复原不了,但是再交换2个相邻角块就又可以复原了。也就是说,从1234变成另外一种位置状态,需要的最少交换次数是固定的,最少交换次数为偶数的状态就是可以复原的。
验证一下:因为转向是可以转180度的,所以1234变成4321应该是可以复原的,而1234变成4321需要的是4次交换,确实是偶数。
PS:
很多年后,当我创作高阶魔方自动复原 这篇文章时,回来仔细审视本文,发现这里写的不够清楚,做一点补充。
从1234变成4321,到底怎么变?是每次只能交换相邻2个,还是每次可以交换任意2个?可以很容易证明,这2种方案下的最少操作次数的奇偶性一定是一样的。
这也是为什么,我们把4个中心块变成一个序列时,无需纠结是按照阅读顺序还是按照转圈顺序等,因为都等价。
七,合并棱块
合并棱块的内容,本该在最前面讨论的,但是这篇博客写作的跨越时间很长,在我漫长的,断断续续的抽空研究四阶魔方的过程中,我一点点实时的把我的研究内容记录了下来。
因为我之前觉得合并棱块的公式并没有什么好记录的,所以就跳过了。
但是现在研究到了最后的阶段,为了确定合并棱块的过程对中心块的位置有没有本质影响,我需要先把我合并棱块的公式固化下来。
合并棱块的公式,是我自己发明的:
在我知道我的公式重复三遍就会还原的前提下,我用一个复原的魔方把公式先用了两遍,然后再以次作为展示我的公式的基础:

现在要合并白蓝棱块,对比三阶魔方的白蓝棱块和一个白蓝橘角块的合并,简单合并自然是三步:

但是四阶魔方直接这样会让中心块乱掉,所以在这三步之外,还需要几步规避操作:

此时只需要一个额外操作:在白蓝棱块的位置保持不变的情况下,把右边逆时针旋转90度。

此时再进行最后一步即可,所以我的合并棱的公式一共6步。
顺便提一下合并棱的收尾,即避免出现最后只剩2组棱位置不对的情况。
很简单,当我们只剩三组棱的时候,我们就需要看一下,合并哪一对棱是可以直接同时把另外两对棱也同时合并的。

我的换棱公式,对所有的中心块的位置都没有任何影响,对棱的影响是让上图中的3组棱组合到一起,其他的棱不影响。
所以,最后只剩三组棱的时候,只需要看一下这个位置关系没错就可以了。
八,中心块的相对位置——验证我的猜想
在充分了解了我自己的这个换棱公式之后,再来继续验证我的猜想。
然而,收集数据时的难点在于,因为转向这个维度是蜷缩的,不到最后一步看不出来转向。
所以,在打乱黄色面的位置的同时,需要记住一共进行了多少次90度旋转,以次为基础判断是否真的打乱了。
继续收集数据:
于是我得到了这样一个状态:

在过程中,六个面的位置都没有变化,最后六个面的4个中心块的位置和转向也都正确,但是还是双棱对换而没有复原,这让我有点意外。
再仔细一想,上面这个思路并不严谨。
虽然换棱公式本身没有直接影响中心块,但是可能导致棱块有不同的位置,而中心块的转向即使表面上没有变化,也可能因为棱块的具体位置而实际上转动了90度。要仔细探究这一点的话,情况又变得比较复杂。
然后我又试了很多次,始终做不到我想象的,单独交换相邻的2个中心块。
或许,这根本就是无法做到的?
如果这样的话,那到底还有什么维度能对应单棱翻转呢?
九,状态恒等式
在有了我的魔方状态恒等式 理论之后,再思考单棱翻转的问题就很简单了。
首先,单棱翻转的本质不是朝向问题,而是位置问题,即2个棱块的位置互换了。
这一点对于8阶魔方更明显,因为会出现一种情况,最后一个棱无论正反,总有几个棱块是反着的。
所以,会不会出现单棱翻转,只取决于所有棱块的位置序列的逆序数对不对。
而解决方法也很简单,直接把第二层转一下,逆序数就改了。然后再不改逆序数的情况下,分3次逐步恢复中心大正方形即可。
PS :这样看来,同色的4个中心块之间的相对位置和朝向,还真的不影响任何部件。
十,复原方法总结
1,复原中心区
同五阶魔方
2,组装侧边区
公式是:F2' R U' R' U F2
效果相当于把这个状态复原:

3,按照三阶魔方层先法复原
(1)单棱翻转的情况

则执行R2 U U R2 U U R2' x' L2' U U L2 x' U U R2 U U R2'
其中,x操作参考高阶魔方自动复原
再重新合并棱块,就不会出现单棱翻转的情况。
(2)双棱对换的情况
交换相邻2个面的中心区的公式:
这个公式操作3次,即可实现把一圈4个中心区轮换一下。
这样,再按照三阶魔方层先法复原就不会再有问题了。
十一,四阶魔方网页版
JS部分:
HTML部分:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>四阶魔方 3D</title>
<style>
:root {
--bg1: #161b26;
--bg2: #1f2635;
--txt: #e8edf5;
--dim: #8fa0bf;
--panel: #1b2231;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: radial-gradient(1200px 700px at 50% -10%, var(--bg2), var(--bg1));
color: var(--txt);
padding: 20px 16px 30px;
overflow: auto;
}
h1 { margin: 0 0 4px; font-size: 24px; letter-spacing: 3px; text-align: center; }
#status {
min-height: 24px;
margin: 2px 0 10px;
font-size: 14px;
color: var(--dim);
transition: color .3s;
text-align: center;
}
#status.solved { color: #7ee08a; font-weight: bold; animation: pop .55s ease; }
@keyframes pop {
0% { transform: scale(.6); opacity: 0; }
70% { transform: scale(1.18); }
100% { transform: scale(1); opacity: 1; }
}
#layout {
display: flex;
gap: 20px;
width: 100%;
max-width: 1420px;
margin: 0 auto;
align-items: flex-start;
justify-content: center;
}
#leftCol {
flex: 0 0 420px;
width: 420px;
display: flex;
flex-direction: column;
align-items: center;
}
#middleCol {
flex: 1 1 auto;
min-width: 340px;
max-width: 580px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
#rightCol {
flex: 0 0 330px;
width: 330px;
position: sticky;
top: 20px;
}
#stage {
width: 100%;
height: 68vh;
min-height: 420px;
max-height: 700px;
}
#controls { display: flex; flex-direction: column; gap: 9px; align-items: center; width: 100%; }
.move-row { display: flex; gap: 7px; flex-wrap: wrap; justify-content: center; }
.move-label { font-size: 12px; color: #6c7b99; letter-spacing: 1px; margin-bottom: -2px; }
#faceButtons, #wideButtons { flex-wrap: wrap; gap: 5px; }
#faceButtons button, #wideButtons button { padding: 9px 8px; min-width: 42px; font-size: 14px; }
button {
border: none;
border-radius: 9px;
padding: 10px 15px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
background: #37425c;
color: #fff;
box-shadow: 0 3px 0 #1a2130;
transition: transform .06s, background .15s, box-shadow .06s;
min-width: 48px;
letter-spacing: .5px;
}
button:hover { background: #46557a; }
button:active { transform: translateY(3px); box-shadow: 0 0 0 #1a2130; }
button.cw { background: #3153a8; }
button.cw:hover { background: #436bcb; }
button.ccw { background: #a84f33; }
button.ccw:hover { background: #c9633f; }
button.wide { background: #1f6f6b; }
button.wide:hover { background: #288b86; }
button.rot { background: #6b4fa0; }
button.rot:hover { background: #8462c4; }
button.tool { background: #2f7a55; }
button.tool:hover { background: #3b9768; }
button.tool.warn { background: #8a3d3d; }
button.tool.warn:hover { background: #a84c4c; }
button.copy { background: #2a6e8f; }
button.copy:hover { background: #3588ad; }
#autoRunBtn { background: #7a3f8a; }
#autoRunBtn:hover { background: #9550a8; }
.hint {
margin-top: 4px;
font-size: 12.5px;
color: #6c7b99;
text-align: center;
line-height: 1.8;
}
kbd {
background: #2a3344;
border: 1px solid #4a5a7a;
border-radius: 5px;
padding: 1px 7px;
font-size: 12px;
}
#recordBox { display: flex; flex-direction: column; gap: 10px; }
#recordTitle { font-size: 15px; font-weight: 700; color: #cfd9ea; letter-spacing: 1px; text-align: center; }
#recordPanel {
background: var(--panel);
border: 1px solid #2a3344;
border-radius: 10px;
padding: 14px 16px;
font-family: "Cascadia Code", "Consolas", "Courier New", monospace;
font-size: 13px;
line-height: 1.65;
color: #cfd9ea;
white-space: pre-wrap;
word-break: break-all;
height: 68vh;
min-height: 420px;
max-height: 700px;
overflow: auto;
margin: 0;
}
#copyBtn { width: 100%; }
#seqRow { width: 100%; margin-top: 4px; flex-wrap: nowrap; }
#seqInput {
flex: 1;
min-width: 150px;
padding: 9px 12px;
border-radius: 8px;
border: 1px solid #4a5a7a;
background: #161b26;
color: #e8edf5;
font-size: 14px;
font-family: "Cascadia Code", "Consolas", monospace;
}
#seqInput:focus { outline: none; border-color: #6d88c8; }
#formulaRow { width: 100%; margin-top: 2px; gap: 6px; flex-wrap: nowrap; }
#formulaNameInput,
#formulaContentInput {
padding: 9px 12px;
border-radius: 8px;
border: 1px solid #4a5a7a;
background: #161b26;
color: #e8edf5;
font-size: 13px;
font-family: "Cascadia Code", "Consolas", monospace;
}
#formulaNameInput { width: 100px; flex: 0 0 100px; }
#formulaContentInput { flex: 1; min-width: 120px; }
#formulaNameInput:focus,
#formulaContentInput:focus { outline: none; border-color: #6d88c8; }
#formulaButtons { margin-top: 2px; }
#formulaButtons button.formula { background: #8a5f9e; }
#formulaButtons button.formula:hover { background: #a174b8; }
@media (max-width: 1240px) {
#layout { flex-wrap: wrap; }
#leftCol { flex: 0 0 380px; width: 380px; }
#rightCol { flex: 0 0 300px; width: 300px; }
}
@media (max-width: 1000px) {
#layout { flex-direction: column; align-items: center; }
#leftCol { flex: 1 1 auto; width: 100%; max-width: 540px; }
#middleCol { flex: 1 1 auto; width: 100%; max-width: 580px; }
#rightCol { width: 100%; max-width: 580px; position: static; }
#stage { height: 52vh; min-height: 320px; max-height: 540px; }
#recordPanel { height: auto; max-height: 340px; min-height: 0; }
}
@media (max-width: 560px) {
button { padding: 8px 10px; font-size: 13px; min-width: 40px; }
#formulaNameInput { width: 84px; flex-basis: 84px; }
#seqRow, #formulaRow { flex-wrap: wrap; }
}
</style>
</head>
<body>
<h1>四阶魔方</h1>
<div id="status">加载中…</div>
<div id="layout">
<div id="leftCol">
<div id="stage"></div>
</div>
<div id="middleCol">
<div id="controls">
<div class="move-label">外层转动</div>
<div class="move-row" id="faceButtons"></div>
<div class="move-label">双层转动(两层一起)</div>
<div class="move-row" id="wideButtons"></div>
<div class="move-label">整体转体(不计步数)</div>
<div class="move-row" id="rotButtons"></div>
<div class="move-row">
<button class="tool" id="scrambleBtn">打乱</button>
<button class="tool warn" id="resetBtn">复原</button>
</div>
<div class="move-row" id="seqRow">
<input id="seqInput" type="text"
placeholder="输入操作序列,如 F' L2 U2' D F B' D F2 U' L' R B2' R' D' R'" />
<button class="tool" id="seqBtn">执行</button>
</div>
<div class="move-row" id="formulaRow">
<input id="formulaNameInput" type="text" placeholder="公式名称" />
<input id="formulaContentInput" type="text"
placeholder="公式内容,如 R2 U R2' U'" />
<button class="tool" id="addFormulaBtn">添加公式</button>
</div>
<div class="move-row" id="formulaButtons"></div>
<div class="move-row">
<button class="tool" id="autoRunBtn">自动运行</button>
</div>
</div>
<div class="hint">
外层:<kbd>U</kbd><kbd>R</kbd><kbd>F</kbd><kbd>D</kbd><kbd>L</kbd><kbd>B</kbd>
双层:<kbd>U2</kbd><kbd>R2</kbd><kbd>F2</kbd><kbd>D2</kbd><kbd>L2</kbd><kbd>B2</kbd>
转体:<kbd>X</kbd><kbd>Y</kbd><kbd>Z</kbd>,<kbd>Shift</kbd> + 字母为逆时针。<br>
下方镜面面板会完整显示底面颜色。右键公式按钮可删除。<br>
自动运行需先启动 server.js 并通过 http://localhost:3000 打开页面。
</div>
</div>
<div id="rightCol">
<div id="recordBox">
<div id="recordTitle">📋 棋谱</div>
<button class="tool copy" id="copyBtn">📋 一键复制棋谱</button>
<pre id="recordPanel"></pre>
</div>
</div>
</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js"
}
}
</script>
<script type="module">
import * as THREE from 'three';
// ---------- 场景 ----------
const stage = document.getElementById('stage');
const statusEl = document.getElementById('status');
const recordPanel = document.getElementById('recordPanel');
const copyBtn = document.getElementById('copyBtn');
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(stage.clientWidth, stage.clientHeight);
stage.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(38, stage.clientWidth / stage.clientHeight, 0.1, 100);
camera.position.set(6.0, 5.2, 8.2);
camera.lookAt(0, -0.8, 0);
scene.add(new THREE.AmbientLight(0xffffff, 0.9));
const light1 = new THREE.DirectionalLight(0xffffff, 1.3);
light1.position.set(5, 9, 6);
scene.add(light1);
const light2 = new THREE.DirectionalLight(0xffffff, 0.45);
light2.position.set(-4, -3, -2);
scene.add(light2);
// ---------- 颜色:上白 下黄 左绿 右蓝 前红 后橙 ----------
const COL_HEX = {
U: '#f5f5f5',
D: '#ffd500',
R: '#3b82f6',
L: '#3aab4e',
F: '#e33c3c',
B: '#ff8c1a'
};
const COL_CHAR = { U: 'W', D: 'Y', R: 'B', L: 'G', F: 'R', B: 'O' };
const CHAR_HEX = { W: '#f5f5f5', Y: '#ffd500', B: '#3b82f6', G: '#3aab4e', R: '#e33c3c', O: '#ff8c1a' };
// ---------- 坐标体系:四阶外层 ±1.5,内层 ±0.5 ----------
const OUT = 1.5;
const IN = 0.5;
const SIZE = 0.96;
// 转动定义:side 为符号(+1 / -1),wide 表示两层一起转
const FACE_DEF = {
// 外层
U: { axis: 'y', side: 1, vec: new THREE.Vector3(0, 1, 0) },
D: { axis: 'y', side: -1, vec: new THREE.Vector3(0,-1, 0) },
R: { axis: 'x', side: 1, vec: new THREE.Vector3(1, 0, 0) },
L: { axis: 'x', side: -1, vec: new THREE.Vector3(-1,0, 0) },
F: { axis: 'z', side: 1, vec: new THREE.Vector3(0, 0, 1) },
B: { axis: 'z', side: -1, vec: new THREE.Vector3(0, 0,-1) },
// 双层(第一层 + 第二层一起转)
U2: { axis: 'y', side: 1, wide: true, vec: new THREE.Vector3(0, 1, 0) },
D2: { axis: 'y', side: -1, wide: true, vec: new THREE.Vector3(0,-1, 0) },
R2: { axis: 'x', side: 1, wide: true, vec: new THREE.Vector3(1, 0, 0) },
L2: { axis: 'x', side: -1, wide: true, vec: new THREE.Vector3(-1,0, 0) },
F2: { axis: 'z', side: 1, wide: true, vec: new THREE.Vector3(0, 0, 1) },
B2: { axis: 'z', side: -1, wide: true, vec: new THREE.Vector3(0, 0,-1) }
};
// 整体转体:y 和 z 已互换 —— y 操作绕 z 轴,z 操作绕 y 轴
const ROT_DEF = {
x: { axis: 'x' },
y: { axis: 'z' },
z: { axis: 'y' }
};
// 局面显示顺序:U D L R F B
const FACE_ORDER = ['U','D','L','R','F','B'];
// ---------- 贴纸纹理 ----------
function roundedRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function makeStickerTexture(hex) {
const size = 128;
const canvas = document.createElement('canvas');
canvas.width = canvas.height = size;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#0b0b0d';
roundedRect(ctx, 0, 0, size, size, 14);
ctx.fill();
const m = 17;
ctx.fillStyle = hex;
roundedRect(ctx, m, m, size - 2 * m, size - 2 * m, 9);
ctx.fill();
const tex = new THREE.CanvasTexture(canvas);
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 4;
return tex;
}
const TEX = {};
for (const f in COL_HEX) TEX[f] = makeStickerTexture(COL_HEX[f]);
const blackPlastic = new THREE.MeshStandardMaterial({ color: 0x0a0a0c, roughness: 0.62, metalness: 0.08 });
function stickerMaterial(face) {
return new THREE.MeshStandardMaterial({ map: TEX[face], roughness: 0.3, metalness: 0.05 });
}
// ---------- 镜面面板(直接绘制底面颜色) ----------
const mirrorCanvas = document.createElement('canvas');
mirrorCanvas.width = mirrorCanvas.height = 256;
const mctx = mirrorCanvas.getContext('2d');
const mirrorTex = new THREE.CanvasTexture(mirrorCanvas);
mirrorTex.colorSpace = THREE.SRGBColorSpace;
const mirrorMat = new THREE.MeshBasicMaterial({ map: mirrorTex });
const mirrorMesh = new THREE.Mesh(new THREE.PlaneGeometry(4.8, 4.8), mirrorMat);
mirrorMesh.rotation.x = -Math.PI / 2;
mirrorMesh.position.y = -2.8;
scene.add(mirrorMesh);
function updateMirror() {
const d = getState().D || '????????????????';
mctx.clearRect(0, 0, 256, 256);
mctx.fillStyle = '#0d0f14';
roundedRect(mctx, 0, 0, 256, 256, 18);
mctx.fill();
const cell = 46, gap = 6;
const ox = (256 - cell * 4 - gap * 3) / 2;
const oy = ox;
for (let i = 0; i < 16; i++) {
const r = Math.floor(i / 4), c = i % 4;
mctx.fillStyle = CHAR_HEX[d[i]] || '#999';
roundedRect(mctx, ox + c * (cell + gap), oy + r * (cell + gap), cell, cell, 7);
mctx.fill();
}
mirrorTex.needsUpdate = true;
}
// ---------- 56 个表面块 ----------
let cubies = [];
const pivot = new THREE.Group();
scene.add(pivot);
function createCubie(x, y, z) {
const mats = [
x === OUT ? stickerMaterial('R') : blackPlastic,
x === -OUT ? stickerMaterial('L') : blackPlastic,
y === OUT ? stickerMaterial('U') : blackPlastic,
y === -OUT ? stickerMaterial('D') : blackPlastic,
z === OUT ? stickerMaterial('F') : blackPlastic,
z === -OUT ? stickerMaterial('B') : blackPlastic
];
const geo = new THREE.BoxGeometry(SIZE, SIZE, SIZE);
const mesh = new THREE.Mesh(geo, mats);
mesh.position.set(x, y, z);
scene.add(mesh);
const colors = {
px: x === OUT ? COL_CHAR.R : null,
nx: x === -OUT ? COL_CHAR.L : null,
py: y === OUT ? COL_CHAR.U : null,
ny: y === -OUT ? COL_CHAR.D : null,
pz: z === OUT ? COL_CHAR.F : null,
nz: z === -OUT ? COL_CHAR.B : null
};
return { mesh, home: new THREE.Vector3(x, y, z), colors };
}
function buildCube() {
cubies.forEach(c => scene.remove(c.mesh));
cubies = [];
const pos = [-OUT, -IN, IN, OUT];
for (const x of pos)
for (const y of pos)
for (const z of pos) {
// 只创建表面块(至少一个坐标在外层),内部 2x2x2 不可见块不创建
if (Math.abs(x) === OUT || Math.abs(y) === OUT || Math.abs(z) === OUT) {
cubies.push(createCubie(x, y, z));
}
}
}
// ---------- 旋转 ----------
const moveQueue = [];
let busy = false;
let moveCount = 0;
let scrambleSeq = [];
let userSeq = [];
let initialState = null;
function easeInOutCubic(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
function snap(v) { return Math.round(v * 2) / 2; }
function performRotation(selected, axis, dir, duration, done) {
selected.forEach(c => pivot.attach(c.mesh));
const angle = -dir * Math.PI / 2;
const start = performance.now();
function frame(now) {
let t = Math.min(1, (now - start) / duration);
const e = easeInOutCubic(t);
pivot.rotation.set(0, 0, 0);
pivot.rotation[axis] = angle * e;
if (t < 1) {
requestAnimationFrame(frame);
} else {
pivot.rotation.set(0, 0, 0);
pivot.rotation[axis] = angle;
selected.forEach(c => {
scene.attach(c.mesh);
c.mesh.position.x = snap(c.mesh.position.x);
c.mesh.position.y = snap(c.mesh.position.y);
c.mesh.position.z = snap(c.mesh.position.z);
});
pivot.rotation.set(0, 0, 0);
done();
}
}
requestAnimationFrame(frame);
}
function performMove(face, dir, duration, done) {
const def = FACE_DEF[face];
if (def) {
let selected;
if (def.wide) {
// 双层:选取该轴正侧或负侧的所有块(两个层)
const s = def.side;
selected = cubies.filter(c => Math.sign(snap(c.mesh.position[def.axis])) === s);
} else {
// 外层:只选最外一层
const target = def.side * OUT;
selected = cubies.filter(c => Math.abs(c.mesh.position[def.axis] - target) < 0.01);
}
// 顺时针 = 从该面外侧看。轴正侧与轴负侧旋转角符号相反
const flip = def.side > 0 ? 1 : -1;
performRotation(selected, def.axis, dir * flip, duration, done);
} else if (ROT_DEF[face]) {
performRotation(cubies.slice(), ROT_DEF[face].axis, dir, duration, done);
}
}
function enqueueMove(face, dir, duration, opts = {}) {
moveQueue.push({
face, dir, duration,
count: opts.count !== false,
record: !!opts.record,
silent: opts.silent === true,
onDone: opts.onDone
});
if (!busy) processQueue();
}
function processQueue() {
if (moveQueue.length === 0) { busy = false; return; }
busy = true;
const m = moveQueue.shift();
if (m.record) userSeq.push({ face: m.face, dir: m.dir });
performMove(m.face, m.dir, m.duration, () => {
if (m.count) {
moveCount++;
if (isSolved()) {
statusEl.textContent = '🎉 已复原!共用了 ' + moveCount + ' 步';
statusEl.classList.add('solved');
} else {
statusEl.textContent = '步数:' + moveCount;
statusEl.classList.remove('solved');
}
}
if (m.onDone) m.onDone();
if (!m.silent) updateRecordPanel();
processQueue();
});
}
// ---------- 轴判定(抗浮点误差) ----------
function axisToKey(v) {
const ax = Math.abs(v.x), ay = Math.abs(v.y), az = Math.abs(v.z);
if (ax >= ay && ax >= az) return v.x >= 0 ? 'px' : 'nx';
if (ay >= ax && ay >= az) return v.y >= 0 ? 'py' : 'ny';
return v.z >= 0 ? 'pz' : 'nz';
}
// ---------- 复原判定:6 个面每面 16 个贴纸颜色一致 ----------
function isSolved() {
const st = getState();
for (const f of FACE_ORDER) {
const s = st[f];
if (!s || s.length !== 16) return false;
for (let i = 1; i < 16; i++) {
if (s[i] !== s[0]) return false;
}
}
return true;
}
// ---------- 局面读取(4x4) ----------
function getFaceSlots(face) {
const s = [];
for (let r = 0; r < 4; r++)
for (let c = 0; c < 4; c++) {
const x = c - 1.5;
const yTop = 1.5 - r;
const z = r - 1.5;
let p;
if (face === 'U') p = [x, OUT, z];
else if (face === 'D') p = [x, -OUT, z];
else if (face === 'F') p = [x, yTop, OUT];
else if (face === 'B') p = [-x, yTop, -OUT];
else if (face === 'R') p = [OUT, yTop, -x];
else p = [-OUT, yTop, x]; // L
s.push(new THREE.Vector3(p[0], p[1], p[2]));
}
return s;
}
function faceColorString(face) {
const slots = getFaceSlots(face);
const n = FACE_DEF[face].vec;
let out = '';
for (const p of slots) {
const cubie = cubies.find(c => c.mesh.position.distanceTo(p) < 0.01);
if (!cubie) { out += '?'; continue; }
const n0 = n.clone().applyQuaternion(cubie.mesh.quaternion.clone().invert());
out += cubie.colors[axisToKey(n0)] || '?';
}
return out;
}
function getState() {
const state = {};
for (const f of FACE_ORDER) state[f] = faceColorString(f);
return state;
}
function stateToText(state) {
return FACE_ORDER.map(f => f + ': ' + state[f]).join('\n');
}
function moveNotation(m) {
return m.face + (m.dir === -1 ? "'" : '');
}
function seqToText(seq) {
return seq.length ? seq.map(moveNotation).join(' ') : '(无)';
}
// ---------- 棋谱 ----------
function buildRecordText() {
const lines = [];
lines.push('四阶魔方棋谱');
lines.push('');
lines.push('【打乱序列】');
lines.push(seqToText(scrambleSeq));
lines.push('');
lines.push('【初始局面(打乱后)】');
lines.push(initialState ? stateToText(initialState) : '(打乱中…)');
lines.push('');
lines.push('【当前局面】');
lines.push(stateToText(getState()));
lines.push('');
lines.push('【完整操作序列】');
lines.push(seqToText([...scrambleSeq, ...userSeq]));
return lines.join('\n');
}
function updateRecordPanel() {
recordPanel.textContent = buildRecordText();
updateMirror();
}
async function copyRecord() {
const text = buildRecordText();
let ok = false;
try {
await navigator.clipboard.writeText(text);
ok = true;
} catch (e) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try { ok = document.execCommand('copy'); } catch (_) {}
ta.remove();
}
const old = copyBtn.textContent;
copyBtn.textContent = ok ? '✅ 已复制' : '❌ 复制失败';
setTimeout(() => { copyBtn.textContent = old; }, 1500);
}
// ---------- 操作序列解析与执行(支持 U2 / R2' 双层记法) ----------
function parseSequence(text) {
const normalized = text.replace(/[’′]/g, "'");
const tokens = normalized.trim().split(/[\s,,]+/).filter(Boolean);
const moves = [];
for (const raw of tokens) {
const m = raw.match(/^([a-zA-Z])(2)?([''])?$/);
if (!m) continue;
const letter = m[1];
const wide = !!m[2];
const dir = m[3] === "'" ? -1 : 1;
const lower = letter.toLowerCase();
let face;
if (lower === 'x' || lower === 'y' || lower === 'z') {
face = lower; // 整体转体
} else {
face = letter.toUpperCase() + (wide ? '2' : ''); // 单层 / 双层
}
moves.push({ face, dir });
}
return moves;
}
function executeSequence(text) {
const moves = parseSequence(text);
if (moves.length === 0) {
statusEl.textContent = '无法解析的操作序列';
statusEl.classList.remove('solved');
return;
}
moves.forEach((mv, i) => {
const isLayer = !!FACE_DEF[mv.face];
const isLast = i === moves.length - 1;
enqueueMove(mv.face, mv.dir, 2, {
count: isLayer,
record: true,
silent: true,
onDone: isLast ? () => {
updateRecordPanel();
if (!isSolved()) {
statusEl.textContent = '序列执行完成(' + moves.length + ' 步操作)';
statusEl.classList.remove('solved');
}
} : undefined
});
});
}
// ---------- 自动运行 ----------
const SERVER = 'http://localhost:3000';
async function autoRun() {
const stateText = stateToText(getState());
try {
const res = await fetch(SERVER + '/write-input', {
method: 'POST',
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
body: stateText
});
if (!res.ok) throw new Error('write failed');
} catch (e) {
statusEl.textContent = '❌ 无法连接本地服务,请先运行 server.js 并从 http://localhost:3000 打开页面';
statusEl.classList.remove('solved');
return;
}
statusEl.textContent = '已写入 D:/cubeInput.txt,等待 D:/cubeOutput.txt…';
statusEl.classList.remove('solved');
let content = '';
const maxWait = 120000;
const start = performance.now();
while (true) {
if (performance.now() - start > maxWait) {
statusEl.textContent = '等待 D:/cubeOutput.txt 超时';
statusEl.classList.remove('solved');
return;
}
try {
const res = await fetch(SERVER + '/read-output');
const data = await res.json();
content = (data.content || '').trim();
} catch (e) {}
if (content) break;
await new Promise(r => setTimeout(r, 500));
}
executeSequence(content);
}
// ---------- 公式录入 ----------
const STORAGE_KEY = 'cubeFormulas';
let formulas = [];
function loadFormulas() {
try {
formulas = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
if (!Array.isArray(formulas)) formulas = [];
} catch (e) {
formulas = [];
}
}
function saveFormulas() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(formulas));
}
function renderFormulaButtons() {
const box = document.getElementById('formulaButtons');
box.innerHTML = '';
formulas.forEach((f, idx) => {
const btn = document.createElement('button');
btn.className = 'formula';
btn.textContent = f.name;
btn.title = f.content + '(右键删除)';
btn.addEventListener('click', () => executeSequence(f.content));
btn.addEventListener('contextmenu', e => {
e.preventDefault();
if (confirm('删除公式「' + f.name + '」?')) {
formulas.splice(idx, 1);
saveFormulas();
renderFormulaButtons();
}
});
box.appendChild(btn);
});
}
function addFormula() {
const nameInput = document.getElementById('formulaNameInput');
const contentInput = document.getElementById('formulaContentInput');
const name = nameInput.value.trim();
const content = contentInput.value.trim();
if (!name) {
statusEl.textContent = '请填写公式名称';
statusEl.classList.remove('solved');
return;
}
if (!parseSequence(content).length) {
statusEl.textContent = '公式内容无法解析';
statusEl.classList.remove('solved');
return;
}
const exist = formulas.findIndex(f => f.name === name);
if (exist >= 0) {
formulas[exist].content = content;
} else {
formulas.push({ name, content });
}
saveFormulas();
renderFormulaButtons();
nameInput.value = '';
contentInput.value = '';
statusEl.textContent = '已添加公式:' + name;
statusEl.classList.remove('solved');
}
// ---------- 打乱 / 复原 ----------
function resetCubeInternal() {
moveQueue.length = 0;
busy = false;
cubies.forEach(c => { if (c.mesh.parent === pivot) scene.attach(c.mesh); });
pivot.rotation.set(0, 0, 0);
cubies.forEach(c => {
c.mesh.position.copy(c.home);
c.mesh.quaternion.identity();
});
}
function generateScramble(n = 30) {
const faces = Object.keys(FACE_DEF); // 外层 + 双层共 12 个
const seq = [];
let last = null;
for (let i = 0; i < n; i++) {
let f, d;
do {
f = faces[Math.floor(Math.random() * faces.length)];
d = Math.random() < 0.5 ? 1 : -1;
} while (last && last === f);
seq.push({ face: f, dir: d });
last = f;
}
return seq;
}
function scramble() {
resetCubeInternal();
userSeq = [];
initialState = null;
moveCount = 0;
statusEl.textContent = '打乱中…';
statusEl.classList.remove('solved');
scrambleSeq = generateScramble(30);
scrambleSeq.forEach((m, idx) => {
enqueueMove(m.face, m.dir, 70, {
count: false,
record: false,
onDone: idx === scrambleSeq.length - 1 ? () => {
initialState = getState();
moveCount = 0;
statusEl.textContent = '已打乱,开始复原吧';
} : undefined
});
});
}
function resetCube() {
resetCubeInternal();
moveCount = 0;
scrambleSeq = [];
userSeq = [];
initialState = null;
statusEl.textContent = '已还原到初始状态';
statusEl.classList.remove('solved');
updateRecordPanel();
}
// ---------- 按钮 & 键盘 ----------
function buildButtons() {
const faceBox = document.getElementById('faceButtons');
for (const face of ['U','R','F','D','L','B']) {
const cw = document.createElement('button');
cw.className = 'cw';
cw.textContent = face;
cw.title = face + ' 顺时针';
cw.addEventListener('click', () => enqueueMove(face, 1, 220, { count: true, record: true }));
const ccw = document.createElement('button');
ccw.className = 'ccw';
ccw.textContent = face + "'";
ccw.title = face + ' 逆时针';
ccw.addEventListener('click', () => enqueueMove(face, -1, 220, { count: true, record: true }));
faceBox.appendChild(cw);
faceBox.appendChild(ccw);
}
const wideBox = document.getElementById('wideButtons');
for (const face of ['U2','R2','F2','D2','L2','B2']) {
const cw = document.createElement('button');
cw.className = 'wide';
cw.textContent = face;
cw.title = face + ' 双层顺时针';
cw.addEventListener('click', () => enqueueMove(face, 1, 220, { count: true, record: true }));
const ccw = document.createElement('button');
ccw.className = 'wide';
ccw.textContent = face + "'";
ccw.title = face + ' 双层逆时针';
ccw.addEventListener('click', () => enqueueMove(face, -1, 220, { count: true, record: true }));
wideBox.appendChild(cw);
wideBox.appendChild(ccw);
}
const rotBox = document.getElementById('rotButtons');
for (const face of ['x','y','z']) {
const cw = document.createElement('button');
cw.className = 'rot';
cw.textContent = face;
cw.title = face + ' 整体顺时针';
cw.addEventListener('click', () => enqueueMove(face, 1, 220, { count: false, record: true }));
const ccw = document.createElement('button');
ccw.className = 'rot';
ccw.textContent = face + "'";
ccw.title = face + ' 整体逆时针';
ccw.addEventListener('click', () => enqueueMove(face, -1, 220, { count: false, record: true }));
rotBox.appendChild(cw);
rotBox.appendChild(ccw);
}
document.getElementById('scrambleBtn').addEventListener('click', scramble);
document.getElementById('resetBtn').addEventListener('click', resetCube);
copyBtn.addEventListener('click', copyRecord);
const seqInput = document.getElementById('seqInput');
const seqBtn = document.getElementById('seqBtn');
seqBtn.addEventListener('click', () => executeSequence(seqInput.value));
seqInput.addEventListener('keydown', e => {
if (e.key === 'Enter') {
e.preventDefault();
executeSequence(seqInput.value);
}
});
document.getElementById('addFormulaBtn').addEventListener('click', addFormula);
const formulaContentInput = document.getElementById('formulaContentInput');
formulaContentInput.addEventListener('keydown', e => {
if (e.key === 'Enter') {
e.preventDefault();
addFormula();
}
});
document.getElementById('autoRunBtn').addEventListener('click', autoRun);
loadFormulas();
renderFormulaButtons();
// 键盘:大写字母 = 外层,X/Y/Z = 整体转体,Shift = 逆时针
window.addEventListener('keydown', e => {
const k = e.key.toUpperCase();
if (!/^[URFDLBXYZ]$/.test(k)) return;
let moveKey = k;
if (k === 'X' || k === 'Y' || k === 'Z') moveKey = k.toLowerCase();
const isLayer = !!FACE_DEF[moveKey];
const dir = e.shiftKey ? -1 : 1;
enqueueMove(moveKey, dir, 220, { count: isLayer, record: true });
});
}
// ---------- 渲染循环 & 自适应 ----------
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
window.addEventListener('resize', () => {
const w = stage.clientWidth, h = stage.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
});
// ---------- 启动 ----------
buildCube();
buildButtons();
statusEl.textContent = '已就绪';
scramble();
updateRecordPanel();
animate();
</script>
</body>
</html>
更多推荐

所有评论(0)