本篇完全基于原生 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 变换在所有版本中都是一样的:将除了 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:重叠
问题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
类接收到新的位置实例并替换掉当前实例,本次旋转结束
可以发现这样实现存在一些问题:
findKeyOfMap
函数获取对应的键。在这里就是返回新位置的相关信息。由于初版很多地方问题太多,维护起来很不方便,开始思考如何优化,就有了第二版。
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
模块中。
页面部分如下所示,与前两版的结构相同,初始状态下,三个外表面仍然位于前三个元素。区别是:
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 变换的调整如下所示。每个块的旋转方式都备注了相应的魔方旋转方式术语,如第二个块绕 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]:
根据 3 的两组结果,使用中心轴和结果值构建能代表旋转方式的值数组,也就是轴的旋转方向(如 AXIS_X/AXIS_Y_REVER):
以下是具体实现(这个函数过长了,需要进一步拆分):
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] 注意这里是平面内的旋转,如果跨层了,说明参考系选得不对,需要调整到同一层。
transform
属性的过渡效果,切换不同的旋转轴时,旋转过程中会有一定角度的倾斜,原因可能来自于 transform-origin
。因为第三版中设置的是魔方的中心点,猜测是渲染引擎在处理过渡效果时,会自动将所有点与原点的相对距离平衡,直到到达目标位置。旋转倾斜 bug:
最后放一小段演示(这里关闭了过渡):
关闭过渡: