Files
KateLegend2_proj/assets/scripts/scene_entries/LevelEntry.ts
T
2026-06-07 22:10:03 +08:00

962 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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();
// 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, 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',
'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}]`;
}
}
}