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, 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). * * 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 { @property({ tooltip: '本关的 levelId (与 configs/levels.json 对应),如 1-1 / 1-2 / 1-3 / 1-4 / 1-5' }) public levelId: string = '1-1'; @property({ tooltip: '胜利后跳转的场景,留空则按 1-1 → 1-2 → ... → 1-5 → Boss 自动推导' }) public nextSceneName: string = ''; @property({ tooltip: '是否自动创建关卡 HUD (顶部显示 Level / 倒计时)' }) public autoBuildUI: boolean = true; // -- 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(); /** Parallax background nodes keyed by layer name. */ private bgNodes = new Map(); /** 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 { // 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(); // 6. Add FloatingControlLayer this.addControlLayer(); // 7. Subscribe to input events this.subscribeInputEvents(); } protected update(dt: number): void { // 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.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 = { '1-1': 'Level_1_2', '1-2': 'Level_1_3', '1-3': 'Level_1_4', '1-4': 'Level_1_5', '1-5': 'Boss_ShuangHuanFang', }; return map[this.levelId] ?? 'Settlement'; } 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 { if (!this.hudNode || !this.mgr) return; const lb = this.hudNode.getComponent(Label); if (lb) { const r = this.mgr.result(); 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}]`; } } }