阅读视图
每日一题-删列造序 III🔴
给定由 n 个小写字母字符串组成的数组 strs ,其中每个字符串长度相等。
选取一个删除索引序列,对于 strs 中的每个字符串,删除对应每个索引处的字符。
比如,有 strs = ["abcdef","uvwxyz"] ,删除索引序列 {0, 2, 3} ,删除后为 ["bef", "vyz"] 。
假设,我们选择了一组删除索引 answer ,那么在执行删除操作之后,最终得到的数组的行中的 每个元素 都是按字典序排列的(即 (strs[0][0] <= strs[0][1] <= ... <= strs[0][strs[0].length - 1]) 和 (strs[1][0] <= strs[1][1] <= ... <= strs[1][strs[1].length - 1]) ,依此类推)。
请返回 answer.length 的最小可能值 。
示例 1:
输入:strs = ["babca","bbazb"] 输出:3 解释: 删除 0、1 和 4 这三列后,最终得到的数组是 strs = ["bc", "az"]。 这两行是分别按字典序排列的(即,strs[0][0] <= strs[0][1] 且 strs[1][0] <= strs[1][1])。 注意,strs[0] > strs[1] —— 数组 strs 不一定是按字典序排列的。
示例 2:
输入:strs = ["edcba"] 输出:4 解释:如果删除的列少于 4 列,则剩下的行都不会按字典序排列。
示例 3:
输入:strs = ["ghi","def","abc"] 输出:0 解释:所有行都已按字典序排列。
提示:
n == strs.length1 <= n <= 1001 <= strs[i].length <= 100-
strs[i]由小写英文字母组成
最长递增子序列(Python/Java/C++/C/Go/JS/Rust)
考虑最多保留多少列。
如果 $n=1$,那么本题是允许相邻元素相等的 300. 最长递增子序列。视频讲解:最长递增子序列【基础算法精讲 20】。
如果 $n>1$ 呢?能不能用同样的套路(枚举选哪个)解决?学习一下 300 题,动手试一试吧!
定义 $f[i]$ 表示每个子序列都以 $i$ 列结尾时,最多保留的列数。
枚举子序列的倒数第二列是 $j$。如果对于每一行,$j$ 列的字母都不超过 $i$ 列的字母,那么和 300 题一样,用 $f[j] + 1$ 更新 $f[i]$ 的最大值。
注意 $i$ 列也可以单独形成一个长为 $1$ 的子序列。
答案为 $m - \max(f)$。其中 $m$ 是 $\textit{strs}[i]$ 的长度。
###py
class Solution:
def minDeletionSize(self, strs: List[str]) -> int:
m = len(strs[0])
f = [0] * m
for i in range(m):
for j in range(i):
# 如果 f[j] <= f[i],就不用跑 O(n) 的 all 了
if f[j] > f[i] and all(s[j] <= s[i] for s in strs):
f[i] = f[j]
f[i] += 1
return m - max(f)
###java
class Solution {
public int minDeletionSize(String[] strs) {
int m = strs[0].length();
int[] f = new int[m];
int maxF = 0;
for (int i = 0; i < m; i++) {
for (int j = 0; j < i; j++) {
// 如果 f[j] <= f[i],就不用跑 O(n) 的 lessEq 了
if (f[j] > f[i] && lessEq(strs, j, i)) {
f[i] = f[j];
}
}
f[i]++;
maxF = Math.max(maxF, f[i]);
}
return m - maxF;
}
// 对于每一行,j 列的字母都 <= i 列的字母?
private boolean lessEq(String[] strs, int j, int i) {
for (String s : strs) {
if (s.charAt(j) > s.charAt(i)) {
return false;
}
}
return true;
}
}
###cpp
class Solution {
public:
int minDeletionSize(vector<string>& strs) {
// 对于每一行,j 列的字母都 <= i 列的字母?
auto less_eq = [&](int j, int i) -> bool {
for (auto& s : strs) {
if (s[j] > s[i]) {
return false;
}
}
return true;
};
int m = strs[0].size();
vector<int> f(m);
for (int i = 0; i < m; i++) {
for (int j = 0; j < i; j++) {
// 如果 f[j] <= f[i],就不用跑 O(n) 的 less_eq 了
if (f[j] > f[i] && less_eq(j, i)) {
f[i] = f[j];
}
}
f[i]++;
}
return m - ranges::max(f);
}
};
###c
#define MAX(a, b) ((b) > (a) ? (b) : (a))
int minDeletionSize(char** strs, int strsSize) {
// 对于每一行,j 列的字母都 <= i 列的字母?
bool less_eq(int j, int i) {
for (int k = 0; k < strsSize; k++) {
if (strs[k][j] > strs[k][i]) {
return false;
}
}
return true;
}
int m = strlen(strs[0]);
int* f = calloc(m, sizeof(int));
int max_f = 0;
for (int i = 0; i < m; i++) {
for (int j = 0; j < i; j++) {
// 如果 f[j] <= f[i],就不用跑 O(n) 的 less_eq 了
if (f[j] > f[i] && less_eq(j, i)) {
f[i] = f[j];
}
}
f[i]++;
max_f = MAX(max_f, f[i]);
}
free(f);
return m - max_f;
}
###go
func minDeletionSize(strs []string) int {
// 对于每一行,j 列的字母都 <= i 列的字母?
lessEq := func(j, i int) bool {
for _, s := range strs {
if s[j] > s[i] {
return false
}
}
return true
}
m := len(strs[0])
f := make([]int, m)
for i := range m {
for j := range i {
// 如果 f[j] <= f[i],就不用跑 O(n) 的 lessEq 了
if f[j] > f[i] && lessEq(j, i) {
f[i] = f[j]
}
}
f[i]++
}
return m - slices.Max(f)
}
###js
var minDeletionSize = function(strs) {
// 对于每一行,j 列的字母都 <= i 列的字母?
function lessEq(j, i) {
for (const s of strs) {
if (s[j] > s[i]) {
return false;
}
}
return true;
}
const m = strs[0].length;
const f = Array(m).fill(0);
for (let i = 0; i < m; i++) {
for (let j = 0; j < i; j++) {
// 如果 f[j] <= f[i],就不用跑 O(n) 的 lessEq 了
if (f[j] > f[i] && lessEq(j, i)) {
f[i] = f[j];
}
}
f[i]++;
}
return m - Math.max(...f);
};
###rust
impl Solution {
pub fn min_deletion_size(strs: Vec<String>) -> i32 {
let m = strs[0].len();
let mut f = vec![0; m];
for i in 0..m {
for j in 0..i {
// 如果 f[j] <= f[i],就不用跑 O(n) 的 all 了
if f[j] > f[i] && strs.iter().all(|s| s.as_bytes()[j] <= s.as_bytes()[i]) {
f[i] = f[j];
}
}
f[i] += 1;
}
m as i32 - *f.iter().max().unwrap()
}
}
复杂度分析
- 时间复杂度:$\mathcal{O}(nm^2)$,其中 $n$ 是 $\textit{strs}$ 的长度,$m$ 是 $\textit{strs}[i]$ 的长度。
- 空间复杂度:$\mathcal{O}(m)$。
专题训练
见下面动态规划题单的「§4.2 最长递增子序列(LIS)」。
分类题单
- 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
- 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
- 单调栈(基础/矩形面积/贡献法/最小字典序)
- 网格图(DFS/BFS/综合应用)
- 位运算(基础/性质/拆位/试填/恒等式/思维)
- 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
- 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
- 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
- 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
- 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
- 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
- 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)
欢迎关注 B站@灵茶山艾府
960. 删列造序 III
解法
思路和算法
这道题要求在数组 $\textit{strs}$ 中删除尽可能少的下标处的字符,满足每个字符串剩余字符都按字典序排序。为了使删除的下标个数最少,应使保留的下标个数最多,因此问题等价于计算数组 $\textit{strs}$ 中的所有字符串的最长公共递增子序列的长度,这里的公共的含义是下标相同。
用 $n$ 表示数组 $\textit{strs}$ 的长度,用 $m$ 表示数组 $\textit{strs}$ 中每个字符串的长度。对于 $0 \le i < m$,需要分别计算以每个下标 $i$ 结尾的最长公共递增子序列的长度,得到所有字符串的最长公共递增子序列的长度。如果存在下标 $j$ 满足 $0 \le j < i$ 且下标 $j$ 和下标 $i$ 都保留,则应满足对于所有 $0 \le k < n$ 都有 $\textit{strs}[k][j] \le \textit{strs}[k][i]$,在每个字符串的下标 $j$ 的字符之后添加下标 $i$ 的字符即可得到新的公共递增子序列。因此可以使用动态规划计算以每个下标结尾的最长公共递增子序列的长度。
为方便表述,对于给定下标 $i$,如果下标 $j$ 满足 $0 \le j < i$ 且对于所有 $0 \le k < n$ 都有 $\textit{strs}[k][j] \le \textit{strs}[k][i]$,则称下标 $j$ 是下标 $i$ 的「前驱下标」。
创建长度为 $m$ 的数组 $\textit{dp}$,其中 $\textit{dp}[i]$ 为以下标 $i$ 结尾的最长公共递增子序列长度。由于以任意一个下标结尾的最长公共递增子序列长度都大于等于 $1$,因此将 $\textit{dp}$ 中的所有值初始化为 $1$。
当 $i = 0$ 时,每个字符串的以下标 $i$ 结尾的子序列都只有一个,长度为 $1$,因此动态规划的边界情况是 $\textit{dp}[0] = 1$。
当 $i > 0$ 时,对于下标 $i$ 的任意前驱下标 $j$,$\textit{dp}[i] \ge \textit{dp}[j] + 1$,为了使 $\textit{dp}[i]$ 最大化,应寻找符合要求的最大的 $\textit{dp}[j]$,此时 $\textit{dp}[i] = \max{\textit{dp}[j]} + 1$。因此动态规划的状态转移方程是:对于下标 $i$ 的所有前驱下标 $j$,$\textit{dp}[i] = \max {\textit{dp}[j]} + 1$。
由于每一项依赖于之前的项,因此应从小到大遍历每个 $i$ 并计算 $\textit{dp}[i]$。计算得到 $\textit{dp}$ 中的所有状态值之后,其中的最大值即为最长公共递增子序列的长度。
用 $\textit{maxRemain}$ 表示 $\textit{dp}$ 中的最大值,则最多保留的下标个数是 $\textit{maxRemain}$,最少删除的下标个数是 $m - \textit{maxRemain}$。
代码
class Solution {
public int minDeletionSize(String[] strs) {
int n = strs.length, m = strs[0].length();
int[] dp = new int[m];
Arrays.fill(dp, 1);
for (int i = 1; i < m; i++) {
for (int j = 0; j < i; j++) {
boolean sorted = true;
for (int k = 0; k < n && sorted; k++) {
if (strs[k].charAt(j) > strs[k].charAt(i)) {
sorted = false;
}
}
if (sorted) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
}
int maxRemain = 0;
for (int remain : dp) {
maxRemain = Math.max(maxRemain, remain);
}
return m - maxRemain;
}
}
public class Solution {
public int MinDeletionSize(string[] strs) {
int n = strs.Length, m = strs[0].Length;
int[] dp = new int[m];
Array.Fill(dp, 1);
for (int i = 1; i < m; i++) {
for (int j = 0; j < i; j++) {
bool sorted = true;
for (int k = 0; k < n && sorted; k++) {
if (strs[k][j] > strs[k][i]) {
sorted = false;
}
}
if (sorted) {
dp[i] = Math.Max(dp[i], dp[j] + 1);
}
}
}
int maxRemain = 0;
foreach (int remain in dp) {
maxRemain = Math.Max(maxRemain, remain);
}
return m - maxRemain;
}
}
class Solution {
public:
int minDeletionSize(vector<string>& strs) {
int n = strs.size(), m = strs[0].size();
vector<int> dp(m, 1);
for (int i = 1; i < m; i++) {
for (int j = 0; j < i; j++) {
bool sorted = true;
for (int k = 0; k < n && sorted; k++) {
if (strs[k][j] > strs[k][i]) {
sorted = false;
}
}
if (sorted) {
dp[i] = max(dp[i], dp[j] + 1);
}
}
}
int maxRemain = 0;
for (int remain : dp) {
maxRemain = max(maxRemain, remain);
}
return m - maxRemain;
}
};
class Solution:
def minDeletionSize(self, strs: List[str]) -> int:
n, m = len(strs), len(strs[0])
dp = [1 for _ in range(m)]
for i in range(1, m):
for j in range(0, i):
sorted = True
for k in range(0, n):
if strs[k][j] > strs[k][i]:
sorted = False
break
if sorted:
dp[i] = max(dp[i], dp[j] + 1)
maxRemain = 0
for remain in dp:
maxRemain = max(maxRemain, remain)
return m - maxRemain
复杂度分析
-
时间复杂度:$O(nm^2)$,其中 $n$ 是数组 $\textit{strs}$ 的长度,$m$ 是数组 $\textit{strs}$ 中每个字符串的长度。状态数是 $O(m)$,计算每个状态时需要遍历当前下标之前的 $O(m)$ 个下标,对于之前的每个下标需要 $O(n)$ 的时间判断之前的下标是否为当前下标的前驱下标,因此时间复杂度是 $O(nm^2)$。
-
空间复杂度:$O(m)$,其中 $m$ 是数组 $\textit{strs}$ 中每个字符串的长度。需要创建长度为 $m$ 的数组 $\textit{dp}$。
删列造序 III
方法 1:动态规划
想法和算法
这是一个复杂的问题,很难抽象出解题思路。
首先,找出需要保留的列数,而不是需要删除的列数。最后,可以相减得到答案。
假设我们一定保存第一列 C,那么保存的下一列 D 就必须保证每行都是字典有序的,也就是 C[i] <= D[i]。那么我们就可以删除 C 和 D 之间的所有列。
我们可以用动态规划来解决这个问题,让 dp[k] 表示在输入为 [row[k:] for row in A] 时保存的列数,那么 dp[k] 的递推式显而易见。
###Java
class Solution {
public int minDeletionSize(String[] A) {
int W = A[0].length();
int[] dp = new int[W];
Arrays.fill(dp, 1);
for (int i = W-2; i >= 0; --i)
search: for (int j = i+1; j < W; ++j) {
for (String row: A)
if (row.charAt(i) > row.charAt(j))
continue search;
dp[i] = Math.max(dp[i], 1 + dp[j]);
}
int kept = 0;
for (int x: dp)
kept = Math.max(kept, x);
return W - kept;
}
}
###Python
class Solution(object):
def minDeletionSize(self, A):
W = len(A[0])
dp = [1] * W
for i in xrange(W-2, -1, -1):
for j in xrange(i+1, W):
if all(row[i] <= row[j] for row in A):
dp[i] = max(dp[i], 1 + dp[j])
return W - max(dp)
复杂度分析
- 时间复杂度:$O(N * W^2)$,其中 $N$ 是
A的长度,$W$ 是A中每个单词的长度。 - 空间复杂度:$O(W)$。
🏒 前端 AI 应用实战:用 Vue3 + Coze,把宠物一键变成冰球运动员!
不是 AI 不够强,而是你还没把它“接进前端”
这是一篇真正「前端视角」的 AI 应用落地实战,而不是模型科普。
🤔 为什么我要做这个「宠物冰球员」AI 应用?
最近刷掘金,你一定发现了一个现象 👇
- AI 很火
- 大模型很强
- 但真正能跑起来的 前端 AI 应用很少
很多同学卡在这一步:
❌ 会 Vue / React
❌ 会调接口
❌ 但不知道 AI 项目整体该怎么搭
于是我做了这个项目。
🎯 项目一句话介绍
上传一张宠物照片,生成一张专属“冰球运动员形象照”
而且不是随便生成,而是可控的 AI👇
- 🧢 队服编号
- 🎨 队服颜色
- 🏒 场上位置(守门员 / 前锋 / 后卫)
- ✋ 持杆方式(左 / 右)
- 🎭 绘画风格(写实 / 日漫 / 国漫 / 油画 / 素描)
📌 这是一个典型的「活动型 AI 应用」
非常适合:
- 冰球协会宣传
- 宠物社区裂变
- 活动拉新
- 朋友圈分享
🧠 整体架构:前端 + AI 是怎么配合的?
先上结论👇
前端负责“意图”,AI 负责“生成”
整体流程非常清晰:
Vue3 前端
↓
图片上传(Coze 文件 API)
↓
调用 Coze 工作流
↓
AI 生成图片
↓
前端展示结果
🧩 技术选型一览
| 模块 | 技术 |
|---|---|
| 前端 | Vue3 + Composition API |
| AI 编排 | Coze 工作流 |
| 网络 | fetch / HTTP |
| 上传 | FormData |
| 状态 | ref 响应式 |
🖼️ 前端第一难点:图片上传 & 预览
AI 应用里,最容易被忽略的不是 AI,而是用户体验。
❓ 一个问题
图片很大,用户点「生成」之后什么都没发生,会怎样?
答案是:
他以为你的网站卡死了
✅ 解决方案:本地预览(不等上传)
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = e => {
imgPreview.value = e.target.result
}
📌 这里的关键点是:
FileReaderreadAsDataURL- base64 直接渲染
图片还没上传,用户已经“看见反馈”了
🎛️ 表单不是表单,而是「AI 参数面板」
很多人写表单是为了提交数据
但 AI 应用的表单,本质是 Prompt 的一部分
<select v-model="style">
<option value="写实">写实</option>
<option value="日漫">日漫</option>
<option value="油画">油画</option>
</select>
最终在调用工作流时,变成:
parameters: {
style,
uniform_color,
uniform_number,
position,
shooting_hand
}
💡 前端的职责不是“生成 AI”
💡 而是“让 AI 更听话”
🤖 AI 真正干活的地方:Coze 工作流
一个非常重要的认知👇
❌ AI 逻辑不应该写在前端
✅ AI 逻辑应该写在「工作流」里
🧩 我的 Coze 工作流结构(核心)
你搭建的工作流大致包含:
- 📷 图片理解(imgUnderstand)
- 🔍 特征提取
- 📝 Prompt 生成
- 🎨 图片生成
- 🔗 输出图片 URL
👉 工作流地址(可直接参考)
🔗 www.coze.cn/work_flow?w…
📌 工作流 = AI 后端
前端只需要做一件事👇
fetch('https://api.coze.cn/v1/workflow/run', {
method: 'POST',
headers: {
Authorization: `Bearer ${patToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
workflow_id,
parameters
})
})
📤 文件上传:前端 AI 项目的必修课
❓ 为什么不能直接把图片传给工作流?
因为:
- 工作流不能直接接收本地文件
- 必须先上传,换一个
file_id
✅ 正确姿势:FormData
const formdata = new FormData()
formdata.append('file', input.files[0])
返回结果中会拿到:
{
"data": {
"id": "file_xxx"
}
}
然后在工作流参数里传👇
picture: JSON.stringify({ file_id })
📌 AI 应用用的还是老朋友:HTTP + 表单
⏳ 状态管理:AI 应用的“信任感来源”
AI ≠ 秒出结果
所以状态提示非常重要👇
status.value = "图片上传中..."
status.value = "正在生成..."
如果出错👇
if (ret.code !== 0) {
status.value = ret.msg
}
一个没有状态提示的 AI 应用 = 不可用
⚠️ AI 应用的三个“隐藏坑”
1️⃣ AI 是慢的
- loading 必须有
- 按钮要禁用
- 用户要知道现在在干嘛
2️⃣ AI 是不稳定的
- 可能失败
- 可能生成不符合预期
- 可能 URL 为空
📌 前端必须兜底,而不是假设 AI 永远成功
3️⃣ AI 应用 ≠ CRUD
它更像一次:
用户意图 → AI 理解 → 内容生成 → 结果反馈
✅ 做完这个项目,你真正掌握了什么?
如果你完整跑通这套流程,你至少学会了👇
- ✅ Vue3 Composition API 实战
- ✅ 文件上传 & 图片预览
- ✅ AI 工作流的正确使用方式
- ✅ 前端如何“驱动 AI”
- ✅ 一个完整 AI 应用的工程思路
✍️ 写在最后:前端 + AI 的真正价值
很多人担心👇
「前端会不会被 AI 取代?」
我的答案是:
❌ 只会写页面的前端会被取代
✅ 会设计 AI 交互体验的前端不会
AI 很强
但AI 不知道用户要什么
而前端,正是连接「用户意图」和「AI 能力」的桥梁。
HarmonyOS6 接入分享,原来也是三分钟的事情
HarmonyOS6 接入分享,原来也是三分钟的事情
前言
最近打算给准备开发的应用接入分享功能,考虑到模板市场上已经有现成的模版了,然后结合AI来接入,接入起来,也就是3分钟的事情。
新建工程
如果还没有工程的话,直接新建工程、创建项目
组件市场直接接入
在devEco Studio 上的 组件市场上搜索,通用系统分享,然后直接下载到项目中即可。
成功后,系统会自动同步和构建,这个时候会提示文件找不到 build-profile.json5,
这个时候手动删除配置即可
AI工具直接帮你接入
然后使用自己习惯的AI编辑器打开当前工程,万少这边使用的是 Trae 海外版 + Gemini-3-pro 模型
你点击刚才组件市场内的通用分享组件的官网链接,复制这个网址。
最后,使用老奶奶也能听懂的自然语言帮你的AI编辑器帮你接入即可,如图所示。
最后效果
注意事项
- 如果需要接受比如分享到QQ后到回调
- 或者需要配置微博分享
都需要仔细查看组件官网的描述
C# 正则表达式:量词与锚点——从“.*”到精确匹配
一、量词:告诉引擎“要重复多少次”
量词出现在一个“单元”后面,表示这个单元要重复多少次。
单元可以是:
- 一个普通字符:
a - 一个字符类:
\d、[A-Z] - 一个分组:
(ab)
1. 常见量词一览
-
?:0 或 1 次 -
*:0 次或多次 -
+:1 次或多次 -
{n}:恰好 n 次 -
{n,}:至少 n 次 -
{n,m}:n 到 m 次之间
示例:
using System.Text.RegularExpressions;
string pattern = @"^\d{3,5}$";
bool ok = Regex.IsMatch("1234", pattern); // True
2. 量词是作用在“前一项”上的
注意:ab+ 只会把 + 作用到 b 上:
- 模式:
ab+- 匹配:"ab"、"abb"、"abbbb"...
- 模式:
(ab)+- 匹配:"ab"、"abab"、"ababab"...
也就是说,当你想对一“串”东西使用量词,一定要用括号分组。
二、贪婪 vs 懒惰:* 和 *? 的根本区别
量词在默认情况下是“贪婪”的:
- 在不影响匹配成功的前提下,尽可能多地吃字符。
1. 贪婪匹配:.*
string input = "<tag>content</tag><tag>more</tag>";
string pattern = @"<tag>.*</tag>";
Match m = Regex.Match(input, pattern);
Console.WriteLine(m.Value);
匹配结果:
<tag>content</tag><tag>more</tag>
-
.*会尽可能多地吃字符,直到最后一个满足条件的</tag>。
2. 懒惰匹配:.*?
在量词后面再加一个 ?,就变成“懒惰”(即最多满足一次):
-
*?:尽可能少的 0 次或多次 -
+?:尽可能少的 1 次或多次 -
??:尽可能少的 0 或 1 次 -
{n,m}?:在 n~m 之间,尽量少
改写上面的例子:
string pattern = @"<tag>.*?</tag>";
Match m = Regex.Match(input, pattern);
Console.WriteLine(m.Value);
匹配结果:
<tag>content</tag>
三、用量词写几个常见“格式”:
1. 简单日期:yyyy-MM-dd
^\d{4}-\d{2}-\d{2}$
- 不考虑合法性,只看格式
2. 用户名:字母开头,后面 3~15 位字母数字下划线
^[A-Za-z]\w{3,15}$
-
[A-Za-z]:首字符必须是字母 -
\w{3,15}:后面 3~15 个字母数字下划线 - 总长度:4~16
C#:
string pattern = @"^[A-Za-z]\w{3,15}$";
bool ok = Regex.IsMatch("User_001", pattern);
3. 整数和小数
简单版本的“非负整数或小数”:
^\d+(\.\d+)?$
-
\d+:至少一位数字 -
(\.\d+)?:可选的小数部分(.+ 至少一位数字)
匹配:0、123、3.14、0.5
不匹配:.、.5、3.(如果你想放宽,可以调整)。
四、锚点:决定“匹配的是不是整串”
锚点(Anchor)是一类特殊的“零宽”匹配,只匹配“位置”,不消耗字符。
1)^:开头,$:结尾
默认情况下:
-
^匹配字符串的开头; -
$匹配字符串的结尾。
示例:
^abc // 匹配以 "abc" 开头的字符串
abc$ // 匹配以 "abc" 结尾的字符串
^abc$ // 字符串只能是 "abc"
Regex.IsMatch("abc123", @"^abc"); // True
Regex.IsMatch("123abc", @"abc$"); // True
Regex.IsMatch("xabcx", @"^abc$"); // False
2. 表单校验一定要写 ^ 和 $
// 不严谨
Regex.IsMatch("abc2025-12-18xyz", @"\d{4}-\d{2}-\d{2}");
// True,只要“包含”符合格式的子串就通过
// 严谨
Regex.IsMatch("abc2025-12-18xyz", @"^\d{4}-\d{2}-\d{2}$");
// False,整个字符串不是完整日期
3. 字符类里的 ^ 意义完全不同
^abc // 锚点:开头
[^abc] // 取反:匹配任何不是a、b、c的字符
-
^在[]外:开头锚点 -
^在[]里且在首位:表示“取反”,方括号内的字符任意组合的反面
五、单词边界:\b
\b 是“单词边界”(word boundary),匹配“从一个 \w 字符到一个非 \w 字符的边界”。
例子:
string text = "cat scat category";
string pattern = @"\bcat\b";
MatchCollection matches = Regex.Matches(text, pattern);
foreach (Match m in matches)
{
Console.WriteLine(m.Value);
}
输出:
cat
解析:
-
"cat"前后都是边界(左边是开头,右边是空格),满足\b。 -
"scat"中的cat左边是s,属于\w,不会被\bcat\b匹配。 -
"category"中的cat右边是e,也是\w,也不符合。
六、多行模式(Multiline)与单行模式(Singleline)
C# 中用 RegexOptions 可以控制 ^ / $ 和 . 的行为。
1. RegexOptions.Multiline:多行模式
默认情况:
string text = "first\nsecond\nthird";
string pattern = @"^second$";
Console.WriteLine(Regex.IsMatch(text, pattern)); // False
因为:
-
^和$在默认模式下只匹配整个字符串起始和结尾,不会感知行。
多行模式开启后:
bool ok = Regex.IsMatch(
text,
pattern,
RegexOptions.Multiline
);
Console.WriteLine(ok); // True
此时:
-
^/$会匹配每一行的开头/结尾(以\n作为换行)。
2. RegexOptions.Singleline:单行模式 / DOTALL
默认情况下:
-
.不匹配换行符。
string text = "line1\nline2";
string pattern = @".*";
Match m1 = Regex.Match(text, pattern);
Console.WriteLine(m1.Value); // "line1"
开启 Singleline 后:
Match m2 = Regex.Match(text, pattern, RegexOptions.Singleline);
Console.WriteLine(m2.Value); // "line1\nline2"
此时:
-
.会匹配包括换行在内的任何字符。
总结一下:
-
Multiline:影响^/$,让它们感知“行” -
Singleline:影响.,让.能匹配换行
它们可以一起用:
var regex = new Regex(
pattern,
RegexOptions.Multiline | RegexOptions.Singleline
);
结语
点个赞,关注我获取更多实用 C# 技术干货!如果觉得有用,记得收藏本文
前端跨页面通讯终极指南⑧:Cookie 用法全解析
前言
之前介绍了很多前端跨页面通讯的方案,今天介绍下Cookie,Cookie自身有“同源共享”的特性,但因为缺少数据变化的主动通知机制,只能使用“轮询”弥补这一缺陷。
本文将使用Cookie轮询,进行跨页面通讯。
1. Cookie 轮询基本原理
Cookie轮询通过“存储-定期检查-差异处理”的核心逻辑实现跨页面通讯,具体流程为:
- 一个页面将消息(如状态、指令等)结构化后存储到Cookie中;
- 其他同源页面通过定时任务定期读取目标Cookie;
- 对比当前Cookie值与历史基准值,若发现内容变化则读取并处理消息;
- 更新基准值,完成一次跨页面通讯闭环。
需特别注意:Cookie的domain和path配置是轮询生效的前提——写入方与轮询方需配置一致的作用域(如根路径/、根域名example.com),否则轮询方无法读取目标Cookie,导致通讯失效。
2. 案例代码
2.1 Cookie 轮询完整实现
代码如下所示:
// 设置 Cookie
function setCookie(name, value, days = 1) {
const date = new Date();
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
const expires = "expires=" + date.toUTCString();
document.cookie = name + "=" + encodeURIComponent(value) + ";" + expires + ";path=/";
updateCookieDisplay();
}
// 获取 Cookie
function getCookie(name) {
const nameEQ = name + "=";
const ca = document.cookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === ' ') c = c.substring(1, c.length);
if (c.indexOf(nameEQ) === 0) return decodeURIComponent(c.substring(nameEQ.length, c.length));
}
return null;
}
// 删除 Cookie
function deleteCookie(name) {
document.cookie = name + "=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
updateCookieDisplay();
}
// 发送消息(Cookie 方式)
function sendMessageByCookie(content) {
// 确定通讯类型
let communicationType = '';
if (isChild) {
communicationType = '子父通讯';
} else {
communicationType = '父子通讯';
}
const message = {
id: Date.now(),
content: content,
sender: clientId,
timestamp: Date.now(),
method: 'cookie',
communicationType: communicationType
};
const cookieValue = JSON.stringify(message);
setCookie(COOKIE_NAME, cookieValue);
addLog(`通过 Cookie 发送消息: ${content}`, '发送', communicationType);
}
3.2 总结
Cookie轮询是一种兼容性极强的跨页面通讯方案,无需依赖现代API,可在老旧浏览器中稳定运行。其核心优势是实现简单、配置灵活,适用于低频率消息同步场景(如登录状态、用户偏好设置)。使用时需重点关注Cookie作用域配置和轮询性能平衡,通过结构化消息设计和异常处理提升方案可靠性。
上线前不做 Code Review?你可能正在给团队埋雷!
一位前端同事花一周时间重构了一个核心组件,自测通过、性能优化、UI 完美。
上线 2 小时后,用户反馈“页面白屏”——原来漏掉了空状态处理。
紧急回滚、加班修复、产品信任受损……
而这一切,本可以在一次 15 分钟的 Code Review 中避免。
Code Review(代码审查)从来不是“找茬”,而是给前端团队上了一个最高效的质量保险。它不仅能提前拦截 Bug,更是知识共享、规范落地、新人成长的加速器。
一、什么是 Code Review
1.1 Code Review 的核心价值和目标
Code Review 翻译成中文,就是代码审查。在前端开发过程中,它不仅是保证代码质量的关键环节,更是团队知识共享、技术统一和工程规范落地的重要机制。团队成员通过规划化的代码审查流程,能够提前发现潜在问题,提升代码的可以维护性。
1.2 Code Review 发生在什么时候
通常发生在:
- 开发者完成一个功能、修复一个 bug 或进行重构后
- 将代码推送到远程仓库并发起 Pull Request(PR) 或 Merge Request(MR)
- 在代码合并到主干分支(如
master/main)之前
二、为什么要 Code Review
代码审查真的不是为了找茬,而是为了让团队能走得更远、更稳的关键一环。想一想,如果花了一周时间,优化了各种性能,写了一大堆代码,结果上线后发现了一个严重 bug,需要紧急修复。这不仅浪费了时间,还可能影响用户对产品团队和开发团队的信任。
下面列举几个 Code Review 的作用:
-
提前发现缺陷
在 Code Review 阶段发现的逻辑错误、业务理解偏差、性能隐患等时有发生,可以提前发现问题 -
提高代码质量
主要体现在代码健壮性、设计合理性、代码优雅性等方面,持续 Code Review 可以提升团队整体代码质量 -
统一规范和风格
集团编码规范自不必说,对于代码风格要不要统一,可能会有不同的看法,个人观点对于风格也不强求。不过代码风格的统一更有助于提升代码的可读性及让继任者快速上手 -
团队共识
通过多次讨论与交流,逐步达成团队共识,特别是对架构理解和设计原则的认知,在共识的基础上团队也会更有凝聚力,特别是在较多新人加入时尤为重要
三、怎么做 Code Review
3.1 代码提交者
3.1.1 使用自动化工具
- ESLint + Prettier
下面是 VS Code 关于 Prettier 的配置示例:
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"eslint.codeActionsOnSave.mode": "problems",
"eslint.validate": [
"typescript",
"javascript",
"javascriptreact",
"typescriptreact"
],
"editor.codeActionsOnSave": {
// 指定是否在保存文件时自动整理导入
"source.organizeImports": "always"
},
}
利用 Ctrl + S 自动格式化代码
- Husky + Lint-staged
安装依赖
yarn add -D husky
yarn add -D lint-staged
husky
一个为 git 客户端增加 hook 的工具。安装后,它会自动在仓库中的 .husky/ 目录下增加相应的钩子;比如 pre-commit 钩子就会在你执行 git commit 的触发。我们可以在 pre-commit 中实现一些比如 lint 检查、单元测试、代码美化等操作。
package.json 需要添加 prepare 脚本
{
"scripts": {
"prepare": "husky install"
}
}
做完以上工作,就可以使用 husky 创建一个 hook 了
npx husky add .husky/pre-commit "npx lint-staged"
lint-staged
一个仅仅过滤出 Git 代码暂存区文件(被 git add 的文件)的工具;这个很实用,因为我们如果对整个项目的代码做一个检查,可能耗时很长,如果是老项目,要对之前的代码做一个代码规范检查并修改的话,这可能就麻烦了,可能导致项目改动很大。所以这个 lint-staged,对团队项目和开源项目来说,是一个很好的工具,它是对个人要提交的代码的一个规范和约束。
此时我们已经实现了监听 Git hooks,接下来我们需要在 pre-commit 这个 hook 使用 Lint-staged 对代码进行 prettier 的自动化修复和 ESLint 的检查,如果发现不符合代码规范的文件则直接退出 commit。
并且 Lint-staged 只会对 Git 暂存区(git add 的代码)内的代码进行检查而不是全量代码,且会自动将 prettier 格式化后的代码添加到此次 commit 中。
在 package.json 中配置
{
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.{ts,tsx,js}": [
"eslint --fix",
"prettier --write",
"git add"
]
},
}
合并代码的时候自动触发执行基于大模型的自动化代码审查工具。可以先根据 AI 提示选择性的整改一波,然后再找评审的人 CR。感兴趣的可以点击链接了解一下。
功能如下:
- 🚀 多模型支持
- 兼容DeepSeek、ZhipuAI、OpenAI、Anthropic、通义千问和Ollama,想用哪个就用哪个。
- 📢消息即时主动
- 结果审查一键直达钉钉、企业微信或飞书,代码问题无处可藏!
- 📅自动化日报生成
- 基于 GitLab & GitHub & Gitea Commit 记录,自动整理每日开发进度,谁在摸鱼、谁在卷,一目了然 😼。
- 📊可视化仪表板
- 集中展示所有代码审查记录,项目统计、开发者统计,数据说话,甩锅无门!
- 🎭 评论风格任你选
- 专业型🤵:严谨严谨,正式专业。
- 论型😈:毒舌吐槽,专治不服(“这代码是用脚写的吗?”)
- 绅士型😍:温柔建议,如沐春风(“也许这里可以再优化一下呢~”)
- 幽默型🤪:搞笑点评,快乐改码(“be if-else比我的相亲经历还曲折!”)
3.1.2 发起 Code Review 时间和代码量
- 时间:尽量上线前一天发起评审
- 代码量:最好在 400 行以下。根据数据分析发现,从代码行数来看,超过 400 行的 CR,缺陷发现率会急剧下降;从 CR 速度来看,超过500 行/小时后,Review 质量也会大大降低,一个高质量的 CR 最好控制在一个小时以内。
3.2 代码评审者
3.2.1 评审时重点关注内容
作为前端评审者,可以检查以下维度:
| 维度 | 检查点示例 |
|---|---|
| 功能正确性 | 逻辑是否覆盖所有场景?边界条件(空值、错误状态)是否处理? |
| 可读性 & 可维护性 | 变量/函数命名是否清晰?组件职责是否单一?重复代码是否可复用? |
| TypeScript 安全 | 是否滥用 any / @ts-ignore?类型定义是否准确? |
| 框架规范 | React:key 是否合理?useEffect 依赖是否完整?Vue:响应式使用是否正确? |
| 样式 & UI | 是否避免全局 CSS 污染?是否适配移动端?是否符合设计系统? |
| 性能 | 是否有不必要的重渲染?图片/资源是否优化?第三方库是否按需引入? |
| 可访问性 (a11y) | 是否使用语义化标签?表单是否有 label?键盘导航是否支持? |
| 安全性 | 用户输入是否转义?是否避免 XSS(如 dangerouslySetInnerHTML)? |
| 测试覆盖 | 关键逻辑是否有单元测试?用户路径是否有 E2E 测试? |
3.2.2 怎么写 Review 评论
首先,不要吝啬你的赞美。代码写的好的地方要表扬!!
区分优先级:
- 🔴 必须改:Bug、安全漏洞、破坏性变更
- 🟡 建议改:可读性、性能优化、最佳实践
- 🟢 可讨论:风格偏好(应由 Lint 工具统一)
用好 “What-Why-How” 法则
✅ 正确示范:
What: 这里直接操作 DOM (`document.getElementById`)
Why: 在 React 中绕过虚拟 DOM 会导致状态不一致,且难以测试
How: 建议改用 `useRef` 获取元素引用,或通过状态驱动 UI 更新
❌ 避免:
“这里写得不好” → 太模糊
“你应该用 Hooks 重写” → 没说明原因
“我以前都这么写的” → 主观经验,缺乏依据
总结
好的 Code Review 是团队进步的放大器,它可以:
- 让新人成长得更快
- 让系统变得更稳定
- 让"技术债"更少
- 让协作更高效
参考文章
感谢
- 文中如有错误,欢迎在评论区批评指正。
- 如果本文对你有帮助,就点赞、收藏支持下吧!感谢阅读。
从硬编码到 Schema 推断:前端表单开发的工程化转型
一、你的表单,是否正在失控?
想象一个场景,你正在开发一个“企业贷款申请”或“保险理赔”系统。
最初,页面只有 5 个字段,你写得优雅从容。随着业务迭代,表单像吹气球一样膨胀到了 50 多个字段: “如果用户选了‘个体工商户’,不仅要隐藏‘企业法人’字段,还得去动态请求‘经营地’的下拉列表,同时‘注册资本’的校验规则还要从‘必填’变成‘选填’……”
于是,你的 Vue 文件变成了这样:
-
<template>里塞满了深层嵌套的v-if和v-show。 -
<script>里到处是监听联动逻辑的watch和冗长的if-else。 - 最痛苦的是: 当后端决定调整字段名,或者公司要求把这套逻辑复用到小程序时,你发现逻辑和 UI 已经像麻绳一样死死缠在一起,拆不开了。
“难道写表单,真的只能靠体力活吗?”
为了摆脱这种低效率重复,我们尝试将 中间件思想 引入 Vue 3,把复杂的业务规则从 UI 框架中剥离出来。今天,我就把这套“一次编写,到处复用”的工程化方案分享给你。
二、 核心思想:让数据自带“说明书”
传统模式下,前端像是一个**“搬运工”:拿到后端数据,手动判断哪个该显、哪个该隐。
而工程化模式下,前端更像是一个“组装厂”**:数据在进入 UI 层之前,先经过一套“中间件流水线”,数据会被自动标注上 UI 描述信息(Schema)。
1. 什么是 Schema 推断?
数据不再是冷冰冰的键值对,而是变成了一个包含“元数据”的对象。通过 TypeScript 的类型推断,我们让数据自己告诉页面:
- 我应该用什么组件渲染(
componentType) - 我是否应该被显示(
visible) - 我依赖哪些字段(
dependencies) - 我的下拉选项去哪里拉取(
request)
2. UI 框架只是“皮肤”
既然逻辑都抽离到了框架无关的中间件里,那么 UI 层无论是用 Ant Design 还是 Element Plus,都只是换个“解析器”而已。
三、 实战:构建 Vue 3 自动化渲染引擎
1. 组件注册表
首先,我们要定义一个组件映射表,把抽象的字符串类型映射为具体的 Vue 组件。
TypeScript
// src-vue/components/FormRenderer/componentRegistry.ts
import NumberField from '../FieldRenderers/NumberField.vue'
import SelectField from '../FieldRenderers/SelectField.vue'
import TextField from '../FieldRenderers/TextField.vue'
import ModeToggle from '../FieldRenderers/ModeToggle.vue'
export const componentRegistry = {
number: NumberField,
select: SelectField,
text: TextField,
modeToggle: ModeToggle,
} as const
2. 组装线:自动渲染器(AutoFormRenderer)
这是我们的核心引擎。它不关心业务,只负责按照加工好的 _fieldOrder 和 _schema 进行遍历。
<template>
<a-row :gutter="[16,16]">
<template v-for="key in orderedKeys" :key="key">
<component
v-if="shouldRender(key)"
:is="resolveComponent(key)"
:value="data[key]"
:config="schema[key].fieldConfig"
:dependencies="collectDeps(schema[key])"
:request="schema[key].request"
@update:value="onFieldChange(key, $event)"
/>
</template>
</a-row>
</template>
<script setup lang="ts">
const props = defineProps<{ data: any }>();
const schema = computed(() => props.data?._schema || {});
const orderedKeys = computed(() => props.data?._fieldOrder || Object.keys(props.data));
// 根据中间件注入的 visible 函数判断显隐
function shouldRender(key: string) {
const s = schema.value[key];
if (!s || s.fieldConfig?.hidden) return false;
return s.visible ? s.visible(props.data) : true;
}
function resolveComponent(key: string) {
const type = schema.value[key]?.componentType || 'text';
return componentRegistry[type];
}
</script>
3. 原子化:会“思考”的字段组件
以 SelectField 为例,它不再是被动等待赋值,而是能感知依赖。当它依赖的字段(如“省份”)变化时,它会自动重新调用 request。
<script setup lang="ts">
const props = defineProps(['value', 'dependencies', 'request']);
const options = ref([]);
async function loadOptions() {
if (props.request) {
options.value = await props.request(props.dependencies || {});
}
}
// 深度监听依赖变化,实现联动效果
watch(() => props.dependencies, loadOptions, { deep: true, immediate: true });
</script>
四、 方案的“真香”时刻
1. 逻辑与 UI 的彻底解耦
所有的联动规则、校验逻辑、接口请求都定义在独立于框架的 src/core 下。如果你明天想把项目从 Vue 3 迁到 React,你只需要重写那几个基础字段组件,核心业务逻辑 一行都不用动。
2. “洁癖型”提交
很多动态表单方案会将 visible、options 等 UI 状态混入业务数据,导致传给后端的 JSON 极其混乱。我们的方案在提交前会运行一次“清洗中间件”:
const cleanPayload = submitCompileOutputs(formData.compileOutputs);
// 自动剔除所有以 _ 开头的辅助字段和临时状态
后端拿到的永远是干净、纯粹的业务模型。
3. 开发体验的飞跃
现在,当后端新增一个字段时,你的工作流变成了:
-
在类型推断引擎里加一行规则。
-
刷新页面,字段已经按预定的位置和样式长好了。
你不再需要去 .vue 文件里翻找几百行处的 template 插入 HTML,更不需要担心漏掉了哪个 v-if。
结语:不要为了用框架而用框架
很多时候,我们觉得 Vue 或 React 难维护,是因为我们将过重的业务决策交给了视图层。
通过引入中间件和 Schema 推断,我们实际上在 UI 框架之上建立了一个“业务逻辑防火墙”。Vue 只负责监听交互和渲染结果,而变幻莫测的业务规则被关在了纯 TypeScript 编写的沙盒里。
这种“工程化”的思维,不仅是为了今天能快速复刻功能,更是为了明天业务变动时,我们能优雅地“配置升级”,而不是“推倒重来”。
你是如何处理复杂表单联动的?欢迎在评论区分享你的“避坑”指南!
TanStack Router 路径参数(Path Params)速查表
路径参数是 URL 中以 $ 开头的变量,用于捕获动态内容。
| 功能 | 语法示例 | URL 匹配示例 | 捕获到的变量 |
|---|---|---|---|
| 标准参数 | $postId |
/posts/123 |
{ postId: '123' } |
| 带前缀 | post-{$id} |
/posts/post-abc |
{ id: 'abc' } |
| 带后缀 | {$name}.pdf |
/files/cv.pdf |
{ name: 'cv' } |
| 通配符 (Splat) | $ |
/files/a/b/c.txt |
{ _splat: 'a/b/c.txt' } |
| 可选参数 | {-$lang} |
/about 或 /en/about
|
{ lang: undefined } 或 'en'
|
详细功能解析
1. 基础用法 (Standard Usage)
在文件路由中,文件名即路径。使用 $ 声明变量。
-
获取参数:
-
在 Loader 中:通过参数对象
params访问。 -
在 组件 中:使用
Route.useParams()钩子。
-
-
代码分割技巧:如果组件是单独定义的,可用
getRouteApi('/path').useParams()来保持类型安全。
2. 前缀与后缀 (Prefixes and Suffixes)
这是 TanStack Router 的一大特色,允许你在动态部分前后添加固定文本。
-
语法:用花括号
{}包裹变量名。 -
场景:比如文件名匹配
{$id}.json,或者带特定标识的 IDuser-{$userId}。 -
通配符组合:你甚至可以写
storage-{$}/$来匹配极其复杂的路径结构。
3. 可选路径参数 (Optional Path Parameters) ✨ 重点
使用 {-$variable} 语法。这意味着该段路径可以存在也可以不存在。
-
匹配逻辑:
/posts/{-$category}既能匹配/posts(参数为undefined),也能匹配/posts/tech。 -
导航:在
Link中,如果想去掉可选参数,将值设为undefined即可。
4. 国际化 (i18n) 应用场景
可选参数最强大的地方在于处理语言前缀。
-
设计:
/{-$locale}/about -
效果:
-
用户访问
/about-> 默认语言(如中文)。 -
用户访问
/en/about-> 英文。
-
-
这样你不需要为每种语言创建文件夹,只需一个路由逻辑即可搞定。
5. 类型安全 (Type Safety)
TanStack Router 的核心优势。
-
当你跳转(
Link或Maps)到一个带参数的路由时,TypeScript 会强制要求你提供该参数。 -
如果是可选参数,TS 会自动推断其类型为
string | undefined,提醒你做空值处理。
常见疑问解答
Q: 怎么在组件外获取参数?
使用全局的 useParams({ strict: false })。但建议尽可能在路由内部使用,以获得完整的类型提示。
Q: 参数里能包含特殊字符(如 @)吗?
默认会进行 URL 编码。如果你想让它直接显示,需要在 createRouter 的配置中设置 pathParamsAllowedCharacters: ['@']。
Q: 导航时如何保留现有的参数?
在 Link 或 Maps 的 params 中使用函数式写法:
params={(prev) => ({ ...prev, newParam: 'value' })}
一句话总结:
路径参数不仅是 $id 这么简单,通过 前缀/后缀、可选标志 和 强类型校验,你可以极其优雅地处理复杂的 URL 结构(如文件系统预览或多语言站点),且完全不会写错路径。
【节点】[GammaToLinearSpaceExact节点]原理解析与实际应用
GammaToLinearSpaceExact节点是Unity URP Shader Graph中用于色彩空间转换的重要工具,专门处理从伽马空间到线性空间的精确转换。在现代实时渲染管线中,正确的色彩空间管理对于实现物理准确的渲染效果至关重要。
色彩空间基础概念
在深入了解GammaToLinearSpaceExact节点之前,需要理解伽马空间和线性空间的基本概念。伽马空间是指经过伽马校正的非线性色彩空间,而线性空间则是未经校正的、与物理光照计算相匹配的色彩空间。
伽马校正的历史背景
- 伽马校正最初是为了补偿CRT显示器的非线性响应特性而引入的
- 人类视觉系统对暗部细节的感知更为敏感,伽马编码可以更有效地利用有限的存储带宽
- 现代数字图像和纹理通常默认存储在伽马空间中
线性空间的重要性
- 物理光照计算基于线性关系,使用线性空间可以确保光照计算的准确性
- 纹理过滤、混合和抗锯齿在线性空间中表现更加正确
- 现代渲染管线普遍采用线性空间工作流以获得更真实的渲染结果
GammaToLinearSpaceExact节点技术细节
GammaToLinearSpaceExact节点实现了从伽马空间到线性空间的精确数学转换,其核心算法基于标准的伽马解码函数。
转换算法原理
该节点使用的转换公式基于sRGB标准的逆伽马校正:
如果输入值 <= 0.04045 线性值 = 输入值 / 12.92 否则 线性值 = ((输入值 + 0.055) / 1.055)^2.4
这个精确的转换公式确保了从伽马空间到线性空间的数学准确性,与简单的幂函数近似相比,在低亮度区域提供了更高的精度。
### 数值范围处理
GammaToLinearSpaceExact节点设计用于处理标准化的数值范围:
- 输入值通常应在[0,1]范围内,但节点也能处理超出此范围的值
- 输出值保持与输入相同的数值范围,但分布特性发生了变化
- 对于HDR(高动态范围)内容,节点同样适用,但需要注意色调映射的后续处理
## 端口详细说明

### 输入端口(In)
输入端口接受Float类型的数值,代表需要转换的伽马空间数值。这个输入可以是单个标量值,也可以是向量形式的色彩值(当连接到色彩输出时)。
输入数值的特性:
- 通常来自纹理采样、常量参数或其他Shader Graph节点的输出
- 如果输入已经是线性空间的数值,使用此节点会导致不正确的渲染结果
- 支持动态输入,可以在运行时根据不同的条件改变输入源
### 输出端口(Out)
输出端口提供转换后的线性空间数值,类型同样为Float。这个输出可以直接用于后续的光照计算、材质属性定义或其他需要线性空间数据的操作。
输出数值的特性:
- 保持了输入数值的相对亮度关系,但数值分布发生了变化
- 暗部区域的数值被扩展,亮部区域的数值被压缩
- 输出可以直接用于物理光照计算,如漫反射、高光反射等
## 实际应用场景
GammaToLinearSpaceExact节点在URP Shader Graph中有多种重要应用场景,正确使用该节点可以显著提升渲染质量。
### 纹理色彩校正
当使用存储在伽马空间中的纹理时,必须将其转换到线性空间才能进行正确的光照计算。
应用示例步骤:
- 从纹理采样节点获取颜色值
- 将采样结果连接到GammaToLinearSpaceExact节点的输入
- 使用转换后的线性颜色值进行光照计算
- 在最终输出前,可能需要使用LinearToGammaSpaceExact节点转换回伽马空间用于显示
这种工作流程确保了纹理颜色在光照计算中的物理准确性,特别是在处理漫反射贴图、自发光贴图等影响场景光照的纹理时尤为重要。
### 物理光照计算
所有基于物理的渲染计算都应在线性空间中执行,GammaToLinearSpaceExact节点在此过程中扮演关键角色。
光照计算应用:
- 将输入的灯光颜色从伽马空间转换到线性空间
- 处理环境光照和反射探针数据
- 计算漫反射和高光反射时确保颜色值的线性特性
通过在线性空间中执行光照计算,可以避免伽马空间中的非线性叠加导致的光照过亮或过暗问题,实现更加自然的明暗过渡和色彩混合。
### 后期处理效果
在实现屏幕后处理效果时,正确管理色彩空间对于保持效果的一致性至关重要。
后期处理中的应用:
- 色彩分级和色调映射
- 泛光和绽放效果
- 色彩校正和滤镜效果
在这些应用中,需要先将输入图像从伽马空间转换到线性空间进行处理,处理完成后再转换回伽马空间用于显示,确保处理过程中的色彩操作符合线性关系。
## 与其他色彩空间节点的对比
Unity Shader Graph提供了多个与色彩空间相关的节点,了解它们之间的区别对于正确选择和使用至关重要。
### GammaToLinearSpaceExact与GammaToLinearSpace
GammaToLinearSpace节点提供了类似的伽马到线性转换功能,但使用的是近似算法:
线性值 ≈ 输入值^2.2
对比分析:
- GammaToLinearSpaceExact使用精确的sRGB标准转换,在低亮度区域更加准确
- GammaToLinearSpace使用简化近似,计算效率更高但精度稍低
- 在需要最高视觉质量的场合推荐使用Exact版本,在性能敏感的场景可以考虑使用近似版本
### 与LinearToGammaSpaceExact的关系
LinearToGammaSpaceExact节点执行相反的转换过程,将线性空间值转换回伽马空间。
转换关系对应:
- GammaToLinearSpaceExact和LinearToGammaSpaceExact是互逆操作
- 在渲染管线的开始阶段使用GammaToLinearSpaceExact,在最终输出前使用LinearToGammaSpaceExact
- 这种配对使用确保了整个渲染流程的色彩空间一致性
## 性能考量与最佳实践
虽然GammaToLinearSpaceExact节点的计算开销相对较小,但在大规模使用时仍需考虑性能影响。
### 性能优化建议
合理使用GammaToLinearSpaceExact节点可以平衡视觉质量和渲染性能:
- 对于已经在线性空间中的纹理(如HDRi环境贴图),不需要使用此节点
- 在不需要最高精度的场合,可以考虑使用GammaToLinearSpace近似节点
- 避免在片段着色器中重复执行相同的转换,尽可能在顶点着色器或预处理阶段完成
- 利用Unity的纹理导入设置,直接将纹理标记为线性空间,避免运行时转换
### 常见错误与调试
使用GammaToLinearSpaceExact节点时常见的错误和调试方法:
色彩过暗或过亮问题:
- 检查是否重复应用了伽马校正
- 确认输入纹理的正确的色彩空间设置
- 验证整个渲染管线中色彩空间转换的一致性
性能问题诊断:
- 使用Unity的Frame Debugger分析着色器执行开销
- 检查是否有不必要的重复转换操作
- 评估使用纹理导入时预转换的可行性
## 完整示例:实现物理准确的漫反射着色
下面通过一个完整的示例展示GammaToLinearSpaceExact节点在实际着色器中的应用。
### 场景设置
创建一个简单的场景,包含:
- 一个定向光源
- 几个具有不同颜色的物体
- 使用标准URP渲染管线
### 着色器图构建
构建一个使用GammaToLinearSpaceExact节点的基本漫反射着色器:
节点连接流程:
- 使用Texture2D节点采样漫反射贴图
- 将采样结果连接到GammaToLinearSpaceExact节点的输入
- 将转换后的线性颜色连接到URP Lit节点的Base Color输入
- 配置适当的光照和材质参数
### 对比测试
创建两个版本的着色器进行对比:
- 版本A:正确使用GammaToLinearSpaceExact节点
- 版本B:直接使用伽马空间的纹理颜色
观察两个版本在相同光照条件下的表现差异:
- 版本A提供更加真实的光照响应和颜色饱和度
- 版本B可能出现不正确的亮度积累和色彩偏移
- 在高光区域和阴影过渡区域,版本A的表现更加自然
## 高级应用技巧
除了基本用法,GammaToLinearSpaceExact节点还可以与其他Shader Graph功能结合,实现更复杂的效果。
### 与自定义光照模型结合
在实现自定义光照模型时,正确管理色彩空间至关重要:
实现步骤:
- 将所有输入的颜色数据转换到线性空间
- 在线性空间中执行光照计算
- 将最终结果转换回伽马空间输出
- 确保所有中间计算保持线性关系
这种方法确保了自定义光照模型与URP内置光照的一致性,避免了因色彩空间不匹配导致的视觉异常。
### HDR内容处理
处理高动态范围内容时,GammaToLinearSpaceExact节点的使用需要特别注意:
HDR工作流考虑:
- HDR纹理通常已经在线性空间中,不需要额外转换
- 当混合LDR和HDR内容时,需要确保统一的色彩空间
- 在色调映射阶段前,所有计算应在线性空间中进行
通过正确应用GammaToLinearSpaceExact节点,可以确保HDR和LDR内容的无缝融合,实现更高品质的视觉表现。
## 平台兼容性说明
GammaToLinearSpaceExact节点在不同平台和渲染API上的行为基本一致,但仍有少量注意事项。
### 移动平台优化
在移动设备上使用GammaToLinearSpaceExact节点时:
- 大部分现代移动GPU能够高效处理sRGB转换
- 在性能较低的设备上,可以考虑使用近似版本
- 利用移动平台的sRGB纹理格式,可以减少显式转换的需要
### 不同图形API的表现
在各种图形API中,GammaToLinearSpaceExact节点的行为:
- 在支持sRGB帧缓冲的API上(如OpenGL ES 3.0+,Metal,Vulkan),Unity会自动处理帧缓冲的伽马校正
- 在旧版API上,可能需要手动管理最终的色彩空间转换
- 节点本身的数学计算在所有API上保持一致
了解这些平台特性有助于编写跨平台兼容的着色器,确保在不同设备上的一致视觉表现。
## 总结
GammaToLinearSpaceExact节点是Unity URP Shader Graph中实现物理准确渲染的关键组件。通过正确理解和使用该节点,开发者可以:
- 确保纹理和颜色数据在光照计算中的物理准确性
- 实现更加真实和一致的视觉表现
- 避免常见的色彩空间相关渲染问题
- 构建高质量、跨平台兼容的着色器效果
---
> [【Unity Shader Graph 使用与特效实现】](https://blog.csdn.net/chenghai37/category_13074589.html?fromshare=blogcolumn&sharetype=blogcolumn&sharerId=13074589&sharerefer=PC&sharesource=chenghai37&sharefrom=from_link)**专栏-直达**
(欢迎*点赞留言*探讨,更多人加入进来能更加完善这个探索的过程,🙏)
Flutter 勇闯2D像素游戏之路(四):与哥布林战斗的滑步魔法师
Flutter 勇闯2D像素游戏之路(一):一个 Hero 的诞生
Flutter 勇闯2D像素游戏之路(二):绘制加载游戏地图
Flutter 勇闯2D像素游戏之路(三):人物与地图元素的交互
Flutter 勇闯2D像素游戏之路(四):与哥布林战斗的滑步魔法师
前言
在上篇文章中,我们完成了和 地图元素 的交互,开箱、开门、被刺扎 无所不精。
那么本章给大家介绍,一款游戏的精髓 对战 相关元素的实现。
这一元素类比 谈恋爱,可以说是,给大多数玩家的第一印象 了。
对战元素的重要性
| 维度 | 对战元素的作用 | 对游戏的影响 |
|---|---|---|
| 玩家存在感 | 操作能立刻产生命中、受伤、击杀得到正反馈 | 强化游戏世界 的真实感 |
| 游戏节奏 | 形成紧张(战斗)与放松(探索/叙事)的结合 | 避免节奏单一,降低疲劳感 |
| 决策决断 | 引入风险与回报(打/绕、进/退、用/留) | 从执行操作升级为策略选择 |
| 重复可玩性 | 敌人组合、走位、战况具有不确定性 | 提高重玩价值,延长游戏寿命 |
| 情绪驱动 | 胜利、失败、逆转带来情绪波动 | 增强成就感与沉浸感 |
总结:
对战元素并不只是战斗机制,而是连接玩家行为、系统设计与情绪体验的 核心枢纽,决定了一款游戏是否真正具备持续吸引力。
github源码 和 游戏在线体验地址(体验版为网页,可能手感较差,推荐源码安装至手机体验) 皆在文章最后。
Myhero
一. 本章目标
二. 实现 HUD 面板
HUD(Heads-Up Display,抬头显示)是始终 固定在屏幕上 的游戏界面层,用于向玩家展示信息并接收操作。
例如 血条、技能按钮、摇杆和暂停按钮,它不参与世界碰撞、不随地图或相机移动,只负责 显示与输入。
1. 素材
大家可以去下面 两个网站 中,找找自己心仪的,或者直接使用我上面的图片(在 仓库 中)。
爱给网: www.aigei.com/
itch : itch.io/
2. 人物血条
观察上述 心型血条 的精灵图,我们可以得到思路:
-
从满血到空血,一共是四个阶段,我们就姑且让 每颗 ❤️ 承载 4点血。
// 每个心跳组件包含的生命值 final int hpPerHeart = 4; -
因此,将每颗 ❤️,单独作为一个
HeartComponent,只负责单颗 ❤️ 扣血或加血的图片变化。class HeartComponent extends SpriteComponent { final List<Sprite> sprites; HeartComponent(this.sprites) { sprite = sprites.last; // 默认满血 } void setHpStage(int stage) { sprite = sprites[stage.clamp(0, sprites.length - 1)]; } } -
最后通过
HeroHpHud统一管理:-
添加管理
HeartComponent: 计算人物总心数 (❤️总颗数 = 总血量 / 每颗 ❤️血量),动态生成 component。// 心跳组件 final List<HeartComponent> hearts = []; // 每个心跳组件的精灵图 late final List<Sprite> heartSprites; ... for (int i = 0; i < hero.maxHp ~/ hpPerHeart; i++) { final double heartSize = 24; final double heartSpacing = heartSize + 1; final heart = HeartComponent(heartSprites) ..size = Vector2(heartSize, heartSize) ..position = Vector2(i * heartSpacing, 0); hearts.add(heart); add(heart); } -
动态更新血条:每帧更新时,获取人物血量,计算得出每颗 ❤️该展示的阶段。
void update(double dt) { super.update(dt); final totalHearts = hearts.length; final clampedHp = hero.hp.clamp(0, hero.maxHp); for (int i = 0; i < totalHearts; i++) { final start = i * hpPerHeart; final filled = (clampedHp - start).clamp(0, hpPerHeart); hearts[i].setHpStage(filled); } }
-
添加管理
3. 怪物血条
相对于 人物血条 的精心展示,怪物血条 可就太简单了,接下来我们就简单阐述一下步骤:
-
绘制血条背景:绘制一个 半透明黑色的canvas ,帮助用户直观的感受怪物血量的减少。
// 背景色 final bgPaint = Paint() ..color = Colors.black.withOpacity(0.6); // 背景 canvas.drawRect( Rect.fromLTWH(0, 0, size.x, size.y), bgPaint, ); -
绘制真实血量条:在黑色背景相同位置,绘制一个真实血量条,且在不同 血量比例 动态变化颜色。
// 血条色 final hpPaint = Paint() ..color = _hpColor(); // 当前血量 final ratio = currentHp / maxHp; canvas.drawRect( Rect.fromLTWH(0, 0, size.x * ratio, size.y), hpPaint, ); ... // 不同血量比例动态变化颜色 Color _hpColor() { final ratio = currentHp / maxHp; if (ratio > 0.6) return Colors.green; if (ratio > 0.3) return Colors.orange; return Colors.red; } -
每帧更新血量变化
void updateHp(int hp) { currentHp = hp.clamp(0, maxHp); }
4. 攻击技能按钮
像手机游戏中 攻击按钮的布局,大多数都是和 王者荣耀 大差不差的,因此我们也来实现一下:
(1)实现单个技能按钮 AttackButton
-
构造参数:- HeroComponent hero:传入按钮的使用者
- String icon:传入按钮的图标
- VoidCallback onPressed:传入按钮的执行函数
AttackButton({ required this.hero, required String icon, required VoidCallback onPressed, }) : iconName = icon, super( onPressed: onPressed, size: Vector2.all(72), anchor: Anchor.center, ); -
加载icon ,绘制外边框:Future<void> onLoad() async { await super.onLoad(); button = await Sprite.load(iconName); // 添加外部圆圈 add( CircleComponent( radius: 36, position: size / 2, anchor: Anchor.center, paint: Paint() ..color = Colors.white38 ..style = PaintingStyle.stroke ..strokeWidth = 4, ), ); } -
重写 render , 裁剪 ⭕️ 多余部分:@override void render(Canvas canvas) { canvas.save(); final path = Path()..addOval(size.toRect()); canvas.clipPath(path); super.render(canvas); canvas.restore(); }
(2)创建按钮组 AttackHud
-
创建一个
buttonGroup容器buttonGroup = PositionComponent() ..anchor = Anchor.center ..position = Vector2.zero(); -
获取技能数量
final attacks = hero.cfg.attack; final count = attacks.length; -
定义了一个
左下 → 正左的扇形final radius = buttonSize + 32.0; final startDeg = 270.0; final endDeg = 180.0; -
动态创建按钮:
- 第一个普通攻击放中间
- 其他按钮靠扇形均匀分布
- 创建
AttackButton并挂载
for (int i = 0; i < count; i++) { Vector2 position; // 普通攻击放中间 if (i == 0) { position = Vector2.zero(); } else { final skillIndex = i - 1; final skillCount = count - 1; // 均匀分布 final t = skillCount <= 1 ? 0.5 : skillIndex / (skillCount - 1); final deg = startDeg + (endDeg - startDeg) * t; // 极坐标 → 屏幕坐标 final rad = deg * math.pi / 180.0; position = Vector2(math.cos(rad), math.sin(rad)) * radius; } buttonGroup.add( AttackButton( hero: hero, icon: attacks[i].icon!, onPressed: () => _attack(i), )..position = position, ); } -
调用人物攻击
void _attack(int index) { hero.attack(index, MonsterComponent); }
三. 人物组件的抽象继承
1. 创建角色配置文件
将角色参数硬编码 在组件中,会导致组件与具体角色 强耦合,一旦涉及多角色体系或怪物规模化生成,代码将迅速💥🥚。
因此我们引入角色配置层,通过配置文件描述角色的 动画、属性与碰撞信息,由 角色基类 在运行时统一加载。
这样就使角色系统从 代码驱动 ➡ 数据驱动,提升了扩展性与维护性。
/// 角色配置
/// id 角色id
/// spritePath 角色sprite路径
/// cellSize 角色sprite单元格大小
/// componentSize 角色组件大小
/// maxHp 最大生命值
/// attackValue 攻击值
/// speed 移动速度
/// detectRadius 检测半径
/// attackRange 攻击范围
/// hitbox 人物体型碰撞框
/// animations 动画
/// attack 攻击列表
class CharacterConfig {
final String id;
final String spritePath;
final Vector2 cellSize;
final Vector2 componentSize;
final int maxHp;
final int attackValue;
final double speed;
final double detectRadius;
final double attackRange;
final HitboxSpec hitbox;
final Map<Object, AnimationSpec> animations;
final List<AttackSpec> attack;
const CharacterConfig({
required this.id,
required this.spritePath,
required this.cellSize,
required this.componentSize,
required this.maxHp,
required this.attackValue,
required this.speed,
this.detectRadius = 500,
this.attackRange = 60,
required this.hitbox,
required this.animations,
required this.attack,
});
static CharacterConfig? byId(String id) => _characterConfigs[id];
}
有了这份驱动数据后,对驴画马 将原来的
HeroComponent中的角色通用数据和方法,集中到CharacterComponent,在单独继承实现HeroComponent和其他扩展类,也是简简单单。因此,人物拆分内容就不多赘述,仅作介绍,具体实现查看 仓库源码。
2. 抽象角色组件 CharacterComponent
| 模块分类 | 功能点 | 已实现内容 | 说明 |
|---|---|---|---|
| 基础定义 | 角色基础组件 | 继承 SpriteAnimationComponent
|
具备精灵动画、位置、尺寸、朝向能力 |
| 游戏引用 | HasGameReference<MyGame> |
可访问 world、blockers、camera 等 | |
| 配置系统 | 角色配置加载 | CharacterConfig.byId(characterId) |
角色属性、攻击配置数据驱动 |
| 贴图资源 | spritePath / cellSize |
统一从配置加载动画资源 | |
| 基础属性 | 生命值系统 | maxHp / hp / loseHp() |
提供完整生命管理与死亡判定 |
| 攻击数值 | attackValue |
基础攻击力字段 | |
| 移动速度 | speed |
用于位移 / 冲刺 | |
| 状态系统 | 状态枚举 | CharacterState |
idle / run / attack / hurt / dead |
| 状态锁 | isActionLocked |
攻击 / 受伤 / 死亡期间禁止操作 | |
| 状态切换 | setState() |
同步动画与状态 | |
| 动画系统 | 动画加载 | loadAnimations() |
从 SpriteSheet 构建状态动画 |
| 攻击动画 | playAttackAnimation() |
播放攻击动画并自动回 idle | |
| 朝向控制 | 水平朝向 | facingRight |
统一攻击 / 移动方向 |
| 翻转逻辑 | faceLeft / faceRight() |
精灵水平翻转 | |
| 攻击系统 | 攻击入口 | attack(index, targetType) |
角色统一攻击接口 |
| Hitbox 解耦 | AttackHitboxFactory.create() |
攻击判定完全工厂化 | |
| 攻击动画驱动 | 攻击前播放动画 | 动画与判定分离 | |
| 碰撞体系 | 主体碰撞体 | RectangleHitbox hitbox |
用于世界实体碰撞 |
| 矩形碰撞检测 | collidesWith(Rect) |
提供矩形级碰撞判断 | |
| 碰撞纠正 | resolveOverlaps(dt) |
解决人物卡死 | |
| 移动系统 | 碰撞移动 | moveWithCollision() |
支持滑动的阻挡碰撞移动 |
| 回退机制 | X/Y 分轴处理 | 防止角色卡死 | |
| 环境交互 | 地形阻挡 | game.blockers |
墙体 / 障碍物阻挡 |
| 门交互 | DoorComponent.attemptOpen() |
带条件的交互碰撞 | |
| 角色交互 | 角色间阻挡 | 与其他 CharacterComponent 碰撞 |
防止角色重叠 |
| 召唤物AI逻辑 | 死亡处理 | updateSummonAI(dt) |
寻找敌人、攻击、跟随主人、待机 |
| 生命周期 | 死亡处理 |
onDead()(抽象) |
子类实现具体死亡行为 |
| 扩展能力 | 抽象基类 | abstract class |
Hero / Monster / NPC 统一父类 |
3. 实现 HeroComponent
| 功能模块 | 已实现作用 | 说明 |
|---|---|---|
| 角色身份 | 明确为玩家角色
|
Hero 是可输入控制的 Character |
| 钥匙系统 | 管理玩家持有的钥匙集合 |
keys 用于门、机关等条件交互 |
| 道具反馈 | 获取钥匙时 UI 提示 |
UiNotify.showToast 属于玩家反馈 |
| 动画初始化 | 加载并绑定角色动画 | 使用配置表中的 animations |
| 初始状态设置 | 初始为 idle 状态 |
Hero 出生即待机 |
| 出生位置设置 | 设置初始坐标 | 通常只由 Hero 决定 |
| 碰撞体创建 | 创建并挂载角色 Hitbox | Hero 的物理形态 |
| 相机绑定 | 相机跟随玩家 | game.camera.follow(this) |
| 输入处理 | 读取摇杆输入 | Hero 独有,怪物不会有 |
| 状态切换 | idle / run 状态管理 | 基于玩家输入 |
| 受击反馈 | 播放受击音效与动画 | 玩家专属体验反馈 |
| 受击状态恢复 | 受击后回到 idle | 保证操作连贯性 |
| 死亡表现 | 播放死亡动画 | 与怪物死亡逻辑不同 |
| 重开流程 | 显示 Restart UI | 只属于玩家死亡逻辑 |
| UI 交互 | 与 HUD / Overlay 联动 | Hero 是 UI 的核心数据源 |
4. 实现 MonsterComponent
| 功能模块 | 已实现作用 | 说明 |
|---|---|---|
| 角色身份 | 明确为怪物角色
|
由 AI 控制的 Character |
| 出生点管理 | 固定出生坐标 |
birthPosition 决定怪物初始位置 |
| 怪物类型标识 | monsterId | 用于读取配置、区分怪物 |
| 动画初始化 | 加载并绑定怪物动画 | 来自 cfg.animations |
| 初始状态设置 | 初始为 idle
|
出生即待机 |
| 碰撞体创建 | 创建并挂载 Hitbox | 怪物物理边界 |
| 血条组件 | 头顶血条显示 | MonsterHpBarComponent |
| 血量同步 | 实时更新血条 | 每帧 hpBar.updateHp
|
| 简单AI | 探测距离、追逐、攻击玩家和自主游荡 | detectRadius、attackRange |
| 状态切换 | idle / run / hurt | 由 AI 决定 |
| 受击反馈 | 播放受击动画 | 无 UI 提示 |
| 受击恢复 | 受击后回到 idle | 保证 AI 连贯 |
| 死亡表现 | 播放死亡动画 | 不显示 UI |
| 销毁逻辑 | 死亡后移除实体 | removeFromParent() |
四. 碰撞类攻击的实现
完成了 人物 那个基础要点,接下来免不了的就是 攻击逻辑 了,这是大多数游戏的核心。
而游戏的 人物 和 攻击 一样都离不开 碰撞,甚至后者更甚之。
1.思路
无论是 近战、远程 和 冲刺,造成 伤害 的 第一要点,就是攻击产生的 矩形 碰撞到目标敌人了。
其次,在手机肉鸽游戏中,近战和远程 的攻击总会自动索敌,这也是一个 通用点。
因此,得出上述逻辑之后,我们必然将共性点抽象为 基类,其他任意 碰撞产生伤害的攻击 继承实现 即可。
2. 攻击判定基类 AbstractAttackRect
(1) 基础属性管理
- damage:伤害
- owner:归属者
- targetType:目标类型
- duration:持续时长
- removeOnHit:是否穿透
- maxLockDistance:最大距离
(2) 命中检测机制
-
提供
getAttackRect接口支持自定义几何区域判定(如扇形、多边形)。/// 返回该组件用于判定的几何区域 ui.Rect getAttackRect(); -
内置目标去重机制
_hitTargets,防止单次攻击多段伤害异常。final Set<PositionComponent> _hitTargets = {}; -
集成
CollisionCallbacks支持物理引擎碰撞。@override void onCollisionStart( Set<Vector2> intersectionPoints, PositionComponent other, ) { super.onCollisionStart(intersectionPoints, other); _applyHit(other); } @override void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) { super.onCollision(intersectionPoints, other); _applyHit(other); }⚠️ 注意
如果这里只依靠 Flame 的
CollisionCallbacks判断命中,是有问题的:- 已经站在攻击矩形里的敌人 ❌ 不会触发
- 攻击生成瞬间就重叠 ❌ 不一定触发
这就是 砍刀贴脸 = 没伤害 的经典 bug 来源
究其原因,Flame 的碰撞模型核心是
发生碰撞 → 触发回调,只能告诉你 两个碰撞体是否接触
但它不能可靠地回答: 当前攻击区域内 有哪些 目标因此,我们需要在
update中手动判断,CollisionCallbacks只能作为辅助判断。@override void update(double dt) { super.update(dt); final ui.Rect attackRect = getAttackRect(); if (targetType == HeroComponent) { for (final h in game.world.children.query<HeroComponent>()) { if (h == owner) continue; final ui.Rect targetRect = h.hitbox.toAbsoluteRect(); if (_shouldDamage(attackRect, targetRect)) { _applyHit(h); } } } else if (targetType == MonsterComponent) { for (final m in game.world.children.query<MonsterComponent>()) { if (m == owner) continue; final ui.Rect targetRect = m.hitbox.toAbsoluteRect(); if (_shouldDamage(attackRect, targetRect)) { _applyHit(m); } } } }
(3) 智能索敌系统
-
autoLockNearestTarget:自动筛选最近的有效目标(排除自身、过滤距离)。/// 子类实现:当找到最近目标时的处理 void onLockTargetFound(PositionComponent target); /// 子类实现:当未找到目标时的处理(如跟随摇杆方向) void onNoTargetFound(); /// 自动锁定最近目标 void autoLockNearestTarget() { final PositionComponent? target = _findNearestTarget(); if (target != null) { onLockTargetFound(target); } else { onNoTargetFound(); } } -
angleToTarget:计算精准的攻击朝向。/// 计算到目标的朝向角度(弧度) double angleToTarget(PositionComponent target, Vector2 from) { final Vector2 origin = from; final Vector2 targetPos = target.position.clone(); return math.atan2(targetPos.y - origin.y, targetPos.x - origin.x); }
(4) 生命周期控制
- 支持命中即销毁 (
removeOnHit) 或穿透模式。(子类通过传递参数控制)if (removeOnHit) { removeFromParent(); } - 基于时间的自动销毁机制。
(5) 扩展说明
- 所有攻击判定体(近战、子弹、AOE等)均应继承此类。
- 子类需实现
getAttackRect以定义具体的攻击区域形状。
3. 普通近战攻击组件 MeleeHitbox
(1) 矩形判定区域
- 使用
RectangleHitbox作为物理碰撞检测区域。@override ui.Rect getAttackRect() => hitbox.toAbsoluteRect(); - 默认配置为被动碰撞类型 (
CollisionType.passive)。hitbox = RectangleHitbox()..collisionType = CollisionType.passive;👉 表示:这个碰撞体只接收碰撞,不主动推动或阻挡别人
(2) 自动索敌转向
@override
void onLockTargetFound(PositionComponent target) {
final ui.Rect rect = getAttackRect();
final Vector2 center = Vector2(
rect.left + rect.width / 2,
rect.top + rect.height / 2,
);
angle = angleToTarget(target, center);
}
- 重写
onLockTargetFound实现攻击方向自动对准最近目标。 - 通过调整组件旋转角度 (
angle) 来指向目标中心。
(3) 生命周期管理
double _timer = 0;
@override
void update(double dt) {
...
_timer += dt;
if (_timer >= duration) {
removeFromParent();
}
}
- 使用内部计时器
_timer精确控制攻击持续时间。 - 超时自动销毁,模拟瞬间挥砍效果。
(4) 位置修正
// 将传入的左上角坐标转换为中心坐标以便旋转
position: position + size / 2,
- 构造时自动将左上角坐标转换为中心坐标,确保旋转围绕中心点进行。
(5) 适用场景
- 刀剑挥砍、拳击等 短距离瞬间攻击。
- 需要自动吸附或转向目标的近身攻击。
4. 远程投射物攻击组件 BulletHitbox
(1) 直线弹道运动
- 基于
direction和config.speed进行每帧位移。 - 记录飞行距离
_distanceTraveled,超过射程config.maxRange自动销毁。@override void update(double dt) { super.update(dt); ... final moveStep = direction * config.speed * dt; position += moveStep; _distanceTraveled += moveStep.length; if (_distanceTraveled >= config.maxRange) { removeFromParent(); } }
(2) 智能索敌与方向锁定
-
onLockTargetFound:发射时若检测到敌人,自动锁定方向朝向敌人。@override void onLockTargetFound(PositionComponent target) { // 设置从人物到最近敌人的直线方向 final Vector2 origin = position.clone(); final Vector2 targetPos = target.position.clone(); direction = (targetPos - origin).normalized(); _locked = true; } -
onNoTargetFound:若无敌人,优先使用摇杆方向,否则保持初始方向。 -
_locked机制:确保子弹一旦发射,方向即被锁定,不会随玩家后续操作改变轨迹。bool _locked = false; @override void onNoTargetFound() { // 子弹攻击:若无目标,且尚未锁定方向,则尝试使用摇杆方向 // 如果摇杆也无输入,保持初始 direction if (!_locked && !game.joystick.delta.isZero()) { direction = game.joystick.delta.normalized(); } // 无论是否使用了摇杆方向,只要进入这里(说明没找到敌人),就锁定方向。 // 防止后续飞行中因为摇杆变动而改变方向。 _locked = true; }
(3) 视觉表现
-
支持静态贴图或帧动画 (
SpriteAnimationComponent)。@override Future<void> onLoad() async { super.onLoad(); if (config.spritePath != null) { final image = await game.images.load(config.spritePath!); if (config.animation != null) { final sheet = SpriteSheet( image: image, srcSize: config.textureSize ?? config.size, ); final anim = sheet.createAnimation( row: config.animation!.row, stepTime: config.animation!.stepTime, from: config.animation!.from, to: config.animation!.to, loop: config.animation!.loop, ); add(SpriteAnimationComponent(animation: anim, size: size)); } else { final sprite = Sprite(image); add(SpriteComponent(sprite: sprite, size: size)); } } }
(4) 碰撞特性
- 使用
RectangleHitbox并设为CollisionType.active主动检测碰撞。RectangleHitbox()..collisionType = CollisionType.active; - 支持穿透属性 (
config.penetrate),决定命中后是否立即销毁。removeOnHit: !config.penetrate,
(5) 适用场景
- 弓箭、魔法球、枪械子弹等远程攻击。
- 需要直线飞行且射程受限的投射物。
5. 冲刺攻击组件 DashHitbox
滑步 其实就是游戏中常见的 冲撞技能。
因此,我们依旧继承我们的攻击判定基类 AbstractAttackRect,并在此基础上实现位移就行了。
(1) 位移与物理运动
- 直接驱动归属者
owner进行高速位移。 - 集成物理碰撞检测
moveWithCollision,防止穿墙。 - 持续同步位置
position.setFrom(owner.position),确保攻击判定跟随角色。
if (_locked && !direction.isZero()) {
final delta = direction * speed * dt;
if (owner is CharacterComponent) {
final char = owner as CharacterComponent;
char.moveWithCollision(delta);
if (delta.x > 0) char.faceRight();
if (delta.x < 0) char.faceLeft();
} else {
owner.position += delta;
}
}
position.setFrom(owner.position);
(2) 摇杆操作与方向锁定
-
onNoTargetFound:优先使用摇杆方向,否则沿当前朝向冲刺。 -
_locked机制:确保冲刺过程中方向恒定,不受中途操作影响。
@override
void onNoTargetFound() {
if (_locked) return;
if (!game.joystick.delta.isZero()) {
direction = game.joystick.delta.normalized();
} else {
if (owner is CharacterComponent) {
direction = Vector2((owner as CharacterComponent).facingRight ? 1 : -1, 0);
} else {
direction = Vector2(1, 0);
}
}
_locked = true;
}
(3) 持续伤害判定
-
removeOnHit: false:冲刺不会因命中敌人而停止 (穿透效果)。 - 在持续时间
duration内,对路径上接触的所有有效目标造成伤害。
_elapsedTime += dt;
if (_elapsedTime >= duration) {
removeFromParent();
return;
}
(4) 生命周期管理
- 基于时间
_elapsedTime控制冲刺时长,结束后自动销毁组件。
(5) 适用场景
- 战士冲锋、刺客突进等位移技能。
- 需要同时兼顾位移和伤害的技能机制。
五. 游戏音效
在游戏体验中,音效并不是装饰品,而是反馈系统的一部分。
无论是攻击命中、角色受伤,还是场景交互,如果音效分散写在各个组件中,往往会造成资源重复加载、逻辑混乱、难以统一管理音量与状态的问题。
因此,我们对游戏音频进行统一封装,引入一个 AudioManager,集中负责 BGM 与音效(SFX)的加载、播放、暂停与语义化调用,让游戏逻辑只关心 发生了什么,而不关心音效怎么放。
1. 封装 AudioManager
class AudioManager {
static bool _inited = false;
static String? _currentBgm;
static double bgmVolume = 0.8;
static double sfxVolume = 1.0;
/// 必须在游戏启动时调用
static Future<void> init() async {
...
}
// ================== SFX ==================
static Future<void> playSfx(String file, {double? volume}) async {
...
}
// ================== BGM ==================
static Future<void> playBgm(String file, {double? volume}) async {
...
}
static Future<void> stopBgm() async {
...
}
static Future<void> pauseBgm() async {
...
}
static Future<void> resumeBgm() async {
...
}
// ================== 语义化封装 ==================
static Future<void> playDoorOpen() => playSfx('door_open.wav');
static Future<void> playSwordClash() => playSfx('sword_clash_2.wav');
static Future<void> playFireLighting() => playSfx('fire_lighting.wav');
static Future<void> startBattleBgm() => playBgm('Goblins_Dance_(Battle).wav');
static Future<void> startRegularBgm() => playBgm('Goblins_Den_(Regular).wav');
static Future<void> playDoorKnock() => playSfx('door_knock.wav');
static Future<void> playWhistle() => playSfx('whistle.wav');
static Future<void> playHurt() => playSfx('Hurt.wav');
static Future<void> playLaserGun() => playSfx('Laser_Gun.wav');
}
2. 音效使用
-
mygame中初始化,并开始播放bgm@override Future<void> onLoad() async { // 加载游戏资源 super.onLoad(); // 初始化音频管理器 await AudioManager.init(); // 播放BGM AudioManager.startRegularBgm(); // 加载地图 await _loadLevel(); } -
BulletHitbox:子弹音效 -
DoorComponent: 敲门与关门音效 -
HeroComponent: 受击音效 - ...
大家去网上找 音频 后,保存在
assets/aduio/下,在AudioManager中加载使用, 就可以在你想要的地方添加了。
六. 召唤术
在上述,有了 近战、远程和冲刺 的矩形判断之后,我们的小人就掌握了 普攻 、 火球法术 和 滑步。
但是,我觉得那些还不够有意思,因为他是 魔法师。
于是乎,会 召唤 小弟的滑步魔法师,他来了。
1. 构思
一开始,我打算新建一个 GenerateComponent 继承 CharacterComponent。
这很简单,依葫芦画瓢 很快也就实现了,但是到了召唤物攻击逻辑时,就头疼了。
因为,我们之前所有逻辑都是围绕两个阵营的 hero 🆚 monster,新增第三方,就要重构了。
但是转念一想,其实这个召唤物和其他两个类没什么不同,索性哪个人物召唤的,召唤物就用哪个人物的类创建就行了。
所有逻辑都不需要改变了,对战逻辑完全符合,仅仅需要新增一段 召唤物AI逻辑 就可以了。
2. 实现
- 新建召唤物生成工厂类
GenerateFactory -
所需属性- game:游戏容器,用于添加召唤物
- center: 人物中心点
- generateId: 召唤物id,用于定位配置资源
- owner: 召唤者
- enemyType: 敌对类型
- count: 召唤物数量
- radius: 角度
- followDistance: 跟随距离
-
确定生成物相对于人物的位置final step = 2 * math.pi / count; final start = -math.pi / 2; final list = <CharacterComponent>[]; for (int i = 0; i < count; i++) { final angle = start + i * step; final pos = Vector2( center.x + radius * math.cos(angle), center.y + radius * math.sin(angle), ); -
生成所有召唤物// 根据拥有者类型决定生成物类型 // Hero生成HeroComponent作为随从 // Monster生成MonsterComponent作为随从 if (owner is HeroComponent) { comp = HeroComponent( heroId: generateId, birthPosition: position, ); } else { comp = MonsterComponent(position, generateId); } // 设置召唤物通用属性 comp.position = position; comp.isGenerate = true; comp.summonOwner = owner; comp.followDistance = followDistance; ... list.add( create( position: pos, generateId: generateId, owner: owner, enemyType: enemyType, followDistance: followDistance, ), );
七. 人机逻辑
在游戏中,敌人是否 像个人 ,很大程度上取决于人机逻辑(AI)。
咱们的 AI 并不追求复杂,而是要做到 感知、判断和反馈 :
能发现敌人、能决定行动、也能在不同状态之间自然切换。
1. 怪物索敌逻辑
- 首先,寻找最近的
HeroComponent作为目标PositionComponent? target; double distance = double.infinity; for (final h in monster.game.world.children.query<HeroComponent>()) { final d = (h.position - monster.position).length; if (d < distance) { distance = d; target = h; } } - 然后,如果超出 感知范围 或未找到 目标,则 游荡
if (target == null || distance > monster.detectRadius) { if (monster.wanderDuration > 0) { monster.setState(CharacterState.run); final delta = monster.wanderDir * monster.speed * dt; monster.moveWithCollision(delta); monster.wanderDuration -= dt; monster.wanderDir.x >= 0 ? monster.faceRight() : monster.faceLeft(); } else { monster.wanderCooldown -= dt; if (monster.wanderCooldown <= 0) { final angle = monster.rng.nextDouble() * 2 * math.pi; monster.wanderDir = Vector2(math.cos(angle), math.sin(angle)); monster.wanderDuration = 0.6 + monster.rng.nextDouble() * 1.2; monster.wanderCooldown = 1.0 + monster.rng.nextDouble() * 2.0; } else { monster.setState(CharacterState.idle); } } return; } - 其次,如果在 感知范围内,就判断是否在可以发起 攻击 的 攻击范围内
// 进入攻击范围 if (distance <= monster.attackRange) { monster.attack(0, HeroComponent); return; } - 最后,如果不在 攻击范围内,则 追逐
// 追逐 monster.setState(CharacterState.run); final toTarget = target!.position - monster.position; final direction = toTarget.normalized(); final delta = direction * monster.speed * dt; monster.moveWithCollision(delta); direction.x >= 0 ? monster.faceRight() : monster.faceLeft();
2. 召唤物运行逻辑
-
确定敌对类型:如果自己是
HeroComponent,则敌人是MonsterComponent,反之亦然final bool isHero = component is HeroComponent; -
寻找最近的敌人
PositionComponent? target; if (isHero) { // 寻找最近的Monster for (final m in component.game.world.children.query<MonsterComponent>()) { if (m == component.summonOwner) continue; // 排除主人(如果是) if (target == null || (m.position - component.position).length < (target!.position - component.position).length) { target = m; } } } else { // 寻找最近的Hero for (final h in component.game.world.children.query<HeroComponent>()) { if (h == component.summonOwner) continue; if (target == null || (h.position - component.position).length < (target!.position - component.position).length) { target = h; } } } - 如果在 攻击范围内,则发起 攻击追逐
final toEnemy = target.position - component.position; final enemyDistance = toEnemy.length; if (enemyDistance <= detectRadius) { // 进入攻击范围 if (enemyDistance <= attackRange) { component.attack(0, isHero ? MonsterComponent : HeroComponent); return; } // 追击敌人 component.setState(CharacterState.run); final direction = toEnemy.normalized(); final delta = direction * component.speed * dt; component.moveWithCollision(delta); direction.x >= 0 ? component.faceRight() : component.faceLeft(); return; } - 如果附近没敌人,就跟随 召唤者
if (component.summonOwner != null && component.summonOwner!.parent != null) { final toOwner = component.summonOwner!.position - component.position; final ownerDistance = toOwner.length; final double deadZone = 8.0; if (ownerDistance > component.followDistance + deadZone) { component.setState(CharacterState.run); final direction = toOwner.normalized(); final delta = direction * component.speed * dt; component.moveWithCollision(delta); direction.x >= 0 ? component.faceRight() : component.faceLeft(); return; } }
八. 游戏逻辑
终于终于,将本期内容介绍的差不多了,那么简单设计一下 体验版demo 的逻辑,完结基础篇吧。
1. 绘图
在图中,新增 名为 spawn_points 的 object layer图层,设置四个 怪物出生点 和 一个胜利的终点:
-
胜利点属性:- type:类型 goal
-
怪物出生点属性:-
type :类型 monster_spawn
-
monsterId:怪物类型id
-
maxCount:该怪物点,最大怪物存活数量
-
perCount:该怪物点,每次生成怪物数量
-
productSpeed:该怪物点,生成怪物速度
-
2. 新增怪物出生点
/// 怪物生成点组件
///
/// - 支持定时按批次生成怪物
/// - 支持最大数量限制与开始/停止控制
/// - 位置与大小由关卡配置决定(用于调试显示)
class SpawnPointComponent extends PositionComponent
with HasGameReference<MyGame> {
/// 场景允许存在的最大怪物总数
final int maxCount;
/// 要生成的怪物类型 ID(与现有代码一致,使用字符串)
final String monsterId;
/// 每次生成的怪物数量
final int perCount;
/// 每次生成的时间间隔
final Duration productSpeed;
bool _running = false;
double _timeSinceLastSpawn = 0;
final Set<MonsterComponent> _spawned = {};
SpawnPointComponent({
required Vector2 position,
required Vector2 size,
required this.maxCount,
required this.monsterId,
required this.perCount,
required this.productSpeed,
Anchor anchor = Anchor.center,
int priority = 0,
}) : super(
position: position,
size: size,
anchor: anchor,
priority: priority,
);
@override
Future<void> onLoad() async {
debugMode = true;
}
/// 开始生成
void start() {
_running = true;
}
/// 停止生成并重置计时
void stop() {
_running = false;
_timeSinceLastSpawn = 0;
}
@override
void update(double dt) {
super.update(dt);
if (!_running) return;
_timeSinceLastSpawn += dt;
final intervalSeconds = productSpeed.inMicroseconds / 1e6;
// 按间隔生成,避免长帧遗漏
while (_timeSinceLastSpawn >= intervalSeconds) {
_timeSinceLastSpawn -= intervalSeconds;
_spawnBatch();
}
}
void _spawnBatch() {
// 仅统计由该生成点产生、且仍存在于场景中的怪物数量
_spawned.removeWhere((m) => m.parent == null);
final currentCount = _spawned.length;
final allowance = maxCount - currentCount;
if (allowance <= 0) return;
final batch = math.min(perCount, allowance);
for (int i = 0; i < batch; i++) {
final monster = MonsterComponent(position.clone(), monsterId);
monster.debugMode = true;
game.world.add(monster);
_spawned.add(monster);
}
}
}
3. 新增通关点
class GoalComponent extends SpriteAnimationComponent
with HasGameReference<MyGame>, CollisionCallbacks {
GoalComponent({required Vector2 position, required Vector2 size})
: super(position: position, size: size);
@override
Future<void> onLoad() async {
await super.onLoad();
final image = await game.images.load('flag.png');
final sheet = SpriteSheet(image: image, srcSize: Vector2(60, 60));
animation = sheet.createAnimation(
row: 0,
stepTime: 0.12,
from: 0,
to: 4,
loop: true,
);
add(RectangleHitbox());
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
if (other is HeroComponent) {
AudioManager.playWhistle();
UiNotify.showToast(game, '恭喜你完成了游戏!');
other.onDead();
}
}
}
4. demo流程
graph TD
Start[启动游戏] --> Init[初始化: 加载资源/音乐/地图]
Init --> Spawn[生成: 英雄, 怪物, 道具]
Spawn --> Loop{游戏循环}
Loop --> Input[玩家输入: 摇杆/攻击]
Input --> Update[状态更新: 移动/战斗/物理]
Update --> Check{检测状态}
Check -- "HP <= 0" --> Dead[死亡: 游戏结束]
Check -- "获得钥匙" --> OpenDoor[交互: 开启门/宝箱]
Check -- "到达终点" --> Win[胜利: 通关]
Check -- "继续" --> Loop
OpenDoor --> Loop
Dead --> Restart[显示重开按钮]
Win --> Restart
Restart --> Init
九. 总结与展望
总结
本章主要介绍了 Flutter&Flame 开发 2D像素游戏 关于 攻击逻辑 的基础实践。
通过上述步骤,我们完成了人物hud界面、近战、远程、冲刺和召唤 这几类常见攻击元素的实现。
截至目前为止,游戏主要包括了以下内容:
-
角色与动画:使用精灵图 (
SpriteSheet) 创建角色,支持 idle/run 等动画状态切换。 - 玩家交互:通过摇杆控制角色移动,并根据方向翻转动画。
-
地图加载:通过
Tiled绘制并在 Flame 中加载的 2d像素地图。 - 地图交互:通过组件化模式,新建了多个可供交互的组件如(门、钥匙、宝箱、地刺),为游戏增加了互动性。
- 统一碰撞区检测:将角色与 需要产生碰撞 的物体统一管理,并实现碰撞时的 平滑侧移。
- 统一人物配置创建: 通过将角色数据配置为文件,达到以动态数据驱动模型的目的。
-
HUD界面: 包括
人物血量条和技能按钮。 -
完善的攻击逻辑:通过统一基类实现
近战、远程、冲刺的攻击方式 和 独特召唤技能。
展望
-
思考 🤔 一个有趣的游戏机制ing ...
-
进阶这个demo版
-
支持局域网多玩家联机功能。
之前尝试的Demo预览
Flutter 实现一个容器内部元素可平移、缩放和旋转等功能(六)
Flutter 实现一个容器内部元素可平移、缩放和旋转等功能(六)
Flutter: 3.35.6
前面有人提到在元素内部的那块判断怎么那么写的,看来对知识渴望的小伙伴还是有,这样挺好的。不至于说牢记部分知识,只需要大致了解一下有个印象后面如果哪里再用到了就可以根据这个印象去查阅资料。
接下来我们说一下判断原理。当我们知晓矩形的四个顶点坐标(包括任意旋转后),可以使用向量叉乘法来判断是否在矩形内部。
向量叉乘法的核心思想就是:如果一个点在凸多边形内部,那么它应该始终位于该多边形每条边的同一侧。
注:凸多边形定义为所有内角均小于180度,并且任意两点之间的连线都完全位于多边形内部或边界。
所以我们利用向量叉乘法来判断点位于线的哪一侧。假设矩形顶点按顺时针或逆时针顺序为 A,B,C,D:
- 对于边 AB,计算向量 AB 和 AP 的叉积。
- 对于边 BC,计算向量 BC 和 BP 的叉积。
- 对于边 CD,计算向量 CD 和 CP 的叉积。
- 对于边 DA,计算向量 DA 和 DP 的叉积。
如果点 P 在所有边的同一侧(即所有叉积结果的符号相同),那么点 P 就在矩形内部。我们假设矩形某条边的顶点为(x1, y1), (x2, y2), 判断的点坐标为(x, y),那么就有:
(x2 - x1) * (y - y1) - (y2 - y1) * (x - x1) > 0
这样就可以判断在某侧,如果其他三条边也满足,那就是内侧了,要转换为下面代码中的形式,那就做一下加减乘除就行了:
- (x2 - x1) * (y - y1) - (y2 - y1) * (x - x1) > 0: 初始
- (x2 - x1) * (y - y1) / (y2 - y1) - (x - x1) > 0: 两边同时除以(y2 - y1)
- (x2 - x1) * (y - y1) / (y2 - y1) - x + x1 > 0: 展开括号
- (x2 - x1) * (y - y1) / (y2 - y1) + x1 > x: 将x移项
这样就得到了代码中的判断依据,至于循环遍历顶点的写法,就是为了获取相邻两个顶点,这个就可以带入square坐标和循环去算一下就行了,保证每次循环都是相邻的两个顶点。
我们使用的顶点坐标顺序是顺时针,第一次循环 i = 0,j = 3,那么i就是左上顶点,j就是左下顶点,两个顶点刚好构成矩形左边;第二次循环 j = i++,此时 j = 0,i = 1后续喜欢以此类推即可。
这样判断在内侧差不多就解释完了。接下来开始我们今天正文。前面我们就简单完成了多个元素的相应操作,剩下的就是一些优化和一些简单的扩展功能。
既然是多个元素,那么肯定就涉及到新增和删除,之前的新增都是在列表里面直接添加,现在我们单独提取一个方法用于新增。至于删除功能我们就定义在元素左上角为删除区域,触发方式为点击。之前我们对热区的数据模型中添加了 trigger 字段,用于表示当前区域触发操作的方式是什么,所以我们得对点击方法进行优化,并且在临时中间变量上面存储 trigger 字段,用于判断:
class ResponseAreaModel {
// 其他省略...
/// 当前响应操作的触发方式
final TriggerMethod trigger;
}
/// 新增返回Records,用于记录状态和触发方式
(ElementStatus, TriggerMethod)? _onDownZone({
required double x,
required double y,
required ElementModel item,
}) {
// 先判断是否在响应对应操作的区域
final (ElementStatus, TriggerMethod)? areaStatus = _getElementZone(x: x, y: y, item: item);
if (areaStatus != null) {
return areaStatus;
} else if (_insideElement(x: x, y: y, item: item)) {
// 因为加入旋转,所以单独抽取落点是否在元素内部的方法
return (ElementStatus.move, TriggerMethod.move);
}
return null;
}
/// 新增返回Records,用于记录状态和触发方式
(ElementStatus, TriggerMethod)? _getElementZone({
required double x,
required double y,
required ElementModel item,
}) {
// 新增Records记录返回的状态和触发方式
(ElementStatus, TriggerMethod)? tempStatus;
for (var i = 0; i < ConstantsConfig.baseAreaList.length; i++) {
// 其他省略...
if (
x >= dx - areaCW &&
x <= dx + areaCW &&
y >= dy - areaCH &&
y <= dy + areaCH
) {
tempStatus = (currentArea.status, currentArea.trigger);
break;
}
}
return tempStatus;
}
这样触发方式和状态都记录了,我们就开始实现删除功能,依然按照之前的步骤快速实现:
// 新增删除
ResponseAreaModel(
areaWidth: 20,
areaHeight: 20,
xRatio: 0,
yRatio: 0,
status: ElementStatus.deleteStatus,
icon: 'assets/images/icon_delete.png',
trigger: TriggerMethod.down,
),
/// 处理删除元素
void _onDelete() {
if (_currentElement == null) return;
_elementList.removeWhere((item) => item.id == _currentElement?.id);
}
/// 按下事件
void _onPanDown(DragDownDetails details) {
// 其他省略...
// 遍历判断当前点击的位置是否落在了某个元素的响应区域
for (var item in _elementList) {
// 新增Records数据,存储元素状态和触发方式
final (ElementStatus, TriggerMethod)? status = _onDownZone(x: dx, y: dy, item: item);
if (status != null) {
currentElement = item;
temp = temp.copyWith(status: status.$1, trigger: status.$2);
break;
}
}
// 新增判断
// 如果当前有选中的元素且和点击区域的currentElement是一个元素
// 并且 temp 的 status对应的触发方式为点击,那么就响应对应的点击事件
if (currentElement?.id == _currentElement?.id && temp.trigger == TriggerMethod.down) {
if (temp.status == ElementStatus.deleteStatus) {
_onDelete();
// 因为是删除,就置空选中,让下面代码执行最后的清除
currentElement = null;
}
}
// 其他省略...
}
运行效果:
这样就简单实现了元素的删除功能。到此操作区域常用的功能差不多就完成,接下来我们考虑一些区域的自定义;例如我希望旋转的区域在右下角(现在在右上角),并且不使用缩放功能,还想自定义一个区域,这时候该如何实现呢?
允许传递配置,通过这份配置来决定元素应该有什么响应区域并且是否使用这些响应区域,然而操作这些内置的我们可以使用之前定义的 final ElementStatus status; 字段来确定要修改哪个区域,毕竟一个操作应该是对应一个区域;对于自定义区域,我们的ElementStatus是个枚举类型且为必传,这就限制了自定义区域,所以我们的改造一下,用户传递的自定义区域,status为自行设置的字符串,我们内部也同时更改为字符串(涉及更改的地方有一些,这里不做过多的说明,后续可以查阅源码):
/// 元素当前操作状态
/// 更改新增字符串的value属性
enum ElementStatus {
move(value: 'move'),
rotate(value: 'rotate'),
scale(value: 'scale'),
deleteStatus(value: 'deleteStatus'),;
final String value;
const ElementStatus({required this.value});
}
/// 大致说一些需要更改的地方
/// TemporaryModel 的 status 更改为字符串类型
/// ResponseAreaModel 的 status 更改为字符串类型
/// _onDownZone 方法中 Records 第一项也返回 String
/// _getElementZone 方法中 Records 第一项也返回 String
接下来我们确定自定义区域配置中需要的字段:
- status:用于映射内置的区域,方便做更改(String 必须)
- use:用于确定该 status 对应的内置区域是否使用(bool 非必须)
- xRatio:用于确定区域位置(double 非必须,如果非内置的默认就是0)
- yRatio:用于确定区域位置(double 非必须,如果非内置的默认就是0)
- trigger:用于确定区域的触发方式(TriggerMethod 非必须,默认TriggerMethod.down)
- icon:用于确定操作区域的展示icon(String 如果是内置的 status 就是非必须,如果不是内置的就是必须)
- fn:自定义区域需要执行的方法(Function({required double x, required double y}) 如果是内置的就是非必须,如果不是内置的就必须)
基于上述开始进行编码:
/// 新增自定义区域配置
class CustomAreaConfig {
const CustomAreaConfig({
required this.status,
this.use,
this.xRatio,
this.yRatio,
this.trigger = TriggerMethod.down,
this.icon,
this.fn,
});
/// 区域的操作状态字符串,可以是内置的,如果是内置的就覆盖内置的属性
final String status;
/// 是否启用
final bool? use;
/// 自定义位置
final double? xRatio;
final double? yRatio;
/// 区域响应操作的触发方式
final TriggerMethod trigger;
/// 自定义区域就是必传
final String? icon;
/// 自定义区域就是必传,点击对应的响应区域就执行自定义的方法
final Function({required double x, required double y})? fn;
}
/// 新增自定义区域配置
final List<CustomAreaConfig> _customAreaList = [
// 不使用缩放区域
CustomAreaConfig(
status: ElementStatus.scale.value,
use: false,
),
// 将旋转移到右下角
CustomAreaConfig(
status: ElementStatus.rotate.value,
xRatio: 1,
yRatio: 1,
),
];
/// 容器响应操作区域,之前是直接使用的常量里面的配置
List<ResponseAreaModel> _areaList = [];
/// 初始化响应区域
void _initArea() {
List<ResponseAreaModel> areaList = [];
for (var area in ConstantsConfig.baseAreaList) {
final int index = _customAreaList.indexWhere((item) => item.status == area.status);
if (index > -1) {
final CustomAreaConfig customArea = _customAreaList[index];
// 如果是不使用,则跳出本次循环
if (customArea.use == false) {
continue;
}
areaList.add(area.copyWith(
xRatio: customArea.xRatio,
yRatio: customArea.yRatio,
icon: customArea.icon,
fn: customArea.fn,
));
} else {
areaList.add(area);
}
}
setState(() {
_areaList = areaList;
});
}
// 其他省略...
/// 抽取渲染的元素
class TransformItem extends StatelessWidget {
const TransformItem({
// 其他省略...
required this.areaList,
});
// 其他省略...
final List<ResponseAreaModel> areaList;
@override
Widget build(BuildContext context) {
return Positioned(
left: elementItem.x,
top: elementItem.y,
// 新增旋转功能
child: Transform.rotate(
angle: elementItem.rotationAngle,
child: Container(
// 其他省略...
// 新增区域的渲染
child: selected ? Stack(
clipBehavior: Clip.none,
children: [
// 修改从外界传递区域列表
...areaList.map((item) => Positioned(
// 其他省略...
)),
],
) : null,
),
)
);
}
}
其他编码不算核心,就不再展示了,反正就一个,之前从 ConstantsConfig.baseAreaList 拿的数据现在都直接使用 _areaList。
运行效果:
可以看到,我们将旋转区域移到右下角了,并且不使用缩放区域,这样就简单完成了区域自定义的配置。
感兴趣的也可以关注我的微信公众号【前端学习小营地】,不定时会分享一些小功能~
今天的分享就到此结束了,感谢阅读~拜拜~