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>
This commit is contained in:
2026-06-16 15:30:58 +08:00
parent cba5105908
commit d393302388
6248 changed files with 17322729 additions and 11036 deletions

View File

@@ -1,20 +1,502 @@
import { _decorator, Component } from 'cc';
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;
/** 对应 Unity UIMainJS 可 SendMessage("UIMain", "SetText", ...) */
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 textContent = '';
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) {
this.textContent = str;
console.log('[UIMain]', str);
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();
};
}