CSS 实现哈利波特活点地图

1,617 阅读5分钟

final.gif

GitHub: github.com/icochi/The-…

Codepen: codepen.io/icochi/pen/…

Bilibili: www.bilibili.com/video/BV1Nv…

一、地图叠加

image.png

新建一个 HTML 文件,创建两层嵌套的容器,并指定容器的尺寸。

<div class="map-container">
    <div class="map"></div>
</div>
.map-container {
    width: 1440px;
    height: 900px;
}

.map {
    width: 100%;
    height: 100%;
}

接下来需要准备纸张和地图图层素材,使用羊皮纸,作为纸张背景。

map-bg.png

然后在地图网站截取一个区域作为地图层的背景。

map.png

分别设置两个容器的背景,外层使用羊皮纸素材,内层使用地图素材。

.map-container {
    /* ... */
    background-image: url('./map-bg.png');
}

.map {
    /* ... */
    background-image: url('./map.png');
}

设置地图层混合模式为正片叠底,并设置灰度滤镜。

.map {
    /* ... */
    mix-blend-mode: multiply;
    filter: grayscale(1);
}

image.png

二、图层渐现

mask-show.gif

首先需要准备遮罩素材,我们需要一个与地图尺寸一致的分层杂色图像,可以用 ps 的云彩滤镜生成。

截屏2024-12-26 13.15.30.png

在代码的地图层内插入一个遮罩元素。

<div class="map">
    <div class="mask"></div>
</div>

设置元素样式,背景使用刚刚生成的云彩图片,混合模式设置为变亮,滤镜亮度设为0。

.map .mask {
    width: 100%;
    height: 100%;
    background-image: url("./cloud.png");
    mix-blend-mode: lighten;
    filter: brightness(0);
}

这里的亮度会影响遮罩的可见性,值越高,可见性越低。

mask-brightness.gif

所以我们设置关键帧来修改亮度值,在遮罩层的动画属性中引用关键帧。

@keyframes magic {
    from {
        filter: brightness(10);
    }

    to {
        filter: brightness(0);
    }
}
.map .mask {
    /* ... */
    animation: 3s linear 0s magic forwards;
}

三、脚印动画

steps.gif

准备脚印出现动画精灵图、脚印消失动画的遮罩。

footprints.png

footprints-cloud.png

在代码中创建一个节点作为脚印的容器。

<div class="track">
    <div class="footprint">
    </div>
</div>

用绝对定位将其置于地图上层。

.footprint {
    position: absolute;
}

在容器中创建脚印节点和左右脚元素。

<div class="footprint">
    <div class="foot left"></div>
    <div class="foot right"></div>
</div>

设置背景为脚印精灵图并调整背景图片定位。

.footprint .foot {
    position: absolute;
    width: 10px;
    height: 22px;
    background-image: url('./footprints.png');
    background-size: 40px;
    background-repeat: no-repeat;
    background-position-x: -30px;
}

调整左右脚的位置和右脚背景图片位置。

.footprint .foot.left {
    left: -5px;
    top: 7px;
}

.footprint .foot.right {
    left: 5px;
    top: -7px;
    background-position-y: -22px;
}

新建关键帧动画,实现脚印出现效果。

@keyframes footsteps {
    0% {
        background-position-x: 0px;
    }

    25% {
        background-position-x: 0px;
    }

    25.1% {
        background-position-x: -10px;
    }

    50% {
        background-position-x: -10px;
    }

    50.1% {
        background-position-x: -20px;
    }

    75% {
        background-position-x: -20px;
    }

    75.1% {
        background-position-x: -30px;
    }

    100% {
        background-position-x: -30px;
    }
}
.footprint .foot {
    /* ... */
    animation: 1s linear 0s footsteps forwards;
}

在脚印上创建 after 伪元素,设置背景为脚印云彩遮罩。

.footprint .foot::after {
    display: block;
    content: '';
    width: 100%;
    height: 100%;
    background-image: url('./footprints-cloud.png');
    background-repeat: no-repeat;
    background-size: 20px 22px;
}

设置混合模式,父元素正片叠底,子元素变亮,亮度值为 1。

.footprint .foot {
    /* ... */
    mix-blend-mode: multiply;
}
.footprint .foot::after {
    /* ... */
    mix-blend-mode: lighten;
    filter: brightness(1);
}

随着亮度值变高,遮罩层会慢慢盖住脚印。

footprint-brightness.gif

使用关键帧动画实现脚印消失效果,加上透明属性使过渡更自然。

@keyframes footHide {
    0% {
        opacity: 0;
    }

    25% {
        opacity: 1;
        filter: brightness(1);
    }

    100% {
        opacity: 1;
        filter: brightness(10);
    }
}
.footprint .foot::after {
    display: block;
    content: '';
    width: 100%;
    height: 100%;
    background-image: url('./footprints-cloud.png');
    background-repeat: no-repeat;
    background-size: 20px 22px;
    mix-blend-mode: lighten;
    /* +++= */
    animation: 8s linear 1s footHide forwards;
    opacity: 0;
    /* =+++ */
}

此时左右脚动画是同时发生的,在样式中将右脚动画延迟一秒。

.footprint .foot.right {
    left: 5px;
    top: -7px;
    background-position-y: -22px;
    /* +++= */
    animation-delay: 1s;
    /* =+++ */
}

最后避免在动画开始前显示脚印,设置初始偏移使背景不可见。

.footprint .foot {
    position: absolute;
    width: 10px;
    height: 22px;
    background-image: url('./footprints.png');
    background-size: 40px;
    background-repeat: no-repeat;
    /* +++= */
    background-position-x: 10px;
    /* ---= */
    animation: 1s linear 0s footsteps forwards;
}

四、脚印轨迹

track.gif

地图路径

打开矢量编辑软件并引入地图图层,用钢笔沿道路绘制路径,设置线条颜色为透明。

截屏2024-12-26 13.02.47.png

导出 svg 文件并复制文件内容到代码中。

<div class="map-container">
    <div class="map">
        <div class="mask"></div>
    </div>
    <!-- +++= -->
    <svg class="path" width="1440px" height="900px" viewBox="0 0 1440 900" version="1.1"
        xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
        <title>path</title>
        <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-opacity="0">
            <g id="编组" stroke="#FFFFFF">
                <rect id="矩形" x="0.5" y="0.5" width="1439" height="899"></rect>
                <polyline id="path"
                    points="130.896846 440.973055 77.5736485 440.973055 37.1505202 369.443329 37.1505202 356.479448 51.9044918 351.186423 224.12596 351.186423 231.548286 330.534893 224.12596 255.156304 231.548286 207.572814 384.619482 213.802866 484.122309 199.455275 653.112882 162.545157 670.983559 247.421636 670.983559 364.482797 638.20106 369.443329 631.544477 406.417259 608.545342 415.874847 608.545342 467.454973 622.076813 475.219868 670.983559 467.454973 670.983559 429.285411">
                </polyline>
            </g>
        </g>
    </svg>
    <!-- =+++ -->
    <div id="track" class="track">
    </div>
</div>

动态创建元素

下面实现 js 脚本部分,初始化脚本,实现创建脚印元素的函数。

function createFootprint() {
    const footprint = document.createElement('div');
    footprint.className = 'footprint';

    const footLeft = document.createElement('div');
    const footRight = document.createElement('div');

    footLeft.classList = 'foot left'
    footRight.classList = 'foot right'

    footprint.appendChild(footLeft);
    footprint.appendChild(footRight);
    return footprint;
}

引用 svg 中的路径元素,使用svg api 获取路径总长度。

const $path = document.querySelector('#path');
const pathLen = $path.getTotalLength();

引用脚印容器,实现并调用绘制脚印的函数。

const $track = document.querySelector('#track');

let tempLen = 0;
function drawFootprint() {
    const point = $path.getPointAtLength(tempLen);

    const footprint = createFootprint();
    footprint.style.left = `${point.x}px`;
    footprint.style.top = `${point.y}px`;

    const prePoint = $path.getPointAtLength(tempLen - 20);
    const nextPoint = $path.getPointAtLength(tempLen + 20);

    $track.appendChild(footprint);
    tempLen += 40;
}

drawFootprint();

可以看到在路径起始位置生成了一次脚印动画。

one-step.gif

用周期函数循环调用绘制函数,即可沿路径生成脚印。

let interval = setInterval(() => {
    drawFootprint();
}, 2000);

但是动画并没有结束,而是在终点重复生成。

foot-end.gif

原因是我们没有终止周期函数。在绘制函数中加入条件清除定时器,脚印动画能正常结束。

function drawFootprint() {
    /* ... */
    
    /* +++= */
    if (tempLen > pathLen) {
        clearInterval(interval);
    }
    /* =+++ */
    tempLen += 40;
}

脚印方向

最后调整一下脚印的方向。

实现一个通过两点计算角度值的函数。

function getAngle(p0, p1) {
    const deltaX = p1.x - p0.x;
    const deltaY = p1.y - p0.y;
    const angle = Math.atan2(deltaY, deltaX);
    return angle;
}

在绘制脚印时调用该函数并旋转脚印元素。

function drawFootprint() {
    /* ... */
    /* +++= */
    const angle = getAngle(prePoint, nextPoint);
    footprint.style.rotate = `${angle + Math.PI / 2}rad`;
    /* =+++ */

    $track.appendChild(footprint);
    if (tempLen > pathLen) {
        clearInterval(interval);
    }
    tempLen += 40;
}

此时脚印能指向路径方向,但是蒙版失去了透明效果。

foot-white.gif

调整一下混合模式的位置到脚印容器上,动画恢复正常。

.footprint .foot {
    /* ... */    
    /* ---= */
    mix-blend-mode: multiply;
    /* =--- */
}
.track {
    /* ... */    
    /* +++= */
    mix-blend-mode: multiply;
    /* =+++ */
}

五、姓名横幅

banner.gif

准备一个横幅背景素材。

image.png

在 Dom 中创建一个姓名节点,给节点指定 id。

<div id="track" class="track">
    <div id="name" class="name-banner">Icochi</div>
</div>

设置样式和背景。

.name-banner {
    position: absolute;
    width: 160px;
    height: 40px;
    background-image: url('./banner.svg');
    background-repeat: no-repeat;
    background-size: 160px 40px;
    text-align: center;
    line-height: 30px;
    font-family: 'Apple Chancery';
    z-index: 1;
}

在 js 代码中引用节点元素,绘制脚印时更新姓名横幅的坐标。

const $name = document.querySelector('#name');
function drawFootprint() {
    const point = $path.getPointAtLength(tempLen);
    /* +++= */
    $name.style.left = `${point.x + 25}px`;
    $name.style.top = `${point.y + 25}px`;
    /* =+++ */
    
    /* ... */
}

横幅能随脚步移动但是帧率太低,缩短计时器的延时和每次绘制增加的长度。

function drawFootprint() {
    /* ... */
    
    /* +++= */
    tempLen += 1;
    /* =+++ */
}
interval = setInterval(() => {
    drawFootprint();
}, 50);

横幅移动正常但是脚印绘制频率太高,再次加入条件减少脚印绘制频率,使动画正常播放。

function drawFootprint() {
    /* ... */

    /* +++= */
    if (tempLen % 40 === 0) {
        const footprint = createFootprint();
        footprint.style.left = `${point.x}px`;
        footprint.style.top = `${point.y}px`;

        const prePoint = $path.getPointAtLength(tempLen - 20);
        const nextPoint = $path.getPointAtLength(tempLen + 20);
        const angle = getAngle(prePoint, nextPoint);
        footprint.style.rotate = `${angle + Math.PI / 2}rad`;

        $track.appendChild(footprint);
        if (tempLen > pathLen) {
            clearInterval(interval);
        }
    }
    /* =+++ */
    tempLen += 1;
}

六、收尾

推迟足迹

足迹动画在地图出现前就发生了。

截屏2024-12-25 00.52.37.png

需要在 js 代码中引用云彩蒙版,并监听动画完成事件,在地图渐现动画完成后再执行脚印绘制。

<div id="mapMask" class="mask"></div>
const $mapMask = document.querySelector('#mapMask');
$mapMask.addEventListener('animationend', (e) => {
    drawFootprint();
    interval = setInterval(() => {
        drawFootprint();
    }, 50);
})

姓名横幅过渡

设置横幅样式初始为不可见,在监听函数中更新为可见。

.name-banner {
    /* ... */
    
    /* +++= */
    display: none;
    /* =+++ */
}
$mapMask.addEventListener('animationend', (e) => {
    drawFootprint();
    /* +++= */
    $name.style.display = 'block';
    /* =+++ */
    interval = setInterval(() => {
        drawFootprint();
    }, 50);
})

样式中加入动画使过渡更自然。

@keyframes nameShow {
    from {
        opacity: 0;
    }

    to {
        opacity: 1;
    }
}
.name-banner {
    /* ... */
    
    /* +++= */
    animation: 1s linear 0s nameShow forwards;
    /* =+++ */
}

保留终点脚印

下一个问题是足迹在最后消失了。

截屏2024-12-25 00.33.08.png

我们的期望是脚印停留在终点位置。

截屏2024-12-25 00.35.37.png

在脚印绘制函数中给最后一个脚印元素加入类标识,并在样式中暂停它的足迹消失动画。

function drawFootprint() {
    /* ... */
    if (tempLen % 40 === 0) {
        /* ... */
        if (tempLen > pathLen) {
            clearInterval(interval);
            /* +++= */
            footprint.classList.add('last');
            /* =+++ */
        }
    }
    tempLen += 1;
}
.footprint.last .foot::after {
    animation-play-state: paused;
}

销毁脚印元素

最后解决一下性能问题,我们延路径生成的所有 dom 节点,在脚印消失后都没有被删除。

截屏2024-12-25 00.18.35.png

只需要在绘制脚印函数中加入定时器,在动画完成后,删除节点就可以了。

function drawFootprint() {
    /* ... */
    if (tempLen % 40 === 0) {
        /* ... */
        if (tempLen > pathLen) {
            clearInterval(interval);
            footprint.classList.add('last');
        } else {
            /* +++= */
            setTimeout(() => {
                footprint.remove();
            }, 15000)
            /* =+++ */
        }
    }
    tempLen += 1;
}