Merge #62

Merged
obugs merged 3 commits from check into main 2026-10-05 17:57:57 +08:00
7 changed files with 5666 additions and 59 deletions
Showing only changes of commit 2af05f761e - Show all commits
+1
View File
@@ -32,6 +32,7 @@
<!-- <script src="/script/docsify-copy-code.min.js"></script> --> <!-- <script src="/script/docsify-copy-code.min.js"></script> -->
<script src="/script/switchLightDarkMode.js"></script> <script src="/script/switchLightDarkMode.js"></script>
<script src="/script/otto.js"></script> <script src="/script/otto.js"></script>
<script src="/script/custommethod-data.js"></script>
<script src="/script/custommethod-search.js"></script> <script src="/script/custommethod-search.js"></script>
<!-- <script <!-- <script
src="https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-yaml.js" src="https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-yaml.js"
+12
View File
@@ -93,6 +93,18 @@
## 快捷查询 ## 快捷查询
<div class="custommethods-search"> <div class="custommethods-search">
<div class="cm-search">
<input class="cm-query" type="text" placeholder="输入自定义方法名,如 ChangeCharacter" autocomplete="off">
<ul class="cm-suggest"></ul>
</div>
<div class="cm-selected" hidden>
<div class="cm-head">
<span class="cm-name"></span>
<span class="cm-desc"></span>
</div>
<div class="cm-row"></div>
<code class="cm-preview"></code>
</div>
</div> </div>
## 我知道你还想问什么 ## 我知道你还想问什么
File diff suppressed because it is too large Load Diff
+625 -30
View File
@@ -1,31 +1,626 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
const customMethods = [ return new (P || (P = Promise))(function (resolve, reject) {
{ type: 'mod',} function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
] function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
const editDistance = (a, b) => { step((generator = generator.apply(thisArg, _arguments || [])).next());
if (a.length === 0) return b.length; });
if (b.length === 0) return a.length; };
const matrix = []; (function () {
for (let i = 0; i <= b.length; i++) { "use strict";
matrix[i] = [i]; let ParameterType;
} (function (ParameterType) {
for (let j = 0; j <= a.length; j++) { ParameterType["String"] = "string";
matrix[0][j] = j; ParameterType["Number"] = "number";
} ParameterType["Boolean"] = "boolean";
for (let i = 1; i <= b.length; i++) { ParameterType["Enum"] = "enum";
for (let j = 1; j <= a.length; j++) { })(ParameterType || (ParameterType = {}));
if (b.charAt(i - 1) === a.charAt(j - 1)) { let MethodType;
matrix[i][j] = matrix[i - 1][j - 1]; (function (MethodType) {
} else { MethodType["Mod"] = "mod";
matrix[i][j] = Math.min( MethodType["Surgery"] = "surgery";
matrix[i - 1][j - 1] + 1, MethodType["Comment"] = "comment";
Math.min(matrix[i][j - 1] + 1, matrix[i - 1][j] + 1), MethodType["CustomMethod"] = "custom_method";
); MethodType["Ink"] = "ink";
} MethodType["Variable"] = "variable";
MethodType["Condition"] = "condition";
MethodType["CustomClass"] = "custom_class";
})(MethodType || (MethodType = {}));
const enumTypeMap = {
Characters: ["Logan", "Green", "Blue", "Yellow", "Purple", "Orange"],
Size: ["Small", "Medium", "Large"],
Shape: ["Circle", "Square", "Triangle"],
};
class NumericRange {
constructor(min, max, isInteger = false) {
this.min = min;
this.max = max;
this.isInteger = isInteger;
}
} }
} class Parameter {
return matrix[b.length][a.length]; constructor(name, type, description, enumType, numericRange) {
} this.name = name;
this.type = type;
this.description = description;
this.enumType = enumType;
this.numericRange = numericRange;
}
}
class Method {
constructor(name, type, description) {
this.name = name;
this.type = type;
this.description = description;
}
}
class Mod extends Method {
constructor(name, description) {
super(name, MethodType.Mod, description);
}
}
class Surgery extends Method {
constructor(name, description) {
super(name, MethodType.Surgery, description);
}
}
class CommentMethod extends Method {
constructor(name, description, parameters) {
super(name, MethodType.Comment, description);
this.parameters = parameters;
}
}
class InkMethod extends Method {
constructor(name, description, parameters) {
super(name, MethodType.Ink, description);
this.parameters = parameters;
}
}
class CustomMethods extends Method {
constructor(name, description, parameters) {
super(name, MethodType.CustomMethod, description);
this.parameters = parameters;
}
}
class VariableMethod extends Method {
constructor(name, description, parameters) {
super(name, MethodType.Variable, description);
this.parameters = parameters;
}
}
class ConditionMethod extends Method {
constructor(name, description, parameters) {
super(name, MethodType.Condition, description);
this.parameters = parameters;
}
}
class CustomClassMethod extends Method {
constructor(name, description) {
super(name, MethodType.CustomClass, description);
}
}
function editDistance(a, b, caseSensitive = true) {
if (a.length === 0)
return b.length;
if (b.length === 0)
return a.length;
const matrix = [];
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
const ac = a.charAt(j - 1);
const bc = b.charAt(i - 1);
if (caseSensitive ? bc === ac : bc.toLowerCase() === ac.toLowerCase()) {
matrix[i][j] = matrix[i - 1][j - 1];
}
else {
matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, Math.min(matrix[i][j - 1] + 1, matrix[i - 1][j] + 1));
}
}
}
return matrix[b.length][a.length];
}
function parametersOf(method) {
var _a;
return (_a = method.parameters) !== null && _a !== void 0 ? _a : [];
}
// ---------------------------------------------------------------------
// 数据:可直接在此编辑,或通过 window.CustomMethodSearch.setMethods(...) 覆盖
// ---------------------------------------------------------------------
let methods = [
new Mod("wobblyLines", "让单发拍子的轨道抖动"),
new Mod("invisibleHeart", "隐藏轨道上的心脏"),
new CustomMethods("ChangeCharacter", "修改某条轨道对应的人物", [
new Parameter("character", ParameterType.Enum, "Characters"),
new Parameter("row", ParameterType.Number, "轨道编号", undefined, new NumericRange(0, 15, true)),
]),
new CustomMethods("DisableRowReflections", "开关某个房间的地面反射", [
new Parameter("room", ParameterType.Number, "房间编号", undefined, new NumericRange(0, 3, true)),
]),
new CustomMethods("SetRowLength", "设置轨道长度", [
new Parameter("row", ParameterType.Number, "轨道编号", undefined, new NumericRange(0, 15, true)),
new Parameter("length", ParameterType.Number, "轨道长度", undefined, new NumericRange(1, 7, true)),
]),
new CommentMethod("trueCameraMove", "移动真实摄像头", [
new Parameter("x", ParameterType.Number, "目标 X"),
new Parameter("y", ParameterType.Number, "目标 Y"),
new Parameter("angle", ParameterType.Number, "角度"),
new Parameter("duration", ParameterType.Number, "时长"),
new Parameter("ease", ParameterType.Enum, "缓动类型", "EaseType"),
]),
new CustomMethods("DemoAllTypes", "演示四种参数控件", [
new Parameter("text", ParameterType.String, "文本"),
new Parameter("count", ParameterType.Number, "数字"),
new Parameter("enabled", ParameterType.Boolean, "开关"),
new Parameter("character", ParameterType.Enum, "角色", "Characters"),
new Parameter("color", ParameterType.Enum, "颜色"),
]),
];
// 合并外置数据(script/custommethod-data.js),同名以内置为准
function hydrateMethod(raw) {
var _a, _b, _c, _d, _e, _f, _g, _h;
const parameters = Array.isArray(raw.parameters)
? raw.parameters.map((item) => {
var _a, _b;
return new Parameter(item.name, (_a = item.type) !== null && _a !== void 0 ? _a : ParameterType.String, (_b = item.description) !== null && _b !== void 0 ? _b : "", item.enumType, item.numericRange
? new NumericRange(item.numericRange.min, item.numericRange.max, item.numericRange.isInteger)
: undefined);
})
: [];
switch (raw.type) {
case MethodType.Mod: return new Mod(raw.name, (_a = raw.description) !== null && _a !== void 0 ? _a : "");
case MethodType.Surgery: return new Surgery(raw.name, (_b = raw.description) !== null && _b !== void 0 ? _b : "");
case MethodType.Comment: return new CommentMethod(raw.name, (_c = raw.description) !== null && _c !== void 0 ? _c : "", parameters);
case MethodType.Ink: return new InkMethod(raw.name, (_d = raw.description) !== null && _d !== void 0 ? _d : "", parameters);
case MethodType.Variable: return new VariableMethod(raw.name, (_e = raw.description) !== null && _e !== void 0 ? _e : "", parameters);
case MethodType.Condition: return new ConditionMethod(raw.name, (_f = raw.description) !== null && _f !== void 0 ? _f : "", parameters);
case MethodType.CustomClass: return new CustomClassMethod(raw.name, (_g = raw.description) !== null && _g !== void 0 ? _g : "");
default: return new CustomMethods(raw.name, (_h = raw.description) !== null && _h !== void 0 ? _h : "", parameters);
}
}
function loadExternalData() {
const data = window.CustomMethodSearchData;
if (!data)
return;
if (data.enumTypes) {
for (const key of Object.keys(data.enumTypes)) {
enumTypeMap[key] = data.enumTypes[key];
}
}
if (Array.isArray(data.methods)) {
const index = new Map();
methods.forEach((method, i) => index.set(method.name.toLowerCase(), i));
for (const raw of data.methods) {
if (!raw || !raw.name)
continue;
const name = String(raw.name).toLowerCase();
const hydrated = hydrateMethod(raw);
if (index.has(name)) {
methods[index.get(name)] = hydrated;
}
else {
index.set(name, methods.length);
methods.push(hydrated);
}
}
}
}
loadExternalData();
// ---------------------------------------------------------------------
// 匹配
// ---------------------------------------------------------------------
function searchMethods(query) {
const q = query.trim();
if (!q)
return [];
const ql = q.toLowerCase();
const maxDistance = Math.max(2, Math.floor(q.length / 2));
return methods
.map((method) => {
const name = method.name;
const lower = name.toLowerCase();
let score;
if (name === q) {
score = 0;
}
else if (lower.startsWith(ql)) {
score = 1;
}
else if (lower.indexOf(ql) >= 0) {
score = 2;
}
else {
score = 3 + editDistance(name, q, false);
}
return { method, score };
})
.filter((item) => item.score <= 2 || item.score <= 3 + maxDistance)
.sort((a, b) => a.score - b.score || a.method.name.localeCompare(b.method.name))
.slice(0, 8)
.map((item) => item.method);
}
// ---------------------------------------------------------------------
// DOM 构建
// ---------------------------------------------------------------------
function el(tag, className) {
const node = document.createElement(tag);
if (className)
node.className = className;
return node;
}
function resolveEnumOptions(parameter) {
if (parameter.enumType && enumTypeMap[parameter.enumType]) {
return enumTypeMap[parameter.enumType];
}
// 兼容把枚举类型名写在 description 的情况
if (enumTypeMap[parameter.description]) {
return enumTypeMap[parameter.description];
}
if (enumTypeMap[parameter.name]) {
return enumTypeMap[parameter.name];
}
return [];
}
// 布尔值可用 true/false,也可用布尔变量 b0~b9
const BOOLEAN_OPTIONS = ["true", "false"];
for (let i = 0; i < 10; i++) {
BOOLEAN_OPTIONS.push("b" + i);
}
function isNumberVariable(value) {
// 数字位置允许的变量:i0~i9(整型)、f0~f9(浮点)、b0~b9(布尔)
return /^[ifb][0-9]$/.test(value);
}
function makeCombo(parameter, options, onChange) {
const combo = el("span", "cm-combo");
const input = el("input", "cm-input cm-combo-input");
input.type = "text";
input.placeholder = parameter.name;
input.autocomplete = "off";
const list = el("ul", "cm-combo-list");
list.hidden = true;
function populate() {
list.textContent = "";
if (options.length === 0) {
list.hidden = true;
return;
}
const query = input.value.trim();
const ql = query.toLowerCase();
const maxDistance = Math.max(1, Math.floor(query.length / 2));
const shown = (query
? options.filter((option) => option.toLowerCase().indexOf(ql) >= 0 ||
editDistance(option, query, false) <= maxDistance)
: options).slice(0, 10);
if (shown.length === 0) {
list.hidden = true;
return;
}
for (const option of shown) {
const item = el("li", "cm-combo-item");
item.textContent = option;
item.title = option;
item.addEventListener("mousedown", (event) => {
event.preventDefault();
input.value = option;
list.hidden = true;
onChange();
});
list.appendChild(item);
}
list.hidden = false;
}
input.addEventListener("input", () => {
populate();
onChange();
});
input.addEventListener("focus", populate);
input.addEventListener("blur", () => {
window.setTimeout(() => { list.hidden = true; }, 120);
});
combo.appendChild(input);
combo.appendChild(list);
return combo;
}
function makeControl(parameter, onChange) {
switch (parameter.type) {
case ParameterType.Boolean: {
return makeCombo(parameter, BOOLEAN_OPTIONS, onChange);
}
case ParameterType.Enum: {
// 可输入的下拉框:输入时用编辑距离筛选候选项
return makeCombo(parameter, resolveEnumOptions(parameter), onChange);
}
case ParameterType.Number: {
// 普通输入框,合法性由 readParameter 校验(非数字标红)
const number = el("input", "cm-input");
number.type = "text";
number.inputMode = "decimal";
number.placeholder = parameter.name;
number.addEventListener("input", onChange);
return number;
}
default: {
const text = el("input", "cm-input");
text.type = "text";
text.placeholder = parameter.name;
text.addEventListener("input", onChange);
return text;
}
}
}
function readParameter(parameter, control) {
switch (parameter.type) {
case ParameterType.Boolean: {
const input = control.querySelector("input");
const value = input.value.trim();
const valid = value === "true" || value === "false" || /^b[0-9]$/.test(value);
return { text: value || "<布尔>", valid, invalid: value.length > 0 && !valid, control };
}
case ParameterType.Enum: {
const input = control.querySelector("input");
const value = input.value.trim();
const options = resolveEnumOptions(parameter);
const valid = options.length > 0
? options.some((option) => option.toLowerCase() === value.toLowerCase())
: value.length > 0;
return { text: value || "<选项>", valid, invalid: value.length > 0 && !valid, control };
}
case ParameterType.Number: {
const input = control;
const value = input.value.trim();
if (value.length === 0) {
return { text: "<数字>", valid: false, invalid: false, control };
}
if (isNumberVariable(value)) {
return { text: value, valid: true, invalid: false, control };
}
const number = Number(value);
let valid = isFinite(number);
const range = parameter.numericRange;
if (valid && range) {
if (range.isInteger && !Number.isInteger(number))
valid = false;
if (number < range.min || number > range.max)
valid = false;
}
return { text: value, valid, invalid: !valid, control };
}
default: {
const input = control;
const value = input.value.trim();
return { text: value ? `str:${value}` : "<文本>", valid: value.length > 0, invalid: false, control };
}
}
}
function stripStringPrefix(text) {
return text.startsWith("str:") ? text.slice(4) : text;
}
function formatMethod(method, states) {
const values = states.map((state) => state.text);
const args = values.join(", ");
switch (method.type) {
case MethodType.Comment:
return `()=>${method.name}(${args})`;
case MethodType.Ink:
return `DOTHIS{${method.name}(${args})}`;
case MethodType.Variable:
return `${method.name} = ${values.length ? stripStringPrefix(values[0]) : ""}`;
case MethodType.Mod:
return `"mods": [..., "${method.name}"]`;
case MethodType.CustomClass:
return `"customClass": "${method.name}"`;
case MethodType.Condition:
return values.length ? `${method.name}(${args})` : method.name;
case MethodType.Surgery:
return method.name;
default:
return values.length ? `${method.name}(${args})` : method.name;
}
}
function copyText(text) {
return __awaiter(this, void 0, void 0, function* () {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
yield navigator.clipboard.writeText(text);
return true;
}
}
catch (error) {
// fallthrough to execCommand
}
try {
const area = document.createElement("textarea");
area.value = text;
area.style.position = "fixed";
area.style.opacity = "0";
document.body.appendChild(area);
area.select();
const ok = document.execCommand("copy");
document.body.removeChild(area);
return ok;
}
catch (error) {
return false;
}
});
}
function renderContainer(container) {
if (container.dataset.cmReady === "1")
return;
container.dataset.cmReady = "1";
// 优先使用 custommethod.md 里写好的骨架;缺失时再动态生成
let query = container.querySelector(".cm-query");
let suggest = container.querySelector(".cm-suggest");
let selected = container.querySelector(".cm-selected");
let nameEl = container.querySelector(".cm-name");
let descEl = container.querySelector(".cm-desc");
let row = container.querySelector(".cm-row");
let preview = container.querySelector(".cm-preview");
if (!query || !suggest || !selected || !nameEl || !descEl || !row || !preview) {
container.textContent = "";
const searchBar = el("div", "cm-search");
query = el("input", "cm-query");
query.type = "text";
query.placeholder = "输入自定义方法名,如 ChangeCharacter";
query.autocomplete = "off";
suggest = el("ul", "cm-suggest");
searchBar.appendChild(query);
searchBar.appendChild(suggest);
selected = el("div", "cm-selected");
selected.hidden = true;
const head = el("div", "cm-head");
nameEl = el("span", "cm-name");
descEl = el("span", "cm-desc");
head.appendChild(nameEl);
head.appendChild(descEl);
row = el("div", "cm-row");
preview = el("code", "cm-preview");
selected.appendChild(head);
selected.appendChild(row);
selected.appendChild(preview);
container.appendChild(searchBar);
container.appendChild(selected);
}
const queryEl = query;
const suggestEl = suggest;
const selectedEl = selected;
const nameNode = nameEl;
const descNode = descEl;
const rowEl = row;
const previewEl = preview;
const copyBtn = el("button", "cm-copy");
copyBtn.type = "button";
copyBtn.textContent = "复制";
copyBtn.hidden = true;
copyBtn.title = "复制生成的调用代码";
selectedEl.appendChild(copyBtn);
copyBtn.addEventListener("click", () => {
copyText(previewEl.textContent).then((ok) => {
copyBtn.textContent = ok ? "已复制" : "复制失败";
window.setTimeout(() => { copyBtn.textContent = "复制"; }, 1200);
});
});
function clearSuggest() {
suggestEl.textContent = "";
}
function selectMethod(method) {
clearSuggest();
queryEl.value = method.name;
selectedEl.hidden = false;
nameNode.textContent = method.name;
descNode.textContent = method.description;
rowEl.textContent = "";
previewEl.textContent = "";
copyBtn.hidden = true;
const parameters = parametersOf(method);
const controls = [];
function updatePreview() {
const states = controls.map((item) => readParameter(item.parameter, item.control));
states.forEach((state) => state.control.classList.toggle("cm-invalid", state.invalid));
previewEl.textContent = formatMethod(method, states);
copyBtn.hidden = !states.every((state) => state.valid);
}
if (parameters.length === 0) {
const empty = el("span", "cm-empty");
empty.textContent = "该方法没有参数";
rowEl.appendChild(empty);
updatePreview();
return;
}
for (const parameter of parameters) {
const field = el("label", "cm-param");
field.title = parameter.description;
const label = el("span", "cm-param-name");
label.textContent = parameter.name;
const control = makeControl(parameter, updatePreview);
field.appendChild(label);
field.appendChild(control);
rowEl.appendChild(field);
controls.push({ parameter, control });
}
updatePreview();
}
function updateSuggestions() {
clearSuggest();
const matches = searchMethods(queryEl.value);
for (const method of matches) {
const item = el("li", "cm-suggest-item");
const nameSpan = el("span", "cm-suggest-name");
nameSpan.textContent = method.name;
const descSpan = el("span", "cm-suggest-desc");
descSpan.textContent = method.description;
item.appendChild(nameSpan);
item.appendChild(descSpan);
item.addEventListener("mousedown", (event) => {
event.preventDefault();
selectMethod(method);
});
suggestEl.appendChild(item);
}
}
queryEl.addEventListener("input", () => {
updateSuggestions();
selectedEl.hidden = true;
});
queryEl.addEventListener("focus", updateSuggestions);
queryEl.addEventListener("blur", () => {
window.setTimeout(clearSuggest, 120);
});
if (queryEl.value) {
updateSuggestions();
}
}
function initAll() {
const containers = document.querySelectorAll(".custommethods-search");
containers.forEach((container) => renderContainer(container));
}
function register() {
const docsify = window.$docsify;
if (docsify) {
docsify.plugins = [].concat(docsify.plugins, (hook) => {
hook.doneEach(() => initAll());
});
}
else if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => initAll());
}
else {
initAll();
}
}
window.CustomMethodSearch = {
search: searchMethods,
editDistance,
renderAll: initAll,
getMethods: () => methods,
setMethods: (list) => {
methods = list;
initAll();
},
Parameter,
ParameterType,
Method,
MethodType,
Mod,
Surgery,
CommentMethod,
InkMethod,
CustomMethods,
VariableMethod,
ConditionMethod,
CustomClassMethod,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", register);
}
else {
register();
}
})();
+656 -27
View File
@@ -1,32 +1,661 @@
class CustomMethods{ (function () {
type: "mod"|"surgery"|"comment"|"custom_method"; "use strict";
}
function editDistance(a: String, b: String): number { enum ParameterType {
if (a.length === 0) return b.length; String = "string",
if (b.length === 0) return a.length; Number = "number",
const matrix: number[][] = []; Boolean = "boolean",
for (let i = 0; i <= b.length; i++) { Enum = "enum",
matrix[i] = [i]; }
} enum MethodType {
for (let j = 0; j <= a.length; j++) { Mod = "mod",
matrix[0][j] = j; Surgery = "surgery",
} Comment = "comment",
for (let i = 1; i <= b.length; i++) { CustomMethod = "custom_method",
for (let j = 1; j <= a.length; j++) { Ink = "ink",
if (b.charAt(i - 1) === a.charAt(j - 1)) { Variable = "variable",
matrix[i][j] = matrix[i - 1][j - 1]; Condition = "condition",
} else { CustomClass = "custom_class",
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1,
Math.min(matrix[i][j - 1] + 1, matrix[i - 1][j] + 1),
);
}
} }
}
return matrix[b.length][a.length];
}
const enumTypeMap: Record<string, string[]> = {
Characters: ["Logan", "Green", "Blue", "Yellow", "Purple", "Orange"],
Size: ["Small", "Medium", "Large"],
Shape: ["Circle", "Square", "Triangle"],
};
class NumericRange {
min: number;
max: number;
isInteger: boolean;
public constructor(min: number, max: number, isInteger: boolean = false) {
this.min = min;
this.max = max;
this.isInteger = isInteger;
}
}
class Parameter {
name: string;
type: ParameterType;
enumType?: string;
numericRange?: NumericRange;
description: string;
public constructor(name: string, type: ParameterType, description: string, enumType?: string, numericRange?: NumericRange) {
this.name = name;
this.type = type;
this.description = description;
this.enumType = enumType;
this.numericRange = numericRange;
}
}
class Method {
name: string;
type: MethodType;
description: string;
public constructor(name: string, type: MethodType, description: string) {
this.name = name;
this.type = type;
this.description = description;
}
}
class Mod extends Method {
public constructor(name: string, description: string) {
super(name, MethodType.Mod, description);
}
}
class Surgery extends Method {
public constructor(name: string, description: string) {
super(name, MethodType.Surgery, description);
}
}
class CommentMethod extends Method {
parameters: Parameter[];
public constructor(name: string, description: string, parameters: Parameter[]) {
super(name, MethodType.Comment, description);
this.parameters = parameters;
}
}
class InkMethod extends Method {
parameters: Parameter[];
public constructor(name: string, description: string, parameters: Parameter[]) {
super(name, MethodType.Ink, description);
this.parameters = parameters;
}
}
class CustomMethods extends Method {
parameters: Parameter[];
public constructor(name: string, description: string, parameters: Parameter[]) {
super(name, MethodType.CustomMethod, description);
this.parameters = parameters;
}
}
class VariableMethod extends Method {
parameters: Parameter[];
public constructor(name: string, description: string, parameters: Parameter[]) {
super(name, MethodType.Variable, description);
this.parameters = parameters;
}
}
class ConditionMethod extends Method {
parameters: Parameter[];
public constructor(name: string, description: string, parameters: Parameter[]) {
super(name, MethodType.Condition, description);
this.parameters = parameters;
}
}
class CustomClassMethod extends Method {
public constructor(name: string, description: string) {
super(name, MethodType.CustomClass, description);
}
}
function editDistance(a: string, b: string, caseSensitive: boolean = true): number {
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
const matrix: number[][] = [];
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
const ac = a.charAt(j - 1);
const bc = b.charAt(i - 1);
if (caseSensitive ? bc === ac : bc.toLowerCase() === ac.toLowerCase()) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1,
Math.min(matrix[i][j - 1] + 1, matrix[i - 1][j] + 1),
);
}
}
}
return matrix[b.length][a.length];
}
export { editDistance }; function parametersOf(method: Method): Parameter[] {
return (method as CommentMethod | InkMethod | CustomMethods | VariableMethod | ConditionMethod).parameters ?? [];
}
// ---------------------------------------------------------------------
// 数据:可直接在此编辑,或通过 window.CustomMethodSearch.setMethods(...) 覆盖
// ---------------------------------------------------------------------
let methods: Method[] = [
new Mod("wobblyLines", "让单发拍子的轨道抖动"),
new Mod("invisibleHeart", "隐藏轨道上的心脏"),
new CustomMethods("ChangeCharacter", "修改某条轨道对应的人物", [
new Parameter("character", ParameterType.Enum, "Characters"),
new Parameter("row", ParameterType.Number, "轨道编号", undefined, new NumericRange(0, 15, true)),
]),
new CustomMethods("DisableRowReflections", "开关某个房间的地面反射", [
new Parameter("room", ParameterType.Number, "房间编号", undefined, new NumericRange(0, 3, true)),
]),
new CustomMethods("SetRowLength", "设置轨道长度", [
new Parameter("row", ParameterType.Number, "轨道编号", undefined, new NumericRange(0, 15, true)),
new Parameter("length", ParameterType.Number, "轨道长度", undefined, new NumericRange(1, 7, true)),
]),
new CommentMethod("trueCameraMove", "移动真实摄像头", [
new Parameter("x", ParameterType.Number, "目标 X"),
new Parameter("y", ParameterType.Number, "目标 Y"),
new Parameter("angle", ParameterType.Number, "角度"),
new Parameter("duration", ParameterType.Number, "时长"),
new Parameter("ease", ParameterType.Enum, "缓动类型", "EaseType"),
]),
new CustomMethods("DemoAllTypes", "演示四种参数控件", [
new Parameter("text", ParameterType.String, "文本"),
new Parameter("count", ParameterType.Number, "数字"),
new Parameter("enabled", ParameterType.Boolean, "开关"),
new Parameter("character", ParameterType.Enum, "角色", "Characters"),
new Parameter("color", ParameterType.Enum, "颜色"),
]),
];
// 合并外置数据(script/custommethod-data.js),同名以内置为准
function hydrateMethod(raw: any): Method {
const parameters: Parameter[] = Array.isArray(raw.parameters)
? raw.parameters.map((item: any) => new Parameter(
item.name,
item.type ?? ParameterType.String,
item.description ?? "",
item.enumType,
item.numericRange
? new NumericRange(item.numericRange.min, item.numericRange.max, item.numericRange.isInteger)
: undefined,
))
: [];
switch (raw.type) {
case MethodType.Mod: return new Mod(raw.name, raw.description ?? "");
case MethodType.Surgery: return new Surgery(raw.name, raw.description ?? "");
case MethodType.Comment: return new CommentMethod(raw.name, raw.description ?? "", parameters);
case MethodType.Ink: return new InkMethod(raw.name, raw.description ?? "", parameters);
case MethodType.Variable: return new VariableMethod(raw.name, raw.description ?? "", parameters);
case MethodType.Condition: return new ConditionMethod(raw.name, raw.description ?? "", parameters);
case MethodType.CustomClass: return new CustomClassMethod(raw.name, raw.description ?? "");
default: return new CustomMethods(raw.name, raw.description ?? "", parameters);
}
}
function loadExternalData(): void {
const data = (window as any).CustomMethodSearchData;
if (!data) return;
if (data.enumTypes) {
for (const key of Object.keys(data.enumTypes)) {
enumTypeMap[key] = data.enumTypes[key];
}
}
if (Array.isArray(data.methods)) {
const index = new Map<string, number>();
methods.forEach((method, i) => index.set(method.name.toLowerCase(), i));
for (const raw of data.methods) {
if (!raw || !raw.name) continue;
const name = String(raw.name).toLowerCase();
const hydrated = hydrateMethod(raw);
if (index.has(name)) {
methods[index.get(name)!] = hydrated;
} else {
index.set(name, methods.length);
methods.push(hydrated);
}
}
}
}
loadExternalData();
// ---------------------------------------------------------------------
// 匹配
// ---------------------------------------------------------------------
function searchMethods(query: string): Method[] {
const q = query.trim();
if (!q) return [];
const ql = q.toLowerCase();
const maxDistance = Math.max(2, Math.floor(q.length / 2));
return methods
.map((method) => {
const name = method.name;
const lower = name.toLowerCase();
let score: number;
if (name === q) {
score = 0;
} else if (lower.startsWith(ql)) {
score = 1;
} else if (lower.indexOf(ql) >= 0) {
score = 2;
} else {
score = 3 + editDistance(name, q, false);
}
return { method, score };
})
.filter((item) => item.score <= 2 || item.score <= 3 + maxDistance)
.sort((a, b) => a.score - b.score || a.method.name.localeCompare(b.method.name))
.slice(0, 8)
.map((item) => item.method);
}
// ---------------------------------------------------------------------
// DOM 构建
// ---------------------------------------------------------------------
function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
if (className) node.className = className;
return node;
}
function resolveEnumOptions(parameter: Parameter): string[] {
if (parameter.enumType && enumTypeMap[parameter.enumType]) {
return enumTypeMap[parameter.enumType];
}
// 兼容把枚举类型名写在 description 的情况
if (enumTypeMap[parameter.description]) {
return enumTypeMap[parameter.description];
}
if (enumTypeMap[parameter.name]) {
return enumTypeMap[parameter.name];
}
return [];
}
// 布尔值可用 true/false,也可用布尔变量 b0~b9
const BOOLEAN_OPTIONS: string[] = ["true", "false"];
for (let i = 0; i < 10; i++) {
BOOLEAN_OPTIONS.push("b" + i);
}
function isNumberVariable(value: string): boolean {
// 数字位置允许的变量:i0~i9(整型)、f0~f9(浮点)、b0~b9(布尔)
return /^[ifb][0-9]$/.test(value);
}
function makeCombo(parameter: Parameter, options: string[], onChange: () => void): HTMLElement {
const combo = el("span", "cm-combo");
const input = el("input", "cm-input cm-combo-input") as HTMLInputElement;
input.type = "text";
input.placeholder = parameter.name;
input.autocomplete = "off";
const list = el("ul", "cm-combo-list");
list.hidden = true;
function populate(): void {
list.textContent = "";
if (options.length === 0) {
list.hidden = true;
return;
}
const query = input.value.trim();
const ql = query.toLowerCase();
const maxDistance = Math.max(1, Math.floor(query.length / 2));
const shown = (query
? options.filter((option) =>
option.toLowerCase().indexOf(ql) >= 0 ||
editDistance(option, query, false) <= maxDistance)
: options
).slice(0, 10);
if (shown.length === 0) {
list.hidden = true;
return;
}
for (const option of shown) {
const item = el("li", "cm-combo-item");
item.textContent = option;
item.title = option;
item.addEventListener("mousedown", (event) => {
event.preventDefault();
input.value = option;
list.hidden = true;
onChange();
});
list.appendChild(item);
}
list.hidden = false;
}
input.addEventListener("input", () => {
populate();
onChange();
});
input.addEventListener("focus", populate);
input.addEventListener("blur", () => {
window.setTimeout(() => { list.hidden = true; }, 120);
});
combo.appendChild(input);
combo.appendChild(list);
return combo;
}
function makeControl(parameter: Parameter, onChange: () => void): HTMLElement {
switch (parameter.type) {
case ParameterType.Boolean: {
return makeCombo(parameter, BOOLEAN_OPTIONS, onChange);
}
case ParameterType.Enum: {
// 可输入的下拉框:输入时用编辑距离筛选候选项
return makeCombo(parameter, resolveEnumOptions(parameter), onChange);
}
case ParameterType.Number: {
// 普通输入框,合法性由 readParameter 校验(非数字标红)
const number = el("input", "cm-input") as HTMLInputElement;
number.type = "text";
number.inputMode = "decimal";
number.placeholder = parameter.name;
number.addEventListener("input", onChange);
return number;
}
default: {
const text = el("input", "cm-input") as HTMLInputElement;
text.type = "text";
text.placeholder = parameter.name;
text.addEventListener("input", onChange);
return text;
}
}
}
interface ParameterState {
text: string;
valid: boolean;
invalid: boolean;
control: HTMLElement;
}
function readParameter(parameter: Parameter, control: HTMLElement): ParameterState {
switch (parameter.type) {
case ParameterType.Boolean: {
const input = control.querySelector("input") as HTMLInputElement;
const value = input.value.trim();
const valid = value === "true" || value === "false" || /^b[0-9]$/.test(value);
return { text: value || "<布尔>", valid, invalid: value.length > 0 && !valid, control };
}
case ParameterType.Enum: {
const input = control.querySelector("input") as HTMLInputElement;
const value = input.value.trim();
const options = resolveEnumOptions(parameter);
const valid = options.length > 0
? options.some((option) => option.toLowerCase() === value.toLowerCase())
: value.length > 0;
return { text: value || "<选项>", valid, invalid: value.length > 0 && !valid, control };
}
case ParameterType.Number: {
const input = control as HTMLInputElement;
const value = input.value.trim();
if (value.length === 0) {
return { text: "<数字>", valid: false, invalid: false, control };
}
if (isNumberVariable(value)) {
return { text: value, valid: true, invalid: false, control };
}
const number = Number(value);
let valid = isFinite(number);
const range = parameter.numericRange;
if (valid && range) {
if (range.isInteger && !Number.isInteger(number)) valid = false;
if (number < range.min || number > range.max) valid = false;
}
return { text: value, valid, invalid: !valid, control };
}
default: {
const input = control as HTMLInputElement;
const value = input.value.trim();
return { text: value ? `str:${value}` : "<文本>", valid: value.length > 0, invalid: false, control };
}
}
}
function stripStringPrefix(text: string): string {
return text.startsWith("str:") ? text.slice(4) : text;
}
function formatMethod(method: Method, states: ParameterState[]): string {
const values = states.map((state) => state.text);
const args = values.join(", ");
switch (method.type) {
case MethodType.Comment:
return `()=>${method.name}(${args})`;
case MethodType.Ink:
return `DOTHIS{${method.name}(${args})}`;
case MethodType.Variable:
return `${method.name} = ${values.length ? stripStringPrefix(values[0]) : ""}`;
case MethodType.Mod:
return `"mods": [..., "${method.name}"]`;
case MethodType.CustomClass:
return `"customClass": "${method.name}"`;
case MethodType.Condition:
return values.length ? `${method.name}(${args})` : method.name;
case MethodType.Surgery:
return method.name;
default:
return values.length ? `${method.name}(${args})` : method.name;
}
}
async function copyText(text: string): Promise<boolean> {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch (error) {
// fallthrough to execCommand
}
try {
const area = document.createElement("textarea");
area.value = text;
area.style.position = "fixed";
area.style.opacity = "0";
document.body.appendChild(area);
area.select();
const ok = document.execCommand("copy");
document.body.removeChild(area);
return ok;
} catch (error) {
return false;
}
}
function renderContainer(container: HTMLElement): void {
if (container.dataset.cmReady === "1") return;
container.dataset.cmReady = "1";
// 优先使用 custommethod.md 里写好的骨架;缺失时再动态生成
let query = container.querySelector<HTMLInputElement>(".cm-query");
let suggest = container.querySelector<HTMLUListElement>(".cm-suggest");
let selected = container.querySelector<HTMLElement>(".cm-selected");
let nameEl = container.querySelector<HTMLElement>(".cm-name");
let descEl = container.querySelector<HTMLElement>(".cm-desc");
let row = container.querySelector<HTMLElement>(".cm-row");
let preview = container.querySelector<HTMLElement>(".cm-preview");
if (!query || !suggest || !selected || !nameEl || !descEl || !row || !preview) {
container.textContent = "";
const searchBar = el("div", "cm-search");
query = el("input", "cm-query") as HTMLInputElement;
query.type = "text";
query.placeholder = "输入自定义方法名,如 ChangeCharacter";
query.autocomplete = "off";
suggest = el("ul", "cm-suggest");
searchBar.appendChild(query);
searchBar.appendChild(suggest);
selected = el("div", "cm-selected");
selected.hidden = true;
const head = el("div", "cm-head");
nameEl = el("span", "cm-name");
descEl = el("span", "cm-desc");
head.appendChild(nameEl);
head.appendChild(descEl);
row = el("div", "cm-row");
preview = el("code", "cm-preview");
selected.appendChild(head);
selected.appendChild(row);
selected.appendChild(preview);
container.appendChild(searchBar);
container.appendChild(selected);
}
const queryEl = query;
const suggestEl = suggest;
const selectedEl = selected;
const nameNode = nameEl;
const descNode = descEl;
const rowEl = row;
const previewEl = preview;
const copyBtn = el("button", "cm-copy") as HTMLButtonElement;
copyBtn.type = "button";
copyBtn.textContent = "复制";
copyBtn.hidden = true;
copyBtn.title = "复制生成的调用代码";
selectedEl.appendChild(copyBtn);
copyBtn.addEventListener("click", () => {
copyText(previewEl.textContent).then((ok) => {
copyBtn.textContent = ok ? "已复制" : "复制失败";
window.setTimeout(() => { copyBtn.textContent = "复制"; }, 1200);
});
});
function clearSuggest(): void {
suggestEl.textContent = "";
}
function selectMethod(method: Method): void {
clearSuggest();
queryEl.value = method.name;
selectedEl.hidden = false;
nameNode.textContent = method.name;
descNode.textContent = method.description;
rowEl.textContent = "";
previewEl.textContent = "";
copyBtn.hidden = true;
const parameters = parametersOf(method);
const controls: { parameter: Parameter; control: HTMLElement }[] = [];
function updatePreview(): void {
const states = controls.map((item) => readParameter(item.parameter, item.control));
states.forEach((state) => state.control.classList.toggle("cm-invalid", state.invalid));
previewEl.textContent = formatMethod(method, states);
copyBtn.hidden = !states.every((state) => state.valid);
}
if (parameters.length === 0) {
const empty = el("span", "cm-empty");
empty.textContent = "该方法没有参数";
rowEl.appendChild(empty);
updatePreview();
return;
}
for (const parameter of parameters) {
const field = el("label", "cm-param");
field.title = parameter.description;
const label = el("span", "cm-param-name");
label.textContent = parameter.name;
const control = makeControl(parameter, updatePreview);
field.appendChild(label);
field.appendChild(control);
rowEl.appendChild(field);
controls.push({ parameter, control });
}
updatePreview();
}
function updateSuggestions(): void {
clearSuggest();
const matches = searchMethods(queryEl.value);
for (const method of matches) {
const item = el("li", "cm-suggest-item");
const nameSpan = el("span", "cm-suggest-name");
nameSpan.textContent = method.name;
const descSpan = el("span", "cm-suggest-desc");
descSpan.textContent = method.description;
item.appendChild(nameSpan);
item.appendChild(descSpan);
item.addEventListener("mousedown", (event) => {
event.preventDefault();
selectMethod(method);
});
suggestEl.appendChild(item);
}
}
queryEl.addEventListener("input", () => {
updateSuggestions();
selectedEl.hidden = true;
});
queryEl.addEventListener("focus", updateSuggestions);
queryEl.addEventListener("blur", () => {
window.setTimeout(clearSuggest, 120);
});
if (queryEl.value) {
updateSuggestions();
}
}
function initAll(): void {
const containers = document.querySelectorAll<HTMLElement>(".custommethods-search");
containers.forEach((container) => renderContainer(container));
}
function register(): void {
const docsify = (window as any).$docsify;
if (docsify) {
docsify.plugins = ([] as any[]).concat(docsify.plugins, (hook: any) => {
hook.doneEach(() => initAll());
});
} else if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => initAll());
} else {
initAll();
}
}
(window as any).CustomMethodSearch = {
search: searchMethods,
editDistance,
renderAll: initAll,
getMethods: () => methods,
setMethods: (list: Method[]) => {
methods = list;
initAll();
},
Parameter,
ParameterType,
Method,
MethodType,
Mod,
Surgery,
CommentMethod,
InkMethod,
CustomMethods,
VariableMethod,
ConditionMethod,
CustomClassMethod,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", register);
} else {
register();
}
})();
+4 -2
View File
@@ -3,7 +3,8 @@
"target": "es2015", "target": "es2015",
"lib": [ "lib": [
"es2017", "es2017",
"dom" "dom",
"dom.iterable"
], ],
"module": "esnext", "module": "esnext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
@@ -14,6 +15,7 @@
"resolveJsonModule": true "resolveJsonModule": true
}, },
"files": [ "files": [
"rdview.ts" "rdview.ts",
"custommethod-search.ts"
], ],
} }
+219
View File
@@ -266,4 +266,223 @@ div.decoration-depth {
} }
} }
} }
}
div.custommethods-search {
position: relative;
margin: 16px 0;
& .cm-search {
position: relative;
}
& .cm-query {
box-sizing: border-box;
width: 100%;
padding: 8px 12px;
font-family: d9c, d9, monospace;
font-size: 15px;
color: inherit;
background-color: var(--background-color);
border: 1px solid var(--background-border-color);
border-radius: 6px;
&:focus {
outline: none;
border-color: #3b8e59;
}
}
& .cm-suggest {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 4px);
z-index: 30;
margin: 0;
padding: 4px;
list-style: none;
max-height: 260px;
overflow-y: auto;
background-color: var(--background-color);
border: 1px solid var(--background-border-color);
border-radius: 6px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.18);
&:empty {
display: none;
}
}
& .cm-suggest-item {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
&:hover {
background-color: rgba(127, 127, 127, 0.16);
}
}
& .cm-suggest-name {
font-family: d9c, d9, monospace;
}
& .cm-suggest-desc {
font-size: 12px;
opacity: 0.6;
text-align: right;
}
& .cm-selected {
position: relative;
margin-top: 10px;
padding: 12px;
border: 1px solid var(--background-border-color);
border-radius: 6px;
background-color: rgba(127, 127, 127, 0.06);
}
& .cm-head {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 10px;
padding-right: 56px;
}
& .cm-name {
font-family: d9c, d9, monospace;
font-weight: bold;
}
& .cm-desc {
font-size: 12px;
opacity: 0.6;
}
& .cm-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
& .cm-param {
display: inline-flex;
align-items: center;
gap: 6px;
}
& .cm-param-name {
font-family: d9c, d9, monospace;
font-size: 13px;
opacity: 0.8;
}
& .cm-input {
padding: 4px 6px;
font-size: 14px;
color: inherit;
background-color: var(--background-color);
border: 1px solid var(--background-border-color);
border-radius: 4px;
&:focus {
outline: none;
border-color: #3b8e59;
}
}
& select.cm-input {
min-width: 90px;
}
& input.cm-input[type="number"] {
width: 96px;
}
& input.cm-input[type="text"] {
width: 150px;
}
& .cm-preview {
display: block;
margin-top: 12px;
padding: 8px 10px;
font-family: d9c, d9, monospace;
white-space: pre-wrap;
word-break: break-all;
background-color: rgba(127, 127, 127, 0.12);
border-radius: 6px;
}
& .cm-empty {
font-size: 13px;
opacity: 0.6;
}
& .cm-input.cm-invalid,
& .cm-combo.cm-invalid .cm-input {
border-color: #e5484d;
box-shadow: 0 0 0 1px #e5484d;
}
& .cm-combo {
position: relative;
display: inline-block;
}
& .cm-combo-input {
width: 150px;
}
& .cm-combo-list {
position: absolute;
top: calc(100% + 2px);
left: 0;
min-width: 100%;
z-index: 40;
margin: 0;
padding: 4px;
list-style: none;
max-height: 200px;
overflow-y: auto;
background-color: var(--background-color);
border: 1px solid var(--background-border-color);
border-radius: 4px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.18);
}
& .cm-combo-item {
padding: 4px 8px;
border-radius: 3px;
cursor: pointer;
&:hover {
background-color: rgba(127, 127, 127, 0.16);
}
}
& .cm-copy {
position: absolute;
top: 8px;
right: 8px;
padding: 2px 10px;
font-size: 12px;
color: inherit;
background-color: var(--background-color);
border: 1px solid var(--background-border-color);
border-radius: 10px;
cursor: pointer;
&:hover {
color: #3b8e59;
border-color: #3b8e59;
}
}
} }