原生 JS+CSS 构建支持 3D 旋转的魔方
2022/7/24 23:24:52
本文主要是介绍原生 JS+CSS 构建支持 3D 旋转的魔方,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!
背景简介
本篇完全基于原生 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); }
块的变换
接下来是块的部分:
- 此时还未设置块元素的变换原点 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:
演示
最后放一小段演示(这里关闭了过渡):
关闭过渡:
这篇关于原生 JS+CSS 构建支持 3D 旋转的魔方的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!
- 2024-11-16Vue3资料:新手入门必读教程
- 2024-11-16Vue3资料:新手入门全面指南
- 2024-11-16Vue资料:新手入门完全指南
- 2024-11-16Vue项目实战:新手入门指南
- 2024-11-16React Hooks之useEffect案例详解
- 2024-11-16useRef案例详解:React中的useRef使用教程
- 2024-11-16React Hooks之useState案例详解
- 2024-11-16Vue入门指南:从零开始搭建第一个Vue项目
- 2024-11-16Vue3学习:新手入门教程与实践指南
- 2024-11-16Vue3学习:从入门到初级实战教程