一、效果展示

二、源码
html
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pac-Man SVG Demo</title>
</head>
<body>
<div class="controls">
<label for="animation-slider">动画进度:</label>
<input type="number" id="slider-value" value="0" />
<br />
<input type="range" id="animation-slider" min="0" max="0.999" step="0.001" value="0">
</div>
<div class="container">
<svg viewBox="0 0 200 200" style="scale: 1">
<circle class="body animation-control" r="50" cx="100" cy="100"></circle>
<circle class="eye animation-control" r="10" cx="150" cy="70"></circle>
<line class="upper-teeth animation-control" x1="100" y1="100" x2="200" y2="100"></line>
<path class="nose animation-control" d="M 199,100 A 99,99 0 1,0 198.9999999999985,100.0000172787596"></path>
</svg>
</div>
</body>
</html>
css
css
.container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
svg {
--animation-delay: 0s;
width: 200px;
height: 200px;
}
.body {
fill: transparent;
stroke: darkorange;
stroke-width: 100;
stroke-dasharray: 314.159; /* 2πr = 2 * π * 50 */
stroke-dashoffset: 0;
transform-origin: center;
animation: pacman-mouth-open-close 1s infinite linear;
}
.upper-teeth {
stroke: black;
stroke-width: 2;
transform-origin: center;
animation: pacman-upper-body-rotation 1s infinite linear;
}
.eye {
fill: white;
transform-origin: center;
animation: pacman-eye-close-polygon 1s infinite linear,
pacman-upper-body-rotation 1s infinite linear;
}
.nose {
fill: none;
stroke: black;
stroke-width: 2;
transform-origin: center;
animation: pacman-nose-adjust 1s infinite linear,
pacman-upper-body-rotation 1s infinite linear;
}
.animation-control {
animation-delay: var(--animation-delay);
animation-play-state: paused;
}
/* 身体张合动画 */
@keyframes pacman-mouth-open-close {
0% {
stroke-dashoffset: 0;
transform: rotate(0deg);
}
100% {
stroke-dashoffset: 314.159;
transform: rotate(180deg);
}
}
/* 上半身旋转跟随 */
@keyframes pacman-upper-body-rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(-180deg);
}
}
/* 眼睛缩小动画 */
@keyframes pacman-eye-shrink {
0% {
r: 10;
}
73% {
r: 10;
}
83% {
r: 0;
}
100% {
r: 0;
}
}
/* 眼睛闭眼效果, 眼角方向平行于上牙膛(椭圆裁剪路径) */
@keyframes pacman-eye-close-ellipse {
0% {
clip-path: ellipse(50% 50% at 50% 50%);
}
73% {
clip-path: ellipse(50% 50% at 50% 50%);
}
83% {
clip-path: ellipse(50% 0% at 50% 50%);
}
100% {
clip-path: ellipse(50% 0% at 50% 50%);
}
}
/* 眼睛闭眼效果, 眼角方向朝向身体圆心(多边形裁剪路径) */
@keyframes pacman-eye-close-polygon {
/*正方形边框点*/
0% {
clip-path: polygon(0 80%, 0 100%, 20% 100%, 40% 100%, 60% 100%, 80% 100%, 100% 100%, 100% 80%, 100% 60%, 100% 40%, 100% 20%, 100% 0, 80% 0, 60% 0, 40% 0, 20% 0, 0 0, 0 20%, 0 40%, 0 60%);
}
/*正方形边框点*/
73% {
clip-path: polygon(0 80%, 0 100%, 20% 100%, 40% 100%, 60% 100%, 80% 100%, 100% 100%, 100% 80%, 100% 60%, 100% 40%, 100% 20%, 100% 0, 80% 0, 60% 0, 40% 0, 20% 0, 0 0, 0 20%, 0 40%, 0 60%);
}
/*贴圆边*/
76% {
clip-path: polygon(0 80%, 14% 86%, 23% 93%, 40% 100%, 60% 100%, 77% 94%, 86% 86%, 94% 77%, 100% 60%, 100% 40%, 100% 20%, 86% 14%, 77% 7%, 60% 0, 40% 0, 23% 7%, 14% 14%, 6% 23%, 0 40%, 0 60%);
}
/*两个扇形的弧线组合*/
79% {
clip-path: polygon(0 80%, 13% 82%, 26% 82%, 38% 80%, 51% 76%, 62% 71%, 73% 63%, 82% 54%, 90% 44%, 96% 32%, 100% 20%, 87% 18%, 74% 18%, 62% 20%, 49% 24%, 38% 29%, 27% 37%, 18% 46%, 10% 56%, 4% 68%);
}
/*闭眼*/
83% {
clip-path: polygon(0 80%, 10% 74%, 20% 68%, 30% 62%, 40% 56%, 50% 50%, 60% 44%, 70% 38%, 80% 32%, 90% 26%, 100% 20%, 90% 26%, 80% 32%, 70% 38%, 60% 44%, 50% 50%, 40% 56%, 30% 62%, 20% 68%, 10% 74%);
}
/*闭眼*/
100% {
clip-path: polygon(0 80%, 10% 74%, 20% 68%, 30% 62%, 40% 56%, 50% 50%, 60% 44%, 70% 38%, 80% 32%, 90% 26%, 100% 20%, 90% 26%, 80% 32%, 70% 38%, 60% 44%, 50% 50%, 40% 56%, 30% 62%, 20% 68%, 10% 74%);
}
}
/* 嘴越大, 身体越小, 添加动画防止鼻子超出身体 */
@keyframes pacman-nose-adjust {
0% {
clip-path: polygon(50% 50%, 100% 50%, 100% 0);
}
70% {
clip-path: polygon(50% 50%, 100% 50%, 100% 0);
}
100% {
clip-path: polygon(50% 50%, 100% 50%, 100% 50%);
}
}
#animation-slider {
width: 350px;
position: absolute;
z-index: 1;
}
js
js
// 防抖函数(默认延迟时间300毫秒)
function debounce(func, delay = 300) {
let timer;
return function() {
const context = this;
const args = arguments;
clearTimeout(timer);
timer = setTimeout(() => {
func.apply(context, args);
}, delay);
};
}
const slider = document.getElementById('animation-slider');
const sliderValue = document.getElementById('slider-value');
const svg = document.querySelector("svg");
// 滑块更新CSS变量
slider.addEventListener('input', function() {
const newDelay = -this.value;
svg.style.setProperty('--animation-delay', `${newDelay}s`);
});
// 滑块更新显示值输入框
slider.addEventListener('input', function() {
sliderValue.value = this.value;
});
// 显示值输入框手动修改滑块值
sliderValue.addEventListener('input', debounce(function() {
slider.value = this.value;
slider.dispatchEvent(new Event('input', {'bubbles': true, 'cancelable': true}));
}));
三、源码(JS)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pac-Man SVG Demo</title>
<style>
input[type="range"] {
width: 100%;
}
.container {
display: flex;
flex-direction: column;
align-items: center;
}
</style>
</head>
<body>
<div class="container" id="demo-container"></div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const container = document.getElementById('demo-container');
// 创建 SVG
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '200');
svg.setAttribute('height', '200');
svg.setAttribute('viewBox', '0 0 200 200');
// 创建圆形(身体)
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('r', '50');
circle.setAttribute('cx', '100');
circle.setAttribute('cy', '100');
circle.setAttribute('fill', 'transparent');
circle.setAttribute('stroke', 'darkorange');
circle.setAttribute('stroke-width', '100');
circle.style.transformOrigin = 'center';
// 创建小圆点(眼睛)
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
dot.setAttribute('r', '10');
dot.setAttribute('cx', '150');
dot.setAttribute('cy', '70');
dot.setAttribute('fill', 'white');
dot.style.transformOrigin = 'center';
// 根据旋转值设置半径
const dotObserver = new MutationObserver((mutations) => {
// 获取元素的当前旋转值
const currentRotate = parseFloat(getComputedStyle(dot).getPropertyValue('rotate')) || 0;
// 根据旋转值设置半径(dot 的角度范围是 180 度到 360 度)
let limitRotateA = 210;
let limitRotateB = 240;
if (currentRotate >= 180 && currentRotate < limitRotateA) {
// 保持 0 不变
dot.setAttribute('r', `0`);
} else if (currentRotate >= limitRotateA && currentRotate <= limitRotateB) {
// 缩放从 0 到 1
const rValue = (currentRotate - limitRotateA) / (limitRotateB - limitRotateA);
dot.setAttribute('r', `${rValue * 10}`);
} else if (currentRotate >= limitRotateB && currentRotate <= 360) {
// 保持 10 不变
dot.setAttribute('r', `10`);
}
});
dotObserver.observe(dot, { attributes: true, attributeFilter: ['style'] });
// 创建线段(上牙膛)
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', '100');
line.setAttribute('y1', '100');
line.setAttribute('x2', '200');
line.setAttribute('y2', '100');
line.setAttribute('stroke', 'white');
line.setAttribute('stroke-width', '2');
line.style.transformOrigin = 'center';
// 创建圆弧(鼻子)(头盔)
const arc = document.createElementNS('http://www.w3.org/2000/svg', 'path');
arc.setAttribute('fill', 'none');
arc.setAttribute('stroke', '#c8ff00');
arc.setAttribute('stroke-width', '2');
arc.style.transformOrigin = 'center';
const arcAngle = 360;
// 更新圆弧的 d 属性
function updateArc(arcX, arcY, arcRadius, arcAngle) {
if (arcAngle === 360) {
// 圆弧不能画整圆, 但是可以差一点点
const arcRadians2 = (arcAngle - 1e-5) * (Math.PI / 180);
const newarcX2 = arcX + arcRadius * Math.cos(arcRadians2);
const newarcY2 = arcY - arcRadius * Math.sin(arcRadians2);
const arc_dm1 = `M ${arcX + arcRadius},${arcY}`;
const arc_da1 = `A ${arcRadius},${arcRadius} 0 ${arcAngle > 180 ? '1':'0'},0 ${newarcX2},${newarcY2}`;
arc.setAttribute('d', `${arc_dm1} ${arc_da1}`);
return;
}
const arcRadians = arcAngle * (Math.PI / 180);
const newarcX = arcX + arcRadius * Math.cos(arcRadians);
const newarcY = arcY - arcRadius * Math.sin(arcRadians);
const arc_dm1 = `M ${arcX + arcRadius},${arcY}`;
const arc_da1 = `A ${arcRadius},${arcRadius} 0 ${arcAngle > 180 ? '1':'0'},0 ${newarcX},${newarcY}`;
arc.setAttribute('d', `${arc_dm1} ${arc_da1}`);
}
updateArc(100, 100, 99, arcAngle);
// 根据旋转值设置半径
const arcObserver = new MutationObserver((mutations) => {
// 获取元素的当前旋转值
const currentRotate = parseFloat(getComputedStyle(arc).getPropertyValue('rotate')) || 0;
// 根据旋转值限制角度大小不超过身体(arc 的角度范围是 180 度到 360 度)
updateArc(100, 100, 99, Math.min((currentRotate - 180) * 2, arcAngle));
// updateArc(100, 100, 99, (currentRotate - 180) * 2);
});
arcObserver.observe(arc, { attributes: true, attributeFilter: ['style'] });
// 创建滑块和角度显示
const sliderDiv = document.createElement('div');
const sliderText = document.createTextNode('拖动滑块控制吃豆人的张嘴角度:');
sliderDiv.style.width = '400px';
const slider = document.createElement('input');
slider.type = 'range';
slider.min = '0';
slider.max = '360';
slider.value = 270;
const angleDisplay = document.createElement('span');
// angleDisplay.textContent = `${360 - slider.value}度`;
sliderDiv.appendChild(sliderText);
sliderDiv.appendChild(angleDisplay);
sliderDiv.appendChild(slider);
// 添加所有元素到容器
container.appendChild(svg);
container.appendChild(sliderDiv);
svg.appendChild(circle);
svg.appendChild(dot);
svg.appendChild(line);
svg.appendChild(arc);
// 更新圆形的 dash 长度和偏移量,以及 circle 和 dot 的 rotate
const updateCircle = () => {
const angle = slider.value;
angleDisplay.textContent = `${360 - angle}度`;
const circumference = 100 * Math.PI;
const dashOffset = circumference / 360 * (360 - angle);
circle.setAttribute('stroke-dasharray', circumference);
circle.setAttribute('stroke-dashoffset', dashOffset);
// 更新 circle 和 dot 的 rotate 属性
circle.style.rotate = `${180 - angle / 2}deg`;
dot.style.rotate = `${180 + angle / 2}deg`;
line.style.rotate = `${180 + angle / 2}deg`;
arc.style.rotate = `${180 + angle / 2}deg`;
};
// 初始更新
updateCircle();
// 添加滑块事件监听器
let animationFrameId;
slider.addEventListener('input', () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
animationFrameId = requestAnimationFrame(updateCircle);
});
// slider.addEventListener('input', updateCircle);
});
</script>
</body>
</html>