« May 2024 | Main

June 22, 2024

一些星舰或太空站建设类游戏

因为玩了一些星舰或太空站建造类的游戏,找找灵感。每个游戏都能给我一些启发,因为玩的都不多,所以就不评价了,只做个列表记录。有些还没有出,先加了个愿望单。

June 11, 2024

监视 Lua 对象的修改

我正在制作的游戏 demo 中,所有对象逻辑上都存在于二维空间,但在 Ant Engine 中通过 3d 渲染方式绘制出来。

我希望有一组简便的 API 方便我控制这些对象的渲染,只是控制它们的位置以及在 Y 轴上的旋转量。Ant Engine 是用场景组件来控制 entity 渲染时的空间状态,但场景节点使用的是 3d 空间的 SRT 即缩放、旋转、位移。而我只需要控制其中的两个坐标轴上的空间位置以及一个旋转轴上的旋转量,直接修改 SRT 太不方便了。而且,使用引擎时,还需要每帧标记被修改过的场景组件对应的 entity ,这也很麻烦。

在 ECS 结构下,最简单的方式是为这些 entity 创建一个额外的组件,里面有 x y r 三个值。通过一个 system 把它们转换到场景节点在 3d 空间下的 SRT 组件中。但如果每帧都全部转换一次显得多余,毕竟大部分 entity 不是每帧都会发生变化的。

我用了一个简单的 Lua 技巧来方便开发,下面便是代码:

local monitor = {}

local function new_type()
    local changes = {}
    local function touch(obj, k, v)
        local raw = obj.__raw
        changes[raw] = true
        raw[k] = v
        obj.__newindex = raw
    end
    local function new (obj)
        obj.__index = obj
        obj.__newindex = touch
        changes[obj] = true
        return setmetatable({ __raw = obj }, obj)
    end
    local function next_raw(t, key)
        local nkey, nvalue = next(t, key)
        if nkey then
            nkey.__newindex = touch
            return nkey, nvalue
        end
    end
    local function pairs()
        if next(changes) then
            local t = changes
            changes = {}
            return next_raw, t
        else
            return next, changes
        end
    end
    return { pairs = pairs, new = new }
end

local types = setmetatable ({}, {
    __index = function(self, name)
        local t = new_type()
        self[name] = t
        return t
    end })

function monitor.new(typename)
    return types[typename].new
end

function monitor.pairs(typename)
    return types[typename].pairs()
end

TEST = true

if TEST then
    local a = monitor.new "test" { x = 1, y = 2 }
    local b = monitor.new "test" { x = 10, y = 20 }

    local function flush()
        print "====="
        for obj in monitor.pairs "test" do
            print(obj.x, obj.y)
        end
    end

    a.x = -1
    flush()
    b.y = -20
    flush()
    local c = monitor.new "test" { x = 0, y = 0 }
    flush()
else
    return monitor
end

从最后的 test 代码可见:我们可以通过 monitor.new "typename" {}创建一个逻辑上有 x y 坐标的 lua 对象,它并不需要是 ECS 的组件,在和 ecs 结合使用的时候,可以把 eid 也放进对象里(在后面遍历的时候,可以对应到 ecs 中的 entity )。当我们后续修改这些对象时,会把修改过的对象标记在内部一张表中。

通过 for obj in monitor.pairs "typename" 可以遍历所有最近修改过(及新创建)的对象。

June 07, 2024

一个游戏的点子

宅在家里一个月了。一直在想,如果不考虑迎合市场,不顾及销量,到底应该做一个怎样的游戏才能让自己在制作过程中得到满足。

过年前曾经参加过一次老同事聚餐。组织者说,这屋子坐的都是做游戏的老人了,程序、策划、美术全齐了,还都是不差钱的主。大家要不要凑个局,想想做个啥游戏出来?接下来是一阵沉默,直到有声音说,“我没什么想法”,饭桌上的人纷纷点头,转移了话题。

前段参加一个独立游戏活动,见了些老朋友。有位同学做游戏很多年了,说起这些年的经历,入行头几年是给老板打工,接下来开了家小公司自己做,没赔钱也没赚钱。但干下来感觉变成了给员工打工。为了可以持续发出工资,每次立项都很匆忙,结果还是在不喜欢的游戏项目上耗掉了太多时间。现在干脆把团队安顿好,一个人出来,好好想想到底要做什么。

可见,想清楚做什么很难。单独一人的状态也很难得,没有太多的外界干扰,不为了做事而做,可以慢慢来。

首先,我想做一款游戏,这毋庸置疑。玩游戏是我这些年最大的爱好。光在 steam 上这些年就花掉了上万小时,switch 上也有几千小时。我能在制作游戏的过程中获得我要的东西。

其次,做一款游戏的目的不是为了收入。我对物质生活要求极低,不需要花钱满足欲望。除非需要雇人一起做游戏,不然制作游戏的开销只是自己家庭的日常开销,而我这些年的积蓄已够过完余生。我喜欢的游戏都不需要太复杂的美术资产,这方面并不需要额外的投入。

另一方面,我也不需要用游戏讨好玩家来获得成就感,不需要用一个产品来证明自己,这些成就感的体验都已有过,不是我想追求的东西。

所以,我所需要的是制作过程带来的持续体验,让自己觉得自己在做一件有意义的事。我所喜欢和擅长的其实是:认清问题,解决它们。


最近玩了很多游戏。有一直想玩但之前没时间玩的博德之门 3 。也有很多新出的游戏如动物井、太空医院、哈迪斯 2 、Laysara: Summit Kingdom 、Final Facory 、Nexus 5X 、Sixty Four …… 很多很多。还回顾了以前玩过的老游戏。

我认为我应该做自己擅长的游戏类型。挑战新类型未尝不可,但可以慢慢来。把我这些年花时间最多(上千小时)的游戏列出来后,得到了三个选题:

第一、自动化工厂类型的游戏,还可以包括 基地建设 和 生存 这样的元素。

第二、有历史感的大战略游戏。记得 30 年前,我最想做的游戏是三国志。其实现在想起来依旧有趣。光荣的三国志系列固然好,但以现在的眼光来看,那些游戏玩点都太陈旧了。我更喜欢群星、维多利亚、十字军之王这些现代战略游戏。这些游戏都有三国题材的 mod ,都并不成功。真正成功的现代三国战略游戏,在我看来只有半个:全面战争:三国。

第三、传统意义上的 Rogue like 。我在 Rogue's Tale 上花了近千小时。Tome4 , Adom , netheck 也都玩过。我喜欢这些游戏玩法的内核,如果接入一些现代游戏的交互元素或许更好?比如博德之门 3 的玩法内核还是古典的 DnD ,但是交互设计上有了巨大的进步。Diablo 在我看来也是 Rogue Like 的进化,动作元素的加入让这个进化非常成功。

有好几天,我都在几个选题间犹豫,对于进一步的思考停滞不前。直到有天我把太空医院的战役通关,突然有了些新的想法。

其实,拘泥于做什么题目更好是没有意义的。任意一个都会很有趣。而做什么游戏也不必标新立异,比如太空医院,我玩的感受就很舒畅。要说和之前的双点医院到底有什么不同?粗看是差不多的。但当初双点医院刚出来时,因对牛蛙的主题医院的旧感情,我第一时间就买了。但当时玩了几个小时就犯困,而这次太空医院我就一口气玩了 20 多小时。为了防止一时偏见,我又重新安装回双点医院,发现感觉并没有错。引用我在 steam 评测中的一句话:“这么说吧,双点医院是旧时代游戏的高清画面重置版,而这个更有现代游戏的感觉。”

延续已有游戏类型其实并无太大问题,但因怀旧或是流行而复刻却没太大意义。加入现代游戏性元素也没问题,但不应硬去缝合。游戏玩点应该自洽,找到整个游戏每个环节应该有什么。从某个特定的乐趣点出发,在此基础上慢慢实现就好了。我突然有了创作欲。


核心乐趣:在不规则的有限空间中拼凑不规则板块。

我觉得这是一个非常打动我的玩点:在太空医院中摆放房间时感受到了这一点。玩异星工厂:太空探索 end game 修飞船时,我也曾反反复复的折腾最终飞船的设计图;warptorio 这个 mod 中也是需要在几个楼层的有限空间塞下整个工厂。

乐高、俄罗斯方块这两个是拥有亿万用户的积木游戏。我觉得拼积木板块可以获得某种原始的快感。对于游戏设计,这是一个很好的起点。然后我随意的完善了整个想法。有点杂乱无章,内容过多,不过无所谓,以后慢慢裁剪就好了。

在银河系中已有无数住人的行星,不同的星际种族。玩家扮演一个独立商贸船的舰长,带领一个船组在银河系中做生意,并探索终极秘密。

在游戏的一开始,玩家拥有一艘很小的飞船,只有寥寥几个舱室,和 2,3 名船组成员。在游戏过程中,每段航程从一个星球到另一个星球。每次抵达新的目的地,就可以做短暂的补给:招募新船员、扩展飞船、修改船上的房间设计。

每个舱室内的机器以半自动化形式工作,船员需要窜梭于狭窄的过道,操纵整个飞船。船的隔间设计,和内部机器的摆放及连接关系决定了船的运作效能。异星工厂和缺氧这些游戏确立了相关玩法是成立的,但这里还需要更多的设计工作。

对于每段航程,将是一个小型挑战,玩家要面对:

  1. 规划燃料、氧气、食物等消耗。
  2. 一路上有随机陨石飞来。可以通过:护盾、无人机等防御抵御。(它可能是一个小型塔防游戏)
  3. 有许多随机负面事件,电离风暴,恒星耀斑等等。这会考验船的供能情况,动态修理能力。
  4. 还会有海盗等敌人出现:需要使用舰载武器攻击,或跃迁逃逸能力。(这里,还可以考虑登船战斗)

这会是一个由随机性驱动的游戏,而不是设计好的若干战役:

船员的获得,初始能力点是随机的。人员的成长会决定:操作机器的熟练度、可以使用的高级机器种类。每段航程面临的挑战有随机性。游戏里的船组成员会永久死亡,游戏会因为全部成员的死亡而失败。游戏不会提供随时 Load/Save 的特性,所以每次启程需要考虑后备方案和额外的资源,避免中途失败。

但在每局游戏中,达成特定目标可以解锁遗产,遗产则能永久保留到未来的游戏中。


这个题材可以追溯到 银河飞将:劫掠者 ,后续还有自由枪骑兵和星际公民都有不错的口碑。这类游戏,我甚至玩过一个 rogue like 叫做 asciisector 。当然它们都不以设计飞船为重点。

在上面这个点子的产生过程中,有很多有趣的(我通关过的)游戏给了我启发。包括并不限于:

June 04, 2024

Ant 的资源内存管理

这两天着手做游戏 demo 时发现 Ant 的 Asset 管理模块之前还留有一些工作没有完成。

那就是,当游戏程序加载 Asset 后,资源管理模块何时释放它们的问题。在 ant.asset 模块中,我们为每种 asset (以文件后缀名区分)定义了 loader unloader reloader 三个接口,分别处理加载、卸载、重载的工作。

但在实际实现时,几乎都没有实现 unloader 。当时是偷懒,因为我们之前的游戏即使把全部资源都加载到内存,也没多少数据,并不需要动态卸载释放内存。而即使实现了 unloader ,管理器也没有实现很好的策略去调用它。只能靠用户主动调用卸载 api 。事实上,一个个资源文件主动卸载也不实用。

考虑到占用内存最大的 asset 是贴图,我们又对贴图做了一些特殊处理:

所有的贴图都可以用一张空白贴图作为替代。引擎有权在任何时候(通常是内存不足时)主动释放长期未使用的贴图,并换用替代。这个特性也可以很好的适配异步加载过程。

所以,未释放的贴图并不会撑满内存。

今天在查看引擎的预制形状相关的 API 实现时想起,我们对一些预制的模型,又有一些特殊处理。

预制模型,例如平板、箭头、方块等,通常在调试或写一些简单 demo 时使用。它们不是从 asset 文件中加载而来的,而是通过一些代码直接填写顶点数据创建出来。所以,这样的网格数据(mesh)并不在 entity 间共享,而是每个 entity 独有一份。目前其生命期跟随 entity ,即在 entity 销毁时主动销毁 mesh 相关数据。

所以,我们就在相关数据结构上打了个标记。拥有这个标记的数据,会在 entity 销毁时做销毁处理,以免造成资源泄露。

我觉得这个设计有不好的味道,所以这次想把资源管理模块重新做一下,统一文件加载的 asset 和程序化生成的数据的管理。

回到前面的问题:到底应该什么时候清理内存中的 Asset 数据?提供怎样的 API 清理?我认为是这样的:

  1. 规模较小的游戏或 demo 事实上并不需要在运行时清理,程序退出时一并释放干净即可。
  2. 规模较大的游戏,通常在场景切换时做清理,且不应让清理粒度太小,那样会增加很多的开发难度。

清理问题的难点在哪?

如果一个对象引用了某个 Asset 数据,通常我们不能直接清理它(除非是贴图管理那样的特例,偷偷替换实际的数据)。引擎中很难找到所有对象和 Asset 数据的引用关系,因为维护这样一张表有额外的复杂度成本。

但是,所有 Asset 数据目前都必定是引擎 ECS 中的 entity 引用,固然可以通过遍历 entity 的特定 component 找到引用关系,但整个 ECS 的 world 被销毁或重启时是一个更好的时机。因为这时,所有 entity 都被销毁了。

在切换场景时,我们建议重新创建 world 中的所有 entity ,这样,资源管理模块就可以把所有的 Asset 全部清理干净了。当然,实际实现时,我们不必真的释放所有的数据。做一个 cache 更好,如果同样的 asset 不久之后又加重新加载,就可以直接利用 cache 中的数据了。

顺着这个思路,我打算重构 Ant 中的 Asset 管理模块。先从 mesh 的管理改起,如果没有问题,就可以推广到所有的 Asset 。

  1. mesh 的 handle 只创建, 不单独销毁。不再区分从文件里读进来的 mesh 和程序创建的数据。
  2. 提供一个方法把内存中所有的 mesh 全部销毁。调用者会保证它不被引用。我们放在 world 的重建过程中,就能保证这点。
  3. 用一个 ltask 服务管理所有的 mesh , 它会做一些 cache 工作。 当 2 发生时不会立刻销毁数据,而根据 cache 算法来决定何时清理。

关于程序创建的 mesh ,我想新增一组 api ,在创建 mesh 时,同时用字符串命名,这样就可以和文件加载的 mesh 统一管理。如果程序创建 mesh 时有参数,将参数也编码到这个字符串中,保证字符串和数据有唯一对应。

Cache 我想使用一个简单的算法:

  1. 将数据分为 new 和 old 两个区,new 表示当前必须持有的数据,old 表示还在内存但可以被清理的数据。
  2. 在刷新时,将 cache 容量设置为 old/2 + new * 2 。然后把当前内存中所有数据置为 old 。
  3. 加载数据时:如数据已在内存中,把数据标记为 new ;否则,加载数据,并检查 new 上限,若超出上限,删除任意一个 old 数据。