431 lines
15 KiB
TypeScript
431 lines
15 KiB
TypeScript
import { _decorator, Component, director, Node, Label, Button, UITransform, Color, Vec3, Graphics, Sprite, SpriteFrame, Texture2D, resources, Rect, Size, ImageAsset } from 'cc';
|
||
import { UIFlowMgr, ISceneEnter } from '../ui/UIFlowMgr';
|
||
import { DESIGN_WIDTH, DESIGN_HEIGHT } from '../common/Constants';
|
||
|
||
const { ccclass, property } = _decorator;
|
||
|
||
/** Maps abstract SceneId → physical Cocos Creator scene name. */
|
||
const SCENE_MAP: Record<string, string> = {
|
||
boot: 'Boot',
|
||
story_intro: 'StoryIntro',
|
||
main_menu: 'MainMenu',
|
||
// level_select currently reuses MainMenu until a dedicated LevelSelect scene exists.
|
||
level_select: 'MainMenu',
|
||
// `gameplay` is dispatched by levelId — resolved at runtime.
|
||
gameplay: 'Level_1_1',
|
||
settlement: 'Settlement',
|
||
// Settings panel is overlayed on MainMenu for the MVP.
|
||
settings: 'MainMenu',
|
||
};
|
||
|
||
/** levelId → physical scene name mapping (chapter 1 only). */
|
||
const LEVEL_SCENE_MAP: Record<string, string> = {
|
||
'1-1': 'Level_1_1',
|
||
'1-2': 'Level_1_2',
|
||
'1-3': 'Level_1_3',
|
||
'1-4': 'Level_1_4',
|
||
'1-5': 'Level_1_5',
|
||
'1-5-boss': 'Boss_ShuangHuanFang',
|
||
};
|
||
|
||
/** Level definitions for the level-select screen (chapter 1 MVP). */
|
||
const LEVEL_DEFS: ReadonlyArray<{ id: string; label: string }> = [
|
||
{ id: '1-1', label: '1-1 森林入口' },
|
||
{ id: '1-2', label: '1-2 竹林深处' },
|
||
{ id: '1-3', label: '1-3 城墙之下' },
|
||
{ id: '1-4', label: '1-4 密道暗行' },
|
||
{ id: '1-5', label: '1-5 魔城前庭' },
|
||
{ id: '1-5-boss', label: 'Boss 双幻坊' },
|
||
];
|
||
|
||
/**
|
||
* MainMenu scene entry (task 9.2 hookup).
|
||
*
|
||
* Owns a `UIFlowMgr` instance and translates each abstract `onSceneEnter`
|
||
* callback into a concrete `director.loadScene` call. Attach this component
|
||
* to the root node of `MainMenu.scene`.
|
||
*
|
||
* Supports two display modes reusing the same scene:
|
||
* - **main_menu**: title + Start / Settings buttons
|
||
* - **level_select**: chapter heading + level buttons + Back button
|
||
*
|
||
* When `autoBuildUI` is enabled (default), all UI is created
|
||
* programmatically so the scene is usable out of the box even before
|
||
* any art pass.
|
||
*/
|
||
@ccclass('MainMenuEntry')
|
||
export class MainMenuEntry extends Component {
|
||
@property({ tooltip: '是否自动生成 Start / Settings 按钮 (方便没美术时就能跑通)' })
|
||
public autoBuildUI: boolean = true;
|
||
|
||
private flow: UIFlowMgr | undefined;
|
||
/** Nodes created in main-menu mode, so we can remove them when switching. */
|
||
private menuNodes: Node[] = [];
|
||
/** Nodes created in level-select mode, so we can remove them when switching. */
|
||
private levelSelectNodes: Node[] = [];
|
||
|
||
protected onLoad(): void {
|
||
this.flow = new UIFlowMgr(undefined, {
|
||
onSceneEnter: (ev) => this.handleSceneEnter(ev),
|
||
});
|
||
if (this.autoBuildUI) this.buildMainMenuUI();
|
||
}
|
||
|
||
/** Bind this to the "Start" button's click event in the Inspector. */
|
||
public onPressStart(): void {
|
||
this.flow?.onPressStartGame();
|
||
}
|
||
|
||
/** Bind this to the "Settings" button's click event. */
|
||
public onPressSettings(): void {
|
||
this.flow?.onOpenSettings();
|
||
}
|
||
|
||
/** Pick a level from the level-select screen. */
|
||
public onPressLevel(levelId: string): void {
|
||
this.flow?.onPickLevel(levelId);
|
||
}
|
||
|
||
/** Go back from level-select to main-menu. */
|
||
public onPressBackToMenu(): void {
|
||
this.showMainMenu();
|
||
}
|
||
|
||
private handleSceneEnter(ev: ISceneEnter): void {
|
||
const payload = ev.payload ?? {};
|
||
|
||
// gameplay: always load a different scene.
|
||
if (ev.scene === 'gameplay' && typeof payload.levelId === 'string') {
|
||
const physical = LEVEL_SCENE_MAP[payload.levelId];
|
||
if (physical) {
|
||
director.loadScene(physical);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// level_select: we reuse this scene — just swap UI panels.
|
||
if (ev.scene === 'level_select') {
|
||
this.showLevelSelect();
|
||
return;
|
||
}
|
||
|
||
// settings: for MVP, overlayed on MainMenu — just swap to menu mode.
|
||
if (ev.scene === 'settings') {
|
||
this.showMainMenu();
|
||
return;
|
||
}
|
||
|
||
// For any other scene (boot / story_intro / settlement / main_menu),
|
||
// load the physical scene normally.
|
||
const physical = SCENE_MAP[ev.scene];
|
||
if (physical && ev.scene !== 'main_menu') {
|
||
director.loadScene(physical);
|
||
} else if (ev.scene === 'main_menu') {
|
||
this.showMainMenu();
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// UI mode switching
|
||
// ------------------------------------------------------------------
|
||
|
||
private showMainMenu(): void {
|
||
this.clearNodes(this.levelSelectNodes);
|
||
if (this.menuNodes.length === 0 && this.autoBuildUI) {
|
||
this.buildMainMenuUI();
|
||
}
|
||
}
|
||
|
||
private showLevelSelect(): void {
|
||
this.clearNodes(this.menuNodes);
|
||
if (this.levelSelectNodes.length === 0 && this.autoBuildUI) {
|
||
this.buildLevelSelectUI();
|
||
}
|
||
}
|
||
|
||
private clearNodes(arr: Node[]): void {
|
||
for (const n of arr) {
|
||
if (n.isValid) {
|
||
n.removeFromParent();
|
||
n.destroy();
|
||
}
|
||
}
|
||
arr.length = 0;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Auto-built UI (development affordance; art pass will replace it)
|
||
// ------------------------------------------------------------------
|
||
private buildMainMenuUI(): void {
|
||
// Ensure the host node has a UITransform matching the design resolution.
|
||
ensureCanvasSize(this.node);
|
||
|
||
// Title.
|
||
this.menuNodes.push(createLabel(this.node, '影 之 传 说', 0, 140, 44, Color.WHITE));
|
||
|
||
// Start button (centered, 40 above origin).
|
||
this.menuNodes.push(createButton(this.node, 'Start', 0, 40, 220, 60, () => this.onPressStart()));
|
||
|
||
// Settings button (centered, 40 below origin).
|
||
this.menuNodes.push(createButton(this.node, 'Settings', 0, -40, 220, 60, () => this.onPressSettings()));
|
||
|
||
// Hint line at the bottom.
|
||
this.menuNodes.push(createLabel(this.node, 'Chapter 1 · MVP', 0, -200, 20, new Color(180, 180, 180, 255)));
|
||
}
|
||
|
||
private buildLevelSelectUI(): void {
|
||
ensureCanvasSize(this.node);
|
||
|
||
// Chapter heading.
|
||
this.levelSelectNodes.push(
|
||
createLabel(this.node, '第一章 青叶之刃', 0, 220, 36, Color.WHITE),
|
||
);
|
||
|
||
// Level buttons — laid out vertically with even spacing.
|
||
const startY = 140;
|
||
const gapY = 55;
|
||
for (let i = 0; i < LEVEL_DEFS.length; i++) {
|
||
const def = LEVEL_DEFS[i];
|
||
const y = startY - i * gapY;
|
||
this.levelSelectNodes.push(
|
||
createButton(this.node, def.label, 0, y, 280, 44, () => this.onPressLevel(def.id)),
|
||
);
|
||
}
|
||
|
||
// Back button at the bottom.
|
||
this.levelSelectNodes.push(
|
||
createButton(this.node, 'Back', 0, -200, 160, 44, () => this.onPressBackToMenu()),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ======================================================================
|
||
// Shared UI helpers — intentionally kept inline (no external module) so
|
||
// each Scene Entry stays self-contained and easy to remove once real UI is
|
||
// authored in the editor.
|
||
// ======================================================================
|
||
|
||
function ensureCanvasSize(host: Node): void {
|
||
let ut = host.getComponent(UITransform);
|
||
if (!ut) ut = host.addComponent(UITransform);
|
||
ut.setContentSize(DESIGN_WIDTH, DESIGN_HEIGHT);
|
||
host.setPosition(0, 0, 0);
|
||
}
|
||
|
||
function createLabel(parent: Node, text: string, x: number, y: number, fontSize: number, color: Color): Node {
|
||
const n = new Node('AutoLabel');
|
||
n.layer = parent.layer;
|
||
parent.addChild(n);
|
||
const ut = n.addComponent(UITransform);
|
||
ut.setContentSize(DESIGN_WIDTH, fontSize * 1.6);
|
||
const lb = n.addComponent(Label);
|
||
lb.useSystemFont = true;
|
||
lb.string = text;
|
||
lb.fontSize = fontSize;
|
||
lb.lineHeight = Math.floor(fontSize * 1.2);
|
||
lb.color = color;
|
||
lb.horizontalAlign = 1; // CENTER
|
||
lb.verticalAlign = 1; // CENTER
|
||
n.setPosition(new Vec3(x, y, 0));
|
||
return n;
|
||
}
|
||
|
||
function createButton(
|
||
parent: Node,
|
||
text: string,
|
||
x: number,
|
||
y: number,
|
||
w: number,
|
||
h: number,
|
||
onClick: () => void
|
||
): Node {
|
||
const n = new Node(`Btn_${text}`);
|
||
n.layer = parent.layer;
|
||
parent.addChild(n);
|
||
const ut = n.addComponent(UITransform);
|
||
ut.setContentSize(w, h);
|
||
|
||
// Background drawn via Graphics — avoids needing any texture asset.
|
||
const g = n.addComponent(Graphics);
|
||
g.fillColor = new Color(40, 40, 60, 230);
|
||
g.rect(-w / 2, -h / 2, w, h);
|
||
g.fill();
|
||
g.strokeColor = new Color(200, 200, 220, 255);
|
||
g.lineWidth = 2;
|
||
g.rect(-w / 2, -h / 2, w, h);
|
||
g.stroke();
|
||
|
||
// Label child for the text.
|
||
const labelNode = new Node('Label');
|
||
labelNode.layer = parent.layer;
|
||
n.addChild(labelNode);
|
||
const lut = labelNode.addComponent(UITransform);
|
||
lut.setContentSize(w, h);
|
||
const lb = labelNode.addComponent(Label);
|
||
lb.useSystemFont = true;
|
||
lb.string = text;
|
||
lb.fontSize = 24;
|
||
lb.lineHeight = 28;
|
||
lb.color = Color.WHITE;
|
||
lb.horizontalAlign = 1;
|
||
lb.verticalAlign = 1;
|
||
|
||
const btn = n.addComponent(Button);
|
||
btn.transition = Button.Transition.SCALE;
|
||
btn.target = n;
|
||
btn.zoomScale = 0.95;
|
||
n.on(Node.EventType.TOUCH_END, onClick, n);
|
||
|
||
n.setPosition(new Vec3(x, y, 0));
|
||
return n;
|
||
}
|
||
|
||
// Re-export helpers so sibling Scene Entries can reuse them.
|
||
export { ensureCanvasSize, createLabel, createButton, createSprite, createSpriteSheetFrame, createSolidRect };
|
||
|
||
// ======================================================================
|
||
// Sprite helpers (task 10.1 hookup) — load a PNG from `resources/` and
|
||
// attach it to a fresh child Node. Returned synchronously; the Sprite's
|
||
// `spriteFrame` is assigned once `resources.load` resolves.
|
||
//
|
||
// Paths passed in are **relative to `assets/resources`** and **without
|
||
// extension**. Example: `textures/characters/kage_red` — the helper will
|
||
// append `/spriteFrame` automatically.
|
||
// ======================================================================
|
||
|
||
// ======================================================================
|
||
// Internal: Load a PNG from `resources/` as a SpriteFrame.
|
||
//
|
||
// Strategy — try two paths so it works regardless of .meta type:
|
||
// 1. `resources.load(path, SpriteFrame)` — works when the .meta has
|
||
// `type: "sprite-frame"` (Creator auto-generates the sub-asset).
|
||
// 2. Fallback: `resources.load(path, ImageAsset)` → Texture2D →
|
||
// SpriteFrame — works when .meta has `type: "texture"`.
|
||
// ======================================================================
|
||
|
||
function _loadSpriteFrameFromImage(
|
||
resPath: string,
|
||
onReady: (sf: SpriteFrame) => void,
|
||
): void {
|
||
// Try the direct SpriteFrame path first (works with sprite-frame meta).
|
||
resources.load(resPath, SpriteFrame, (err, sf) => {
|
||
if (!err && sf) {
|
||
onReady(sf as SpriteFrame);
|
||
return;
|
||
}
|
||
// Fallback: load raw image and build SpriteFrame manually.
|
||
resources.load(resPath, ImageAsset, (err2, imgAsset) => {
|
||
if (err2 || !imgAsset) {
|
||
console.warn(`[_loadSpriteFrameFromImage] Failed to load '${resPath}' (both paths):`, err, err2);
|
||
return;
|
||
}
|
||
const tex = new Texture2D();
|
||
tex.image = imgAsset as ImageAsset;
|
||
const frame = new SpriteFrame();
|
||
frame.texture = tex;
|
||
frame.rect = new Rect(0, 0, tex.width, tex.height);
|
||
frame.originalSize = new Size(tex.width, tex.height);
|
||
onReady(frame);
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Create a child Node with a Sprite that displays the entire PNG at
|
||
* `resPath`. If `w` / `h` are omitted the node will be resized to the
|
||
* native image size once the texture finishes loading.
|
||
*
|
||
* NOTE: Loads via `ImageAsset` → `Texture2D` → `SpriteFrame` pipeline
|
||
* instead of relying on a `/spriteFrame` sub-asset, because PNG .meta
|
||
* files may not have `type: "sprite-frame"`.
|
||
*/
|
||
function createSprite(
|
||
parent: Node,
|
||
resPath: string,
|
||
x: number,
|
||
y: number,
|
||
w?: number,
|
||
h?: number,
|
||
opts?: { name?: string; color?: Color; flipX?: boolean }
|
||
): Node {
|
||
const n = new Node(opts?.name ?? `Sprite_${resPath.split('/').pop()}`);
|
||
n.layer = parent.layer;
|
||
parent.addChild(n);
|
||
const ut = n.addComponent(UITransform);
|
||
if (w != null && h != null) ut.setContentSize(w, h);
|
||
const sp = n.addComponent(Sprite);
|
||
sp.sizeMode = Sprite.SizeMode.CUSTOM;
|
||
if (opts?.color) sp.color = opts.color;
|
||
n.setPosition(new Vec3(x, y, 0));
|
||
if (opts?.flipX) n.setScale(-Math.abs(n.scale.x), n.scale.y, n.scale.z);
|
||
|
||
_loadSpriteFrameFromImage(resPath, (sf) => {
|
||
if (!n.isValid) return;
|
||
sp.spriteFrame = sf;
|
||
if (w == null || h == null) {
|
||
ut.setContentSize(sf.rect.width, sf.rect.height);
|
||
}
|
||
});
|
||
return n;
|
||
}
|
||
|
||
/**
|
||
* Create a child Node that renders a single frame out of a horizontal
|
||
* sprite-sheet PNG. `frameIndex` is 0-based; `frameW`/`frameH` describe
|
||
* each frame's pixel size; the sheet is expected to lay frames left-to-
|
||
* right in one row (matches `gen_pixel_art_assets.py`).
|
||
*
|
||
* The returned Node is shown at `displayW`×`displayH` (defaults to the
|
||
* native frame size — i.e. 1 CSS px per source px).
|
||
*/
|
||
function createSpriteSheetFrame(
|
||
parent: Node,
|
||
resPath: string,
|
||
frameIndex: number,
|
||
frameW: number,
|
||
frameH: number,
|
||
x: number,
|
||
y: number,
|
||
displayW?: number,
|
||
displayH?: number,
|
||
opts?: { name?: string; flipX?: boolean }
|
||
): Node {
|
||
const n = new Node(opts?.name ?? `Frame_${resPath.split('/').pop()}_${frameIndex}`);
|
||
n.layer = parent.layer;
|
||
parent.addChild(n);
|
||
const ut = n.addComponent(UITransform);
|
||
ut.setContentSize(displayW ?? frameW, displayH ?? frameH);
|
||
const sp = n.addComponent(Sprite);
|
||
sp.sizeMode = Sprite.SizeMode.CUSTOM;
|
||
n.setPosition(new Vec3(x, y, 0));
|
||
if (opts?.flipX) n.setScale(-Math.abs(n.scale.x), n.scale.y, n.scale.z);
|
||
|
||
_loadSpriteFrameFromImage(resPath, (sf) => {
|
||
if (!n.isValid) return;
|
||
// Slice a single frame out of the full sheet.
|
||
const sliced = new SpriteFrame();
|
||
sliced.texture = sf.texture;
|
||
sliced.rect = new Rect(frameIndex * frameW, 0, frameW, frameH);
|
||
sliced.originalSize = new Size(frameW, frameH);
|
||
sp.spriteFrame = sliced;
|
||
});
|
||
return n;
|
||
}
|
||
|
||
/**
|
||
* Utility for semi-transparent overlays (e.g. story-page text backdrop).
|
||
*/
|
||
function createSolidRect(parent: Node, x: number, y: number, w: number, h: number, color: Color): Node {
|
||
const n = new Node('SolidRect');
|
||
n.layer = parent.layer;
|
||
parent.addChild(n);
|
||
const ut = n.addComponent(UITransform);
|
||
ut.setContentSize(w, h);
|
||
const g = n.addComponent(Graphics);
|
||
g.fillColor = color;
|
||
g.rect(-w / 2, -h / 2, w, h);
|
||
g.fill();
|
||
n.setPosition(new Vec3(x, y, 0));
|
||
return n;
|
||
}
|