Files
KateLegend2_proj/assets/scripts/scene_entries/MainMenuEntry.ts
T
2026-06-07 22:10:03 +08:00

431 lines
15 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, 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;
}