diff --git a/assets/Script/JiaZai.ts b/assets/Script/JiaZai.ts index 418b05a..2cac022 100644 --- a/assets/Script/JiaZai.ts +++ b/assets/Script/JiaZai.ts @@ -20,6 +20,7 @@ import { initProvinceLocator } from "./module/Position/GetPosition"; import LoadingCatAnimation from "./LoadingCatAnimation"; import BlockAssetManager from "./module/Tool/BlockAssetManager"; import MiniProgramBenefitsBridge from "./module/MiniProgramBenefitsBridge"; +import { onHomeButtonTouchEnd, vibrateHomeButton } from "./module/Tool/HomeButtonVibration"; const { ccclass, property } = cc._decorator; @ccclass @@ -46,6 +47,7 @@ export default class JiaZai extends cc.Component { } // 新的主动操作取代尚未打开的按需弹窗;资源下载仍正常完成并写入缓存。 this.homePopupOpenRequest++; + if (vibrateHomeButton(event)) return; try { cc.fx.GameTool.setVibrate("light", 1); } catch (error) { @@ -366,6 +368,14 @@ export default class JiaZai extends cc.Component { MiniGameSdk.API.showToast("当前设备内存不足,排行榜暂不可用"); } + onEnable() { + this.node.on(cc.Node.EventType.TOUCH_END, onHomeButtonTouchEnd, this, true); + } + + onDisable() { + this.node.off(cc.Node.EventType.TOUCH_END, onHomeButtonTouchEnd, this, true); + } + onLoad() { // 首页在进关前展示连胜;覆盖登录或缓存恢复的测试初始值。 if (cc.fx.GameConfig.forceOldRainbowWinStreakC || cc.fx.GameConfig.forceRainbowWinStreakB) { diff --git a/assets/Script/Map.ts b/assets/Script/Map.ts index b48ea3c..6cdf548 100644 --- a/assets/Script/Map.ts +++ b/assets/Script/Map.ts @@ -1022,7 +1022,7 @@ export default class MapConroler extends cc.Component { const hand = child.getChildByName('hand'); if (!hand) return; const animation = hand.getComponent(GuideHand) || hand.addComponent(GuideHand); - animation.rippleMaterial = rippleSprite.sharedMaterials[0]; + animation.rippleMaterial = rippleSprite.getMaterial(0); }); this.propGuideMask = guide; guide.parent = this.node.parent; diff --git a/assets/Script/module/MiniProgramBenefitsBridge.ts b/assets/Script/module/MiniProgramBenefitsBridge.ts index 4574c0f..5309a23 100644 --- a/assets/Script/module/MiniProgramBenefitsBridge.ts +++ b/assets/Script/module/MiniProgramBenefitsBridge.ts @@ -1,5 +1,6 @@ import LoadingCatAnimation from "../LoadingCatAnimation"; import SevenDayGiftBootstrapApi from "../seven_day_gift/SevenDayGiftBootstrapApi"; +import { vibrateHomeButton } from "./Tool/HomeButtonVibration"; declare const wx: any; @@ -18,7 +19,7 @@ export default class MiniProgramBenefitsBridge { private entryRed: cc.Node = null; private entryFrame: cc.SpriteFrame = null; private entryVisual: cc.Node = null; - private onEntryTouch = (): void => { this.open(); }; + private onEntryTouch = (event: cc.Event): void => { vibrateHomeButton(event); this.open(); }; private session: any = null; private rewardRequests = 0; private testMount = 0; diff --git a/assets/Script/module/Tool/HomeButtonVibration.ts b/assets/Script/module/Tool/HomeButtonVibration.ts new file mode 100644 index 0000000..dd75d71 --- /dev/null +++ b/assets/Script/module/Tool/HomeButtonVibration.ts @@ -0,0 +1,34 @@ +const handledEvents = new WeakSet(); + +/** 返回是否由首页统一处理;旧入口在其他场景仍可保留原有行为。 */ +export function vibrateHomeButton(event?: cc.Event): boolean { + if (!event) return false; + const scene = cc.director.getScene(); + if (!scene || scene.name !== "HomeScene") return false; + if (handledEvents.has(event)) return true; + handledEvents.add(event); + try { + cc.fx.GameTool.setVibrate("light", 1); + } catch (error) { + console.warn("HomeScene 按钮轻振动调用失败:", error); + } + return true; +} + +/** 根节点捕获触摸,自动覆盖后来挂入首页的 Bundle 按钮。 */ +export function onHomeButtonTouchEnd(event: cc.Event.EventTouch): void { + // 引擎可能复用事件对象,每次派发重新去重。 + handledEvents.delete(event); + let target = event.target as cc.Node; + while (target) { + const button = target.getComponent(cc.Button); + if (button) { + if (button.interactable && button.enabledInHierarchy && (button as any)._pressed) { + vibrateHomeButton(event); + } + return; + } + if (target === event.currentTarget) return; + target = target.parent; + } +} diff --git a/assets/Script/module/Tool/HomeButtonVibration.ts.meta b/assets/Script/module/Tool/HomeButtonVibration.ts.meta new file mode 100644 index 0000000..35d0a1f --- /dev/null +++ b/assets/Script/module/Tool/HomeButtonVibration.ts.meta @@ -0,0 +1,10 @@ +{ + "ver": "1.1.0", + "uuid": "b9f1f6e3-d1e1-4a3d-a1a6-f3089fd502dd", + "importer": "typescript", + "isPlugin": false, + "loadPluginInWeb": true, + "loadPluginInNative": true, + "loadPluginInEditor": false, + "subMetas": {} +} \ No newline at end of file diff --git a/assets/career/script/CareerManager2.ts b/assets/career/script/CareerManager2.ts index e659ef7..18e0835 100644 --- a/assets/career/script/CareerManager2.ts +++ b/assets/career/script/CareerManager2.ts @@ -1,4 +1,5 @@ import NumberToImage from "../../Script/NumberToImage"; +import { vibrateHomeButton } from "../../Script/module/Tool/HomeButtonVibration"; import CareerList2 from "./CareerList2"; import CareerItem2 from "./CareerItem2"; @@ -12,6 +13,7 @@ export default class CareerManager2 extends cc.Component { if (!event) { return; } + if (vibrateHomeButton(event)) return; try { cc.fx.GameTool.setVibrate("light", 1); } catch (error) { diff --git a/assets/jungle_treasure/script/JungleOver.ts b/assets/jungle_treasure/script/JungleOver.ts index 7a97493..fe89422 100644 --- a/assets/jungle_treasure/script/JungleOver.ts +++ b/assets/jungle_treasure/script/JungleOver.ts @@ -1,3 +1,5 @@ +import { vibrateHomeButton } from "../../Script/module/Tool/HomeButtonVibration"; + const { ccclass, property } = cc._decorator; type RewardType = "hammer" | "freeze" | "magic_wand" | "coin" | "infinite_health"; @@ -59,10 +61,11 @@ export default class JungleOver extends cc.Component { this.confirming = confirming; } - private onClose(): void { + private onClose(event?: cc.Event): void { if (this.confirming) { return; } + vibrateHomeButton(event); this.confirming = true; this.node.emit("jungle-over-close"); } diff --git a/assets/mini_program_benefits/script/MiniProgramBenefitsPanel.ts b/assets/mini_program_benefits/script/MiniProgramBenefitsPanel.ts index a038b30..fdda6ce 100644 --- a/assets/mini_program_benefits/script/MiniProgramBenefitsPanel.ts +++ b/assets/mini_program_benefits/script/MiniProgramBenefitsPanel.ts @@ -1,4 +1,5 @@ import MiniProgramBenefitsReward from "./MiniProgramBenefitsReward"; +import { vibrateHomeButton } from "../../Script/module/Tool/HomeButtonVibration"; const { ccclass, property } = cc._decorator; const TASKS = ["favorite_entry", "desktop_entry"]; @@ -37,18 +38,22 @@ export default class MiniProgramBenefitsPanel extends cc.Component { const key = index === 0 ? "list" : TASKS[index - 1]; const view = this.views[key] = cc.instantiate(prefab); view.parent = this.pages; const content = view.getChildByName("Content") || view; - content.getChildByName("Close").on(cc.Node.EventType.TOUCH_END, () => { + content.getChildByName("Close").on(cc.Node.EventType.TOUCH_END, (event: cc.Event) => { if (this.closing || this.busy) return; + vibrateHomeButton(event); if (key === "list") this.closeWithAnimation(); else { this.page = "list"; this.message = ""; this.render(); } }); if (key === "list") TASKS.forEach((task, i) => { const row = view.getChildByName("Task" + i); - row.getChildByName("View").on(cc.Node.EventType.TOUCH_END, () => { + row.getChildByName("View").on(cc.Node.EventType.TOUCH_END, (event: cc.Event) => { if (this.busy || this.closing) return; + vibrateHomeButton(event); this.page = task; this.message = ""; this.render(); }); - row.getChildByName("Claim").on(cc.Node.EventType.TOUCH_END, () => this.claim(task)); + row.getChildByName("Claim").on(cc.Node.EventType.TOUCH_END, (event: cc.Event) => { + this.claim(task, event); + }); }); }); this.unsubscribe = host.subscribe(() => { if (!this.busy) this.render(); }); @@ -155,8 +160,9 @@ export default class MiniProgramBenefitsPanel extends cc.Component { cc.tween(overlay).delay(1.08).call(() => { ready = true; }).start(); }); } - private async claim(task: string): Promise { + private async claim(task: string, event?: cc.Event): Promise { if (this.closing || this.busy || this.claiming) return; + vibrateHomeButton(event); const token = this.generation; this.claiming = true; this.busy = true; this.message = "正在领取…"; this.render(); try { diff --git a/assets/seven_day_gift/SevenDayGift.ts b/assets/seven_day_gift/SevenDayGift.ts index fd9a497..945b517 100644 --- a/assets/seven_day_gift/SevenDayGift.ts +++ b/assets/seven_day_gift/SevenDayGift.ts @@ -3,6 +3,7 @@ import SevenDayGiftRuntime from "./SevenDayGiftRuntime"; import ActivityPopupAnimator, { ActivityPopupAnimationType } from "../Script/ActivityPopupAnimator"; import RewardClaimEffect from "../reward_claim_effect/RewardClaimEffect"; import SevenDayHitTest from "./SevenDayHitTest"; +import { vibrateHomeButton } from "../Script/module/Tool/HomeButtonVibration"; const { ccclass, property } = cc._decorator; const TOTAL_DAYS = 7; @@ -170,7 +171,7 @@ export default class SevenDayGift extends cc.Component { while (hit && hit !== this.panel) { const index = this.cards.indexOf(hit); if (index >= 0) { - this.onCardClicked(index + 1); + this.onCardClicked(index + 1, event); return; } hit = hit.parent; @@ -234,7 +235,7 @@ export default class SevenDayGift extends cc.Component { } // 只允许领取当前可领天数,其他卡片点击无效。 - private onCardClicked(day: number) { + private onCardClicked(day: number, event?: cc.Event) { const reward = this.findReward(day); if (reward && reward.claimed) { return; @@ -244,6 +245,7 @@ export default class SevenDayGift extends cc.Component { } if (this.claiming || !this.claimHandler) return; + vibrateHomeButton(event); this.requestCurrentReward(); } diff --git a/tools/test-home-button-vibration.cjs b/tools/test-home-button-vibration.cjs new file mode 100644 index 0000000..6a83b13 --- /dev/null +++ b/tools/test-home-button-vibration.cjs @@ -0,0 +1,145 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const ts = require('typescript'); + +function evaluate(source, globals) { + return vm.runInNewContext(ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2017, module: ts.ModuleKind.CommonJS }, + }).outputText, globals); +} + +function method(file, name, globals) { + const source = ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true); + let found; + function visit(node) { + if (ts.isMethodDeclaration(node) && node.name.getText(source) === name) found = node; + else ts.forEachChild(node, visit); + } + visit(source); + assert.ok(found, `${file}: ${name}`); + return evaluate(`({ ${found.getText(source).replace(/^private\s+/, '')} }).${name};`, globals); +} + +function fixture() { + const state = { scene: { name: 'HomeScene' }, pulses: [], warnings: [] }; + const cc = { Button: 'Button', Node: { EventType: { TOUCH_END: 'touchend' } }, + director: { getScene: () => state.scene }, fx: { GameConfig: { GM_INFO: { vibrateOpen: true } } } }; + const wx = { vibrateShort: options => state.pulses.push(options.type) }; + cc.fx.GameTool = { setVibrate: method('assets/Script/module/Tool/GameTool.ts', 'setVibrate', { cc, wx }) }; + const globals = { cc, exports: {}, console: { warn: (...args) => state.warnings.push(args) } }; + evaluate(fs.readFileSync('assets/Script/module/Tool/HomeButtonVibration.ts', 'utf8'), globals); + Object.assign(globals, globals.exports); + const node = (parent = null, button = null) => ({ parent, getComponent: () => button }); + const root = node(); + const button = { interactable: true, enabledInHierarchy: true, _pressed: true }; + const target = node(root, button); + const event = { target, currentTarget: root }; + return { state, cc, wx, globals, node, root, button, event, ...globals.exports }; +} + +test('existing and late-mounted bundle buttons vibrate once, including child hit targets', () => { + const f = fixture(); + f.onHomeButtonTouchEnd(f.event); + f.vibrateHomeButton(f.event); + assert.deepEqual(f.state.pulses, ['light']); + const popup = f.node(f.root); + const dynamicButton = f.node(popup, { ...f.button }); + f.onHomeButtonTouchEnd({ target: f.node(dynamicButton), currentTarget: f.root }); + assert.deepEqual(f.state.pulses, ['light', 'light']); +}); + +test('disabled buttons, cancelled presses and empty backgrounds do not vibrate', () => { + for (const property of ['interactable', 'enabledInHierarchy', '_pressed']) { + const f = fixture(); f.button[property] = false; + f.onHomeButtonTouchEnd(f.event); + assert.equal(f.state.pulses.length, 0, property); + } + const f = fixture(); + f.onHomeButtonTouchEnd({ target: f.node(f.root), currentTarget: f.root }); + assert.equal(f.state.pulses.length, 0); +}); + +test('reused event objects vibrate on each new dispatch, but never twice per dispatch', () => { + const f = fixture(); + for (let i = 0; i < 2; i++) { + f.onHomeButtonTouchEnd(f.event); + f.vibrateHomeButton(f.event); + assert.equal(f.state.pulses.length, i + 1); + } +}); + +test('respects the vibration switch and leaves GameScene and other scenes untouched', () => { + const f = fixture(); + f.cc.fx.GameConfig.GM_INFO.vibrateOpen = false; + f.onHomeButtonTouchEnd(f.event); + assert.equal(f.state.pulses.length, 0); + f.cc.fx.GameConfig.GM_INFO.vibrateOpen = true; + for (const scene of [{ name: 'GameScene' }, { name: 'LoadScene' }, null]) { + f.state.scene = scene; + f.onHomeButtonTouchEnd(f.event); + assert.equal(f.vibrateHomeButton({ target: f.event.target }), false); + assert.equal(f.state.pulses.length, 0); + } +}); + +test('legacy Home and Career callbacks do not double vibrate or alter popup request cancellation', () => { + const f = fixture(); + const homeClick = method('assets/Script/JiaZai.ts', 'vibrateButtonClick', f.globals); + const careerClick = method('assets/career/script/CareerManager2.ts', 'vibrateButtonClick', f.globals); + const home = { homePopupOpenRequest: 7 }; + f.onHomeButtonTouchEnd(f.event); + homeClick.call(home, f.event); + careerClick(f.event); + assert.equal(home.homePopupOpenRequest, 8); + assert.deepEqual(f.state.pulses, ['light']); + homeClick.call(home); + assert.equal(home.homePopupOpenRequest, 8); + f.state.scene = { name: 'GameScene' }; + careerClick({}); + assert.deepEqual(f.state.pulses, ['light', 'light'], 'preserves the legacy callback outside Home'); +}); + +test('vibration API errors do not interrupt click handling or cause a duplicate attempt', () => { + const f = fixture(); let attempts = 0; + f.wx.vibrateShort = () => { attempts++; throw Error('unavailable'); }; + assert.doesNotThrow(() => f.onHomeButtonTouchEnd(f.event)); + assert.doesNotThrow(() => f.vibrateHomeButton(f.event)); + assert.equal(attempts, 1); + assert.equal(f.state.warnings.length, 1); +}); + +test('Home component registers capture on enable and removes the same listener on disable', () => { + const f = fixture(); const calls = []; + const home = { node: { on: (...args) => calls.push(['on', ...args]), off: (...args) => calls.push(['off', ...args]) } }; + method('assets/Script/JiaZai.ts', 'onEnable', f.globals).call(home); + method('assets/Script/JiaZai.ts', 'onDisable', f.globals).call(home); + assert.equal(calls[0][0], 'on'); assert.equal(calls[1][0], 'off'); + assert.deepEqual(calls[0].slice(1), ['touchend', f.onHomeButtonTouchEnd, home, true]); + assert.deepEqual(calls[0].slice(1), calls[1].slice(1)); +}); + +test('touch-only Jungle close vibrates once and preserves confirmation guarding', () => { + const f = fixture(); let closes = 0; + const close = method('assets/jungle_treasure/script/JungleOver.ts', 'onClose', f.globals); + const panel = { confirming: false, node: { emit: () => closes++ } }; + const event = { target: f.node(f.root), currentTarget: f.root }; + f.onHomeButtonTouchEnd(event); + assert.equal(f.state.pulses.length, 0); + close.call(panel, event); close.call(panel, event); + assert.equal(closes, 1); assert.deepEqual(f.state.pulses, ['light']); +}); + +test('seven-day cards only vibrate when the reward can be claimed', () => { + const f = fixture(); let claims = 0; + const click = method('assets/seven_day_gift/SevenDayGift.ts', 'onCardClicked', f.globals); + const panel = { findReward: () => ({ claimed: false }), info: { canClaim: true, nextRewardDay: 2 }, + claiming: false, claimHandler() {}, requestCurrentReward: () => claims++ }; + click.call(panel, 1, {}); + assert.equal(f.state.pulses.length, 0); + click.call(panel, 2, {}); + assert.equal(claims, 1); assert.deepEqual(f.state.pulses, ['light']); + panel.claiming = true; click.call(panel, 2, {}); + assert.equal(claims, 1); assert.equal(f.state.pulses.length, 1); +}); diff --git a/tools/test-prop-guide.cjs b/tools/test-prop-guide.cjs index 0e28f7b..147be0f 100644 --- a/tools/test-prop-guide.cjs +++ b/tools/test-prop-guide.cjs @@ -73,7 +73,9 @@ function inflate(data, id) { for (const ref of raw._components || []) { const component = data[ref.__id__]; node.components[component.__type__] = { ...component, updateAlignment() {}, setState() {} }; - if (component.__type__ === 'cc.Sprite') node.components[component.__type__].sharedMaterials = component._materials; + if (component.__type__ === 'cc.Sprite') { + node.components[component.__type__].getMaterial = index => component._materials[index] || null; + } } for (const ref of raw._children || []) inflate(data, ref.__id__).parent = node; return node; @@ -144,6 +146,9 @@ for (const [level, name, amount] of [[8, 'destroyBtn', 'hammerAmount'], [11, 'ti map.showPropGuideMask(level); assert.equal(map.propGuideMask, guide); const activeButton = buttons.find(button => button.active); + const rippleSprite = gameNode.getChildByName('Bottom').getChildByName('rippleShrink').getComponent('cc.Sprite'); + assert.ok(rippleSprite.getMaterial(0)); + assert.equal(activeButton.getChildByName('hand').getComponent('GuideHand').rippleMaterial, rippleSprite.getMaterial(0)); assert.equal(activeButton.getComponent('cc.Button').clickEvents[0].customEventData, 'guide'); activeButton.click(); assert.equal(map.propGuideMask, null); diff --git a/tools/tests/mini-program-benefits-client.test.cjs b/tools/tests/mini-program-benefits-client.test.cjs index bbc58df..56fca13 100644 --- a/tools/tests/mini-program-benefits-client.test.cjs +++ b/tools/tests/mini-program-benefits-client.test.cjs @@ -60,6 +60,7 @@ function environment() { if(name==='../LoadingCatAnimation')return {default:{play:node=>{node.active=true;return true},stop:node=>{node.active=false}}}; if(name==='./MiniProgramBenefitsReward')return {default:class {request(){throw Error('Unexpected network adapter call in UI test');}}}; if(name.endsWith('/MiniProgramBenefitsBridge'))return {default:Bridge}; + if(name.endsWith('/HomeButtonVibration'))return {vibrateHomeButton() {}}; throw new Error('Unexpected dependency: '+name); },cc,wx,window:{wx},console,setTimeout,clearTimeout,Promise,Set},{filename:file}); return module.exports.default;