diff --git a/assets/Script/coin_madness/GoldMinerService.ts b/assets/Script/coin_madness/GoldMinerService.ts index 668ea74..7598650 100644 --- a/assets/Script/coin_madness/GoldMinerService.ts +++ b/assets/Script/coin_madness/GoldMinerService.ts @@ -121,7 +121,11 @@ export default class GoldMinerService { } get settlementNotice() { return Object.keys(this.ledger.notices).reduce((sum, id) => sum + this.ledger.notices[id], 0); } dismissNotice() { this.ledger.notices = {}; this.persist(); } - get hasBadge() { return this.settlementNotice > 0 || !!(this.info && this.info.tasks.some(t => t.claimStatus === 'claimable' || t.claimStatus === 'issuing')); } + get hasBadge() { + return this.settlementNotice > 0 || !!(this.info && this.info.tasks.some(t => + t.claimStatus !== 'claimed' && (t.claimStatus === 'claimable' || t.claimStatus === 'issuing' + || t.claimStatus === 'pending_unlock' || this.info.progressWins >= t.targetWins))); + } stopPolling() { this.polling++; } /** First validated snapshot; recovery continues on the existing serialized queue. */ readForDisplay(): Promise { diff --git a/assets/coin_madness/README.md b/assets/coin_madness/README.md index d6e7163..6439a75 100644 --- a/assets/coin_madness/README.md +++ b/assets/coin_madness/README.md @@ -85,7 +85,7 @@ info ← 首份数据返回后即可显示入口/主面 ### 入口显示与布局 -- 有补发到账通知或可领取/发放中的奖励时,入口可显示红点。 +- 有补发到账通知,或存在未领取的已达标任务时,入口显示提示标记(叹号);未购买也提示。任务 pending_unlock、claimable、issuing 或累计进度达到目标都可触发,claimed 不重复提示。活动结束后仍按 Host 的期次/补发规则决定入口是否保留。 - 普通展示还要求 canIos 开关允许,且 info.status 为 purchasable、purchase_disabled 或 unlocked。 - locked、unavailable 默认不显示入口,奖励通知是例外。 - 使用 HomeScene 的 Load/Top/CoinMadnessEntry;找不到时才创建。 @@ -274,6 +274,8 @@ confirm_settlement_delivery 请求携带 settlementId、grantIds;当前逐档 ## 11. 排查与验证 +数字适配:总奖励限制在 164 像素、每档奖励限制在 170 像素的局部宽度内,按实际字形宽度等比缩放并保留正字间距。价格与“元”整体居中,数字变长时单位跟随移动,超过按钮可用宽度才缩小。离线预览检查 `check-number-layout.cjs` 覆盖四/五位奖励、一/二/三位价格、重复刷新和未购买达标提示;运行前启动 `preview.cjs`(检查默认端口 8879,可用 PORT 指定)。 + 2026-09-30 清理:已删除游戏测试分包、活动测试面板、首页测试入口及两个演示场景。保留活动正式流程、支付诊断日志和离线回归检查。 - 进度不涨:查看真实通关那条 userLevel 的请求 goldMiner 与响应 data.goldMiner,再对比 info 的 periodId/progressWins/tasks。 diff --git a/assets/coin_madness/script/CoinMadnessDigits.ts b/assets/coin_madness/script/CoinMadnessDigits.ts index 248f25c..bbe2ded 100644 --- a/assets/coin_madness/script/CoinMadnessDigits.ts +++ b/assets/coin_madness/script/CoinMadnessDigits.ts @@ -9,10 +9,13 @@ export default class CoinMadnessDigits extends cc.Component { @property gap = 0; @property colonWidth = .36; private text = ''; - setText(text: string) { - if (this.text === text) return; - if (text.length > this.node.children.length) { cc.warn('Coin Madness digit capacity exceeded', text); return; } + private widthLimit = -1; + private renderedWidth = 0; + setText(text: string, maxWidth = 0): number { + if (this.text === text && this.widthLimit === maxWidth) return this.renderedWidth; + if (text.length > this.node.children.length) { cc.warn('Coin Madness digit capacity exceeded', text); return this.renderedWidth; } this.text = text; + this.widthLimit = maxWidth; const glyphs = text.split('').map(char => { // The countdown separator is intentional text, never a missing-image fallback. if (char === ':') return { char, frame: null, width: this.height * this.colonWidth }; @@ -20,14 +23,20 @@ export default class CoinMadnessDigits extends cc.Component { const rect = frame.getRect(); return { char, frame, width: this.height * rect.width / rect.height }; }); - let x = -(glyphs.reduce((sum, glyph) => sum + glyph.width, 0) + this.gap * (glyphs.length - 1)) / 2; + // Bounded amounts need clear spacing, including outlines on the digit artwork. + const gap = maxWidth > 0 ? Math.max(2, this.gap) : this.gap; + const width = glyphs.reduce((sum, glyph) => sum + glyph.width, 0) + gap * Math.max(0, glyphs.length - 1); + const scale = maxWidth > 0 && width > maxWidth ? maxWidth / width : 1; + this.renderedWidth = width * scale; + let x = -this.renderedWidth / 2; this.node.children.forEach((slot, i) => { slot.active = i < glyphs.length; if (!slot.active) return; - const glyph = glyphs[i]; slot.x = x + glyph.width / 2; + const glyph = glyphs[i]; slot.x = x + glyph.width * scale / 2; const image = slot.getChildByName('Image'); image.active = glyph.char !== ':'; - if (glyph.char !== ':') { image.getComponent(cc.Sprite).spriteFrame = glyph.frame; image.setContentSize(glyph.width, this.height); } - x += glyph.width + this.gap; + if (glyph.char !== ':') { image.getComponent(cc.Sprite).spriteFrame = glyph.frame; image.setContentSize(glyph.width * scale, this.height * scale); } + x += (glyph.width + gap) * scale; }); + return this.renderedWidth; } } diff --git a/assets/coin_madness/script/CoinMadnessPanel.ts b/assets/coin_madness/script/CoinMadnessPanel.ts index 2db25de..39f64a2 100644 --- a/assets/coin_madness/script/CoinMadnessPanel.ts +++ b/assets/coin_madness/script/CoinMadnessPanel.ts @@ -132,13 +132,20 @@ export default class CoinMadnessPanel extends cc.Component { this.unlockedOffer.active = unlocked; this.purchaseButton.active = !unlocked && (!this.api.request || data.status === 'purchasable'); this.totalDigits.node.position = unlocked ? this.unlockedTotalPosition.position : this.lockedTotalPosition; - this.totalDigits.setText(String(config.stages.reduce((sum, s) => sum + s.coins, 0))); + this.totalDigits.setText(String(config.stages.reduce((sum, s) => sum + s.coins, 0)), 164); this.purchaseLabel.string = expired ? '已结束' : state.stage >= config.stages.length ? '已全部领取' : state.paidAt ? '已解锁' : ''; const canBuy = !state.paidAt && !expired && (!this.api.request || data.status === 'purchasable'); this.purchaseButton.color = canBuy ? cc.Color.WHITE : new cc.Color(200, 195, 180); this.priceDigits.node.active = this.currency.active = canBuy; if (this.discountDigits) this.discountDigits.setText(String(this.discount)); - if (Number.isFinite(config.priceCents)) this.priceDigits.setText(String(Math.floor(config.priceCents / 100))); + if (Number.isFinite(config.priceCents)) { + const currencyWidth = this.currency.width * Math.abs(this.currency.scaleX), gap = 12; + const width = this.priceDigits.setText(String(Math.floor(config.priceCents / 100)), + Math.max(1, this.purchaseButton.width - 64 - currencyWidth - gap)); + // Center the price and currency together instead of keeping “元” at a fixed x. + this.priceDigits.node.x = -(currencyWidth + gap) / 2; + this.currency.x = (width + gap) / 2; + } // The first complete prefab row is the template for additional configured stages. while (this.content.children.length < config.stages.length) { const row = cc.instantiate(this.content.children[0]); this.content.addChild(row); @@ -188,7 +195,7 @@ export default class CoinMadnessPanel extends cc.Component { lock.getComponent(cc.Sprite).spriteFrame = this.lockedFrame; } else if (!lock.getNumberOfRunningActions()) lock.active = false; this.paintProgress(row, i, expired); - child('Reward').getComponent(CoinMadnessDigits).setText(String(stage.coins)); + child('Reward').getComponent(CoinMadnessDigits).setText(String(stage.coins), 170); ['Task', 'CompletedTask'].forEach(name => child(name).getChildByName('Target').getComponent(CoinMadnessDigits).setText(String(stage.wins))); }); this.focusGrowingTask(); diff --git a/tools/coin-madness/check-number-layout.cjs b/tools/coin-madness/check-number-layout.cjs new file mode 100644 index 0000000..abc48aa --- /dev/null +++ b/tools/coin-madness/check-number-layout.cjs @@ -0,0 +1,76 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const ts = require('typescript'); +const { chromium } = require('playwright'); + +const output = ts.transpileModule(fs.readFileSync('assets/Script/coin_madness/GoldMinerService.ts', 'utf8'), { + compilerOptions: { target: ts.ScriptTarget.ES2017, module: ts.ModuleKind.CommonJS } +}).outputText; +const exported = {}; +new Function('require', 'exports', output)(() => ({}), exported); +const service = Object.create(exported.default.prototype); +service.ledger = { notices: {} }; +for (const status of ['purchasable', 'unlocked']) { + service.info = { status, progressWins: 4, tasks: [{ targetWins: 5, claimStatus: 'locked' }] }; + assert.equal(service.hasBadge, false); + service.info.progressWins = 5; + assert.equal(service.hasBadge, true); + service.info.tasks[0].claimStatus = 'claimed'; + assert.equal(service.hasBadge, false); +} +service.info.tasks[0].claimStatus = 'pending_unlock'; +assert.equal(service.hasBadge, true); +service.info = null; service.ledger.notices = { old: 100 }; +assert.equal(service.hasBadge, true); + +(async () => { + const browser = await chromium.launch({ headless: true, channel: 'msedge' }); + try { + const page = await browser.newPage({ viewport: { width: 450, height: 800 } }); + page.on('pageerror', error => console.error('Preview:', error.message)); + await page.goto('http://127.0.0.1:' + (process.env.PORT || 8879)); + await page.waitForFunction(() => window.demo && demo.ready); + await page.evaluate(() => { + const data = { serverNow: Date.now(), status: 'purchasable', state: { + config: { periodId: 'layout-test', startsAt: Date.now() - 1000, endsAt: Date.now() + 3600000, + priceCents: 300, stages: Array.from({ length: 5 }, (_, i) => ({ wins: i + 1, coins: 1000 })) }, + totalWins: 0, paidAt: 0, claimed: Array(5).fill(false), stage: 0 } }; + window.testService = { info: { ...data.state.config, status: data.status }, + settlementNotice: 0, serverNow: Date.now(), hasBadge: false, + readForDisplay: async () => data, refresh: async () => data, view: () => data, stopPolling() {} }; + cc.director.getScene().children[0].getComponent('CoinMadnessHost').resumeSync(); + }); + await page.evaluate(() => { demo.open(); }); + await page.waitForFunction(() => cc.director.getScene().children[0].getChildByName('CoinMadness')?.getComponent('CoinMadnessPanel')?.data); + for (const price of [3, 68, 128, 3]) { + const result = await page.evaluate(price => { + const panel = demo.panel(), data = JSON.parse(JSON.stringify(panel.data)); + data.state.config.priceCents = price * 100; + data.state.config.stages.forEach((s, i) => { s.coins = i ? 9999 : 45000; }); + data.state.paidAt = 0; data.status = 'purchasable'; + panel.render(data); + function bounds(digits) { + const boxes = digits.node.children.filter(n => n.active).map(n => { + const image = n.getChildByName('Image'); + return { left: n.x - image.width / 2, right: n.x + image.width / 2 }; + }); + return { left: boxes[0].left, right: boxes[boxes.length - 1].right, + separated: boxes.every((b, i) => !i || b.left >= boxes[i - 1].right) }; + } + const p = bounds(panel.priceDigits), total = bounds(panel.totalDigits); + return { total, price: p, priceRight: panel.priceDigits.node.x + p.right, + currencyLeft: panel.currency.x - panel.currency.width / 2, + currencyRight: panel.currency.x + panel.currency.width / 2, + buttonRight: panel.purchaseButton.width / 2, + rewards: panel.content.children.filter(n => n.active).map(n => bounds(n.getChildByName('Reward').getComponent('CoinMadnessDigits'))) }; + }, price); + assert(result.total.separated && result.total.right - result.total.left <= 164.01); + assert(result.price.separated && result.priceRight < result.currencyLeft); + assert(result.currencyRight < result.buttonRight); + assert(result.rewards.every(r => r.separated && r.right - r.left <= 170.01)); + fs.mkdirSync('.codex_tmp/number-layout', { recursive: true }); + await page.screenshot({ path: '.codex_tmp/number-layout/price-' + price + '.png' }); + } + console.log('PASS: 4/5-digit rewards, 1/2/3-digit prices, repeated layout and unpaid completion badge'); + } finally { await browser.close(); } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tools/coin-madness/preview.cjs b/tools/coin-madness/preview.cjs index 7b6ce45..7523ccf 100644 --- a/tools/coin-madness/preview.cjs +++ b/tools/coin-madness/preview.cjs @@ -55,7 +55,7 @@ const server = http.createServer((req,res)=>{ if(url==='/host.js'){ const source=fs.readFileSync(path.join(root,'assets/Script/coin_madness/CoinMadnessHost.ts'),'utf8'); const output=ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES5,module:ts.ModuleKind.CommonJS,experimentalDecorators:true}}).outputText; - res.setHeader('Content-Type','text/javascript');return res.end('(function(){var exports={};function require(name){if(name.includes("GoldMinerService"))return {default:{testEnabled:()=>!!window.testService?.testAvailable(),current:()=>window.testService||null}};if(name.includes("MiniGameSdk"))return {MiniGameSdk:{API:{showToast:console.log}}};return {default:{get:()=>({enqueue:(k,p,run)=>run(()=>{})})}};}cc._RF.push({},"CoinMadnessHost","CoinMadnessHost");'+output+';cc._RF.pop();})();'); + res.setHeader('Content-Type','text/javascript');return res.end('(function(){var exports={};function require(name){if(name.includes("GoldMinerService"))return {default:{testEnabled:()=>!!window.testService?.testAvailable(),current:()=>window.testService||null}};if(name.includes("Utils"))return {default:{serverUrl:()=>"fixture://"}};if(name.includes("MiniGameSdk"))return {MiniGameSdk:{API:{showToast:console.log}}};return {default:{get:()=>({enqueue:(k,p,run)=>run(()=>{})})}};}cc._RF.push({},"CoinMadnessHost","CoinMadnessHost");'+output+';cc._RF.pop();})();'); } if(/^\/native\/[a-f0-9]{2}\/[a-f0-9-]+\.png$/.test(url)){ const target=path.join(root,'library/imports',url.slice('/native/'.length));