ICode9

精准搜索请尝试: 精确搜索
首页 > 其他分享> 文章详细

原生 JS+CSS 构建支持 3D 旋转的魔方

2022-07-24 22:04:33  阅读:151  来源: 互联网

标签:rotate 魔方 位置 transform 旋转 CSS JS block


背景简介

本篇完全基于原生 JS 和 CSS,不需要额外的开发框架或工具。但由于用到了 ES6 模块化语法,如果在浏览器中查看结果,需要添加相应的环境工具。这里是用的 VSCODE 里的 Live Server 插件,如果用 webpack 等工具构建的话,也可以添加相应的插件。

以下以二阶魔方为例,三阶及更高阶的页面部分暂时还未想到如何自动化构建,不过脚本部分已经为自动化构建优化了很多。

PS:由于自己对二阶魔方的公式不熟,就不放完整的演示了,只放几步操作过程。

实现详解

文件结构

代码说明

点击这里直接跳转最新版

复盘

由于实现 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:

演示

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

关闭过渡:

标签:rotate,魔方,位置,transform,旋转,CSS,JS,block
来源: https://www.cnblogs.com/cjc-0313/p/16513638.html

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有