import { Button, Color, EditBox, Graphics, Label, Layout, Mask, Node, ScrollView, UITransform, Widget, HorizontalTextAlignment, VerticalTextAlignment, Layers, Overflow, } from 'cc'; import type { IGmContentPanel, IGmPanelContext } from '../../mx/module/gm'; import { Doc } from '../game/GameData'; import { ProjectGmActions } from './ProjectGmActions'; import MTools from '../../mx/tools/MTools'; const C = { text: new Color(255, 255, 255, 255), dim: new Color(180, 190, 210, 255), accent: new Color(255, 220, 70, 255), rowBg: new Color(28, 36, 56, 255), inputBg: new Color(12, 16, 28, 255), inputLine: new Color(100, 160, 240, 255), btn: new Color(36, 130, 95, 255), btnSet: new Color(210, 120, 35, 255), btnSave: new Color(45, 105, 190, 255), btnCancel: new Color(70, 78, 96, 255), }; /** 固定字号,避免 Overflow.SHRINK 把字压到看不清 */ const FONT = { section: 26, label: 24, input: 24, tip: 20, btn: 26, }; /** Doc 字段中文名 */ const DOC_LABELS: { [key: string]: string } = { key: '存档key', uid: '用户uid', roleId: '角色roleId', playerTitleLevel: '称号等级', dataVersion: 'dataVersion', rankId: '段位id', rankStars: '总星数', pendingBattleOppRankId: '未结算对手段位', matchWinCount: '匹配胜场', matchLoseCount: '匹配负场', totalBattleCount: '总挑战次数', matchWinStatsDate: '胜率统计日', matchWinStreak: '连胜', matchLoseStreak: '连败', dailyWins: '今日胜场', yesterdayWins: '昨日胜场', dailyWinsDate: '今日胜场日期', rankLastSyncDailyWins: '同步每日胜场', rankLastSyncDailyWinsDate: '同步每日胜场日', userName: '昵称', userAvatar: '头像url', userProvince: '省份', rankUserAuthPrompted: '已弹授权', rankSyncedOnce: '已同步排行', rankLastSyncStars: '同步星数', rankLastSyncProvince: '同步省份', rankLastSyncNick: '同步昵称', rankLastSyncAvatar: '同步头像', rankLastSyncRegion: '同步地区', emolumentSnapStars: '俸禄待领星', emolumentSnapWins: '俸禄待领胜', emolumentSnapDate: '俸禄快照日', gmAwaitEmolumentPop: 'GM弹俸禄', headIconId: '头像id', battleIllustratedGuideClicked: '已点图鉴', level: '关卡level', itemCount: '道具itemCount', status: '状态status', statistics: '统计statistics', guide: '引导guide', other: '其他other', onlyLocal: '仅本地onlyLocal', failCount: '上报失败次数', lastFailCount: '上次上报失败次数', }; /** * 存档页:复制 / 设置(必弹粘贴层)+ Doc 全字段上下布局编辑。 */ export class GmSaveEditorPanel implements IGmContentPanel { private _root: Node | null = null; private _ctx: IGmPanelContext | null = null; private _scrollContent: Node | null = null; private _listW = 660; private _fieldEdits: { key: string; edit: EditBox }[] = []; private _pasteLayer: Node | null = null; private _pasteEdit: EditBox | null = null; 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; this._listW = W - 24; this._root = new Node('saveEditor'); this._root.layer = Layers.Enum.UI_2D; 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 topH = 118; const top = new Node('top'); top.layer = Layers.Enum.UI_2D; top.addComponent(UITransform).setContentSize(W, topH); const tw = top.addComponent(Widget); tw.isAlignTop = tw.isAlignLeft = tw.isAlignRight = true; tw.top = 0; tw.left = tw.right = 0; this._root.addChild(top); this._makeBtn(top, '复制存档', 200, 52, C.btn, () => { ProjectGmActions.copySave(); }).setPosition(-120, 28); this._makeBtn(top, '设置存档', 200, 52, C.btnSet, () => { this._openSetSaveFlow(); }).setPosition(120, 28); const tip = new Node('tip'); tip.layer = Layers.Enum.UI_2D; tip.addComponent(UITransform).setContentSize(W - 32, 44); const tipLb = tip.addComponent(Label); tipLb.string = '设置存档会打开粘贴框;下方每字段:上名称、下输入'; tipLb.fontSize = FONT.tip; tipLb.lineHeight = 24; tipLb.color = C.dim; tipLb.isBold = true; tipLb.horizontalAlign = HorizontalTextAlignment.CENTER; tip.setPosition(0, -32); top.addChild(tip); const bottomH = 76; const { host, content } = this._makeScroll(this._listW, Math.max(200, H - topH - bottomH - 8)); this._scrollContent = content; const sw = host.addComponent(Widget); sw.isAlignTop = sw.isAlignBottom = sw.isAlignLeft = sw.isAlignRight = true; sw.top = topH; sw.bottom = bottomH; sw.left = sw.right = 12; this._root.addChild(host); this._rebuildFieldList(); const bottom = new Node('bottom'); bottom.layer = Layers.Enum.UI_2D; bottom.addComponent(UITransform).setContentSize(W, bottomH); const bw = bottom.addComponent(Widget); bw.isAlignBottom = bw.isAlignLeft = bw.isAlignRight = true; bw.bottom = 0; bw.left = bw.right = 0; this._root.addChild(bottom); this._makeBtn(bottom, '保存全部字段', 320, 54, C.btnSave, () => { this._applyFieldEdits(); }).setPosition(0, 4); } close(): void { this._hidePasteLayer(); if (this._root?.isValid) this._root.destroy(); this._root = null; this._ctx = null; this._scrollContent = null; this._fieldEdits = []; } /** 每次点击必有界面反馈:打开粘贴层,并尝试自动填入剪切板 */ private _openSetSaveFlow(): void { const host = this._ctx?.host; if (this._pasteLayer?.isValid) { host?.toast('请在弹层中粘贴或确认'); return; } this._showPasteLayer('可粘贴存档 JSON;若剪切板有内容将自动填入'); host?.toast('请粘贴或确认存档'); void this._tryFillClipboard(); } private async _tryFillClipboard(): Promise { const r = await MTools.readClipboardResult(1500); if (!this._pasteEdit || !this._pasteLayer?.isValid) return; if (r.err || !r.text?.trim()) return; this._pasteEdit.string = r.text.trim(); this._ctx?.host.toast('已从剪切板填入,请点确认设置'); } private _rebuildFieldList(): void { if (!this._scrollContent?.isValid) return; this._scrollContent.removeAllChildren(); this._fieldEdits = []; const doc = gg.data.doc as unknown as { [key: string]: unknown }; const template = new Doc() as unknown as { [key: string]: unknown }; const keySet: { [k: string]: boolean } = {}; const keys: string[] = []; const addKey = (k: string): void => { if (keySet[k]) return; keySet[k] = true; keys.push(k); }; const tKeys = Object.keys(template); for (let i = 0; i < tKeys.length; i++) addKey(tKeys[i]); const dKeys = Object.keys(doc); for (let i = 0; i < dKeys.length; i++) addKey(dKeys[i]); this._addSection('标量字段'); for (let i = 0; i < keys.length; i++) { const key = keys[i]; const cur = doc[key] !== undefined ? doc[key] : template[key]; if (Array.isArray(cur)) continue; const title = DOC_LABELS[key] || key; const sub = DOC_LABELS[key] ? key : ''; const row = this._addFieldRow(title, sub, false); row.edit.string = cur === undefined || cur === null ? '' : String(cur); this._fieldEdits.push({ key, edit: row.edit }); } this._addSection('数组字段(JSON)'); for (let i = 0; i < keys.length; i++) { const key = keys[i]; const cur = doc[key] !== undefined ? doc[key] : template[key]; if (!Array.isArray(cur)) continue; const title = DOC_LABELS[key] || key; const sub = DOC_LABELS[key] ? key : ''; const row = this._addFieldRow(title, sub, true); try { row.edit.string = JSON.stringify(cur); } catch { row.edit.string = '[]'; } this._fieldEdits.push({ key, edit: row.edit }); } this._scrollContent.getComponent(Layout)?.updateLayout(); } private _showPasteLayer(reason: string): void { this._hidePasteLayer(); if (!this._root?.isValid) return; const W = this._root.getComponent(UITransform)!.width; const H = this._root.getComponent(UITransform)!.height; this._pasteLayer = new Node('pasteLayer'); this._pasteLayer.layer = Layers.Enum.UI_2D; this._pasteLayer.addComponent(UITransform).setContentSize(W, H); const g = this._pasteLayer.addComponent(Graphics); g.fillColor = new Color(0, 0, 0, 210); g.rect(-W / 2, -H / 2, W, H); g.fill(); this._root.addChild(this._pasteLayer); const cardW = Math.min(W - 32, 640); const cardH = 400; const card = new Node('card'); card.layer = Layers.Enum.UI_2D; card.addComponent(UITransform).setContentSize(cardW, cardH); this._drawRound(card, C.rowBg, cardW, cardH, 16); this._pasteLayer.addChild(card); const title = new Node('t'); title.layer = Layers.Enum.UI_2D; title.addComponent(UITransform).setContentSize(cardW - 24, 40); const tl = title.addComponent(Label); tl.string = '设置存档'; tl.fontSize = 30; tl.isBold = true; tl.color = C.accent; tl.horizontalAlign = HorizontalTextAlignment.CENTER; title.setPosition(0, cardH / 2 - 32); card.addChild(title); const reasonN = new Node('r'); reasonN.layer = Layers.Enum.UI_2D; reasonN.addComponent(UITransform).setContentSize(cardW - 32, 48); const rl = reasonN.addComponent(Label); rl.string = reason; rl.fontSize = FONT.tip; rl.color = C.dim; rl.isBold = true; rl.horizontalAlign = HorizontalTextAlignment.CENTER; rl.overflow = Overflow.RESIZE_HEIGHT; reasonN.setPosition(0, cardH / 2 - 78); card.addChild(reasonN); const editW = cardW - 40; const editH = 180; const editN = new Node('edit'); editN.layer = Layers.Enum.UI_2D; editN.addComponent(UITransform).setContentSize(editW, editH); this._drawRound(editN, C.inputBg, editW, editH, 10, C.inputLine); const textLbN = new Node('TEXT_LABEL'); textLbN.layer = Layers.Enum.UI_2D; textLbN.addComponent(UITransform).setContentSize(editW - 16, editH - 16); const textLb = textLbN.addComponent(Label); textLb.fontSize = FONT.input; textLb.color = C.accent; textLb.isBold = true; textLb.horizontalAlign = HorizontalTextAlignment.LEFT; textLb.verticalAlign = VerticalTextAlignment.TOP; textLb.overflow = Overflow.CLAMP; editN.addChild(textLbN); const phN = new Node('PLACEHOLDER_LABEL'); phN.layer = Layers.Enum.UI_2D; phN.addComponent(UITransform).setContentSize(editW - 16, editH - 16); const phLb = phN.addComponent(Label); phLb.string = '在此粘贴存档 JSON'; phLb.fontSize = FONT.input; phLb.color = C.dim; phLb.horizontalAlign = HorizontalTextAlignment.CENTER; phLb.verticalAlign = VerticalTextAlignment.CENTER; editN.addChild(phN); this._pasteEdit = editN.addComponent(EditBox); this._pasteEdit.textLabel = textLb; this._pasteEdit.placeholderLabel = phLb; this._pasteEdit.maxLength = 500000; this._pasteEdit.inputMode = EditBox.InputMode.ANY; editN.setPosition(0, 8); card.addChild(editN); this._makeBtn(card, '确认设置', 170, 52, C.btnSet, () => { const raw = this._pasteEdit?.string ?? ''; if (!raw.trim()) { this._ctx?.host.toast('内容为空'); return; } const ok = ProjectGmActions.applySaveJson(raw, false); if (ok) { this._hidePasteLayer(); this._rebuildFieldList(); } }).setPosition(-100, -cardH / 2 + 44); this._makeBtn(card, '取消', 170, 52, C.btnCancel, () => { this._hidePasteLayer(); }).setPosition(100, -cardH / 2 + 44); } private _hidePasteLayer(): void { if (this._pasteLayer?.isValid) this._pasteLayer.destroy(); this._pasteLayer = null; this._pasteEdit = null; } private _applyFieldEdits(): void { const fields: { [key: string]: string } = {}; for (let i = 0; i < this._fieldEdits.length; i++) { const f = this._fieldEdits[i]; fields[f.key] = f.edit.string ?? ''; } ProjectGmActions.applySaveFieldEdits(fields); this._rebuildFieldList(); } private _makeScroll(w: number, h: number): { host: Node; content: Node } { const host = new Node('scroll'); host.layer = Layers.Enum.UI_2D; host.addComponent(UITransform).setContentSize(w, h); const view = new Node('view'); view.layer = Layers.Enum.UI_2D; view.addComponent(UITransform).setContentSize(w, h); view.addComponent(Mask); const vw = view.addComponent(Widget); vw.isAlignLeft = vw.isAlignRight = vw.isAlignTop = vw.isAlignBottom = true; vw.left = vw.right = vw.top = vw.bottom = 0; host.addChild(view); const content = new Node('content'); content.layer = Layers.Enum.UI_2D; const cut = content.addComponent(UITransform); cut.setContentSize(w, h); cut.setAnchorPoint(0.5, 1); content.setPosition(0, h / 2); const layout = content.addComponent(Layout); layout.type = Layout.Type.VERTICAL; layout.resizeMode = Layout.ResizeMode.CONTAINER; layout.spacingY = 12; layout.paddingTop = 8; layout.paddingBottom = 28; view.addChild(content); const sv = host.addComponent(ScrollView); sv.content = content; sv.horizontal = false; sv.vertical = true; return { host, content }; } private _addSection(title: string): void { if (!this._scrollContent) return; const n = new Node('sec'); n.layer = Layers.Enum.UI_2D; n.addComponent(UITransform).setContentSize(this._listW, 40); const lb = n.addComponent(Label); lb.string = title; lb.fontSize = FONT.section; lb.isBold = true; lb.color = C.accent; lb.horizontalAlign = HorizontalTextAlignment.LEFT; lb.overflow = Overflow.NONE; this._scrollContent.addChild(n); } /** * 上下布局:上行标题(固定字号),下行全宽输入框(固定字号)。 */ private _addFieldRow(title: string, subKey: string, tall: boolean): { edit: EditBox } { const rowH = tall ? 120 : 100; const w = this._listW; const row = new Node(title); row.layer = Layers.Enum.UI_2D; row.addComponent(UITransform).setContentSize(w, rowH); this._drawRound(row, C.rowBg, w, rowH, 12); const headH = 36; const lbN = new Node('lb'); lbN.layer = Layers.Enum.UI_2D; lbN.addComponent(UITransform).setContentSize(w - 24, headH); const lb = lbN.addComponent(Label); lb.string = subKey ? `${title} · ${subKey}` : title; lb.fontSize = FONT.label; lb.isBold = true; lb.color = C.text; lb.horizontalAlign = HorizontalTextAlignment.LEFT; lb.verticalAlign = VerticalTextAlignment.CENTER; lb.overflow = Overflow.CLAMP; lb.enableWrapText = false; lbN.setPosition(0, rowH / 2 - headH / 2 - 6); row.addChild(lbN); const editW = w - 24; const editH = tall ? 68 : 48; const editN = new Node('edit'); editN.layer = Layers.Enum.UI_2D; editN.addComponent(UITransform).setContentSize(editW, editH); this._drawRound(editN, C.inputBg, editW, editH, 8, C.inputLine); const textLbN = new Node('TEXT_LABEL'); textLbN.layer = Layers.Enum.UI_2D; textLbN.addComponent(UITransform).setContentSize(editW - 16, editH - 10); const textLb = textLbN.addComponent(Label); textLb.fontSize = FONT.input; textLb.isBold = true; textLb.color = C.accent; textLb.horizontalAlign = HorizontalTextAlignment.LEFT; textLb.verticalAlign = tall ? VerticalTextAlignment.TOP : VerticalTextAlignment.CENTER; textLb.overflow = Overflow.CLAMP; editN.addChild(textLbN); const phN = new Node('PLACEHOLDER_LABEL'); phN.layer = Layers.Enum.UI_2D; phN.addComponent(UITransform).setContentSize(editW - 16, editH - 10); const phLb = phN.addComponent(Label); phLb.string = tall ? '输入 JSON 数组' : '点击输入'; phLb.fontSize = FONT.input; phLb.color = C.dim; phLb.horizontalAlign = HorizontalTextAlignment.LEFT; phLb.verticalAlign = VerticalTextAlignment.CENTER; editN.addChild(phN); const eb = editN.addComponent(EditBox); eb.textLabel = textLb; eb.placeholderLabel = phLb; eb.maxLength = tall ? 200000 : 512; eb.inputMode = tall ? EditBox.InputMode.ANY : EditBox.InputMode.SINGLE_LINE; editN.setPosition(0, -rowH / 2 + editH / 2 + 8); row.addChild(editN); this._scrollContent!.addChild(row); return { edit: eb }; } private _makeBtn(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 - 8, h - 4); const lb = lbN.addComponent(Label); lb.string = text; lb.fontSize = FONT.btn; lb.isBold = true; lb.color = C.text; lb.horizontalAlign = HorizontalTextAlignment.CENTER; lb.verticalAlign = VerticalTextAlignment.CENTER; lb.overflow = Overflow.NONE; 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 = 2; g.roundRect(-w / 2, -h / 2, w, h, r); g.stroke(); } } }