962 lines
39 KiB
TypeScript
962 lines
39 KiB
TypeScript
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}]`;
|
||
}
|
||
}
|
||
}
|