发布于 2026-01-06 0 阅读
0

CSS + JS 中的复古 CRT 终端屏幕 目标 灵感 拟物化设计 编写 CRT 代码 下次……

复古 CRT 终端屏幕,使用 CSS + JS 制作

目标

灵感

拟物化

对 CRT 进行编码

下次...

作为一项有趣的工程,我决定创建一个外观和感觉都像老式 CRT 显示器的终端界面。

终端响应迅速,但在更大的屏幕上操作可能会更容易(并且会提供登录提示)。

现在,是时候启动终端了!

目标

我的目标是完全使用现代ECMAScript 6特性构建整个程序(不使用任何转译器,例如Babel)。抱歉了,Internet Explorer,你该退休了。

在这个项目中,我学习了很多知识:

  • ES6 特性,例如模块、动态导入和 async/await
  • CSS边框图像、背景渐变、动画和变量
  • JavaScript 音频和语音合成 API
  • 使用纯 JavaScript 处理 DOM 元素

内容太多,无法写成完整的教程,但本文将解释最重要的部分。之后,我可能会写一篇后续文章,详细介绍各个组件以及我的项目组织方式。为了清晰起见,示例中我通常会省略一些最终代码,但您始终可以在 Codesandbox 上查看源代码。

灵感

这个项目的灵感主要来源于《辐射3》游戏,在游戏中,你可以通过在其中一个终端上玩一个小游戏来“入侵”电脑:

拟物化

在设计中模仿现实物体的属性(例如材质或形状)被称为拟物化。其原理是,通过使设计看起来像用户熟悉的物体,可以使其更容易理解。苹果公司在其应用程序中大量运用了这种设计,例如,书店应用程序会将书籍展示在“真实”的书架上,指南针应用程序则会显示一个旋转的指南针指向用户面向的方向。

这种风格之所以逐渐被淘汰,主要是因为扁平化设计的流行,极简主义似乎成了主流。不过,大多数软件仍然保留了一些拟物化元素。例如,网页上简单的、未经样式化的HTML代码<button>看起来就像一个硬件按钮,这可以提示用户该元素是可以按下的。导航标签看起来就像一个实体文件夹。

我最近遇到的另一个绝佳例子是这款宝丽来相机:

对 CRT 进行编码

那么,我们如何才能让我们的CRT显示器看起来像真的一样呢?我们需要一些零件:

  • 扫描线,即这种显示器曾经具有的交替水平线的视觉图案。
  • 巨大的圆形边框,让它看起来像那种小型便携式电视机。
  • 有些按钮,比如电源开关。我觉得手动打开设备并亲眼看到设备启动的过程,能增强整个体验的沉浸感。
  • 一个基于文本的界面,用户可以在其中输入命令。

搭建屏幕📺

基本的HTML代码非常简单,就是<div>为每个部分添加一个`for`标签:

<!-- the actual device -->
<div id="monitor">
    <!-- the rounded edge near the glass -->
    <div id="bezel">
        <!-- the overlay and horizontal pattern -->
        <div id="crt" class="off" onClick="handleClick(event)"> 
            <!-- slowly moving scanline -->
            <div class="scanline"></div>
            <!-- the input and output -->
            <div class="terminal"></div>
        </div>
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

我可能会在以后的文章中介绍按钮控制功能。

扫描线

这个Codepen 示例中的水平黑色半透明线条似乎达到了预期效果:

#crt:before {
    content: " ";
    display: block;
    position: absolute;
    top: 0;
    left: 0;
    bottom: 0;
    right: 0;
    background: linear-gradient(
        to bottom,
        rgba(18, 16, 16, 0) 50%,
        rgba(0, 0, 0, 0.25) 50%
    );
    background-size: 100% 8px;
    z-index: 2;
    pointer-events: none;
}

Enter fullscreen mode Exit fullscreen mode

:before类结合使用position: absolute,可以让我们在元素上方叠加线条图案。线性渐变会将上半部分的背景填充为不透明的深色线条,下半部分填充为半透明的黑色线条。该background-size属性使其宽度为全宽,高度为 8 像素,因此每条线宽为 4 像素。此背景垂直重复,从而创建交替的线条图案。

本文介绍了一种创建非常逼真的扫描线图案的方法,甚至还包括纱窗效应:一种类似网格的外观,可以看到屏幕上像素之间的缝隙。这会导致屏幕闪烁,非常伤眼,所以我决定不采用这种方法。我确实对文本使用了颜色分离效果,它为终端文本添加了动态文本阴影,使文本看起来略微移动:

@keyframes textShadow {
  0% {
    text-shadow: 0.4389924193300864px 0 1px rgba(0,30,255,0.5), -0.4389924193300864px 0 1px rgba(255,0,80,0.3), 0 0 3px;
  }
  5% {
    text-shadow: 2.7928974010788217px 0 1px rgba(0,30,255,0.5), -2.7928974010788217px 0 1px rgba(255,0,80,0.3), 0 0 3px;
  }
  /** etc */
}
Enter fullscreen mode Exit fullscreen mode

此外,屏幕上每隔十秒钟还有一条扫描线从上到下移动。它使用类似但更大的线性渐变,并通过动画使其从上到下移动。

.scanline {
    width: 100%;
    height: 100px;
    z-index: 8;
    background: linear-gradient(
        0deg,
        rgba(0, 0, 0, 0) 0%,
        rgba(255, 255, 255, 0.2) 10%,
        rgba(0, 0, 0, 0.1) 100%
    );
    opacity: 0.1;
    position: absolute;
    bottom: 100%;
    animation: scanline 10s linear infinite;
}
Enter fullscreen mode Exit fullscreen mode

动画有 80% 的时间处于不可见状态,剩余 20% 的时间则从上到下移动:

@keyframes scanline {
    0% {
        bottom: 100%;
    }
    80% {
        bottom: 100%;
    }
    100% {
        bottom: 0%;
    }
}
Enter fullscreen mode Exit fullscreen mode

表圈🖵

为了创建圆角效果,我使用了边框图像( border -image),这是一个我以前从未听说过的 CSS 属性!它的原理是创建一个背景图像,然后自动将其分割成多个区域,每个区域对应一条边和一个角。

表圈

你可以使用无单位 属性来指定实际使用的图像区域大小border-image-slice。对于栅格图像,它以像素为单位;对于 SVG 图像,它以百分比为单位。在本例中,我们希望距离边缘 30 像素。定义此border: 30px solid transparent属性似乎是必要的,以确保图像在 Android Chrome 浏览器中显示正常。

#screen {
    position: relative;
    width: 100%;
    height: 67.5vmin;
    border: 30px solid transparent;
    border-image-source: url(./bezel.png);
    border-image-slice: 30 fill;
    border-image-outset: 0;
    overflow: hidden;
}
Enter fullscreen mode Exit fullscreen mode

浏览器随后会自动使用边框图片,并根据元素的宽度和高度调整中间部分的缩放比例。✨

屏幕

为了创造一种用户可以与终端交互的体验,其中一些屏幕的所有输出都是自动的,而另一些屏幕则交替进行输入/输出,我为每个屏幕创建了一个函数:

  • 启动 - 启动过程
  • 登录——一种非常安全的身份验证机制
  • 主界面 - 用户可以在此处输入命令

启动

启动画面会在屏幕上显示大量文本。为了实现这一点,我创建了一个type()函数,该函数返回一个Promise,该 Promise 会在打字动画完成后解析。将其设为异步函数至关重要,因为我们希望在允许用户输入内容之前等待打字动画完成。该函数的工作原理将在下文中进一步解释。

在我的所有函数中,我都使用这里所示的简单 async/await 模式,这样我就可以以同步流程构建我的屏幕,从而保持代码的可读性。

boot()函数中,我只需等待typer()函数执行完毕,然后跳转到下一个屏幕:

async function boot() {
    clear();
    await typer("Hello world");

    login();
}
Enter fullscreen mode Exit fullscreen mode

clear()函数通过重置来清空终端div innerHTML。我暂时跳过登录界面,直接解释主循环。

主要的

main()函数会显示输入框并等待用户输入命令。然后解析命令,并根据大量的 if/else 语句,我们可以调用函数和/或向用户显示一些输出。命令执行完毕后,我们会递归调用该main()函数,重新开始!

async function main() {
    let command = await input();
    await parse(command);

    main();
}
Enter fullscreen mode Exit fullscreen mode

我非常喜欢这段代码的简洁性和可读性,尽管我们使用的是命令式编程风格。手动创建和更新 DOM 元素确实有点麻烦,但在我们的场景下完全可以胜任。

输入/输出⌨️

输入和输出文本的 CSS 非常简单,唯一值得一提的是使用了像素化的VT323字体,并且所有文本都转换为大写:

@import url("https://fonts.googleapis.com/css?family=VT323&display=swap");

.terminal {
    font-family: "VT323", monospace;
    text-transform: uppercase;
}
Enter fullscreen mode Exit fullscreen mode

动画打字输出

这部分主要用到了 JavaScript。我一开始用的是一个名为TypeIt 的库,用来为命令行输出创建动画打字效果。它非常灵活——你只需要给它一个容器元素和一个字符串数组,它就能正常工作!

new TypeIt('#container', {
    strings: ["Hello", "world"],
    speed: 50,
    lifeLike: true,
    startDelay: 0,
    cursorChar: ""
}).go();
Enter fullscreen mode Exit fullscreen mode

过了一段时间,我决定自己编写一个打字功能,因为我想在字符出现在屏幕上时添加一个炫酷的动画效果(试试点击红色按钮)。这个功能的核心是一个 while 循环,它会在屏幕上添加一个字符,然后暂停片刻:

async function type(text, container) {

    await pause(1);

    let queue = text.split("");

    while (queue.length) {
        let char = queue.shift();
        container.appendChild(char);
        await pause(0.05);
    }

    await pause(0.5);
    container.classList.remove("active");
    return;
}
Enter fullscreen mode Exit fullscreen mode

while只要队列字符串还有剩余空间length > 0循环就会一直运行,String.shift()函数会移除第一个字符并返回它。

`pause` 函数实际上是 `await` 的一个高级封装setTimeout(),它返回一个 Promise,这样我们就可以使用 `await` 来等待它完成async/await——真是妙啊!通常你会使用 `await` 来延迟执行回调函数setTimeout,但在这里我们只是想暂停代码执行,模拟终端处理命令的过程。感谢Stack Overflow

function pause(s = 1) {
    return new Promise(resolve => setTimeout(resolve, 1000 * Number(s)));
}
Enter fullscreen mode Exit fullscreen mode

默认参数为一秒,因为大多数情况下我都想这样使用。

处理输入命令

类似地,我创建了一个输入元素,当用户按下回车键时,该元素会返回一个已解决的 Promise,从而让用户输入命令。

async function input(pw) {
    return new Promise(resolve => {
        const onKeyDown = event => {
            if (event.keyCode === 13) {
                event.preventDefault();
                let result = event.target.textContent;
                resolve(result);
            }
        };

        let terminal = document.querySelector(".terminal");
        let input = document.createElement("div");
        input.setAttribute("id", "input");
        input.setAttribute("contenteditable", true);
        input.addEventListener("keydown", onKeyDown);
        terminal.appendChild(input);
        input.focus();
    });
}
Enter fullscreen mode Exit fullscreen mode

实际上,这个输入框是一个<div>带有contenteditable属性的 div 元素,它允许用户在元素内部输入内容。如果我们想在 div 元素内部进行一些复杂的 HTML 操作(而这些操作在普通元素内部通常是不允许的),这个属性就非常有用<input>

闪烁的插入符号🟩

行尾闪烁的方块确实为整个打字动画增色不少(灵感来自 TypeIt)。它只不过是一个放置在:after伪类中的字符而已!

#input {
    position: relative;
    caret-color: transparent;
}
/* Puts a blinking square after the content as replacement for caret */
#input[contenteditable="true"]:after {
    content: "■";
    animation: cursor 1s infinite;
    animation-timing-function: step-end;
    margin-left: 1px;
}
/* Inserts the > before terminal input */
#input:before {
    content: ">";
    position: absolute;
    padding-left: 1.5rem;
    left: 0;
}
Enter fullscreen mode Exit fullscreen mode

这样animation-timing-function: step-end,光标的透明度就会发生细微的变化,使其闪烁,而不是线性淡入淡出。

然后,我在输入框前添加了一个>字符,提示用户可以在此处输入内容。一个巧妙的小技巧是直接caret-color: transparent;在元素本身上进行设置,隐藏默认的光标。如果用户点击文本中间,这会导致光标无法移动,但对我来说影响不大。

执行命令

我一开始使用了一个大型的 if/else 块来处理所有不同的命令,但这很快就变得难以控制,所以我需要一些更模块化的东西。

这就是我决定使用动态导入的地方。这是 ES6 的另一个特性,现在 Chrome 内核版的 Edge 浏览器也得到了很好的支持!

你可能了解静态导入,也就是在模块顶部导入依赖项:

import moment from 'moment'
Enter fullscreen mode Exit fullscreen mode

动态导入可以用于任何地方,甚至可以有条件地使用,支持可变路径,并按需加载指定的资源!这正是我们需要的!导入操作会返回一个包含模块的 Promise。如果您使用 async/await,可以直接访问模块的任何导出项:

const { format } = await import('date-fns');
Enter fullscreen mode Exit fullscreen mode

以下是我如何使用导入语句来解析命令的方法:

async function parse(command) {

    let module;

    // Try to import the command function
    try {
        module = await import(`../commands/${command}.js`);
    } catch (e) {
        console.error(e);
        return await type("Unknown command");
    }

    // Type the output if the command exports any
    if (module && module.output) {
        await type(module.output);
    }

    await pause();

    // Execute and wait for the command (default export) to finish
    if (module.default) {
        await module.default();
    }
    return;
}
Enter fullscreen mode Exit fullscreen mode

直接在浏览器中执行这类操作,无需像 Babel 这样的转译器和像 Webpack 这样的代码打包工具,是非常前沿的技术。它赋予开发者极大的自由度,让他们可以仅在需要时加载资源,从而避免主应用程序变得臃肿。这是让使用原生 JavaScript 编写模块化、轻量级应用程序变得轻松的关键特性之一。

命令👨‍💻

每个命令本质上都是一个JavaScript 模块,它带有一个默认的导出函数,该函数会在模块加载时执行。output如上所述,通过添加一个命名导出,它还可以让用户按下回车键时直接输出一些文本。如果这里返回的是一个 Promise 对象,则该main()函数会等待命令执行完毕。

const output = "Hello world.";

const helloWorld = () => {
   // do whatever...
};

export { output };

export default helloWorld;
Enter fullscreen mode Exit fullscreen mode

现在我们可以以模块化的方式添加命令,我们可以完全放飞自我,编写任何我们能想到的酷炫的东西。

尼奥,我试图解放你的思想。但我只能为你指明方向,最终走进去的还是你自己。

——墨菲斯

矩阵

下次...

本文下一部分将详细介绍如何添加声音、控制按钮和主题!现在,尽情享受破解的乐趣吧!

黑客

文章来源:https://dev.to/ekeijl/retro-crt-terminal-screen-in-css-js-4afh