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 = { 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 = { '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; }