import { _decorator, Component, director, Label, Node, Color, EventTouch, UITransform, Sprite, SpriteFrame, Texture2D, ImageAsset, Rect, Size, resources } from 'cc'; import { ConfigMgr } from '../data/ConfigMgr'; import { CCJsonLoader } from './CCJsonLoader'; import { StorySceneCtrl } from '../ui/StorySceneCtrl'; import { ensureCanvasSize, createLabel, createButton, createSprite, createSolidRect, } from './MainMenuEntry'; import { DESIGN_WIDTH, DESIGN_HEIGHT } from '../common/Constants'; import { IStoryPageConfig } from '../data/Interfaces'; const { ccclass, property } = _decorator; /** * StoryIntro scene entry (task 9.1 hookup, req 19.x). * * Attach to the root of `StoryIntro.scene`. Inspector: * - labelNode: optional — if left empty and `autoBuildUI` is true, a * centered Label and a bottom-right "Skip" button are auto-created. * - storyId: story id in `configs/stories.json` (default `chapter_1_intro`). * * Tap anywhere → accelerate / advance (req 19.3). * "Skip" button → finish immediately (req 19.4). */ @ccclass('StorySceneEntry') export class StorySceneEntry extends Component { @property({ type: Node, tooltip: '打字机 Label 节点 (可留空,自动创建)' }) public labelNode: Node | null = null; @property({ tooltip: '对应 configs/stories.json 中的 id' }) public storyId: string = 'chapter_1_intro'; @property({ tooltip: '是否自动创建 Label / Skip 按钮 / 背景遮罩' }) public autoBuildUI: boolean = true; private ctrl: StorySceneCtrl | undefined; private bgNode: Node | null = null; protected async onLoad(): Promise { // Always ensure the illustration bgNode exists (even if the scene // was hand-built in the editor and labelNode is already bound). if (!this.bgNode) { this.ensureBgNode(); } if (this.autoBuildUI && !this.labelNode) { this.buildDefaultUI(); } // Full-screen tap accelerator: listen on the host node itself. this.node.on(Node.EventType.TOUCH_END, () => this.onTap(), this); const cfg = await this.loadStoryConfig(); this.ctrl = new StorySceneCtrl(cfg, undefined, { onTextChanged: (text) => this.updateLabel(text), onPageEntered: (page) => this.swapIllustration(page), onFinished: () => director.loadScene('Level_1_1'), }); const outcome = this.ctrl.start(); if (outcome === 'already_seen') { director.loadScene('Level_1_1'); } } protected update(dt: number): void { this.ctrl?.tick(dt); } /** Tap handler (called by auto-built full-screen listener or external). */ public onTap(): void { this.ctrl?.onTap(); } /** Bound to the "Skip" button. */ public onSkip(): void { this.ctrl?.onSkip(); } private updateLabel(text: string): void { if (!this.labelNode) return; const label = this.labelNode.getComponent(Label); if (label) label.string = text; } private async loadStoryConfig() { const mgr = new ConfigMgr(new CCJsonLoader()); await mgr.load(); return mgr.story(this.storyId); } /** Ensure the full-screen illustration layer exists. Called even when * the scene was hand-built in the editor (labelNode already bound). */ private ensureBgNode(): void { ensureCanvasSize(this.node); this.bgNode = new Node('StoryIllustration'); this.bgNode.layer = this.node.layer; // Added before any scrim/label nodes so it renders behind them. this.node.addChild(this.bgNode); const bgUt = this.bgNode.addComponent(UITransform); bgUt.setContentSize(DESIGN_WIDTH, DESIGN_HEIGHT); const bgSp = this.bgNode.addComponent(Sprite); bgSp.sizeMode = Sprite.SizeMode.CUSTOM; this.bgNode.setPosition(0, 0, 0); } private buildDefaultUI(): void { // bgNode is already created by ensureBgNode() — just build the // text overlay, scrim and skip button. // Dark scrim below the text area for readability. createSolidRect( this.node, 0, -DESIGN_HEIGHT / 2 + 110, DESIGN_WIDTH, 180, new Color(0, 0, 0, 170), ); // Central typewriter label (sits on top of the scrim). this.labelNode = createLabel(this.node, '', 0, -DESIGN_HEIGHT / 2 + 110, 26, Color.WHITE); const ut = this.labelNode.getComponent(UITransform); if (ut) ut.setContentSize(DESIGN_WIDTH - 80, 160); const lb = this.labelNode.getComponent(Label); if (lb) lb.enableWrapText = true; // Skip button at bottom-right. const skipX = DESIGN_WIDTH / 2 - 90; const skipY = -DESIGN_HEIGHT / 2 + 40; createButton(this.node, 'Skip >>', skipX, skipY, 140, 50, () => this.onSkip()); } /** Swap the full-screen illustration when the controller enters a new page. */ private swapIllustration(page: IStoryPageConfig): void { if (!this.bgNode) return; const sp = this.bgNode.getComponent(Sprite); if (!sp) return; const path = page.illustration; // Strategy 1: direct SpriteFrame load (works with sprite-frame meta). resources.load(path, SpriteFrame, (err, sf) => { if (!err && sf) { if (this.bgNode && this.bgNode.isValid) sp.spriteFrame = sf as SpriteFrame; return; } // Strategy 2: ImageAsset → Texture2D → SpriteFrame fallback. resources.load(path, ImageAsset, (err2, imgAsset) => { if (err2 || !imgAsset) { console.warn(`[StorySceneEntry] failed to load illustration '${path}':`, err, err2); return; } if (!this.bgNode || !this.bgNode.isValid) 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); sp.spriteFrame = frame; }); }); } }