读:gamegrid.el——Emacs 内置游戏是怎么写出来的
目录
vannilla.org 上有一个关于 Emacs 内置 gamegrid.el 库的四篇系列教程。Tetris、Snake、Pong 这些 Emacs 内置游戏,背后用的都是这一个核心库。但这个库没有手册,大量函数没有文档,作者是照着源码逆向才搞清楚它怎么工作。本文将四篇教程的内容合并梳理,从零开始讲解 gamegrid.el 的设计思路和用法。
阅读提示 :本文代码分散在不同的讲解段落中,是为说明每个概念而拆开的。文末你会得到一个完整可运行的游戏骨架,但中间阅读时不用急着拼装,先理解每个部分在做什么。
核心概念:gamegrid 能做什么
Gamegrid 库的核心职责是帮游戏处理显示。Emacs 本身是文本编辑器,而且能在纯文本终端里运行,用 Gamegrid 写游戏意味着同一份代码既能在图形 Emacs 里显示图片,也能在终端里用字符代替。
这类游戏有个限制:必须是基于网格的。元素不能重叠,移动也只能是网格大小的整数倍。所以它叫 gamegrid。
除了显示,Gamegrid 还管两件事:一是自动更新游戏状态(内置了游戏循环,你只需要写 update 函数),二是分数管理(提供保存和加载分数的功能)。
搭框架:buffer、mode、keymap
使用 gamegrid 之前,需要先把"舞台"搭好。
Step 1:定义游戏 buffer
大部分 gamegrid 函数需要一个特定的 buffer 才能工作,所以第一步是定义这个 buffer 的名字:
(require 'gamegrid) (defconst my-gamegrid-game-buffer-name "Gamegrid Game" "Name of the game buffer.")
gamegrid 的大部分函数依赖 buffer-local 变量。所谓 buffer-local,就是变量的值只在特定 buffer 里生效,切到其他 buffer 就变回默认值。因为这一点,在调用 gamegrid 更新显示之前,最好检查一下当前 buffer 是不是游戏 buffer。
Step 2:定义启动函数
启动函数是玩家入口,M-x 调用它开始游戏:
(defun my-gamegrid-game () "How to play the game should be placed in the docstring." (interactive) (switch-to-buffer my-gamegrid-game-buffer-name) (gamegrid-kill-timer) (my-gamegrid-game-mode) (my-gamegrid-game-start-game))
做的事包括:切到游戏 buffer,关掉可能残留的旧定时器,激活 major mode,启动新游戏。
gamegrid-kill-timer 的作用是停掉上一次可能还在跑的游戏循环,相当于"所有东西从头来"。
Step 3:定义 major mode
major mode 是 Emacs 里每个 buffer 的"模式身份",它决定这个 buffer 里有哪些快捷键、语法高亮怎么显示、kill buffer 时做哪些清理工作。gamegrid 游戏的 major mode 通常从 special-mode 派生( special-mode 是 Emacs 给只读展示类 buffer 准备的基类):
(define-derived-mode my-gamegrid-game-mode special-mode "Gamegrid Game" "Mode for my Gamegrid Game." (add-hook 'kill-buffer-hook #'gamegrid-kill-timer nil t) (use-local-map my-gamegrid-game-null-map) (gamegrid-init (my-gamegrid-game-display-options)))
做三件事:
add-hook挂了一个钩子:kill buffer 时自动停掉游戏循环。少了这行的话,buffer 关了游戏还在后台跑。use-local-map设置初始快捷键。用的是"空 map",只有"开始新游戏"和"关闭 buffer"两个键。gamegrid-init初始化显示(这个最复杂,放到最后讲)。
Step 4:两套 keymap
Gamegrid 的设计里用了两套快捷键映射:
- 空 map :游戏开始前/结束后使用,只有
n开始新游戏、q关闭 buffer - 游戏 map :游戏中使用的完整操作键
好处是游戏结束(比如"死"了)后,玩家可以选择再来一局或关掉 buffer,而不会不小心触发游戏操作。
空 map 长这样:
(defvar my-gamegrid-game-null-map (let ((map (make-sparse-keymap))) (define-key map (kbd "q") #'bury-buffer) (define-key map (kbd "n") #'my-gamegrid-game-start-game) map) "Gamegrid Game's menu keymap.")
bury-buffer 不关闭 buffer,只是把它从当前窗口移走沉到底部,下次想玩了可以再找出来。
启动游戏与初始化 buffer
启动函数与游戏循环
my-gamegrid-game-start-game 负责切换到"游戏中"状态:
(defconst my-gamegrid-game-tick 0.5 "Time interval between each updates.") (defun my-gamegrid-game-start-game () "Start a new game." (interactive) (unless (string= (buffer-name (current-buffer)) my-gamegrid-game-buffer-name) (error "To start a new game, switch to the game buffer.")) (my-gamegrid-game-reset-game) (use-local-map my-gamegrid-game-mode-map) (gamegrid-start-timer my-gamegrid-game-tick #'my-gamegrid-game-update-game))
做的事情:
- 检查当前 buffer 是不是游戏 buffer,不是就报错(防止在别的 buffer 里误启动)。这属于防御性编程:
n键只在游戏 buffer 的 keymap 里绑了start-game,正常不会触发,但万一有别的代码直接调用了这个函数,至少不会在错误的 buffer 里搞乱游戏状态 my-gamegrid-game-reset-game重置游戏状态use-local-map把快捷键从空 map 换成游戏 map。切换 keymap 是游戏"开始"与"结束"之间的分界线gamegrid-start-timer启动游戏循环
my-gamegrid-game-tick 的值 0.5 表示每 0.5 秒执行一次 update 函数。值越小游戏越快。作为参考,Emacs 内置的 Snake 用的是 0.2 秒。
gamegrid-start-timer 的本质是一个 固定间隔的 Emacs 定时器 。定时器就是"每过一段时间自动调某个函数",这里的 update 函数就是游戏的"心跳"。
重置游戏状态
my-gamegrid-game-reset-game 负责初始化 buffer 并重置状态:
(defun my-gamegrid-game-reset-game () "Reset the game." (gamegrid-kill-timer) (my-gamegrid-game-init-buffer))
先关掉旧定时器,再初始化 buffer。为什么要有独立的 reset 函数而不是直接写在 start-game 里?因为如果重置逻辑很长很复杂(比如一个大型 Roguelike),拆出来更好维护。
初始化 buffer:画网格
Gamegrid 初始化 buffer 时,本质上是定义了一个矩形区域,里面布满网格单元。这跟用 SDL 或 GTK 写游戏时指定窗口尺寸是同样的概念,只是这里的单位不是像素,是"单元格数量"。
下面是初始化函数,画一个四面围墙的空房间(代码中用到的 my-gamegrid-game-player 和 my-gamegrid-game-player-x/y 在后面的"处理输入"章节定义,这里先知道它们代表玩家实体的编号和坐标即可):
(defconst my-gamegrid-game-buffer-width 16 "Width of the game grid in cells.") (defconst my-gamegrid-game-buffer-height 16 "Height of the game grid in cells.") (defconst my-gamegrid-game-empty 0 "Entity ID for empty/uninitialized cells.") (defconst my-gamegrid-game-floor 1 "Entity ID for floor cells.") (defconst my-gamegrid-game-wall 2 "Entity ID for wall cells.") (defun my-gamegrid-game-init-buffer () "Initialize the buffer." (gamegrid-init-buffer my-gamegrid-game-buffer-width my-gamegrid-game-buffer-height my-gamegrid-game-empty) (let ((buffer-read-only nil)) ;; 先把所有格子设为墙 (dotimes (y my-gamegrid-game-buffer-height) (dotimes (x my-gamegrid-game-buffer-width) (gamegrid-set-cell x y my-gamegrid-game-wall))) ;; 再把内部区域镂空为地板 (let ((y 1) (wmax (1- my-gamegrid-game-buffer-width)) (hmax (1- my-gamegrid-game-buffer-height))) (while (< y hmax) (let ((x 1)) (while (< x wmax) (gamegrid-set-cell x y my-gamegrid-game-floor) (setq x (1+ x)))) (setq y (1+ y)))) ;; 在空地上放置玩家 (gamegrid-set-cell my-gamegrid-game-player-x my-gamegrid-game-player-y my-gamegrid-game-player)))
逐段解释:
gamegrid-init-buffer的第一个参数 16 是每行 16 个格子,第二个 16 是每列 16 个格子,第三个参数my-gamegrid-game-empty (0)是"默认填充值",后面会被覆盖掉buffer-read-only用let临时设为nil:major mode 从special-mode派生,而special-mode默认把 buffer 设成只读,不临时解开就没法写入内容- 第一个双重循环用
gamegrid-set-cell把所有格子设为墙(2) - 第二个双重循环从
y=1, x=1到y=14, x=14,把内部区域改成地板(1)。最终效果是外面一圈墙、中间是空地
gamegrid-set-cell 的三个参数是:x 坐标、y 坐标、内容标识(数字)。坐标从 0 开始,(0, 0) 是 buffer 左上角。往坐标传负数或超过初始化尺寸的值会报错。
处理输入:push/pop 解耦模式(重点)
这是整个系列里最有意思的设计。
游戏中的 keymap
首先定义游戏中使用的 keymap:
(defconst my-gamegrid-game-score-file-name "gamegrid-game-scores" "File name for storing high scores.") (defvar my-gamegrid-game-score 0) (defvar my-gamegrid-game-mode-map (let ((map (make-sparse-keymap))) (define-key map (kbd "q") #'my-gamegrid-game-end-game) (define-key map (kbd "n") #'my-gamegrid-game-start-game) (define-key map (kbd "p") #'my-gamegrid-game-pause-game) (define-key map (kbd "a") #'my-gamegrid-game-move-left) (define-key map (kbd "s") #'my-gamegrid-game-move-down) (define-key map (kbd "d") #'my-gamegrid-game-move-right) (define-key map (kbd "w") #'my-gamegrid-game-move-up) map) "The in-game keymap.")
WASD 移动、 p 暂停、 n 开始新游戏、 q 结束游戏。
结束游戏
结束游戏时需要做三件事:停止游戏循环、切换回空 keymap、保存分数:
(defun my-gamegrid-game-end-game () "End the current game." (interactive) (gamegrid-kill-timer) (use-local-map my-gamegrid-game-null-map) (gamegrid-add-score my-gamegrid-game-score-file-name my-gamegrid-game-score))
gamegrid-add-score 把分数写入指定的分数文件。
核心问题:为什么不能直接在按键回调里改游戏状态?
这是理解 Gamegrid 输入处理的关键。
gamegrid-start-timer 底层用的是 run-with-timer ,即非空闲定时器——理想情况下每隔固定时间触发一次。但这有两个问题叠加在一起:
- Emacs 是单线程事件循环,处理按键命令时定时器不会触发。如果你在按键回调里做耗时操作,定时器会被推迟到所有输入处理完才执行。
- 即使回调本身很快,按键自动重复的频率(每秒二三十次)也远高于定时器间隔(0.5 秒才一次)。
结果就是:按住 d 键不松手,角色会在一个更新周期内跳好几个格子——游戏逻辑完全跟着按键频率走,跟原本设好的"每 tick 走一格"的节奏脱节。这在有自动移动物体的游戏里(比如 Pong 的球)尤其糟糕:球的更新跟玩家输入搅在一起,根本无法预测行为。
解决方案:push/pop 解耦
Gamegrid 内置游戏的解决方法是:
- 输入函数只做一件事 :往全局列表里 push 一个操作指令,然后立刻返回
- update 函数 在定时器回调中 pop 这个操作指令,真正执行游戏逻辑
输入函数几微秒就完成(只是往列表塞个 cons),不拖慢 Emacs 的命令循环。
方向移动函数长这样:
(defvar my-gamegrid-game-update-list ()) (defvar my-gamegrid-game-moved nil) (defun my-gamegrid-game-move-left () "Move the player left." (interactive) (unless my-gamegrid-game-moved (push (cons -1 0) my-gamegrid-game-update-list) (setq my-gamegrid-game-moved t))) (defun my-gamegrid-game-move-down () "Move the player down." (interactive) (unless my-gamegrid-game-moved (push (cons 0 1) my-gamegrid-game-update-list) (setq my-gamegrid-game-moved t))) (defun my-gamegrid-game-move-right () "Move the player right." (interactive) (unless my-gamegrid-game-moved (push (cons 1 0) my-gamegrid-game-update-list) (setq my-gamegrid-game-moved t))) (defun my-gamegrid-game-move-up () "Move the player up." (interactive) (unless my-gamegrid-game-moved (push (cons 0 -1) my-gamegrid-game-update-list) (setq my-gamegrid-game-moved t)))
每个方向函数往 my-gamegrid-game-update-list 里 push 一个 (X增量 . Y增量) 的 cons cell。
unless 检查配 setq 的作用是 防止长按 :没有这个保护的话,按住键不放会往列表里塞几十上百个操作,玩家松开键后角色还会自己走半天。加了这个 flag 之后,每个更新周期只接收一次方向输入。等 update 函数消费了这个操作,flag 被重置,才能接收下一次输入。
暂停的实现
暂停的实现只用一个布尔变量:
(defvar my-gamegrid-game-paused nil) (defun my-gamegrid-game-pause-game () "Pause the game." (interactive) (if my-gamegrid-game-paused (setq my-gamegrid-game-paused nil) (setq my-gamegrid-game-paused t)))
my-gamegrid-game-paused 在 update 函数里被检查:如果为 t ,update 函数就不 pop 操作列表,也就不处理输入。输入函数本身仍然可以 push(不过有 moved flag 挡着,实际上 push 不了),但不会被消费。
update 函数
更新函数是整个游戏的核心逻辑。示例游戏的目标很简单:用 WASD 移动一个彩色方块在房间里走,撞到墙就不动:
(defconst my-gamegrid-game-player 3 "Entity ID for the player.") (defvar my-gamegrid-game-player-x 4) (defvar my-gamegrid-game-player-y 5) (defun my-gamegrid-game-update-game (buffer) "Update the game. BUFFER is the buffer in which the function is called." (unless (or my-gamegrid-game-paused (not (string= (buffer-name buffer) my-gamegrid-game-buffer-name)) (null my-gamegrid-game-update-list)) (let ((action (pop my-gamegrid-game-update-list))) (let ((nx (+ my-gamegrid-game-player-x (car action))) (ny (+ my-gamegrid-game-player-y (cdr action)))) (unless (= (gamegrid-get-cell nx ny) my-gamegrid-game-wall) ;; 擦掉旧位置 (gamegrid-set-cell my-gamegrid-game-player-x my-gamegrid-game-player-y my-gamegrid-game-floor) ;; 画上新位置 (gamegrid-set-cell nx ny my-gamegrid-game-player) (setq my-gamegrid-game-player-x nx my-gamegrid-game-player-y ny my-gamegrid-game-moved nil))))))
注意函数签名里的 buffer 参数: gamegrid-start-timer 传的是函数符号 #'my-gamegrid-game-update-game ,gamegrid 内部会自动把当前游戏 buffer 作为第一个参数传进来,不需要你手动传。
逐段解释:
- 最外层的
unless检查三个条件是否 任一 为真,为真就跳过本次更新:- 游戏已暂停
- 当前 buffer 不是游戏 buffer(防止后台误更新)
- 操作列表为空
pop从列表里取出一个 cons,同时从列表中移除- 算出新坐标
nx, ny,用gamegrid-get-cell检查目标位置是不是墙。gamegrid-get-cell返回的就是之前用gamegrid-set-cell设进去的那个数字标识(0/1/2/3),用=直接比,很快 - 不是墙的话:先把旧位置擦成地板,再把新位置画成玩家,最后更新坐标变量,把
moved置nil。这步很关键,moved复位后输入函数才能接收下一个方向操作
显示系统:三层 display options
这是整个 gamegrid.el 最复杂的部分,原作者花了一整篇文章才讲清楚。
实体标识与显示向量
前面用数字 0、1、2、3 标识"空""地板""墙""玩家"。这些数字不光当标签用,它们在显示系统里还是数组索引。Gamegrid 用一个 固定 256 个元素 的向量(Elisp 里的向量就是方括号写的数组 [a b c] )来存储每个实体的显示信息。256 这个数字不能改,也就是说一个 gamegrid 游戏最多有 256 种可显示元素。
把实体编号映射到显示选项的函数:
(defun my-gamegrid-game-display-options () "Return a vector with display informations." (let ((vec (make-vector 256 nil))) (dotimes (c 256) (aset vec c (cond ((= c my-gamegrid-game-empty) my-gamegrid-game-empty-options) ((= c my-gamegrid-game-floor) my-gamegrid-game-floor-options) ((= c my-gamegrid-game-wall) my-gamegrid-game-wall-options) ((= c my-gamegrid-game-player) my-gamegrid-game-player-options) (t '(nil nil nil))))) vec))
索引就是实体编号(0/1/2/3),值是对应的显示选项变量。 t 分支的 '(nil nil nil) 是兜底值,用了无效索引时什么都不显示。
三层结构
每个实体的显示选项是一个 包含三个元素的列表 :
'((第一层) ; 显示形式 (第二层) ; face 配置 (第三层)) ; 颜色配置
逐层来看。
第一层:显示形式
决定实体"长什么样"。里面是一个嵌套列表,可能包含:
| 第一个元素 | 含义 |
|---|---|
t |
用字符显示,第二个元素是字符的编码(如 32 = 空格, ?+ = 加号) |
glyph |
用图片显示,第二个元素是图片规格 |
emacs-tty |
终端模式下覆盖 t 的显示 |
t 列表是"兜底"列表,必须放在最后。如果 Gamegrid 找不到合适的图形方式,就用字符代替。
最简单的例子:空实体(未初始化的格子),就显示一个空格:
(defconst my-gamegrid-game-empty-options '(((t 32)) nil nil) "Display options for empty cells.")
32 是空格字符的 ASCII 码,写成数字比写成 =? = 更直观。
如果想用 gamegrid 内置的 3D 方块图(XPM 格式),要写成:
((glyph colorize) (t 32))
colorize 表示"用默认 XPM 方块图,但可以重新着色"。Emacs 内置的 Tetris、Snake、Pong 用的就是这个技巧:同一张方块图,通过改颜色来区分不同的实体。
墙用 ?+ 号兜底,这样在纯文本终端里至少能看到加号组成的边界。第一层写成:
((glyph colorize) (t ?+))
如果需要自定义图片,把 colorize 换成图片规格列表:
((glyph ((:type xpm :file "my-player.xpm"))) (t ?P))
这会用一个自定义 XPM 文件显示玩家,如果图形不可用就用字符 P 替代。Gamegrid 支持的图片格式跟 Emacs 一样,Emacs 能显示 PNG,Gamegrid 就能用 PNG。
不过原作者提醒:自定义图片是"实验性"功能,他测试时偶尔会出奇怪的结果,简单用的话通常没事。
第二层:face 配置
Emacs 里的 face 决定文字的字体、颜色、粗细等外观属性。当 glyph 不可用时,gamegrid 回退到这一层,用字符 + face 的组合来显示实体。
第二层是嵌套列表,每个子列表都包含两个元素:第一个元素指定"什么时候用",第二个元素指定"用哪个 face"。
什么时候用(四种显示类型,按功能从高到低排列):
| 显示类型 | 环境 |
|---|---|
color-x |
图形界面 + 支持颜色但不支持图片 |
mono-x |
图形界面 + 不支持颜色和图片 |
color-tty |
终端 + 支持颜色 |
mono-tty |
终端 + 单色(实际上几乎永远不会触发,源码实现可能有 bug) |
face 的可选值由 Gamegrid 预设,不能自定义。除了跟显示类型同名的 face( color-x, mono-x, color-tty, mono-tty ),还有一个特殊的 grid-x 给墙之类的东西在单色模式下用。
地板和墙的 face 层实际都长一样:
((color-x color-x) (mono-x mono-x) (color-tty color-tty))
每种显示类型匹配对应的同名 face。 mono-tty 没写是因为前面说的,它几乎永远不会被触发。
第三层:颜色配置
决定实体在支持颜色时的具体颜色。第三个元素也是列表,但结构跟前面不同:它包含 两个子列表 ,分别对应图形环境和终端的颜色规格。
图形环境用 RGB 浮点向量 [红 绿 蓝] ,每个值从 0 到 1:
((glyph color-x) [0 0 0]) ; 纯黑
终端用颜色名字符串(一般是 ANSI 颜色名):
(color-tty "black")
RGB 向量用于给 XPM 方块重新着色。3D 方块的不同面会自动变深或变浅,你只需要给定基准色。
完整的地板和墙显示选项:
(defconst my-gamegrid-game-floor-options '(((glyph colorize) (t 32)) ((color-x color-x) (mono-x mono-x) (color-tty color-tty)) (((glyph color-x) [0 0 0]) (color-tty "black"))) "Display options for floor cells.") (defconst my-gamegrid-game-wall-options '(((glyph colorize) (t ?+)) ((color-x color-x) (mono-x mono-x) (color-tty color-tty)) (((glyph color-x) [0.5 0.5 0.5]) (color-tty "gray"))) "Display options for wall cells.")
地板黑色、墙灰色。终端模式下地板不显示(空格看不见),墙用加号。
玩家实体的完整选项:
(defconst my-gamegrid-game-player-options '(((glyph ((:type xpm :file "my-player.xpm"))) (t ?P)) ((color-x color-x) (mono-x mono-x) (color-tty color-tty)) (((glyph color-x) [0.9 0.3 0.7]) (color-tty "yellow"))) "Display options for the player (using a custom XPM image).")
图形环境用自定义 XPM 图 + 粉红色,终端用黄色字符 P 。
三层结构回顾
Gamegrid 选择实体的显示方式时从上往下尝试:先看第一层有没有合适的显示形式(glyph > emacs-tty > t 字符);没有 glyph 就看第二层的 face;有了 face 再看第三层的颜色。这是一套完整的多级回退机制,让游戏在图形 Emacs 和纯文本终端里都能正常玩。
附录:完整代码组装
以下是按依赖顺序排列的完整代码。新建一个 my-game.el 文件,把下面的内容全部粘贴进去, M-x eval-buffer 后再 M-x my-gamegrid-game 就能开始玩了。
;;; my-game.el --- 一个 Gamegrid 示例游戏 (require 'gamegrid) ;; ── 基础常量 ── (defconst my-gamegrid-game-buffer-name "Gamegrid Game" "Name of the game buffer.") (defconst my-gamegrid-game-buffer-width 16 "Width of the game grid in cells.") (defconst my-gamegrid-game-buffer-height 16 "Height of the game grid in cells.") (defconst my-gamegrid-game-empty 0 "Entity ID for empty/uninitialized cells.") (defconst my-gamegrid-game-floor 1 "Entity ID for floor cells.") (defconst my-gamegrid-game-wall 2 "Entity ID for wall cells.") (defconst my-gamegrid-game-player 3 "Entity ID for the player.") (defconst my-gamegrid-game-tick 0.5) (defconst my-gamegrid-game-score-file-name "gamegrid-game-scores" "File name for storing high scores.") ;; ── 全局变量 ── (defvar my-gamegrid-game-score 0) (defvar my-gamegrid-game-update-list ()) (defvar my-gamegrid-game-moved nil) (defvar my-gamegrid-game-paused nil) (defvar my-gamegrid-game-player-x 4) (defvar my-gamegrid-game-player-y 5) ;; ── 显示选项 ── (defconst my-gamegrid-game-empty-options '(((t 32)) nil nil) "Display options for empty cells.") (defconst my-gamegrid-game-floor-options '(((glyph colorize) (t 32)) ((color-x color-x) (mono-x mono-x) (color-tty color-tty)) (((glyph color-x) [0 0 0]) (color-tty "black"))) "Display options for floor cells.") (defconst my-gamegrid-game-wall-options '(((glyph colorize) (t ?+)) ((color-x color-x) (mono-x mono-x) (color-tty color-tty)) (((glyph color-x) [0.5 0.5 0.5]) (color-tty "gray"))) "Display options for wall cells.") ;; 玩家也用 colorize(默认方块),靠颜色区分。 ;; 正文中展示了如何替换为自定义图片。 (defconst my-gamegrid-game-player-options '(((glyph colorize) (t ?P)) ((color-x color-x) (mono-x mono-x) (color-tty color-tty)) (((glyph color-x) [0.9 0.3 0.7]) (color-tty "yellow"))) "Display options for the player.") ;; ── 显示选项向量 ── (defun my-gamegrid-game-display-options () (let ((vec (make-vector 256 nil))) (dotimes (c 256) (aset vec c (cond ((= c my-gamegrid-game-empty) my-gamegrid-game-empty-options) ((= c my-gamegrid-game-floor) my-gamegrid-game-floor-options) ((= c my-gamegrid-game-wall) my-gamegrid-game-wall-options) ((= c my-gamegrid-game-player) my-gamegrid-game-player-options) (t '(nil nil nil))))) vec)) ;; ── Keymap ── (defvar my-gamegrid-game-null-map (let ((map (make-sparse-keymap))) (define-key map (kbd "q") #'bury-buffer) (define-key map (kbd "n") #'my-gamegrid-game-start-game) map)) (defvar my-gamegrid-game-mode-map (let ((map (make-sparse-keymap))) (define-key map (kbd "q") #'my-gamegrid-game-end-game) (define-key map (kbd "n") #'my-gamegrid-game-start-game) (define-key map (kbd "p") #'my-gamegrid-game-pause-game) (define-key map (kbd "a") #'my-gamegrid-game-move-left) (define-key map (kbd "s") #'my-gamegrid-game-move-down) (define-key map (kbd "d") #'my-gamegrid-game-move-right) (define-key map (kbd "w") #'my-gamegrid-game-move-up) map)) ;; ── 输入函数 ── (defun my-gamegrid-game-move-left () (interactive) (unless my-gamegrid-game-moved (push (cons -1 0) my-gamegrid-game-update-list) (setq my-gamegrid-game-moved t))) (defun my-gamegrid-game-move-down () (interactive) (unless my-gamegrid-game-moved (push (cons 0 1) my-gamegrid-game-update-list) (setq my-gamegrid-game-moved t))) (defun my-gamegrid-game-move-right () (interactive) (unless my-gamegrid-game-moved (push (cons 1 0) my-gamegrid-game-update-list) (setq my-gamegrid-game-moved t))) (defun my-gamegrid-game-move-up () (interactive) (unless my-gamegrid-game-moved (push (cons 0 -1) my-gamegrid-game-update-list) (setq my-gamegrid-game-moved t))) (defun my-gamegrid-game-pause-game () (interactive) (if my-gamegrid-game-paused (setq my-gamegrid-game-paused nil) (setq my-gamegrid-game-paused t))) (defun my-gamegrid-game-end-game () (interactive) (gamegrid-kill-timer) (use-local-map my-gamegrid-game-null-map) (gamegrid-add-score my-gamegrid-game-score-file-name my-gamegrid-game-score)) ;; ── Buffer 初始化 ── (defun my-gamegrid-game-init-buffer () (gamegrid-init-buffer my-gamegrid-game-buffer-width my-gamegrid-game-buffer-height my-gamegrid-game-empty) (let ((buffer-read-only nil)) (dotimes (y my-gamegrid-game-buffer-height) (dotimes (x my-gamegrid-game-buffer-width) (gamegrid-set-cell x y my-gamegrid-game-wall))) (let ((y 1) (wmax (1- my-gamegrid-game-buffer-width)) (hmax (1- my-gamegrid-game-buffer-height))) (while (< y hmax) (let ((x 1)) (while (< x wmax) (gamegrid-set-cell x y my-gamegrid-game-floor) (setq x (1+ x)))) (setq y (1+ y)))) (gamegrid-set-cell my-gamegrid-game-player-x my-gamegrid-game-player-y my-gamegrid-game-player))) ;; ── 游戏循环 ── (defun my-gamegrid-game-reset-game () (gamegrid-kill-timer) (my-gamegrid-game-init-buffer)) (defun my-gamegrid-game-update-game (buffer) (unless (or my-gamegrid-game-paused (not (string= (buffer-name buffer) my-gamegrid-game-buffer-name)) (null my-gamegrid-game-update-list)) (let ((action (pop my-gamegrid-game-update-list))) (let ((nx (+ my-gamegrid-game-player-x (car action))) (ny (+ my-gamegrid-game-player-y (cdr action)))) (unless (= (gamegrid-get-cell nx ny) my-gamegrid-game-wall) (gamegrid-set-cell my-gamegrid-game-player-x my-gamegrid-game-player-y my-gamegrid-game-floor) (gamegrid-set-cell nx ny my-gamegrid-game-player) (setq my-gamegrid-game-player-x nx my-gamegrid-game-player-y ny my-gamegrid-game-moved nil)))))) (defun my-gamegrid-game-start-game () (interactive) (unless (string= (buffer-name (current-buffer)) my-gamegrid-game-buffer-name) (error "To start a new game, switch to the game buffer.")) (my-gamegrid-game-reset-game) (use-local-map my-gamegrid-game-mode-map) (gamegrid-start-timer my-gamegrid-game-tick #'my-gamegrid-game-update-game)) ;; ── Major Mode ── (define-derived-mode my-gamegrid-game-mode special-mode "Gamegrid Game" (add-hook 'kill-buffer-hook #'gamegrid-kill-timer nil t) (use-local-map my-gamegrid-game-null-map) (gamegrid-init (my-gamegrid-game-display-options))) ;; ── 入口 ── (defun my-gamegrid-game () (interactive) (switch-to-buffer my-gamegrid-game-buffer-name) (gamegrid-kill-timer) (my-gamegrid-game-mode) (my-gamegrid-game-start-game))