惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

有赞技术团队
有赞技术团队
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
aimingoo的专栏
aimingoo的专栏
IT之家
IT之家
G
Google Developers Blog
爱范儿
爱范儿
博客园 - 司徒正美
Recent Announcements
Recent Announcements
The Register - Security
The Register - Security
J
Java Code Geeks
The Cloudflare Blog
M
MIT News - Artificial intelligence
Apple Machine Learning Research
Apple Machine Learning Research
Microsoft Security Blog
Microsoft Security Blog
博客园 - Franky
雷峰网
雷峰网
酷 壳 – CoolShell
酷 壳 – CoolShell
Blog — PlanetScale
Blog — PlanetScale
Vercel News
Vercel News
宝玉的分享
宝玉的分享
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
B
Blog
小众软件
小众软件
Microsoft Azure Blog
Microsoft Azure Blog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
WordPress大学
WordPress大学
T
Troy Hunt's Blog
Application and Cybersecurity Blog
Application and Cybersecurity Blog
H
Hacker News: Front Page
H
Help Net Security
S
Security @ Cisco Blogs
V
V2EX
Security Archives - TechRepublic
Security Archives - TechRepublic
Stack Overflow Blog
Stack Overflow Blog
O
OpenAI News
L
LINUX DO - 最新话题
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
S
Secure Thoughts
Help Net Security
Help Net Security
F
Full Disclosure
博客园 - 叶小钗
The Hacker News
The Hacker News
Spread Privacy
Spread Privacy
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Jina AI
Jina AI
K
Kaspersky official blog
www.infosecurity-magazine.com
www.infosecurity-magazine.com
V
Vulnerabilities – Threatpost
P
Privacy International News Feed
Scott Helme
Scott Helme

暗无天日

读:AI Agent 安全日志——从可见性与隐私的两难说起 - 暗无天日 AI写作的语言指纹——如何让文字不那么像机器 - 暗无天日 读:50 条 Claude Code 技巧——一个工程经理的六个月使用心得 读:AI 辅助开发为什么让 E2E 测试更有价值 - 暗无天日 读:在Emacs中使用Claude Code(Spacemacs适配版) - 暗无天日 Claude Code 背后的工程哲学——读 Agent Harness Engineering 读:Agent Harness Engineering——AI 智能体不只是模型,还有套件 - 暗无天日 browser-harness:让 AI 直接接管你的浏览器 - 暗无天日 读:Security-First CI/CD —— DevSecOps 自动化实践指南 TIL: 数字小键盘的小数点陷阱与行内算术求值 - 暗无天日 读:Immutability 不是万能药,它是一种权衡 - 暗无天日 Conducty:给 Claude Code 加上项目记忆和并行执行能力 - 暗无天日 读 — GitHub Trending 里的 Claude Code 技能包 读 — Prompt Caching 省钱指南 TIL: Emacs 中那些跟鼠标配合的冷门快捷键 - 暗无天日 读:Anvil——把 Emacs 变成 AI 的工具服务器 读:Emacs 代码折叠终极指南 - 暗无天日 读:Clojure 搭车客指南 - 暗无天日 git推送失败后恢复仓库损坏的完整记录 - 暗无天日 多智能体系统的两个有效模式——以及对 Claude Code 用户的启示 - 暗无天日 用 Org Babel 写 Literate 博文:扩展执行 + 定制导出 proced:Emacs 内置的进程查看器 - 暗无天日 从 proced 定制中学到的 Elisp 模式 读:让 Emacs proced 在 macOS 上显示 CPU 和内存 异步编程的函数着色税 - 暗无天日 链式调用的代价:JavaScript 和 Clojure 的共同教训 - 暗无天日 hyperfine:命令行基准测试工具 - 暗无天日 管道中的变量去哪了?——子 shell 作用域陷阱 - 暗无天日 开源包装器的信任陷阱:四个危险信号 - 暗无天日 程序员愿意为 AI 写文档,却不愿为同事写 - 暗无天日 mktemp: Shell 脚本中临时文件的安全陷阱与最佳实践 - 暗无天日 WSL9x —— 在 Windows 9x 里跑 Linux 内核 6.19 用 ox.el 做你想做的事 —— org-export 高级编程指南 读:Hot-wiring the Lisp Machine —— 用纯 Elisp 构建零依赖的 Org 静态站点生成器 Elisp 性能优化的六个实战教训 - 暗无天日 fcitx5 下 Emacs 无法切换输入法的排查 - 暗无天日 ERT 测试交互命令的三种方式 - 暗无天日 SEM Assistant: 当 Elisp 守护进程遇上 LLM 用 dmsg 给 Elisp 加上结构化调试日志 用 org-habit 追踪非每日习惯 - 暗无天日 Clojure X-Men:当编程语言特性变成超能力 - 暗无天日 TIL: 用 diff-hl 在 fringe 中显示 git 变更 读:llm-test —— 用 LLM agent 驱动 Emacs 测试 TIL: AI 时代的橡皮鸭调试 - 暗无天日 fcitx 启动后键盘输入卡顿的排查 - 暗无天日 TIL: 早期网页的图片热区导航 - 暗无天日 读 Seeing the Whole System 用 Emacs 自动生成每周链接推荐 - 暗无天日 读:ASCII control characters in my terminal 读 What to learn - 暗无天日 Lisp 的括号之痛——一个愚人节玩笑揭开的老伤疤 - 暗无天日 一本书该"线性读"还是"并行读" - 暗无天日 读 How to Monetize a Blog:一篇伪装成变现指南的讽刺文 Python Mock 第三方依赖的四种策略 - 暗无天日 Emacs Lisp 热重载实用指南 - 暗无天日 Prot 的 Emacs 配置哲学 - 暗无天日 TIL: 从直播对谈中学到的三个 Emacs 技巧 - 暗无天日 TIL: 自动使用项目虚拟环境的 Python - 暗无天日 TIL: 让 Help buffer 自动获得焦点 一条命令让本地开发用上 HTTPS —— slim 工具介绍 用 fsck 检查和修复 Linux 文件系统 排查Linux进程"卡死"实战:从strace到gdb全流程 - 暗无天日 PostgreSQL 索引:从基础到你可能不知道的高级用法 - 暗无天日 用 .pdbrc 自定义 Python 调试器 ANSI 转义码的标准化现状 - 暗无天日 终端程序的潜规则 - 暗无天日 PARA Org-mode 测试配置 - 暗无天日 AI越强越辣鸡?控制论说这是必然的 - 暗无天日 AI 越强越需要你盯着——反馈循环实操指南 - 暗无天日 你的AI代理正在偷你的密钥——四种你没想到的泄露通道 - 暗无天日 LLM 在 DevOps 中的三种角色 - 暗无天日 写作风格的反建议 - 暗无天日 反驳本质复杂性——Dan Luu 论为什么《没有银弹》错了 - 暗无天日 文件充满了危险——Dan Luu 谈文件系统的可靠性陷阱 - 暗无天日 AI 时代的 PARA 方法:用 Org-mode 和 AI 打造个人知识管理系统 Linux 数据去重学习笔记 - 暗无天日 创建跨平台 ZIP 文件的隐藏陷阱:Extra Field - 暗无天日 X11 Forwarding 排障指南 - 暗无天日 IP欺骗端口扫描:当别人冒充你去扫描别人 - 暗无天日 Linux 输入栈全景解析:从硬件按键到屏幕响应 - 暗无天日 Unix 系统中那些被埋没的配置开关——以 FontConfig 为例 - 暗无天日 在Linux上限制儿童使用电脑 - 暗无天日 GIF不仅仅是一种图片格式——用GIF流做些奇怪的事 - 暗无天日 Leiningen 学习笔记:Clojure 项目构建与管理从入门到实战配置 - 暗无天日 Google SRE Book 读书笔记 - 暗无天日 yes 管道 head 发生了什么 - 暗无天日 为什么 nohup 在 crontab 中不起作用 Bash中的Indirection与Nameref - 暗无天日 Linux PAM 简介 - 暗无天日 从Linux ISO文件启动计算机 - 暗无天日 用 Bash 打造一个Screen Locker 用GitHub Actions自动构建EGO博客 - 暗无天日 blocking I/O 的作用 - 暗无天日 mobileog 手机端同步提示Error:2 No such file 的解决方法 回收 WSL2 VHDX 文件占用空间 使用 org-mode columnview 生成任务列表 - 暗无天日 Emacs 作为 MPD 客户端 - 暗无天日 移动文件路径却不破坏org file link的方法 - 暗无天日 如何合理的导出help link 成HTML - 暗无天日 笑话理解之Biology - 暗无天日
读:gamegrid.el——Emacs 内置游戏是怎么写出来的 - 暗无天日
2026-05-18 · via 暗无天日

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)))

做三件事:

  1. add-hook 挂了一个钩子:kill buffer 时自动停掉游戏循环。少了这行的话,buffer 关了游戏还在后台跑。
  2. use-local-map 设置初始快捷键。用的是"空 map",只有"开始新游戏"和"关闭 buffer"两个键。
  3. 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))

做的事情:

  1. 检查当前 buffer 是不是游戏 buffer,不是就报错(防止在别的 buffer 里误启动)。这属于防御性编程: n 键只在游戏 buffer 的 keymap 里绑了 start-game ,正常不会触发,但万一有别的代码直接调用了这个函数,至少不会在错误的 buffer 里搞乱游戏状态
  2. my-gamegrid-game-reset-game 重置游戏状态
  3. use-local-map 把快捷键从空 map 换成游戏 map。切换 keymap 是游戏"开始"与"结束"之间的分界线
  4. 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-playermy-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-onlylet 临时设为 nil :major mode 从 special-mode 派生,而 special-mode 默认把 buffer 设成只读,不临时解开就没法写入内容
  • 第一个双重循环用 gamegrid-set-cell 把所有格子设为墙( 2
  • 第二个双重循环从 y=1, x=1y=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 ,即非空闲定时器——理想情况下每隔固定时间触发一次。但这有两个问题叠加在一起:

  1. Emacs 是单线程事件循环,处理按键命令时定时器不会触发。如果你在按键回调里做耗时操作,定时器会被推迟到所有输入处理完才执行。
  2. 即使回调本身很快,按键自动重复的频率(每秒二三十次)也远高于定时器间隔(0.5 秒才一次)。

结果就是:按住 d 键不松手,角色会在一个更新周期内跳好几个格子——游戏逻辑完全跟着按键频率走,跟原本设好的"每 tick 走一格"的节奏脱节。这在有自动移动物体的游戏里(比如 Pong 的球)尤其糟糕:球的更新跟玩家输入搅在一起,根本无法预测行为。

解决方案:push/pop 解耦

Gamegrid 内置游戏的解决方法是:

  1. 输入函数只做一件事 :往全局列表里 push 一个操作指令,然后立刻返回
  2. 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 作为第一个参数传进来,不需要你手动传。

逐段解释:

  1. 最外层的 unless 检查三个条件是否 任一 为真,为真就跳过本次更新:
    • 游戏已暂停
    • 当前 buffer 不是游戏 buffer(防止后台误更新)
    • 操作列表为空
  2. pop 从列表里取出一个 cons,同时从列表中移除
  3. 算出新坐标 nx, ny ,用 gamegrid-get-cell 检查目标位置是不是墙。 gamegrid-get-cell 返回的就是之前用 gamegrid-set-cell 设进去的那个数字标识(0/1/2/3),用 = 直接比,很快
  4. 不是墙的话:先把旧位置擦成地板,再把新位置画成玩家,最后更新坐标变量,把 movednil 。这步很关键, 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) 是兜底值,用了无效索引时什么都不显示。

三层结构

每个实体的显示选项是一个 包含三个元素的列表

'((第一层)     (第二层)     (第三层))  

逐层来看。

第一层:显示形式

决定实体"长什么样"。里面是一个嵌套列表,可能包含:

第一个元素 含义
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 就能开始玩了。

(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.")

(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))


(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))


(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))


(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))

收尾

至此,gamegrid.el 的四个核心模块都讲完了:搭框架、初始化、输入处理、显示系统。Emacs 不只是编辑器,还是一个有自己图形系统的游戏引擎。

本文代码基于 vannilla.org 四篇系列教程整合:Part 1Part 2Part 3Part 4