想用纯代码做气球爆破游戏,弹出层API到底有多能整活?

3,439字
15–22 分钟
in

只用HTML和CSS也能做出带弹窗、计时器、输赢判断的互动小游戏?没错,Popover API这玩意儿就是这么顶。下面拿一个“一分钟内扎破所有气球”的游戏当例子,把整个折腾过程摊开聊。从弹出层的基础概念,到怎么用<details>标签搭架子,再到给气球绑按钮、调样式、摆位置、做胜利和失败弹窗,最后再整上一个会缩水的计时条。全程不写一行JavaScript,纯靠HTML和CSS硬扛。每个环节都有代码片段和避坑提醒,跟着操作就能跑起来。

目录

弹出层属性咋分

任何HTML元素只要加上popover属性,立马变身弹窗。默认写<div popover>,等价于popover="auto"。自动弹窗带“轻触关闭”特性——点弹窗外任意位置就能关掉它,而且打开一个新弹窗时,页面上其他自动弹窗会自动关闭,互相牵制。

另一种是手动模式:<div popover="manual">。这种弹窗倔得很,必须靠指定的按钮才能打开和关闭,跟其他弹窗谁也不搭理谁。做游戏时经常需要多层弹窗独立控制,手动模式就派上用场了。

模式关闭方式多窗关系
auto点外部或按钮互斥关闭
manual仅指定按钮相互独立

打个比方:auto像排队上厕所,进去一个外面的就得等着;manual像自家卧室门,想开就开想关就关,跟隔壁没关系。

details搭舞台

弹窗默认不能页面加载时就打开,这对游戏来说是个硬伤。好在<details>标签有个open属性,一上来就能展示内容。拿它当游戏的最外层容器,把初始气球塞进去。

<details open>
  <summary>🎈</summary>
  <button>🎈</button>
  <button>🎈</button>
  <button>🎈</button>
</details>

点击<summary>里的气球,整个<details>会闭合,里面的按钮气球就跟被扎破一样消失。为啥不用<dialog>?那玩意儿有两个坑:一是页面加载时打开的对话框没法纯HTML关闭;二是对话框是模态的,打开期间点不了外面任何东西,而游戏需要玩家在计时过程中到处点气球。

按钮控制显隐

要让按钮和弹窗联动,得给弹窗一个唯一ID,然后在按钮上用popovertarget指向这个ID。

<details open>
  <summary>🎈</summary>
  <button popovertarget="lvl1">🎈</button>
</details>

<div id="lvl1" popover="manual">
  第二层气球阵
</div>

这样点一下按钮弹窗出现,再点一下弹窗消失。但游戏需要区分“打开”和“关闭”动作,不然玩家同一个按钮反复戳就能无脑过关。用popovertargetaction属性单独指定动作——设为show只负责打开,设为hide只负责关闭。

<button popovertarget="lvl1" popovertargetaction="show">🎈</button>
<button popovertarget="lvl1" popovertargetaction="hide">🎈</button>

如果某个按钮故意不写这个属性,那它就同时管开和关,相当于一个捣蛋气球,点了可能多出新气球。游戏里那些“狡猾气球”就是这么来的。

气球样式拿捏

需要把<summary><button>打扮得一模一样,让玩家分不清哪个是关全局的开关。先定义一个.balloon类。

.balloon {
  background-color: transparent;
  border: none;
  cursor: pointer;
  display: block;
  font-size: 4em;
  height: 1em;
  list-style-type: none;
  margin: 0;
  padding: 0;
  text-align: center;
  user-select: none;
  -webkit-user-select: none;  /* 照顾Safari */
  width: 1em;
}

去掉<summary>默认的三角形小箭头,不然玩家一眼就能看出哪个是“真·关全局按钮”。普通浏览器用list-style-type: none,Safari还得补一刀summary::-webkit-details-marker { display: none; }。鼠标移到气球上变成小手cursor: pointer,同时禁止选中气球文字user-select: none,让它们更像可点击的物体。

加点点击反馈,按下气球时缩小一下,模拟被戳破的瞬间。

.balloon:active {
  scale: 0.7;
  transition: 0.5s;
}

鼠标指针原本就是食指形状,点下去缩小的动画,看着就像手指把气球戳爆了,效果很带感。

行列防重叠

纯CSS没法生成真正的随机位置,那就搞一套伪随机——用8×8的虚拟网格,每个气球分到不同的行类(.r1.r7)和列类(.c1.c7)。避开最顶上和最左边那一行一列,防止气球贴边。

.r1 { --row: 1; }
.r2 { --row: 2; }
/* 一直到 .r7 */

.c1 { --col: 1; }
.c2 { --col: 2; }
/* 一直到 .c7 */

.balloon {
  position: absolute;
  top: calc(12.5vh * (var(--row) + 1) - 12.5vh);
  left: calc(12.5vw * (var(--col) + 1) - 12.5vw);
}

只要保证每个气球拿到的行和列组合不重复,它们就不会叠在一起。虽然没有真正的随机感,但至少不会出现两三个气球挤成一团的情况。

输赢咋判断

游戏最外层<details>的ID设为#root。一旦它被关闭(也就是玩家点了那个隐藏的<summary>气球),游戏就该结束了。用:not([open])选中关闭状态,把#root藏起来。

#root:not([open]) {
  display: none;
}

这时候借助父容器#game:has()选择器,检测到#root关闭后,显示祝贺弹窗#congrats

#game:has(#root:not([open])) #congrats {
  display: flex;
}

但有个bug:手动弹窗即使外层容器关了,它自己可能还开着。玩家没把所有二层三层气球关掉就提前关了大箱子,按理说不该算赢。用:popover-open伪类来排查——如果页面上还有任何弹窗处于打开状态,就把祝贺弹窗再藏回去。

#game:has(#root:not([open])):has(:popover-open) #congrats {
  display: none;
}

输的情况更简单。弄一个#fail弹窗,默认透明且层级为负,用动画等60秒后渐变出现。

#fail {
  animation: fadein 0.5s forwards 60s;
  display: flex;
  opacity: 0;
  z-index: -1;
}

@keyframes fadein {
  0% { opacity: 0; z-index: -1; }
  100% { opacity: 1; z-index: 10; }
}

如果胜利弹窗已经显示,就别让失败弹窗出来抢戏。

#game:has(#root:not([open])) #fail {
  display: none;
}

计时条玩法

做一个横跨整个屏幕宽度的进度条,配合上面失败动画的60秒时长,从满宽缩到零宽。

<div id="timer">
  <div id="bar"></div>
</div>
#timer {
  width: 100vw;
  height: 1em;
}

#bar {
  animation: 60s timebar forwards;
  background-color: #e60b0b;
  width: 100vw;
  height: 1em;
  transform-origin: right;
}

@keyframes timebar {
  0% { scale: 1 1; }
  100% { scale: 0 1; }
}

为了增加难度,可以再塞一个#root2作为第二个全局闭合点。玩家必须把两个大箱子都关掉才算赢,判断条件改成:has(#root:not([open])):has(#root2:not([open]))

整个游戏跑起来后,会发现纯HTML和CSS的整活空间比想象中大得多。Popover API配合:has():popover-open,能把以前必须写JS的交互逻辑硬啃下来。当然,这种玩法没怎么考虑无障碍访问,但拿来练手或者做个小彩蛋,已经够骚了。