MenuScene:菜单场景与故障文字特效

举报
yd_296221794 发表于 2026/09/13 11:12:48 2026/09/13
【摘要】 > 214 行代码,把"菜单"做成了赛博朋克体验的第一入口。## 场景概览`MenuScene` 是 NEON RUNNER 2077 里最"花哨"的场景——故障文字、扫描线、全息面板、键盘/鼠标双输入、呼吸动画。它不是一个简单的"开始游戏"按钮列表,而是一个**沉浸式的菜单界面**。```ts// src/scenes/MenuScene.tsexport class MenuScene ...

> 214 行代码,把"菜单"做成了赛博朋克体验的第一入口。


## 场景概览


`MenuScene` 是 NEON RUNNER 2077 里最"花哨"的场景——故障文字、扫描线、全息面板、键盘/鼠标双输入、呼吸动画。它不是一个简单的"开始游戏"按钮列表,而是一个**沉浸式的菜单界面**。


```ts

// src/scenes/MenuScene.ts

export class MenuScene extends Phaser.Scene {

  private buttons: MenuButton[] = [];

  private selectedIndex = 0;

  private titleGlitch!: GlitchText;

  private subtitle!: Phaser.GameObjects.Text;

```


## 视觉层次:从背景到前景


`MenuScene` 的 `create()` 里,视觉元素按以下顺序叠加:


1. **背景网格**(`drawBackgroundGrid`)— 紫色细线,60px 间距,透明度 0.25

2. **扫描线**(`drawScanLines`)— 黑色横线,每 3px 一条,透明度 0.08

3. **故障标题**(`titleGlitch`)— GlitchText 组件,88px Orbitron

4. **副标题**(`subtitle`)— "// 2 0 7 7 //",呼吸动画

5. **全息面板**(`HoloPanel`)— 青色角标框,扫描浮线

6. **菜单按钮**(3 个 Container)— 键盘/鼠标可选

7. **底部信息**(版本号 + 操作提示)


这个顺序不是随意的——`setDepth()` 决定了谁在谁上面。背景网格 `setDepth(0)`,扫描线 `setDepth(20)`,按钮 `setDepth(6-7)`,底部信息 `setDepth(10)`。扫描线在网格之上但在按钮之下,所以按钮文字不会被扫描线遮挡。


## 扫描线效果:CRT 显示器的数字回声


```ts

private drawScanLines(width: number, height: number): void {

  const g = this.add.graphics().setDepth(20).setAlpha(0.08);

  g.fillStyle(0x000000, 1);

  for (let y = 0; y < height; y += 3) {

    g.fillRect(0, y, width, 1);

  }

}

```


每 3 像素画一条 1 像素高的黑线,透明度 0.08。这个效果模拟的是 CRT 显示器的扫描线——老式电视的电子束从左到右、从上到下扫过屏幕,留下一条条暗线。


在赛博朋克语境里,扫描线不只是"复古",它暗示**这是一个模拟信号的世界**——不是干净的数字界面,而是有噪点、有干扰的系统。


## 菜单按钮:键盘 + 鼠标双输入


`MenuScene` 的按钮系统是整个项目里最完整的输入处理:


```ts

const labels = ['> JACK IN // START GAME', '> SYSTEM // OPTIONS', '> DISCONNECT // EXIT'];

const startY = height * 0.58 - 70;

labels.forEach((label, i) => {

  const y = startY + i * 60;

  const bg = this.add.rectangle(width / 2, y, 420, 48, COLORS.DARK_LINE, 0.6)

    .setStrokeStyle(1, COLORS.NEON_CYAN, 0.4)

    .setDepth(6);


  const text = this.add.text(width / 2, y, label, {

    fontFamily: FONTS.MONO,

    fontSize: '20px',

    color: '#FFFFFF',

  }).setOrigin(0.5).setDepth(7);


  const container = this.add.container(0, 0, [bg, text]).setDepth(6);

  this.buttons.push({ container, text, bg, label });

});

```


每个按钮是一个 `Container`,包含背景矩形和文字。`Container` 的好处是:移动容器时,里面的所有子对象一起移动。


### 键盘导航


```ts

keyboard.on('keydown-UP', () => this.moveSelection(-1));

keyboard.on('keydown-DOWN', () => this.moveSelection(1));

keyboard.on('keydown-W', () => this.moveSelection(-1));

keyboard.on('keydown-S', () => this.moveSelection(1));

keyboard.on('keydown-ENTER', () => this.confirmSelection());

keyboard.on('keydown-SPACE', () => this.confirmSelection());

```


WASD 和方向键都支持,Enter 和 Space 都确认。这是"玩家友好"的最低标准——不要让玩家猜"哪个键能用"。


### 鼠标点击


```ts

this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {

  for (let i = 0; i < this.buttons.length; i++) {

    const btn = this.buttons[i];

    const bg = btn.bg;

    const halfW = bg.width / 2;

    const halfH = bg.height / 2;

    if (

      pointer.x >= bg.x - halfW &&

      pointer.x <= bg.x + halfW &&

      pointer.y >= bg.y - halfH &&

      pointer.y <= bg.y + halfH

    ) {

      this.selectedIndex = i;

      this.updateSelection();

      this.confirmSelection();

      return;

    }

  }

});

```


鼠标点击的碰撞检测是手动写的——没有用 Phaser 的 `setInteractive()`,因为按钮是 `Container`,而 Phaser 的交互系统对 Container 的支持在 3.90 版本里还不稳定。手动检测 `pointer.x` 和 `pointer.y` 是否在按钮的包围盒内,是最可靠的方式。


## 选中态切换


```ts

private updateSelection(): void {

  this.buttons.forEach((btn, i) => {

    const selected = i === this.selectedIndex;

    btn.bg.setFillStyle(selected ? COLORS.NEON_YELLOW : COLORS.DARK_LINE, selected ? 0.25 : 0.6);

    btn.bg.setStrokeStyle(1, selected ? COLORS.NEON_YELLOW : COLORS.NEON_CYAN, selected ? 1 : 0.4);

    btn.text.setColor(selected ? '#FCEE0A' : '#FFFFFF');

  });

}

```


选中态的视觉变化有三层:

1. 背景填充色从暗线色变成霓虹黄

2. 描边从青色变成黄色,线宽从 0.4 变成 1

3. 文字颜色从白色变成霓虹黄


这三层变化叠加在一起,让选中态非常明显——玩家不需要"猜"哪个按钮被选中了。


## 场景切换:淡出 + 重启


```ts

private confirmSelection(): void {

  switch (this.selectedIndex) {

    case 0:

      this.cameras.main.fadeOut(400, 10, 10, 15);

      this.cameras.main.once('camerafadeoutcomplete', () => {

        this.scene.start('GameScene');

      });

      break;

    case 2:

      this.cameras.main.fadeOut(400, 255, 0, 60);

      this.cameras.main.once('camerafadeoutcomplete', () => {

        this.scene.start('MenuScene');

      });

      break;

  }

}

```


"开始游戏"用深蓝 (10,10,15) 淡出,"退出"用红/橙 (255,0,60) 淡出——颜色暗示了"危险"。"选项"目前是空的(`break;` 没有逻辑),这是 TODO 标记。


## 下一章预告


`MenuScene` 讲完了,但菜单里最"花哨"的部分是 `GlitchText` 组件——那个 88px 的 "NEON RUNNER" 标题是怎么做出 RGB 分离故障效果的?下一章我会拆解这个 105 行的组件。


> 菜单设计的核心原则:**视觉层次 > 功能密度**。菜单不是功能列表,是游戏体验的第一印象。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。