原生 JS+CSS 构建支持 3D 旋转的魔方
背景简介
本篇完全基于原生 JS 和 CSS,不需要额外的开发框架或工具。但由于用到了 ES6 模块化语法,如果在浏览器中查看结果,需要添加相应的环境工具。这里是用的 VSCODE 里的 Live Server 插件,如果用 webpack 等工具构建的话,也可以添加相应的插件。
以下以二阶魔方为例,三阶及更高阶的页面部分暂时还未想到如何自动化构建,不过脚本部分已经为自动化构建优化了很多。
PS:由于自己对二阶魔方的公式不熟,就不放完整的演示了,只放几步操作过程。
demo 链接
github 链接:
https://github.com/CJc-3103/cubic
实现详解
文件结构
代码说明
点击这里直接跳转最新版
复盘
由于实现 demo 之前并没有良好调研和设计,因此重构了很多版,包括页面以及 JS 中的关键脚本。各个版本在页面的 HTML 结构方面变化不大,主要是对魔方的块和面的实现细节进行调整。
而脚本,除了主模块因为只是简单调用 Cubic 类创建实例,并添加事件监听,因此不需要修改外,其他模块尤其是魔方的构建参数、以及旋转处理两大块,基本是大改。重构的过程中思考了很多,也发现了一些问题,因此决定仔细复盘,加强在设计和重构方面的思维能力。
以下复盘不会带上全部的代码,因为一些较早的实现已经删去了,这些部分会大概描述之前的实现思路。
初版
页面
第一版基本是静态的魔方,只能看看,很难与脚本结合实现动态旋转。
页面基本结构(后面的修改都没有动基本结构,只是调整了部分 className):
.stage>.cubic>.block*8>.face*6 (这里是用 Emmet 语法,表达起来更加简单点)
block 元素同时还带有类名 block_angle--blu 等,表示当前块在魔方中的位置,相当于坐标。face 元素同时还带有 front/up/right/down/left/back/inner
等 7 个表示面朝向的类,inner 即是朝向魔方内部。
对于所有的块元素,面的 classname
都是按照 F->U->R->D->L->B 的顺序,也就是 “前->上->右->下->左->后”。这是因为在初版中,所有块元素都是通过平移函数到达指定位置,所以所有块的朝向都相同。
块元素的顺序倒是无所谓,因为都是通过 3D 变换实现的位置。
具体的变换逻辑在下面样式与布局的 3D 变换部分会说明。
样式与布局
基本样式和布局
这部分在各版本中基本相同(可能有些许区别,但影响不大)。
展开查看
* { padding: 0; margin: 0; }html, body {
width: 100%;
height: 100%;
}.stage {
transform-style: preserve-3d;
display: flex;
width: 100%;
height: 100%;
}.cubic {
width: 200px;
height: 200px;
position: relative;
/* 配合 stage 元素的 flex 属性,居中显示魔方 /
margin: auto;
transform-style: preserve-3d;
/ 事先倾斜使得正面的左上角块距离人眼最近 */
transform: perspective(500px) rotate3d(-1, 1, 0, 45deg);
}/* 魔方的块 /
.block {
width: 50%;
height: 50%;
position: absolute;
/ transition: transform 0.5s linear; */
transform-style: preserve-3d;
}/* 面 */
.block .face {
width: 100%;
height: 100%;
position: absolute;
border-radius: 10px;
box-sizing: border-box;
border: 2px solid black;
backface-visibility: hidden;
}
魔方表面的颜色(包括内部)
展开查看
/* 面上的颜色;可以任意修改面上的颜色而不需要调整html,html会自动对应 */ .block .face.front { background-color: white; }.block .face.up {
background-color: orange;
}.block .face.left {
background-color: green;
}.block .face.down {
background-color: red;
}.block .face.right {
background-color: blue;
}.block .face.back {
background-color: yellow;
}.block .inner {
background-color: black;
}
功能区
展开查看
.action-group-container { z-index: -1; }.cubic-action-group {
width: 85px;
position: fixed;
top: 50%;
transform: translateY(-50%);
left: 40px;
}.rotate-cubic-group {
width: 85;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(4, 35px);
gap: 5px 5px;
}.layer-action-group {
width: 175px;
position: fixed;
top: 50%;
transform: translateY(-50%);
right: 40px;
}.action-group p {
line-height: 20px;
text-align: center;
vertical-align: center;
}/* 将文字脱离文本流,并向上偏移,使得按钮区域整体在屏幕中垂直居中 */
.layer-action-group {
padding-top: 40px;
}.layer-action-group p {
position: relative;
margin-top: -40px;
}.layer-action-group .rotate-layer-group {
width: 85px;
}.action-group .rotate-group .rotate-direction {
width: 40px;
height: 35px;
border: 1px solid #409eff;
background-color: #409eff;
color: white;
border-radius: 5px;
}.action-group .rotate-group .rotate-direction:hover {
background-color: #66B1FF;
}/* 中间区域采用网格布局,2行*4列 */
.layer-action-group .rotate-layer-group .main-group {
width: 100%;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(4, 35px);
gap: 5px 5px;
}.layer-action-group .rotate-layer-group .extra-group {
width: 40px;
}.layer-action-group .rotate-layer-group .extra-group .rotate-direction {
display: block;
margin-bottom: 5px;
}.layer-action-group .rotate-layer-group .extra-group .rotate-direction:last-child {
margin-bottom: 0;
}/* 三列采用圣杯布局 /
/ 通用代码 */
.grail.container::before, .grail.container::after {
content: '';
display: block;
clear: both;
}.grail .left,
.grail .middle,
.grail .right {
float: left;
text-align: center;
}.grail .middle {
width: 100%;
}.grail .left,
.grail .right {
position: relative;
}/* 与实际布局长度相关的代码 */
.grail.container {
padding: 0 45px 0 45px;
}.grail .left {
margin-left: -100%;
left: -45px;
}.grail .right {
margin-left: -40px;
right: -45px;
}/* 左右两侧的按钮区垂直居中 */
.grail .left,
.grail .right {
display: flex;
flex-direction: column;
height: 155px;
}.grail .left, .grail .right {
justify-content: center;
}
3D 变换
这一部分是最关键的。魔方相关的变换包括面和块两部分。先说面,面的 3D 变换在所有版本中都是一样的:将除了 F 面之外的 5 个面,分别经过旋转和平移,拼成一个立方体的表面。这里的平移距离即为立方体(也就是块)边长的一半。
面的变换
展开查看
/* F 与投影面平行,无需变换 *//* L */
.block .face:nth-child(2) {
transform: translate3d(-50px, 0, -50px) rotateY(-90deg);
}/* U */
.block .face:nth-child(3) {
transform: translate3d(0, -50px, -50px) rotateX(90deg);
}/* R */
.block .face:nth-child(4) {
transform: translate3d(50px, 0, -50px) rotateY(90deg);
}/* D */
.block .face:nth-child(5) {
transform: translate3d(0, 50px, -50px) rotateX(-90deg);
}/* B */
.block .face:nth-child(6) {
transform: translateZ(-100px) rotateX(180deg);
}
块的变换
接下来是块的部分:
- 此时还未设置块元素的变换原点 transform-origin ,也就是取了默认值(面的中心点,没有 Z 轴方向的深度)
- 所有块从中间经过平移,到达 8 个角块的位置,无需经过旋转
此时的缺点很明显,由于变换原点并非是魔方整体的中心,因此通过脚本控制使得魔方的层旋转起来时,各个块会相互重叠。而且由于所有块在初始状态都是经过平移达到的角块位置,因此旋转时会偏离魔方的范围
注意: 截图中由于页面部分并不是初版的顺序,因此不变关注面的颜色错误,主要看提到的两个问题。
问题1:重叠
问题2:偏离中心轴
展开查看
.block.block_angle--ful { transform: translate(-50%, -50%); }.block.block_angle--fur {
transform: translate(50%, -50%);
}.block.block_angle--fdr {
transform: translate(50%, 50%);
}.block.block_angle--fdl {
transform: translate(-50%, 50%);
}.block.block_angle--bul {
transform: translate3d(-50%, -50%, -100px);
}.block.block_angle--bur {
transform: translate3d(50%, -50%, -100px);
}.block.block_angle--bdr {
transform: translate3d(50%, 50%, -100px);
}.block.block_angle--bdl {
transform: translate3d(-50%, 50%, -100px);
}
脚本
初版的脚本可以用惨不忍睹来形容,到处都是设计缺陷,可读性差、维护困难。以下是大概思路:
- 首先创建
Cubic
(魔方)类和Block
(块)类:Cubic
类负责管理全部Block
对象,以及接收外部的旋转事件;Block
类负责各个块自身的旋转处理逻辑。 - 将构建魔方相关的参数统一放到一个模块中,并手动配置。一开始还没有想好怎么设计整个结构,所以将所有配置信息都直接硬编码了。该版本的相关数据有:
- 魔方的轴和层,以及轴和层对应的顺时针和逆时针旋转信息,分别是一个对象,属性的值是旋转方向的别名
- 魔方的块所处的位置信息,这里是用块所处的三个层(也就是三维坐标)表示
- 创建一个
BlockPosition
类,负责处理Block
旋转后在脚本内部的逻辑位置,与视图位置相匹配。 - 主模块导入
Cubic
类并创建实例;同时绑定功能区的 click 事件。
上面是模块的基本结构,接下来是具体的处理逻辑:
-
Cubic
类:- 接收外部事件,也就是层的旋转方向(这时还没有考虑到魔方整体旋转),此时接收到的参数是旋转方向的别名,如:
U/U'/F/F'
等。 - 将别名转换成旋转方向对象的
key
,并据此调用Block
的判定方法isBlockInRotatingLayer
,筛选出位于旋转层中的块(因为层的旋转只会带动该层中块的旋转,不会影响其他层),并保存筛选出的块 - 接下来一一调用层中块的
rotate
方法。
- 接收外部事件,也就是层的旋转方向(这时还没有考虑到魔方整体旋转),此时接收到的参数是旋转方向的别名,如:
-
Block
类:块需要处理两个问题,一是修改视图元素的transform
属性,二是将脚本中的位置信息改变。- 接收
Cubic
类传入的旋转方向参数,也就是旋转方向对象的key
- 根据
key
的值,通过switch
块匹配transform
属性需要对应的rotate()
值。由于此时还未想到可以将层的旋转方向转换到轴向,因此列出了全部 6×2 种(二阶魔方只有 阶数×3 也就是 2×3 个层,与面数相等)旋转方式和对应的rotate()
值。
switch (rotateDirectionKey) { case LAYER_U: case LAYER_D_REVER: rotate = 'rotateY(-90deg)'; break; case LAYER_U_REVER: case LAYER_D: rotate = 'rotateY(90deg)'; break; case LAYER_R: case LAYER_L_REVER: rotate = 'rotateX(90deg)'; break; case LAYER_R_REVER: case LAYER_L: rotate = 'rotateX(-90deg)'; break; case LAYER_F: case LAYER_B_REVER: rotate = 'rotateZ(90deg)'; break; case LAYER_F_REVER: case LAYER_B: rotate = 'rotateZ(-90deg)'; break; default: break; }
之后,通过
const origTransform = window.getComputedStyle(this.element).transform
获取旋转前的matrix()
矩阵,然后在此基础上添加旋转函数this.element.style.transform = `${rotate} ${origTransform}`;
,变量rotate
保存的就是上面得到的rotateX/Y/Z(±90deg)
的值 - 接收
-
BlockPosition
类:一开始创建该类的目的是考虑到位置信息属于块的状态,而每个位置都是一个独立的状态,因此尝试通过状态模式实现状态管理。BlockPosition
作为基类,其他 8 个位置分别创建一个子类。基类并不实现具体的rotate
方法,而是由子类实现,并在方法中写入当前位置经过一次平面内 90° 旋转(也就是rotateX()/rotateY()/rotateZ()
)可以到达的 3 个位置以及需要的旋转方式(每个位置有两种方式)。
这时候还没有想到位置之间存在的关系,因此也是在每个子类中写死了可到达的位置和相应的旋转方式- 判断出当前旋转方式能达到的位置后,创建相应的
BlockPosition
子类的构造函数并返回新实例
-
之后
Block
类接收到新的位置实例并替换掉当前实例,本次旋转结束
可以发现这样实现存在一些问题:
- 存在太多分支结构而且是硬编码,不便于扩展和维护
- 分支结构太长,降低了可读性,而且对于多条件匹配相同的迁移路径时,可以采用一对多映射(不一定是 Map 类型,但键与值的配置数据必须是一对多)的形式,然后将分支判定转变为属性访问。
我常用的是创建一个 Map 对象,将位置信息作为键,然后将多个条件作为数组存到键对应的值;并在执行到迁移条件的判断部分时,通过自定义的findKeyOfMap
函数获取对应的键。在这里就是返回新位置的相关信息。
用 Map 的好处是键可以是任意类型,包括原始类型和引用类型。 - 重复创建和替换 position 对象造成了资源浪费
由于初版很多地方问题太多,维护起来很不方便,开始思考如何优化,就有了第二版。
第二版
页面
html 结构基本不变。
区别是将面的顺序从原先的所有块朝向相同,改成了:所有块的三个外表面都调整到前三个 face
子元素,比如正面的左上角这个块,其子元素的面按照 .front+.left+.up+.inner*3
的顺序。
在这版实现中,第一个面都是 front/back
,后面的两个面按照顺时针方向书写,以下是所有块上面的顺序:flu->fur->frd->fdl->bul->bru->bdr->bld
,如 flu
代表 front->left->up
。
布局
之后,3D 变换时通过平移(和旋转),将所有块移动到指定位置。
由于此时是先确定了面的初始位置,因此 3D 变换时,需要注意块在移动到指定位置的时候需要考虑面的旋转,否则就会出现部分外表面位于魔方内侧,外侧显示黑色的问题(类似上面截图中问题 2 的黑色部分)。
具体的 3D 变换代码由于已经删去,这里不再复现。
脚本
由于发现了初版存在的问题,因此将各分支结构优化成映射数据;同时,考虑到不止存在一个映射数据,因此将所有映射数据抽离到类的外部,单独创建一个模块。
一开始是将模块命名为全局常量,后来考虑到这些数据其实都是魔方构建的一部分,就将模块重新命名为 setupCubic
。
在重构为第二版时,只是将一部分数据移动到了这里,在模块外部,其实还存在一些紧密相关的数据结构,后面也会提到。这里先说明第二版已经转移的数据(包括初版已经存在的数据):
LAYERS_DIRECTIONS
层旋转方向BLOCK_POSITION
位置与层的映射(用三个层代表三条轴上的坐标来表示位置)ROTATE_DIRECTIONS
所有的旋转方向(新增轴的旋转方向,初版中只有层的旋转方向)AXES_DIRECTIONS
轴旋转方向 newAXES_TO_LAYERS
轴旋转方向与层旋转方向的映射 newROTATE_DIRECTION_2_TRANSFORM
轴旋转方向与rotate()
的映射 new
同时,考虑到:
- 位置信息有两个地方被使用到——
Cubic
类初始化Block
示例时,以及触发旋转后BlockPosition
需要更新状态信息; - 通过状态模式管理位置状态有点多余。此处的状态迁移判定也可以转换成映射数据,而转换之后,此处就不需要新建状态子类了,只需要更新:① 类内部的位置数据 ② 新的位置可到达的块和对应的旋转方式。
之后,短暂尝试过使用工厂方法代替构造函数的形式,负责创建 Block
实例,但发现这实际上与之前通过状态模式实现状态迁移存在相同的问题
- 仍然需要手动指定
Block
实例构造时的参数,并一 一调用构造函数 - 而且如果后续对魔方升阶,必须添加新的工厂方法
因此,将多余的位置类删去,只留下 BlockPosition
类继续负责位置管理。
并且,参照上面的方式,将构造 Block
实例的参数也重构为映射数据,添加到 setupCubic
模块中。
第三版(最新版)
页面
页面部分如下所示,与前两版的结构相同,初始状态下,三个外表面仍然位于前三个元素。区别是:
- 调整了 3D 变换的实现逻辑。根据现实中的魔方原理,只通过三个轴方向的旋转,使初始位于正面左上角的块移动到指定位置,不添加任何平移。
因此,除了原本就位于正面左上角的第一个块外表面无需旋转之外,其他块的外表面顺序与第二版不一定相同。而是通过反推的方式,从指定位置反向移动到正面的左上角,并据此推算各个面的相对位置。 - 将块元素的位置
classname
后缀改为坐标,而非沿用之前的三字母简写
<div class="stage">
<div class="cubic">
<div class="block block_angle--BLOCK_X_1_Y_2_Z_2">
<div class="face front"></div>
<div class="face left"></div>
<div class="face up"></div>
<div class="face inner"></div>
<div class="face inner"></div>
<div class="face inner"></div>
</div>
<div class="block block_angle--BLOCK_X_2_Y_2_Z_2">
<div class="face front"></div>
<div class="face up"></div>
<div class="face right"></div>
<div class="face inner"></div>
<div class="face inner"></div>
<div class="face inner"></div>
</div>
<div class="block block_angle--BLOCK_X_2_Y_1_Z_2">
<div class="face front"></div>
<div class="face right"></div>
<div class="face down"></div>
<div class="face inner"></div>
<div class="face inner"></div>
<div class="face inner"></div>
</div>
<div class="block block_angle--BLOCK_X_1_Y_1_Z_2">
<div class="face front"></div>
<div class="face down"></div>
<div class="face left"></div>
<div class="face inner"></div>
<div class="face inner"></div>
<div class="face inner"></div>
</div>
<div class="block block_angle--BLOCK_X_1_Y_2_Z_1">
<div class="face up"></div>
<div class="face left"></div>
<div class="face back"></div>
<div class="face inner"></div>
<div class="face inner"></div>
<div class="face inner"></div>
</div>
<div class="block block_angle--BLOCK_X_2_Y_2_Z_1">
<div class="face back"></div>
<div class="face right"></div>
<div class="face up"></div>
<div class="face inner"></div>
<div class="face inner"></div>
<div class="face inner"></div>
</div>
<div class="block block_angle--BLOCK_X_2_Y_1_Z_1">
<div class="face down"></div>
<div class="face right"></div>
<div class="face back"></div>
<div class="face inner"></div>
<div class="face inner"></div>
<div class="face inner"></div>
</div>
<div class="block block_angle--BLOCK_X_1_Y_1_Z_1">
<div class="face back"></div>
<div class="face left"></div>
<div class="face down"></div>
<div class="face inner"></div>
<div class="face inner"></div>
<div class="face inner"></div>
</div>
</div>
<div class="action-group-container">
<div class="action-group cubic-action-group">
<p>按下按钮,魔方会自动旋转对应的轴</p>
<div class="rotate-group rotate-cubic-group ">
<button class="rotate-direction">X</button>
<button class="rotate-direction">X'</button>
<button class="rotate-direction">Y</button>
<button class="rotate-direction">Y'</button>
<button class="rotate-direction">Z</button>
<button class="rotate-direction">Z'</button>
</div>
</div>
<div class="action-group layer-action-group">
<p>按下按钮,魔方会自动旋转对应的层</p>
<div class="rotate-group rotate-layer-group grail container">
<div class="middle">
<div class="main-group">
<button class="rotate-direction">U</button>
<button class="rotate-direction">U'</button>
<button class="rotate-direction">F</button>
<button class="rotate-direction">F'</button>
<button class="rotate-direction">B</button>
<button class="rotate-direction">B'</button>
<button class="rotate-direction">D</button>
<button class="rotate-direction">D'</button>
</div>
</div>
<div class="left">
<div class="extra-group">
<button class="rotate-direction">L</button>
<button class="rotate-direction">L'</button>
</div>
</div>
<div class="right">
<div class="extra-group">
<button class="rotate-direction">R</button>
<button class="rotate-direction">R'</button>
</div>
</div>
</div>
</div>
</div>
</div>
块的 3D 变换
3D 变换的调整如下所示。每个块的旋转方式都备注了相应的魔方旋转方式术语,如第二个块绕 Z 轴旋转 90° 相当于旋转魔方的正面,用魔方术语就是执行了一次 F 。所有块都通过最少的步数旋转到指定位置。90° 为一步,180° 为两步。对于部分块,旋转方式并不唯一,但步数相同,选择其中一种即可。
当然,也可以调整块的外表面顺序,不一定按我这种初始状态位于正面左上角的形式,任意一个角都可以,甚至每个块的初始状态不一样也可以,但相对来说,初始位置统一更方便推理,而且可以设置统一的 transform-origin
。
此外,transform-origin
需要统一设置为块的三个内表面的交点,此处即为背面的右下角,具体值是 100% 宽度 + 100% 高度 + (-100%) 厚度
。当然,由于是立方体,边长都一样。
展开查看
.block { /* 调整所有块的中心点为:正面看去的 BDR(背面的右下角) 角块的顶点 */ transform-origin: 100% 100% -100px; }.block.block_angle--BLOCK_X_1_Y_2_Z_2 {
/* 必须指定 transform 初始值,否则执行 js 时无法执行 3D 变换,因此使用无影响的变换效果 */
transform: rotate(0);
}.block.block_angle--BLOCK_X_2_Y_2_Z_2 {
/* F */
transform: rotateZ(90deg);
}.block.block_angle--BLOCK_X_2_Y_1_Z_2 {
/* F2 或 F'2 */
transform: rotateZ(180deg);
}.block.block_angle--BLOCK_X_1_Y_1_Z_2 {
/* F' */
transform: rotateZ(-90deg);
}.block.block_angle--BLOCK_X_1_Y_2_Z_1 {
/* L' */
transform: rotateX(90deg);
}.block.block_angle--BLOCK_X_2_Y_2_Z_1 {
/* U2 或 U'2 */
transform: rotateY(180deg);
}.block.block_angle--BLOCK_X_2_Y_1_Z_1 {
/* U'*2 + R */
transform: rotateY(180deg) rotateX(-90deg);
}.block.block_angle--BLOCK_X_1_Y_1_Z_1 {
/* L2 或 L'2 */
transform: rotateX(180deg);
}
脚本
最后是脚本部分的调整。
Block
和 BlockPosition
基本不变。
Cubic
类调整了 Block
实例的创建部分:将构建所有块所需的参数传给一个统一的工厂方法,工厂方法内部会遍历参数对象,找到每一个元素中的对应参数,并调用 Block
和 BlockPosition
类以创建实例。
setupCubic
模块现在拥有以下内容:
数据
AXES
轴旋转方向的别名对象;设置顺时针即可LAYERS
层的别名对象,同时也是部分旋转方向的别名对象;可以设置顺时针或逆时针;最好至少设置每一层的其中一个旋转方向,否则需要额外增加页面输入的处理,将别名转换成当前的 keyAXES_DIRECTIONS
轴的所有旋转方向,包括顺时针和逆时针LAYERS_DIRECTIONS
层的所有旋转方向,包括顺时针和逆时针ROTATE_DIRECTIONS
所有的旋转方向,包括轴和层BLOCK_POSITION
位置与层的一对三映射AXES_DIRECTIONS
轴旋转方向AXES_TO_LAYERS
轴旋转方向与层旋转方向的映射,对于 N 阶魔方就是一对 N 映射ROTATE_DIRECTION_2_TRANSFORM
轴旋转方向与rotate()
的映射 newBLOCKS_PARAMS
创建所有块实例需要的参数
构建函数
为了减少硬编码,将无法通过函数构建的对象保留:
AXES
别名对象只能手动设置,因为个人的偏好不同;同时也是为了与页面的输入保持一致。目前,输入时会将按钮上的 innerText 作为旋转方向,而这里的文本使用的就是别名,而非AXES
对象的 key ,这点对于LAYERS
对象同理。LAYERS
略
其他与魔方构建紧密相关的对象,全部改成通过专门的 setup 函数动态生成:
setupLayers
构建层旋转方向的完整对象,参数是层旋转方向的别名,常见的外表面所在层会使用上面提到过的F/U/R/D/L/B
的形式,因此给这些层设置对应的别名,其他层通过其在三条轴方向上的顺序(从负到正)依次从 1 排到 N 。setupReverseDirections
构建轴和层旋转方向的逆时针对象,参数是轴和层的旋转方对象。setupAxis2Layer
构建轴旋转方向与层旋转方向的一对多映射。这里使用的是 Map 类型。setupBlockPosition
构建块的位置与层的一对三映射。这里用的是 Object 类型。setupDirectionTransform
构建轴旋转方向与rotate()
的映射setupBlocksParams
构建生成所有Block
实例所需的参数对象
辅助函数:
setupDirectionKey
构建旋转方向对象的 keysetupAxisKey
构建轴旋转方向对象的 keycalAxisFromAxisKey
从 key 中解析出轴的别名setupLayerKey
构建层旋转方向对象的 key;层不需要反向解析为别名的函数,因为各种层相关的数据结构都是以相同的字符串作为 keyisDirectionClockwise
判断当前旋转方向是否为顺时针setupBlockPositionKey
构建位置对象的 keycalcCoordinateFromPositionKey
从位置的 key 计算三维坐标[x,y,z]
countSameCoordinate
计算两个位置中相同坐标值的数量calcRotatablePositions
计算当前位置经过一次平面内 90° 旋转能到达的位置,返回位置的 keycalcRotateDirections
计算从当前位置到目标位置需要经过何种方式的旋转,返回旋转方向的 key
其他的配置数据:
LayerCount
魔方的阶数AxisKeyPrefix = 'AXIS_'
轴对象 key 的前缀LayerKeyPrefix = 'LAYER_'
层对象 key 的前缀BlockPositionKeyPrefix = 'BLOCK_'
位置对象 key 的前缀SurfixRever = '_REVER'
旋转方向对象中逆时针属性 key 的后缀SurfixReverVal = "'"
旋转方向对象中逆时针属性值的后缀
重要构建函数说明
calcRotatablePositions
计算当前位置经过一次平面内 90° 旋转能到达的位置,返回位置的 key 。其实现思路是:从所有位置中筛选与当前位置具有两个坐标值相同的位置
calcRotateDirections
计算从当前位置到目标位置需要经过何种方式的旋转,返回旋转方向的 key 。其实现思路是:
-
找到中心轴和辅助轴(的序号):坐标值相同的两条轴作为旋转时的中心轴,不同的轴作为辅助轴:
rotateAxesIdx: [axisIdx1,axisIdx2], assistAxisIdx -
构建二维坐标:分别取一个中心轴坐标值和辅助轴坐标值组成平面坐标(也就是向量在平面上的投影向量),坐标顺序是 [x,y]/[y,z]/[z,x]
2.1 构建一个映射表数组,代表某个轴为辅助轴时,构建新的二维坐标需要查找的坐标值的数组下标:
['X', [1, 2]],
['Y', [2, 0]],
['Z', [0, 1]],
2.2 过滤掉未包含辅助轴的映射
2.3 取出两组序号;并构建两组二维坐标 -
经过推理,发现平面坐标系内的向量经过 n*90deg 旋转后可以到达的坐标有如下规律(两个坐标均为正数):
[a,b],[b,n-a+1],[n-a+1,n-b+1],[n-b+1,a]
由上可得,顺时针旋转时坐标的递推公式为(以 x/y 平面为例):[Xn,Yn] = [Y(n-1),n-X(n-1)+1]根据递推公式,分别判断 2 中得到的两组坐标:
当前坐标和对应的目标坐标哪个是 [Xn,Yn]:- 若是目标块,则需要顺时针旋转
- 若为当前块,则是逆时针旋转
之后,将两组结果按顺序存到数组中 [result1,result2]
-
根据 3 的两组结果,使用中心轴和结果值构建能代表旋转方式的值数组,也就是轴的旋转方向(如 AXIS_X/AXIS_Y_REVER):
- 若 result 为 true,则不需要添加后缀
- 若 result 为 false,则需要添加后缀 _REVER
一共两个平面坐标系,因此是长度为2的数组
以下是具体实现(这个函数过长了,需要进一步拆分):
function calcRotateDirections(
currentPositionKey,
rotatablePosition,
layerCount
) {
const currentCoordinate = calcCoordinateFromPositionKey(currentPositionKey),
rotatableCoordinate = calcCoordinateFromPositionKey(rotatablePosition);
// 1. 找到中心轴和辅助轴:坐标值相同的两条轴作为旋转时的中心轴,不同的轴作为辅助轴
let rotateAxesIdx = [], // 中心轴
assistAxisIdx = 0;
for (let i = 0; i < 3; i++) {
if (currentCoordinate[i] === rotatableCoordinate[i]) {
rotateAxesIdx.push(i);
} else {
assistAxisIdx = i;
}
}
// 2.构建二维坐标:分别取一个中心轴坐标值和辅助轴坐标值组成平面坐标(也就是向量在平面上的投影向量),坐标顺序是 [x,y]/[y,z]/[z,x]
// 构建一个映射表数组:代表某个轴为辅助轴时,构建新的二维坐标需要查找的坐标值的数组下标
const axisToPlaneMapping = [
['X', [1, 2]],
['Y', [2, 0]],
['Z', [0, 1]],
];
// 过滤掉未包含辅助轴的映射
const filteredMapping = axisToPlaneMapping.filter(
([, axis]) => axis[0] === assistAxisIdx || axis[1] === assistAxisIdx
);
// 取出两组序号;并构建两组二维坐标
const [[, [axis1_1, axis1_2]], [, [axis2_1, axis2_2]]] = filteredMapping;
const newCurrentCoordinates = [
[currentCoordinate[axis1_1], currentCoordinate[axis1_2]],
[currentCoordinate[axis2_1], currentCoordinate[axis2_2]],
],
newRotatableCoordinates = [
[rotatableCoordinate[axis1_1], rotatableCoordinate[axis1_2]],
[rotatableCoordinate[axis2_1], rotatableCoordinate[axis2_2]],
];
// 3. 分析旋转方向
const analyseRotateDirection = (
currentCoordinate,
rotatableCoordinate,
layerCount
) => {
// 每一组两个坐标的两对坐标值,必须同时满足递推公式的要求:[Xn,Yn] = [Y(n-1),n-X(n-1)+1]
if (
rotatableCoordinate[0] === currentCoordinate[1] &&
rotatableCoordinate[1] === layerCount - currentCoordinate[0] + 1
// [1,1] -> [1,2] 代入公式: [1,2] = [1,2-1+1] 顺时针旋转可到达
) {
return true; // 顺时针旋转可到达
} else {
if (
currentCoordinate[0] === rotatableCoordinate[1] &&
currentCoordinate[1] === layerCount - rotatableCoordinate[0] + 1
) {
return false; // 逆时针旋转可到达
} else {
console.error('some error here');
}
}
};
const results = [
analyseRotateDirection(
newCurrentCoordinates[0],
newRotatableCoordinates[0],
layerCount
),
analyseRotateDirection(
newCurrentCoordinates[1],
newRotatableCoordinates[1],
layerCount
),
];
// 4. 构建代表旋转方向的值数组
let rotateDirections = [
setupAxisKey(filteredMapping[0][0], results[0]),
setupAxisKey(filteredMapping[1][0], results[1]),
];
// 返回值类型 [AXES_Z, AXES_Y_REVER]
return rotateDirections;
}
这里是推理过程:
2×2
[1,2],[2,2]
[1,1],[2,1]
3×3
[1,3],[2,3],[3,3]
[1,2],[2,2],[3,2]
[1,1],[2,1],[3,1]
4×4
[1,4],[2,4],[3,4],[4,4]
[1,3],[2,3],[3,3],[4,3]
[1,2],[2,2],[3,2],[4,2]
[1,1],[2,1],[3,1],[4,1]
n×n
x/y平面(上面的 2×2 3×3 4×4 其实可以跳过)
[1,n],[2,n],[3,n],...,[n,n]
...
[1,3],[2,3],[3,3],...,[n,3]
[1,2],[2,2],[3,2],...,[n,2]
[1,1],[2,1],[3,1],...,[n,1]
角块:[1,1],[1,n],[n,n],[n,1]
棱块:[1,a],[a,n],[n,n-a+1],[n-a+1,1]
中间块(非中心块):[a,b],[b,n-a+1],[n-a+1,n-b+1],[n-b+1,a]
中心块(只有单数阶的魔方有);[(n+1)/2,(n+1)/2]
注意这里是平面内的旋转,如果跨层了,说明参考系选得不对,需要调整到同一层。
目前尚未解决的问题
- 如何根据配置参数,自动化构建页面结构,包括块、面以及对应的 3D 变换。其中的难点是如何找到三阶及高阶魔方棱块和面上的块 3D 变换的规律,以及面元素的顺序规律。在当前的页面构建思路下,尚未找到这两个规律
- 如何实现鼠标拖拽层时,控制对应的脚本行为。这点相对比较容易解决,只需要给 cubic 元素添加事件监听,并捕获当前点击的层以及鼠标的移动方向,就能生成对应的旋转方向参数。不过具体还未实践过。
- 如果给块添加
transform
属性的过渡效果,切换不同的旋转轴时,旋转过程中会有一定角度的倾斜,原因可能来自于transform-origin
。因为第三版中设置的是魔方的中心点,猜测是渲染引擎在处理过渡效果时,会自动将所有点与原点的相对距离平衡,直到到达目标位置。
但这个 bug 在使用同一条中心轴时却又不会发生,似乎猜测又不是很准确。
旋转倾斜 bug:
演示
最后放一小段演示(这里关闭了过渡):
关闭过渡: