详细手册

Ai玩家详细教程:变量与同层卡

从完整创作者说明中拆出的主题页。需要其它主题可返回详细目录。

11. 变量系统教程

变量系统是平台最强大的功能之一,它让角色卡从"静态对话"升级为"有状态的互动体验"。本章从零开始,带你用变量系统构建一个完整的 RPG 角色属性面板。

在编辑器中预定义变量

在创作者编辑器的「变量系统」标签页,可以预先定义角色卡会用到的所有变量及其默认值。

这一步不是必须的——变量也可以完全在 JS 脚本中动态创建。但预定义变量有两个好处:

  1. 方便创作者自己在多次开发中查阅「这张卡用了哪些变量」
  2. 平台可以在对话初始化时自动注入默认值,无需手写 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号"

变量的特点:

变量 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>

平台会自动:

  1. 解析 JSON,调用 $charx.variable.setMany({ hp: 82, mp: 50, location: "星港走廊" })
  2. 从显示内容中删除 <charx_vars> 标签,用户看不到这个块

注意事项


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"。平台检测到该值后,会:

  1. 将开场白 HTML 渲染为全屏持久化 iframe,覆盖整个聊天区域
  2. 隐藏平台原生消息列表和底部输入框
  3. 将后续所有消息(用户发送、AI 回复)通过 postMessage 推送给该 iframe
  4. 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)
})

触发时机:

$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()
  }
})

注意onStreamChunkonMessage 会同时触发同一条 AI 消息。先收到若干 chunk(done: false),最后收到 done: true,然后 onMessage 触发完整消息。建议用 chunk 做打字动画,用 onMessage 更新最终状态。


12.4 HTML 开场白的设计规范

同层卡的开场白是一个完整 HTML 片段(不需要 <!DOCTYPE><html><head><body> 标签,平台会自动包裹)。平台会自动注入:

推荐的 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 注意事项