纯文字游戏。界面上只有文字和按钮两种东西。
框架优雅,面向对象风格,只有4种类。
有一种类叫对话,但和普通分支对话不一样的是,数据结构里可以有函数。这让分支的实现实际上是自我修改对话结构来写的。
演示农场游戏内包含大量各种类型的演示,甚至拿对话做小型数字输入软键盘。
这个农场游戏代码量还是非常大的。
试玩:https://www.altzin.com/terminal/
框架和插件源码:https://github.com/QianCF/textgame
演示插件的代码风格还是很奇特的。截取一段给你们看看。
(function () {
"use strict";
class 收纳袋 {
static 名字 = "收纳袋";
constructor(data) {
this.描述 = "一个神奇的袋子,可以存放最多64个物品。";
this.卖掉的价值 = 10;
this.能否使用 = true;
// 初始化内部存储
this.内部物品 = data?.内部物品 || [];
this.容量 = 64;
// 确保每个物品都有唯一的ID(为了在对话中精确识别)
this.内部物品.forEach((item, index) => {
if (!item.__收纳袋id) {
item.__收纳袋id = Date.now() + '-' + index + '-' + Math.random().toString(36).substr(2, 9);
}
});
}
使用后执行的js函数(thisArg, cls) {
// 显示操作选择对话
Game.显示对话(new 收纳袋操作对话(thisArg));
return false; // 不自动消耗
}
显示附加文本所用js函数(thisArg, cls) {
// 动态拼接前6个物品的名字
if (!thisArg.内部物品 || thisArg.内部物品.length === 0) {
return "(空)";
}
const 前6个物品 = thisArg.内部物品.slice(0, 6);
const 名字列表 = 前6个物品.map(item => {
// 获取物品的基本名字(不包括附加文本)
if (item && item.constructor) {
return item.constructor.名字 || "未知物品";
}
return "未知物品";
});
let 显示文本 = 名字列表.join(',');
if (thisArg.内部物品.length > 6) {
显示文本 += `...等${thisArg.内部物品.length}个物品`;
} else {
显示文本 += `(共${thisArg.内部物品.length}个)`;
}
return `(${显示文本})`;
}readme:
插件化游戏框架文档
核心引擎在全局暴露 Game,插件通过调用注入 API 注册“物品 / 商店 / 地点”,并可在任意时刻发起“对话”。
全局对象 Game
Game.状态: 当前游戏状态对象(插件请直接读写它)。默认字段:钱:number,当前货币(默认为 0)。物品:Array<物品实例>,背包列表。所在地点:string,当前地点名称。打开的商店:string | null,当前打开的商店名称。购买物品历史记录:Array<{ 商店: string; 物品: string; 价格: number; 时间: number; }>进入商店历史记录:Array<{ 商店: string; 时间: number; 地点: string; }>进入地点历史记录:Array<{ 地点: string; 时间: number; 从: string | null; }>其他状态栏:Array<{ 名字: string; 值: any }>,自定义状态栏显示(一行三个,按顺序显示在"钱"、"地点"、"商店"下方)- 插件可以随意添加新字段
Game.提示(消息, 标题?):alert弹窗。Game.消息队列添加(消息, 标题?): 推送一条消息到消息队列并触发渲染。Game.消息队列:Array,消息队列数组(响应式),可以直接访问和操作。Game.显示对话(对话实例): 进入对话模式(插件自行new对话实例)。Game.关闭对话(): 关闭当前打开的对话。Game.进入地点(地点名, 从点击进入=false): 切换地点。- 会调用目标地点
检查是否允许进入的js函数;返回false会阻止进入。 - 允许进入后会调用旧地点
离开时执行的js函数,再调用新地点进入时执行的js函数。 Game.打开商店(商店名): 打开商店(调用该商店进入时执行的js函数)。Game.关闭商店(): 关闭当前商店(调用当前商店离开时执行的js函数)。Game.购买物品(商店名, idx): 通过商店名和物品索引定位物品条目进行购买。- 会调用商店条目
购买时执行的js函数(cls, 条目);返回false会阻止购买。 - 购买成功后会实例化物品(通过条目中的
物品名和物品数据),并调用物品实例不持有到持有时执行的js函数(thisArg, cls, shopCls)。
+ Game.购买物品(商店名, idx): 购买物品。
+ - 通过商店名和物品索引定位物品条目。
+ - 会调用商店条目 购买时执行的js函数;返回 false 会阻止购买。
+ - 购买成功后会实例化物品,并调用物品实例 不持有到持有时执行的js函数。
- Game.卖出物品(itemInst): 卖出背包中的物品实例。
- 会调用物品实例 卖掉时执行的js函数;返回 false 会阻止卖出。
- 卖出成功后会调用物品实例 持有到不持有时执行的js函数。
- Game.使用物品(itemInst): 使用背包中的物品实例。
- 会调用/读取物品 能否使用;结果为 false 时 阻止使用(按钮禁用/提示无法使用)。
- 会调用物品实例 使用后执行的js函数;返回 false 会阻止引擎继续结算/后续逻辑(是否消耗物品由物品代码自行控制)。
- 注入/加载:
- Game.注入物品类(cls): 注入物品类,名字从类的 static 名字 属性自动读取
- Game.注入商店类(cls): 注入商店类
- Game.注入地点类(cls): 注入地点类
- Game.加载插件(list: string[]): 顺序加载脚本后调用各类 加载时执行的js函数
- Game.启动(): 初始化并进入"起点",如果有设置 Game.起点,则会进入对应起点
- Game.起点:string:自定义起点
- 类列表:
- Game.物品类列表: 所有已注册的物品类
- Game.商店类列表: 所有已注册的商店类
- Game.地点类列表: 所有已注册的地点类
- 渲染:
- Game.渲染(): 手动触发渲染。注意:渲染 不会实时进行,请在按钮状态改变等任何情况下调用 Game.渲染() 来更新界面。
统一的回调参数约定(非常重要)
所有回调/钩子请统一通过 Game.状态 访问状态。
引擎调用回调时:
- 实例方法(物品、对话)
- 第 1 个参数:
thisArg(实例对象) - 第 2 个参数:
cls(该实例的类) - 其余参数:见对应方法说明
- 静态方法(商店、地点,含条目里的函数)
- 第 1 个参数:
cls(该类对象) - 引擎不会自动绑定 JavaScript 的
this,请使用传入的thisArg访问实例。
插件约定的 4 类对象(需要哪些字段/方法)
1) 物品类(需要实例化)
class 我的物品 {
// 必须:物品名字(用于显示和注册)
static 名字 = "物品名";
// 跨实例数据(可选)
static 数据 = {};
this.描述 = "描述";
this.卖掉的价值 = 0;
constructor(data) {
// 展示字段
// 物品可以读取 data 并写入自己的属性,也可以不这么做
// data 来自商店的 物品数据 字段(如果商店有提供)
if (data) {
// 示例:可以读取并处理 data
// this.自定义属性 = data.自定义属性;
}
}
能否使用(thisArg, cls) { return true; }
// 返回 false:阻止使用(按钮禁用/提示无法使用)
使用后执行的js函数(thisArg, cls) { return true; }
// 返回 false:阻止引擎继续结算/后续逻辑
卖掉时执行的js函数(thisArg, cls) { return true; }
// 返回 false:阻止卖出
不持有到持有时执行的js函数(thisArg, cls, shopCls) {}
// 购买成功并加入背包后调用
持有到不持有时执行的js函数(thisArg, cls) {}
// 卖出成功并从背包移除后调用
static 加载时执行的js函数(cls) {}
// 插件加载完成后统一调用(每个物品类一次)
显示附加文本所用js函数(thisArg, cls) { return ""; }
// 背包列表里,物品名后显示的附加信息
}注册:Game.注入物品类(我的物品);
注意:物品名字从类的 static 名字 或 name 属性自动读取,任何情况下都使用真名显示(包括在商店中)。
2) 商店类(不实例化,使用静态字段)
class 我的商店 {
static 名字 = "展示名";
static 显示的文本 = "欢迎词";
// 任意自定义数据(默认空对象)
static 其它数据 = {};
// 物品数据现在是数组形式,支持同名物品不同数据
static 数据 = [
{
物品名: "物品A",
价格: 0,
物品数据: {}, // 商店可以写物品数据,物品初始化时可以通过 constructor 的 data 参数读取并写入自己的属性
购买时执行的js函数(cls, 条目) { return true; },
// 返回 false:阻止购买(通过 cls / Game.状态 获取上下文)
显示商店对物品附加文本所用js函数(cls, 条目) { return ""; },
},
];
static 进入时执行的js函数(cls) {}
static 离开时执行的js函数(cls, nextShopCls) {}
static 加载时执行的js函数(cls) {}
}注册:Game.注入商店类("商店名", 我的商店);
注意:物品数据由对象改为数组,支持同名物品不同数据的购买和显示。购买时会遍历数组找到对应条目。
3) 地点类(不实例化,使用静态字段)
class 我的地点 {
static 名字 = "地点名";
static 显示的文本 = "描述";
// 任意自定义数据(默认空对象)
static 其它数据 = {};
static 数据 = {
指向地点: [{ 地点: "目标名", 显示的文本: "按钮文本" }],
商店: ["商店名"],
按钮: [{
显示文本: "动作",
按下调用的js函数(cls, btn) {},
// 返回值不影响流程(仅用于副作用)
判断是否禁用而被系统频繁调用的js函数(cls, btn) { return true; },
// 返回 false:禁用按钮
}],
};
static 检查是否允许进入的js函数(cls, from地点名, fromCls, 从点击进入) { return true; }
// 返回 false:阻止进入
static 进入时执行的js函数(cls, from地点名, fromCls, 从点击进入) {}
static 离开时执行的js函数(cls, to地点名, toCls) {}
static 加载时执行的js函数(cls) {}
static 判断是否禁用而被系统频繁调用的js函数(cls, 当前地点名, 当前地点cls) { return true; }
// 返回 false:禁用“指向地点”按钮
}注册:Game.注入地点类("地点名", 我的地点);
注意:必须至少有一个名字为“起点”的地点作为入口。
4) 对话对象(插件自行 new 实例)
对话不是通过注入,而是创建实例并调用 Game.显示对话(new 我的对话())。
class 我的对话 {
标题 = "对话标题";
消息 = [
{
人名: "NPC",
内容: "一句话",
进入时调用的js函数(thisArg, cls, msg, idx) {},
按钮: [{
显示文本: "选项",
按下调用的js函数(thisArg, cls, msg, idx, btn) {},
// 如需阻止自动下一条:在对话实例上设置 实例.__阻止自动下一条 = true,不需要时记得设置回去,通常的分支对话不需要更改,只需要原始有一句,动态使用按钮append即可
判断是否禁用而被系统频繁调用的js函数(thisArg, cls, msg, idx, btn) { return true; },
// 返回 false:禁用该按钮
}],
用户退出时调用的js函数(thisArg, cls, msg, idx) { return true; },
// 返回 false:阻止退出对话
},
];
}渲染机制
重要:渲染由vue实时进行,一般不需要手动调用渲染
错误处理
- 引擎内部调用钩子时会捕获异常并写入消息队列,不会让整个框架崩溃。
- thisArg是实例对象,cls是类对象
- 持有到不持有和不持有到持有仅针对买卖,如果是插件自己移除的是不会被自动调用的
插件加载
- 插件一般放在plugins文件夹,但没有硬性要求不能从其他地方加载
- 插件的加载写在plugins.json内,格式:
["plugins/demo.js"]