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:
151
assets/scripts/ui/GameplayDebugBar.ts
Normal file
151
assets/scripts/ui/GameplayDebugBar.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
_decorator, Component, Node, Label, EditBox, Button, UITransform, Color, Widget, Graphics, Layers,
|
||||
} from 'cc';
|
||||
import { EDITOR, PREVIEW } from 'cc/env';
|
||||
import { GameManager } from '../manager/GameManager';
|
||||
|
||||
const { ccclass } = _decorator;
|
||||
|
||||
const HUD_LAYER = Layers.Enum.UI_3D;
|
||||
|
||||
/** 预览模式下调试条(对齐 Unity Editor/TestPlayer) */
|
||||
@ccclass('GameplayDebugBar')
|
||||
export class GameplayDebugBar extends Component {
|
||||
private stepBox: EditBox | null = null;
|
||||
|
||||
static shouldShow(): boolean {
|
||||
return EDITOR && PREVIEW;
|
||||
}
|
||||
|
||||
static ensure(parent: Node): GameplayDebugBar | null {
|
||||
if (!GameplayDebugBar.shouldShow()) return null;
|
||||
let bar = parent.getChildByName('GameplayDebugBar');
|
||||
if (!bar) {
|
||||
bar = new Node('GameplayDebugBar');
|
||||
bar.parent = parent;
|
||||
bar.layer = HUD_LAYER;
|
||||
bar.addComponent(GameplayDebugBar);
|
||||
}
|
||||
bar.setSiblingIndex(parent.children.length - 1);
|
||||
console.log('[GameplayDebugBar] 已显示(左下角)');
|
||||
return bar.getComponent(GameplayDebugBar)!;
|
||||
}
|
||||
|
||||
onLoad() {
|
||||
this.node.layer = HUD_LAYER;
|
||||
this.buildUI();
|
||||
}
|
||||
|
||||
private gm() {
|
||||
return GameManager.instance;
|
||||
}
|
||||
|
||||
private buildUI() {
|
||||
const rootUi = this.node.getComponent(UITransform) || this.node.addComponent(UITransform);
|
||||
rootUi.setContentSize(680, 52);
|
||||
rootUi.setAnchorPoint(0, 0);
|
||||
|
||||
const bg = this.node.getComponent(Graphics) || this.node.addComponent(Graphics);
|
||||
bg.fillColor = new Color(0, 0, 0, 160);
|
||||
bg.clear();
|
||||
bg.rect(0, 0, 680, 52);
|
||||
bg.fill();
|
||||
|
||||
this.mkLabel('Title', '调试', 8, 26, 14, new Color(255, 220, 120));
|
||||
|
||||
this.stepBox = this.mkEdit('StepInput', '1', 52, 8, 40);
|
||||
this.mkBtn('前', 100, 8, 44, () => this.act('debugMove', this.step()));
|
||||
this.mkBtn('后', 150, 8, 44, () => this.act('debugMove', -this.step()));
|
||||
this.mkBtn('跳', 200, 8, 40, () => this.act('debugJump'));
|
||||
this.mkBtn('←', 246, 8, 36, () => this.act('debugRotateLeft', 1));
|
||||
this.mkBtn('→', 288, 8, 36, () => this.act('debugRotateRight', 1));
|
||||
this.mkBtn('坐标', 330, 8, 52, () => this.act('debugPlayerInfo'));
|
||||
this.mkBtn('结束', 388, 8, 52, () => this.act('debugInputEnd'));
|
||||
this.mkBtn('载具', 446, 8, 52, () => this.act('debugVehicleMove', 1));
|
||||
|
||||
const widget = this.node.getComponent(Widget) || this.node.addComponent(Widget);
|
||||
widget.isAlignBottom = true;
|
||||
widget.bottom = 12;
|
||||
widget.isAlignLeft = true;
|
||||
widget.left = 12;
|
||||
widget.alignMode = Widget.AlignMode.ON_WINDOW_RESIZE;
|
||||
}
|
||||
|
||||
private step(): number {
|
||||
const n = parseInt(this.stepBox?.string ?? '1', 10);
|
||||
return Number.isNaN(n) || n === 0 ? 1 : n;
|
||||
}
|
||||
|
||||
private act(method: string, arg?: number) {
|
||||
const gm = this.gm();
|
||||
if (!gm) {
|
||||
console.warn('[GameplayDebugBar] GameController 未就绪');
|
||||
return;
|
||||
}
|
||||
const fn = (gm as unknown as Record<string, unknown>)[method];
|
||||
if (typeof fn !== 'function') {
|
||||
console.warn(`[GameplayDebugBar] 无方法 ${method}`);
|
||||
return;
|
||||
}
|
||||
if (arg === undefined) (fn as () => void).call(gm);
|
||||
else (fn as (n: number) => void).call(gm, arg);
|
||||
}
|
||||
|
||||
private mkLabel(name: string, text: string, x: number, y: number, size: number, color: Color): Label {
|
||||
const n = new Node(name);
|
||||
n.parent = this.node;
|
||||
n.layer = HUD_LAYER;
|
||||
n.setPosition(x, y, 0);
|
||||
n.addComponent(UITransform).setContentSize(80, 24);
|
||||
const lb = n.addComponent(Label);
|
||||
lb.string = text;
|
||||
lb.fontSize = size;
|
||||
lb.color = color;
|
||||
return lb;
|
||||
}
|
||||
|
||||
private mkEdit(name: string, value: string, x: number, y: number, w: number): EditBox {
|
||||
const n = new Node(name);
|
||||
n.parent = this.node;
|
||||
n.layer = HUD_LAYER;
|
||||
n.setPosition(x, y, 0);
|
||||
n.addComponent(UITransform).setContentSize(w, 32);
|
||||
const box = n.addComponent(EditBox);
|
||||
box.string = value;
|
||||
box.fontSize = 16;
|
||||
const tl = new Node('TEXT_LABEL');
|
||||
tl.parent = n;
|
||||
tl.layer = HUD_LAYER;
|
||||
tl.addComponent(UITransform).setContentSize(w, 32);
|
||||
box.textLabel = tl.addComponent(Label);
|
||||
box.textLabel.fontSize = 16;
|
||||
box.textLabel.color = new Color(255, 255, 255);
|
||||
const pl = new Node('PLACEHOLDER_LABEL');
|
||||
pl.parent = n;
|
||||
pl.layer = HUD_LAYER;
|
||||
pl.addComponent(UITransform).setContentSize(w, 32);
|
||||
box.placeholderLabel = pl.addComponent(Label);
|
||||
box.placeholderLabel.fontSize = 16;
|
||||
box.placeholderLabel.color = new Color(140, 140, 140);
|
||||
return box;
|
||||
}
|
||||
|
||||
private mkBtn(caption: string, x: number, y: number, w: number, handler: () => void) {
|
||||
const n = new Node(`Btn_${caption}`);
|
||||
n.parent = this.node;
|
||||
n.layer = HUD_LAYER;
|
||||
n.setPosition(x, y, 0);
|
||||
n.addComponent(UITransform).setContentSize(w, 32);
|
||||
const btn = n.addComponent(Button);
|
||||
btn.transition = Button.Transition.SCALE;
|
||||
const tl = new Node('Label');
|
||||
tl.parent = n;
|
||||
tl.layer = HUD_LAYER;
|
||||
tl.addComponent(UITransform).setContentSize(w, 32);
|
||||
const lb = tl.addComponent(Label);
|
||||
lb.string = caption;
|
||||
lb.fontSize = 15;
|
||||
lb.color = new Color(255, 255, 255);
|
||||
n.on(Button.EventType.CLICK, handler, this);
|
||||
}
|
||||
}
|
||||
9
assets/scripts/ui/GameplayDebugBar.ts.meta
Normal file
9
assets/scripts/ui/GameplayDebugBar.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "bdc2f2db-a739-4cc1-a5d8-84ca0303d703",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
161
assets/scripts/ui/LevelSwitchBar.ts
Normal file
161
assets/scripts/ui/LevelSwitchBar.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
_decorator, Component, Node, Label, EditBox, Button, UITransform, Color, Widget,
|
||||
} from 'cc';
|
||||
import { GameManager } from '../manager/GameManager';
|
||||
import { getMaxLevelId, getMinLevelId } from '../level/LevelRegistry';
|
||||
|
||||
const { ccclass } = _decorator;
|
||||
|
||||
/**
|
||||
* 预览/运行时的关卡切换条(真实 Button,不依赖 Inspector 扩展)
|
||||
*/
|
||||
@ccclass('LevelSwitchBar')
|
||||
export class LevelSwitchBar extends Component {
|
||||
private editBox: EditBox | null = null;
|
||||
|
||||
static ensure(parent: Node): LevelSwitchBar {
|
||||
let bar = parent.getChildByName('LevelSwitchBar');
|
||||
if (!bar) {
|
||||
bar = new Node('LevelSwitchBar');
|
||||
bar.parent = parent;
|
||||
bar.addComponent(LevelSwitchBar);
|
||||
}
|
||||
return bar.getComponent(LevelSwitchBar)!;
|
||||
}
|
||||
|
||||
onLoad() {
|
||||
this.buildUI();
|
||||
}
|
||||
|
||||
private buildUI() {
|
||||
const rootUi = this.node.getComponent(UITransform) || this.node.addComponent(UITransform);
|
||||
rootUi.setContentSize(520, 48);
|
||||
|
||||
const hint = this.node.getChildByName('HintLabel') ?? new Node('HintLabel');
|
||||
hint.parent = this.node;
|
||||
const hintUi = hint.getComponent(UITransform) || hint.addComponent(UITransform);
|
||||
hintUi.setContentSize(200, 36);
|
||||
hint.setPosition(-160, 0, 0);
|
||||
const hintLabel = hint.getComponent(Label) || hint.addComponent(Label);
|
||||
hintLabel.string = `关卡 ${getMinLevelId()}–${getMaxLevelId()}`;
|
||||
hintLabel.fontSize = 18;
|
||||
hintLabel.color = new Color(200, 220, 255);
|
||||
|
||||
let inputNode = this.node.getChildByName('LevelInput');
|
||||
if (!inputNode) {
|
||||
inputNode = new Node('LevelInput');
|
||||
inputNode.parent = this.node;
|
||||
}
|
||||
inputNode.setPosition(-20, 0, 0);
|
||||
const inputUi = inputNode.getComponent(UITransform) || inputNode.addComponent(UITransform);
|
||||
inputUi.setContentSize(80, 36);
|
||||
this.editBox = inputNode.getComponent(EditBox) || inputNode.addComponent(EditBox);
|
||||
this.editBox.placeholder = '关卡号';
|
||||
this.editBox.string = '1';
|
||||
this.editBox.fontSize = 20;
|
||||
this.editBox.textLabel = this.ensureEditLabel(inputNode);
|
||||
this.editBox.placeholderLabel = this.ensurePlaceholderLabel(inputNode);
|
||||
|
||||
let btnNode = this.node.getChildByName('BtnSwitchLevel');
|
||||
if (!btnNode) {
|
||||
btnNode = new Node('BtnSwitchLevel');
|
||||
btnNode.parent = this.node;
|
||||
}
|
||||
btnNode.setPosition(120, 0, 0);
|
||||
const btnUi = btnNode.getComponent(UITransform) || btnNode.addComponent(UITransform);
|
||||
btnUi.setContentSize(140, 40);
|
||||
const btn = btnNode.getComponent(Button) || btnNode.addComponent(Button);
|
||||
btn.transition = Button.Transition.SCALE;
|
||||
const btnLabel = this.ensureButtonLabel(btnNode);
|
||||
btnLabel.string = '切换关卡';
|
||||
btnLabel.fontSize = 20;
|
||||
btnLabel.color = new Color(255, 255, 255);
|
||||
btn.node.off(Button.EventType.CLICK);
|
||||
btn.node.on(Button.EventType.CLICK, this.onClickSwitch, this);
|
||||
|
||||
let prevNode = this.node.getChildByName('BtnPrev');
|
||||
if (!prevNode) {
|
||||
prevNode = new Node('BtnPrev');
|
||||
prevNode.parent = this.node;
|
||||
}
|
||||
prevNode.setPosition(200, 0, 0);
|
||||
prevNode.getComponent(UITransform) || prevNode.addComponent(UITransform).setContentSize(56, 36);
|
||||
const prevBtn = prevNode.getComponent(Button) || prevNode.addComponent(Button);
|
||||
const prevLbl = this.ensureButtonLabel(prevNode);
|
||||
prevLbl.string = '◀';
|
||||
prevLbl.fontSize = 22;
|
||||
prevNode.off(Button.EventType.CLICK);
|
||||
prevNode.on(Button.EventType.CLICK, () => GameManager.instance?.prevLevel(), this);
|
||||
|
||||
let nextNode = this.node.getChildByName('BtnNext');
|
||||
if (!nextNode) {
|
||||
nextNode = new Node('BtnNext');
|
||||
nextNode.parent = this.node;
|
||||
}
|
||||
nextNode.setPosition(248, 0, 0);
|
||||
nextNode.getComponent(UITransform) || nextNode.addComponent(UITransform).setContentSize(56, 36);
|
||||
const nextBtn = nextNode.getComponent(Button) || nextNode.addComponent(Button);
|
||||
const nextLbl = this.ensureButtonLabel(nextNode);
|
||||
nextLbl.string = '▶';
|
||||
nextLbl.fontSize = 22;
|
||||
nextNode.off(Button.EventType.CLICK);
|
||||
nextNode.on(Button.EventType.CLICK, () => GameManager.instance?.nextLevel(), this);
|
||||
|
||||
const widget = this.node.getComponent(Widget) || this.node.addComponent(Widget);
|
||||
widget.isAlignTop = true;
|
||||
widget.top = 12;
|
||||
widget.isAlignHorizontalCenter = true;
|
||||
widget.alignMode = Widget.AlignMode.ON_WINDOW_RESIZE;
|
||||
}
|
||||
|
||||
syncFromManager() {
|
||||
const gm = GameManager.instance;
|
||||
if (!gm || !this.editBox) return;
|
||||
this.editBox.string = gm.inputLevel || String(gm.curLevelID);
|
||||
}
|
||||
|
||||
private onClickSwitch() {
|
||||
const gm = GameManager.instance;
|
||||
if (!gm) {
|
||||
console.warn('[LevelSwitchBar] GameManager 未就绪');
|
||||
return;
|
||||
}
|
||||
if (this.editBox) {
|
||||
gm.inputLevel = this.editBox.string.trim();
|
||||
}
|
||||
gm.clickSwitchLevel();
|
||||
this.syncFromManager();
|
||||
}
|
||||
|
||||
private ensureButtonLabel(btnNode: Node): Label {
|
||||
let t = btnNode.getChildByName('Label');
|
||||
if (!t) {
|
||||
t = new Node('Label');
|
||||
t.parent = btnNode;
|
||||
t.addComponent(UITransform).setContentSize(140, 40);
|
||||
}
|
||||
return t.getComponent(Label) || t.addComponent(Label);
|
||||
}
|
||||
|
||||
private ensureEditLabel(parent: Node): Label {
|
||||
let t = parent.getChildByName('TEXT_LABEL');
|
||||
if (!t) {
|
||||
t = new Node('TEXT_LABEL');
|
||||
t.parent = parent;
|
||||
t.addComponent(UITransform).setContentSize(80, 36);
|
||||
}
|
||||
return t.getComponent(Label) || t.addComponent(Label);
|
||||
}
|
||||
|
||||
private ensurePlaceholderLabel(parent: Node): Label {
|
||||
let t = parent.getChildByName('PLACEHOLDER_LABEL');
|
||||
if (!t) {
|
||||
t = new Node('PLACEHOLDER_LABEL');
|
||||
t.parent = parent;
|
||||
t.addComponent(UITransform).setContentSize(80, 36);
|
||||
}
|
||||
const lb = t.getComponent(Label) || t.addComponent(Label);
|
||||
lb.color = new Color(160, 160, 160);
|
||||
return lb;
|
||||
}
|
||||
}
|
||||
9
assets/scripts/ui/LevelSwitchBar.ts.meta
Normal file
9
assets/scripts/ui/LevelSwitchBar.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "d0e55793-0083-4720-af9d-153fabbbc424",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -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 UIMain,JS 可 SendMessage("UIMain", "SetText", ...) */
|
||||
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 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();
|
||||
};
|
||||
}
|
||||
|
||||
90
assets/scripts/ui/UIStyleAssets.ts
Normal file
90
assets/scripts/ui/UIStyleAssets.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { ImageAsset, resources, SpriteFrame, Texture2D } from 'cc';
|
||||
import { Direction } from '../core/Define';
|
||||
import { ensureSpriteFrameSize } from '../visual/EntityDisplayRefs';
|
||||
import { ensureResourcesBundle } from '../core/ResourcesBundle';
|
||||
import { resolvePlayerTexturePaths, EntityVisualOptions } from '../visual/EntityTextureResolver';
|
||||
import { VisualAssets } from '../visual/VisualAssets';
|
||||
import {
|
||||
getThemeHudIconCandidates,
|
||||
getThemePortraitPath,
|
||||
ThemeHudIconKey,
|
||||
} from '../theme/ThemeRegistry';
|
||||
|
||||
export type UIIconKey = ThemeHudIconKey;
|
||||
|
||||
const frameCache = new Map<string, SpriteFrame>();
|
||||
|
||||
function loadSpriteAt(path: string): Promise<SpriteFrame | null> {
|
||||
const cached = frameCache.get(path);
|
||||
if (cached) return Promise.resolve(cached);
|
||||
|
||||
return ensureResourcesBundle().then(() => new Promise((resolve) => {
|
||||
resources.load(`${path}/spriteFrame`, SpriteFrame, (err, sf) => {
|
||||
if (!err && sf) {
|
||||
frameCache.set(path, sf);
|
||||
resolve(sf);
|
||||
return;
|
||||
}
|
||||
resources.load(path, SpriteFrame, (err2, sf2) => {
|
||||
if (!err2 && sf2) {
|
||||
frameCache.set(path, sf2);
|
||||
resolve(sf2);
|
||||
return;
|
||||
}
|
||||
resources.load(path, ImageAsset, (err3, img) => {
|
||||
if (!err3 && img) {
|
||||
const tex = new Texture2D();
|
||||
tex.image = img;
|
||||
const frame = new SpriteFrame();
|
||||
frame.texture = tex;
|
||||
ensureSpriteFrameSize(frame, img.width, img.height);
|
||||
frameCache.set(path, frame);
|
||||
resolve(frame);
|
||||
return;
|
||||
}
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
export function getUIIconPath(uiStyle: string | undefined, icon: UIIconKey): string {
|
||||
const paths = getThemeHudIconCandidates(uiStyle, icon);
|
||||
if (!paths.length) throw new Error(`[UIStyleAssets] 缺少图标 ${icon}`);
|
||||
return paths[0];
|
||||
}
|
||||
|
||||
export async function loadUIIcon(
|
||||
uiStyle: string | undefined,
|
||||
icon: UIIconKey,
|
||||
): Promise<SpriteFrame | null> {
|
||||
for (const path of getThemeHudIconCandidates(uiStyle, icon)) {
|
||||
const sf = await loadSpriteAt(path);
|
||||
if (sf) return sf;
|
||||
}
|
||||
console.warn(`[UIStyleAssets] 贴图加载失败: ${icon} (${uiStyle ?? 'silu'})`);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function clearUIIconCache() {
|
||||
frameCache.clear();
|
||||
}
|
||||
|
||||
/** 地图左上角角色肖像(优先 entities.portrait,否则 playerFront) */
|
||||
export async function loadThemeCharacterPortrait(
|
||||
options: EntityVisualOptions = {},
|
||||
): Promise<SpriteFrame | null> {
|
||||
const theme = options.theme;
|
||||
const portrait = getThemePortraitPath(theme);
|
||||
if (portrait) {
|
||||
const sf = await VisualAssets.loadTexturePath(portrait);
|
||||
if (sf) return sf;
|
||||
}
|
||||
for (const path of resolvePlayerTexturePaths(Direction.South, options)) {
|
||||
const sf = await VisualAssets.loadTexturePath(path);
|
||||
if (sf) return sf;
|
||||
}
|
||||
console.warn(`[UIStyleAssets] 角色肖像加载失败 (${theme ?? 'silu'})`);
|
||||
return null;
|
||||
}
|
||||
9
assets/scripts/ui/UIStyleAssets.ts.meta
Normal file
9
assets/scripts/ui/UIStyleAssets.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "bd61a294-d993-404c-8818-cd71d51557eb",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
Reference in New Issue
Block a user