只用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的交互逻辑硬啃下来。当然,这种玩法没怎么考虑无障碍访问,但拿来练手或者做个小彩蛋,已经够骚了。
