C64 Basic:游戏地图俯视“摄像机视角”
摘要
本教程讲解如何在C64 BASIC中实现游戏地图的俯视摄像机视图,并介绍了优化技巧。
暂无内容
查看缓存全文
缓存时间: 2026/05/26 15:55
# C64 BASIC:游戏地图俯视"摄像机视角"
来源:https://retrogamecoders.com/overhead-camera-view/
深度解析如何创建《创世纪》风格的地图视图,以及由此引发的 C64 BASIC 优化之旅
像《创世纪》这类游戏使用的经典俯视摄像机视角,与我之前展示的移动角色视角(参见我的复古 roguelike 教程 (https://retrogamecoders.com/roguelike-multiplatform/))有何不同?这种视角又是如何实现的?
Jay 在 Commodore 64 Ultimate Development & Modifications Facebook 群组 (https://www.facebook.com/groups/754519687678324/?multi_permalinks=874168462380112&hoisted_section_header_type=recently_seen&__cft__[0]=AZYb8kGPXn9ElKM-LpSGVJhipT0rb1zdd7qJw7JtIPoPEWj9LCWX1ezzO53q4ajrUJ3F0kLuZKbiZKpVhHH6snrX0hAtQEC2c6CBY9FsHkW3kTH5LvfPNb2Nzcfv-BIWNa1P852__TfZ2tOSMFQHuhsx&__tn__=%2CO%2CP-R) 中提出了一个问题 (https://www.facebook.com/groups/754519687678324/?multi_permalinks=874168462380112&hoisted_section_header_type=recently_seen&__cft__[0]=AZYb8kGPXn9ElKM-LpSGVJhipT0rb1zdd7qJw7JtIPoPEWj9LCWX1ezzO53q4ajrUJ3F0kLuZKbiZKpVhHH6snrX0hAtQEC2c6CBY9FsHkW3kTH5LvfPNb2Nzcfv-BIWNa1P852__TfZ2tOSMFQHuhsx&__tn__=%2CO%2CP-R):
> 角色移动 vs 地图移动。希望这说得通。我有一个 40x24 的地图。我知道如何编码角色在地图上移动,这部分很简单。我想要的是让角色保持在屏幕中心,然后移动地图来响应角色移动。就像《创世纪3》那样,地图窗口是 11x11,角色永远不离开中心。当你移动时,地图会随之滚动。有人能分享一个不错的示例程序源码吗?我在这里学习。
以下是我的回答(事后看来,这个回答其实并不够有帮助):
> 在 C64 字符模式下实现的方法是:地图定义了整个潜在区域,而"摄像机视角"是地图上从 (x, y) 位置开始的一个切片。如果地图是 100x100,那么你需要绘制 x 到 x+11、y 到 y+11 的范围。我相信有人已经有现成的代码了,如果没有,我之后也可以提供一些。
我不想就这样留下一个不完整的回答,我觉得有必要给出一个更好的解决方案,而且这个话题本身也是一个很适合写成博客的挑战,所以就有了这篇文章。
## 视口 vs 地图
正如我在回答中简要提到的,核心思想在于区分"世界地图"和可见部分。我们是在模拟一个进入完整世界的视口或窗口,解决方案需要从大地图中切出正确的部分并将其粘贴到游戏屏幕上。
- 我们的玩家拥有 `X` 和 `Y` 坐标,代表玩家在世界中的水平(左)和垂直(顶)位置。
- 世界地图是**整个潜在区域**,存储在内存中,与当前屏幕上的人物或物体无关。
- 屏幕上的可玩区域只是一个固定大小的**摄像机视图**,它是从世界地图中某个 `(x, y)` 位置开始的一个切片。
- 对于 100×100 的地图,你只绘制 `x 到 x+10` 列和 `y 到 y+10` 行(即 11 个方块,形成 11×11 的窗口)。
另一个难点是,无论我们如何绘制,我们都希望将游戏焦点集中在玩家角色上,因此还需要从左上角增加一个额外的偏移量,确保精灵或其他元素在屏幕的水平和垂直中心,而不是始终位于游戏屏幕的左上角。
[](https://ide.retrogamecoders.com/?file=yt_hello.bas&platform=c64)你现在可以跟随教程,在 Online Retro IDE (https://ide.retrogamecoders.com/?file=yt_hello.bas&platform=c64) 中边看边编辑代码。
– 无需下载、配置等操作,而且完全免费!
## 初稿:未经优化
我们的原始初稿完全未经优化,几乎只是伪代码的简单实现:
1. 定义一个二维地图数组 `M(x, y)`,其大小等于完整世界的尺寸。
2. 单独跟踪玩家的**世界位置** `(PX, PY)` 和其**屏幕位置**。
3. 每次游戏循环:
- 计算摄像机左上角:`CX = PX - 5`,`CY = PY - 5`(11×11 视口的一半)
- 将摄像机位置夹紧,确保不会读取到地图边界之外(《塞尔达传说》风格)
- 对于 11×11 视口中的每个单元格 `(I, J)`,将 `M(CX+I, CY+J)` 复制到屏幕 RAM 的 `(OX+I, OY+J)` 位置
- 在屏幕中心(或如果摄像机被夹紧则偏移)绘制玩家
- 等待输入,更新 `(PX, PY)`,重新绘制
显然,这非常非常慢,但作为概念验证,它帮助我们确定了基本的实现框架。
### 第一级代码
→ 获取最终代码的可编辑副本,并在在线编辑器中运行 (https://ide.retrogamecoders.com/?share=TmEQZCu8pi-jJC36)。
``
10 REM ---- 大地图 / 小摄像机 演示 ----
20 REM JAY 的问题:让玩家居中,
30 REM 地图围绕玩家移动(《创世纪》风格)
40 REM 第一级:极其缓慢的未优化初稿
50 MW=40 : MH=24
60 VW=11 : VH=11
70 HX=INT(VW/2) : HY=INT(VH/2)
80 OX=14 : OY=6
90 SC=1024
100 DIM M(MW-1,MH-1)
110 GOSUB 600 : REM 构建地图
120 PX=20 : PY=12
130 PRINT CHR$(147)
140 GOSUB 300 : REM 绘制视口
150 GOSUB 500 : REM 绘制玩家
160 GET K$ : IF K$="" THEN 160
170 DX=0 : DY=0
180 IF K$="W" THEN DY=-1
190 IF K$="S" THEN DY=1
200 IF K$="A" THEN DX=-1
210 IF K$="D" THEN DX=1
220 IF K$="Q" THEN PRINT CHR$(147) : END
230 NX=PX+DX : NY=PY+DY
240 IF NX<0 OR NX>MW-1 OR NY<0 OR NY>MH-1 THEN 160
250 PX=NX : PY=NY
260 GOSUB 300 : GOSUB 500 : GOTO 160
270 REM
300 REM ---- 绘制视口(初级方法) ----
310 CX=PX-HX : CY=PY-HY
320 IF CX<0 THEN CX=0
330 IF CY<0 THEN CY=0
340 IF CX>MW-VW THEN CX=MW-VW
350 IF CY>MH-VH THEN CY=MH-VH
360 FOR J=0 TO VH-1
370 FOR I=0 TO VW-1
380 POKE SC+(OY+J)*40+(OX+I),M(CX+I,CY+J)
390 NEXT I
400 NEXT J
410 RETURN
420 REM
500 REM ---- 绘制玩家(初级方法) ----
510 SX=OX+(PX-CX) : SY=OY+(PY-CY)
520 POKE SC+SY*40+SX,81
530 RETURN
540 REM
600 REM ---- 构建测试地图 ----
610 FOR Y=0 TO MH-1
620 FOR X=0 TO MW-1
630 T=46
640 IF X=0 OR Y=0 OR X=MW-1 OR Y=MH-1 THEN T=160
650 IF (X=10 AND Y>4 AND Y<15) THEN T=160
660 IF (Y=8 AND X>14 AND X<25) THEN T=87
670 M(X,Y)=T
680 NEXT X
690 NEXT Y
700 RETURN
``
## 第二阶段:屏幕查找表 (LUT)
我们可以止步于上述代码,但它的动画效果更像幻灯片而不是游戏,而且你可能会因为启动速度极慢而认为它已经崩溃了。我们先来调整显示逻辑。
在这个阶段,我们能做的最重要的优化是替换昂贵的乘法运算 `(OY+J)*40`,改用预计算的查找表。我们的 8 位 6510 处理器在乘法运算上并不快,在浮点 BASIC (https://www.youtube.com/watch?v=wo14rDnGUbY) 中更是如此。因此我们添加 `DIM R(24)`,并在启动时一次性填充:`FOR Y = 0 TO 24 : R(Y) = Y*40 : NEXT Y`。
显示循环变成了一次查找,没有乘法:
`RO = SC + R(OY+J) + OX`
`POKE RO+I, M(CX+I, CY+J)`
消除了 121 次浮点乘法!速度提升约 3–5 倍。
不过这会使启动速度变得更慢。
## 第三阶段:双重查找表
为什么还是慢?BASIC v2 中访问二维数组每次读取仍然隐藏了一次乘法。
那么,我们把地图改为扁平的一维数组:`DIM M(MW*MH - 1)`。
由于这一改动,我们还需要添加一个地图行查找表:`DIM MR(MH-1)`,并在启动时用 `MR(Y) = Y * MW` 填充该表。
现在,我们的视口循环只剩下加法操作,这是微处理器更擅长的:
- `RO = SC + R(OY+J) + OX`(该行的屏幕基址)
- `MO = MR(CY+J) + CX`(该行的地图基址)
- `POKE RO+I, M(MO+I)`(零次乘法)
我们再次用初始化延迟换取了运行速度——我们在启动时增加了约 24 次乘法,但每显示一帧消除了约 121 次乘法。
## 第四阶段:初始化进度指示器
我们一开始的启动就慢,现在更慢了,如果不显示程序正在运行,看起来肯定像是卡住了。
我们只需要在每个初始化循环内部打印一些内容。讽刺的是,这些打印语句会进一步降低启动速度——C64 BASIC 的打印可一点也不快。
幸运的是,在实际游戏中,我们会将 LUT 和地图编码为 `DATA` 语句并用 `READ` 读取,或者更好的方法是直接从磁盘加载。
## 第五阶段:循环展开
优化的最后一步是找到下一个"热点路径"并进行优化。
> "热点路径"是程序中执行最频繁的部分。优化这些部分能带来最显著的性能提升。
在本例中,`FOR J = 0 TO 10 ... NEXT J` 每次重绘都会执行,且循环边界是固定的常量。
BASIC 的 `FOR/NEXT` 每次迭代的开销很大(压栈、变量查找、比较、跳转)。
因此,我们可以将计算移到另一个 LUT 中:`DIM VR(10)` 在启动时填充 `VR(J) = SC + R(OY+J) + OX`,然后"展开"其中一个循环。我们用 11 条显式行替换外层的 `FOR` 循环,每行负责输出一行:
`RO = VR(0) : MO = MR(CY) + CX : GOSUB 460`
`RO = VR(1) : MO = MR(CY+1) + CX : GOSUB 460`
是的,我们仍然有一个 `FOR`,但每显示一帧消除了 10 个 `NEXT`,这已经相当不错了。
每次按下 WASD 键时,感觉响应快多了,这很重要。
## 最终代码(暂定)
→ 获取最终代码的可编辑副本,并在在线编辑器中运行 (https://ide.retrogamecoders.com/?share=TmEQZCu8pi-jJC36)。
后续还有进一步的优化思路,但目前这个版本已经可以运行,虽然仍然有点慢。
``
10 REM ---- 大地图 / 小摄像机 演示 ----
20 REM JAY 的问题:让玩家居中,
30 REM 地图围绕玩家移动(《创世纪》风格)
40 REM 第三级:展开的视口 + 预计算
50 MW=40 : MH=24
60 VW=11 : VH=11
70 HX=INT(VW/2) : HY=INT(VH/2)
80 OX=14 : OY=6
90 SC=1024
95 PRINT CHR$(147) : PRINT "LOADING";
100 DIM M(MW*MH-1)
105 DIM R(24)
106 DIM MR(MH-1)
107 DIM VR(10) : REM 预烘焙的视口行屏幕基址
110 FOR Y=0 TO 24 : R(Y)=Y*40 : PRINT "."; : NEXT Y
111 FOR Y=0 TO MH-1 : MR(Y)=Y*MW : PRINT "."; : NEXT Y
112 FOR J=0 TO 10 : VR(J)=SC+R(OY+J)+OX : NEXT J
113 PRINT : PRINT "BUILDING MAP";
114 GOSUB 600
115 PRINT : PRINT "READY"
120 PX=20 : PY=12
130 PRINT CHR$(147)
140 GOSUB 300
150 GOSUB 500
160 GET K$ : IF K$="" THEN 160
170 DX=0 : DY=0
180 IF K$="W" THEN DY=-1
190 IF K$="S" THEN DY=1
200 IF K$="A" THEN DX=-1
210 IF K$="D" THEN DX=1
220 IF K$="Q" THEN PRINT CHR$(147) : END
230 NX=PX+DX : NY=PY+DY
240 IF NX<0 OR NX>MW-1 OR NY<0 OR NY>MH-1 THEN 160
250 PX=NX : PY=NY
260 GOSUB 300 : GOSUB 500 : GOTO 160
270 REM
300 REM ---- 绘制视口(展开 + LUT) ----
310 CX=PX-HX : CY=PY-HY
320 IF CX<0 THEN CX=0
330 IF CY<0 THEN CY=0
340 IF CX>MW-VW THEN CX=MW-VW
350 IF CY>MH-VH THEN CY=MH-VH
360 RO=VR(0) : MO=MR(CY)+CX : GOSUB 460
361 RO=VR(1) : MO=MR(CY+1)+CX : GOSUB 460
362 RO=VR(2) : MO=MR(CY+2)+CX : GOSUB 460
363 RO=VR(3) : MO=MR(CY+3)+CX : GOSUB 460
364 RO=VR(4) : MO=MR(CY+4)+CX : GOSUB 460
365 RO=VR(5) : MO=MR(CY+5)+CX : GOSUB 460
366 RO=VR(6) : MO=MR(CY+6)+CX : GOSUB 460
367 RO=VR(7) : MO=MR(CY+7)+CX : GOSUB 460
368 RO=VR(8) : MO=MR(CY+8)+CX : GOSUB 460
369 RO=VR(9) : MO=MR(CY+9)+CX : GOSUB 460
370 RO=VR(10) : MO=MR(CY+10)+CX : GOSUB 460
410 LX=CX : LY=CY
420 RETURN
430 REM
460 REM ---- POKE 一个视口行 ----
470 FOR I=0 TO 10 : POKE RO+I, M(MO+I) : NEXT I
480 RETURN
490 REM
500 REM ---- 绘制玩家 ----
510 SX=OX + (PX-LX)
520 SY=OY + (PY-LY)
530 POKE SC+R(SY)+SX, 81
540 RETURN
550 REM
600 REM ---- 构建测试地图 ----
620 FOR Y=0 TO MH-1
625 YB=MR(Y) : PRINT ".";
630 FOR X=0 TO MW-1
640 T=46
650 IF X=0 OR Y=0 OR X=MW-1 OR Y=MH-1 THEN T=160
660 IF (X=10 AND Y>4 AND Y<15) THEN T=160
670 IF (Y=8 AND X>14 AND X<25) THEN T=87
675 M(YB+X)=T
680 NEXT X
690 NEXT Y
700 RETURN
``
## 未来优化思路
接下来是一些未来优化方向的想法……
### 1. PRINT 比 POKE 更快
最大的优化是消除缓慢的 POKE(笑)。我们之前已经看到,在 C64 BASIC 中没有汇编子程序的情况下,print 比 poke 更快 (https://retrogamecoders.com/printing-petscii-faster/)。`POKE` 逐个字符写入屏幕内存是缓慢的。而使用光标转义控制符定位光标再 `PRINT`,是纯 BASIC 中剩余的最大优化点。
通过字符串拼接构建字符串仍然慢,所以我认为更好的做法是将地图构建为字符串数组的行。我们可以使用 C64 BASIC 的字符串操作命令 (https://retrogamecoders.com/strings-text-adventure) 来提取我们需要的部分。
### 2. 从 BASIC 调用的汇编子程序
或者,作为补充,我们可以编写一个汇编子程序来完成显示,并使用内存拷贝。这将绕过我们的显示循环和逐字符的摩擦,只需给定一个起始内存地址,就能快速将源数据复制到目标位置。
### 3. 元图块
Bitmap Brothers 的像素艺术给了我很大的启发。例如《混沌引擎》中元图块的出色运用。
最后想到的是使用**元图块**。初始化之所以慢,部分原因是地图是一个字符一个字符组成的,但在《创世纪》或《塞尔达传说》这类游戏中,你可能使用 3×3 或 5×5 的图块来构成墙角、房屋的一部分、道路的拐弯等。这将使加载或生成世界地图快得多,因为显示时虽然是相同大小,但数据量可以压缩到原来的 1/3 或更小。
### 其他改进:
我还会如何改进?
- **颜色**:并行 POKE 到 `$D800`(55296),使墙壁为灰色、水域为蓝色、草地为绿色、玩家为黄色……
- **局部重绘**:当移动一个图块时,你只需要绘制新显露出的行或列,以及旧/新玩家位置,大约 12 个字符而不是 121 个,因此速度大约提升 10 倍。
- **硬件平滑滚动**:写入 `$D016` / `$D011` 实现亚字符级别的像素滚动。
- **自定义字符集**:用定制的瓦片图形替换默认字体,为游戏带来真正的精致感。
## 经验教训
本讨论中介绍的技术适用于任何平台或语言。将世界坐标与可见屏幕坐标解耦,并将可玩的可见区域视为一个进入更大"世界"缓冲区的窗口。虽然我的"塞尔达风格 (https://ide.retrogamecoders.com/?platform=rgc-basic&file=rpg%2Frpg.bas)"演示使用了推动滚动,但核心概念是相同的。
我们很快陷入了一个支线任务:试图让 CBM BASIC v2 跑得更快。特别是乘法运算的成本非常明显。
查找表是复古系统上最强大的优化工具:用一点点 RAM 和预先计算的开销,换取消运行时巨大的时间节省。你在演示场景中会反复看到这种技术。
最后,**展开循环**(以及任何你需要做的事情),但在测量出瓶颈所在(你的热点路径)之前不要进行优化。正如 Robin 的视频 (https://www.youtube.com/watch?v=wo14rDnGUbY) 中所提到的,仅仅因为某件事看起来应该能加快速度,并不意味着它真的会!
相似文章
这个周末你打算做什么?
一位开发者描述了将《完美黑暗64》关卡移植到 noclip.website 的过程,强调了读取 N64 显示列表和重新实现渲染引擎的挑战。
无头截图循环使得本地30B智能体用纯C完成光线追踪FPS演示
一个使用300亿参数模型的AI智能体利用无头截图循环,自主完成了一个用C语言编写的光线追踪第一人称射击演示,展示了先进的自主编程和调试能力。
Voxel Space
本文介绍了1992年游戏《Comanche》中使用的Voxel Space渲染技术,涵盖其高度图、颜色图和简单的光栅化算法。
Rust 与 GBA:环境搭建与像素渲染
一份教程指南,讲解如何搭建 Rust 项目以构建能在 Game Boy Advance 上运行的 ROM,涵盖项目设置和像素渲染。
模拟器调试:Area 5150 的 Lake Effect
本文详细介绍了在MartyPC模拟器上调试Area5150演示中“Lake”效应的过程,解释了需要特定标题hack的原因,以及通过总线嗅探和动态时钟实现周期精确CGA模拟的后续修复方法。