- 游戏开发
- UI组件
【免费下载链接】godot-card-game-framework
A framework which comes with prepared scenes and classes to kickstart your card game, as well as a powerful scripting engine to use to provide full rules enforcement.
本文以仓库中src/custom/cards/sets/README.md为骨架,系统讲解 godot-card-game-framework(以下简称 CGF)中卡牌行为脚本的定义格式:从SetScripts_文件的组织方式,到「卡名 → 触发器 → 卡牌状态 → 任务」四层字典结构的每一层规则,再到KEY_/FILTER_/TRIGGER_三类常量的分工,并结合ScriptingEngine、CFControl等核心源码说明脚本如何被加载、过滤与执行。读完本文,你可以在不修改引擎代码的前提下,为卡牌写出完整的规则强制(rules enforcement)脚本。
1. SetScripts_ 文件:按卡牌包(Set)组织脚本定义
CGF 采用数据驱动的方式定义卡牌行为:每张卡的"能力文字"写在卡牌定义里,而真正可执行的行为写在脚本定义文件中。按 sets/README.md 的约定:
- 文件名以
SetScripts_开头的.gd文件包含你游戏中所有卡牌脚本的定义,按卡牌包(set)划分。例如仓库自带的两个演示文件 SetScripts_Demo1.gd 与 SetScripts_Demo2.gd,分别对应SetDefinition_Demo1.gd/SetDefinition_Demo2.gd中定义的卡牌。 - 一个最小的卡牌操作被称为一个 "task"(任务)。这是后文理解任务字典的前提:任务数组中的每一个字典就是一个 task,引擎按顺序逐条执行。
- 所有定义都放在
get_scripts()函数返回的scripts字典中,键是卡牌名(card name)。这意味着同名卡牌共享同一份脚本——脚本与"卡名"绑定,而非与某张具体卡牌实例绑定。
1.1 命名前缀是框架的硬约定
框架通过文件名前缀识别哪些文件是脚本定义。在 CFConst.gd 中可以看到两条约定:
# The path where the set definitions exist. const PATH_SETS := PATH_CARDS + "sets/" # res://src/custom/cards/sets/ # 卡牌定义文件前缀 const CARD_SET_NAME_PREPEND := "SetDefinition_" # 卡牌脚本定义文件前缀 const SCRIPT_SET_NAME_PREPEND := "SetScripts_"因此你新增一个卡牌包时,只需在src/custom/cards/sets/下放一个SetDefinition_你的包名.gd(卡牌静态属性)和SetScripts_你的包名.gd(行为脚本),框架会自动发现它们。
1.2 加载链路:SetPreload 或直接扫描目录
脚本如何进入运行时?CFControl.gd 的load_script_definitions()给出了完整流程:
func load_script_definitions() -> void: var loaded_script_definitions := [] # 优先使用 SetPreload.CARD_SCRIPTS 中 preload 的脚本文件 if "CARD_SCRIPTS" in SetPreload and SetPreload.CARD_SCRIPTS.size() > 0: for preload_set_scripts in SetPreload.CARD_SCRIPTS: loaded_script_definitions.append(preload_set_scripts.new()) else: # 否则运行时扫描 sets/ 目录下所有 SetScripts_ 前缀的文件 var script_definition_files := CFUtils.list_files_in_directory( CFConst.PATH_SETS, CFConst.SCRIPT_SET_NAME_PREPEND) for script_file in script_definition_files: loaded_script_definitions.append(load(CFConst.PATH_SETS + script_file).new()) # 对每张已定义的卡,调用其 SetScripts 文件的 get_scripts() 合并脚本 for card_name in card_definitions.keys(): for scripts_obj in loaded_script_definitions: if combined_scripts.get(card_name): break var card_script = scripts_obj.get_scripts(card_name) ...两点实战结论:
- 每个
SetScripts_文件必须实现get_scripts(card_name: String, get_modified = true) -> Dictionary,按卡名返回脚本字典(找不到时返回空字典{})。仓库中的标准实现是:
# SetScripts_Demo1.gd(L89-L92) func get_scripts(card_name: String, get_modified = true) -> Dictionary: # We return only the scripts that match the card name and trigger return(scripts.get(card_name,{}))SetPreload.gd是可选的编译期预加载入口。SetPreload.gd 提供两个可选常量CARD_SETS与CARD_SCRIPTS,填入对应文件的preload()引用即可让编译器提前参与类型检查;留空则退化为运行时目录扫描(演示项目中即为空)。
2. 四层字典结构:卡名 → 触发器 → 卡牌状态 → 任务
README 对每张卡的脚本格式给出了逐层说明,下面逐层展开,并用 SetScripts_Demo1.gd 的真实定义佐证。
2.1 第一层:卡牌名(明文)
最外层字典的键是卡名明文,即每张卡card_name(运行时对应canonical_name)变量的值。例如:
const scripts := { "Test Card 1": { ... }, "Test Card 2": { ... }, "Shaking Card": { ... }, }2.2 第二层:触发器(Trigger)
卡名之下是一个以触发器为键的字典,每个触发器声明"什么事件会点燃这段脚本":
- 默认触发器是
"manual"——玩家双击卡牌时触发。这一点可以在 CardTemplate.gd 的输入处理中得到印证:当检测到event.doubleclick且卡牌处于board状态(或处于hand且费用可支付)时,直接调用execute_scripts(),而该函数的trigger参数默认值正是"manual"(见 CardTemplate.gd#L1407-L1411)。 - 其余触发器即卡牌信号名。README 指向的外部 wiki 列表,在仓库内对应 CFControl.gd 中
SignalPropagator内建的known_card_signals:
var known_card_signals := [ "card_rotated", "card_flipped", "card_viewed", "card_moved_to_board", "card_moved_to_pile", "card_moved_to_hand", "card_token_modified", "card_attached", "card_unattached", "card_targeted", "card_properties_modified", ]信号到达后的传播机制同样在SignalPropagator中:每张新实例化的卡牌都会把上述信号全部连接到_on_signal_received(),后者再向场景树中cards分组里的每一张卡广播execute_scripts(trigger_card, trigger, details),由各卡自行判断是否命中自己的触发器(CFControl.gd#L457-L474)。
- 过滤条件(
FILTER_键)放在这一层,与状态键平级。README 明确写道:“These have to be placed at the same level as the dictionary defining the position of the card. (i.e "Board", "Hand" etc)”,即放在触发器字典内、"board"/"hand"等状态键的同一层级。所有FILTER_常量定义在 SP.gd(演示游戏的脚本属性类)及其父类 ScriptProperties.gd 中。
SetScripts_Demo2.gd中Test Card 3的"card_rotated"触发器是完整范例(SetScripts_Demo2.gd#L26-L36):
"card_rotated": { "trigger": "another", # 只响应"其他卡"触发的信号,自身旋转不触发 "board": [ { "name": "mod_tokens", "subject": "self", "modification": 1, "token_name": "blood", }, ] },2.3 第三层:卡牌状态(Card State)
触发器之下是以卡牌所处容器为键的字典:脚本只在卡牌处于对应状态时执行。键取自卡牌运行时状态判定,从 CardTemplate.gd 的get_state_exec()看,引擎会返回"board"、"hand"、"pile"(以及无状态时的"NONE"):
match state: CardState.ON_PLAY_BOARD, CardState.FOCUSED_ON_BOARD, CardState.DROPPING_TO_BOARD: if is_faceup: # 只有正面朝上的卡才能执行 board 脚本 state_exec = "board" CardState.IN_HAND, ...: state_exec = "hand" CardState.IN_PILE, ...: state_exec = "pile"这正是 README 所述"cards can handle different scripts, depending on where they are when the scripts are triggered (hand, board, pile etc)"的实现。以 SetScripts_Demo1.gd 的Test Card 1为例,同一张卡在手牌和战场上有完全不同的表现:
"Test Card 1": { "manual": { "board": [ { "name": "rotate_card", "subject": "self", "degrees": 90 } ], "hand": [ { "name": "spawn_card", "card_name": "Spawn Card", "board_position": Vector2(500,200), } ] } },从源码结构看,
execute_scripts()中还支持一个"all"键作为兜底:var any_state_scripts = card_scripts.get('all', []),即某触发器下未定义具体状态时执行all中的任务(CardTemplate.gd#L1435-L1436)。
2.4 第四层:任务(Task)数组或多选字典
状态字典内部有两种合法形式,对应 README 的两种说明:
形式一:任务数组。每个元素是一个任务字典,严格按定义顺序执行。上例中Test Card 2的两个任务先弃掉目标卡、再弃掉自己(SetScripts_Demo1.gd#L23-L43):
"Test Card 2": { "manual": { "board": [ { "name": "move_card_to_container", "subject": "target", "dest_container": "discard" }, { "name": "move_card_to_container", "subject": "self", "dest_container": "discard" }, ], "hand": [ { "name": "custom_script" } ] } },形式二:多选字典。键是选项标题字符串,值仍是任务数组;运行时弹出选项窗口让玩家选择其一。Multiple Choices Test Card在战场上可在"旋转自己"与"翻面"之间二选一(SetScripts_Demo2.gd#L38-L55):
"Multiple Choices Test Card": { "manual": { "board": { "Rotate This Card": [ { "name": "rotate_card", "subject": "self", "degrees": 90 } ], "Flip This Card Face-down": [ { "name": "flip_card", "subject": "self", "set_faceup": false } ] }, ... } },引擎侧的判断在 CardTemplate.gd#L1452-L1465:若状态脚本是Dictionary而非数组,则实例化卡牌选择场景,等待玩家点击后取出对应任务数组执行;玩家直接关闭窗口则什么都不执行。
2.5 任务字典的键:少量必需 + 大量可选
README 指出每个任务字典"有许多可能键(取决于任务类型),也有少数必需键",完整清单以 ScriptProperties.gd 的文档为准(该文件即 README 中链接的ScriptProperties页面,仓库内位于src/core/ScriptingEngine/,并另有人工维护的 SCRIPTING_ENGINE.md 总览)。实践中所有任务都需要的键是"name"(任务类型,决定调用引擎中哪个函数)。常用可选键包括:
键(KEY_常量) | 类型/默认值 | 作用 |
|---|---|---|
subject | String,默认空(无主体) | 任务作用于哪些卡:self/target/trigger/boardseek/tutor/index/previous |
subject_count | int,默认 1,或"all" | 需要检索到的主体数量(配合boardseek/tutor/index) |
is_cost | bool,默认 false | 标记任务为"费用":dry-run 阶段先校验,付不起则整个脚本不执行 |
needs_subject | bool,默认 false | 要求任务至少有一个有效主体,否则整段脚本中止 |
degrees | int | rotate_card的旋转角度(90 的倍数) |
set_faceup | bool | flip_card的目标朝向 |
src_container/dest_container | String | 移动类任务的源/目的容器 |
card_name | String | spawn_card要生成的卡名 |
board_position | Vector2 | 战场放置位置 |
token_name/counter_name/modification | String/String/动态值 | 令牌与计数器任务参数 |
nested_tasks | Array | 嵌套子脚本,拥有独立的费用计算 |
tags | Array[String] | 为脚本打标签,供filter_tags过滤或 Alterant 引擎挂钩 |
以上默认值均可在 ScriptProperties.gd 的get_default()中逐条核对。"subject"各取值的语义(target发起目标选取、boardseek全战场检索、tutor指定堆检索、index按索引取卡、previous复用上一任务主体)见 ScriptProperties.gd#L31-L73。
2.6 特例:custom_script任务
README 特别强调:当任务"name"为 **custom_script"时,不需要任何其他键,完整实现写在 CustomScripts.gd 中。引擎在 ScriptingEngine.gd#L151-L156 对该任务名做了分支,直接调用CustomScripts.new(costs_dry_run()).custom_script(script)。演示项目的实现按卡的canonical_name分发(CustomScripts.gd#L18-L38):
func custom_script(script: ScriptTask) -> void: var card: Card = script.owner var subjects: Array = script.subjects match script.owner.canonical_name: "Test Card 2": if not costs_dry_run: card.queue_free() # "手牌中的 Test Card 2 将自己移出游戏" "Test Card 3": ...注意costs_dry_run参数:费用预检(dry-run)阶段也会走到这里,实现中必须用它把"不可逆副作用"挡在外面。Test Card 2手牌分支{ "name": "custom_script" }就是这条路径的调用方。
3. 三类常量前缀:KEY_、FILTER_、TRIGGER_ 的分工
README 最后一段是对整个常量体系的总纲,这是理解"脚本如何被自动触发"的关键。三类常量都定义在 ScriptProperties.gd 中(游戏侧可通过继承 SP.gd 扩展):
3.1KEY_前缀——任务行为键
用在任务字典内部,描述单个任务如何执行(如KEY_IS_COST、KEY_SELECTION_COUNT、KEY_DEGREES……)。唯一的"例外"即上文 2.6 节的custom_script:它不需要任何KEY_配套,行为完全由CustomScripts.gd中的代码决定。
3.2TRIGGER_前缀——信号携带的事实
以TRIGGER_开头的常量是卡牌信号在trigger_details字典中发送的键名,描述"触发脚本的那次操作长什么样"。例如 ScriptProperties.gd#L1122-L1224 中:
card_rotated发送degrees(旋转了多少度);card_flipped发送is_faceup;card_moved_to_*发送source/destination(离开的容器、进入的容器);card_token_modified发送new_count/previous_count/token_name;card_properties_modified发送property_name/new_property_value/previous_property_value。
这些值不是写死在脚本里的,而是每次信号触发时动态传入。
3.3FILTER_前缀——对触发事实的声明式校验
以FILTER_开头的常量用在卡牌脚本定义中,但只对自动触发的脚本(信号触发器)或目标/主体检索有效。它们在运行时与对应的TRIGGER_值做比对:不一致则该触发器被判无效、脚本不执行。这一比对逻辑全部集中在 ScriptProperties.gd 的静态方法filter_trigger()中,节选其对照关系:
| FILTER_ 键 | 比对的 TRIGGER_ 值 | 语义 |
|---|---|---|
filter_degrees | degrees | 要求触发旋转的角度恰好等于该值 |
filter_faceup | is_faceup | 要求触发翻面的朝向 |
filter_source/filter_destination | source/destination | 要求卡牌是从/移动到指定容器 |
filter_token_name | token_name | 只响应指定名称令牌的变动 |
filter_count | new_count | 令牌/计数器到达某个数量(可配comparison:eq/ne/gt/lt/le/ge) |
filter_count_difference | previous_countvsnew_count | 只响应"增加"(increased)或"减少"(decreased) |
filter_tags | tags | 要求触发方脚本/信号携带全部指定标签 |
filter_task | task_name | 只响应特定任务名发出的信号 |
filter_card_name | card_name | 只响应生成/操作指定卡名的信号 |
此外,FILTER_STATE("filter_state_"+ 后缀)是一族用于主体检索的状态过滤器,后缀必须指明校验对象:trigger(触发卡)/subject(目标卡)/seek(战场自动检索卡)/tutor(堆内自动检索卡)(ScriptProperties.gd#L856-L907)。每个filter_state_*的值是数组:数组元素之间为OR,元素内的各过滤键为AND,可组合filter_properties、filter_tokens、filter_parent、filter_degrees、filter_faceup、filter_group、filter_class等。其校验实现在check_validity()(ScriptProperties.gd#L1659-L1712)。
放置位置的硬性要求(README 原文):这些过滤键必须与状态字典("board"/"hand"等)放在同一层,即触发器字典内部:
"card_rotated": { "trigger": "another", "filter_degrees": 90, # ← 与 "board" 平级,只响应 90° 旋转 "board": [ ... ] }3.4 扩展自己的过滤器:继承 SP
SP类(class_name SP extends ScriptProperties)是为游戏方预留的扩展点。SP.gd 演示了如何新增一个FILTER_常量并在filter_trigger()中追加校验——先调用父类逻辑,再叠加自定义条件:
const FILTER_DEMONSTRATION = "is_demonstration" static func filter_trigger(card_scripts, trigger_card, owner_card, trigger_details) -> bool: var is_valid := .filter_trigger(card_scripts, trigger_card, owner_card, trigger_details) # 若脚本定义了 filter_is_demonstration 且与触发详情不一致,则判定无效 if is_valid and card_scripts.get("filter" + FILTER_DEMONSTRATION) \ and card_scripts.get("filter" + FILTER_DEMONSTRATION) != \ trigger_details.get(FILTER_DEMONSTRATION): is_valid = false return(is_valid)因为CardTemplate.execute_scripts()调用的是SP.filter_trigger(...)(CardTemplate.gd#L1426-L1430),游戏方对SP的任何扩展都会自动生效于所有卡牌。
4. 执行管线:从双击/信号到任务序列
把 README 描述的格式落到运行时,CardTemplate.gd 的execute_scripts()揭示了完整管线,这也是脚本格式各层级"为什么长这样"的答案:
- 取脚本:
retrieve_scripts(trigger)先查卡实例自身的scripts(运行时可覆盖,同名卡中仅这张卡生效),否则回落到加载期合并好的cfc.set_scripts[canonical_name](CardTemplate.gd#L1520-L1534)。 - 触发器过滤:
SP.filter_trigger()按 3.3 节规则校验;不通过则清空脚本,什么都不执行。 - 状态分发:
get_state_exec()决定取"board"/"hand"/"pile"哪一份任务(2.3 节)。 - 可选确认:
is_optional_<state>键会让引擎先弹确认框(对应KEY_IS_OPTIONAL)。 - 多选弹窗:任务若是字典则弹出选项菜单(2.4 节)。
- 费用 dry-run:以
RunType.COST_CHECK跑一遍ScriptingEngine.execute()——只有is_cost/needs_subject任务会被真正评估,且不做实际改动;can_all_costs_be_paid为真才进入正式执行,否则改跑is_else任务(ScriptingEngine.gd#L97-L233)。 - 正式执行:按数组顺序逐条调用任务函数,
previous主体在任务间传递(prev_subjects),每个任务完成时发出single_task_completed,全部完成发出tasks_completed。
SetScripts_Demo1.gd中Shaking Card的手牌脚本几乎把上述机制全部串了一遍(SetScripts_Demo1.gd#L44-L83):先无条件mod_counter +5;再执行一个nested_script任务,其nested_tasks里是一个可选项式费用——从手牌弃掉恰好 2 张卡(needs_selection+selection_count: 2+selection_type: "equal"+selection_optional: true+selection_ignore_self: true,全部标记is_cost),费用成立则追加mod_counter +3。它对应的能力文字在 SetDefinition_Demo1.gd 中("Gain 5 Research, 你可以弃置两张牌再获得 3 点")。
SetScripts_Demo2.gd中Multiple Choices Test Card的手牌脚本还演示了ask_integer+retrieve_integer的数值传递:先让玩家输入 1~5,再用subject_count: "retrieve_integer"从牌库顶弃掉相应数量的卡(SetScripts_Demo2.gd#L56-L71)。
4.1 引擎提供的内置任务一览
任务name的合法取值即 ScriptingEngine.gd 中可被调用的任务函数:rotate_card(L241)、flip_card(L259)、view_card(L272)、move_card_to_container(L288)、move_card_to_board(L324)、mod_tokens(L381)、spawn_card(L444)、spawn_card_to_container(L519)、shuffle_container(L604)、attach_to_card(L617)、host_card(L627)、modify_properties(L639)、ask_integer(L746)、add_grid(L768)、mod_counter(L806)、execute_scripts(L864)、nested_script(L903)、null_script(L937),外加 2.6 节的custom_script特例。每个任务的详细键要求见其函数头部的注释块(如 ScriptingEngine.gd#L236-L240)。
5. 落地清单:新增一个卡牌包的脚本文件
综合 README 约定与源码证据,新增一个带脚本的卡牌包需要满足:
- 在
src/custom/cards/sets/下创建SetDefinition_包名.gd(SET+CARDS常量,见 SetDefinition_Demo1.gd)与SetScripts_包名.gd; SetScripts_文件extends Reference,定义scripts字典,卡名必须与CARDS中的键逐字一致(脚本按卡名精确匹配,见 CFControl.gd#L275-L285 的合并循环);- 实现
get_scripts(card_name, get_modified = true),返回scripts.get(card_name, {}); - 严格遵循四层结构:卡名 → 触发器(
manual或 11 个known_card_signals之一)→ 状态(board/hand/pile,可选all兜底)→ 任务数组或多选字典; - 任务字典以
"name"为必需键,其余行为键从KEY_常量中选取;自动触发脚本在触发器层级补充FILTER_键与trigger(self/another/any)限定; - 无法用内置任务表达的逻辑,用
custom_script落到CustomScripts.gd(记得区分costs_dry_run); - 需要自定义过滤器时,在
SP.gd中新增FILTER_常量并扩展filter_trigger(); - 可选:在 SetPreload.gd 的
CARD_SCRIPTS中preload()新文件以获得编译期检查。
适用前提:以上结构以当前仓库的 Godot 3 语法(Reference、yield、signal关键字)为准;任务/过滤器的完整键参考以仓库内 ScriptProperties.gd 的常量注释和 SCRIPTING_ENGINE.md 为最终依据。仓库集成测试中大量脚本引擎用例(如 test_scripting_engine_tasks.gd、test_scripting_engine_signals.gd、test_scripting_engine_costs.gd)可作为各键行为的可执行验证。
- 游戏开发
- UI组件
【免费下载链接】godot-card-game-framework
A framework which comes with prepared scenes and classes to kickstart your card game, as well as a powerful scripting engine to use to provide full rules enforcement.
相关推荐
Android蓝牙BLE开发完全解析:从连接到数据传输
Android蓝牙BLE开发完全解析:从连接到数据传输 蓝牙BLE(低功耗蓝牙)技术在物联网设备、智能硬件和移动应用中广泛应用。本指南将带你快速掌握Androi
godot-card-game-framework:从安装部署到脚本引擎的卡牌游戏框架实战指南
godot card game framework:从安装部署到脚本引擎的卡牌游戏框架实战指南 godot card game framework(下文简称 C
游戏开发UI组件Command & Conquer Generals - Zero Hour任务系统与脚本执行机制完全指南
Command & Conquer Generals Zero Hour任务系统与脚本执行机制完全指南 《命令与征服:将军 绝命时刻》作为即时战略游戏的经典之作
游戏开发
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考