11. 变量系统教程
变量系统是平台最强大的功能之一,它让角色卡从"静态对话"升级为"有状态的互动体验"。本章从零开始,带你用变量系统构建一个完整的 RPG 角色属性面板。
在编辑器中预定义变量
在创作者编辑器的「变量系统」标签页,可以预先定义角色卡会用到的所有变量及其默认值。
这一步不是必须的——变量也可以完全在 JS 脚本中动态创建。但预定义变量有两个好处:
- 方便创作者自己在多次开发中查阅「这张卡用了哪些变量」
- 平台可以在对话初始化时自动注入默认值,无需手写
onReady判断
| 字段 | 说明 |
|---|---|
| 变量名 | 与 JS 脚本中 $charx.variable.get('xxx') 的 key 对应 |
| 类型 | number / string / boolean / object(影响编辑器输入方式;object 默认值使用 JSON 文本) |
| 默认值 | 用户首次进入对话时的初始值 |
| 描述 | 仅供创作者参考的备注,不影响运行 |
11.1 什么是对话变量
对话变量是绑定在单次对话会话上的键值存储。每个变量就是一对 key: value,例如:
hp → 80
mp → 50
gold → 120
location → "星港9号"
变量的特点:
- 随对话持久化:刷新页面、关闭再打开,变量不会丢失
- 对话隔离:不同对话会话的变量互相独立
- AI 可写入:通过
<charx_vars>标签,AI 可以在回复时自动更新变量 - JS 可读写:全局 JS 脚本可以随时读写变量
- HTML 可读取:嵌入 AI 消息的 HTML 面板可以读取变量并展示
变量 vs 持久化存储的区别见 10.5 节
11.2 变量的生命周期
角色卡初始化
↓
onReady 钩子触发 → 设置初始变量(如果尚未存在)
↓
用户/AI 对话循环
↓
AI 回复中包含 <charx_vars>{...}</charx_vars>
↓
平台解析,自动调用 $charx.variable.setMany(...)
↓
onMessageReceived 钩子触发 → 读取最新变量,更新 UI
↓
对话结束(或手动清空)→ 变量保留 / 清空(可配置)
11.3 初始化变量
变量初始化应该在 onReady 钩子中完成,并做"首次判断",避免每次刷新都被重置:
window.CharxHooks = {
onReady(context) {
// 只在变量不存在时初始化(防止刷新后重置)
if ($charx.variable.get('hp') === undefined) {
$charx.variable.setMany({
hp: 100,
maxHp: 100,
mp: 50,
maxMp: 50,
gold: 0,
level: 1,
location: '起点城镇'
})
console.log('[初始化] 角色属性已设置')
} else {
console.log('[加载] 恢复变量:', $charx.variable.getAll())
}
}
}
关键点: $charx.variable.get('hp') === undefined 判断变量是否为首次初始化,如果已存在(返回旧值)则跳过初始化,保留上次对话的进度。
11.4 让 AI 自动更新变量
这是变量系统的核心用法:在系统提示词中要求 AI 输出 <charx_vars> 标签,平台自动解析并更新变量。
第一步:在系统提示词中添加指令
你是 Aria,一名星际赏金猎人。请始终保持角色性格。
【状态追踪规则】
每次回复末尾必须附带以下格式的状态块,不得省略,不得修改格式:
<charx_vars>{"hp": <当前HP整数>, "mp": <当前MP整数>, "location": "<当前地点>"}</charx_vars>
HP 变化规则:
- 受到攻击时 HP 减少,减少量根据攻击描述判断(轻伤 -5~-15,重伤 -20~-40)
- 使用急救包时 HP 恢复 +30,不超过最大值 100
- MP 每次释放技能消耗 10
请根据剧情合理更新这些数值,不要胡乱修改。
第二步:AI 回复示例
AI 的正常回复:
*Aria 侧身躲开了第一击,但右臂还是被擦中了。*
"就这点实力?" *她咬牙,从腰间抽出短刀*
<charx_vars>{"hp": 82, "mp": 50, "location": "星港走廊"}</charx_vars>
平台会自动:
- 解析 JSON,调用
$charx.variable.setMany({ hp: 82, mp: 50, location: "星港走廊" }) - 从显示内容中删除
<charx_vars>标签,用户看不到这个块
注意事项
- JSON 必须合法,键名需与变量名一致(大小写敏感)
- AI 不会每次都完美执行,可以在后置指令中重申:"记住,每次回复末尾必须附带
<charx_vars>状态块" - 如果 AI 漏掉了标签,变量不会被更新,旧值保持不变(不会出错)
11.5 在 JS 中读取并响应变量变化
onMessageReceived 钩子在 AI 完整回复后触发,此时 <charx_vars> 已经被处理完毕,变量已是最新值:
window.CharxHooks = {
onReady() {
if ($charx.variable.get('hp') === undefined) {
$charx.variable.setMany({ hp: 100, mp: 50, gold: 0 })
}
},
onMessageReceived(message) {
const hp = Number($charx.variable.get('hp') ?? 100)
const mp = Number($charx.variable.get('mp') ?? 50)
// HP 为 0 时触发结局
if (hp <= 0) {
setTimeout(() => {
$charx.chat.send('[系统:你的 HP 归零,触发死亡结局]')
}, 800)
}
// HP 低于 30% 时提示
if (hp <= 30 && hp > 0) {
console.warn('[警告] HP 危险:', hp)
// 未来可以通过 $charx.ui.toast() 显示提示
}
}
}
11.6 构建 HTML 属性面板
让 AI 在回复中输出 HTML 状态面板,实时展示变量值。
方法一:让 AI 在回复中附带 HTML 面板
在系统提示词中要求 AI 每次输出状态面板:
每次回复末尾,在状态块之后,附带一个 HTML 状态面板:
<charx_vars>{"hp": ..., "mp": ...}</charx_vars>
```html
<!-- 状态面板,平台会渲染为 iframe -->
但这种方式依赖 AI 生成 HTML,格式容易混乱。推荐使用方法二。
方法二:全局 JS 注入固定面板
在全局 JS 中,用 DOM 操作在聊天界面插入一个固定显示的状态面板,并在每次 onMessageReceived 后更新它:
// ---- 状态面板样式 ----
const PANEL_STYLE = `
position: fixed;
top: 70px;
right: 16px;
z-index: 999;
background: rgba(10, 10, 26, 0.92);
border: 1px solid rgba(100, 200, 255, 0.3);
border-radius: 10px;
padding: 12px 16px;
color: #e0f0ff;
font-size: 13px;
min-width: 140px;
backdrop-filter: blur(8px);
pointer-events: none;
`
// ---- 创建面板 DOM ----
function createPanel() {
const panel = document.createElement('div')
panel.id = 'charx-status-panel'
panel.style.cssText = PANEL_STYLE
document.body.appendChild(panel)
return panel
}
// ---- 渲染面板内容 ----
function renderPanel() {
const panel = document.getElementById('charx-status-panel') || createPanel()
const hp = Number($charx.variable.get('hp') ?? 100)
const maxHp = Number($charx.variable.get('maxHp') ?? 100)
const mp = Number($charx.variable.get('mp') ?? 50)
const maxMp = Number($charx.variable.get('maxMp') ?? 50)
const gold = $charx.variable.get('gold') ?? 0
const loc = $charx.variable.get('location') ?? '—'
const hpPct = Math.max(0, Math.min(100, (hp / maxHp) * 100))
const mpPct = Math.max(0, Math.min(100, (mp / maxMp) * 100))
panel.innerHTML = `
<div style="margin-bottom:8px;font-weight:bold;color:#64c8ff;letter-spacing:.5px">📊 角色状态</div>
<div style="margin-bottom:4px">❤️ HP ${hp}/${maxHp}</div>
<div style="background:#1a2a3a;border-radius:4px;height:6px;margin-bottom:8px">
<div style="background:#ff4d6d;width:${hpPct}%;height:100%;border-radius:4px;transition:width .4s"></div>
</div>
<div style="margin-bottom:4px">💙 MP ${mp}/${maxMp}</div>
<div style="background:#1a2a3a;border-radius:4px;height:6px;margin-bottom:8px">
<div style="background:#4d9fff;width:${mpPct}%;height:100%;border-radius:4px;transition:width .4s"></div>
</div>
<div style="color:#ffd700">💰 ${gold} 金币</div>
<div style="margin-top:6px;color:#aaa;font-size:11px">📍 ${loc}</div>
`
}
// ---- 钩子注册 ----
window.CharxHooks = {
onReady() {
if ($charx.variable.get('hp') === undefined) {
$charx.variable.setMany({ hp: 100, maxHp: 100, mp: 50, maxMp: 50, gold: 0, location: '起点城镇' })
}
renderPanel()
},
onMessageReceived() {
renderPanel()
},
onDestroy() {
const panel = document.getElementById('charx-status-panel')
if (panel) panel.remove()
}
}
效果:聊天界面右上角会出现一个半透明的属性面板,每次 AI 回复后自动更新。
11.7 手动操作变量(JS 直接写入)
除了依赖 AI 的 <charx_vars>,也可以在 JS 钩子中直接修改变量:
window.CharxHooks = {
onBeforeSend(message) {
// 用户消息中包含"使用急救包"时,立即回复 HP
if (message.includes('急救包')) {
const hp = Number($charx.variable.get('hp') ?? 100)
const maxHp = Number($charx.variable.get('maxHp') ?? 100)
const newHp = Math.min(hp + 30, maxHp)
$charx.variable.set('hp', newHp)
console.log(`[急救] HP 恢复:${hp} → ${newHp}`)
}
return message
}
}
建议:让 AI 负责剧情驱动的变量变化(攻击、奖励等),JS 负责规则性的确定变化(装备效果、上限保护、边界检查等)。两者配合可以构建稳定的游戏逻辑。
11.8 变量命名规范
| 类型 | 推荐命名 | 示例 |
|---|---|---|
| 数值属性 | 小写 + 下划线 | hp, max_hp, mp, gold |
| 字符串状态 | 小写 + 下划线 | location, current_quest |
| 布尔标记 | is_ 或 has_ 前缀 |
is_injured, has_key |
| 计数器 | _count 后缀 |
battle_count, heal_count |
| 临时标记 | tmp_ 前缀 |
tmp_choice |
建议:所有初始变量在 onReady 中统一定义,方便查阅和维护。
11.9 完整示例:轻量级 RPG 角色卡
以下是一个完整的变量系统配置,适合入门级 RPG 角色卡。
系统提示词:
你是 Aria,一名星际赏金猎人,玩家需要和你一起完成任务。
【角色属性系统】
- HP(生命值):最大 100,归零则任务失败
- MP(能量值):最大 50,每次使用技能 -10
- Gold(金币):完成任务奖励,可用于购买物资
【每次回复必须包含(不得省略)】
在回复正文之后,附带状态更新块:
<charx_vars>{"hp": <整数>, "mp": <整数>, "gold": <整数>, "location": "<地点>"}</charx_vars>
变量更新规则:
- 战斗受伤:hp 减少 5-35(轻伤/重伤)
- 使用技能:mp 减少 10
- 完成支线任务:gold 增加 20-100
- 地点改变时更新 location
全局 JS:
(使用 10.6 节中的面板代码,并加上结局检测)
// 在 onMessageReceived 中加入结局判断
onMessageReceived() {
renderPanel()
const hp = Number($charx.variable.get('hp') ?? 100)
if (hp <= 0) {
setTimeout(() => $charx.chat.send('[系统提示:HP 归零,任务失败——请输入"重新开始"以复活]'), 600)
}
},
onBeforeSend(message) {
// 输入"重新开始"时重置变量
if (message.trim() === '重新开始') {
$charx.variable.setMany({ hp: 100, mp: 50, gold: 0, location: '星港9号' })
renderPanel()
}
return message
}
12. 同层卡(Immersive Canvas)模式
同层卡是平台的高级创作模式。在这种模式下,角色卡的开场白是一个完整的 HTML 游戏界面,它占满整个聊天区域,所有对话(用户输入和 AI 回复)都发生在这个 HTML 内部,而不是传统的上下滚动气泡列表。
适合场景:文字 RPG、策略游戏、视觉小说、数值模拟类角色卡。
12.1 与传统卡的对比
| 传统卡 | 同层卡 | |
|---|---|---|
| 消息展示 | 上下滚动的气泡列表 | 全部在一个 HTML 界面内 |
| 用户输入 | 平台底部输入框 | HTML 内的自定义按钮/输入框 |
| AI 回复 | 追加新气泡 | 通过事件推送给 HTML,由 HTML 自己渲染 |
| 游戏状态 | 通过 <charx_vars> 更新变量后在浮层显示 |
直接渲染在 HTML 的状态面板里 |
| 开场白 | 文本/Markdown/HTML 片段 | 完整的 HTML 游戏主界面 |
12.2 开启同层卡模式
在创作者编辑器中,找到「展示模式」下拉框,选择 同层卡(Immersive)。
这会将角色卡的 displayMode 字段设为 "immersive"。平台检测到该值后,会:
- 将开场白 HTML 渲染为全屏持久化 iframe,覆盖整个聊天区域
- 隐藏平台原生消息列表和底部输入框
- 将后续所有消息(用户发送、AI 回复)通过 postMessage 推送给该 iframe
- iframe 内的 HTML 通过
$charx.chat.onMessage()和$charx.chat.onStreamChunk()订阅并自行渲染
12.3 两个新 API:订阅消息
同层卡模式下,iframe 内部可以使用两个新的订阅方法:
$charx.chat.onMessage(callback)
订阅完整消息(用户发送 + AI 回复完成后)。
window.$charx.chat.onMessage(function(msg) {
// msg.role — "user" 或 "assistant"
// msg.content — 消息的完整文本内容
// msg.id — 消息 ID(数字)
console.log(msg.role, msg.content)
})
触发时机:
- 用户点击发送(立即触发,
role: "user") - AI 流式回复完成后(
role: "assistant",内容已包含完整文本,<charx_vars>等标签已被平台提取)
$charx.chat.onStreamChunk(callback)
订阅 AI 回复的流式输出,用于实现逐字打字效果。
window.$charx.chat.onStreamChunk(function(data) {
// data.chunk — 当前新增的文字片段(字符串)
// data.messageId — 本条消息的临时 ID
// data.done — 是否流式结束(boolean)
if (!data.done) {
appendToStreamingArea(data.chunk)
} else {
finalizeStreamingArea()
}
})
注意:
onStreamChunk和onMessage会同时触发同一条 AI 消息。先收到若干 chunk(done: false),最后收到done: true,然后onMessage触发完整消息。建议用 chunk 做打字动画,用onMessage更新最终状态。
12.4 HTML 开场白的设计规范
同层卡的开场白是一个完整 HTML 片段(不需要 <!DOCTYPE>、<html>、<head>、<body> 标签,平台会自动包裹)。平台会自动注入:
- Tailwind CSS CDN
- Font Awesome 图标库
- jQuery
window.$charxshim(含新的 onMessage/onStreamChunk)
推荐的 HTML 结构
<style>
/* 自定义样式,可覆盖 Tailwind */
:root { --bg: #1a1208; --gold: #c8a96e; }
html, body { background: var(--bg); color: #f0e6d0; height: 100%; overflow: hidden; }
</style>
<!-- 主布局 -->
<div id="app" class="flex flex-col h-screen">
<!-- 顶栏 -->
<header id="topbar" class="flex items-center px-4 py-2 border-b">...</header>
<!-- 主区域:叙事 + 状态面板 -->
<main class="flex flex-1 overflow-hidden">
<!-- 叙事日志 -->
<div id="log" class="flex-1 overflow-y-auto p-4">...</div>
<!-- 右侧状态面板 -->
<aside id="stats" class="w-48 p-3 border-l">...</aside>
</main>
<!-- 底部选项区 -->
<footer id="choices" class="p-3 border-t">...</footer>
</div>
<script>
(async function() {
// 1. 初始化:读取变量恢复状态
const vars = await window.$charx.variable.getAll() || {}
renderStats(vars)
// 2. 显示开场内容(仅首次)
if (!vars.initialized) {
appendNarrative('assistant', OPENING_TEXT, OPENING_OPTIONS)
await window.$charx.variable.set('initialized', true)
}
// 3. 订阅 AI 流式输出
window.$charx.chat.onStreamChunk(function(data) {
if (!data.done) {
appendStreamChunk(data.chunk)
} else {
finalizeStream()
}
})
// 4. 订阅完整消息(用于更新状态面板)
window.$charx.chat.onMessage(function(msg) {
if (msg.role === 'user') {
appendUserMessage(msg.content)
} else {
parseAndRender(msg.content) // 解析 <maintext>/<option> 等标签
}
})
})()
</script>
12.5 解析 AI 回复的标签格式
推荐在系统提示词中要求 AI 用结构化标签输出,方便 HTML 解析:
系统提示词要求:
每次回复使用以下格式:
<maintext>
[叙事正文内容]
</maintext>
<option>
A. [选项1]
B. [选项2]
C. [选项3]
D. [选项4]
</option>
<sum>[本轮剧情一句话摘要]</sum>
<charx_vars>{"score": 数值, "affection": 数值}</charx_vars>
前端解析函数示例:
function parseAssistantContent(raw) {
const maintext = (raw.match(/<maintext>([\s\S]*?)<\/maintext>/) || [])[1]?.trim() || raw
const optionBlock = (raw.match(/<option>([\s\S]*?)<\/option>/) || [])[1]?.trim() || ''
const sum = (raw.match(/<sum>([\s\S]*?)<\/sum>/) || [])[1]?.trim() || ''
// 解析选项列表
const options = optionBlock
.split('\n')
.map(l => l.trim())
.filter(l => /^[A-Z]\./.test(l))
return { maintext, options, sum }
}
function parseAndRender(content) {
const { maintext, options } = parseAssistantContent(content)
appendNarrativeText(maintext)
renderChoiceButtons(options)
}
提示:
<charx_vars>标签无需在 HTML 里手动解析——平台会在调用onMessage前自动提取并更新变量,传给回调的content已不含该标签。
12.6 状态面板与变量同步
变量在每次 AI 回复后自动更新(通过 <charx_vars>),HTML 中只需在 onMessage 时重新读取:
window.$charx.chat.onMessage(async function(msg) {
if (msg.role === 'assistant') {
// 消息已处理完,变量已是最新值
const vars = await window.$charx.variable.getAll()
updateStatsPanel(vars)
}
})
function updateStatsPanel(vars) {
document.getElementById('score').textContent = vars.score ?? 0
document.getElementById('stage').textContent = vars.stage ?? '生存期'
document.getElementById('affection').textContent = vars.affection ?? 0
}
12.7 用户发送消息
在 HTML 内部,可以通过 $charx.chat.setInput() 先填充草稿,再按需发送:
// 选项按钮点击
function sendChoice(text) {
// 禁用所有按钮,避免重复发送
document.querySelectorAll('.choice-btn').forEach(btn => {
btn.disabled = true
btn.classList.add('opacity-50')
})
window.$charx.chat.setInput(text)
}
// 自由输入框
document.getElementById('send-btn').addEventListener('click', function() {
const input = document.getElementById('free-input')
const text = input.value.trim()
if (!text) return
input.value = ''
window.$charx.chat.send(text)
})
读取平台输入框当前草稿时,可使用:
async function inspectDraft() {
const draft = await window.$charx.chat.getInput()
console.log('当前草稿:', draft)
}
12.8 状态恢复(刷新页面)
刷新页面时,持久化 iframe 的 DOM 会重建,但变量数据保存在后端。利用 $charx.variable.getAll() 恢复上次状态:
(async function init() {
const vars = await window.$charx.variable.getAll() || {}
if (!vars.initialized) {
// 第一次进入:显示开场白
renderOpening()
await window.$charx.variable.set('initialized', true)
} else {
// 刷新后恢复:显示上次的状态快照
renderRestoredState(vars)
}
})()
建议:对于较长的叙事日志,不要试图恢复完整对话历史(数据量太大),只恢复关键状态(积分、阶段、重要标记),并显示一段简短的"当前状态摘要"。
12.9 完整示例:最小化同层卡模板
以下是一个可直接运行的最小化同层卡开场白 HTML:
<style>
html,body{margin:0;padding:0;height:100%;background:#1a1208;color:#f0e6d0;font-family:system-ui,sans-serif}
#log{height:calc(100vh - 120px);overflow-y:auto;padding:16px}
#choices{position:fixed;bottom:0;left:0;right:0;padding:12px;background:#1a1208;border-top:1px solid #c8a96e44;display:flex;gap:8px;flex-wrap:wrap}
.msg-ai{color:#f0e6d0;margin-bottom:12px;line-height:1.7}
.msg-user{color:#c8a96e;margin-bottom:12px;text-align:right}
.choice-btn{background:#c8a96e22;border:1px solid #c8a96e66;color:#c8a96e;padding:6px 14px;border-radius:8px;cursor:pointer}
.choice-btn:hover{background:#c8a96e44}
.choice-btn:disabled{opacity:0.4;cursor:default}
#streaming{color:#f0e6d0;margin-bottom:12px;line-height:1.7;min-height:1.7em}
</style>
<div id="log">
<div id="streaming"></div>
</div>
<div id="choices"></div>
<script>
var streaming = document.getElementById('streaming')
var log = document.getElementById('log')
var choices = document.getElementById('choices')
var streamBuffer = ''
var isStreaming = false
function appendMsg(role, text) {
var div = document.createElement('div')
div.className = role === 'user' ? 'msg-user' : 'msg-ai'
div.textContent = role === 'user' ? '▶ ' + text : text
log.insertBefore(div, streaming)
log.scrollTop = log.scrollHeight
}
function renderChoices(options) {
choices.innerHTML = ''
options.forEach(function(opt) {
var btn = document.createElement('button')
btn.className = 'choice-btn'
btn.textContent = opt
btn.onclick = function() { sendChoice(opt) }
choices.appendChild(btn)
})
}
function parseContent(raw) {
var maintext = (raw.match(/<maintext>([\s\S]*?)<\/maintext>/) || [])[1]?.trim() || raw
var optBlock = (raw.match(/<option>([\s\S]*?)<\/option>/) || [])[1] || ''
var opts = optBlock.split('\n').map(s=>s.trim()).filter(s=>/^[A-Z]\./.test(s))
return { maintext, opts }
}
function sendChoice(text) {
document.querySelectorAll('.choice-btn').forEach(function(b){ b.disabled=true })
window.$charx.chat.send(text)
}
// 流式打字
window.$charx.chat.onStreamChunk(function(data) {
if (!isStreaming) { isStreaming = true; streamBuffer = '' }
if (!data.done) {
streamBuffer += data.chunk
streaming.textContent = streamBuffer
log.scrollTop = log.scrollHeight
} else {
streaming.textContent = ''
isStreaming = false
streamBuffer = ''
}
})
// 完整消息
window.$charx.chat.onMessage(function(msg) {
if (msg.role === 'user') {
appendMsg('user', msg.content)
} else {
var result = parseContent(msg.content)
appendMsg('ai', result.maintext)
renderChoices(result.opts)
}
})
// 初始化
;(async function() {
var vars = await window.$charx.variable.getAll() || {}
if (!vars.initialized) {
appendMsg('ai', '欢迎来到同层卡世界。请在此替换开场白内容。')
renderChoices(['A. 继续', 'B. 查看状态'])
await window.$charx.variable.set('initialized', true)
}
})()
</script>
12.10 注意事项
- 开场白不含
<!DOCTYPE>/<html>/<head>/<body>标签,平台自动补全外层结构 onMessage和onStreamChunk仅在同层卡模式(displayMode: "immersive")下有效,传统模式的 iframe 内无法接收这两个事件- 流式结束后才触发
onMessage,不要在onStreamChunk完成时做最终渲染,等onMessage确认 - 刷新页面:DOM 状态不会保留,必须通过变量系统恢复
- 移动端:建议为右侧面板等大块区域提供折叠方案,确保在小屏上可用