import { _decorator, Button, Component, director, find, Label, Node, Sprite, SpriteFrame, UITransform, view, AudioSource, Color, Layers, tween, Tween, Vec3, Widget, } from 'cc'; import { EventManager, EventType } from '../core/EventManager'; import { GameManager } from '../manager/GameManager'; import { ViewController } from '../controller/ViewController'; import { LineGridRenderer } from '../gameplay/LineGridRenderer'; import { Movement } from '../gameplay/Movement'; import { GameAudio } from '../audio/GameAudio'; import { loadThemeCharacterPortrait, loadUIIcon, UIIconKey } from './UIStyleAssets'; import { getThemeHudIconScale, getThemePortraitFlipX, getThemePortraitScale, } from '../theme/ThemeRegistry'; import { DESIGN_HEIGHT, DESIGN_WIDTH } from '../core/GridConstants'; import { syncEmbeddedCamerasOrtho } from '../core/EmbeddedView'; import { spriteOriginalSize } from '../visual/EntityDisplayRefs'; const { ccclass } = _decorator; type IconSlot = { node: Node; sprite: Sprite }; type BtnLayout = { node: Node }; /** Unity UIMain/Right:VerticalLayout spacing=20, padding right=20 top=20, 按钮 165×165 */ const UNITY_BTN = 165; const UNITY_SPACING = 20; const UNITY_PAD_RIGHT = 20; const UNITY_PAD_TOP = 20; /** Unity UIMain/ImageBall:左上角角色肖像 194×194,距左 40、距顶 28 */ const UNITY_PORTRAIT = 194; const UNITY_PAD_LEFT = 40; const UNITY_PAD_TOP_PORTRAIT = 28; const REF_WIDTH = 2560; const scaleUi = (v: number) => v * (DESIGN_WIDTH / REF_WIDTH); const BTN_SIZE = Math.round(scaleUi(UNITY_BTN)); const BTN_SPACING = Math.round(scaleUi(UNITY_SPACING)); const PAD_RIGHT = Math.round(scaleUi(UNITY_PAD_RIGHT)); const PAD_TOP = Math.round(scaleUi(UNITY_PAD_TOP)); const PORTRAIT_SIZE = Math.round(scaleUi(UNITY_PORTRAIT)); const PAD_LEFT = Math.round(scaleUi(UNITY_PAD_LEFT)); const PAD_TOP_PORTRAIT = Math.round(scaleUi(UNITY_PAD_TOP_PORTRAIT)); /** 点击时短暂放大再回弹 */ const BTN_POP_SCALE = 1.14; const BTN_POP_UP = 0.07; const BTN_POP_DOWN = 0.13; const HUD_LAYER = Layers.Enum.UI_3D; /** 左上角肖像等比缩放系数(统一高度优先,与 Unity ImageBall 194 框一致) */ function portraitUniformScale(sf: SpriteFrame, scaleMul = 1): number { const max = PORTRAIT_SIZE * scaleMul; const { width: ow, height: oh } = spriteOriginalSize(sf); return Math.min(max / ow, max / oh); } /** 对齐 Unity UIMain:右侧 6 个圆形功能按钮(HUD 相机固定,不随关卡缩放) */ @ccclass('UIMain') export class UIMain extends Component { private static readonly SPEEDS = [1, 2, 4]; private speedIndex = 0; private audioMute = false; private textVisible = false; private nodePlaySpeed: IconSlot | null = null; private nodeAudio: IconSlot | null = null; private portraitSprite: Sprite | null = null; private textLabel: Label | null = null; private uiBuilt = false; private readonly buttons: BtnLayout[] = []; /** 忽略过期的异步贴图回调,避免重置关卡时 HUD 闪一下 */ private hudStyleGen = 0; static ensure(parent: Node): UIMain { parent.getChildByName('UIMain')?.destroy(); const root = new Node('UIMain'); root.parent = parent; root.layer = HUD_LAYER; return root.addComponent(UIMain); } static findInstance(): UIMain | null { const scene = director.getScene(); if (!scene) return null; for (const rootName of ['UIOverlay', 'GameRoot', 'Canvas']) { const ui = scene.getChildByName(rootName)?.getChildByName('UIMain')?.getComponent(UIMain); if (ui) return ui; } return null; } setMuted(mute: boolean) { if (this.audioMute === mute) return; this.audioMute = mute; this.applyAudioVolume(); void this.setAudioIcon(this.resolveHudTheme()); } onLoad() { const speed = UIMain.SPEEDS[this.speedIndex]; GameManager.instance?.setGameSpeed(speed); Movement.setSpeedMultiplier(speed); this.buildUI(); EventManager.register(EventType.LevelInit, this.onLevelInit); } onDestroy() { view.off('canvas-resize', this.layoutPanel, this); EventManager.remove(EventType.LevelInit, this.onLevelInit); } SetText(str: string) { this.setText(str); } SetTextActive(active: string) { this.setTextActive(active); } setText(str: string) { if (this.textLabel) { this.textLabel.string = str; if (this.textLabel.node.parent) { this.textLabel.node.parent.active = this.textVisible && str.length > 0; } } } setTextActive(active: string) { this.textVisible = active === 'true'; if (this.textLabel?.node.parent) { this.textLabel.node.parent.active = this.textVisible && (this.textLabel.string?.length ?? 0) > 0; } } refreshStyle(uiStyle?: string) { const style = uiStyle ?? this.resolveHudTheme(); const gen = ++this.hudStyleGen; void this.setPlaySpeedSprite(style, gen); void this.setAudioIcon(style, gen); const keys: UIIconKey[] = ['navigation', 'revert', 'zoomIn', 'zoomOut']; const names = ['NodeNavigation', 'NodeRevert', 'NodeZoomIn', 'NodeZoomOut']; names.forEach((name, i) => { const slot = this.node.getChildByName(name)?.getChildByName('Icon')?.getComponent(Sprite); if (!slot) return; void loadUIIcon(style, keys[i]).then((sf) => { if (gen !== this.hudStyleGen || !sf || !slot.isValid) return; slot.spriteFrame = sf; }); }); this.applyHudIconScale(style); void this.refreshThemePortrait(style, gen); } /** Unity redArmy 等主题的按钮图标缩放 */ private applyHudIconScale(themeId?: string) { const { x, y } = getThemeHudIconScale(themeId); for (const { node } of this.buttons) { const icon = node.getChildByName('Icon'); if (icon?.isValid) icon.setScale(x, y, 1); } } private buildUI() { if (this.uiBuilt) { this.layoutPanel(); return; } this.uiBuilt = true; const rootUi = this.node.getComponent(UITransform) ?? this.node.addComponent(UITransform); rootUi.setAnchorPoint(1, 1); this.setupButtonColumnWidget(); const specs: { name: string; onClick: () => void }[] = [ { name: 'NodeNavigation', onClick: () => this.onToggleLineGrid() }, { name: 'NodeRevert', onClick: () => this.onRevertGame() }, { name: 'NodePlaySpeed', onClick: () => this.onPlaySpeedChange() }, { name: 'NodeZoomIn', onClick: () => this.onZoomIn() }, { name: 'NodeZoomOut', onClick: () => this.onZoomOut() }, { name: 'NodeAudio', onClick: () => this.onAudioMute() }, ]; this.buttons.length = 0; for (const spec of specs) { const slot = this.ensureIconButton(spec.name, spec.onClick); if (spec.name === 'NodePlaySpeed') this.nodePlaySpeed = slot; if (spec.name === 'NodeAudio') this.nodeAudio = slot; } this.buildTextArea(); this.buildThemePortrait(); this.node.setSiblingIndex(this.node.parent!.children.length - 1); this.layoutPanel(); view.on('canvas-resize', this.layoutPanel, this); this.refreshStyle(this.resolveHudTheme()); } /** HUD 贴图始终跟当前关卡主题,避免倍速/音量误用 uiStyle 默认 silu */ private resolveHudTheme(): string | undefined { return GameManager.instance?.getCurLevel()?.theme ?? GameManager.instance?.uiStyle; } private syncOverlaySize() { const vis = view.getVisibleSize(); const overlay = this.node.parent; const ui = overlay?.getComponent(UITransform); if (!ui) return; if (ui.contentSize.width !== vis.width || ui.contentSize.height !== vis.height) { ui.setContentSize(vis.width, vis.height); } this.ensureOverlayWidget(overlay); } private ensureOverlayWidget(overlay: Node) { const widget = overlay.getComponent(Widget) ?? overlay.addComponent(Widget); widget.isAlignTop = true; widget.isAlignBottom = true; widget.isAlignLeft = true; widget.isAlignRight = true; widget.top = 0; widget.bottom = 0; widget.left = 0; widget.right = 0; widget.alignMode = Widget.AlignMode.ON_WINDOW_RESIZE; widget.updateAlignment(); } private setupButtonColumnWidget() { const widget = this.node.getComponent(Widget) ?? this.node.addComponent(Widget); widget.isAlignRight = true; widget.isAlignTop = true; widget.right = PAD_RIGHT; widget.top = PAD_TOP; widget.alignMode = Widget.AlignMode.ON_WINDOW_RESIZE; } private setupPortraitWidget(portrait: Node) { const widget = portrait.getComponent(Widget) ?? portrait.addComponent(Widget); widget.isAlignTop = true; widget.isAlignLeft = true; widget.top = PAD_TOP_PORTRAIT; widget.left = PAD_LEFT; widget.alignMode = Widget.AlignMode.ON_WINDOW_RESIZE; } private layoutPanel = () => { syncEmbeddedCamerasOrtho(); this.syncOverlaySize(); const count = this.buttons.length; const totalHeight = count > 0 ? count * BTN_SIZE + (count - 1) * BTN_SPACING : 0; const rootUi = this.node.getComponent(UITransform)!; rootUi.setContentSize(BTN_SIZE, totalHeight); const btnX = -BTN_SIZE * 0.5; for (let i = 0; i < count; i++) { const y = -BTN_SIZE * 0.5 - i * (BTN_SIZE + BTN_SPACING); this.buttons[i].node.setPosition(btnX, y, 0); } this.node.getComponent(Widget)?.updateAlignment(); const h = view.getVisibleSize().height || DESIGN_HEIGHT; const texts = this.node.parent?.getChildByName('NodeTexts'); if (texts) texts.setPosition(0, -h * 0.5 + 36, 0); const portrait = this.portraitSprite?.node.parent ?? this.node.parent?.getChildByName('ImageBall'); portrait?.getComponent(Widget)?.updateAlignment(); }; private ensureIconButton(name: string, onClick: () => void): IconSlot { let btnNode = this.node.getChildByName(name); if (!btnNode) { btnNode = new Node(name); btnNode.parent = this.node; } btnNode.layer = HUD_LAYER; btnNode.active = true; const ui = btnNode.getComponent(UITransform) ?? btnNode.addComponent(UITransform); ui.setContentSize(BTN_SIZE, BTN_SIZE); ui.setAnchorPoint(0.5, 0.5); let iconNode = btnNode.getChildByName('Icon'); if (!iconNode) { iconNode = new Node('Icon'); iconNode.parent = btnNode; } iconNode.layer = HUD_LAYER; const iconUi = iconNode.getComponent(UITransform) ?? iconNode.addComponent(UITransform); iconUi.setContentSize(BTN_SIZE, BTN_SIZE); const sprite = iconNode.getComponent(Sprite) ?? iconNode.addComponent(Sprite); sprite.sizeMode = Sprite.SizeMode.CUSTOM; const btn = btnNode.getComponent(Button) ?? btnNode.addComponent(Button); btn.transition = Button.Transition.NONE; btn.target = iconNode; btnNode.off(Button.EventType.CLICK); btnNode.on(Button.EventType.CLICK, () => { GameAudio.resumeAll(); this.playClickPop(btnNode); onClick(); }, this); this.buttons.push({ node: btnNode }); return { node: btnNode, sprite }; } /** 点击反馈:只缩放 Icon,避免整颗按钮放大遮挡下方倍速键 */ private playClickPop(target: Node) { if (!target.isValid) return; const popTarget = target.getChildByName('Icon') ?? target; const { x, y } = getThemeHudIconScale(this.resolveHudTheme()); const base = new Vec3(x, y, 1); Tween.stopAllByTarget(popTarget); popTarget.setScale(base); const pop = new Vec3(x * BTN_POP_SCALE, y * BTN_POP_SCALE, 1); tween(popTarget) .to(BTN_POP_UP, { scale: pop }, { easing: 'quadOut' }) .to(BTN_POP_DOWN, { scale: base }, { easing: 'backOut' }) .start(); } /** 对齐 Unity UIMain/ImageBall:关卡主题角色待机贴图 */ private buildThemePortrait() { const overlay = this.node.parent; if (!overlay) return; let portrait = overlay.getChildByName('ImageBall'); if (!portrait) { portrait = new Node('ImageBall'); portrait.parent = overlay; } portrait.layer = HUD_LAYER; portrait.active = true; portrait.getComponent(Sprite)?.destroy(); const ui = portrait.getComponent(UITransform) ?? portrait.addComponent(UITransform); ui.setAnchorPoint(0, 1); let icon = portrait.getChildByName('Portrait'); if (!icon) { icon = new Node('Portrait'); icon.parent = portrait; } icon.layer = HUD_LAYER; const iconUi = icon.getComponent(UITransform) ?? icon.addComponent(UITransform); iconUi.setAnchorPoint(0, 1); const sprite = icon.getComponent(Sprite) ?? icon.addComponent(Sprite); sprite.sizeMode = Sprite.SizeMode.TRIMMED; this.portraitSprite = sprite; this.setupPortraitWidget(portrait); } private applyPortraitSprite(sf: SpriteFrame, themeId?: string) { if (!this.portraitSprite?.isValid) return; const icon = this.portraitSprite.node; const container = icon.parent; if (!container?.isValid) return; const theme = themeId ?? this.resolveHudTheme(); const s = portraitUniformScale(sf, getThemePortraitScale(theme)); const { width: ow, height: oh } = spriteOriginalSize(sf); const flipX = getThemePortraitFlipX(theme); const dispW = Math.round(ow * s); const dispH = Math.round(oh * s); const iconUi = icon.getComponent(UITransform) ?? icon.addComponent(UITransform); this.portraitSprite.spriteFrame = sf; if (flipX) { // 绕右缘翻转,避免 scaleX<0 时贴图画到屏幕左侧外 iconUi.setAnchorPoint(1, 1); icon.setPosition(dispW, 0, 0); icon.setScale(-s, s, 1); } else { iconUi.setAnchorPoint(0, 1); icon.setPosition(0, 0, 0); icon.setScale(s, s, 1); } const containerUi = container.getComponent(UITransform) ?? container.addComponent(UITransform); containerUi.setAnchorPoint(0, 1); containerUi.setContentSize(dispW, dispH); container.getComponent(Widget)?.updateAlignment(); } private async refreshThemePortrait(themeId?: string, gen = this.hudStyleGen) { if (!this.portraitSprite) return; const gm = GameManager.instance; const theme = themeId ?? gm?.getCurLevel()?.theme ?? gm?.uiStyle; const options = gm?.getEntityVisualOptions() ?? { theme }; const sf = await loadThemeCharacterPortrait({ ...options, theme: options.theme ?? theme }); if (gen !== this.hudStyleGen || !sf || !this.portraitSprite?.isValid) return; this.applyPortraitSprite(sf, theme); } private buildTextArea() { let texts = this.node.parent?.getChildByName('NodeTexts'); if (!texts) { texts = new Node('NodeTexts'); texts.parent = this.node.parent!; texts.layer = HUD_LAYER; texts.addComponent(UITransform).setContentSize(400, 48); texts.active = false; const labelNode = new Node('Text1'); labelNode.parent = texts; labelNode.addComponent(UITransform).setContentSize(400, 48); this.textLabel = labelNode.addComponent(Label); this.textLabel.fontSize = 22; this.textLabel.color = new Color(255, 240, 200); this.textLabel.horizontalAlign = Label.HorizontalAlign.CENTER; } else { this.textLabel = texts.getChildByName('Text1')?.getComponent(Label) ?? null; } } private onToggleLineGrid() { const entrance = this.resolveLevelEntrance(); if (!entrance) { console.warn('[UIMain] 未找到 MainLevelEntrance,无法切换网格'); return; } const grid = LineGridRenderer.instance?.isValid ? LineGridRenderer.instance : LineGridRenderer.ensure(entrance); grid.toggleGridVisibility(); console.log(`[UIMain] 导航网格 ${grid.isGridVisible() ? '显示' : '隐藏'}`); } private resolveLevelEntrance(): Node | null { const gm = GameManager.instance; if (gm?.mainLevelEntrance?.isValid) return gm.mainLevelEntrance; const scene = director.getScene(); if (!scene) return null; return find('MainLevelEntrance', scene) ?? find('GameRoot/MainLevelEntrance', scene); } private onRevertGame() { this.speedIndex = 0; GameManager.instance?.setGameSpeed(UIMain.SPEEDS[this.speedIndex]); GameManager.instance?.resetLevel(); void this.setPlaySpeedSprite(this.resolveHudTheme()); } private onPlaySpeedChange() { this.speedIndex = (this.speedIndex + 1) % UIMain.SPEEDS.length; const speed = UIMain.SPEEDS[this.speedIndex]; GameManager.instance?.setGameSpeed(speed); console.log(`[UIMain] 倍速 x${speed}`); void this.setPlaySpeedSprite(this.resolveHudTheme()); } private onZoomIn() { ViewController.instance?.zoomIn(); } private onZoomOut() { ViewController.instance?.zoomOut(); } private onAudioMute() { this.audioMute = !this.audioMute; this.applyAudioVolume(); void this.setAudioIcon(this.resolveHudTheme()); } private async setPlaySpeedSprite(uiStyle?: string, gen = this.hudStyleGen) { const keys: UIIconKey[] = ['speed1', 'speed2', 'speed4']; const icon = keys[this.speedIndex]; const sf = await loadUIIcon(uiStyle ?? this.resolveHudTheme(), icon); if (gen !== this.hudStyleGen || !sf || !this.nodePlaySpeed) return; this.nodePlaySpeed.sprite.spriteFrame = sf; } private async setAudioIcon(uiStyle?: string, gen = this.hudStyleGen) { const icon: UIIconKey = this.audioMute ? 'audioOff' : 'audioOn'; const sf = await loadUIIcon(uiStyle ?? this.resolveHudTheme(), icon); if (gen !== this.hudStyleGen || !sf || !this.nodeAudio) return; this.nodeAudio.sprite.spriteFrame = sf; } private applyAudioVolume() { const vol = this.audioMute ? 0 : 1; const scene = director.getScene(); if (!scene) return; for (const src of scene.getComponentsInChildren(AudioSource)) { src.volume = vol; } } private onLevelInit = () => { this.setText(''); this.applyAudioVolume(); }; }