Files
cocos/assets/scripts/ui/UIMain.ts
刘宇飞 d393302388 Complete Cocos Creator port with level bundles, themes, and tooling.
Adds level prefabs, theme assets, audio, extensions, and deployment scripts for the Unity WebGL migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 15:30:58 +08:00

503 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/RightVerticalLayout 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();
};
}