谢夏戈 @ xiexiage.com

设置菜单

Sep 7

菜单场景和游戏场景是两个世界,玩家在菜单里选的"穿墙",游戏怎么知道?直觉是"菜单把值传给游戏"。但 change_scene_to_file先销毁菜单,再创建游戏——游戏出生那一刻,菜单已经不在了,没人可传。

答案:放到一个"两边都在的时候它都在"的地方——Autoload,也就是 GameManager。菜单往里写,游戏从里读。这就是"单一数据源":以后任何地方想知道"现在是穿墙模式吗",都只问 GameManager 一个人。

GameManager 加三个字段

## ===== 开局设置:菜单写、游戏读,只存这一份 =====
var wrap_mode := false                 # true = 穿墙,false = 撞墙死(菜单里选)
var cell_px := 20                      # 每格像素,本版固定 20 → 96×54 格
var snake_color := Color("#63de8c")    # 蛇的皮肤,本版固定

后两个这一版菜单里根本不露出来,为什么也放进 GameManager?因为它们是"设置",只是这版不让玩家改。放在这,游戏代码读的是"设置"而不是写死的数字;以后想让玩家选棋盘大小、选皮肤,菜单一露,游戏一字不改。先按设置写,露不露是菜单的事。

阶段 4 的菜单先用最丑的控件

scenes/menu.tscn,然后文件系统里右键它 → 设为主场景

Menu (Node2D)                          ← 挂脚本 scripts/menu.gd
└── CanvasLayer
    └── VBoxContainer
        ├── Title (Label)
        ├── WrapToggle (CheckButton)   ← "穿墙模式"
        └── StartButton (Button)
extends Node2D

@onready var wrap_toggle: CheckButton = $CanvasLayer/VBoxContainer/WrapToggle

func _ready() -> void:
    wrap_toggle.grab_focus()                        # 手柄:一进菜单就得有一个控件亮着

func _on_start_button_pressed() -> void:
    GameManager.wrap_mode = wrap_toggle.button_pressed     # 勾上 = 穿墙
    get_tree().change_scene_to_file("res://scenes/main.tscn")

为什么用最丑的 CheckButton?外壳阶段先要"能用":能选、能传、能进游戏。好不好看是第 10 章的事,那时候换控件只动菜单,GameManager 和游戏代码不动——这就是把设置放 GameManager 的回报。

主场景是按 F5(以及玩家双击游戏图标)时第一个加载的场景。之前设成 main.tscn 只是为了能按 F5;现在入口变成"先菜单、再游戏",入口变了,主场景就得跟着变。不改的话 F5 直接进游戏、跳过菜单。

常量什么时候该变成变量

第 3 章说 GRID_W 是常量"定了不能改",现在它要跟着设置走,怎么办?它的意思一直是"游戏运行中不能改"。现在格子大小是一项设置、穿墙是玩家开局前选的——都是"开局读一次的变量",不再是常量。判断标准:这个值会因为设置或玩家的选择而不同吗?会 → 变量;永远一样 → 常量。

main.gd 顶部:

const BOARD_W := 1920                  # 画面宽,和项目设置的视口一致
const BOARD_H := 1080
const START_LENGTH := 3                # 蛇初始几节
var grid_w := 96                       # 原来是 const GRID_W
var grid_h := 54
var cell_size := 20
var snake_body: Array[Vector2i] = []   # 第 0 个是头,_ready() 里生成

_ready() 里:

    # 先有蛇,再生世界:食物要避开蛇出生的格子
    cell_size = GameManager.cell_px
    grid_w = BOARD_W / cell_size
    grid_h = BOARD_H / cell_size

    var mid := Vector2i(grid_w / 2, grid_h / 2)                    # 蛇从正中间出发
    for i in START_LENGTH:
        snake_body.append(Vector2i(mid.x - i, mid.y))   # i=0 是头,往左一节节排

    _on_food_timer_timeout()                # 开局先长一个食物(避开蛇)
    redraw()

用循环生成初始蛇身,而不是手写三个坐标——以后想改成 5 节,改一个数。

一行实现穿墙

    if GameManager.wrap_mode:
        new_head.x = posmod(new_head.x, grid_w)   # -1 → 95,96 → 0
        new_head.y = posmod(new_head.y, grid_h)
    elif new_head.x < 0 or new_head.x >= grid_w \
    or new_head.y < 0 or new_head.y >= grid_h:
        EventBus.snake_died.emit()
        return

posmod:positive modulo,正取余。普通 % 对负数给负结果(-1 % 20 = -1),posmod(-1, 20) = 19。从左边 x = -1 出去,要从最右边 x = 95 进来——正好是 posmod 的答案。周期性的东西用取余。

验证"设置"这条线通了:勾穿墙 → 蛇从左边出去右边进来;再把 cell_px 临时改成 12 → F5 → 格子变小蛇变小(改回 20)。菜单露不露,游戏都读得到。

07-settings-穿墙

下一章:暂停菜单——你第一次做的外壳,也是坑最多的一个。

2023-PRESENT © 谢夏戈