master
humingxing 6 hours ago
parent 5ddd8cfb5a
commit d92b16cd6d
  1. 14
      assets/mx/module/gm/GmEasy.ts
  2. 36
      assets/mx/module/gm/GmRegistry.ts
  3. 4
      assets/mx/module/gm/GmTypes.ts
  4. 90
      assets/mx/module/gm/GmView.ts
  5. 30
      assets/script/gm/ProjectGmBootstrap.ts
  6. 516
      assets/script/gm/ProjectGmPanels.ts
  7. 45
      docs/方案-GM可视化模块.md

@ -20,6 +20,7 @@ export class GmEasy {
private _group = '常用';
private _order = 0;
private _seq = 0;
private _pinNext = false;
/**
* 切换 Tab 分组(后续命令都进这个分组)。
@ -33,6 +34,14 @@ export class GmEasy {
return this;
}
/**
* 下一条命令可钉到「常用」页。
*/
pinable(): this {
this._pinNext = true;
return this;
}
/**
* 快捷按钮:点一下执行。
*/
@ -179,8 +188,13 @@ export class GmEasy {
private _push(cmd: IGmCommand): void {
if (!cmd.title) {
console.warn('[GmEasy] 跳过无标题命令');
this._pinNext = false;
return;
}
if (this._pinNext) {
cmd.pinable = true;
this._pinNext = false;
}
this._cmds.push(cmd);
}
}

@ -1,5 +1,9 @@
import { sys } from 'cc';
import type { GmPanelFactory, IGmCommand } from './GmTypes';
/** 钉选本地存储前缀 */
const PIN_KEY = 'gm.pin.';
/**
* GM 命令与内容面板注册表(普通对象,避免 Map)。
*/
@ -94,6 +98,38 @@ export class GmRegistry {
return this._order.length > 0;
}
/**
* 命令是否已钉到常用。
*/
static isPinned(id: string): boolean {
if (!id) return false;
return sys.localStorage.getItem(PIN_KEY + id) === '1';
}
/**
* 设置钉到常用。
*/
static setPinned(id: string, on: boolean): void {
if (!id) {
console.warn('[GmRegistry] setPinned 空 id');
return;
}
if (on) sys.localStorage.setItem(PIN_KEY + id, '1');
else sys.localStorage.removeItem(PIN_KEY + id);
}
/**
* 已钉到常用的命令(注册顺序)。
*/
static listPinned(): IGmCommand[] {
const out: IGmCommand[] = [];
for (let i = 0; i < this._order.length; i++) {
const c = this._cmds[this._order[i]];
if (c?.pinable && this.isPinned(c.id)) out.push(c);
}
return out;
}
/**
* 清空(测试 / 热重载)。
*/

@ -56,6 +56,8 @@ export interface IGmCommand {
summary?: () => string;
/** 为 true 时该 Tab 直接嵌入面板(不显示列表入口) */
embed?: boolean;
/** 为 true 时可钉到「常用」页展示 */
pinable?: boolean;
}
/**
@ -70,6 +72,8 @@ export interface IGmPanelContext {
goBack: () => void;
/** 刷新列表摘要 */
refreshList: () => void;
/** 是否 Tab 内嵌整页(无返回) */
embedded?: boolean;
}
/**

@ -33,6 +33,8 @@ const TAB_H = 60;
const ROW_H = 76;
const QUICK_H = 72;
const SECTION_H = 36;
/** 常用 Tab 名 */
const COMMON_TAB = '常用';
/**
* GM 主视图:设备全屏、高对比;数值支持设为覆盖 + 增减。
@ -291,7 +293,7 @@ export class GmView {
this._tabBar.removeAllChildren();
const n = this._groups.length || 1;
const barW = this._tabBar.getComponent(UITransform)?.width ?? this._listW;
const btnW = Math.max(110, (barW - (n - 1) * 10) / n);
const btnW = Math.max(88, Math.min(130, (barW - (n - 1) * 8) / n));
for (let i = 0; i < this._groups.length; i++) {
const gname = this._groups[i];
const idx = i;
@ -305,7 +307,7 @@ export class GmView {
const lb = btn.getChildByName('lb')?.getComponent(Label);
if (lb) {
lb.isBold = true;
lb.fontSize = 26;
lb.fontSize = n > 5 ? 22 : 26;
}
}
this._tabBar.getComponent(Layout)?.updateLayout();
@ -350,26 +352,48 @@ export class GmView {
this._listHost.active = true;
this._contentHost.active = false;
const onCommon = group === COMMON_TAB;
if (quicks.length) {
this._addSection('快捷 · 点一下即执行');
this._addQuickGrid(quicks);
}
if (toggles.length) {
this._addSection('开关 · 点右侧切换');
for (let i = 0; i < toggles.length; i++) this._addToggle(toggles[i]);
for (let i = 0; i < toggles.length; i++) this._addToggle(toggles[i], !onCommon);
}
if (contents.length) {
this._addSection('数值 / 选择');
this._addSection(onCommon ? '数值 / 选择' : '数值 / 选择 · 右侧可钉常用');
for (let i = 0; i < contents.length; i++) {
const c = contents[i];
if (c.panelId === GmBuiltinPanelId.numberStepper) this._addInlineStepper(c);
else this._addContentEntry(c);
if (c.panelId === GmBuiltinPanelId.numberStepper) this._addInlineStepper(c, !onCommon);
else this._addContentEntry(c, !onCommon);
}
}
if (onCommon) this._appendPinnedExtras();
this._scrollContent.getComponent(Layout)?.updateLayout();
}
/** 常用页尾追加已钉选命令 */
private _appendPinnedExtras(): void {
const pinned = GmRegistry.listPinned();
const extras: IGmCommand[] = [];
for (let i = 0; i < pinned.length; i++) {
const c = pinned[i];
if (c.group === COMMON_TAB) continue;
extras.push(c);
}
if (extras.length <= 0) return;
this._addSection('钉到常用');
for (let i = 0; i < extras.length; i++) {
const c = extras[i];
if (c.kind === GmKind.Toggle) this._addToggle(c, false);
else if (c.panelId === GmBuiltinPanelId.numberStepper) this._addInlineStepper(c, false);
else if (c.kind === GmKind.Content) this._addContentEntry(c, false);
else console.warn('[GM] 钉选项 kind 不支持', c.id, c.kind);
}
}
/** Tab 内嵌整页面板 */
private _openEmbed(cmd: IGmCommand): void {
if (!cmd.panelId) {
@ -393,6 +417,7 @@ export class GmView {
host: this._host,
goBack: () => { /* 嵌入页无返回 */ },
refreshList: () => this.refresh(),
embedded: true,
};
this._embedPanel.open(this._contentHost, ctx);
}
@ -488,15 +513,17 @@ export class GmView {
}
}
private _addToggle(cmd: IGmCommand): void {
private _addToggle(cmd: IGmCommand, showPin: boolean): void {
const n = new Node(cmd.id);
n.layer = Layers.Enum.UI_2D;
n.addComponent(UITransform).setContentSize(this._listW, ROW_H);
this._fillRound(n, C.card, this._listW, ROW_H, 14, C.cardLine);
const pinW = showPin && cmd.pinable ? 72 : 0;
const titleW = this._listW - 180 - pinW;
const title = this._label(n, cmd.title, 28, C.text, HorizontalTextAlignment.LEFT);
title.getComponent(UITransform)!.setContentSize(this._listW - 180, ROW_H - 8);
title.setPosition(-(this._listW / 2) + (this._listW - 180) / 2 + 18, 0);
title.getComponent(UITransform)!.setContentSize(titleW, ROW_H - 8);
title.setPosition(-(this._listW / 2) + titleW / 2 + 18, 0);
const on = !!(cmd.get?.());
const sw = this._makeSwitch(n, on, () => {
@ -508,9 +535,22 @@ export class GmView {
this.refresh();
});
sw.setPosition(this._listW / 2 - 72, 0);
if (showPin && cmd.pinable) this._addPinChip(n, cmd, this._listW / 2 - 172, 0);
this._scrollContent.addChild(n);
}
/** 钉到常用芯片 */
private _addPinChip(parent: Node, cmd: IGmCommand, x: number, y: number): void {
const pinned = GmRegistry.isPinned(cmd.id);
const chip = this._pillBtn(parent, pinned ? '常用✓' : '常用', 68, 36, () => {
GmRegistry.setPinned(cmd.id, !GmRegistry.isPinned(cmd.id));
this.refresh();
}, pinned ? C.on : C.chip, pinned ? new Color(8, 40, 20, 255) : C.text);
chip.setPosition(x, y);
const lb = chip.getChildByName('lb')?.getComponent(Label);
if (lb) lb.fontSize = 18;
}
private _makeSwitch(parent: Node, on: boolean, onClick: () => void): Node {
const trackW = 100;
const trackH = 48;
@ -539,11 +579,11 @@ export class GmView {
/**
* 数值卡:EditBox +「设为」覆盖;下方 ± 立刻增减。
*/
private _addInlineStepper(cmd: IGmCommand): void {
private _addInlineStepper(cmd: IGmCommand, showPin: boolean): void {
const data = cmd.panelData as IGmNumberStepperData;
if (!data?.getValue || !data?.apply) {
console.warn('[GM] numberStepper 缺少 panelData', cmd.id);
this._addContentEntry(cmd);
this._addContentEntry(cmd, showPin);
return;
}
@ -556,6 +596,7 @@ export class GmView {
const title = this._label(n, cmd.title, 26, C.textDim, HorizontalTextAlignment.LEFT);
title.getComponent(UITransform)!.setContentSize(this._listW * 0.4, 30);
title.setPosition(-(this._listW / 2) + this._listW * 0.2 + 14, 48);
if (showPin && cmd.pinable) this._addPinChip(n, cmd, this._listW / 2 - 40, 48);
const cur = Math.floor(Number(data.getValue()) || 0);
const editNode = new Node('edit');
@ -587,7 +628,8 @@ export class GmView {
eb.inputMode = EditBox.InputMode.NUMERIC;
eb.inputFlag = EditBox.InputFlag.DEFAULT;
eb.returnType = EditBox.KeyboardReturnType.DONE;
editNode.setPosition(this._listW / 2 - editW / 2 - 130, 48);
const setShift = showPin && cmd.pinable ? 78 : 0;
editNode.setPosition(this._listW / 2 - editW / 2 - 130 - setShift, 48);
n.addChild(editNode);
const syncEdit = (): void => {
@ -606,7 +648,7 @@ export class GmView {
syncEdit();
this._host.toast(`${cmd.title} 已设为 ${Math.floor(Number(data.getValue()) || 0)}`);
}, C.setBtn, C.text);
setBtn.setPosition(this._listW / 2 - 52, 48);
setBtn.setPosition(this._listW / 2 - 52 - setShift, 48);
const steps = [-100, -10, -1, 1, 10, 100];
const gap = 8;
@ -633,23 +675,32 @@ export class GmView {
this._scrollContent.addChild(n);
}
private _addContentEntry(cmd: IGmCommand): void {
private _addContentEntry(cmd: IGmCommand, showPin: boolean): void {
const n = new Node(cmd.id);
n.layer = Layers.Enum.UI_2D;
n.addComponent(UITransform).setContentSize(this._listW, ROW_H);
this._fillRound(n, C.card, this._listW, ROW_H, 14, C.cardLine);
const pinW = showPin && cmd.pinable ? 80 : 0;
const title = this._label(n, cmd.title, 28, C.text, HorizontalTextAlignment.LEFT);
title.getComponent(UITransform)!.setContentSize(280, ROW_H - 8);
title.setPosition(-(this._listW / 2) + 150, 0);
const sumText = cmd.summary ? cmd.summary() : '选择';
const sumW = this._listW - 320 - pinW;
const sum = this._label(n, `${sumText} ›`, 24, C.accent, HorizontalTextAlignment.RIGHT);
sum.getComponent(UITransform)!.setContentSize(this._listW - 320, ROW_H - 8);
sum.setPosition(this._listW / 2 - (this._listW - 320) / 2 - 16, 0);
n.addComponent(Button);
n.on(Button.EventType.CLICK, () => this._openContent(cmd), this);
sum.getComponent(UITransform)!.setContentSize(sumW, ROW_H - 8);
sum.setPosition(this._listW / 2 - sumW / 2 - 16 - pinW, 0);
if (showPin && cmd.pinable) this._addPinChip(n, cmd, this._listW / 2 - 40, 0);
const hit = new Node('hit');
hit.layer = Layers.Enum.UI_2D;
hit.addComponent(UITransform).setContentSize(this._listW - pinW - 8, ROW_H);
hit.setPosition(-pinW / 2, 0);
hit.addComponent(Button);
hit.on(Button.EventType.CLICK, () => this._openContent(cmd), this);
n.addChild(hit);
this._scrollContent.addChild(n);
}
@ -676,6 +727,7 @@ export class GmView {
host: this._host,
goBack: () => this._showList(),
refreshList: () => this.refresh(),
embedded: false,
};
this._activePanel.open(this._contentHost, ctx);
}

@ -27,7 +27,7 @@ export class ProjectGmBootstrap {
/**
* 注册全部命令(开发者主要改这里)。
* btn=快捷 / sw=开关 / num=数值 / page=子面板 / danger=确认 / text=粘贴
* btn=快捷 / sw=开关 / pinable=可钉常用 / num=数值 / page=子面板 / embed=整页 / danger=确认
*/
private static _register(): void {
const A = ProjectGmActions;
@ -46,19 +46,23 @@ export class ProjectGmBootstrap {
.sw('显示广告', () => A.getShowAd(), (v) => A.setShowAd(v))
.sw('内购版本', () => A.getIap(), (v) => A.setIap(v))
.sw('快速胜利', () => A.getFastWin(), (v) => A.setFastWin(v))
.page('设置道具', P.itemPicker, () => '图标点选')
.page('设置段位和星级', P.rankPicker, () => A.rankSummary())
.num('设置金钱', () => A.getCurrency(ItemNumType.money), (v) => A.setCurrency(ItemNumType.money, v, 'set'))
.num('设置体力', () => A.getCurrency(ItemNumType.vigour), (v) => A.setCurrency(ItemNumType.vigour, v, 'set'))
.num('设置钻石', () => A.getCurrency(ItemNumType.diamond), (v) => A.setCurrency(ItemNumType.diamond, v, 'set'))
.num('通关怪物数量', () => A.getFastWinNum(), (v) => A.setFastWinNum(v))
.danger('GM重置进度', '将关闭所有面板并重置游戏进度,确定?', () => A.hardReset())
.tab('战斗')
.sw('己方营地不掉血', () => A.getLocalCampImmune(), (v) => A.setCampImmune(true, v))
.sw('敌方营地不掉血', () => A.getEnemyCampImmune(), (v) => A.setCampImmune(false, v))
.page('固定地图', P.levelPicker, () => A.fixedMapSummary())
.num('玩家局内血量', () => A.getWallHp() || 1000, (v) => A.setWallHp(v), 1)
.tab('道具')
.embed(P.itemPicker)
.tab('段位')
.embed(P.rankPicker)
.tab('通用')
.pinable().sw('己方营地不掉血', () => A.getLocalCampImmune(), (v) => A.setCampImmune(true, v))
.pinable().sw('敌方营地不掉血', () => A.getEnemyCampImmune(), (v) => A.setCampImmune(false, v))
.pinable().page('固定地图', P.levelPicker, () => A.fixedMapSummary())
.pinable().num('设置金钱', () => A.getCurrency(ItemNumType.money), (v) => A.setCurrency(ItemNumType.money, v, 'set'))
.pinable().num('设置体力', () => A.getCurrency(ItemNumType.vigour), (v) => A.setCurrency(ItemNumType.vigour, v, 'set'))
.pinable().num('设置钻石', () => A.getCurrency(ItemNumType.diamond), (v) => A.setCurrency(ItemNumType.diamond, v, 'set'))
.pinable().num('通关怪物数量', () => A.getFastWinNum(), (v) => A.setFastWinNum(v))
.pinable().num('玩家局内血量', () => A.getWallHp() || 1000, (v) => A.setWallHp(v), 1)
.danger('GM重置进度', '将关闭所有面板并重置游戏进度,确定?', () => A.hardReset())
.tab('词条')
.embed(P.buffPicker, { expectType: 0, mode: 'carryPassive' })

@ -116,17 +116,35 @@ function makeVScroll(parent: Node, w: number, h: number): { host: Node; content:
return { host, content };
}
const ITEM_C = {
text: new Color(255, 255, 255, 255),
dim: new Color(170, 185, 210, 255),
accent: new Color(255, 220, 70, 255),
card: new Color(28, 36, 56, 255),
cellBg: new Color(18, 24, 40, 255),
cellLine: new Color(90, 120, 170, 160),
selLine: new Color(255, 220, 70, 255),
selBg: new Color(48, 42, 18, 255),
btn: new Color(45, 105, 190, 255),
btnHot: new Color(18, 92, 78, 255),
};
/**
* 道具图标点选 + 数量步进。
*/
class GmItemPickerPanel implements IGmContentPanel {
private _root: Node | null = null;
private _ctx: IGmPanelContext | null = null;
private _selectedId = 0;
private _qty = 1;
private _lbSel: Label | null = null;
private _selectedName = '';
private _lbHead: Label | null = null;
private _lbQty: Label | null = null;
private _cells: { [id: number]: Node } = {};
private _cellSz = 76;
open(parent: Node, ctx: IGmPanelContext): void {
this.close();
this._ctx = ctx;
const pUt = parent.getComponent(UITransform);
const W = pUt?.width || 660;
const H = pUt?.height || 900;
@ -139,117 +157,202 @@ class GmItemPickerPanel implements IGmContentPanel {
rw.left = rw.right = rw.top = rw.bottom = 0;
parent.addChild(this._root);
const tip = new Node('tip');
tip.layer = Layers.Enum.UI_2D;
tip.addComponent(UITransform).setContentSize(W - 24, 40);
const tl = tip.addComponent(Label);
tl.string = '点图标立刻 +1 · 底部可一次 +10/+100';
tl.fontSize = 22;
tl.color = new Color(255, 196, 72, 255);
tl.horizontalAlign = HorizontalTextAlignment.CENTER;
tip.setPosition(0, H / 2 - 28);
this._root.addChild(tip);
const barH = 120;
const scrollH = H - 80 - barH;
const headH = 88;
const barH = 108;
const head = new Node('head');
head.layer = Layers.Enum.UI_2D;
head.addComponent(UITransform).setContentSize(W - 16, headH);
this._drawRound(head, ITEM_C.card, W - 16, headH, 14, ITEM_C.selLine);
head.setPosition(0, H / 2 - headH / 2 - 4);
this._root.addChild(head);
this._lbHead = this._makeLb(head, '点击道具图标:选中并 +1', W - 40, 32, 24, ITEM_C.accent);
this._lbHead.node.setPosition(0, 18);
this._lbQty = this._makeLb(head, '底部可一次 +10 / +100', W - 40, 28, 20, ITEM_C.dim);
this._lbQty.node.setPosition(0, -18);
const scrollH = H - headH - barH - 16;
const { host, content } = makeVScroll(this._root, W - 16, scrollH);
host.setPosition(0, barH / 2 - 10);
host.setPosition(0, (barH - headH) / 2);
const items = gg.data.table.getTableList<ITableItem>(TableNames.Item) ?? [];
items.sort((a, b) => Number(a.id) - Number(b.id));
const cell = 80;
const cols = Math.max(4, Math.floor((W - 24) / (cell + 6)));
const gap = 8;
this._cellSz = 76;
const cols = Math.max(4, Math.floor((W - 24) / (this._cellSz + gap)));
let rowNode: Node | null = null;
for (let i = 0; i < items.length; i++) {
if (i % cols === 0) {
rowNode = new Node(`row${i}`);
rowNode.layer = Layers.Enum.UI_2D;
rowNode.addComponent(UITransform).setContentSize(W - 24, cell);
rowNode.addComponent(UITransform).setContentSize(W - 24, this._cellSz);
const lay = rowNode.addComponent(Layout);
lay.type = Layout.Type.HORIZONTAL;
lay.spacingX = 6;
lay.spacingX = gap;
content.addChild(rowNode);
}
const row = items[i];
const cellN = new Node(`i${row.id}`);
cellN.layer = Layers.Enum.UI_2D;
cellN.addComponent(UITransform).setContentSize(cell - 4, cell - 4);
const sp = cellN.addComponent(Sprite);
sp.sizeMode = Sprite.SizeMode.CUSTOM;
const loader = cellN.addComponent(SpriteLoad);
if (row.icon) loader.setSprite(GPath.Item(row.icon), GBundle.Items);
cellN.addComponent(Button);
const id = Number(row.id);
const name = row.name ?? '';
cellN.on(Button.EventType.CLICK, () => {
this._selectedId = id;
ProjectGmActions.addItemNum(id, 1);
this._qty = gg.data.getItemNum(id);
this._paint();
ctx.host.toast(`${name} +1 → ${this._qty}`);
}, this);
const cellN = this._makeCell(id, name, row.icon);
rowNode!.addChild(cellN);
this._cells[id] = cellN;
}
content.getComponent(Layout)?.updateLayout();
const bar = new Node('bar');
bar.layer = Layers.Enum.UI_2D;
bar.addComponent(UITransform).setContentSize(W - 16, barH - 16);
bar.addComponent(UITransform).setContentSize(W - 16, barH - 12);
this._drawRound(bar, ITEM_C.card, W - 16, barH - 12, 14, ITEM_C.cellLine);
bar.setPosition(0, -H / 2 + barH / 2);
this._root.addChild(bar);
this._lbSel = (() => {
const sel = new Node('sel');
sel.layer = Layers.Enum.UI_2D;
sel.addComponent(UITransform).setContentSize(W - 40, 28);
const lb = sel.addComponent(Label);
lb.fontSize = 20;
lb.color = new Color(180, 220, 255, 255);
lb.horizontalAlign = HorizontalTextAlignment.CENTER;
lb.overflow = Overflow.SHRINK;
sel.setPosition(0, 28);
bar.addChild(sel);
return lb;
})();
makeLabelBtn(bar, '+10', 100, 44, () => {
if (!this._selectedId) {
ctx.host.toast('先点一个图标');
return;
}
ProjectGmActions.addItemNum(this._selectedId, 10);
this._qty = gg.data.getItemNum(this._selectedId);
this._paint();
}).setPosition(-160, -16);
makeLabelBtn(bar, '+100', 100, 44, () => {
if (!this._selectedId) {
ctx.host.toast('先点一个图标');
return;
}
ProjectGmActions.addItemNum(this._selectedId, 100);
this._qty = gg.data.getItemNum(this._selectedId);
this._paint();
}).setPosition(-40, -16);
makeLabelBtn(bar, '返回', 120, 44, () => {
ctx.refreshList();
ctx.goBack();
}).setPosition(120, -16);
const embedded = !!ctx.embedded;
const btnY = 0;
this._pill(bar, '+10', 120, 48, ITEM_C.btnHot, () => this._addQty(10)).setPosition(embedded ? -140 : -180, btnY);
this._pill(bar, '+100', 120, 48, ITEM_C.btn, () => this._addQty(100)).setPosition(embedded ? 140 : -40, btnY);
if (!embedded) {
this._pill(bar, '返回', 110, 48, new Color(70, 78, 96, 255), () => {
ctx.refreshList();
ctx.goBack();
}).setPosition(160, btnY);
}
this._paint();
this._paintSel();
}
close(): void {
if (this._root?.isValid) this._root.destroy();
this._root = null;
this._lbSel = null;
this._ctx = null;
this._lbHead = null;
this._lbQty = null;
this._cells = {};
}
private _paint(): void {
if (this._lbSel) {
this._lbSel.string = this._selectedId
? `最近:${this._selectedId} 持有 ${gg.data.getItemNum(this._selectedId)}`
: '点图标立刻 +1';
private _makeCell(id: number, name: string, icon: string | undefined): Node {
const sz = this._cellSz;
const cellN = new Node(`i${id}`);
cellN.layer = Layers.Enum.UI_2D;
cellN.addComponent(UITransform).setContentSize(sz, sz);
this._drawRound(cellN, ITEM_C.cellBg, sz, sz, 12, ITEM_C.cellLine);
const iconN = new Node('icon');
iconN.layer = Layers.Enum.UI_2D;
iconN.addComponent(UITransform).setContentSize(sz - 16, sz - 16);
const sp = iconN.addComponent(Sprite);
sp.sizeMode = Sprite.SizeMode.CUSTOM;
const loader = iconN.addComponent(SpriteLoad);
if (icon) loader.setSprite(GPath.Item(icon), GBundle.Items);
cellN.addChild(iconN);
const badge = new Node('qty');
badge.layer = Layers.Enum.UI_2D;
badge.addComponent(UITransform).setContentSize(sz - 8, 20);
const bl = badge.addComponent(Label);
bl.string = `${gg.data.getItemNum(id)}`;
bl.fontSize = 16;
bl.isBold = true;
bl.color = ITEM_C.accent;
bl.horizontalAlign = HorizontalTextAlignment.RIGHT;
bl.verticalAlign = VerticalTextAlignment.CENTER;
bl.overflow = Overflow.SHRINK;
badge.setPosition(0, -sz / 2 + 12);
cellN.addChild(badge);
cellN.addComponent(Button);
cellN.on(Button.EventType.CLICK, () => {
this._selectedId = id;
this._selectedName = name;
ProjectGmActions.addItemNum(id, 1);
this._paintSel();
this._ctx?.host.toast(`${name} +1 → ${gg.data.getItemNum(id)}`);
}, this);
return cellN;
}
private _addQty(delta: number): void {
if (!this._selectedId) {
this._ctx?.host.toast('先点选一个道具');
return;
}
ProjectGmActions.addItemNum(this._selectedId, delta);
this._paintSel();
this._ctx?.host.toast(`${this._selectedName || this._selectedId} +${delta}`);
}
private _paintSel(): void {
const ids = Object.keys(this._cells);
for (let i = 0; i < ids.length; i++) {
const id = Number(ids[i]);
const cell = this._cells[id];
if (!cell?.isValid) continue;
const on = id === this._selectedId;
this._drawRound(cell, on ? ITEM_C.selBg : ITEM_C.cellBg, this._cellSz, this._cellSz, 12, on ? ITEM_C.selLine : ITEM_C.cellLine);
const qtyLb = cell.getChildByName('qty')?.getComponent(Label);
if (qtyLb) qtyLb.string = `${gg.data.getItemNum(id)}`;
}
if (this._lbHead) {
this._lbHead.string = this._selectedId
? `已选 · ${this._selectedId} ${this._selectedName}`
: '点击道具图标:选中并 +1';
}
if (this._lbQty) {
this._lbQty.string = this._selectedId
? `持有 ${gg.data.getItemNum(this._selectedId)} · 点图标再 +1`
: '底部可一次 +10 / +100';
}
}
private _makeLb(parent: Node, text: string, w: number, h: number, size: number, color: Color): Label {
const n = new Node('lb');
n.layer = Layers.Enum.UI_2D;
n.addComponent(UITransform).setContentSize(w, h);
const lb = n.addComponent(Label);
lb.string = text;
lb.fontSize = size;
lb.isBold = true;
lb.color = color;
lb.horizontalAlign = HorizontalTextAlignment.CENTER;
lb.verticalAlign = VerticalTextAlignment.CENTER;
lb.overflow = Overflow.SHRINK;
parent.addChild(n);
return lb;
}
private _pill(parent: Node, text: string, w: number, h: number, bg: Color, onClick: () => void): Node {
const n = new Node(text);
n.layer = Layers.Enum.UI_2D;
n.addComponent(UITransform).setContentSize(w, h);
this._drawRound(n, bg, w, h, 12);
const lbN = new Node('lb');
lbN.layer = Layers.Enum.UI_2D;
lbN.addComponent(UITransform).setContentSize(w - 6, h - 4);
const lb = lbN.addComponent(Label);
lb.string = text;
lb.fontSize = 24;
lb.isBold = true;
lb.color = ITEM_C.text;
lb.horizontalAlign = HorizontalTextAlignment.CENTER;
lb.verticalAlign = VerticalTextAlignment.CENTER;
n.addChild(lbN);
n.addComponent(Button);
n.on(Button.EventType.CLICK, onClick, this);
parent.addChild(n);
return n;
}
private _drawRound(node: Node, fill: Color, w: number, h: number, r: number, stroke?: Color): void {
const g = node.getComponent(Graphics) || node.addComponent(Graphics);
g.clear();
g.fillColor = fill;
g.roundRect(-w / 2, -h / 2, w, h, r);
g.fill();
if (stroke) {
g.strokeColor = stroke;
g.lineWidth = stroke === ITEM_C.selLine ? 3 : 2;
g.roundRect(-w / 2, -h / 2, w, h, r);
g.stroke();
}
}
}
@ -575,26 +678,39 @@ class GmLevelPickerPanel implements IGmContentPanel {
open(parent: Node, ctx: IGmPanelContext): void {
this.close();
const pUt = parent.getComponent(UITransform);
const W = pUt?.width || 660;
const H = pUt?.height || 900;
const cur = ProjectGmActions.fixedMapSummary();
this._root = new Node('levelPicker');
this._root.layer = Layers.Enum.UI_2D;
this._root.addComponent(UITransform).setContentSize(660, 900);
this._root.addComponent(UITransform).setContentSize(W, H);
const rw = this._root.addComponent(Widget);
rw.isAlignLeft = rw.isAlignRight = rw.isAlignTop = rw.isAlignBottom = true;
rw.left = rw.right = rw.top = rw.bottom = 0;
parent.addChild(this._root);
const title = new Node('t');
title.layer = Layers.Enum.UI_2D;
title.addComponent(UITransform).setContentSize(600, 36);
const tl = title.addComponent(Label);
tl.string = ctx.command.title;
tl.fontSize = 26;
tl.color = Color.WHITE;
tl.horizontalAlign = HorizontalTextAlignment.CENTER;
title.setPosition(0, 420);
this._root.addChild(title);
const { host, content } = makeVScroll(this._root, 640, 700);
host.setPosition(0, 20);
makeLabelBtn(content, '0 关闭固定(随机)', 620, 44, () => {
const head = new Node('head');
head.layer = Layers.Enum.UI_2D;
head.addComponent(UITransform).setContentSize(W - 16, 72);
const hl = head.addComponent(Label);
hl.string = `固定地图 · 当前 ${cur}`;
hl.fontSize = 26;
hl.isBold = true;
hl.color = new Color(255, 220, 70, 255);
hl.horizontalAlign = HorizontalTextAlignment.CENTER;
hl.verticalAlign = VerticalTextAlignment.CENTER;
head.setPosition(0, H / 2 - 40);
this._root.addChild(head);
const footH = 72;
const { host, content } = makeVScroll(this._root, W - 16, H - 120 - footH);
const lay = content.getComponent(Layout);
if (lay) lay.spacingY = 8;
host.setPosition(0, footH / 2 - 20);
makeLabelBtn(content, '0 关闭固定(随机)', W - 40, 52, () => {
ProjectGmActions.setFixedMap(0);
ctx.refreshList();
ctx.goBack();
@ -606,14 +722,14 @@ class GmLevelPickerPanel implements IGmContentPanel {
const row = list[i];
const id = Number(row.id);
const name = row.name ?? '';
makeLabelBtn(content, `${id} ${name}`, 620, 44, () => {
makeLabelBtn(content, `${id} ${name}`, W - 40, 52, () => {
ProjectGmActions.setFixedMap(id);
ctx.refreshList();
ctx.goBack();
});
}
content.getComponent(Layout)?.updateLayout();
makeLabelBtn(this._root, '返回', 140, 44, () => ctx.goBack()).setPosition(0, -400);
makeLabelBtn(this._root, '返回', 160, 48, () => ctx.goBack()).setPosition(0, -H / 2 + footH / 2);
}
close(): void {
@ -622,6 +738,21 @@ class GmLevelPickerPanel implements IGmContentPanel {
}
}
const RANK_C = {
text: new Color(255, 255, 255, 255),
dim: new Color(170, 185, 210, 255),
accent: new Color(255, 220, 70, 255),
card: new Color(28, 36, 56, 255),
row: new Color(22, 30, 48, 255),
rowLine: new Color(90, 120, 170, 140),
selBg: new Color(48, 42, 18, 255),
selLine: new Color(255, 220, 70, 255),
btn: new Color(45, 105, 190, 255),
btnHot: new Color(18, 92, 78, 255),
starOn: new Color(255, 200, 60, 255),
starOff: new Color(80, 88, 110, 255),
};
/**
* 段位点选 + 星级步进。
*/
@ -629,41 +760,52 @@ class GmRankPickerPanel implements IGmContentPanel {
private _root: Node | null = null;
private _rankId = 1;
private _star = 1;
private _lb: Label | null = null;
private _rankName = '';
private _lbHead: Label | null = null;
private _lbStar: Label | null = null;
private _listW = 640;
private _rows: { [id: number]: Node } = {};
open(parent: Node, ctx: IGmPanelContext): void {
this.close();
this._rankId = RankDataManager.getPlayerRankId() || 1;
this._star = RankDataManager.getIntraTierStars() || 1;
const pUt = parent.getComponent(UITransform);
const W = pUt?.width || 660;
const H = pUt?.height || 900;
this._listW = W - 16;
this._root = new Node('rankPicker');
this._root.layer = Layers.Enum.UI_2D;
this._root.addComponent(UITransform).setContentSize(660, 900);
this._root.addComponent(UITransform).setContentSize(W, H);
const rw = this._root.addComponent(Widget);
rw.isAlignLeft = rw.isAlignRight = rw.isAlignTop = rw.isAlignBottom = true;
rw.left = rw.right = rw.top = rw.bottom = 0;
parent.addChild(this._root);
const title = new Node('t');
title.layer = Layers.Enum.UI_2D;
title.addComponent(UITransform).setContentSize(600, 36);
const tl = title.addComponent(Label);
tl.string = ctx.command.title;
tl.fontSize = 26;
tl.color = Color.WHITE;
tl.horizontalAlign = HorizontalTextAlignment.CENTER;
title.setPosition(0, 420);
this._root.addChild(title);
const cur = new Node('cur');
cur.layer = Layers.Enum.UI_2D;
cur.addComponent(UITransform).setContentSize(600, 36);
this._lb = cur.addComponent(Label);
this._lb.fontSize = 24;
this._lb.color = Color.YELLOW;
this._lb.horizontalAlign = HorizontalTextAlignment.CENTER;
cur.setPosition(0, 360);
this._root.addChild(cur);
const { host, content } = makeVScroll(this._root, 640, 480);
host.setPosition(0, 40);
const headH = 100;
const footH = 120;
const head = new Node('head');
head.layer = Layers.Enum.UI_2D;
head.addComponent(UITransform).setContentSize(this._listW, headH);
this._drawRound(head, RANK_C.card, this._listW, headH, 14, RANK_C.selLine);
head.setPosition(0, H / 2 - headH / 2 - 4);
this._root.addChild(head);
this._lbHead = this._makeLb(head, '', this._listW - 24, 36, 26, RANK_C.accent);
this._lbHead.node.setPosition(0, 22);
this._lbStar = this._makeLb(head, '', this._listW - 24, 32, 28, RANK_C.starOn);
this._lbStar.node.setPosition(0, -22);
const scrollH = H - headH - footH - 16;
const { host, content } = makeVScroll(this._root, this._listW, scrollH);
const lay = content.getComponent(Layout);
if (lay) {
lay.spacingY = 8;
lay.paddingBottom = 12;
}
host.setPosition(0, (footH - headH) / 2);
const list = gg.data.table.getTableList<ITableRank>(TableNames.Rank) ?? [];
list.sort((a, b) => Number(a.id) - Number(b.id));
@ -671,40 +813,138 @@ class GmRankPickerPanel implements IGmContentPanel {
const row = list[i];
const id = Number(row.id);
const name = row.name ?? '';
makeLabelBtn(content, `${id} ${name}`, 620, 44, () => {
this._rankId = id;
this._paint();
});
if (id === this._rankId) this._rankName = name;
const card = this._makeRankRow(id, name);
content.addChild(card);
this._rows[id] = card;
}
content.getComponent(Layout)?.updateLayout();
makeLabelBtn(this._root, '星-1', 100, 40, () => {
const foot = new Node('foot');
foot.layer = Layers.Enum.UI_2D;
foot.addComponent(UITransform).setContentSize(this._listW, footH - 12);
this._drawRound(foot, RANK_C.card, this._listW, footH - 12, 14, RANK_C.rowLine);
foot.setPosition(0, -H / 2 + footH / 2);
this._root.addChild(foot);
this._pill(foot, '星 -1', 110, 48, new Color(70, 78, 96, 255), () => {
this._star = Math.max(1, this._star - 1);
this._paint();
}).setPosition(-160, -260);
makeLabelBtn(this._root, '星+1', 100, 40, () => {
}).setPosition(-200, 0);
this._pill(foot, '星 +1', 110, 48, new Color(70, 78, 96, 255), () => {
this._star = Math.min(5, this._star + 1);
this._paint();
}).setPosition(-40, -260);
makeLabelBtn(this._root, '应用', 120, 44, () => {
}).setPosition(-70, 0);
this._pill(foot, '应用', 140, 52, RANK_C.btnHot, () => {
ProjectGmActions.setRankAndStar(this._rankId, this._star);
ctx.host.toast(`已设段位 ${this._rankId} 星 ${this._star}`);
ctx.refreshList();
ctx.goBack();
}).setPosition(100, -260);
if (!ctx.embedded) ctx.goBack();
}).setPosition(120, 0);
makeLabelBtn(this._root, '返回', 120, 44, () => ctx.goBack()).setPosition(0, -320);
this._paint();
}
close(): void {
if (this._root?.isValid) this._root.destroy();
this._root = null;
this._lb = null;
this._lbHead = null;
this._lbStar = null;
this._rows = {};
}
private _makeRankRow(id: number, name: string): Node {
const h = 64;
const w = this._listW;
const card = new Node(`rank${id}`);
card.layer = Layers.Enum.UI_2D;
card.addComponent(UITransform).setContentSize(w, h);
this._drawRound(card, RANK_C.row, w, h, 12, RANK_C.rowLine);
const idLb = this._makeLb(card, `${id}`, 56, 36, 24, RANK_C.accent);
idLb.node.setPosition(-w / 2 + 40, 0);
const nameLb = this._makeLb(card, name, w - 120, 36, 24, RANK_C.text);
nameLb.horizontalAlign = HorizontalTextAlignment.LEFT;
nameLb.node.setPosition(20, 0);
card.addComponent(Button);
card.on(Button.EventType.CLICK, () => {
this._rankId = id;
this._rankName = name;
this._paint();
}, this);
return card;
}
private _paint(): void {
if (this._lb) this._lb.string = `段位 ${this._rankId} 星 ${this._star}`;
const ids = Object.keys(this._rows);
for (let i = 0; i < ids.length; i++) {
const id = Number(ids[i]);
const row = this._rows[id];
if (!row?.isValid) continue;
const on = id === this._rankId;
this._drawRound(row, on ? RANK_C.selBg : RANK_C.row, this._listW, 64, 12, on ? RANK_C.selLine : RANK_C.rowLine);
}
if (this._lbHead) {
this._lbHead.string = `当前选中 · ${this._rankId} ${this._rankName || ''}`.trim();
}
if (this._lbStar) {
let stars = '';
for (let i = 1; i <= 5; i++) stars += i <= this._star ? '★' : '☆';
this._lbStar.string = `${stars} 星级 ${this._star}`;
}
}
private _makeLb(parent: Node, text: string, w: number, h: number, size: number, color: Color): Label {
const n = new Node('lb');
n.layer = Layers.Enum.UI_2D;
n.addComponent(UITransform).setContentSize(w, h);
const lb = n.addComponent(Label);
lb.string = text;
lb.fontSize = size;
lb.isBold = true;
lb.color = color;
lb.horizontalAlign = HorizontalTextAlignment.CENTER;
lb.verticalAlign = VerticalTextAlignment.CENTER;
lb.overflow = Overflow.SHRINK;
parent.addChild(n);
return lb;
}
private _pill(parent: Node, text: string, w: number, h: number, bg: Color, onClick: () => void): Node {
const n = new Node(text);
n.layer = Layers.Enum.UI_2D;
n.addComponent(UITransform).setContentSize(w, h);
this._drawRound(n, bg, w, h, 12);
const lbN = new Node('lb');
lbN.layer = Layers.Enum.UI_2D;
lbN.addComponent(UITransform).setContentSize(w - 6, h - 4);
const lb = lbN.addComponent(Label);
lb.string = text;
lb.fontSize = 22;
lb.isBold = true;
lb.color = RANK_C.text;
lb.horizontalAlign = HorizontalTextAlignment.CENTER;
lb.verticalAlign = VerticalTextAlignment.CENTER;
n.addChild(lbN);
n.addComponent(Button);
n.on(Button.EventType.CLICK, onClick, this);
parent.addChild(n);
return n;
}
private _drawRound(node: Node, fill: Color, w: number, h: number, r: number, stroke?: Color): void {
const g = node.getComponent(Graphics) || node.addComponent(Graphics);
g.clear();
g.fillColor = fill;
g.roundRect(-w / 2, -h / 2, w, h, r);
g.fill();
if (stroke) {
g.strokeColor = stroke;
g.lineWidth = stroke === RANK_C.selLine ? 3 : 2;
g.roundRect(-w / 2, -h / 2, w, h, r);
g.stroke();
}
}
}

@ -1,6 +1,6 @@
# 方案:可视化可扩展 GM 模块
> 更新日期:2026-10-16
> 更新日期:2026-10-19
## 1. 目标
@ -24,42 +24,53 @@
|------|------|------|
| Quick | 一键动作 | 复制存档、每日重置、发宝石 |
| Toggle | 开/关 | 广告、营地免伤、商店无限 |
| Content | 可视化子页 | 道具图标点选、关卡列表、段位步进;JSON 用 textArea |
| Content | 可视化子页 / 内嵌整页 | 道具图标点选、关卡列表、段位步进;JSON 用 textArea |
## 4. 开发者扩展(优先用 GmEasy)
## 4. Tab 结构(本项目)
| Tab | 内容 |
|-----|------|
| 常用 | 仅快捷按钮 + 快捷开关;尾部追加「钉到常用」的项 |
| 道具 | `embed` 道具点选(选中高亮) |
| 段位 | `embed` 段位/星级(选中高亮) |
| 通用 | 数值、营地开关、固定地图、局内血量、危险重置;每项可钉常用 |
| 词条 | `embed` buff 选择 |
| 存档 | `embed` 存档编辑 |
已去掉独立「战斗」Tab,原战斗项并入「通用」。
## 5. 开发者扩展(优先用 GmEasy)
日常加功能只改 `ProjectGmBootstrap._register`,链式几行即可:
```ts
new GmEasy()
.tab('常用')
.btn('复制存档', () => copy()) // 快捷
.sw('显示广告', () => on, v => set(v)) // 开关
.num('金钱', () => get(), v => set(v)) // 数值设为+增减
.page('道具', 'itemPicker', () => '点选') // 自定义面板
.btn('复制存档', () => copy())
.sw('显示广告', () => on, v => set(v))
.tab('道具')
.embed('itemPicker')
.tab('通用')
.pinable().num('金钱', () => get(), v => set(v)) // 可钉到常用
.pinable().sw('营地免伤', () => on, v => set(v))
.danger('重置', '确定?', () => reset())
.text('存档', raw => apply(raw))
.commit();
```
- `.pinable()`:标记下一条命令可钉到「常用」;钉选状态存 `sys.localStorage`(`gm.pin.{id}`)
- `.embed(panelId)`:当前 Tab 整页嵌入面板(无二次点击入口)
底层仍是 `IGmCommand` / `GmRegistry`;自定义面板:`GmRegistry.registerPanel(id, () => new XxxPanel())`。
内置面板 id:`numberStepper`、`confirmQuick`、`textArea`。
## 5. 换项目接入
## 6. 换项目接入
1. 拷贝 `assets/mx/module/gm/`
2. 实现 `IGmHost`(约 30 行)
3. `new GmEasy()...commit()` 写命令;需要时 `registerPanel`
4. `GmShell.bindHost` + `GmShell.show()`
## 6. 本项目迁移
- Toggle:广告、内购、商店无限、营地免伤、快速胜利
- Quick:每日重置、复制存档、发宝石/武器、清地图、商店重置、排行测试等
- Content:道具/BUFF/关卡/段位/货币/血量/存档;武器相关可打开原 PopGM
- 删除帮助有、代码无的死条目;空实现等级命令不注册
## 7. 入口
- 设置页 `GM.show()` → 委托 `ProjectGmBootstrap.show()` → `GmShell`
@ -74,5 +85,3 @@ new GmEasy()
| 类型/注册/壳/视图/内置面板 | `assets/mx/module/gm/` |
| Host / Actions / Panels / Bootstrap | `assets/script/gm/` |
| 门面 | `assets/script/game/GM.ts`(薄委托,无 EditBox/switch) |
未迁入本版(原孤儿/死代码):玩家等级空实现、七十二变帮助项、指定背包文字(可后续加 Content)。

Loading…
Cancel
Save