165 lines
6.2 KiB
TypeScript
165 lines
6.2 KiB
TypeScript
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<void> {
|
|
// 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;
|
|
});
|
|
});
|
|
}
|
|
}
|