首页 › 模组制作 › Classic,一个完整实例
第 09 章

Classic,一个完整实例

用 Lua 写成的内置电脑玩家:它的文件、任务表、一个任务、quirk 注记、留在引擎里的部分,以及同样的命令如何被证明。

本章概述 docs/brains/oabrain-standard.md、docs/brains/authoring.md 和 docs/playing-with-brains.md。那些文档是规范性文本。

Classic 是什么

Classic 就是 brains/classic。它是内置电脑玩家的文档:同样的命令,写成你可以读的样子。内置电脑玩家仍留在引擎里,并且仍是默认。选用 Classic 会让这个包入座。席位留在内置电脑玩家上时,运行的是引擎自己的电脑玩家。

清单写明 works-with 是星号,并且 fullAwareness 为 true。它的描述说,它的打法与内置电脑玩家完全一样。全图视野是清单标志。它允许用于网络游戏,席位会标出它。标识、名字和安装路径不会使一个包成为 Classic。文件必须是这一构建的 brains/classic 文件夹,路径相同,字节相同。

清单 · oabrain.yaml
# SPDX-FileCopyrightText: The Open Annihilation Authors; see COPYRIGHT
# SPDX-License-Identifier: GPL-3.0-only

oabrain: 1
id: classic
name: Classic
description: "The engine's own computer player, written in Lua: it plays exactly as the built-in computer player does."
version: "1.0"
author:
  name: The Open Annihilation Authors
tags: [classic, teaching]
works-with: ["*"]
requires:
  engine: ">= 0.8.6"
levels:
  - id: easy
    name: Easy
    difficulty: easy
  - id: medium
    name: Medium
    difficulty: medium
  - id: hard
    name: Hard
    difficulty: hard
fullAwareness: true
entry: brain.lua

文件

brain.lua 为 0 到 9 的每个小队建立一个任务。在第一个 tick,onTick 刷新建造列表并为网格播种。之后每个 tick 按倒计时运行小队排序,并运行每一个轮到的任务。

路径作用
oabrain.yaml清单。
brain.lua席位、任务表和 onTick。
lua/build.lua给一个类型打分,并从建造者的列表里挑一个。
lua/knowledge.lua权重、上限、建造网格和金属点。从 me.knowledge 读取引擎的记录。
lua/place.lua搜索选址,不断加宽,直到搜索和地图一样宽。
lua/profile.lua读取配置文本并应用权重。
lua/report.lua写出 report 返回的文本。
lua/rules.lua读取 Classic 所依据的 ai.* 规则。
lua/squads.lua把单位分进小队,并给小队一条命令。
lua/types.lua读取电脑玩家所用的类型字段。
lua/tasks/structures、construction、strike、rally、air_raid 和 siege。
README.md一份说明。游戏不加载它。
LICENSE许可证。

任务表

小队 1 和 5 建造建筑。小队 5 只在规则这样说时才做,否则空闲。小队 2 和 6 是突击组。小队 3 和 7 向它们集结。小队 4 是工程。小队 8 是飞行中队。小队 9 是攻城。小队 9 的注释是一处 quirk:排序从不填满那个小队,所以攻城任务只保持自己的时间表,除非已经有单位在里面。

-- …
    for index = 0, 9 do
        seat.tasks[index] = blank(index)
    end
    local wave = loaded_rules.attack_wave
    seat.tasks[1].kind = "structures"
    seat.tasks[1].every = 30
    seat.tasks[2].kind = "strike"
    seat.tasks[2].min_size = 3
    seat.tasks[2].launch = wave
    seat.tasks[2].merge_radius = 20000
    seat.tasks[2].source = 3
    seat.tasks[2].every = 300
    seat.tasks[3].kind = "rally"
    seat.tasks[3].source = 2
    seat.tasks[3].every = 150
    seat.tasks[4].kind = "construction"
    seat.tasks[4].every = 90
    if loaded_rules.squad5_factory_tick then
        seat.tasks[5].kind = "structures"
        seat.tasks[5].every = 30
    else
        seat.tasks[5].kind = "idle"
    end
    seat.tasks[6].kind = "strike"
    seat.tasks[6].min_size = 3
    seat.tasks[6].launch = wave
    seat.tasks[6].merge_radius = 50000
    seat.tasks[6].source = 7
    seat.tasks[6].every = 300
    seat.tasks[7].kind = "rally"
    seat.tasks[7].source = 6
    seat.tasks[7].every = 150
    seat.tasks[8].kind = "air_raid"
    seat.tasks[8].every = function()
        return 30 + brain.shared_random(900)
    end
    seat.tasks[8].run = function()
        air_raid.run(seat, seat.tasks[8])
    end
    -- @quirk The sort never fills squad 9, so the siege task only keeps its
    -- timetable unless a unit was already in that squad.
    local centre = map_centre(game)
    seat.tasks[9].kind = "siege"
    seat.tasks[9].every = function()
        return 30 + brain.shared_random(150)
    end
    seat.tasks[9].siege_target = point_of(centre)
    seat.tasks[9].siege_probe = point_of(centre)
    seat.tasks[9].siege_step = point_of(centre)
    seat.tasks[9].siege_weight = 0
-- …

在第一个 tick,onTick 刷新建造列表并为网格播种。排序本身就是那个倒计时。它从 30 开始。每个 tick 减一,到底之后回到 30 并调用小队排序。

-- …
function hooks.onTick(tick)
    local seat = hooks.seat
    if seat.builds_ready ~= 1 then
        types.refresh_builds(seat.types, seat.game)
        seat.builds_ready = 1
    end
    if seat.grids_ready ~= 1 then
        knowledge.seed_grids(seat)
    end
    seat.sort_countdown = seat.sort_countdown - 1
    if seat.sort_countdown < 1 then
        seat.sort_countdown = 30
        brain.section("sort")
        squads.sort(seat)
    end
    for index = 0, 9 do
        local task = seat.tasks[index]
        if task.kind ~= "none" and task.next <= tick then
            if task.kind ~= "idle" then
                brain.section("task" .. index)
                local interval = task.every
                if type(interval) == "function" then
                    interval = interval()
                end
                task.next = (tick + interval) & 0xFFFFFFFF
                task.run()
            end
        end
    end
end
-- …

squads.lua 说明排序做些什么:席位范围内的可选择单位接受常备命令,仍在小队 0 里的单位加入其类型所分到的小队。已经在某个小队里的单位保持那个小队。

一个任务

lua/tasks/rally.lua 就是集结任务的全部。当集结小队和突击组都有成员时,集结小队移动到突击组的中心。

-- SPDX-FileCopyrightText: The Open Annihilation Authors; see COPYRIGHT
-- SPDX-License-Identifier: GPL-3.0-only

-- A waiting squad. When it and the strike group both have members, it moves
-- to the strike group's centre. The timetable is already set.

local squads = require("squads")

local rally = {}

function rally.run(seat, task)
    local me = seat.me
    if squads.count(me, task.squad) < 1 or squads.count(me, task.source) < 1 then
        return
    end
    local center = squads.centroid(me, task.source)
    if center == nil then
        return
    end
    squads.order(me, task.squad, "move", false, nil, squads.fixed_point(center))
end

return rally

quirk 注记

标了 @quirk 的一行,记录内置电脑玩家的一种行为。注记写在脚本里,让读的人在代码旁边看到它。小队 9 的注记在上面的任务表里。另外两条在 profile.lua 里。单词 any 只作为 plan 之后的第一个词才算数,而同一个词会在这一行后来的每一个词上再测一次。可下载类型的 ai_weight 对每个电脑玩家应用一次,因此权重按那个数量累加。

-- The builders' tick. Each builder places a weighted pick near the base,
-- and an idle one patrols home. A capturing builder builds only while the
-- player is under the withhold count and the hold tick has passed.
-- @quirk ai.builder-withhold-threshold moves only the build limit: an idle
-- capturing builder still circles the base from five build-capable units.

工程会压住建造,并且仍然巡逻。没有基地的小型飞行中队,在 ai.patrol-null-enemy-skip 下,抽取一条地图边缘的巡逻,三次抽取与大型中队相同,然后在那里巡逻,而不是把一次巡逻排到最近的敌人身上。

留在引擎里的部分

内置电脑玩家仍然是默认。只有文件与这一构建的文件夹一致时,席位才是 Classic。那项测试授予两种行为,以及第三项:从准备流做一次抽取。brain.shared_random 抽取对局的共享流,也就是单位脚本和模拟所抽取的那一条,因此 Classic 的飞机间隔和攻城间隔与内置电脑玩家保持同步。direct 设为 true 的 unit:stance 写入站立的移动位和开火位,并且不给出命令。任何其他席位都会被拒绝。brain.setup_random 从席位做内置准备时取下的那条流的副本中抽取,并且只推进副本。

任何其他大脑都没有这两个随机函数,直接写入姿态也会被拒绝。brain.section 为每一个大脑安装。在游戏中这个标记什么也不做。全图视野不在这份列表里。它是清单标志。Steady Commander 这些一个也不调用。

如何证明

在影子运行中,Classic 在内置电脑玩家旁边入座。对局打出的是内置电脑玩家的命令。Classic 的命令被记录并比较。任何一个 tick 都不得不同。引擎对那次比较的测试是 sim-brains-shadow 和 sim-brains-shadow-match。

换入之后,Classic 入座在内置电脑玩家原本要打的位置。引擎对此的测试是 sim-brains-classic、sim-brains-classic-data、sim-brains-classic-match、ai-computer-player、match-computer-build、native-trace、native-stage、native-director-render、native-classic-trace、native-classic-stage、native-classic-director-render、native-net-loopback-computer、native-net-loopback-computer-watcher、native-net-loopback-each-ai、native-net-loopback-computer-classic、native-net-loopback-computer-watcher-classic 和 native-net-loopback-each-ai-classic。

一次浸泡打 1,000 场带种子的无头遭遇战,每场 20,000 个 tick,覆盖基础游戏和已发布的模组。相同的种子打出相同的对局。这次浸泡是一项长时间检查:运行 Classic 的席位在整场游戏里都停留在内置电脑玩家的命令上。

如何阅读

你可以把 Classic 读成写下来的内置电脑玩家行为。引擎自己的电脑玩家仍是无人选用大脑时席位所用的玩家。安装 Classic,或打包它,在席位被设成它之前什么也不会改变。