update spirit

This commit is contained in:
jakciehan
2026-06-07 22:10:03 +08:00
parent 427a33c55b
commit 9c57deff6d
82 changed files with 5465 additions and 149 deletions
+904 -27
View File
@@ -1,21 +1,102 @@
import { _decorator, Component, director, Color, Label, Node } from 'cc';
import { _decorator, Component, director, Color, Graphics, Label, Node, Sprite, UITransform, Vec3 } from 'cc';
import { ConfigMgr } from '../data/ConfigMgr';
import { CCJsonLoader } from './CCJsonLoader';
import { LevelMgr } from '../logic/LevelMgr';
import { ensureCanvasSize, createLabel } from './MainMenuEntry';
import { DESIGN_HEIGHT } from '../common/Constants';
import {
ensureCanvasSize,
createLabel,
createSprite,
createSpriteSheetFrame,
} from './MainMenuEntry';
import {
DESIGN_WIDTH,
DESIGN_HEIGHT,
PlayerColorState,
} from '../common/Constants';
import { globalEventBus, globalTimeMgr } from '../common/index';
import { PlayerMotionModel, HorizontalInput, IPlatform } from '../logic/PlayerMotionModel';
import { JumpController } from '../logic/JumpController';
import { PlayerStateMachine } from '../logic/PlayerStateMachine';
import { AttackController, IJumpStateProvider, IAttackDispatchEvent } from '../logic/AttackController';
import { EnemyAIBase, EnemyManager, QingRenAI, ChiRenAI, HeiRenAI, YaoFangAI, IEnemyAction, ICullingRect, ReinforcementScheduler, IReinforcementSpawn } from '../logic/EnemyAI';
import { CameraScroller, cameraFromLevel, PARALLAX_LAYERS, ParallaxLayer } from '../logic/CameraScroller';
import { DamageSystem } from '../logic/DamageSystem';
import { InputEvents } from '../ui/InputEvents';
import { JoystickAngleClass, DEFAULT_LAYOUT } from '../ui/InputModel';
import { FloatingControlLayer } from '../ui/FloatingControlLayer';
import { EnemyType, WeaponType, IReinforcementRule } from '../data/Interfaces';
const { ccclass, property } = _decorator;
// ---------------------------------------------------------------------------
// Coordinate conventions
// ---------------------------------------------------------------------------
// PlayerMotionModel uses +y-up (physics). Cocos Creator uses +y-down
// (render). The design resolution origin is at the centre of the 960×540
// canvas. We convert: cocosY = -physicsY (relative to canvas centre).
// ---------------------------------------------------------------------------
/** Ground platform Y in physics coords (+y up). */
const GROUND_Y = 110;
/** Hero spawn X in physics coords. */
const HERO_SPAWN_X = 120;
/** Visual scale applied to the hero sprite (16×32 → 48×96). */
const HERO_SCALE = 3;
/** Visual scale applied to enemy sprites (16×16 → 48×48). */
const ENEMY_SCALE = 3;
/**
* Map an EnemyType to its sprite-sheet resource path and frame dimensions.
*/
function enemySpriteInfo(type: EnemyType): { res: string; fw: number; fh: number } {
switch (type) {
case EnemyType.QingRen: return { res: 'textures/enemies/qing_ren', fw: 16, fh: 16 };
case EnemyType.ChiRen: return { res: 'textures/enemies/chi_ren', fw: 16, fh: 16 };
case EnemyType.HeiRen: return { res: 'textures/enemies/hei_ren', fw: 20, fh: 24 };
case EnemyType.YaoFang: return { res: 'textures/enemies/yao_fang', fw: 18, fh: 20 };
default: return { res: 'textures/enemies/qing_ren', fw: 16, fh: 16 };
}
}
/** Map an EnemyType enum to the concrete AI class. */
function createEnemyAI(type: EnemyType, cfg: { moveSpeed: number; attackIntervalSec: number; size: { w: number; h: number } }, spawnX: number, spawnY: number): EnemyAIBase {
const fullCfg = { id: type, displayName: type, size: cfg.size, moveSpeed: cfg.moveSpeed, attackIntervalSec: cfg.attackIntervalSec, attacks: [] as never[], hp: 1 };
switch (type) {
case EnemyType.ChiRen: return new ChiRenAI(fullCfg, spawnX, spawnY);
case EnemyType.HeiRen: return new HeiRenAI(fullCfg, spawnX, spawnY);
case EnemyType.YaoFang: return new YaoFangAI(fullCfg, spawnX, spawnY);
case EnemyType.QingRen:
default: return new QingRenAI(fullCfg, spawnX, spawnY);
}
}
// ---------------------------------------------------------------------------
// JumpStateProvider — bridges JumpController ↔ PlayerMotionModel
// ---------------------------------------------------------------------------
class JumpStateProvider implements IJumpStateProvider {
private _lastJumpPressTs: number | undefined;
public setLastJumpPressTs(ts: number | undefined): void {
this._lastJumpPressTs = ts;
}
public lastJumpPressTs(): number | undefined {
return this._lastJumpPressTs;
}
constructor(private readonly motion: PlayerMotionModel) {}
public isGrounded(): boolean {
return this.motion.isGrounded;
}
}
/**
* Generic Level scene entry (task 7.1-7.2 hookup).
*
* Attach to the root node of `Level_1_1` … `Level_1_5`. Configure the
* `levelId` property in the Inspector ("1-1", "1-2", ...).
*
* For MVP the scene has no real gameplay rendering yet; `autoBuildUI`
* simply draws a top-centered label with the current level id so you can
* verify scene transitions by eye.
* Now fully wired: input events → logic models → scene node positions,
* so both the player and enemies move in real-time.
*/
@ccclass('LevelEntry')
export class LevelEntry extends Component {
@@ -28,27 +109,821 @@ export class LevelEntry extends Component {
@property({ tooltip: '是否自动创建关卡 HUD (顶部显示 Level / 倒计时)' })
public autoBuildUI: boolean = true;
private mgr: LevelMgr | undefined;
// -- logic models -------------------------------------------------------
private mgr!: LevelMgr;
private cfg!: ConfigMgr;
private motion!: PlayerMotionModel;
private jumpCtrl!: JumpController;
private psm!: PlayerStateMachine;
private attackCtrl!: AttackController;
private jumpStateProvider!: JumpStateProvider;
private enemyMgr!: EnemyManager;
private camera!: CameraScroller;
private dmgSystem!: DamageSystem;
private reinforcementScheduler!: ReinforcementScheduler;
private levelElapsedSec = 0;
// -- view nodes ---------------------------------------------------------
private hudNode: Node | null = null;
private heroNode: Node | null = null;
/** Enemy AI → visual node mapping. */
private enemyNodes = new Map<EnemyAIBase, Node>();
/** Parallax background nodes keyed by layer name. */
private bgNodes = new Map<string, Node>();
/** Projectile visual nodes (shuriken, fireball, etc.). */
private projectileNodes: Node[] = [];
// -- input state --------------------------------------------------------
private currentJoystickKlass: JoystickAngleClass = 'none';
private currentHorizontalInput: HorizontalInput = 0;
// -- event handler refs (for cleanup) -----------------------------------
private boundHandlers: Array<[string, (payload: any) => void]> = [];
private _deathHandled = false;
/** Reference to the FloatingControlLayer container node — must remain the
* top-most sibling so dynamically-added sprites (projectiles, slashes,
* re-created hero) never occlude touch input. */
private ctrlLayerNode: Node | null = null;
protected async onLoad(): Promise<void> {
// 1. Load configs
this.cfg = new ConfigMgr(new CCJsonLoader());
await this.cfg.load();
const levelCfg = this.cfg.level(this.levelId);
// 0. Reset global singletons that survive across scene loads.
// Placed AFTER await so update()-driven accumulation during the
// async gap does not negate the reset.
globalTimeMgr.reset();
this.mgr = new LevelMgr(levelCfg);
// 2. Build platforms (simple ground + level-specific platforms)
const platforms = this.buildPlatforms();
// 3. Create logic models
// AABB.y is the **centre** of the box; feet must align with the
// ground surface (topY = GROUND_Y), so centre = GROUND_Y + h/2.
const HERO_H = 32;
this.motion = new PlayerMotionModel({
aabb: { x: HERO_SPAWN_X, y: GROUND_Y + HERO_H / 2, w: 16, h: HERO_H },
platforms,
initialColorState: PlayerColorState.Red,
levelLengthPx: levelCfg.levelLengthPx,
});
this.psm = new PlayerStateMachine(1);
this.jumpStateProvider = new JumpStateProvider(this.motion);
this.jumpCtrl = new JumpController(this.motion);
this.attackCtrl = new AttackController(this.jumpStateProvider);
this.dmgSystem = new DamageSystem(this.psm);
this.camera = cameraFromLevel(levelCfg);
this.enemyMgr = new EnemyManager();
// 4. Spawn enemies from level config
for (const spawn of levelCfg.enemySpawns) {
const enemyCfg = this.cfg.enemy(spawn.type);
// Enemy pos.y = centre; feet must sit on ground (topY = GROUND_Y).
const spawnY = GROUND_Y + enemyCfg.size.h / 2;
const count = spawn.count ?? 1;
for (let i = 0; i < count; i++) {
const ai = createEnemyAI(spawn.type, enemyCfg, spawn.atPx + i * 60, spawnY);
this.enemyMgr.spawn(ai);
}
}
// 4b. Initialize reinforcement scheduler from level config
this.reinforcementScheduler = new ReinforcementScheduler(levelCfg.reinforcements ?? []);
// 5. Build UI & sprites
if (this.autoBuildUI) this.buildDefaultUI();
const cfg = new ConfigMgr(new CCJsonLoader());
await cfg.load();
this.mgr = new LevelMgr(cfg.level(this.levelId));
// 6. Add FloatingControlLayer
this.addControlLayer();
// 7. Subscribe to input events
this.subscribeInputEvents();
}
protected update(dt: number): void {
if (!this.mgr) return;
const status = this.mgr.tick(dt);
// 1. Update time
globalTimeMgr.update(dt);
const scaledDt = globalTimeMgr.scaledDelta(dt);
if (scaledDt <= 0) return; // paused
// Guard: onLoad is async — models may not be ready yet.
if (!this.attackCtrl || !this.psm) return;
// 11. Early-out: if the player died in a previous frame, skip all
// gameplay logic (motion, attacks, enemies) and go straight to
// the death → scene-transition path.
if (this.psm.isDead) {
if (!this._deathHandled) {
const deathStatus = this.mgr.tick(scaledDt);
this.refreshHud();
if (deathStatus === 'player_dead') {
this._deathHandled = true;
this.attackCtrl.reset();
this.unsubscribeInputEvents();
director.loadScene('Settlement');
}
// If deathStatus is still 'running', onPlayerDied() was just
// called in the previous frame's step-11 but LevelMgr hasn't
// propagated the state yet. _deathHandled stays false so we
// retry next frame.
}
return;
}
const nowMs = globalTimeMgr.realTime * 1000;
// 2. Tick player motion FIRST so that isGrounded is up-to-date
// when jumpCtrl.tick reads it (fixes "cannot jump after landing"
// bug — jumpCtrl was reading stale isGrounded from previous frame).
this.motion.setHorizontalInput(this.currentHorizontalInput);
this.motion.update(scaledDt);
this.jumpCtrl.tick(nowMs);
// 3. Tick player state machine (i-frames)
this.psm.tick(scaledDt);
// 4. Tick attack controller
const attacks = this.attackCtrl.tick(nowMs, this.psm.color);
// 5. Tick enemies
const playerSense = {
x: this.motion.aabb.x,
y: this.motion.aabb.y,
isGrounded: this.motion.isGrounded,
};
const cull = this.camera.cullRect();
const CULLING_MARGIN = 100; // allow enemies just outside screen to still update AI (reinforcements)
const enemyActions = this.enemyMgr.update(scaledDt, nowMs, playerSense, cull as ICullingRect, CULLING_MARGIN);
// 6. Process enemy actions (spawn projectiles, etc.)
this.processEnemyActions(enemyActions);
// 6b. Tick reinforcement scheduler — spawn enemies from screen edges
this.levelElapsedSec += scaledDt;
const newReinforcements = this.reinforcementScheduler.tick(
scaledDt,
this.levelElapsedSec,
cull as ICullingRect,
GROUND_Y,
(type: EnemyType) => this.cfg.enemy(type),
);
for (const r of newReinforcements) {
this.enemyMgr.spawn(r.enemy);
// Create visual node for the reinforcement enemy
const info = enemySpriteInfo(r.enemy.type);
const ePos = this.physicsToCocos(r.enemy.pos.x, r.enemy.pos.y);
const node = createSpriteSheetFrame(
this.node,
info.res,
0,
info.fw, info.fh,
ePos.x, ePos.y,
info.fw * ENEMY_SCALE, info.fh * ENEMY_SCALE,
{ name: `Reinforce_${r.enemy.type}`, flipX: r.edge === 'right' },
);
this.enemyNodes.set(r.enemy, node);
this.promoteCtrlLayerToTop();
}
// 7. Process player attacks (spawn projectiles)
this.processPlayerAttacks(attacks);
// 8. Camera follow
this.camera.followPlayer(this.motion.aabb.x, this.motion.aabb.y);
// 9. Tick level manager
const status = this.mgr.tick(scaledDt);
// 10. Sync all visual nodes
this.syncHeroNode();
this.syncEnemyNodes();
this.syncParallax();
this.syncProjectiles(scaledDt);
this.refreshHud();
// Keep the FloatingControlLayer as the top-most sibling so touch
// events on attack/jump buttons are never shadowed by dynamically
// spawned sprites (projectiles / slash FX / re-created hero).
this.promoteCtrlLayerToTop();
// 11. Check game-over conditions
if (this.psm.isDead) {
this.mgr.onPlayerDied();
}
if (status === 'victory') {
this.unsubscribeInputEvents();
director.loadScene(this.nextSceneName || this.deriveNextScene());
} else if (status === 'timeout' || status === 'player_dead') {
this.unsubscribeInputEvents();
director.loadScene('Settlement');
}
}
protected onDestroy(): void {
this.attackCtrl?.reset();
this.unsubscribeInputEvents();
}
// ======================================================================
// Platform construction
// ======================================================================
private buildPlatforms(): IPlatform[] {
const platforms: IPlatform[] = [];
// Ground platform — spans the entire level length
const levelCfg = this.cfg.level(this.levelId);
platforms.push({
topY: GROUND_Y,
leftX: 0,
rightX: levelCfg.levelLengthPx,
});
// TODO: Add elevated platforms per level layout when available.
return platforms;
}
// ======================================================================
// UI construction
// ======================================================================
private buildDefaultUI(): void {
ensureCanvasSize(this.node);
// Parallax background (4 layers).
const theme = this.pickThemeFolder();
for (const layer of PARALLAX_LAYERS) {
const node = createSprite(
this.node,
`textures/scenes/${theme}/${layer}`,
0, 0,
DESIGN_WIDTH, DESIGN_HEIGHT,
{ name: `BG_${layer}` },
);
this.bgNodes.set(layer, node);
}
// Hero sprite (first idle frame of kage_red, 16×32 upscaled ×3).
const heroPos = this.physicsToCocos(this.motion.aabb.x, this.motion.aabb.y);
this.heroNode = createSpriteSheetFrame(
this.node,
'textures/characters/kage_red',
0,
16, 32,
heroPos.x, heroPos.y,
16 * HERO_SCALE, 32 * HERO_SCALE,
{ name: 'Hero' },
);
// Enemy sprites — one per spawned enemy.
for (const enemy of this.enemyMgr.all) {
const info = enemySpriteInfo(enemy.type);
const ePos = this.physicsToCocos(enemy.pos.x, enemy.pos.y);
const node = createSpriteSheetFrame(
this.node,
info.res,
0,
info.fw, info.fh,
ePos.x, ePos.y,
info.fw * ENEMY_SCALE, info.fh * ENEMY_SCALE,
{ name: `Enemy_${enemy.type}`, flipX: true },
);
this.enemyNodes.set(enemy, node);
}
// HUD text.
createLabel(this.node, `Level ${this.levelId}`, 0, DESIGN_HEIGHT / 2 - 50, 28, Color.WHITE);
this.hudNode = createLabel(
this.node,
'Time: --',
0,
DESIGN_HEIGHT / 2 - 90,
22,
new Color(255, 220, 120, 255),
);
}
/** Add the FloatingControlLayer component on a full-screen UI child node. */
private addControlLayer(): void {
console.log('[LevelEntry] addControlLayer start — this.node=', this.node?.name, 'isValid=', this.node?.isValid);
const ctrlNode = new Node('FloatingControlLayer');
ctrlNode.layer = this.node.layer;
this.node.addChild(ctrlNode);
this.ctrlLayerNode = ctrlNode;
const ut = ctrlNode.addComponent(UITransform);
ut.setContentSize(DESIGN_WIDTH, DESIGN_HEIGHT);
ctrlNode.setPosition(new Vec3(0, 0, 0));
// Create visual sub-nodes for joystick and buttons.
const fcl = ctrlNode.addComponent(FloatingControlLayer);
console.log('[LevelEntry] addControlLayer — FloatingControlLayer component added. fcl=', !!fcl, 'ctrlNode.isValid=', ctrlNode.isValid);
const layout = DEFAULT_LAYOUT;
// Joystick: background disc + handle.
fcl.joystickRoot = this.createJoystickVisual(ctrlNode, layout.joystick);
// Jump button.
fcl.jumpRoot = this.createButtonVisual(ctrlNode, layout.jump, 'J', new Color(80, 200, 80, 180));
// Shuriken button.
fcl.shurikenRoot = this.createButtonVisual(ctrlNode, layout.shuriken, 'S', new Color(80, 120, 220, 180));
// Ninja-sword button.
fcl.ninjaSwordRoot = this.createButtonVisual(ctrlNode, layout.ninjaSword, 'K', new Color(220, 80, 80, 180));
console.log('[LevelEntry] addControlLayer done — visuals built');
}
/**
* Ensure the FloatingControlLayer is always the top-most sibling of
* `this.node`. Dynamically-spawned sprites (projectiles / slash FX /
* re-created hero sprite) are appended to the end of the sibling list
* by default, pushing the ctrl layer down the Z-order. When that
* happens, touch events on attack/jump buttons can be intercepted by
* those sprites' UITransform bounding boxes — producing the
* "buttons occasionally unresponsive" symptom.
*
* Called in `update()` every frame as a cheap safety net.
*/
private promoteCtrlLayerToTop(): void {
const ctrl = this.ctrlLayerNode as any;
if (!ctrl || !ctrl.isValid) return;
const parent = ctrl.parent;
if (!parent) return;
const lastIndex = parent.children.length - 1;
if (ctrl.getSiblingIndex() !== lastIndex) {
ctrl.setSiblingIndex(lastIndex);
}
}
/** Create a joystick visual (semi-transparent disc + smaller handle). */
private createJoystickVisual(parent: Node, rect: { cx: number; cy: number; w: number; h: number }): Node {
const root = new Node('JoystickRoot');
root.layer = parent.layer;
parent.addChild(root);
const rootUt = root.addComponent(UITransform);
rootUt.setContentSize(rect.w, rect.h);
root.setPosition(new Vec3(rect.cx - DESIGN_WIDTH / 2, rect.cy - DESIGN_HEIGHT / 2, 0));
// Background disc.
const bg = new Node('JoystickBg');
bg.layer = parent.layer;
root.addChild(bg);
const bgUt = bg.addComponent(UITransform);
const bgRadius = rect.w / 2;
bgUt.setContentSize(rect.w, rect.h);
const bgGfx = bg.addComponent(Graphics);
bgGfx.fillColor = new Color(255, 255, 255, 50);
bgGfx.strokeColor = new Color(255, 255, 255, 100);
bgGfx.circle(0, 0, bgRadius);
bgGfx.fill();
bgGfx.stroke();
// Handle (smaller circle, offset to show direction).
const handle = new Node('JoystickHandle');
handle.layer = parent.layer;
root.addChild(handle);
const handleRadius = bgRadius * 0.4;
const handleUt = handle.addComponent(UITransform);
handleUt.setContentSize(handleRadius * 2, handleRadius * 2);
const handleGfx = handle.addComponent(Graphics);
handleGfx.fillColor = new Color(255, 255, 255, 140);
handleGfx.strokeColor = new Color(255, 255, 255, 200);
handleGfx.circle(0, 0, handleRadius);
handleGfx.fill();
handleGfx.stroke();
return root;
}
/** Create a simple circular button with a text label. */
private createButtonVisual(
parent: Node,
rect: { cx: number; cy: number; w: number; h: number },
label: string,
tint: Color,
): Node {
const root = new Node(`Btn_${label}`);
root.layer = parent.layer;
parent.addChild(root);
const radius = Math.min(rect.w, rect.h) / 2;
const rootUt = root.addComponent(UITransform);
rootUt.setContentSize(rect.w, rect.h);
root.setPosition(new Vec3(rect.cx - DESIGN_WIDTH / 2, rect.cy - DESIGN_HEIGHT / 2, 0));
// Circle background.
const bg = new Node('Bg');
bg.layer = parent.layer;
root.addChild(bg);
const bgUt = bg.addComponent(UITransform);
bgUt.setContentSize(rect.w, rect.h);
const bgGfx = bg.addComponent(Graphics);
bgGfx.fillColor = tint;
bgGfx.strokeColor = new Color(255, 255, 255, 120);
bgGfx.circle(0, 0, radius);
bgGfx.fill();
bgGfx.stroke();
// Text label.
const lblNode = new Node('Label');
lblNode.layer = parent.layer;
root.addChild(lblNode);
const lblUt = lblNode.addComponent(UITransform);
lblUt.setContentSize(rect.w, rect.h);
const lbl = lblNode.addComponent(Label);
lbl.string = label;
lbl.fontSize = 28;
lbl.lineHeight = 28;
lbl.color = new Color(255, 255, 255, 220);
return root;
}
// ======================================================================
// Input event subscription
// ======================================================================
private subscribeInputEvents(): void {
// Defensive: clear any stale handlers left by a previous LevelEntry
// instance that was destroyed before unsubscribeInputEvents() ran.
// Only clear the events that LevelEntry registers — not ButtonVisualChanged
// or other events that might be registered by other components.
const eventsToClear = [
InputEvents.JoystickMove,
InputEvents.JumpPressed,
InputEvents.JumpReleased,
InputEvents.ShurikenPressed,
InputEvents.ShurikenReleased,
InputEvents.NinjaSwordPressed,
InputEvents.NinjaSwordReleased,
];
for (const event of eventsToClear) {
globalEventBus.off(event);
}
const onJoystickMove = (payload: { dx: number; dy: number; klass: JoystickAngleClass }) => {
console.log('[LevelEntry] JoystickMove:', payload.klass);
this.currentJoystickKlass = payload.klass;
if (payload.klass === 'none') {
this.currentHorizontalInput = 0;
} else if (payload.dx >= 0) {
this.currentHorizontalInput = 1;
} else {
this.currentHorizontalInput = -1;
}
};
const onJumpPressed = () => {
const nowMs = globalTimeMgr.realTime * 1000;
console.log('[LevelEntry] JumpPressed at nowMs=', nowMs, 'realTime=', globalTimeMgr.realTime);
this.jumpStateProvider.setLastJumpPressTs(nowMs);
this.jumpCtrl.pressJump(nowMs);
};
const onJumpReleased = (payload: { holdMs: number }) => {
const nowMs = globalTimeMgr.realTime * 1000;
console.log('[LevelEntry] JumpReleased holdMs=', payload.holdMs);
this.jumpCtrl.releaseJump(nowMs, this.currentJoystickKlass, this.psm.color);
};
const onShurikenPressed = () => {
const nowMs = globalTimeMgr.realTime * 1000;
console.log('[LevelEntry] ShurikenPressed at nowMs=', nowMs, 'active=', this.attackCtrl.getActive(), 'pressed=', this.attackCtrl.isPressed(WeaponType.Shuriken));
this.attackCtrl.press(WeaponType.Shuriken, nowMs);
};
const onShurikenReleased = () => {
console.log('[LevelEntry] ShurikenReleased');
this.attackCtrl.release(WeaponType.Shuriken);
};
const onNinjaSwordPressed = () => {
const nowMs = globalTimeMgr.realTime * 1000;
console.log('[LevelEntry] NinjaSwordPressed at nowMs=', nowMs);
this.attackCtrl.press(WeaponType.NinjaSword, nowMs);
};
const onNinjaSwordReleased = () => {
console.log('[LevelEntry] NinjaSwordReleased');
this.attackCtrl.release(WeaponType.NinjaSword);
};
const handlers: Array<[string, (payload: any) => void]> = [
[InputEvents.JoystickMove, onJoystickMove],
[InputEvents.JumpPressed, onJumpPressed],
[InputEvents.JumpReleased, onJumpReleased],
[InputEvents.ShurikenPressed, onShurikenPressed],
[InputEvents.ShurikenReleased, onShurikenReleased],
[InputEvents.NinjaSwordPressed, onNinjaSwordPressed],
[InputEvents.NinjaSwordReleased, onNinjaSwordReleased],
];
for (const [event, handler] of handlers) {
globalEventBus.on(event, handler);
this.boundHandlers.push([event, handler]);
}
console.log('[LevelEntry] subscribeInputEvents done — registered', handlers.length, 'handlers. Listener counts:',
Object.values(InputEvents).map(e => `${e}=${globalEventBus.listenerCount(e)}`).join(', '));
}
private unsubscribeInputEvents(): void {
for (const [event, handler] of this.boundHandlers) {
globalEventBus.off(event, handler);
}
this.boundHandlers.length = 0;
}
// ======================================================================
// Visual sync — physics coords → Cocos node positions
// ======================================================================
/** Convert physics world coords (+y up) to Cocos node position (centred at 0,0). */
private physicsToCocos(physX: number, physY: number): Vec3 {
return new Vec3(
physX - DESIGN_WIDTH / 2 - this.camera.offsetX,
physY - DESIGN_HEIGHT / 2 - this.camera.offsetY,
0,
);
}
private syncHeroNode(): void {
if (!this.heroNode || !this.heroNode.isValid) return;
const pos = this.physicsToCocos(this.motion.aabb.x, this.motion.aabb.y);
this.heroNode.setPosition(pos);
// Flip sprite based on horizontal velocity.
if (this.motion.vx > 0) {
this.heroNode.setScale(Math.abs(this.heroNode.scale.x), this.heroNode.scale.y, this.heroNode.scale.z);
} else if (this.motion.vx < 0) {
this.heroNode.setScale(-Math.abs(this.heroNode.scale.x), this.heroNode.scale.y, this.heroNode.scale.z);
}
// Update color-state texture.
this.updateHeroTexture();
}
private _lastColorState: PlayerColorState = PlayerColorState.Red;
private updateHeroTexture(): void {
if (this.psm.color === this._lastColorState) return;
this._lastColorState = this.psm.color;
if (!this.heroNode || !this.heroNode.isValid) return;
const texMap: Record<PlayerColorState, string> = {
[PlayerColorState.Red]: 'textures/characters/kage_red',
[PlayerColorState.Green]: 'textures/characters/kage_green',
[PlayerColorState.Yellow]: 'textures/characters/kage_yellow',
};
// Recreate the hero node with the new texture.
// Use the physics model position directly (avoids CC 3.x Node.position getter issues).
const cocosPos = this.physicsToCocos(this.motion.aabb.x, this.motion.aabb.y);
const facingRight = this.motion.vx >= 0;
this.heroNode.destroy();
this.heroNode = createSpriteSheetFrame(
this.node,
texMap[this.psm.color],
0,
16, 32,
cocosPos.x, cocosPos.y,
16 * HERO_SCALE, 32 * HERO_SCALE,
{ name: 'Hero' },
);
if (!facingRight) {
this.heroNode.setScale(-HERO_SCALE, HERO_SCALE, HERO_SCALE);
}
// Newly-added hero sprite would otherwise sit on top of the ctrl
// layer and shadow touch input — promote ctrl layer back to top.
this.promoteCtrlLayerToTop();
}
private syncEnemyNodes(): void {
for (const [enemy, node] of this.enemyNodes) {
if (!node.isValid || !enemy.alive) {
if (node.isValid) {
node.active = false;
}
continue;
}
const pos = this.physicsToCocos(enemy.pos.x, enemy.pos.y);
node.setPosition(pos);
}
}
private syncParallax(): void {
for (const layer of PARALLAX_LAYERS) {
const node = this.bgNodes.get(layer);
if (!node || !node.isValid) continue;
const offset = this.camera.offsetForLayer(layer);
// Parallax scroll: camera moves right → bg shifts left; camera moves up → bg shifts down.
node.setPosition(new Vec3(-offset.x, -offset.y, 0));
}
}
// ======================================================================
// Projectile handling (visual only — spawn & move)
// ======================================================================
private processEnemyActions(actions: IEnemyAction[]): void {
for (const action of actions) {
if (action.kind === 'fire_bullet' && action.originX !== undefined && action.originY !== undefined) {
this.spawnProjectile(
action.attackType ?? 'shuriken',
action.originX,
action.originY,
action.velX ?? 0,
action.velY ?? 0,
true, // enemy projectile → red tint
);
}
}
}
private processPlayerAttacks(attacks: IAttackDispatchEvent[]): void {
for (const atk of attacks) {
const heroX = this.motion.aabb.x;
const heroY = this.motion.aabb.y;
const direction = this.motion.vx >= 0 ? 1 : -1;
if (atk.weapon === WeaponType.Shuriken) {
this.spawnProjectile(
'shuriken',
heroX + direction * 20,
heroY + 10,
direction * 350,
0,
false, // player projectile → white tint
);
} else if (atk.weapon === WeaponType.NinjaSword) {
// Sword is melee — check hit against enemies directly.
this.processSwordHit(heroX, heroY, direction);
}
}
}
private processSwordHit(heroX: number, heroY: number, direction: number): void {
const swordRange = 50;
const weaponCfg = this.cfg.weapon(WeaponType.NinjaSword);
for (const enemy of this.enemyMgr.all) {
if (!enemy.alive) continue;
const dx = enemy.pos.x - heroX;
const dy = enemy.pos.y - heroY;
if (Math.abs(dx) <= swordRange && Math.abs(dy) <= 40 && (direction >= 0 ? dx >= 0 : dx <= 0)) {
const remainingHp = this.dmgSystem.applyToEnemy(enemy.hp, weaponCfg.damage);
enemy.hp = remainingHp;
if (remainingHp <= 0) {
this.enemyMgr.kill(enemy);
this.mgr.onEnemyKilled(enemy.type);
// Hide the visual node.
const node = this.enemyNodes.get(enemy);
if (node && node.isValid) node.active = false;
}
}
}
// Sword active parry window (req 3.7-3.8).
this.psm.setSwordActive(true);
// Deactivate after a short delay (handled by next frame reset).
setTimeout(() => this.psm.setSwordActive(false), 200);
// Visual feedback: brief slash arc in front of the hero.
this.showSwordSlash(heroX, heroY, direction);
}
/** Show a brief slash flash in front of the hero for visual feedback. */
private showSwordSlash(physX: number, physY: number, direction: number): void {
const pos = this.physicsToCocos(physX + direction * 35, physY);
const slashNode = new Node('SwordSlash');
slashNode.layer = this.node.layer;
this.node.addChild(slashNode);
const ut = slashNode.addComponent(UITransform);
ut.setContentSize(40, 50);
slashNode.setPosition(new Vec3(pos.x, pos.y, 0));
const sp = slashNode.addComponent(Sprite);
sp.sizeMode = Sprite.SizeMode.CUSTOM;
sp.color = new Color(255, 255, 220, 200);
// Restore ctrl-layer Z-order after appending the slash sprite.
this.promoteCtrlLayerToTop();
// Auto-remove after 120ms.
setTimeout(() => {
if (slashNode.isValid) {
slashNode.removeFromParent();
slashNode.destroy();
}
}, 120);
}
private spawnProjectile(
attackType: string,
x: number, y: number,
velX: number, velY: number,
isEnemy: boolean,
): void {
const resPath = attackType === 'fireball'
? 'textures/fx/parry_spark' // reuse spark as fireball placeholder
: attackType === 'smoke_bomb'
? 'textures/fx/jump_dust' // reuse dust as smoke placeholder
: isEnemy
? 'textures/enemies/qing_ren' // enemy shuriken uses enemy sheet
: 'textures/characters/kage_red'; // player shuriken uses hero sheet
const projSize = attackType === 'smoke_bomb' ? 24 : 20;
const frameSize = 16;
const node = createSpriteSheetFrame(
this.node,
resPath,
0,
frameSize, frameSize,
0, 0, // position set in syncProjectiles
projSize, projSize,
{ name: `Projectile_${attackType}`, flipX: isEnemy },
);
// Store physics state on the node's userData for movement tracking.
(node as any)._projState = { x, y, velX, velY, isEnemy, attackType, alive: true };
this.projectileNodes.push(node);
// Restore ctrl-layer Z-order after appending the projectile sprite.
this.promoteCtrlLayerToTop();
}
private syncProjectiles(dt: number): void {
const toRemove: number[] = [];
for (let i = 0; i < this.projectileNodes.length; i++) {
const node = this.projectileNodes[i];
const state = (node as any)._projState;
if (!state || !state.alive || !node.isValid) {
toRemove.push(i);
continue;
}
// Move the projectile.
state.x += state.velX * dt;
state.y += state.velY * dt;
const pos = this.physicsToCocos(state.x, state.y);
node.setPosition(pos);
// Check bounds — remove if far outside the camera's culling rect.
const cull = this.camera.cullRect();
const margin = 200;
if (state.x < cull.leftX - margin || state.x > cull.rightX + margin ||
state.y < cull.bottomY - margin || state.y > cull.topY + margin) {
state.alive = false;
toRemove.push(i);
continue;
}
// Check collision.
if (state.isEnemy) {
// Enemy projectile → check against player.
const dx = state.x - this.motion.aabb.x;
const dy = state.y - this.motion.aabb.y;
if (Math.abs(dx) < 20 && Math.abs(dy) < 30) {
const outcome = this.dmgSystem.applyToPlayer({
attackType: state.attackType as any,
attackerX: state.x,
attackerY: state.y,
victimX: this.motion.aabb.x,
victimY: this.motion.aabb.y,
});
// Only destroy the projectile if it actually dealt damage
// (downgraded or died). On 'no_effect' (i-frames / parry),
// let the projectile fly through the player unharmed.
if (outcome && outcome.kind !== 'no_effect') {
state.alive = false;
toRemove.push(i);
}
}
} else {
// Player projectile → check against enemies.
for (const enemy of this.enemyMgr.all) {
if (!enemy.alive) continue;
const dx = state.x - enemy.pos.x;
const dy = state.y - enemy.pos.y;
if (Math.abs(dx) < 20 && Math.abs(dy) < 20) {
const weaponCfg = this.cfg.weapon(WeaponType.Shuriken);
const remainingHp = this.dmgSystem.applyToEnemy(enemy.hp, weaponCfg.damage);
enemy.hp = remainingHp;
if (remainingHp <= 0) {
this.enemyMgr.kill(enemy);
this.mgr.onEnemyKilled(enemy.type);
const eNode = this.enemyNodes.get(enemy);
if (eNode && eNode.isValid) eNode.active = false;
}
state.alive = false;
toRemove.push(i);
break;
}
}
}
}
// Remove dead projectiles (iterate in reverse).
for (let i = toRemove.length - 1; i >= 0; i--) {
const idx = toRemove[i];
const node = this.projectileNodes[idx];
if (node && node.isValid) {
node.removeFromParent();
node.destroy();
}
this.projectileNodes.splice(idx, 1);
}
}
// ======================================================================
// Scene flow
// ======================================================================
private deriveNextScene(): string {
const map: Record<string, string> = {
'1-1': 'Level_1_2',
@@ -60,17 +935,18 @@ export class LevelEntry extends Component {
return map[this.levelId] ?? 'Settlement';
}
private buildDefaultUI(): void {
ensureCanvasSize(this.node);
createLabel(this.node, `Level ${this.levelId}`, 0, DESIGN_HEIGHT / 2 - 50, 28, Color.WHITE);
this.hudNode = createLabel(
this.node,
'Time: --',
0,
DESIGN_HEIGHT / 2 - 90,
22,
new Color(255, 220, 120, 255),
);
private pickThemeFolder(): 'forest' | 'castle_wall' | 'demon_castle' {
switch (this.levelId) {
case '1-1':
case '1-2':
return 'forest';
case '1-3':
case '1-4':
return 'castle_wall';
case '1-5':
default:
return 'demon_castle';
}
}
private refreshHud(): void {
@@ -78,7 +954,8 @@ export class LevelEntry extends Component {
const lb = this.hudNode.getComponent(Label);
if (lb) {
const r = this.mgr.result();
lb.string = `Time: ${Math.max(0, Math.ceil(r.remainingSec))}s Kills: ${Object.values(r.kills).reduce((a, b) => a + b, 0)}`;
const colorStr = this.psm.color.charAt(0).toUpperCase() + this.psm.color.slice(1);
lb.string = `Time: ${Math.max(0, Math.ceil(r.remainingSec))}s Kills: ${Object.values(r.kills).reduce((a, b) => a + b, 0)} Lives: ${this.psm.lives} [${colorStr}]`;
}
}
}
+260 -12
View File
@@ -1,4 +1,4 @@
import { _decorator, Component, director, Node, Label, Button, UITransform, Color, Vec3, Graphics } from 'cc';
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';
@@ -28,6 +28,16 @@ const LEVEL_SCENE_MAP: Record<string, string> = {
'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).
*
@@ -35,9 +45,13 @@ const LEVEL_SCENE_MAP: Record<string, string> = {
* callback into a concrete `director.loadScene` call. Attach this component
* to the root node of `MainMenu.scene`.
*
* When `autoBuildUI` is enabled (default), two centered buttons (Start /
* Settings) and a title label are created programmatically so the scene is
* usable out of the box even before any art pass.
* 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 {
@@ -45,12 +59,16 @@ export class MainMenuEntry extends Component {
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.buildDefaultUI();
if (this.autoBuildUI) this.buildMainMenuUI();
}
/** Bind this to the "Start" button's click event in the Inspector. */
@@ -63,8 +81,20 @@ export class MainMenuEntry extends Component {
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) {
@@ -72,28 +102,100 @@ export class MainMenuEntry extends Component {
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) director.loadScene(physical);
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 buildDefaultUI(): void {
private buildMainMenuUI(): void {
// Ensure the host node has a UITransform matching the design resolution.
ensureCanvasSize(this.node);
// Title.
createLabel(this.node, '影 之 传 说', 0, 140, 44, Color.WHITE);
this.menuNodes.push(createLabel(this.node, '影 之 传 说', 0, 140, 44, Color.WHITE));
// Start button (centered, 40 above origin).
createButton(this.node, 'Start', 0, 40, 220, 60, () => this.onPressStart());
this.menuNodes.push(createButton(this.node, 'Start', 0, 40, 220, 60, () => this.onPressStart()));
// Settings button (centered, 40 below origin).
createButton(this.node, 'Settings', 0, -40, 220, 60, () => this.onPressSettings());
this.menuNodes.push(createButton(this.node, 'Settings', 0, -40, 220, 60, () => this.onPressSettings()));
// Hint line at the bottom.
createLabel(this.node, 'Chapter 1 · MVP', 0, -200, 20, new Color(180, 180, 180, 255));
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()),
);
}
}
@@ -179,4 +281,150 @@ function createButton(
}
// Re-export helpers so sibling Scene Entries can reuse them.
export { ensureCanvasSize, createLabel, createButton };
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;
}
@@ -1,9 +1,16 @@
import { _decorator, Component, director, Label, Node, Color, EventTouch, UITransform } from 'cc';
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 } from './MainMenuEntry';
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;
@@ -30,8 +37,15 @@ export class StorySceneEntry extends Component {
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();
}
@@ -41,6 +55,7 @@ export class StorySceneEntry extends Component {
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();
@@ -75,16 +90,75 @@ export class StorySceneEntry extends Component {
return mgr.story(this.storyId);
}
private buildDefaultUI(): void {
/** 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);
// Ensure the root node can receive touch (size = design resolution).
// Central typewriter label.
this.labelNode = createLabel(this.node, '', 0, 0, 28, Color.WHITE);
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, DESIGN_HEIGHT - 120);
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 + 50;
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;
});
});
}
}