原生 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);
}

块的变换

接下来是块的部分:

  1. 此时还未设置块元素的变换原点 transform-origin ,也就是取了默认值(面的中心点,没有 Z 轴方向的深度)
  2. 所有块从中间经过平移,到达 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);
}

脚本

初版的脚本可以用惨不忍睹来形容,到处都是设计缺陷,可读性差、维护困难。以下是大概思路:

  1. 首先创建 Cubic (魔方)类和 Block (块)类:Cubic 类负责管理全部 Block 对象,以及接收外部的旋转事件;Block 类负责各个块自身的旋转处理逻辑。
  2. 将构建魔方相关的参数统一放到一个模块中,并手动配置。一开始还没有想好怎么设计整个结构,所以将所有配置信息都直接硬编码了。该版本的相关数据有:
    • 魔方的轴和层,以及轴和层对应的顺时针和逆时针旋转信息,分别是一个对象,属性的值是旋转方向的别名
    • 魔方的块所处的位置信息,这里是用块所处的三个层(也就是三维坐标)表示
  3. 创建一个 BlockPosition 类,负责处理 Block 旋转后在脚本内部的逻辑位置,与视图位置相匹配。
  4. 主模块导入 Cubic 类并创建实例;同时绑定功能区的 click 事件。

上面是模块的基本结构,接下来是具体的处理逻辑:

  1. Cubic 类:

    1. 接收外部事件,也就是层的旋转方向(这时还没有考虑到魔方整体旋转),此时接收到的参数是旋转方向的别名,如:U/U'/F/F' 等。
    2. 将别名转换成旋转方向对象的 key ,并据此调用 Block 的判定方法 isBlockInRotatingLayer ,筛选出位于旋转层中的块(因为层的旋转只会带动该层中块的旋转,不会影响其他层),并保存筛选出的块
    3. 接下来一一调用层中块的 rotate 方法。
  2. Block 类:块需要处理两个问题,一是修改视图元素的 transform 属性,二是将脚本中的位置信息改变。

    1. 接收 Cubic 类传入的旋转方向参数,也就是旋转方向对象的 key
    2. 根据 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) 的值

  3. BlockPosition 类:一开始创建该类的目的是考虑到位置信息属于块的状态,而每个位置都是一个独立的状态,因此尝试通过状态模式实现状态管理。

    1. BlockPosition 作为基类,其他 8 个位置分别创建一个子类。基类并不实现具体的 rotate 方法,而是由子类实现,并在方法中写入当前位置经过一次平面内 90° 旋转(也就是 rotateX()/rotateY()/rotateZ())可以到达的 3 个位置以及需要的旋转方式(每个位置有两种方式)。
      这时候还没有想到位置之间存在的关系,因此也是在每个子类中写死了可到达的位置和相应的旋转方式
    2. 判断出当前旋转方式能达到的位置后,创建相应的 BlockPosition 子类的构造函数并返回新实例
  4. 之后 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 轴旋转方向 new
  • AXES_TO_LAYERS 轴旋转方向与层旋转方向的映射 new
  • ROTATE_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);
}

脚本

最后是脚本部分的调整。

BlockBlockPosition 基本不变。

Cubic 类调整了 Block 实例的创建部分:将构建所有块所需的参数传给一个统一的工厂方法,工厂方法内部会遍历参数对象,找到每一个元素中的对应参数,并调用 BlockBlockPosition 类以创建实例。

setupCubic 模块现在拥有以下内容:

数据

  • AXES 轴旋转方向的别名对象;设置顺时针即可
  • LAYERS 层的别名对象,同时也是部分旋转方向的别名对象;可以设置顺时针或逆时针;最好至少设置每一层的其中一个旋转方向,否则需要额外增加页面输入的处理,将别名转换成当前的 key
  • AXES_DIRECTIONS 轴的所有旋转方向,包括顺时针和逆时针
  • LAYERS_DIRECTIONS 层的所有旋转方向,包括顺时针和逆时针
  • ROTATE_DIRECTIONS 所有的旋转方向,包括轴和层
  • BLOCK_POSITION 位置与层的一对三映射
  • AXES_DIRECTIONS 轴旋转方向
  • AXES_TO_LAYERS 轴旋转方向与层旋转方向的映射,对于 N 阶魔方就是一对 N 映射
  • ROTATE_DIRECTION_2_TRANSFORM 轴旋转方向与 rotate() 的映射 new
  • BLOCKS_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 构建旋转方向对象的 key
  • setupAxisKey 构建轴旋转方向对象的 key
  • calAxisFromAxisKey 从 key 中解析出轴的别名
  • setupLayerKey 构建层旋转方向对象的 key;层不需要反向解析为别名的函数,因为各种层相关的数据结构都是以相同的字符串作为 key
  • isDirectionClockwise 判断当前旋转方向是否为顺时针
  • setupBlockPositionKey 构建位置对象的 key
  • calcCoordinateFromPositionKey 从位置的 key 计算三维坐标 [x,y,z]
  • countSameCoordinate 计算两个位置中相同坐标值的数量
  • calcRotatablePositions 计算当前位置经过一次平面内 90° 旋转能到达的位置,返回位置的 key
  • calcRotateDirections 计算从当前位置到目标位置需要经过何种方式的旋转,返回旋转方向的 key

其他的配置数据:

  • LayerCount 魔方的阶数
  • AxisKeyPrefix = 'AXIS_' 轴对象 key 的前缀
  • LayerKeyPrefix = 'LAYER_' 层对象 key 的前缀
  • BlockPositionKeyPrefix = 'BLOCK_' 位置对象 key 的前缀
  • SurfixRever = '_REVER' 旋转方向对象中逆时针属性 key 的后缀
  • SurfixReverVal = "'" 旋转方向对象中逆时针属性值的后缀
重要构建函数说明

calcRotatablePositions 计算当前位置经过一次平面内 90° 旋转能到达的位置,返回位置的 key 。其实现思路是:从所有位置中筛选与当前位置具有两个坐标值相同的位置

calcRotateDirections 计算从当前位置到目标位置需要经过何种方式的旋转,返回旋转方向的 key 。其实现思路是:

  1. 找到中心轴和辅助轴(的序号):坐标值相同的两条轴作为旋转时的中心轴,不同的轴作为辅助轴:
    rotateAxesIdx: [axisIdx1,axisIdx2], assistAxisIdx

  2. 构建二维坐标:分别取一个中心轴坐标值和辅助轴坐标值组成平面坐标(也就是向量在平面上的投影向量),坐标顺序是 [x,y]/[y,z]/[z,x]
    2.1 构建一个映射表数组,代表某个轴为辅助轴时,构建新的二维坐标需要查找的坐标值的数组下标:
    ['X', [1, 2]],
    ['Y', [2, 0]],
    ['Z', [0, 1]],
    2.2 过滤掉未包含辅助轴的映射
    2.3 取出两组序号;并构建两组二维坐标

  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]
  4. 根据 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:

演示

最后放一小段演示(这里关闭了过渡):

关闭过渡:

posted @ 2022-07-24 21:45  CJc_3103  阅读(454)  评论(1编辑  收藏  举报