资讯动态

Pico-8游戏开发实战:用Lua构建128x128像素复古游戏

发布时间:2026/10/18 5:55:13 来源:尧图企业网站定制
简介这是一份基于PICO-8幻想游戏机的Lua创作合集内含多个小游戏、动画演示与技术实验适合PICO-8玩家、独立游戏开发初学者和复古像素美术爱好者既可作为灵感库也能用于学习Lua编码、像素动画和复古游戏设计。压缩包共121个文件约4.9MB核心是44个p8工程文件另有34张png图片、12组js/html演示、4个gif动图、3个ase绘图源文件以及mid音乐与md说明等p8工程可随意修改png/ase适合查看美术素材gif可快速预览效果html/js方便网页演示。作品涵盖邦戈猫模因动画、康威生命游戏编辑器、蜜蜂采蜜原型、受限字数下的Duck Hunt翻版、经典游戏标题曲编曲以及用二十面体练习3D图形理论的尝试还包含若干小涂鸦和未完成原型。透过这些工程可以理解PICO-8如何用极简方式整合代码、图形、音乐和交互逻辑也能借鉴小体量游戏的玩法拆解与资源受限下的实现思路其中生命游戏编辑器和3D图形尝试尤其适合研究事件循环、二维数组及简单投影算法。已有1941人学习下载。1. Pico-8 创作合集用 Lua 在 128x128 像素里做游戏在 128x128 分辨率和 16 色调色板的限制下做游戏听起来像上个时代的玩法但 Pico-8 这种 fantasy console 恰恰用硬规格换来了创作自由。这份「Pico-8 创作合集」装着我在这台虚拟游戏机上写下的几个可玩游戏一个平台跳跃、一个太空射击、外加一个音序器 demo全部用 Lua 写成。游戏逻辑、精灵图、音效、地图数据都挤在同一个.p8卡带文件里没有外部素材包没有引擎依赖。它在官方编辑器里直接运行适合想快速验证玩法原型的人也适合想搞懂帧循环游戏如何工作的开发者。老手可以改改核心系统新手照着跑一遍半小时就能做出自己的第一个小游戏。2. 架构与工程结构一个 .p8 卡带文件里的完整游戏2.1 硬性规格就是设计语言16 色、4 声道、32KB 代码空间Pico-8 的很多设计决策都源于那 32KB 的 Lua 代码空间和 32KB 运行内存。写惯了 Web 游戏的人第一次接触它最容易犯的错是拿浏览器的内存观念来写代码——每帧 new 一个 table、随手塞一堆匿名函数、字符串拼接当调试日志用。跑不了几个精灵就开始掉帧然后一脸懵。这个项目的每段代码几乎都在规避这类写法复用对象、用整数坐标、分组更新逻辑。具体规格落在数字上屏幕 128x128调色板 16 色编号 0 到 15音频 4 个声道可同时播放 4 个波形通道。颜色 0 到 15 不是 RGB 值而是固定色板索引这意味着绘图的每个参数都像在跟一个极简设计系统对话。色板 7 是白、8 是红、12 是蓝写print(..., 7)就知道是白字根本不用查色值。这种硬编码风格看似偷懒但对像素游戏来说反而高效全世界的 Pico-8 游戏都以 0 到 15 作为约定俗成的沟通语言。地图数据也有限制128x32 的瓦片网格每个瓦片对应精灵区 0 到 255 中的一张 8x8 精灵。关卡手工置放内存非常小。这个项目的所有关卡都控制在 128x32 以内超过一半的瓦片甚至没用到。这不是没素材可用而是因为 Pico-8 的map区就这么大你只能在边界内做文章。限制反倒让每张地图都要精算重复使用率砖块、平台、装饰物尽量复用同几张精灵。2.2 卡带文件解剖__lua、__gfx、__sfx、__music、__map.p8文件是一个带标记的纯文本文件不是二进制的。用任意文本编辑器打开你会看到五个区块每个区块前有一行双下划线标签。游戏代码、精灵数据、音效定义、音乐序列、地图布局分门别类躺在里面__lua function _init() player {x64, y100, vx0, vy0, on_groundfalse} end function _update() -- 每帧更新逻辑默认 30fps end __gfx 0000000000000000000000000000000000000000 ... __sfx ... __music ... __map 00000000000000...__lua段保存 Lua 源码最多约 32KB__gfx段是 128x128 的像素阵列用十六进制字符0123456789abcdef表示颜色索引每行 128 个字符正好是一行像素__sfx段定义了音效编辑器里的 64 个音效槽包含音高、音长、音量包络等参数__music段保存 64 个音乐槽每个槽最多编排 4 个音效通道对应 4 声道的播放能力最后__map段保存地图瓦片128 列 x 32 行每个字节是 0 到 255 的精灵编号。理解了这个结构你就知道精灵、音效、地图在磁盘上不过是几个文本块。所以用脚本去批量生成地图、批量生成音效都是可行的项目里的音序器 demo 就是这么干的——在运行时用mset()写地图、用sfx()串音序把代码数据化。2.3 项目文件清单与开发流这份合集的文件组织很简单我在目录下只放了三类文件文件/目录作用关键点platformer.p8平台跳跃游戏主卡带含完整代码、精灵、地图shooter.p8太空射击游戏主卡带演示对象池与子弹复用sequencer.p8音序器 demo演示运行时写 sfx 与 musicshare/导出的 HTML 与桌面版可直接双击运行README.md每个游戏的操作与改法说明记录按键映射与核心函数位置拿到手先跑通第二个文件也就是shooter.p8。它在官方编辑器打开后按CtrlR运行代码最完整方便顺着函数跳转。运行起来后你应该看到玩家在底部用左右方向键移动发射键打出一串子弹敌人从顶部成群压下来。每帧的子弹和敌人都存在固定大小的数组里死了的立刻回收不会产生垃圾回收卡顿——这正是后面第三章节要拆的主线。开发流上Pico-8 自带精灵编辑器和音效编辑器所有素材都在编辑器里直接绘制保存时整包写同一个.p8文件。版本管理也简单Git 可以直接 diff 代码段精灵和地图变更则表现为大段文本变化。我建议把.p8当作文本文件交给 Git不要另存二进制。3. 核心模块拆解一个复古游戏从输入到渲染的完整链路3.1 帧循环与输入_update60 与 btn/btnp 的组合哲学Pico-8 默认以 30fps 跑_update()如果你想让手感更顺滑就定义_update60()游戏切换到 60fps 模式。平台跳跃这类对输入延迟敏感的游戏我直接定义_update60()所有物理常量按每帧 60 分之一秒来调跳跃初速、重力、敌人速度都按 60fps 标定。输入读取就两个函数btn(n)返回当前帧按住状态btnp(n)返回本帧刚被按下的瞬间。二者差异巨大按钮编号固定0 左、1 右、2 上、3 下、4 跳/确认、5 射击/取消。项目里平台跳跃的输入逻辑浓缩成下面这段function _update60() local left, right btn(0), btn(1) local jump btnp(4) -- 按下瞬间为 true local shoot btnp(5) if jump and player.on_ground then player.vy -5.2 player.on_ground false sfx(3, 2) -- 音效槽 3声道 2 end player.vx (right and 1 or 0) - (left and 1 or 0) player.vx * 1.8 endbtn(0)和btn(1)是持续按住的状态用来控制水平速度btnp(4)只在按下的那一帧返回 true配合地面状态判断保证不会在空中多跳一次。sfx(3, 2)里的参数一是音效槽编号参数二是声道编号Pico-8 支持 0 到 3 四个声道同时播放。跳跃初速 -5.2 是因为 y 轴向下为正负值代表向上这个数字在 60fps 下能稳定越过两格高的平台调太快会觉得飘调太小跳不上台子。翻车点在这儿如果你把btnp()放进_draw()里用会因为绘制循环和更新循环的调用时机不一致导致某些按键事件被漏掉。所有输入读取必须放到_update()或_update60()里_draw()只负责画不读按钮。3.2 地图与相机spr、map、camera 的坐标换算地图绘制用的是map()函数一次调用就能把整块瓦片区画上屏。但 128x128 的屏幕显示不下 128x32 的地图所以要引入相机概念。Pico-8 的camera(x, y)把绘制坐标系平移让玩家的位置始终位于屏幕中央附近function _draw() cls(1) -- 相机跟随玩家clamp 到地图边界 local cam_x mid(0, player.x - 64, 128 * 8 - 128) local cam_y mid(0, player.y - 64, 32 * 8 - 128) camera(cam_x, cam_y) -- 从地图(0,0)开始绘制 16x16 个瓦片到屏幕(0,0) map(0, 0, 0, 0, 16, 16) spr(1, player.x, player.y) camera() endmap()的六个参数依次是地图起点 x、起点 y、绘制起点屏幕 x、屏幕 y、要画多少列瓦片、多少行瓦片。camera(x, y)后的map()会把地图和精灵都平移到相机坐标系中最后用无参camera()复位以免影响 HUD 绘制。这里mid(a, b, c)是 Pico-8 内置的 clamp 函数返回三者中间值作用是把相机限制在地图边界内。精灵绘制用spr(tile, x, y)tile 是从 0 到 255 的精灵编号。注意 Pico-8 的精灵编号是按 8x8 格子排布的第一行是 0 到 15因为 128/816 个精灵每行第二行是 16 到 31。我的精灵表里 1 号是玩家第 1 帧2 号是玩家第 2 帧两帧交替播放就是走路动画。动画可以用t()全局时钟驱动local frame 1 (flr(t()/4) % 2)分割成一个每 4 帧切换一次图案的循环适合走路动画。3.3 碰撞检测与平台逻辑先横后纵的移动分解刚接触 Pico-8 的开发者容易把碰撞做成“移动后检测重叠”结果会产生钻墙、卡角、甚至直接穿过平台。这个项目的平台跳跃里移动被拆成水平和垂直两步每步移动后都单独做瓦片碰撞并且只回退对应轴function move_entity(e) -- 水平移动 local prev_x e.x e.x e.vx if collide_tile(e) then e.x prev_x -- 撞墙回退到移动前 e.vx 0 end -- 垂直移动 e.y e.vy if collide_tile(e) then if e.vy 0 then e.y flr((e.y e.h) / 8) * 8 - e.h -- 脚部对齐平台 e.on_ground true else e.y (flr(e.y / 8) 1) * 8 -- 头部顶到上方平台 e.vy 0 end end e.vy min(e.vy 0.35, 3.2) -- 重力限制最大下落速度 end先动 x 再动 y是 2D 像素游戏规避斜角卡墙的经典技巧。每一轴碰撞后只回退该轴位置就不会因为对角线移动同时触发两个轴的回退而出现“投掷”或“抖动”。e.on_ground在落地瞬间标记为 true供跳跃逻辑判断注意落地判定必须在 y 轴修正之后设置否则悬空时也会错误地认为着地。重力系数 0.35 和最大下落速度 3.2 是配合 60fps 调的数值越大下落越猛但手感越硬需要反复试。碰撞检测函数的核心是把实体的包围盒四角映射到瓦片坐标查mget(tx, ty)返回的瓦片编号非 0 就算撞function collide_tile(e) -- e.x, e.y 是左上角e.w, e.h 是宽高 local x0 flr(e.x / 8) local y0 flr(e.y / 8) local x1 flr((e.x e.w - 1) / 8) local y1 flr((e.y e.h - 1) / 8) for ty y0, y1 do for tx x0, x1 do if mget(tx, ty) ~ 0 then return true end end end return false endmget(tx, ty)从内存取地图瓦片返回 0 表示空格非 0 表示有碰撞。四角遍历的写法对小实体很稳但物体速度太快时会撞穿薄墙这就是第 4 章会重点讲的坑位移量大于瓦片尺寸时单次检测会漏掉中间格。3.4 敌人 AI 与对象池让世界自己动起来还不掉帧太空射击的敌人 AI 是最典型的“追尾式”逻辑敌人往玩家方向施加速度但限制最大速率碰到屏幕边缘就转头防止全部聚在一点。敌人的生命周期用一个简单的状态字段管理state0是待机入场state1是追踪state2是死亡。状态切换本身就等价于 AI 的决策树。为了避免每帧创建新 table 导致的 GC 卡顿敌人和子弹都放在固定数组里用active标记实现对象池。子弹发射和回收的代码是这套系统的心脏function fire_bullet(x, y, vx, vy) for i 1, MAX_BULLETS do local b bullets[i] if b.active false then b.active true b.x, b.y, b.vx, b.vy x, y, vx, vy b.hp 1 return i end end end function update_bullets() for i 1, MAX_BULLETS do local b bullets[i] if b.active then b.x b.vx b.y b.vy if b.y -8 or b.y 136 or b.hp 0 then b.active false end end end endbullets在_init()里一次性创建为MAX_BULLETS个固定 table游戏过程中只修改字段、不新建 table。出屏或者击中目标就把active置 false下次发射直接复用。这样内存占用是恒定的Pico-8 的垃圾回收器几乎无事可做敌人再多也不掉帧。音频方面平台跳跃的跳和落地音效各占一个音效槽音乐则由music(0)开启循环播放音序器 demo 更暴力一点直接在 update 里按节拍调sfx(i, ch)把音效槽当乐器采样来用。四声道在混音时互不干扰一个放旋律、一个放打击、一个放低音剩下的留给音效。sfx()第二个参数传声道编号 0 到 3超出范围会静音这是个调试时很容易忽略的玄学问题。4. 避坑手记Pico-8 开发中我踩过的四个坑4.1 跳跃键按一下跳两次想要连跳却跳不了现象是玩家按一次跳跃键有时起跳、有时不起跳甚至在空中抖一下反过来把jump btnp(4)改成btn(4)后按住跳跃键就连跳完全没法玩。原因是btnp()的“按下瞬间”是按帧状态比较实现的如果和物理逻辑分开放在不同回调里比如在_update60()里读输入、在_draw()末尾做跳跃运算两个回调执行顺序不稳定btnp()会把按下状态重复判定。解决方法是全部输入读取集中在_update60()开头并且跳跃动作立刻执行不留给绘制阶段。从那以后我写 Pico-8 的逻辑一律按“读输入 → 更新状态 → 绘制”的顺序排_draw()里只做纯绘制绝不读写按钮。4.2 子弹速度一快就穿墙明明检测了碰撞却穿过去了现象是发射高速子弹或敌人跑得快时偶尔直接穿过 8 像素宽的墙碰撞检测没触发。原因是瓦片碰撞检测只检查移动前后两个位置对应的格子如果单帧位移大于 8 像素子弹可能从墙壁格子的一侧跳到另一侧中间那帧的格子根本没被查询。解决方式是限制所有实体的最大速度不超过每帧 8 像素vx mid(-8, vx, 8)对于需要更快的子弹则改用分段移动把一帧的位移切成小段每段都执行一次collide_tile()。-- 分段移动示例把大位移切成最多 4 像素的小步 local steps ceil(dist / 4) for s 1, steps do bullet.x bullet.vx / steps bullet.y bullet.vy / steps if collide_tile(bullet) then bullet.active false break end endsteps根据本帧位移算出位移 10 像素就分 3 步走每一步都做碰撞查询。我在太空射击里给高速子弹单独开了这个“连续检测”分支速度超过 6 像素/帧就走分段逻辑穿墙问题再没出现过。4.3 在卡带里写的注释一保存就变乱码甚至编辑器卡死现象是在 Pico-8 内置编辑器里敲中文注释保存后重新打开注释变成一串乱码极端情况下整个代码区无法滚动就像卡死了一样。原因是 Pico-8 内置编辑器按单字节处理字符中文 UTF-8 编码是多字节序列编辑器既显示不对又可能在括号配对时算错。解决方法是代码注释一律用英文字符串字面量里也不要放中文如果确实要写中文说明就把说明写在README.md里.p8文件保持纯 ASCII。用外部文本编辑器写 UTF-8 无 BOM 的.p8也可以但 Pico-8 界面里注释仍然显示为乱码所以我的习惯是干脆不写中文注释。这个坑最狠的地方是它不影响运行只祸害源码可读性等第二个人接手时代码里的注释早就是一堆乱码完全失去沟通价值。4.4 导出成 HTML 后键盘没反应鼠标点来点去也没用现象是.p8在编辑器里跑得好好的export成 HTML 后丢到浏览器打开键盘按键完全不响应但鼠标能点。原因是导出的 HTML 里 game canvas 没有获得键盘焦点浏览器把按键事件都发到了页面 body 上Pico-8 的按键监听只挂在 canvas 上。解决方法是让玩家先点一下 canvas 区域再操作或者你在宿主页面里主动帮他聚焦。我的习惯是生成 HTML 之后在嵌入页加一行gamecanvas.focus()并给 canvas 设置tabindex0iframe 场景则在 iframe 加载完成后把焦点传进内部文档。别小看这个焦点问题很多分享出去的游戏都是因为少了这一步被人打开后“按什么键都没反应”第一印象直接拉胯。5. 导出与分发把 .p8 卡带变成网页和桌面程序5.1 export 命令一条命令的三平台打包Pico-8 自带导出功能在编辑器终端里输入export就能把当前卡带编译为目标平台的独立程序。我在项目里统一用下面的命令打包export game # 导出为 HTML export game.windows # 导出 Windows 可执行 export game.macos # 导出 macOS 应用 export game.linux # 导出 Linux 可执行第一条默认输出一个 HTML 文件文件里内联了完整卡带数据和运行时双击就能在浏览器里玩不需要服务器后面三条生成对应平台的原生程序运行时不再依赖 Pico-8 环境。注意导出后最好立即运行一下game.html做冒烟测试因为导出过程偶尔会因为卡带里使用了不兼容的 API 而静默降级出问题的表现为画面正常但声音缺失这个只能靠真机跑一遍才能发现。export默认会在产物里保留源码方便分享和二次开发如果你不想让别人看到 Lua 代码可以加-x参数把代码区从产物里剔除。这个参数在分享比赛作品时很实用。我平时分发自己玩的版本直接不带参给别人做二次开发反而希望保留代码可读性。5.2 网页嵌入的尺寸与缩放导出的 HTML 默认画布大小是 512x512128x128 的 4 倍内嵌到自己的页面时一般要控制尺寸。Pico-8 的 HTML 运行时支持在 URL 加参数控制行为常用几个参数我用下表列出来URL 参数作用示例scale画面缩放倍率?scale6volume音量 0 到 1?volume0.8pixelperfect像素整数倍缩放防模糊?pixelperfect1这些参数挂在 URL 上就能生效不需要改运行时代码。iframe srcshare/game.html?scale6volume0.8 width768 height768 styleborder:0; image-rendering: pixelated; /iframescale参数在移动端更有用小屏手机用 3 到 4 倍缩放能保住清晰度image-rendering: pixelated是配套的 CSS防止浏览器把像素画插值模糊。桌面端 768px 配上键盘操作手感和街机很像。如果嵌入后按钮失效对照第 4.4 节的焦点问题排查。5.3 性能探针与线框调试游戏跑起来卡成幻灯片时别急着猜原因先打探针。Pico-8 的stat(0)返回当前 CPU 占用率stat(1)返回内存占用用print()打在屏幕角落function _draw() cls(0) -- ... 正常绘制 ... print(cpu: ..flr(stat(0) * 100)..%, 0, 120, 7) end把stat(0)变成百分比打在底部跑几分钟观察峰值。如果 CPU 长期超过 60%就该查对象池了如果只在某个动作瞬间飙高八成是那瞬间 new 了一堆 table 触发 GC。Pico-8 的 GC 是全局停顿式的表现为一瞬间所有动画卡住正是第 3.4 节对象池要解决的问题。碰撞盒可视化也是排查移动 bug 的利器我在调试期会给实体画一个红色线框function debug_box(e) rect(e.x - 1, e.y - 1, e.w 1, e.h 1, 8) endrect的四个参数是左上角坐标、宽、高和颜色8 是红色。开着线框跑一轮你能直接看到实体判定区域和精灵显示是否对齐很多“明明没碰却受伤”的谜题一眼就能看穿。复杂逻辑还要看具体函数的执行次数我的做法是在入口函数里放计数器每 60 帧打印一次if t() % 60 1 then printh(bullet active: ..count_active(bullets)) endprinth()会输出到 Pico-8 的日志文件这么干能统计对象活跃度、分支命中情况不用打断运行流程。养成在代码里留探针的习惯后改性能问题的时间至少省一半。血泪经验别在_draw()里放printh每帧写日志会直接把性能拖垮测试完记得把探针删干净。6. 二次开发把别人的卡带改成自己的游戏6.1 从哪下手三个最值得改的改造点拿到这个合集别急着通读全部代码先锁定最短的改造链路。我建议从下面三个方向里挑一个动手每个都有一行代码能触发明显变化改造点位置一行改动输入映射_update60()里的btn编号把跳跃从btnp(4)改成btnp(0)试试用方向键起跳难度曲线fire_bullet()的调用间隔把发射间隔从 30 帧改 15 帧体验弹幕化角色手感player.vy -5.2的初速改成 -6.5感受更高跳跃带来的节奏变化改完数值跑一遍你会发现这项目的代码耦合度很低输入、物理、绘制各管各的改一处不会连坐。真改坏了也有后悔药——.p8是文本文件Git 回滚极其方便。6.2 实战改造给平台跳跃加一个道具系统加道具是验证你是否读懂了这套架构的试金石。下面这段代码可以在玩家碰到水晶后加一条命并播放音效-- 新增道具水晶碰到后加 1 生命值并播放音效 function spawn_pickup(x, y) add(pickups, {xx, yy, activetrue}) end function update_pickups() for i, p in ipairs(pickups) do if p.active then local hit abs(player.x 4 - p.x) 8 and abs(player.y 4 - p.y) 8 if hit then p.active false player.hp min(player.hp 1, 3) sfx(12, 1) -- 音效槽 12声道 1 end end end endabs判断的是玩家中心点和道具中心的曼哈顿式粗碰撞因为玩家精灵是 8x8加 4 就是中心坐标min限制生命值上限为 3防止刷命刷到溢出。道具池和子弹池共用一套回收模式理解对象池后你就知道为什么要用active而不是直接del(pickups, p)——前者没有垃圾回收压力。6.3 改造后的验证清单改造完别直接关编辑器我给自己定的冒烟测试顺序是CtrlR能开、角色能左右移动、跳跃落地各一次、敌人不穿墙、道具触发时音效出声。想快速验证道具位置就临时在玩家旁边生成一个-- 快速验证spawn 一个水晶直接放玩家旁边 spawn_pickup(player.x 10, player.y)从那以后我每次给卡带加新功能都强制走一遍这套冒烟清单再顺手用stat(0)确认 CPU 没暴涨。改完数值也会特意试着把玩家顶到地图边缘看有没有越界的怪癖。这套 Pico-8 的快速验证流程帮我省掉的排查时间比写代码的时间还多。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑