fix: fit activity amounts and show unpaid completion badge

This commit is contained in:
keke 2026-09-30 22:05:58 +08:00
parent 96a5e1acf9
commit 7fb430e512
6 changed files with 111 additions and 13 deletions

View File

@ -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<any> {

View File

@ -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。

View File

@ -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;
}
}

View File

@ -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();

View File

@ -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; });

View File

@ -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));