0%

原文:TGDC | 打造口碑,营销不止 —— 构建口碑生态圈下沉降维聚焦私域流量池

原视频链接:https://www.bilibili.com/video/BV1Mf4y1e73C?p=3

原文链接:https://mp.weixin.qq.com/s/dlJ64YC4qqcmsKeI44fj8Q

本文为看完视频后的笔记,和一些随想。

口碑是什么

狭义上来说,口碑就是大伙都愿意为你的产品自发宣传,口口相传。

第一步:跟谁说

反面例子:《The Last Of Us Part 2》(最后的生还者2)

如果单从产品品质上来讲,无论是美术风格、人物刻画、情绪渲染都可以称得上是当世代游戏里面的最高水准。但产品上市以来,《The Last Of Us》的粉丝们无法接受其中的剧情设置,结果恶评铺天盖地而来,有玩家表示无法相信自己等了几年的续作会以这样的方式来“惩罚”粉丝。在它发售以后经过几天的发酵,甚至开始出现了猜忌、谣言认为工作室很傲慢、江郎才尽、人身攻击制作人、谩骂曾经给其打高分的媒体等等等等情况,可以说是怎么狠怎么来,好多粉丝一点都不顾及往日的情面了。

这是我从外网摘录的一段玩家的评论,字里行间你也应该看得出来这是一个《The Last Of Us》的粉丝,整个话语都很无情,可以说是全面否定了这个产品并且打出了一星。

我自己也是《The Last Of Us》的粉丝,第一时间体验完这个产品以后,客观上讲这款产品在各个维度上都可以称得上是非常棒的作品。只是在某个剧情点的设计上让核心玩家无法接受,结果就引来这样的非议,我自己也不是很理解。那这里因为牵涉到剧透的问题,我就不讲得那么清楚了,如果有兴趣的朋友们可以自己去体验一下。我也不想去猜忌顽皮狗为何要这么设计整体的剧情脉络,我只是想说在这个事件之后,全网几乎都弥漫着一种踩《The Last Of Us》的气息,甚至你敢说它一句好话,就会被愤怒的粉丝围攻,说它不好变成了一件政治正确的事情。

想法:我不是《最后的生还者》的玩家(没有PS游戏机),但是根据我云的剧情,从一个玩家的角度出发来看,这游戏的编剧上出了大问题。

这种问题不像很多游戏制作人想的那样,只是剧情上的一点瑕疵,而是对玩家情感上的一种摧毁。

游戏本身给玩家带来了情感表达,在最后的生还者1中,乔尔是为了艾莉,甘愿放弃拯救世界的机会(从细节分析,那个研究员要杀死唯一样本拯救世界,这个思路本身也不怎么靠谱)。

但是最后的生还者2的剧情直接摧毁了一代结局的立足点,让一代的结局显得“小丑”化,并且抹杀了玩家在游玩一代时做出的选择(真的有选择吗)。

所以这绝不是“某个剧情上的设计”无法让核心玩家接受,不然社区也不会持续好几年给编剧冠以“精神变态”的外号了。

收获

如果连最爱你的人都不说你的好话,那就不会有任何人为你说好话,或者敢为你说好话了。

正面例子:《复仇者联盟4》

假设我们是这个片子的导演,在这个片子还没有拍的时候,作为整个系列的终章,前面已经拍过20部了,那么在最后一部大结局的时候,我们会不会想着去把它拍成一部大众都能看得懂的片子?然而事实情况是真正的《复仇者联盟4》出来的时候,如果你没有看过《复仇者联盟3》甚至前面的20部,你很难完全理解《复仇者联盟4》里面的很多桥段。

就是这样一部完全为粉丝打造的电影,一上映就席卷全球。在我身边有很多不是《复联》粉丝的朋友,都排队抢票去看。虽然其中有很多人都是看完以后去问那些粉丝说这里有什么梗吗,但丝毫不影响这部电影在粉丝群体当中的成功和口碑。那在上映以后,就是这样一部粉丝向的电影,成为了全球票房冠军,一举超越了《阿凡达》和《泰坦尼克号》,拿到了接近28亿美金的全球票房。

票房

想法:电影破圈的经典案例,但由于我对复联不是特别了解,不做深入分析。

破圈

这两年特别流行一个词叫做破圈,很多人都会觉得有没有好的口碑是看到底有没有破圈,有多少大众用户知道我这个产品了,结果把所有的注意力都放到破圈这件事情上,这是不对的。

在这次演讲的第一个部分我想说的是,多去想想你的核心用户,先让最爱你的人或者最可能爱你的人感受到你,这才是最重要的。把你最大的精力放在核心用户的身上,他们才是宝藏,暂时忘掉出圈这件事情,如果你的核心用户都不说你的好话,谁会说你的好话呢?

第二步:说什么

案例:《完美世界》手游

这是完美开发腾讯运营的一款手游。这款游戏在上线阶段主打的点就是飞行,就是图上的这个样子。在游戏里你可以骑乘在不同的坐骑上飞行,甚至可以站在武器上飞行。

听起来不错,那从用户调研上来看,排名第一的就是这个空中飞行最吸引用户。我们通常把这样的点叫做卖点,指的是用户在还没有体验到之前,就容易理解、容易感受到的点。

那么在《完美》上线的阶段,我们也确实在传递这个卖点。但是当用户实际进入到这个游戏当中以后,我们再通过用户的游戏行为发现,用户在游戏当中实际体验时间最长的内容其实是小队PK,并不是之前说的空中飞行。我们把这种当用户进入到了游戏中,让用户真正觉得有意思的玩法,能让他留存下来的玩法叫做买点。

它和前面提到的卖点有一点不同的是,在用户还没有体验到的情况下,买点是难以感知到的。就好像由于上线前,如果你对用户去讲这个小队PK,一是很难讲清楚怎么玩,二来用户也很难感知到好玩在哪里,所以在口碑传递的过程当中建议大家讲卖点,而不是讲买点。

想法:经典的买量游戏,从我现在的角度出发来看,这种游戏其实就是吃IP老本,只要游戏质量没有出太大的问题,总有一些人愿意为IP买单。或许等个几年我的想法会变?

至少我是觉得,所谓空中飞行的卖点并不怎么站得住脚,完美世界这个IP才是最大的卖点。

卖点

卖点,指的是用户在还没有体验到之前,就容易理解、容易感受到的点。

买点

买点,用户真正体验后,能留存下来的玩法。

卖点与买点

第三步:怎么说

案例:iPhone SE2

alt text

这个是苹果在iPhone SE 2上市的时候,宣传它的CPU很强劲,它用了两种方式来讲。首先它列举了一堆的技术数据来讲这块CPU,但这些技术数据对于大多数人来说真的太难懂。但是别着急,苹果讲的也不仅仅只是这些技术参数,它还在讲感受。

alt text

就是这句,它强调了这些参数你看不懂没关系,你只要知道iPhone SE 2虽然在当年是一款入门手机,但它里面用的这颗CPU可一点都不差,与当年的旗舰机用的是同样一块CPU,速度上一点都不妥协。那为什么还要展示左边的这些技术参数?那是因为这些技术参数核心用户能看懂,他不仅能看懂他还在意这些技术参数。

收获

说“易懂”的话,易懂不代表浅显。

说用户认同的,且能精准得表达用户体验感受的话。

说能激发用户各种情绪的话。

想法:即见人下菜碟,既要为高级用户展示硬核参数,也要为初级用户说明白亮点。

而高级用户,在被震撼后,也会自发的为初级用户讲解内容。

近期案例:《战地6》宣发中,展示了一个门户2.0系统,高级用户看出了这是使用Godot引擎制作的一个地图编辑器,并为初级用户讲解该模式可能带来怎么样的新玩法。

总结

总结

还没完

营销真的为王?

alt text

在这里我列举了一些最近几年来口碑不错的一些产品,我们仔细来看一看这个上面列出的这些产品,有哪一个产品是靠前面我们讲到的口碑营销的手段成功的?

不,都不是。它们都为用户提供了独一无二的、无可取代的体验价值。比如我们看一下这个AirPods耳机。AirPods并不是世界上第一个无线耳机,但是它是世界上第一个真正做到了双蓝牙同步的耳机,并且把它的体积压缩到了一个非常小巧的程度。用户第一次发现,我戴一个耳机出去,它的线不会缠在一块儿了,也可以很轻松地放进自己的口袋里,可以随意把它带着走,这就是它无可取代的体验价值。

黑神话:悟空!

我们再来看一个近期在游戏圈引起轰动的产品:《黑神话:悟空》。一家不到30人的工作室,在这个短视频横行的时代,“违反常识”地发布了一段长达13分钟的实机演示视频,短短几天单个视频就成为B站游戏类历史播放量第一,海外中国游戏宣传片历史播放量第一,唯一一个被《人民日报》评论部,《光明日报》、《观察者网》头图点赞的游戏。难道这也是靠口碑营销的手段做到的吗?

微信

再来看看微信。几乎没有任何营销活动,每一次版本调整都能引起刷屏,甚至激起行业的讨论。难道这也是依靠口碑营销的手段做到的吗?不,不是,是因为产品本身满足了用户最基础但高效的通信需求。

想法:真的满足了吗?恐怕微信的先发优势+腾讯体量才是占据了市场的主要原因吧。

不过竞品过于拉跨也是真的,各种APP想着往本就臃肿的软件里加社交是怎么想的

加就加吧,还把各种广告放消息里,是生怕看消息的用户太多了吗?

腾讯

同样的今天在观看的朋友里面,如果有人爱着上图的这家公司,是因为它的营销做得足够好吗?不,也不是,是因为这家公司在连接人和人,连接人和社会的方方面面,让用户的生活更加便捷,这才是它的核心价值。

或许是腾讯的内部价值观?但是从事实来看,一个庞大的公司,显然无法以简单的价值来定义。

所以让我们回到一开始口碑的这个定义。这句话最核心的主语是什么?是产品的独特价值。那么如果你是一位产品的负责人,那么请把你最大的精力放在这里,而不是前面那些营销手段。

独特价值

独特价值

那什么是产品的独特价值?这里我也列出了一些点。产品的独特价值,是你产品一切口碑的基础,是产品的卖点,甚至还是产品的买点,是产品某个功能带给用户独一无二的体验,是超出预期的体验,是你做口碑营销之前就应该确定的,并且应该是你花了最大的力气去打磨的。这才是产品的独特价值,这才是口碑的来源。

那有朋友就问了,你前面讲得这么热闹,那口碑营销的那些手段到底有没有用呢?

也不是,只是它能起到的作用和产品之间是相辅相成的。营销的资源是有限的,但产品提供的价值却可能是无限的。所以它们之间的关系大概是上图这样的。当然这不是一个精确的公式,只是表达它和口碑之间的关系。产品的上限越高,口碑的天花板就越高,所以不是说口碑营销不重要,而是要先把精力放在产品能给用户带来的价值上。如果你的所有精力都在研究下面的这些营销该怎么做,那恐怕可能永远都做不好口碑了。

总结一下就是这样一句话:先创造独一无二的产品或者服务,再将它们准确地传递给每一个潜在的可能爱你的用户。

真·总结

打铁还需自身硬,营销是扩大流量的方式,但是若没有足够的质量,那么流量终究会迎来反噬。

对吧?《清初·第一女巴图鲁》?

仓库地址:https://github.com/magicskysword/NightBlog

前言

一直想拥有一个属于自己的独立博客,用来写点技术和杂谈。

经过一番折腾,最终选择了 Hexo + NexT 主题。

由于该博客使用Claw Cloud Run进行托管,服务商不支持API更新(蛋疼),因此改为采用 Docker 部署基础服务器 + 使用 Github Action 构建网站后主动通知服务器拉取更新的方式。

前置环境

系统:Ubuntu in wsl

编辑器:vscode

node.js:22

docker:最新发行版

一、Hexo 初始化与主题集成

万事开头难,Hexo 提供了简洁的命令行工具,很适合搭建个人博客。

1. 初始化 Hexo 博客

1
2
3
4
npm install -g hexo-cli
hexo init blog
cd blog
npm install

初始化完成后,项目结构如下:

1
2
3
4
5
6
7
blog/
├── _config.yml # 全局配置
├── package.json # Node.js 项目文件
├── source/ # 文章与静态资源目录
├── themes/ # 主题目录
├── scaffolds/ # 文章模板
└── public/ # 构建输出目录

2. 集成 NexT 主题

我选择了 NexT 主题,比较顺眼,对中文支持较好。

1
2
3
4
5
6
cd themes
git clone https://github.com/theme-next/hexo-theme-next next

# 删除.git文件,因为我会改动主题里的内容+需要托管到git里
cd next
rm -rf .git

配置 _config.yml 指定主题:

1
theme: next

3. Docker配置

为了方便开发,配置了一套本地构建的开发环境Docker

由于我初学Docker,这部分基本都使用AI生成,赞叹AI的伟大。

Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
FROM node:22

# 设置工作目录
WORKDIR /app

# 安装 pnpm
RUN npm install -g pnpm

# 复制包管理配置文件
COPY package.json pnpm-lock.yaml ./

# 安装主项目依赖(包含 hexo、主题依赖声明)
RUN pnpm install

# 复制所有源代码
COPY . .

# 单独为 NexT 主题安装依赖
RUN if [ -f themes/next/package.json ]; then \
cd themes/next && pnpm install || true; \
fi

# 构建静态页面
RUN pnpm run build

# 启动本地 Hexo 服务器
EXPOSE 4000
# 如果设置了 BUILD_ONLY=true,则容器启动时什么也不做
# 否则,启动 Hexo 本地服务器
CMD ["/bin/sh", "-c", "if [ \"$BUILD_ONLY\" != \"true\" ]; then pnpm run server; else echo 'Build only mode. Nothing to run.'; fi"]

docker-compose.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
version: '3'

services:
# 本地开发:启动 hexo server
hexo:
build: .
ports:
- "4000:4000"
volumes:
- ./source:/app/source
- ./themes:/app/themes
- ./_config.yml:/app/_config.yml
command: pnpm run server

# CI 构建服务:只构建静态文件
hexo-builder:
build: .
environment:
- BUILD_ONLY=true
volumes:
- ./public:/app/public # 导出构建结果
command: /bin/true # 什么都不做,避免执行 CMD 中的 server

第一篇帖子与构建

使用以下命令生成第一篇帖子。

1
hexo new post HelloWorld

使用以下指令构建博客

1
pnpm run build

到这里博客搭建部分就结束了。

博客自动化部署

接下来我打算使用 node.js 制作一个docker image部署到服务器上。核心步骤如下:

            graph TD
            subgraph 博客流程
A[本地编写博客] --> B[推送到Github]
B --> C[Github构建静态页面]
C --> D[发布为Release]
D --> E[通知云服务器下载]
end

subgraph 云服务器流程
F[接受通知] --> G[下载最新Release]
G --> H[部署并更新]
end

E --> F
          

1.编写 server.js 和 deploy.js

server.js 的作用就是挂载博客页面和监听更新

server.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
const express = require('express');
const path = require('path');
const { exec } = require('child_process');
const fs = require('fs');
const { downloadAndExtract } = require('./deploy');

const app = express();
require('dotenv').config();

const PORT = process.env.PORT || 3000;
const TOKEN = process.env.UPDATE_TOKEN;
const BLOG_ZIP_URL = process.env.BLOG_ZIP_URL;
const DEPLOY_DIR = process.env.DEPLOY_DIR || path.join(__dirname, 'public');

// Serve static website
app.use('/', express.static(DEPLOY_DIR));

// API trigger
app.post('/api/update', async (req, res) => {
const reqToken = req.query.token;
if (reqToken !== TOKEN) {
return res.status(403).json({ message: 'Forbidden: Invalid token' });
}

try {
console.log('Triggered update via API.');
await downloadAndExtract(BLOG_ZIP_URL, DEPLOY_DIR);
res.json({ message: 'Update successful' });
} catch (err) {
console.error('Update failed:', err);
res.status(500).json({ message: 'Update failed', error: err.message });
}
});

// Start server
app.listen(PORT, '0.0.0.0', async () => {
console.log(`Server running at http://localhost:${PORT}`);
console.log('Performing initial deployment...');
try {
await downloadAndExtract(BLOG_ZIP_URL, DEPLOY_DIR);
console.log('Initial deployment complete.');
} catch (err) {
console.error('Initial deployment failed:', err);
}
});

deploy.js 的作用是从GitHub下载文件。

需注意,此处使用了 follow-redirects 库替代了内置的http库,是因为GitHub的文件链接存在一次重定向,而内置的库不支持重定向。

deploy.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
const { https } = require('follow-redirects');
const fs = require('fs');
const path = require('path');
const AdmZip = require('adm-zip');

/**
* 下载 zip 并解压到目标目录(会先清空该目录)
* @param {string} url 下载地址
* @param {string} targetDir 解压目录
*/
async function downloadAndExtract(url, targetDir) {
const tmpZip = path.join('/tmp', 'blog.zip');

// 下载 zip 文件
console.log(`Downloading ${url}...`);
await new Promise((resolve, reject) => {
const file = fs.createWriteStream(tmpZip);
https.get(url, (response) => {
if (response.statusCode !== 200) {
return reject(new Error(`Failed to download: ${response.statusCode}`));
}

response.pipe(file);
file.on('finish', () => {
file.close((err) => {
if (err) return reject(err);
resolve();
});
});
}).on('error', reject);
});

const stats = fs.statSync(tmpZip);
console.log(`Downloaded zip size: ${stats.size} bytes`);

// 清空目标目录
if (fs.existsSync(targetDir)) {
console.log(`Removing existing directory: ${targetDir}`);
fs.rmSync(targetDir, { recursive: true, force: true });
}
fs.mkdirSync(targetDir, { recursive: true });

// 解压(使用 adm-zip)
console.log(`Extracting to ${targetDir}...`);
const zip = new AdmZip(tmpZip);
zip.extractAllTo(targetDir, true);

fs.unlinkSync(tmpZip);
console.log('Deploy complete.');
}

module.exports = { downloadAndExtract };

对应的package.json

package.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"name": "blog-deployer",
"version": "1.0.0",
"description": "Webhook-based blog deployer for GitHub Actions",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"author": "your-name",
"license": "MIT",
"dependencies": {
"adm-zip": "^0.5.16",
"dotenv": "^16.4.1",
"express": "^4.19.2",
"follow-redirects": "^1.15.11",
"unzipper": "^0.10.11"
}
}

2.编写Docker

这一步是为了把环境打包成一个Docker,方便后续挂载到云服务器上。

Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
FROM node:18

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY server.js deploy.js ./

EXPOSE 3000

CMD ["node", "server.js"]

docker-compose.yml

1
2
3
4
5
6
7
8
services:
deployer:
build: .
container_name: blog-deployer
ports:
- "3000:3000"
env_file:
- .env

3.Github Action自动化

新建一个文件 .github/workflows/build.yml,该文件用于 Github 上的自动化。

我的目的是修改了deployer目录里的文件时,自动打新的包。而 Github Action,对我而言并不是特别熟悉。

因此我选择把我的流程描述给 LLM,让 LLM 帮我来编写

Action的主要功能如下:

  • blog目录变动时,打包博客页面,并让云服务器自动拉取更新。
  • deploy目录变动时,构建新的docker镜像。

build.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
name: Build and Release Blog + Deployer

on:
push:
branches:
- main
paths:
- 'blog/**'
- 'deployer/**'
- '.github/workflows/**'

workflow_dispatch:

permissions:
contents: write
packages: write

jobs:
build-blog:
name: Build and Release Blog
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0

- name: Detect blog directory change
id: changes
uses: dorny/paths-filter@v3
with:
filters: |
blog:
- 'blog/**'

- name: Exit if no blog changes
if: steps.changes.outputs.blog != 'true'
run: echo "No changes in blog/, skipping build."

- name: Set up Git identity
if: steps.changes.outputs.blog == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

- name: Set up Node.js
if: steps.changes.outputs.blog == 'true'
uses: actions/setup-node@v3
with:
node-version: 22

- name: Install pnpm
if: steps.changes.outputs.blog == 'true'
run: npm install -g pnpm

- name: Install dependencies
if: steps.changes.outputs.blog == 'true'
working-directory: blog
run: |
pnpm install
if [ -f themes/next/package.json ]; then
cd themes/next
pnpm install || true
fi

- name: Build static site
if: steps.changes.outputs.blog == 'true'
working-directory: blog
run: pnpm run build

- name: Check built files
if: steps.changes.outputs.blog == 'true'
run: ls -R blog/public

- name: Zip built blog
if: steps.changes.outputs.blog == 'true'
working-directory: blog/public
run: zip -r $GITHUB_WORKSPACE/blog-release.zip .

- name: Force-update blog-main tag
if: steps.changes.outputs.blog == 'true'
run: |
git tag -f blog-main
git push origin blog-main --force

- name: Create or Update GitHub Release
if: steps.changes.outputs.blog == 'true'
uses: softprops/action-gh-release@v1
with:
tag_name: blog-main
name: Blog Release (main)
files: blog-release.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Trigger Remote Deploy via Webhook
if: steps.changes.outputs.blog == 'true'
run: |
curl -X POST "${{ secrets.DEPLOY_WEBHOOK_URL }}?token=${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" \
|| echo "⚠️ Webhook failed, continuing anyway."

build-deployer:
name: Build and Push Deployer Image
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref_type != 'tag'

steps:
- name: Checkout code
uses: actions/checkout@v3

- name: Detect deployer directory change
id: changes
uses: dorny/paths-filter@v3
with:
filters: |
deployer:
- 'deployer/**'

- name: Log in to GitHub Container Registry
if: steps.changes.outputs.deployer == 'true'
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push image
if: steps.changes.outputs.deployer == 'true'
uses: docker/build-push-action@v5
with:
context: ./deployer
file: ./deployer/Dockerfile
push: true
tags: ghcr.io/${{ github.repository_owner }}/deployer:latest

总结

这次博客搭建的过程蛮曲折的,中途有各种报错,各种各种小问题。

但是现在的大语言模型还是太强大了,一点一点使用大模型辅助排查报错,在无人指导的情况下还是顺利搭建完成了。

xNode的魔改记录

目录

一、接入Odin
二、修改Node的标题
三、替换DynamicPortList
四、修改Node创建菜单


想尝试一下制作一个可视化的剧情编辑器,于是翻阅了诸多文章后找到了开源的xNode。

xNode地址:https://github.com/Siccity/xNode

但是由于xNode的部分功能不是很符合我的需求,于是乎开始动手魔改,写下此文章以记录魔改过程。

一、接入Odin

xNode虽然兼容Odin插件,但是其本身的NodeNodeGraph还是使用ScriptableObject进行序列化的。因此我们可以将其换为Odin里的SerializedScriptableObject,这样可以使Node和Graph更好的储存诸如DictionaryHashset之类的数据。

NodeGraph修改

打开NodeGraph.cs
NodeGraph本身涉及的数据较少,直接将ScriptableObject替换成SerializedScriptableObject,并删除所有的[SerializeField][Serializable]即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
using System;
using System.Collections.Generic;
using Sirenix.OdinInspector;
using UnityEngine;

namespace XNode {
public abstract class NodeGraph : SerializedScriptableObject
{
[HideInInspector]
public List<Node> nodes = new List<Node>();
......
}
}

[HideInInspector]标签是为了让被Odin序列化的属性不会出现在面板上

Node修改

打开Node.cs文件
与NodeGraph一样,将ScriptableObject替换成SerializedScriptableObject,并删除[SerializeField][Serializable]

Node和NodeGraph不同的是,Node里原本使用了一个自建的NodePortDictionary来替代Dictionary,并为其自定义了序列化方法。但是我们使用SerializedScriptableObject的话,可以让Odin帮我们完成Dictionary的序列化。因此将ports变量的类型从NodePortDictionary改成Dictionary<string, NodePort>,并添加标签[OdinSerialize]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using System;
using System.Collections.Generic;
using Sirenix.OdinInspector;
using UnityEngine;

namespace XNode
{
public abstract class Node : SerializedScriptableObject
{
......
[HideInInspector]
public NodeGraph graph;

[HideInInspector]
public Vector2 position;

[OdinSerialize]
[HideInInspector]
private Dictionary<string, NodePort> ports = new Dictionary<string, NodePort>();
......
}
}

二、修改Node的标题

xNode里,Node默认使用的是name属性作为标题,而name属性则是由脚本名称自动生成的。虽然node可以进行rename,但是这样的显示方法还是不够友好。
我所希望的理想方式是能显示自定义的Node注释,并且可以控制是否显示自定义名称,如:对话 (游戏开始) 或 剧情开始
而自定义的方式,使用特性标签(Attribute)是较为合适的。

打开Node.cs文件,找到#region Attributes部分,在里面添加新的Attribute

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/// <summary>
/// Add custom title for node
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class NodeTitleAttribute : Attribute
{
public string title;
public bool allowCustomName;
// allowCustomName为是否显示Node的name
public NodeTitleAttribute(string title,bool allowCustomName = true)
{
this.title = title;
this.allowCustomName = allowCustomName;
}
}

打开NodeEditor.cs文件,找到public virtual void OnHeaderGUI()函数,修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private string title;
private bool allowCustomName;

// 获取自定义的标题Attribute
public override void OnCreate()
{
var nodeTitleAttribute = target.GetType().GetCustomAttribute<Node.NodeTitleAttribute>();
if (nodeTitleAttribute != null)
{
title = nodeTitleAttribute.title;
allowCustomName = nodeTitleAttribute.allowCustomName;
}
if (string.IsNullOrEmpty(title))
title = target.GetType().Name;

Debug.Log("OnCreate");
}

public virtual void OnHeaderGUI()
{
// 绘制自定义的标题头
if(allowCustomName)
GUILayout.Label($"{title} ({target.name})",
NodeEditorResources.styles.nodeHeader,
GUILayout.Height(30));
else
GUILayout.Label(title, NodeEditorResources.styles.nodeHeader, GUILayout.Height(30));
}

此时通过添加标签[NodeTitle("对话")]即可让Node显示自定义的标题:
自定义的Node头

三、替换DynamicPortList

xNode里有一个实用的功能,动态端口列表(dynamicPortList)。可以通过列表来自动生成对应的端口(port),一个列表元素对应一个port。使用方法也很简单,在例如List<string> options之类的字段前加上[Output(dynamicPortList = true)][Inputput(dynamicPortList = true)]即可。

但是xNode的DynamicPortList使用的是Unity自带的ReorderableList进行绘制的,不仅不是很美观,Odin相关的Attribute也全部失效了,如下图:
打上了Odin的Attribute

DynamicPortList

xNode原版的DynamicPortList,Attribute全部失效

要想修改这种项的绘制,只能使用Unity原生的PropertyDrawer,但这样麻烦不说,还失去了我们接入Odin的初衷:高效、省时、省力。因此,最好还是想办法让其使用Odin的绘制流程来进行绘制。

在装上Odin插件后,xNode里会由OutputAttributeDrawer.cs来接管Output的绘制(Input同理)

首先找到OutputAttributeDrawer.cs里的DrawPropertyLayout(GUIContent label)函数,找到如下语句:

1
2
NodeEditorGUILayout.PropertyField(portPropoerty, label == null ? GUIContent.none : label, 
true, GUILayout.MinWidth(30));

在此处进行判断,非dynamicPortList的变量仍使用该语句渲染,而dynamicPortList则使用我们修改的方法:

1
2
3
4
5
6
7
8
9
10
11
if (Attribute.dynamicPortList)
{
CallNextDrawer(label);
//NodeEditorGUILayout.DrawDynamicPortList(Property,NodePort.IO.Output,
//Attribute.connectionType,Attribute.typeConstraint);
}
else
{
NodeEditorGUILayout.PropertyField(portPropoerty, label == null ? GUIContent.none : label,
true, GUILayout.MinWidth(30));
}

其中,CallNextDrawer(label);是Odin的Draw里正常渲染的方法,我们使用该方法先把列表渲染出来。而NodeEditorGUILayout.DrawDynamicPortList将是我们需要使用的渲染动态port的新方法,由于目前还没有写,暂时先注释掉。

回到Unity,可以看到xNode里变成了如下样式。

xNode里的样子

列表的端口单独独立出来了,且列表项没有被绘制。

首先解决列表项没有被绘制的问题,该处是因为Output标签对列表里的子元素也生效了,而因此子元素也受到了OutputAttributeDrawer的影响,没能被绘制出来。解决方法很简单,找到Node.cs里的OutputAttribute类,在前面加上[DontApplyToListElements]标签即可,如:

1
2
3
4
5
6
[AttributeUsage(AttributeTargets.Field)]
[DontApplyToListElements]
public class OutputAttribute : Attribute
{
......
}

回到xNode,此时子元素已经被正确的绘制出来了,但是依然有独立的port被绘制在外面。
如 Options 0 和 Options 1 两个端口

这是因为这些dynamic port没有被xnode的dynamicPortList绘制后,就会被自动以普通端口的形式进行绘制,具体逻辑在NodeEditor.csOnBodyGUI()函数里,如下:

1
2
3
4
5
foreach (XNode.NodePort dynamicPort in target.DynamicPorts) 
{
if (NodeEditorGUILayout.IsDynamicPortListPort(dynamicPort)) continue;
NodeEditorGUILayout.PortField(dynamicPort);
}

因此,我们只需要修改NodeEditorGUILayout.IsDynamicPortListPort的判断,让其将这些动态端口识别出来并跳过处理即可。

跳转到其函数位置,将其修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
public static bool IsDynamicPortListPort(XNode.NodePort port) 
{
string[] parts = port.fieldName.Split(' ');
if (parts.Length != 2) return false;
return true;
// Dictionary<string, ReorderableList> cache;
// if (reorderableListCache.TryGetValue(port.node, out cache)) {
// ReorderableList list;
// if (cache.TryGetValue(parts[0], out list)) return true;
// }
// return false;
}

xNode里,动态端口的命名是 字段名+” “+序号,因此,我们只需要判断出来其中包含一个空格即可。如果有其他需求可自行修改。

返回Unity,此时显示如下:
子元素显示正常,dynamic port没有额外显示

此时,我们需要开始添加自己的绘制Port的逻辑。返回到OutputAttributeDrawer.cs,取消之前注释的NodeEditorGUILayout.DrawDynamicPortList函数。

打开NodeEditorGUILayout.cs文件,在末尾添加以下函数:
DrawDynamicPortList

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static void DrawDynamicPortList(InspectorProperty property,NodePort.IO portType,
Node.ConnectionType connectionType,Node.TypeConstraint typeConstraint)
{
Node node = property.Parent.ValueEntry.WeakSmartValue as Node;
// 判断是否是node的字段
if(node == null)
return;
// 修改DynamicPort的数量,使之与List的大小对应
OnDynamicPortChange(property, node, portType, connectionType, typeConstraint);
// 绘制Port
for (int i = 0; i < property.Children.Count; i++)
{
NodePort port = node.GetPort($"{property.Name} {i}");
if(port == null)
return;
var propertyChild = property.Children.Get(i);
DrawDynamicPortListItem(propertyChild, i,port);
}
}

OnDynamicPortChange

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public static void OnDynamicPortChange(InspectorProperty property,Node node,
NodePort.IO portType,Node.ConnectionType connectionType,Node.TypeConstraint typeConstraint)
{
property.Update();
var dynamicDic = node.DynamicPorts.Where(
port =>
{
string[] names = port.fieldName.Split(' ');

return names.Length == 2 && names[0] == property.Name;
}).ToDictionary(port => port.fieldName);

for (int i = 0; i < property.Children.Count; i++)
{
string portName = $"{property.Name} {i}";
var propertyChildren = property.Children.Get(i);
if (dynamicDic.ContainsKey(portName))
{
dynamicDic.Remove(portName);
}
else
{
if (portType == NodePort.IO.Input)
{
node.AddDynamicInput(propertyChildren.ValueEntry.BaseValueType,
connectionType, typeConstraint, portName);
}
else
{
node.AddDynamicOutput(propertyChildren.ValueEntry.BaseValueType,
connectionType, typeConstraint, portName);
}
}
}
// 删除多余port
foreach (var nodePort in dynamicDic)
{
node.RemoveDynamicPort(nodePort.Value);
}
}

DrawDynamicPortListItem

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static void DrawDynamicPortListItem(InspectorProperty property,int index,NodePort port)
{
Rect rect = property.LastDrawnValueRect;
if (port.direction == NodePort.IO.Input)
{
rect.position = rect.position + new Vector2(-16, 0);
}
else
{
rect.position = rect.position + new Vector2(rect.width+20, 0);
}

rect.height = EditorGUIUtility.singleLineHeight;
rect.size = new Vector2(16, 16);

var portPos = rect.center;
NodeEditor.portPositions[port] = portPos;
PortField(rect.position,port);
}

DrawDynamicPortList函数负责收集Port的信息,并调用刷新函数和绘制函数。
OnDynamicPortChange则是在List的大小变更后,对应调整Port的数量。
DrawDynamicPortListItem负责渲染每一个子元素对应的port

保存后,打开Unity,可以看到列表和对应的Port都能正常显示和使用了。

成功显示

此处只修改了Output的Attribute,对于Input修改方法一样。

但是当前的List依然存在几个问题

  1. 调整List里的元素顺序后,Port并不会跟随一起变动
  2. 当拖拽、折叠、翻页时,由于rect获取异常,所有的端口都会堆积在左上角。

为此,还需要进一步的修改。

List元素变动时,修改Port

打开Node.cs文件,在Node类里插入以下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 用于临时记录index的变量
private int _lastDynamicPortIndex;
public void OnDynamicPortListChange(InspectorProperty property,CollectionChangeInfo changeInfo, object value)
{
if (changeInfo.ChangeType == CollectionChangeType.RemoveIndex)
{
// 记录上一个移动的列表项的Index
// 虽然删除列表元素时也会触发该回调,但是移动操作会同时触发移除和插入操作,因此不用担心冲突问题。
_lastDynamicPortIndex = changeInfo.Index;
}
else if(changeInfo.ChangeType == CollectionChangeType.Insert)
{
string fieldName = property.Name;
// 上移Port的Connection
if (changeInfo.Index > _lastDynamicPortIndex) {
for (int i = _lastDynamicPortIndex; i < changeInfo.Index; ++i) {
NodePort port = GetPort(fieldName + " " + i);
NodePort nextPort = GetPort(fieldName + " " + (i + 1));
port.SwapConnections(nextPort);
}
}
// 下移Port的Connection
else {
for (int i = _lastDynamicPortIndex; i > changeInfo.Index; --i) {
NodePort port = GetPort(fieldName + " " + i);
NodePort nextPort = GetPort(fieldName + " " + (i - 1));
port.SwapConnections(nextPort);
}
}
}
}

随后回到添加了dynamicPortList的变量边上,加入标签[OnCollectionChanged(After = "OnDynamicPortListChange")]

1
2
3
4
5
[Output(backingValue = ShowBackingValue.Never,
connectionType = ConnectionType.Override,
dynamicPortList = true)]
[OnCollectionChanged(After = "OnDynamicPortListChange")]
public List<OptionData> options = new List<OptionData>();

该标签会在List的面板上发生列表变动后,调用Node基类里的OnDynamicPortListChange函数回调。
这样在List里调整元素顺序时,Port也会相应变动了。

解决异常渲染的Port

找到之前在NodeEditorGUILayout类里添加的DrawDynamicPortListItem函数,将其修改为如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public static void DrawDynamicPortListItem(InspectorProperty property,int index,NodePort port)
{
Rect rect = property.LastDrawnValueRect;
// 判断Port是否能渲染在正确的位置,当被折叠或翻页时,Port改为渲染到父控件的边上
bool isShowing = rect != Rect.zero && property.Parent.State.Expanded;
if (isShowing)
{
if (port.direction == NodePort.IO.Input)
{
rect.position = rect.position + new Vector2(-16, 0);
}
else
{
rect.position = rect.position + new Vector2(rect.width+20, 0);
}
}
else
{
rect = property.Parent.LastDrawnValueRect;
if (port.direction == NodePort.IO.Input)
{
rect.position = rect.position + new Vector2(0, 0);
}
else
{
rect.position = rect.position + new Vector2(rect.width, 0);
}
}


rect.height = EditorGUIUtility.singleLineHeight;
rect.size = new Vector2(16, 16);

var portPos = rect.center;
NodeEditor.portPositions[port] = portPos;
PortField(rect.position,port);
}

这样Port在List折叠或翻页时,也能显示在正确的位置上了。

正常情况
折叠时
翻页时

四、修改Node创建菜单

xNode的Graph界面,右键菜单默认是显示所有的Node,但是当Node很多的时候,查找起来相当的不方便。因此,我们可以使用Odin的Selector来替代Node的创建菜单。

添加新的Node选择器

打开NodeGraphEditor.cs,找到AddContextMenuItems函数,该函数是用于控制右键菜单弹出时添加的内容。
将其修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
public virtual void AddContextMenuItems(GenericMenu menu, Type compatibleType = null, 
XNode.NodePort.IO direction = XNode.NodePort.IO.Input)
{
var mousePosition = Event.current.mousePosition;
Vector2 pos = NodeEditorWindow.current.WindowToGridPosition(mousePosition);

var nodesCreator = GetGenericSelector(compatibleType, direction, pos);

menu.AddItem(new GUIContent("创建节点"),false, () =>
{
nodesCreator.ShowInPopup(mousePosition);
});
menu.AddSeparator("");
if (NodeEditorWindow.copyBuffer != null && NodeEditorWindow.copyBuffer.Length > 0) menu.AddItem(new GUIContent("Paste"), false, () => NodeEditorWindow.current.PasteNodes(pos));
else menu.AddDisabledItem(new GUIContent("粘贴"));
menu.AddItem(new GUIContent("偏好设置"), false, () => NodeEditorReflection.OpenPreferences());
menu.AddCustomContextMenuItems(target);
}

public GenericSelector<Type> GetGenericSelector(Type compatibleType,
NodePort.IO direction, Vector2 pos)
{
Type[] nodeTypes;

if (compatibleType != null && NodeEditorPreferences.GetSettings().createFilter)
{
nodeTypes = NodeEditorUtilities
.GetCompatibleNodesTypes(NodeEditorReflection.nodeTypes, compatibleType, direction)
.OrderBy(GetNodeMenuOrder).ToArray();
}
else
{
nodeTypes = NodeEditorReflection.nodeTypes.OrderBy(GetNodeMenuOrder).ToArray();
}

Dictionary<Type, string> typesCache = new Dictionary<Type, string>();
for (int i = 0; i < nodeTypes.Length; i++)
{
Type type = nodeTypes[i];

string path = GetNodeMenuName(type);
if (string.IsNullOrEmpty(path)) continue;

XNode.Node.DisallowMultipleNodesAttribute disallowAttrib;
bool disallowed = false;
if (NodeEditorUtilities.GetAttrib(type, out disallowAttrib))
{
int typeCount = target.nodes.Count(x => x.GetType() == type);
if (typeCount >= disallowAttrib.max) disallowed = true;
}

if (!disallowed)
{
typesCache.Add(type, $"{path} ({NodeEditorUtilities.NodeDefaultName(type)})");
}
}

GenericSelector<Type> nodesCreator = new GenericSelector<Type>("选择节点", false,
x => typesCache[x], typesCache.Keys);
nodesCreator.SelectionTree.Config.DrawSearchToolbar = true;
nodesCreator.SelectionTree.Config.AutoFocusSearchBar = true;
nodesCreator.SelectionTree.Config.ConfirmSelectionOnDoubleClick = true;
nodesCreator.SelectionConfirmed += col =>
{
XNode.Node node = CreateNode(col.FirstOrDefault(), pos);
NodeEditorWindow.current.AutoConnect(node);
};
return nodesCreator;
}

此时,Graph界面里,右键会变成如下样式:

右键菜单

而点击创建节点后,会出现如下菜单:

节点创建菜单

此时,初步的改造已经完成了。但是,以往右键直接打开创建菜单的方式变成了如今的两步点击,是很不方便的。因此我们还可以进行进一步的优化:

添加快捷键

打开NodeEditorAction.cs,搜索case EventType.KeyDown:,在该case语句的末尾,添加新的按键判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
case EventType.KeyDown:
if (EditorGUIUtility.editingTextField || GUIUtility.keyboardControl != 0) break;
else if (e.keyCode == KeyCode.F) Home();
if (NodeEditorUtilities.IsMac()) {
if (e.keyCode == KeyCode.Return) RenameSelectedNode();
} else {
if (e.keyCode == KeyCode.F2) RenameSelectedNode();
}
if (e.keyCode == KeyCode.A) {
if (Selection.objects.Any(x => graph.nodes.Contains(x as XNode.Node))) {
foreach (XNode.Node node in graph.nodes) {
DeselectNode(node);
}
} else {
foreach (XNode.Node node in graph.nodes) {
SelectNode(node, true);
}
}
Repaint();
}

if (e.keyCode == KeyCode.Space)
{
var mousePosition = e.mousePosition;
Vector2 pos = WindowToGridPosition(mousePosition);
var nodesCreator = graphEditor.GetGenericSelector(null,
NodePort.IO.Input, pos);
nodesCreator.ShowInPopup(mousePosition);
}
break;

回到xNode里,此时按Space键能快速打开节点选择器

修改拖拽端口时弹出的菜单

搜索else if (draggedOutputTarget == null && NodeEditorPreferences.GetSettings().dragToCreate && autoConnectOutput != null)
将其if语句块内的代码修改如下:

1
2
3
4
5
6
7
8
9
else if (draggedOutputTarget == null && NodeEditorPreferences.GetSettings().dragToCreate 
&& autoConnectOutput != null)
{
var mousePosition = e.mousePosition;
Vector2 pos = WindowToGridPosition(mousePosition);
var nodesCreator = graphEditor.GetGenericSelector(draggedOutput.ValueType,
NodePort.IO.Input, pos);
nodesCreator.ShowInPopup(mousePosition);
}

这样在拖拽端口后,可以快速打开节点选择器。

拖拽端口创建节点

修改拖拽端口创建节点时的逻辑判断

在测试DynamicPortList时,我发现即使Input端口的TypeConstraint已经设置成了TypeConstraint.None,但是在拖拽时依然无法弹出可选的节点列表,如图:

从DynamicPortList的端口拖动来创建节点

分析代码时,发现是由于判断端口是否能连接的函数HasCompatiblePortType里,没有判断Attribute的typeConstraint所导致的。

打开NodeEditorUtilities.cs文件,找到HasCompatiblePortType方法,将其替换如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public static bool HasCompatiblePortType(Type nodeType, Type compatibleType, 
XNode.NodePort.IO direction = XNode.NodePort.IO.Input)
{
Type findType = typeof(XNode.Node.InputAttribute);
if (direction == XNode.NodePort.IO.Output)
findType = typeof(XNode.Node.OutputAttribute);

//Get All fields from node type and we go filter only field with portAttribute.
//This way is possible to know the values of the all ports and if have some with compatible value tue
foreach (FieldInfo f in XNode.NodeDataCache.GetNodeFields(nodeType)) {
var portAttribute = f.GetCustomAttributes(findType, false).FirstOrDefault();
if (portAttribute != null) {
switch (portAttribute)
{
case Node.InputAttribute inputAttribute:
if (inputAttribute.typeConstraint == Node.TypeConstraint.None)
return true;
break;
case Node.OutputAttribute outputAttribute:
if (outputAttribute.typeConstraint == Node.TypeConstraint.None)
return true;
break;
}
if (IsCastableTo(f.FieldType, compatibleType)) {
return true;
}
}
}

return false;
}

我们新增了端口特性的判断后,便可以通过拖拽来创建并连接端口类型不同的节点了。

拖拽创建并连接端口类型不同的节点

XML数据持久化

XML文件格式

1.XML是什么

  • XML是一种树形结构格式
  • XML文件后缀为 *.xml

2.注释

1
2
3
4
5
6
<!---->
<!--这里写注释信息-->
<!--
多行
注释
-->

3.固定内容

  • 固定内容很重要,在XML第一行写上
1
<?xml version="1.0" encoding="UTF-8"?>
  • version 代表版本
  • encoding 代表编码格式

4.基本语法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- <元素标签>元素内容</元素标签> -->
<!--xml语法是配对出现的-->
<PlayerInfo>
<Name>角色名</Name>
<Age>18</Age>
<Sex>1</sex>
<ItemList>
<Item>
<id>1</id>
<sum>10</sum>
</Item>
<Item>
<id>2</id>
<sum>0</sum>
</Item>
</ItemList>
</PlayerInfo>

5.基本规则

  1. 每个元素都必须有关闭标签
  2. 元素命名规则基本参照C#命名规则
  3. XML标签大小写敏感
  4. XML元素必须有根元素
  5. 特殊的符号应该用实体引用
符号 作用
&It < 小于
&gt > 大于
&amp & 和号
&apos ‘ 单引号
&quot “ 引号

6.属性

1
2
3
4
5
6
7
8
<!--属性是在元素标签空格后,添加的内容-->
<!--注意:属性必须要用引号包裹,单引号双引号皆可-->
<Role Type="Hero">
<Name>超人</Name>
<Age>26</Age>
</Role>
<!--注意:如果使用属性记录信息,不需要使用元素时,可以使用如下写法-->
<Role Type="Hero" Name="超人" Age="26"/>

7.属性和元素节点的区别

  • 属性和元素节点只是写法上的区别
  • 可以选择自己喜欢的写法来记录数据

8.如何检查语法错误

  1. 元素节点必须配对
  2. 属性必须有引号
  3. 注意命名

使用C#读取XML

XML文件存放位置

  • 只读不写的XML文件

    Resources或StreamingAssets文件夹下

  • 动态储存的XML文件

    Application.persistentDataPath 路径下

C#加载XML的方式

  1. XmlDocument
    可以把数据加载到内存中,方便读取
  2. XmlTextReader
    以流的形式加载,内存占用更少,但是单向只读,使用不方便。
  3. Linq

使用XmlDocument读取XML

1.加载XML

引用

1
using system.xml;
  1. 使用字符串加载xml
1
2
3
TextAsset textAsset = Resources.Load<TextAsset>("Test");
XmlDocument document = new XmlDocument();
document.LoadXml(textAsset.text);
  1. 通过XML文件的路径加载
1
2
XmlDocument document = new XmlDocument();
document.Load(Application.streamingAssetsPath + "/Test.xml");

2.读取XML

Test.xml内容

1
2
3
4
5
6
7
8
9
<?xml version="1.0" encoding="UTF-8"?>
<Root>
<name>超人</name>
<item id="1" count="1"/>
<firendList>
<friend name="小明" />
<friend name="小美" />
</firendList>
</Root>

读取方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 获取根节点
XmlNode root = document.SelectSingleNode("Root");
// 通过根节点,获取子节点
XmlNode nodeName = document.SelectSingleNode("name");
// 通过Node.InnerText获取节点元素
print(nodeName.InnerText);
// 通过Attributes获得属性
XmlNode nodeItem = root.SelectSingleNode("item");
print(nodeItem.Attributes["id"]);
print(nodeItem.Attributes.GetNamedItem("count").Value);
// 获取一个节点下的同名节点
XmlNode firendList = root.SelectSingleNode("firendList");
XmlNodeList nodeFriends = firendList.SelectNodes("firend");
// 通过foreach遍历节点列表
foreach (XmlNode friend in nodeFriends)
{
print(friend.Attributes("id").InnerText);
}
// 通过for循环遍历节点列表
for (int i = 0; i < nodeFriends.Count; i++)
{
print(nodeFriends[i].Attributes("id").InnerText);
}

IMGUI学习

简介

来源

GUI 全称 IMGUI,全称“即时模式游戏用户交互界面”
是Unity里最早的GUI系统

主要作用

  1. 游戏程序调试工具
  2. 为脚本组件创建自定义检视面板
  3. 创建新的编辑器窗口,以及扩展Unity本身
    注意:不要用IMGUI编写玩家GUI

工作原理

在继承MonoBehavior的脚本中的特殊函数里,每帧调用OnGUI方法。

1
2
3
4
private void OnGUI()
{
//在其中书写GUI相关代码 即可显示GUI内容
}
  • 注意事项
  1. 在游戏里每帧调用执行
  2. 一般只在其中执行GUI界面的操作相关函数
  3. 该函数在 OnDisable() 之前, LateUpdate() 之后执行
  4. 只要继承MonoBehaviour 的脚本,都可以在OnGUi中绘制UI

绘制

1.GUI绘制的共同点

  1. 同属于GUI类中的静态方法,可以直接调用
  2. 共同参数大同小异
类型 参数 常用字段
位置 Rect 位置:x y 尺寸:w h
文本 string
图片 Texture
综合 GUIContent
自定义样式 GUIStyle
  1. 每一种控件都有多种重载,是各个参数的排列组合
    必备的参数 是 位置信息显示信息

2.控件

1. 文本控件

1
2
3
4
5
6
7
8
9
10
public Texture texture;
private void OnGUI()
{
// 绘制文本
GUI.Label(new Rect(0,0,100,20),"Hello World");
// 绘制图片
GUI.Label(new Rect(0,20,100,100),texture);
// 绘制GUIContent
GUI.Label(new Rect(0,120,120,100),new GUIContent("Hello World",texture));
}

2. GUIStyle

  • 影响GUI控件的样式
1
2
3
4
5
6
public GUIStyle guiStyle;
private void OnGUI()
{
// GUIStyle
GUI.Label(new Rect(0,0,100,20),"Hello World",guiStyle);
}

3. 按钮控件

  • 绘制按钮
1
2
3
4
5
6
7
8
9
10
11
public Rect rect;
public GUIContent guiContent;
public Texture texture;
public GUIStyle guiStyle;
private void OnGUI()
{
// 修改按钮图片
guiStyle.normal.background = texture;
// 绘制按钮
GUI.Button(rect, guiContent, guiStyle);
}
  • 普通按钮点击逻辑
1
2
3
4
5
6
7
8
9
public Rect rect;
private void OnGUI()
{
// 只有按下并抬起才算一次点击
if(GUI.Button(rect, "按钮"))
{
Debug.Log("按钮被按下");
}
}
  • 长按按钮点击逻辑
1
2
3
4
5
6
7
8
9
public Rect rect;
private void OnGUI()
{
// 按下时一直算点击
if(GUI.RepeatButton(rect, "按钮"))
{
Debug.Log("按钮被按下");
}
}

4. 多选框

  • 绘制多选框
1
2
3
4
5
6
public Rect rect;
private void OnGUI()
{
// 多选框绘制
GUI.Toggle(rect, true, "按钮");
}
  • 接受多选框信息
1
2
3
4
5
6
7
public Rect rect;
public bool curState = false;
private void OnGUI()
{
// 接受bool值
curState = GUI.Toggle(rect, curState, "按钮");
}

注意:当对Toggle使用Style时,使用fixedWidth与fixedHeight控制图片大小,使用Padding修改文字位置。

5. 单选框

  • 单选框的实现是基于多选框的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public int curSelIndex = 1;
private void OnGUI()
{
// 使用多选框实现
if (GUI.Toggle(new Rect(0,0,100,20), curSelIndex == 1, "按钮1"))
{
curSelIndex = 1;
}
if (GUI.Toggle(new Rect(0,20,100,20), curSelIndex == 2, "按钮2"))
{
curSelIndex = 2;
}
if (GUI.Toggle(new Rect(0,40,100,20), curSelIndex == 3, "按钮3"))
{
curSelIndex = 3;
}
if (GUI.Toggle(new Rect(0,60,100,20), curSelIndex == 4, "按钮4"))
{
curSelIndex = 4;
}
}

6. 输入框

  • 普通输入
1
2
3
4
5
6
public string inputStr = "";
private void OnGUI()
{
// 接受字符串并重新赋值
inputStr = GUI.TextField(new Rect(0, 0, 200, 40), inputStr);
}
  • 密码输入
1
2
3
4
5
6
public string inputStr = "";
private void OnGUI()
{
// 接受字符串并重新赋值
inputStr = GUI.PasswordField(new Rect(0, 0, 200, 40), inputStr,'*');
}

7. 拖动条

1
2
3
4
5
6
7
8
9
public float inputValH;
public float inputValV;
private void OnGUI()
{
// 水平拖动条
inputValH = GUI.HorizontalSlider(new Rect(0, 0, 100, 20), inputValH, 0, 1);
// 垂直拖动条
inputValV = GUI.VerticalSlider(new Rect(0, 0, 100, 20), inputValV, 0, 1);
}

8. 图片绘制

1
2
3
4
5
6
public Texture texture;
private void OnGUI()
{
// 绘制图片
GUI.DrawTexture(new Rect(0,0,100,100),texture);
}

9. 框绘制

1
2
3
4
5
private void OnGUI()
{
// 绘制图片
GUI.Box(new Rect(0,0,100,100),"123");
}

10. 工具栏

  • 水平工具栏
1
2
3
4
5
6
public int selIndex = 0;
public string[] toolbars = new string[] {"选项一", "选项二", "选项三"};
private void OnGUI()
{
selIndex = GUI.Toolbar(new Rect(0, 0, 100*toolbars.Length, 100), selIndex, toolbars);
}
  • 网格工具栏
1
2
3
4
5
6
public int selIndex = 0;
public string[] toolbars = new string[] {"选项一", "选项二", "选项三"};
private void OnGUI()
{
selIndex = GUI.SelectionGrid(new Rect(0, 0, 100*2, 100*Mathf.Ceil(toolbars.Length/2f)), selIndex, toolbars,2);
}

11. 分组

  • 分组里,GUI的大小与位置会受到组Rect的影响
1
2
3
4
5
6
7
8
9
public Rect rect = new Rect(0,0,100,100);
private void OnGUI()
{
GUI.BeginGroup(rect);
{
GUI.Button(new Rect(0, 0, 50, 50), "按钮");
}
GUI.EndGroup();
}

12. 滚动列表

  • 滚动列表里,内容会被限制在Rect里,而viewRect则是总体的View内容大小
1
2
3
4
5
6
7
8
9
10
11
12
public Rect rect = new Rect(0,0,100,800);
public Rect rectView = new Rect(0, 0, 100, 100);
public Vector2 view = new Vector2(0, 0);
private void OnGUI()
{
view = GUI.BeginScrollView(rect, view, rectView);   
for (int i = 0; i < 8; i++)   
{       
GUI.Button(new Rect(0, 100 * i, 100, 100), $"按钮 {i} ");
}   
GUI.EndScrollView();
}

13. 窗口

  • 窗口
1
2
3
4
5
6
7
8
9
10
11
public Rect rect = new Rect(100,100,100,100);
private void OnGUI()
{
// 窗口ID必须唯一
GUI.Window(1,rect,DrawWindow,"测试窗口");
}

private void DrawWindow(int id)
{
GUI.Button(new Rect(5, 20, 50, 20), "按钮");
}
  • 模态窗口
    模态窗口位于所有窗口的最上层。当模态窗口出现时,其他窗口无法点击。
1
2
3
4
5
6
7
8
9
10
11
public Rect rect = new Rect(100,100,100,100);
private void OnGUI()
{
// 窗口ID必须唯一
GUI.ModalWindow(1,rect,DrawWindow,"测试窗口");
}

private void DrawWindow(int id)
{
GUI.Button(new Rect(5, 20, 50, 20), "按钮");
}
  • 拖动窗口
1
2
3
4
5
6
7
8
9
10
11
12
13
public Rect rect = new Rect(100,100,100,100);
private void OnGUI()
{
// 窗口ID必须唯一
rect = GUI.Window(1,rect,DrawWindow,"测试窗口");
}

private void DrawWindow(int id)
{
GUI.Button(new Rect(5, 20, 50, 20), "按钮");
// 另一种重载,使用Rect固定拖动位置
GUI.DragWindow();
}

3. 样式

1. 颜色设置

  • 全局颜色
1
GUI.color = Color.red;
  • 文本颜色
1
GUI.contentColor = Color.red;
  • 背景颜色
1
GUI.backgroundColor = Color.red;

2. 整体皮肤样式

GUISkin 是 所有GUI的Style的一个集合。