关于如何建立一个响应性、适应性和无障碍的3D游戏菜单的基础性概述。
在这篇文章中,我想带着大家写一个3D游戏菜单组件的案例。首先让我们看看成品是什么样子的
相信大家都玩过赛博朋克吧,最近正好迷上了这个游戏,里面的菜单栏深深引起了我的注意,特别是在新的AR/VR游戏中,让菜单看起来漂浮在空间中也是很炫的事情。今天我们讲重现这种效果的精髓,但要加上自适应和色彩方案。
我们会使用实验性的CSS @custom-media和@nest来防止重复的媒体查询,并将媒体查询集中在组件样式块中。这些规范中提出的语法可以通过PostCSS和这两个插件实现:postcss-custom-media和postcss-nesting。
游戏菜单是一个按钮的列表。用HTML表示的最佳方法如下。
<ul class="threeD-button-set">
<li><button>New Gamebutton>li>
<li><button>Continuebutton>li>
<li><button>Onlinebutton>li>
<li><button>Settingsbutton>li>
<li><button>Quitbutton>li>
ul>
设计按钮列表的样式可分为以下几个高级步骤。
自定义属性通过给其他看起来很随机的值起有意义的名字来帮助区分这些值,避免重复的代码和在子项之间共享值。
下面是保存为CSS变量的媒体查询,也被称为自定义媒体。这些是全局性的,将在各种选择器中使用,以保持代码的简洁和可读性。游戏菜单组件使用运动偏好、系统色彩方案和显示器的色彩范围能力。
@custom-media --motionOK (prefers-reduced-motion: no-preference);
@custom-media --dark (prefers-color-scheme: dark);
@custom-media --HDcolor (dynamic-range: high);
下面的自定义属性管理颜色方案并保持鼠标位置值,以使游戏菜单在悬停时具有交互性。命名自定义属性有助于代码的可读性,因为它揭示了该值的用例或该值的结果的友好名称。
.threeD-button-set {
--y:;
--x:;
--distance: 1px;
--theme: hsl(180 100% 50%);
--theme-bg: hsl(180 100% 50% / 25%);
--theme-bg-hover: hsl(180 100% 50% / 40%);
--theme-text: white;
--theme-shadow: hsl(180 100% 10% / 25%);
--_max-rotateY: 10deg;
--_max-rotateX: 15deg;
--_btn-bg: var(--theme-bg);
--_btn-bg-hover: var(--theme-bg-hover);
--_btn-text: var(--theme-text);
--_btn-text-shadow: var(--theme-shadow);
--_bounce-ease: cubic-bezier(.5, 1.75, .75, 1.25);
@media (--dark) {
--theme: hsl(255 53% 50%);
--theme-bg: hsl(255 53% 71% / 25%);
--theme-bg-hover: hsl(255 53% 50% / 40%);
--theme-shadow: hsl(255 53% 10% / 25%);
}
@media (--HDcolor) {
@supports (color: color(display-p3 0 0 0)) {
--theme: color(display-p3 .4 0 .9);
}
}
}
浅色主题有一个鲜艳的青色到粉红色的圆锥渐变,而深色主题有一个暗色的微妙圆锥渐变。
html {
background: conic-gradient(at -10% 50%, deeppink, cyan);
@media (--dark) {
background: conic-gradient(at -10% 50%, #212529, 50%, #495057, #212529);
}
}
为了让元素存在于网页的三维空间中,需要初始化一个带透视的视口。我选择把透视放在主体元素上,并使用视口单元来创建我喜欢的风格。
body {
perspective: 40vw;
}
这就是视角所能产生的影响。
按钮列表设计样式这个元素负责整个按钮列表的宏观布局,同时也是一个互动的、3D的浮动卡片。这里有一个实现的方法。
Flexbox可以管理容器的布局。通过flex-direction
将flex的默认方向从行改为列,并通过align-items
从stretch
改为start
来确保每个项目都是其内容的大小。
.threeD-button-set {
/* remove margins */
margin: 0;
/* vertical rag-right layout */
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2.5vh;
}
接下来,将容器建立为一个三维空间背景,并设置CSS clamp()
函数,以确保卡片的旋转不会超过可识别的旋转。注意,夹子的中间值是一个自定义属性,这些--x
和--y
值将在以后的鼠标交互时从JavaScript中设置。
.threeD-button-set {
…
transform-style: preserve-3d;
transform:
rotateY(
clamp(
calc(var(--_max-rotateY) * -1),
var(--y),
var(--_max-rotateY)
)
)
rotateX(
clamp(
calc(var(--_max-rotateX) * -1),
var(--x),
var(--_max-rotateX)
)
)
;
}
接下来,如果运动对访问用户来说是可以的,就向浏览器添加一个提示,说明这个项目的变换会随着will-change
不断变化。此外,通过在变换上设置一个过渡来实现插值。这个过渡将在鼠标与卡片互动时发生,使旋转变化的过渡平稳。该动画是一个持续运行的动画,即使鼠标不能或没有与该组件互动,也能展示出该卡所处的三维空间。
@media (--motionOK) {
.threeD-button-set {
will-change: transform;
transition: transform .1s ease;
animation: rotate-y 5s ease-in-out infinite;
}
}
旋转-y
动画只将中间的关键帧设置为50%
,因为浏览器会将0%
和100%
默认为该元素的默认样式。这是对交替出现的动画的简称,需要在同一位置开始和结束。这是一种阐述无限交替动画的好方法。
@keyframes rotate-y {
50% {
transform: rotateY(15deg) rotateX(-6deg);
}
}
元素定型每个列表项(
都包含按钮和它的边框元素。显示样式被改变了,所以该项目不显示::
标记。位置样式被设置为相对的,所以即将到来的按钮伪元素可以将自己定位在按钮所消耗的全部区域内。
.threeD-button-set > li {
display: inline-flex;
position: relative;
transform-style: preserve-3d;
}
elements为按钮设计造型是一项艰难的工作,有很多状态和交互类型需要考虑。由于要平衡伪元素、动画和交互,这些按钮很快就会变得复杂。
样式以下是将支持其他状态的基础风格。
.threeD-button-set button {
appearance: none;
outline: none;
border: none;
background-color: var(--_btn-bg);
color: var(--_btn-text);
text-shadow: 0 1px 1px var(--_btn-text-shadow);
font-size: 5vmin;
font-family: Audiowide;
padding-block: .75ch;
padding-inline: 2ch;
border-radius: 5px 20px;
}
按钮的边框不是传统的边框,它们是有边框的绝对位置的伪元素。
这些元素对于展示已经建立的3D视角至关重要。这些伪元素中的一个会被推离按钮,另一个会被拉近到用户身边。这种效果在顶部和底部的按钮中最为明显。
.threeD-button button {
…
&::after,
&::before {
/* 创建空元素 */
content: '';
opacity: .8;
/* 掩盖父体 (button) */
position: absolute;
inset: 0;
/* 样式元素的边框强调 */
border: 1px solid var(--theme);
border-radius: 5px 20px;
}
/* 其中一个伪元素的例外情况 */
/* 这将被推后(3倍),并有一个更厚的边界 */
&::before {
border-width: 3px;
/* 在黑暗模式下,它会发亮! */
@media (--dark) {
box-shadow:
0 0 25px var(--theme),
inset 0 0 25px var(--theme);
}
}
}
下面的transform-style
被设置为preserve-3d
,这样就可以在Z轴上拉开空间。变换被设置为–距离的自定义属性,它将在悬停和聚焦时被增加。
.threeD-button-set button {
…
transform: translateZ(var(--distance));
transform-style: preserve-3d;
&::after {
/* 以3倍的倍数在Z空间向前拉动 */
transform: translateZ(calc(var(--distance) / 3));
}
&::before {
/* 在Z空间以3倍的倍数推回 */
transform: translateZ(calc(var(--distance) / 3 * -1));
}
}
如果用户对运动没有意见,按钮就会向浏览器提示变换属性,并为变换和背景颜色属性设置了一个过渡。注意这里因为持续时间的不同,所以会有一个漂亮的微妙的交错效果。
.threeD-button-set button {
…
@media (--motionOK) {
will-change: transform;
transition:
transform .2s ease,
background-color .5s ease
;
&::before,
&::after {
transition: transform .1s ease-out;
}
&::after { transition-duration: .5s }
&::before { transition-duration: .3s }
}
}
交互动画的目标是将构成平面出现的按钮的各层展开。通过设置–距离变量来实现这一目标,最初是1px。下面代码示例中的选择器会检查按钮是否被一个应该看到焦点指示器的设备悬停或聚焦,而不是被激活。如果是这样,它就应用CSS来做以下事情。
不同的过渡时间值只在悬停时才会出现,只为悬停而交错的动画。当悬停或焦点移开时,每个层都会一致地过渡到静止的地方。
.threeD-button-set button {
…
&:is(:hover, :focus-visible):not(:active) {
/* 悬停/聚焦时微妙的距离和bg颜色变化 */
--distance: 15px;
background-color: var(--_btn-bg-hover);
/* 如果运动没有问题,设置过渡,增加距离 */
@media (--motionOK) {
--distance: 3vmax;
transition-timing-function: var(--_bounce-ease);
transition-duration: .4s;
&::after { transition-duration: .5s }
&::before { transition-duration: .3s }
}
}
}
三维视角对于减少运动的偏好来说还是非常整洁的。顶部和底部的元素以一种很好的微妙方式展示了这种效果。
这个界面已经可以通过键盘、手柄、触屏和鼠标来使用,但我们可以添加一些轻微的JavaScript来缓解一些情况。
标签键是导航菜单的好方法,但我希望用方向盘或操纵杆来移动游戏板上的焦点。经常用于GUI挑战界面的roving-ux库将为我们处理方向键。下面的代码告诉库在.threeD-button-set中捕获焦点,并将焦点转给按钮的子代。
import {rovingIndex} from 'roving-ux'
rovingIndex({
element: document.querySelector('.threeD-button-set'),
target: 'button',
})
跟踪鼠标并让它倾斜菜单是为了模仿AR和VR视频游戏界面,在那里你可能有一个虚拟的指针,而不是鼠标。当元素对指针有超强意识时,这可能很有趣。
由于这是一个小的额外功能,我们将把交互放在对用户的运动偏好的查询后面。另外,作为设置的一部分,用querySelector
将按钮列表组件存储到内存中,并将该元素的边界缓存到menuRect
中。使用这些边界来确定基于鼠标位置应用于卡片的旋转偏移。
const menu = document.querySelector('.threeD-button-set')
const menuRect = menu.getBoundingClientRect()
const { matches:motionOK } = window.matchMedia(
'(prefers-reduced-motion: no-preference)'
)
接下来,我们需要一个函数,接受鼠标的x和y位置,并返回一个我们可以用来旋转卡片的值。下面的函数使用鼠标的位置来确定它在盒子的哪一边,以及多少。从该函数中返回delta
。
const getAngles = (clientX, clientY) => {
const { x, y, width, height } = menuRect
const dx = clientX - (x + 0.5 * width)
const dy = clientY - (y + 0.5 * height)
return {dx,dy}
}
最后,观察鼠标的移动,把位置传给我们的getAngles()
函数,并使用delta
值作为自定义属性样式。我除以20
来填充delta
,使其不那么抽搐,可能有更好的方法来做。如果你还记得一开始,我们把--x
和--y
道具放在clamp()
函数的中间,这可以防止鼠标位置过度旋转,使卡片无法辨认。
if (motionOK) {
window.addEventListener('mousemove', ({target, clientX, clientY}) => {
const {dx,dy} = getAngles(clientX, clientY)
menu.attributeStyleMap.set('--x', `${dy / 20}deg`)
menu.attributeStyleMap.set('--y', `${dx / 20}deg`)
})
}