首页 › 模组制作 › 大脑的 Lua
第 08 章

大脑的 Lua

Steady Commander 的清单和脚本、钩子、大脑可以知道和做到的事、数值、随机抽取、计时器和聊天。

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

Steady Commander

Steady Commander 是 brains/examples/steady-commander 里虚构的大脑。下面两个文件按写好的原样复制。works-with 是加了引号的星号,因此这个包适配任意模组和基础游戏。脚本返回一张钩子表。你给那张表起一个不是 brain 的名字:以 local brain 开头的文件会遮蔽配置所安装的全局变量。fits 返回 true。save 返回已经派出多少个小队。进攻选择是 soon、steady 或 late。

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

oabrain: 1
id: steady-commander
name: Steady Commander
version: "1.0"
author:
  name: Ridge Workshop
description: "Builds a little economy, then a factory, and sends a squad when it is ready."
tags:
  - economy
  - teaching
works-with:
  - "*"
levels:
  - id: easy
    name: Easy
    difficulty: easy
  - id: medium
    name: Medium
    difficulty: medium
  - id: hard
    name: Hard
    difficulty: hard
options:
  attack:
    type: choice
    label: "How soon to attack"
    values: ["soon", "steady", "late"]
    default: "steady"
    labels: ["Soon", "Steady", "Late"]
entry: brain.lua
入口脚本 · brain.lua
-- SPDX-FileCopyrightText: The Open Annihilation Authors; see COPYRIGHT
-- SPDX-License-Identifier: GPL-3.0-only

-- Economy, then one land factory. Combat units gather until the attack
-- option's count is met, then go to the nearest enemy in sight.
-- save() keeps how many squads have been sent.

local hooks = {}
local sent, armed = 0, false
local commander, metal_type, energy_type, factory_type, combat_type
local metal_done, energy_done, factory_ordered = 0, 0, false
local factories, squad, sent_ids = {}, {}, {}
local ready = false

local function real_of(record, key)
    local value = record[key]
    if type(value) == "userdata" and type(value.tonumber) == "function" then
        return value:tonumber()
    end
    return type(value) == "number" and value or 0
end

local function positive(record, key)
    return real_of(record, key) > 0
end

local function words(record)
    local found, list = {}, record.category
    if type(list) ~= "table" then return found end
    for index = 1, #list do found[list[index]] = true end
    return found
end

-- A category the commander lacks, or no categories, so a copy is skipped.
local function labelled(record, commander_words)
    local any, fresh = false, false
    for word in pairs(words(record)) do
        any, fresh = true, fresh or commander_words[word] ~= true
    end
    return fresh or not any
end

local function land(record)
    return real_of(record, "minwaterdepth") <= 0
end

local function land_combat(record)
    return record.canmove == true and record.canattack == true and record.builder ~= true
        and record.canfly ~= true and land(record)
end

local function energy_record(record)
    return positive(record, "energymake") or positive(record, "makesmetal")
        or positive(record, "windgenerator") or positive(record, "tidalgenerator")
end

local function pick(list)
    if #list == 0 then return nil end
    return list[brain.random(#list)]
end

local function classify(list, commander_words)
    local metals, energies, plants = {}, {}, {}
    for index = 1, #list do
        local record = list[index]
        if record.commander ~= true and labelled(record, commander_words) then
            if positive(record, "extractsmetal") and land(record) then
                metals[#metals + 1] = record
            elseif energy_record(record) and land(record) then
                energies[#energies + 1] = record
            elseif record.builder == true and record.bmcode == 0 and land(record) then
                local built, fights = game.build_list(record), {}
                if type(built) == "table" then
                    for slot = 1, #built do
                        if land_combat(built[slot]) then fights[#fights + 1] = built[slot] end
                    end
                end
                local fight = pick(fights)
                if fight ~= nil then plants[#plants + 1] = { plant = record, unit = fight } end
            end
        end
    end
    return metals, energies, plants
end

local function squad_size()
    if me.options.attack == "soon" then return 4 end
    if me.options.attack == "late" then return 12 end
    return 8
end

local function plan_counts()
    if me.difficulty == "easy" then return 1, 1 end
    if me.difficulty == "hard" then return 2, 2 end
    return 2, 1
end

local function find_commander()
    local found = nil
    me:each_unit(function(unit)
        if found == nil and unit.type ~= nil and unit.type.commander == true then found = unit end
    end)
    return found
end

local function free(unit)
    local kind, _, idle = unit:head_order()
    return kind == nil or idle == true
end

local function place_build(builder, record)
    if builder.x == nil or builder.z == nil then return false end
    local facing = brain.random(4) - 1
    local _, _, x, z = brain.place(record, { x = builder.x, z = builder.z }, 200, facing)
    if x == nil then return false end
    return builder:build(record, { x = x, z = z }, facing) == true
end

local function note_finished(unit)
    local record = unit.type
    if record == nil or record.commander == true or unit.alive ~= true then return end
    if positive(record, "extractsmetal") then metal_done = metal_done + 1
    elseif energy_record(record) then energy_done = energy_done + 1
    elseif record.builder == true and record.bmcode == 0 then
        factories[unit.id], factory_ordered = unit, true
    elseif land_combat(record) and sent_ids[unit.id] ~= true then squad[unit.id] = unit
    end
end

local function nearest(origin)
    local best, best_distance
    for index = 1, #me.seen do
        local unit = me.seen[index]
        if unit.alive == true and unit.owner ~= me.player and me:allied(unit.owner) ~= true then
            local distance = brain.distance(origin, unit)
            if distance ~= nil and (best_distance == nil or distance < best_distance) then
                best, best_distance = unit, distance
            end
        end
    end
    return best
end

local function count_squad()
    local count = 0
    for _ in pairs(squad) do count = count + 1 end
    return count
end

local function try_send()
    if count_squad() < squad_size() then return end
    local origin = (commander ~= nil and commander.alive == true) and commander or nil
    if origin == nil then
        for _, unit in pairs(squad) do origin = unit break end
    end
    local target = origin ~= nil and nearest(origin) or nil
    if target == nil then return end
    for id, unit in pairs(squad) do
        if unit.alive == true then unit:attack(target) end
        sent_ids[id] = true
    end
    squad, sent = {}, sent + 1
end

local function prepare()
    if ready then return end
    if commander == nil or commander.alive ~= true then commander = find_commander() end
    if commander == nil or commander.type == nil then return end
    local list = game.build_list(commander.type)
    if type(list) ~= "table" or #list == 0 then return end
    local metals, energies, plants = classify(list, words(commander.type))
    metal_type, energy_type = pick(metals), pick(energies)
    local plant = pick(plants)
    if plant ~= nil then factory_type, combat_type = plant.plant, plant.unit end
    ready = metal_type ~= nil or energy_type ~= nil or factory_type ~= nil
end

local function build_next()
    prepare()
    if commander == nil or commander.alive ~= true then commander = find_commander() end
    if commander == nil or not free(commander) then return end
    local metal_need, energy_need = plan_counts()
    if metal_type ~= nil and metal_done < metal_need then
        if place_build(commander, metal_type) then return end
    end
    if energy_type ~= nil and energy_done < energy_need then
        if place_build(commander, energy_type) then return end
    end
    if factory_type ~= nil and not factory_ordered then
        if place_build(commander, factory_type) then factory_ordered = true end
    end
end

local function queue_plants()
    if combat_type == nil then return end
    for _, plant in pairs(factories) do
        if plant.alive == true and free(plant) then plant:queue(combat_type) end
    end
end

local function step()
    build_next()
    queue_plants()
    try_send()
end

function hooks.fits() return true end

function hooks.start(_, _, saved)
    sent = type(saved) == "table" and type(saved.sent) == "number" and saved.sent or 0
    metal_done, energy_done, factory_ordered, factories, squad = 0, 0, false, {}, {}
    metal_type, energy_type, factory_type, combat_type = nil, nil, nil, nil
    ready, commander = false, find_commander()
    prepare()
    me:each_unit(function(unit)
        if unit.finished == true then note_finished(unit) end
    end)
    if not armed then
        brain.every(30, step)
        armed = true
    end
end

function hooks.onUnitFinished(unit)
    note_finished(unit)
    try_send()
end

function hooks.onUnitIdle(unit)
    local record = unit.type
    if record == nil then return end
    if record.commander == true then
        commander = unit
        build_next()
    elseif record.builder == true and record.bmcode == 0 then
        factories[unit.id] = unit
        queue_plants()
    end
end

function hooks.save() return { sent = sent } end

return hooks

钩子

入口返回一张表。缺了的函数会被跳过。fits 和 start 在第一个 tick 之前运行。随后一个回合按这个顺序交付:排好队的单位事件,雷达和声纳先失去再获得,同盟变化,战败,聊天,计时器,然后 onTick。第一个回合存下同盟以及谁在参与,并且不报告这些变化。一个回合最多保留 65,536 个事件。超出的事件会被丢掉。

钩子何时参数
fits第一个 tick 之前。返回 true,或一个字符串理由。其他任何东西都会停止入座。game
start第一个 tick 之前。saved 是已加载的对局所保留的,或 nil。game, me, saved
onUnitCreated一个单位被创建。是席位自己的单位。没有建造者时 builder 为 nil。unit, builder
onUnitFinished一个单位完成。是席位自己的单位。没有建造者,或建造者就是这个单位时,builder 为 nil。同一单位随后有一次被取消的死亡时,完成事件会被丢掉。unit, builder
onUnitDamaged席位自己的单位受到伤害。kind 是 weapon、paralyze、heal 或 environment。只有席位可以知道那个单位时才传入 attacker,否则为 nil。unit, attacker, amount, kind
onUnitDestroyed一个单位死亡:席位自己的、盟友的、已见列表上的敌人,或大脑有全图视野时的任何死亡。kind 为 captured 的死亡不是这个钩子。wreck 是一个名字和一个位置,或 nil。unit, killer, wreck
onUnitGiven一个单位被移交。旧所有者和新所有者。unit, old, from, to
onUnitIdle席位自己的单位变为空闲。unit
onCommandDone席位自己的单位上的一条命令完成。result 是 done 或 failed。unit, order, result
onUnitLoaded一个单位被装载。单位的所有者和运载单位的所有者。unit, carrier
onUnitUnloaded一个单位被卸载。单位的所有者和运载单位的所有者。unit, carrier
onUnitSeen席位看见一个单位。unit
onUnitLost席位失去对一个单位的视野。unit
onPathFailed一个单位找不到路径。该单位的所有者。unit
onRadarLost一个接触离开画面。同一槽位先失去、后获得。盟友单位永远不是接触。contact
onRadarContact一个接触进入画面。接触没有类型、没有所有者,也没有单位句柄。contact
onAllianceChanged一个同盟位发生变化。其他玩家,按索引顺序。player, allied
onPlayerDefeated一名玩家停止参与。player
onChat一行聊天到达了席位。from 是发送者的索引。text 前面没有名字。to 是 all、allies、enemies 或 me。最多有 32 行在等待。from, text, to
onTick回合的末尾。参数是对局 tick。tick
save存档来询问时。返回纯数据,或什么都不返回。出错会把席位交还。不是纯数据的值不会写入存档。无
report控制台来询问时。返回文本,至多 64 KiB。没有 report 的大脑写出一行:清单名字、一个空格、版本,并且不给出报告。无

大脑可以知道什么

game 是一张只读表,在席位的整个存续期间都是这一张。fits 和 start 收到同一张表。标准点名每一个字段。下面是这些分组。

哪里作用
game内容包、已开启的扩展、引擎版本、模式、难度、对局是否开放、模组(它的 id、name、version、sides、hacks 和 limits)、按生效顺序的变异器、地图,以及 AI 配置。
game.map地图的名字、它的尺寸、海平面、潮汐和风、金属和高度、移动类别、可通过性,以及两个位置能否经由某一类别的开放格子相遇。它不查看席位已经看见的东西,也不搜索路径。
定义units、weapons 和 features:对局已加载的记录,在模组和变异器之后。它们是只读的。
me玩家索引、级别标识、难度、解析后的选项、资源、盟友、席位自己的单位和小队、视野,以及雷达画面。接触在仍是接触时给出一个位置。它没有类型,也没有所有者。
单位句柄任何句柄都能读 id 和 slot。位置、health 和类型在席位能看见那个存活单位时可以读,或在大脑有全图视野时可以读。命令、小队和 cloak 只在席位自己的存活单位上可以读。类型隐藏伤害时 health 保持 nil,除非席位拥有该单位,或大脑有全图视野。
全图视野没有这个标志时,game.units、最近的敌人和原始已见列表都是 nil。有这个标志时,game.units 列出每一个被占用的槽位。me:sees 和 me:sees_unit 不把这个标志当作肯定。这个标志不授予共享随机流,也不授予直接姿态。

大脑可以做什么

单位句柄接受玩家可以下达的命令。每次调用立刻到达对局。命令在下一个 tick 运行。拒绝时返回 false。第一个失败的检查是 dead、not_own、over_limit(该回合已经向对局传递了 4,096 条命令)、not_seen 或 not_buildable。对局作出的拒绝计为 by_engine。

调用作用
unit:move一次地面移动。
unit:patrol一次巡逻。
unit:attack对一个单位的攻击。
unit:attack_ground对空地的攻击。
unit:attack_point对一个点的攻击,不排队。
unit:dgun特殊攻击,对一个位置或一个单位。
unit:guard守卫一个单位。
unit:repair修理一个单位。
unit:help协助一次建造。
unit:reclaim回收一个单位,或一个位置上的地物。
unit:capture捕获一个单位。
unit:load装载一个单位。
unit:unload在一个位置卸载。
unit:build一次移动建造。facing 是 0 到 3。
unit:queue一次工厂建造。count 是 1 到 1000,默认是 1。
unit:requeue改变已排队的数量。delta 是从 -1000 到 1000 的非零整数。
unit:stop停止。
unit:on设置活动位。
unit:cloak隐形。
unit:self_destruct切换那一个单位的自毁。
unit:stancemove 是 hold、manoeuvre 或 roam。fire 是 hold、return 或 at_will。direct 设为 true 会写入站立位并且不给出命令,只有 Classic 可以这样做。
me:assign把席位自己的一个单位放进小队。席位并不拥有的单位,或一个已死亡的句柄,返回 false。
brain.say把一行聊天交给对局。聊天面板说明怎么做。
brain.site测试一个建造格子。facing 是 0 到 3。check 是 site 或 yard。
brain.place从一个点一圈圈向外找,返回选址检查成立的第一个格子,以及那个格子的中心,或 nil。
map:reachable两个位置能否经由一个移动类别相遇。它不搜索路径,也不改变移动图。

数值

fixed 以 65536 分之一计数。轴是 x 向东、z 向南、y 向上。角度是一个整数,65536 为一整圈。f32 保存一个 binary32 值,也就是玩家资源的数值宽度。geometry.direction、geometry.planar_length、geometry.length、geometry.scale、geometry.sine_scaled 和 geometry.cosine_scaled 是引擎自己的朝向、距离,以及缩放后的正弦和余弦。

随机抽取

brain.random(n) 从大脑自己的流中返回 1 到 n 的一个整数。这条流的种子来自对局、玩家以及大脑的标识和版本。从它抽取不会推进对局的共享流。替换 math.random 不会改变 brain.random。

brain.shared_random 抽取对局的共享流,brain.setup_random 抽取席位做内置准备时取下的一份副本。标准为 Classic 安装它们,并说明影子运行在哪里拥有它们。任何其他大脑两者都没有。brain.float_heap_order 按整数距离排序,近的在前,并且为每个席位安装。

计时器

brain.after(ticks, fn) 在那么多个回合之后把 fn 运行一次。brain.every(ticks, fn) 在那时运行,并在它刚遇到的到期 tick 之后,每隔 ticks 个回合再运行。ticks 是从 1 到 2147483647 的整数。每次调用返回一个标识。标识从 1 开始,只增不减。brain.cancel(id) 在移除了一个等待中的计时器时返回 true。一个大脑可以让 1024 个计时器处于等待。到期的计时器在聊天之后、onTick 之前运行。计时器内部的错误会像钩子错误一样停掉席位。

聊天

自上一个回合以来,onChat 对每一行运行一次。缺少 onChat 会被跳过,而那一行仍会被消耗。brain.say(text, to) 立刻把一行交给对局。to 是 all(默认)、allies、enemies,或从 0 到 9 的玩家索引。行已经排队时,调用返回 true。它自己不控制节奏。对局会控制,电脑席位说出的行之间隔 30 个 tick。拒绝是 not_a_seat、empty、command、too_soon、no_target 或 no_chat。