import { _decorator, AudioClip, AudioSource, Camera, Canvas, Color, Component, EventKeyboard, EventMouse, EventTouch, Graphics, ImageAsset, input, Input, KeyCode, Label, Layers, Node, resources, ResolutionPolicy, Sprite, SpriteFrame, UIOpacity, UITransform, Vec3, view } from 'cc'; import { HP_PANEL_DATA_URI, LEVEL1_BACKGROUND_DATA_URI } from './generatedLevelArtV2'; const { ccclass, property } = _decorator; type WeaponId = 'rifle' | 'flame' | 'rail'; type EnemyType = 'spore' | 'wing' | 'turret' | 'boss'; type AudioId = 'bgm_level1_loop' | 'shot_rifle' | 'shot_flame' | 'shot_rail' | 'jump' | 'switch_weapon' | 'boss_alert'; type MobileAction = 'left' | 'right' | 'up' | 'fire' | 'switch'; interface Enemy { type: EnemyType; x: number; y: number; hp: number; maxHp: number; vx: number; cd: number; } interface Obstacle { x: number; y: number; width: number; height: number; } interface UiRect { x: number; y: number; width: number; height: number; } interface Bullet { x: number; y: number; vx: number; vy: number; life: number; damage: number; player: boolean; pierce: boolean; kind: WeaponId | 'enemy'; homing?: boolean; hitEnemies?: Set; } const W = 1280; const H = 720; const SCALE = H / 540; const U = (value: number) => Math.round(value * SCALE); const US = (value: number) => value * SCALE; const GROUND = U(-174); const LANE_Y = GROUND + U(62); const BOSS_Y = LANE_Y + U(76); const END_X = U(3320); const ACTOR_RENDER_Y_OFFSET = 0; const VISUAL_LANE_Y = LANE_Y + ACTOR_RENDER_Y_OFFSET; const ENEMY_BULLET_HIT_RADIUS = U(24); const BOSS_MAX_HP = 300; const BOSS_PHASE_TWO_RATIO = 0.6; const BOSS_ENRAGE_RATIO = 0.2; const WEAPONS = { rifle: { name: 'RIFLE', cd: 0.11, damage: 4, speed: U(880) }, flame: { name: 'FLAME', cd: 0.055, damage: 1.4, speed: U(520) }, rail: { name: 'RAIL', cd: 0.58, damage: 5, speed: U(1250) } }; function enemyVisualProjectileOffset(type: EnemyType) { if (type === 'boss') return U(18); return U(8); } @ccclass('SteelAssaultGame') export class SteelAssaultGame extends Component { @property autoStart = true; private cameraNode = new Node('RuntimeCamera'); private backgroundLayer = new Node('RuntimeBackground'); private renderNode = new Node('RuntimeGraphics'); private spriteLayer = new Node('RuntimeSprites'); private propLayer = new Node('RuntimeProps'); private uiArtLayer = new Node('RuntimeUIArt'); private outcomeButtonLayer = new Node('RuntimeOutcomeButtons'); private hudNode = new Node('RuntimeHUD'); private graphics!: Graphics; private outcomeButtonGraphics!: Graphics; private hud!: Label; private hpHud!: Label; private scoreHud!: Label; private weaponHud!: Label; private outcomeTitle!: Label; private outcomeStats!: Label; private outcomeAction!: Label; private outcomeNextAction!: Label; private outcomeRestartAction!: Label; private keys = new Set(); private weapon: WeaponId = 'rifle'; private player = { x: U(-360), y: LANE_Y, vx: 0, vy: 0, hp: 6, face: 1, grounded: true }; private previousPlayerY = LANE_Y; private enemies: Enemy[] = []; private bullets: Bullet[] = []; private spawned = new Set(); private boss: Enemy | null = null; private fireCd = 0; private mercy = 0; private cameraX = 0; private score = 0; private state: 'playing' | 'win' | 'next' | 'lose' = 'playing'; private bossTime = 0; private bossEnrageAnnounced = false; private bossRoarTimer = 0; private elapsed = 0; private spritesReady = false; private heroSprite: Sprite | null = null; private enemySprites = new Map(); private bulletSprites = new Map(); private obstacleSprites = new Map(); private heroFrames: SpriteFrame[] = []; private sporeFrames: SpriteFrame[] = []; private wingFrames: SpriteFrame[] = []; private turretFrames: SpriteFrame[] = []; private bossFrames: SpriteFrame[] = []; private projectileFrames: SpriteFrame[] = []; private sceneFrames = new Map(); private uiFrames = new Map(); private backgroundSprite: Sprite | null = null; private backgroundSprites: Sprite[] = []; private failOverlaySprite: Sprite | null = null; private outcomePanelSprite: Sprite | null = null; private hpPanelSprite: Sprite | null = null; private weaponSprite: Sprite | null = null; private heroWeaponSprite: Sprite | null = null; private weaponSlotSprites = new Map(); private touchLeftSprite: Sprite | null = null; private touchRightSprite: Sprite | null = null; private touchJumpSprite: Sprite | null = null; private touchFireSprite: Sprite | null = null; private touchSwitchSprite: Sprite | null = null; private retrySprite: Sprite | null = null; private hpSprites: Sprite[] = []; private propSprites: Array<{ sprite: Sprite; worldX: number; y: number; scale: number }> = []; private createdProps = new Set(); private weaponSwitchTimer = 0; private musicSource!: AudioSource; private sfxSource!: AudioSource; private audioClips = new Map(); private audioUnlocked = false; private musicStarted = false; private mobileLeft = false; private mobileRight = false; private mobileUp = false; private mobileDown = false; private mobileFire = false; private joystickTilt = { x: 0, y: 0 }; private pointerActions = new Map(); private switchInputLock = 0; private viewportWidth = W; private sceneReady = false; private waves = [ { x: U(420) }, { x: U(860) }, { x: U(1280) }, { x: U(1680) }, { x: U(2200) }, { x: U(2700) } ]; private obstacles: Obstacle[] = [ { x: U(520), y: LANE_Y, width: U(118), height: U(58) }, { x: U(1120), y: LANE_Y, width: U(138), height: U(72) }, { x: U(2220), y: LANE_Y, width: U(132), height: U(64) }, { x: U(2600), y: LANE_Y + U(8), width: U(132), height: U(58) }, { x: U(2760), y: LANE_Y + U(78), width: U(126), height: U(56) }, { x: U(2940), y: LANE_Y + U(48), width: U(132), height: U(58) }, { x: U(3100), y: LANE_Y + U(132), width: U(138), height: U(56) }, { x: U(3190), y: LANE_Y + U(82), width: U(132), height: U(72) } ]; onLoad() { console.log('[SteelAssaultGame] boot'); this.bootstrapScene(); input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this); input.on(Input.EventType.KEY_UP, this.onKeyUp, this); input.on(Input.EventType.TOUCH_START, this.onTouchStart, this); input.on(Input.EventType.TOUCH_MOVE, this.onTouchMove, this); input.on(Input.EventType.TOUCH_END, this.onTouchEnd, this); input.on(Input.EventType.TOUCH_CANCEL, this.onTouchEnd, this); input.on(Input.EventType.MOUSE_DOWN, this.onMouseDown, this); input.on(Input.EventType.MOUSE_MOVE, this.onMouseMove, this); input.on(Input.EventType.MOUSE_UP, this.onMouseUp, this); } onDestroy() { view.setResizeCallback(null); input.off(Input.EventType.KEY_DOWN, this.onKeyDown, this); input.off(Input.EventType.KEY_UP, this.onKeyUp, this); input.off(Input.EventType.TOUCH_START, this.onTouchStart, this); input.off(Input.EventType.TOUCH_MOVE, this.onTouchMove, this); input.off(Input.EventType.TOUCH_END, this.onTouchEnd, this); input.off(Input.EventType.TOUCH_CANCEL, this.onTouchEnd, this); input.off(Input.EventType.MOUSE_DOWN, this.onMouseDown, this); input.off(Input.EventType.MOUSE_MOVE, this.onMouseMove, this); input.off(Input.EventType.MOUSE_UP, this.onMouseUp, this); } update(dt: number) { this.elapsed += dt; if (this.state === 'playing') { this.updatePlayer(dt); this.updateObstacleHits(); this.updateWaves(); this.updateEnemies(dt); this.updateBullets(dt); this.weaponSwitchTimer = Math.max(0, this.weaponSwitchTimer - dt); this.switchInputLock = Math.max(0, this.switchInputLock - dt); this.cameraX = Math.max(0, Math.min(END_X - 600, this.player.x - 120)); } this.draw(); } private bootstrapScene() { this.hideBootCanvas(); this.node.layer = Layers.Enum.UI_2D; const transform = this.node.getComponent(UITransform) ?? this.node.addComponent(UITransform); transform.setContentSize(this.viewportWidth, H); const canvas = this.node.getComponent(Canvas) ?? this.node.addComponent(Canvas); canvas.alignCanvasWithScreen = true; this.node.addChild(this.cameraNode); this.cameraNode.layer = Layers.Enum.UI_2D; this.cameraNode.setPosition(0, 0, 1000); const camera = this.cameraNode.addComponent(Camera); //camera.orthoHeight = H / 2; camera.visibility = Layers.Enum.UI_2D; camera.clearColor = new Color(7, 12, 24, 255); canvas.cameraComponent = camera; const musicNode = new Node('RuntimeMusic'); musicNode.layer = Layers.Enum.UI_2D; this.node.addChild(musicNode); this.musicSource = musicNode.addComponent(AudioSource); this.musicSource.loop = true; this.musicSource.volume = 0.34; const sfxNode = new Node('RuntimeSfx'); sfxNode.layer = Layers.Enum.UI_2D; this.node.addChild(sfxNode); this.sfxSource = sfxNode.addComponent(AudioSource); this.sfxSource.volume = 0.9; this.node.addChild(this.backgroundLayer); this.backgroundLayer.layer = Layers.Enum.UI_2D; this.backgroundLayer.addComponent(UITransform).setContentSize(this.viewportWidth, H); this.node.addChild(this.renderNode); this.renderNode.layer = Layers.Enum.UI_2D; this.renderNode.addComponent(UITransform).setContentSize(this.viewportWidth, H); this.graphics = this.renderNode.addComponent(Graphics); this.node.addChild(this.propLayer); this.propLayer.layer = Layers.Enum.UI_2D; this.propLayer.addComponent(UITransform).setContentSize(this.viewportWidth, H); this.node.addChild(this.spriteLayer); this.spriteLayer.layer = Layers.Enum.UI_2D; this.spriteLayer.addComponent(UITransform).setContentSize(this.viewportWidth, H); this.node.addChild(this.uiArtLayer); this.uiArtLayer.layer = Layers.Enum.UI_2D; this.uiArtLayer.addComponent(UITransform).setContentSize(this.viewportWidth, H); this.node.addChild(this.outcomeButtonLayer); this.outcomeButtonLayer.layer = Layers.Enum.UI_2D; this.outcomeButtonLayer.addComponent(UITransform).setContentSize(this.viewportWidth, H); this.outcomeButtonGraphics = this.outcomeButtonLayer.addComponent(Graphics); this.bindOutcomeRestartNode(this.outcomeButtonLayer); this.node.addChild(this.hudNode); this.hudNode.layer = Layers.Enum.UI_2D; this.hudNode.addComponent(UITransform).setContentSize(W, U(64)); this.hudNode.setPosition(0, H / 2 - U(34), 0); this.hud = this.hudNode.addComponent(Label); this.hud.fontSize = 22; this.hud.lineHeight = 28; this.hud.color = new Color(230, 250, 255, 255); this.hud.string = 'STEEL ASSAULT'; const half = this.visibleWorldWidth() / 2; const hpNode = new Node('HpHud'); hpNode.layer = Layers.Enum.UI_2D; this.node.addChild(hpNode); hpNode.addComponent(UITransform).setContentSize(U(210), U(52)); hpNode.setPosition(-half + U(112), H / 2 - U(30), 8); this.hpHud = hpNode.addComponent(Label); this.hpHud.fontSize = 30; this.hpHud.lineHeight = 34; this.hpHud.color = new Color(255, 255, 255, 255); const scoreNode = new Node('ScoreHud'); scoreNode.layer = Layers.Enum.UI_2D; this.node.addChild(scoreNode); scoreNode.addComponent(UITransform).setContentSize(U(300), U(52)); scoreNode.setPosition(half - U(126), H / 2 - U(30), 8); this.scoreHud = scoreNode.addComponent(Label); this.scoreHud.fontSize = 30; this.scoreHud.lineHeight = 34; this.scoreHud.color = new Color(255, 244, 165, 255); const weaponNode = new Node('WeaponHud'); weaponNode.layer = Layers.Enum.UI_2D; this.node.addChild(weaponNode); weaponNode.addComponent(UITransform).setContentSize(U(276), U(28)); weaponNode.setPosition(half - U(126), H / 2 - U(60), 24); this.weaponHud = weaponNode.addComponent(Label); this.weaponHud.fontSize = 16; this.weaponHud.lineHeight = 20; this.weaponHud.color = new Color(230, 255, 255, 255); const outcomeTitleNode = new Node('OutcomeTitle'); outcomeTitleNode.layer = Layers.Enum.UI_2D; this.node.addChild(outcomeTitleNode); outcomeTitleNode.addComponent(UITransform).setContentSize(U(720), U(72)); outcomeTitleNode.setPosition(0, U(70), 28); this.outcomeTitle = outcomeTitleNode.addComponent(Label); this.outcomeTitle.fontSize = 46; this.outcomeTitle.lineHeight = 54; this.outcomeTitle.color = new Color(255, 244, 168, 255); const outcomeStatsNode = new Node('OutcomeStats'); outcomeStatsNode.layer = Layers.Enum.UI_2D; this.node.addChild(outcomeStatsNode); outcomeStatsNode.addComponent(UITransform).setContentSize(U(720), U(56)); outcomeStatsNode.setPosition(0, U(10), 28); this.outcomeStats = outcomeStatsNode.addComponent(Label); this.outcomeStats.fontSize = 28; this.outcomeStats.lineHeight = 34; this.outcomeStats.color = new Color(225, 250, 255, 255); const outcomeActionNode = new Node('OutcomeAction'); outcomeActionNode.layer = Layers.Enum.UI_2D; this.node.addChild(outcomeActionNode); outcomeActionNode.addComponent(UITransform).setContentSize(U(620), U(90)); outcomeActionNode.setPosition(0, U(-92), 28); this.outcomeAction = outcomeActionNode.addComponent(Label); this.outcomeAction.fontSize = 28; this.outcomeAction.lineHeight = 36; this.outcomeAction.color = new Color(14, 29, 36, 255); const outcomeNextNode = new Node('OutcomeNextAction'); outcomeNextNode.layer = Layers.Enum.UI_2D; this.node.addChild(outcomeNextNode); outcomeNextNode.addComponent(UITransform).setContentSize(U(220), U(64)); outcomeNextNode.setPosition(U(-126), U(-100), 30); this.outcomeNextAction = outcomeNextNode.addComponent(Label); this.outcomeNextAction.fontSize = 26; this.outcomeNextAction.lineHeight = 32; this.outcomeNextAction.color = new Color(23, 32, 38, 255); const outcomeRestartNode = new Node('OutcomeRestartAction'); outcomeRestartNode.layer = Layers.Enum.UI_2D; this.node.addChild(outcomeRestartNode); outcomeRestartNode.addComponent(UITransform).setContentSize(U(220), U(64)); outcomeRestartNode.setPosition(U(126), U(-100), 30); this.outcomeRestartAction = outcomeRestartNode.addComponent(Label); this.outcomeRestartAction.fontSize = 26; this.outcomeRestartAction.lineHeight = 32; this.outcomeRestartAction.color = new Color(235, 252, 255, 255); view.resizeWithBrowserSize(true); this.applyFullscreenLandscapeLayout(); view.setResizeCallback(() => { this.applyFullscreenLandscapeLayout(); this.draw(); }); this.loadSpriteSheets(); this.loadSceneUiArt(); this.loadAudioAssets(); this.sceneReady = true; this.draw(); } private hideBootCanvas() { const bootCanvas = this.node.scene?.getChildByName('BootCanvas'); if (bootCanvas) bootCanvas.active = false; } private applyFullscreenLandscapeLayout() { // 横屏游戏标准方案:固定高度,宽度按屏幕比例自动扩展 view.setDesignResolutionSize(W, H, ResolutionPolicy.FIXED_HEIGHT); const visible = view.getVisibleSize(); const worldWidth = Math.max(W, visible.width || W); this.viewportWidth = worldWidth; const camera = this.cameraNode.getComponent(Camera); if (camera) { camera.orthoHeight = H / 2; } for (const node of [ this.node, this.backgroundLayer, this.renderNode, this.propLayer, this.spriteLayer, this.uiArtLayer, this.hudNode ]) { const transform = node.getComponent(UITransform); if (transform) { transform.setContentSize(worldWidth, H); } } this.resizeFullscreenBackground(); console.log( '[SteelAssaultGame] layout', 'frame:', view.getFrameSize(), 'visible:', view.getVisibleSize(), 'viewport:', view.getViewportRect(), 'worldWidth:', this.viewportWidth ); } private resizeFullscreenBackground() { if (!this.backgroundSprite) return; if (!this.backgroundSprite) return; const width = this.visibleWorldWidth(); this.backgroundSprite.sizeMode = Sprite.SizeMode.CUSTOM; const transform = this.backgroundSprite.node.getComponent(UITransform); if (transform) { transform.setContentSize(width, H); } this.backgroundSprite.node.setPosition(0, 0, 0); this.backgroundSprite.node.setScale(new Vec3(1, 1, 1)); } private loadSpriteSheets() { const hero = [ 'hero_idle', 'hero_walk_1', 'hero_walk_2', 'hero_walk_3', 'hero_walk_4', 'hero_jump_rise', 'hero_jump_fall' ]; const enemies = [ 'spore_idle', 'spore_walk_1', 'spore_walk_2', 'wing_idle', 'wing_flap_1', 'wing_flap_2', 'turret_idle', 'turret_aim', 'turret_fire' ]; const boss = [ 'boss_idle_closed', 'boss_idle_pulse', 'boss_attack_charge', 'boss_core_enraged' ]; const projectiles = [ 'rifle_1', 'rifle_2', 'rifle_3', 'rifle_4', 'flame_1', 'flame_2', 'flame_3', 'flame_4', 'rail_1', 'rail_2', 'acid_1', 'acid_2', 'acid_3', 'acid_4' ]; this.heroFrames = new Array(hero.length); const enemyFrames = new Array(enemies.length); this.bossFrames = new Array(boss.length); this.projectileFrames = new Array(projectiles.length); const jobs: Array<{ name: string; frames: SpriteFrame[]; index: number }> = []; hero.forEach((name, index) => jobs.push({ name, frames: this.heroFrames, index })); enemies.forEach((name, index) => jobs.push({ name, frames: enemyFrames, index })); boss.forEach((name, index) => jobs.push({ name, frames: this.bossFrames, index })); projectiles.forEach((name, index) => jobs.push({ name, frames: this.projectileFrames, index })); let pending = jobs.length; let failed = false; const done = () => { pending -= 1; if (pending === 0 && !failed) { this.sporeFrames = enemyFrames.slice(0, 3); this.wingFrames = enemyFrames.slice(3, 6); this.turretFrames = enemyFrames.slice(6, 9); this.spritesReady = true; console.log('[SteelAssaultGame] imagegen frame assets ready'); } }; for (const job of jobs) { resources.load(`sprites/imagegen/frames/${job.name}`, ImageAsset, (err, image) => { if (err || !image) { failed = true; console.warn(`[SteelAssaultGame] sprite frame fallback for ${job.name}`, err); done(); return; } job.frames[job.index] = SpriteFrame.createWithImage(image); done(); }); } } private loadSceneUiArt() { this.loadDataUriFrame(LEVEL1_BACKGROUND_DATA_URI, (frame) => { this.sceneFrames.set('background', frame); this.backgroundSprites = []; const sprite = this.createSprite('Level1Background', this.backgroundLayer); sprite.spriteFrame = frame; sprite.sizeMode = Sprite.SizeMode.CUSTOM; sprite.node.getComponent(UITransform)?.setContentSize(this.visibleWorldWidth(), H); sprite.node.setPosition(0, 0, 0); sprite.node.setScale(new Vec3(1, 1, 1)); const opacity = sprite.node.getComponent(UIOpacity) ?? sprite.node.addComponent(UIOpacity); opacity.opacity = 255; this.backgroundSprite = sprite; this.backgroundSprites.push(sprite); this.resizeFullscreenBackground(); }); this.loadDataUriFrame(HP_PANEL_DATA_URI, (frame) => { this.uiFrames.set('hp_numeric_plaque_v2', frame); this.hpPanelSprite = this.createSprite('HpNumericPanel', this.uiArtLayer); this.hpPanelSprite.spriteFrame = frame; this.hpPanelSprite.node.setPosition(-this.visibleWorldWidth() / 2 + 112, H / 2 - U(30), 6); this.hpPanelSprite.node.setScale(new Vec3(US(0.5), US(0.5), 1)); }); const ui = [ 'weapon_rifle', 'weapon_flame', 'weapon_rail', 'retry_button', 'outcome_fail_panel', 'outcome_win_panel', 'mobile_left', 'mobile_right', 'mobile_jump', 'mobile_fire', 'mobile_switch' ]; for (const name of ui) { this.loadFrame(`sprites/imagegen/ui_scene/ui/${name}`, (frame) => this.uiFrames.set(name, frame)); } this.loadFrame('sprites/imagegen/frames/obstacle_block', (frame) => this.uiFrames.set('obstacle_block', frame)); } private loadFrame(path: string, use: (frame: SpriteFrame) => void) { resources.load(path, ImageAsset, (err, image) => { if (err || !image) { console.warn(`[SteelAssaultGame] art fallback for ${path}`, err); return; } use(SpriteFrame.createWithImage(image)); }); } private loadDataUriFrame(dataUri: string, use: (frame: SpriteFrame) => void) { const image = new Image(); image.onload = () => { use(SpriteFrame.createWithImage(image)); }; image.onerror = () => { console.warn('[SteelAssaultGame] embedded image asset failed'); }; image.src = dataUri; } private loadAudioAssets() { const ids: AudioId[] = [ 'bgm_level1_loop', 'shot_rifle', 'shot_flame', 'shot_rail', 'jump', 'switch_weapon', 'boss_alert' ]; for (const id of ids) { resources.load(`audio/${id}`, AudioClip, (err, clip) => { if (err || !clip) { console.warn(`[SteelAssaultGame] audio fallback for ${id}`, err); return; } this.audioClips.set(id, clip); if (id === 'bgm_level1_loop' && this.audioUnlocked) this.startMusic(); }); } } private unlockAudio() { this.audioUnlocked = true; this.startMusic(); } private startMusic() { if (this.musicStarted || !this.musicSource) return; const clip = this.audioClips.get('bgm_level1_loop'); if (!clip) return; this.musicSource.clip = clip; this.musicSource.loop = true; this.musicSource.volume = 0.34; this.musicSource.play(); this.musicStarted = true; } private playSfx(id: AudioId, volume = 0.85) { const clip = this.audioClips.get(id); if (!clip || !this.sfxSource) return; this.sfxSource.playOneShot(clip, volume); } private updateBossEnrageAudio(enraged: boolean, dt: number) { if (!enraged) { this.bossEnrageAnnounced = false; this.bossRoarTimer = 0; return; } if (!this.bossEnrageAnnounced) { this.playSfx('boss_alert', 1.0); this.bossEnrageAnnounced = true; this.bossRoarTimer = 1.8; return; } this.bossRoarTimer -= dt; if (this.bossRoarTimer <= 0) { this.playSfx('boss_alert', 0.86); this.bossRoarTimer = 2.4; } } private updatePlayer(dt: number) { this.previousPlayerY = this.player.y; const left = this.keys.has(KeyCode.KEY_A) || this.keys.has(KeyCode.ARROW_LEFT); const right = this.keys.has(KeyCode.KEY_D) || this.keys.has(KeyCode.ARROW_RIGHT); const jump = this.keys.has(KeyCode.SPACE) || this.keys.has(KeyCode.KEY_W) || this.keys.has(KeyCode.ARROW_UP); const down = this.keys.has(KeyCode.KEY_S) || this.keys.has(KeyCode.ARROW_DOWN) || this.mobileDown; const moveLeft = left || this.mobileLeft; const moveRight = right || this.mobileRight; const fire = this.keys.has(KeyCode.KEY_J) || this.keys.has(KeyCode.ENTER) || this.mobileFire; this.player.vx = moveLeft === moveRight ? 0 : moveLeft ? -U(270) : U(270); if (this.player.vx !== 0) this.player.face = this.player.vx > 0 ? 1 : -1; if (jump || this.mobileUp) this.tryJump(); if (down && !this.player.grounded) this.player.vy -= U(1700) * dt; this.player.vy -= U(1450) * dt; this.player.x = Math.max(U(-410), Math.min(END_X - U(180), this.player.x + this.player.vx * dt)); this.player.y += this.player.vy * dt; if (this.player.y <= LANE_Y) { this.player.y = LANE_Y; this.player.vy = 0; this.player.grounded = true; } this.fireCd -= dt; this.mercy = Math.max(0, this.mercy - dt); if (fire) this.tryFire(); if (this.player.x > U(3090) && !this.boss) this.spawnBoss(); } private updateWaves() { for (let index = 0; index < this.waves.length; index++) { const wave = this.waves[index]; const activeNormalEnemies = this.enemies.filter((enemy) => enemy.type !== 'boss').length; if (!this.spawned.has(index) && this.player.x > wave.x - U(520) && activeNormalEnemies === 0) { this.spawned.add(index); this.spawnEnemy(this.randomEnemyType(index), wave.x + U(120), index); } } } private randomEnemyType(_seed: number): Exclude { return 'spore'; } private updateObstacleHits() { const playerHalfWidth = U(22); const landingTolerance = U(14); for (const obstacle of this.obstacles) { const left = obstacle.x - obstacle.width / 2; const right = obstacle.x + obstacle.width / 2; const top = obstacle.y + obstacle.height; const horizontalOverlap = this.player.x + playerHalfWidth > left && this.player.x - playerHalfWidth < right; if (!horizontalOverlap) continue; const wasNearOrAboveTop = this.previousPlayerY >= top - landingTolerance; const crossedOrRestingOnTop = this.player.y <= top + landingTolerance && this.player.y >= top - landingTolerance; const fallingOntoTop = this.player.vy <= 0 && wasNearOrAboveTop && crossedOrRestingOnTop; const alreadyOnTop = this.player.grounded && Math.abs(this.player.y - top) <= landingTolerance; if (fallingOntoTop || alreadyOnTop) { this.player.y = top; this.player.vy = 0; this.player.grounded = true; return; } const nearObstacleBody = this.player.y < top - U(10) && this.player.y > obstacle.y - U(4); if (nearObstacleBody) { const pushLeft = Math.abs(this.player.x - left); const pushRight = Math.abs(this.player.x - right); this.player.x = pushLeft < pushRight ? left - playerHalfWidth : right + playerHalfWidth; this.player.vx = 0; return; } } } private updateEnemies(dt: number) { for (const enemy of this.enemies) { enemy.cd -= dt; if (enemy.type === 'spore') { enemy.y = LANE_Y; enemy.vx = enemy.x > this.player.x ? -U(82) : U(82); } if (enemy.type === 'wing') { enemy.vx = enemy.x > this.player.x ? -U(70) : U(70); enemy.y = LANE_Y + Math.sin(this.elapsed * 3 + enemy.x * 0.02) * U(4); if (enemy.cd <= 0 && this.canEnemyShoot(enemy, U(360))) { this.enemyShot(enemy.x, enemy.y + this.enemyShotOffset(enemy), this.player.x > enemy.x ? 1 : -1, U(130)); enemy.cd = 3.4; } } if (enemy.type === 'turret') { enemy.y = LANE_Y; enemy.vx = 0; if (enemy.cd <= 0 && this.canEnemyShoot(enemy, U(420))) { this.enemyShot(enemy.x - U(20), enemy.y + this.enemyShotOffset(enemy) + U(8), this.player.x > enemy.x ? 1 : -1, U(135)); enemy.cd = 3.1; } } if (enemy.type === 'boss') { this.bossTime += dt; enemy.vx = Math.sin(this.bossTime * 0.7) * U(28); enemy.y = BOSS_Y + Math.sin(this.bossTime * 1.1) * U(10); const hpRatio = enemy.hp / enemy.maxHp; const phase = hpRatio <= BOSS_ENRAGE_RATIO ? 3 : hpRatio <= BOSS_PHASE_TWO_RATIO ? 2 : 1; this.updateBossEnrageAudio(phase >= 3, dt); if (enemy.cd <= 0 && this.canEnemyShoot(enemy, U(980))) { const shotX = enemy.x - U(115); const targetX = this.player.x; const targetY = this.player.y + U(12); this.enemyShotAt(shotX, enemy.y + this.enemyShotOffset(enemy) + U(46), targetX, targetY, phase >= 3 ? U(255) : U(220), 2.6); if (phase >= 2) this.enemyShotAt(shotX, enemy.y + this.enemyShotOffset(enemy) - U(18), targetX, targetY + U(34), U(205), 2.45); if (phase >= 3) this.enemyShotAt(shotX, enemy.y + this.enemyShotOffset(enemy) + U(88), targetX, targetY - U(28), U(190), 2.35); if (phase >= 3 && this.enemies.filter((e) => e.type === 'spore').length < 5) this.spawnEnemy('spore', enemy.x - U(180), 0); enemy.cd = phase === 1 ? 1.65 : phase === 2 ? 1.28 : 0.95; } } enemy.x += enemy.vx * dt; if (this.enemyTouchesPlayer(enemy)) this.damagePlayer(); } this.enemies = this.enemies.filter((enemy) => { if (enemy.hp > 0) return true; this.score += enemy.type === 'boss' ? 5000 : 100; if (enemy.type === 'boss') this.state = 'win'; return false; }); } private updateBullets(dt: number) { for (const bullet of this.bullets) { bullet.life -= dt; if (!bullet.player && bullet.homing) { const speed = Math.max(1, Math.sqrt(bullet.vx * bullet.vx + bullet.vy * bullet.vy)); const dx = this.player.x - bullet.x; const dy = this.player.y + U(12) - bullet.y; const length = Math.max(1, Math.sqrt(dx * dx + dy * dy)); const blend = Math.min(1, dt * 1.45); bullet.vx = bullet.vx * (1 - blend) + (dx / length) * speed * blend; bullet.vy = bullet.vy * (1 - blend) + (dy / length) * speed * blend; } bullet.x += bullet.vx * dt; bullet.y += bullet.vy * dt; if (bullet.player) { for (const enemy of this.enemies) { const radius = enemy.type === 'boss' ? U(155) : U(44); if (bullet.pierce && bullet.hitEnemies?.has(enemy)) continue; if (enemy.hp > 0 && this.hit(bullet.x, bullet.y, enemy.x, enemy.y, radius)) { enemy.hp -= bullet.damage; if (bullet.pierce) { if (!bullet.hitEnemies) bullet.hitEnemies = new Set(); bullet.hitEnemies.add(enemy); } else { bullet.life = 0; } } } } else if (this.hit(bullet.x, bullet.y, this.player.x, this.player.y, ENEMY_BULLET_HIT_RADIUS)) { bullet.life = 0; this.damagePlayer(); } } this.bullets = this.bullets.filter((bullet) => bullet.life > 0 && Math.abs(bullet.x - this.player.x) < U(1320)); } private fire() { const spec = WEAPONS[this.weapon]; this.fireCd = spec.cd; this.playSfx(this.weapon === 'flame' ? 'shot_flame' : this.weapon === 'rail' ? 'shot_rail' : 'shot_rifle', this.weapon === 'rail' ? 0.86 : 0.74); if (this.weapon === 'flame') { for (let i = 0; i < 2; i++) { this.bullets.push({ x: this.player.x + this.player.face * U(46), y: this.player.y + U(12), vx: this.player.face * spec.speed, vy: i ? U(70) : -U(40), life: 0.24, damage: spec.damage, player: true, pierce: false, kind: 'flame' }); } return; } this.bullets.push({ x: this.player.x + this.player.face * U(46), y: this.player.y + U(12), vx: this.player.face * spec.speed, vy: 0, life: 0.85, damage: spec.damage, player: true, pierce: this.weapon === 'rail', kind: this.weapon }); } private enemyShot(x: number, y: number, dir: number, speed = U(155), life = 1.35) { this.bullets.push({ x, y, vx: dir * speed, vy: 0, life, damage: 1, player: false, pierce: false, kind: 'enemy' }); } private enemyShotAt(x: number, y: number, targetX: number, targetY: number, speed: number, life = 2.4) { const dx = targetX - x; const dy = targetY - y; const length = Math.max(1, Math.sqrt(dx * dx + dy * dy)); this.bullets.push({ x, y, vx: (dx / length) * speed, vy: (dy / length) * speed, life, damage: 1, player: false, pierce: false, kind: 'enemy', homing: false }); } private spawnEnemy(type: Exclude, x: number, seed: number) { const hp = type === 'turret' ? 24 : type === 'wing' ? 16 : 12; this.enemies.push({ type, x, y: LANE_Y, hp, maxHp: hp, vx: 0, cd: 1.25 + seed * 0.28 }); } private spawnBoss() { const boss = { type: 'boss' as const, x: U(3290), y: BOSS_Y, hp: BOSS_MAX_HP, maxHp: BOSS_MAX_HP, vx: 0, cd: 0.5 }; this.boss = boss; this.enemies.push(boss); this.playSfx('boss_alert', 0.95); } private damagePlayer() { if (this.mercy > 0) return; this.player.hp = Math.max(0, this.player.hp - 1); this.mercy = 0.55; if (this.player.hp <= 0) { this.state = 'lose'; this.clearMobileControls(); } } private draw() { if (!this.sceneReady || !this.graphics) return; const g = this.graphics; g.clear(); if (!this.backgroundSprite) { const half = this.visibleWorldWidth() / 2; g.fillColor = new Color(8, 18, 37, 255); g.fillRect(-half, -H / 2, half * 2, H); } this.drawWorld(g); this.drawHudBackplates(g); this.syncEnvironmentProps(); this.drawObstacleSolids(g); this.drawActors(g); this.drawControlButtons(g); this.drawOutcomePanel(g); this.syncUiArt(); this.drawHud(); } private drawHudBackplates(g: Graphics) { if (this.state === 'lose' || this.state === 'win' || this.state === 'next') { g.fillColor = new Color(0, 0, 0, 145); const half = this.visibleWorldWidth() / 2; g.fillRect(-half, -H / 2, half * 2, H); return; } const half = this.visibleWorldWidth() / 2; const hpX = -half + 18; const scorePanel = this.scoreWeaponPanelRect(); g.fillColor = new Color(5, 12, 22, 205); g.fillRect(hpX, H / 2 - U(54), 232, 56); g.fillRect(scorePanel.x, scorePanel.y, scorePanel.width, scorePanel.height); g.fillColor = new Color(255, 207, 72, 230); g.fillRect(hpX, H / 2 - U(54), 4, 56); g.fillRect(scorePanel.x, scorePanel.y, 4, scorePanel.height); g.strokeColor = new Color(64, 238, 255, 190); g.lineWidth = 2; g.rect(hpX, H / 2 - U(54), 232, 56); g.rect(scorePanel.x, scorePanel.y, scorePanel.width, scorePanel.height); g.stroke(); } private drawWorld(g: Graphics) { const offset = this.cameraX; const half = this.visibleWorldWidth() / 2; if (this.backgroundSprite) return; if (!this.backgroundSprite) { g.fillColor = new Color(18, 52, 50, 255); g.fillRect(-half, GROUND - 38, half * 2, 110); for (let i = 0; i < 46; i++) { const x = i * 120 - offset - 520; g.fillColor = i % 2 ? new Color(33, 112, 78, 190) : new Color(39, 92, 104, 170); g.circle(x, -84 + (i % 4) * 10, 58 + (i % 3) * 9); g.fill(); } g.fillColor = new Color(90, 240, 255, 150); for (let i = 0; i < 18; i++) { const x = i * 235 - offset - 430; g.moveTo(x, GROUND + 10); g.lineTo(x + 30, GROUND + 88); g.lineTo(x + 62, GROUND + 10); g.close(); g.fill(); } } g.fillColor = new Color(42, 112, 70, 255); g.fillRect(-half, GROUND - 70, half * 2, 44); g.fillColor = new Color(105, 224, 141, 255); for (let x = -half; x < half; x += 24) { g.moveTo(x, GROUND - 28); g.lineTo(x + 9, GROUND + 4); g.lineTo(x + 18, GROUND - 28); g.close(); g.fill(); } } private syncEnvironmentProps() { this.hideEnvironmentProps(); for (const prop of this.propSprites) { prop.sprite.node.setPosition(prop.worldX - this.cameraX, prop.y, 1); prop.sprite.node.active = false; } if (this.backgroundSprite) { const maxDrift = 0; const drift = -Math.min(maxDrift, (this.cameraX / Math.max(1, END_X - 600)) * maxDrift); for (let i = 0; i < this.backgroundSprites.length; i++) { this.backgroundSprites[i].node.setPosition(drift, 0, 0); } } } private hideEnvironmentProps() { for (const sprite of this.propSprites) sprite.sprite.node.active = false; } private drawObstacleSolids(g: Graphics) { if (this.state !== 'playing') { this.hideObstacleSprites(); return; } const frame = this.uiFrames.get('obstacle_block'); if (frame) { this.syncObstacleSprites(frame); return; } this.hideObstacleSprites(); for (const obstacle of this.obstacles) { const x = obstacle.x - this.cameraX; const half = this.visibleWorldWidth() / 2; if (x < -half - 180 || x > half + 180) continue; const y = obstacle.y + ACTOR_RENDER_Y_OFFSET; const left = x - obstacle.width / 2; g.fillColor = new Color(15, 28, 32, 235); g.fillRect(left, y, obstacle.width, obstacle.height); g.strokeColor = new Color(255, 212, 66, 255); g.lineWidth = 4; g.rect(left, y, obstacle.width, obstacle.height); g.stroke(); g.fillColor = new Color(255, 73, 44, 235); for (let stripe = left + 8; stripe < left + obstacle.width - 8; stripe += 26) { g.moveTo(stripe, y + 8); g.lineTo(stripe + 14, y + 8); g.lineTo(stripe + 4, y + obstacle.height - 8); g.lineTo(stripe - 10, y + obstacle.height - 8); g.close(); g.fill(); } g.fillColor = new Color(64, 238, 255, 245); g.fillRect(left + 8, y + obstacle.height - 8, obstacle.width - 16, 5); } } private syncObstacleSprites(frame: SpriteFrame) { const half = this.visibleWorldWidth() / 2; for (const obstacle of this.obstacles) { let sprite = this.obstacleSprites.get(obstacle); if (!sprite) { sprite = this.createSprite('ObstacleBlock', this.propLayer); sprite.sizeMode = Sprite.SizeMode.CUSTOM; this.obstacleSprites.set(obstacle, sprite); } const x = obstacle.x - this.cameraX; const visible = x >= -half - U(180) && x <= half + U(180); sprite.spriteFrame = frame; sprite.node.getComponent(UITransform)?.setContentSize(obstacle.width, obstacle.height); sprite.node.setPosition(x, obstacle.y + obstacle.height / 2 + ACTOR_RENDER_Y_OFFSET, 2); sprite.node.active = visible && this.state === 'playing'; } } private hideObstacleSprites() { for (const sprite of this.obstacleSprites.values()) sprite.node.active = false; } private drawControlButtons(g: Graphics) { if (this.state !== 'playing') return; const panel = this.scoreWeaponPanelRect(); const slotWidth = U(54); const slotHeight = U(36); const y = panel.y + U(18); (Object.keys(WEAPONS) as WeaponId[]).forEach((id, index) => { const sx = panel.x + U(68) + index * U(64) - slotWidth / 2; const active = this.weapon === id; g.fillColor = active ? this.weaponColor(id, 235) : new Color(17, 34, 42, 225); g.fillRect(sx, y, slotWidth, slotHeight); g.strokeColor = active ? new Color(255, 255, 255, 245) : new Color(64, 238, 255, 120); g.lineWidth = active ? 4 : 2; g.rect(sx, y, slotWidth, slotHeight); g.stroke(); }); } private drawActors(g: Graphics) { if (this.isOutcomeState()) { this.hideActorSprites(); return; } if (this.spritesReady) { this.drawSpriteActors(g); return; } const ox = this.cameraX; const px = this.player.x - ox; const py = this.player.y + ACTOR_RENDER_Y_OFFSET; g.fillColor = this.mercy > 0 ? new Color(255, 255, 255, 220) : new Color(38, 56, 74, 255); g.fillRect(px - 22, py - 36, 44, 62); g.fill(); g.fillColor = new Color(70, 217, 255, 255); g.fillRect(px - 16, py + 17, 32, 24); g.fill(); g.fillColor = new Color(247, 209, 106, 255); g.fillRect(px + this.player.face * 6, py - 8, this.player.face * 42, 10); for (const enemy of this.enemies) { const x = enemy.x - ox; const y = this.enemyRenderY(enemy); if (enemy.type === 'spore') { g.fillColor = new Color(124, 48, 164, 255); g.circle(x, y, 30); g.fill(); g.fillColor = new Color(168, 255, 92, 255); g.circle(x - 9, y + 8, 5); g.fill(); g.circle(x + 11, y + 6, 5); g.fill(); } else if (enemy.type === 'wing') { g.fillColor = new Color(80, 244, 255, U(130)); g.moveTo(x - 45, y + 4); g.lineTo(x - 10, y + 28); g.lineTo(x - 12, y - 18); g.close(); g.fill(); g.moveTo(x + 45, y + 4); g.lineTo(x + 10, y + 28); g.lineTo(x + 12, y - 18); g.close(); g.fill(); g.fillColor = new Color(122, 51, 200, 255); g.circle(x, y, 22); g.fill(); } else if (enemy.type === 'turret') { g.fillColor = new Color(36, 62, 81, 255); g.fillRect(x - 38, y - 22, 76, 42); g.fillColor = new Color(146, 251, 255, 255); g.fillRect(x - 52, y + 4, 48, 10); } else { const ratio = Math.max(0, enemy.hp / enemy.maxHp); g.fillColor = new Color(58, 28, 100, 255); g.circle(x, y, 104); g.fill(); g.fillColor = new Color(255, 79, 174, 255); g.circle(x, y + 8, 56); g.fill(); g.fillColor = new Color(249, 255, 144, 255); g.circle(x, y + 16, 20); g.fill(); g.fillColor = new Color(255, 79, 174, 255); g.fillRect(-140, H / 2 - U(70), 280 * ratio, 10); } } for (const bullet of this.bullets) { g.fillColor = bullet.player ? this.weaponColor(bullet.kind as WeaponId) : new Color(185, 84, 255, 255); const x = bullet.x - ox; const y = bullet.y + ACTOR_RENDER_Y_OFFSET; if (bullet.pierce) g.fillRect(x - 32, y - 4, 64, 8); else { g.circle(x, y, bullet.player ? 8 : 11); g.fill(); } } } private drawSpriteActors(g: Graphics) { this.syncHeroSprite(); this.syncEnemySprites(); this.syncBulletSprites(); if (this.boss && this.boss.hp > 0) { const ratio = Math.max(0, this.boss.hp / this.boss.maxHp); g.fillColor = new Color(255, 79, 174, 255); g.fillRect(-140, H / 2 - U(70), 280 * ratio, 10); } } private hideActorSprites() { if (this.heroSprite) this.heroSprite.node.active = false; if (this.heroWeaponSprite) this.heroWeaponSprite.node.active = false; for (const sprite of this.enemySprites.values()) sprite.node.active = false; for (const sprite of this.bulletSprites.values()) sprite.node.active = false; } private syncHeroSprite() { if (!this.heroSprite) this.heroSprite = this.createSprite('HeroSprite'); const frame = this.getHeroFrame(); if (frame) this.heroSprite.spriteFrame = frame; const x = this.player.x - this.cameraX; const blink = this.mercy > 0 && Math.floor(this.elapsed * 18) % 2 === 0; const switchLift = this.weaponSwitchTimer > 0 ? Math.sin((this.weaponSwitchTimer / 0.22) * Math.PI) : 0; this.heroSprite.node.active = !blink; this.heroSprite.node.setPosition(x, this.player.y + ACTOR_RENDER_Y_OFFSET + switchLift * U(5), 0); this.heroSprite.node.setScale(new Vec3((US(0.53) + switchLift * US(0.035)) * this.player.face, US(0.53) - switchLift * US(0.014), 1)); this.syncHeroWeaponSprite(x, switchLift, blink); } private syncHeroWeaponSprite(heroX: number, switchLift: number, blink: boolean) { const frame = this.uiFrames.get(`weapon_${this.weapon}`); if (!frame) { if (this.heroWeaponSprite) this.heroWeaponSprite.node.active = false; return; } if (!this.heroWeaponSprite) this.heroWeaponSprite = this.createSprite('HeroWeaponSprite'); this.heroWeaponSprite.spriteFrame = frame; const recoil = this.weaponSwitchTimer > 0 ? Math.sin((this.weaponSwitchTimer / 0.22) * Math.PI) * U(12) : 0; const x = heroX + this.player.face * (U(34) - recoil); const y = this.player.y + ACTOR_RENDER_Y_OFFSET + 8 + switchLift * U(18); this.heroWeaponSprite.node.setPosition(x, y, 1); this.heroWeaponSprite.node.setScale(new Vec3(US(0.22) * this.player.face, US(0.22), 1)); this.heroWeaponSprite.node.active = this.state === 'playing' && !blink; } private syncEnemySprites() { for (const [enemy, sprite] of this.enemySprites) { if (!this.enemies.includes(enemy)) { sprite.node.destroy(); this.enemySprites.delete(enemy); } } for (const enemy of this.enemies) { let sprite = this.enemySprites.get(enemy); if (!sprite) { sprite = this.createSprite(`Enemy_${enemy.type}`); this.enemySprites.set(enemy, sprite); } const frame = this.getEnemyFrame(enemy); if (frame) sprite.spriteFrame = frame; sprite.node.setPosition(enemy.x - this.cameraX, this.enemyRenderY(enemy), 0); const face = enemy.type === 'spore' ? (enemy.x > this.player.x ? 1 : -1) : (enemy.x > this.player.x ? -1 : 1); const bossEnraged = enemy.type === 'boss' && enemy.hp <= enemy.maxHp * BOSS_ENRAGE_RATIO; const scale = enemy.type === 'boss' ? (bossEnraged ? US(1.42) : US(1.24)) : enemy.type === 'turret' ? US(0.44) : enemy.type === 'wing' ? US(0.41) : US(0.51); sprite.node.setScale(new Vec3(scale * face, scale, 1)); sprite.node.active = true; } } private syncBulletSprites() { for (const [bullet, sprite] of this.bulletSprites) { if (!this.bullets.includes(bullet)) { sprite.node.destroy(); this.bulletSprites.delete(bullet); } } for (const bullet of this.bullets) { let sprite = this.bulletSprites.get(bullet); if (!sprite) { sprite = this.createSprite('Projectile'); this.bulletSprites.set(bullet, sprite); } const frame = this.getProjectileFrame(bullet); if (frame) sprite.spriteFrame = frame; const scale = bullet.kind === 'rail' ? US(0.27) : bullet.kind === 'flame' ? US(0.24) : US(0.22); const face = bullet.vx >= 0 ? 1 : -1; sprite.node.setPosition(bullet.x - this.cameraX, bullet.y + ACTOR_RENDER_Y_OFFSET, 0); sprite.node.setScale(new Vec3(scale * face, scale, 1)); sprite.node.active = true; } } private createSprite(name: string, parent: Node = this.spriteLayer) { const node = new Node(name); node.layer = Layers.Enum.UI_2D; parent.addChild(node); node.addComponent(UITransform); const sprite = node.addComponent(Sprite); sprite.type = Sprite.Type.SIMPLE; sprite.sizeMode = Sprite.SizeMode.RAW; return sprite; } private syncUiArt() { this.syncResponsiveUiLayout(); this.syncHpArt(); this.syncWeaponArt(); this.syncTouchControls(); this.syncOutcomePanelArt(); this.syncOutcomeButtons(); this.syncRetryArt(); this.syncOutcomeText(); if (this.failOverlaySprite) this.failOverlaySprite.node.active = this.state === 'lose'; } private syncResponsiveUiLayout() { const half = this.visibleWorldWidth() / 2; const scorePanel = this.scoreWeaponPanelRect(); this.hpHud.node.setPosition(-half + U(112), H / 2 - U(30), 8); this.scoreHud.node.setPosition(scorePanel.x + scorePanel.width / 2, scorePanel.y + scorePanel.height - U(34), 8); this.weaponHud.node.setPosition(scorePanel.x + scorePanel.width / 2, scorePanel.y + U(70), 24); if (this.hpPanelSprite) this.hpPanelSprite.node.setPosition(-half + U(112), H / 2 - U(30), 6); } private scoreWeaponPanelRect(): UiRect { const half = this.visibleWorldWidth() / 2; return { x: half - U(430), y: H / 2 - U(150), width: U(306), height: U(112) }; } private syncTouchControls() { const visible = this.state === 'playing'; this.touchLeftSprite = this.syncMobileButton( this.touchLeftSprite, 'TouchLeft', this.uiFrames.get('mobile_left'), this.leftButtonCenter(), this.mobileLeft, visible, () => this.applyMobileAction('left', true), () => this.applyMobileAction('left', false) ); this.touchRightSprite = this.syncMobileButton( this.touchRightSprite, 'TouchRight', this.uiFrames.get('mobile_right'), this.rightButtonCenter(), this.mobileRight, visible, () => this.applyMobileAction('right', true), () => this.applyMobileAction('right', false) ); this.touchJumpSprite = this.syncMobileButton( this.touchJumpSprite, 'TouchJump', this.uiFrames.get('mobile_jump'), this.jumpButtonCenter(), this.mobileUp, visible, () => this.applyMobileAction('up', true), () => this.applyMobileAction('up', false), false ); const fireFrame = this.uiFrames.get('mobile_fire'); if (fireFrame) { if (!this.touchFireSprite) { this.touchFireSprite = this.createSprite('TouchFire', this.uiArtLayer); this.bindTouchControlNode( this.touchFireSprite.node, () => { this.mobileFire = true; }, () => { this.mobileFire = false; } ); } const pos = this.fireButtonCenter(); this.touchFireSprite.spriteFrame = fireFrame; this.touchFireSprite.node.getComponent(UITransform)?.setContentSize(U(128), U(128)); this.touchFireSprite.node.setPosition(pos.x, pos.y, 18); this.touchFireSprite.node.setScale(new Vec3(this.mobileFire ? 1.08 : 1, this.mobileFire ? 1.08 : 1, 1)); this.touchFireSprite.node.active = visible; this.setSpriteOpacity(this.touchFireSprite, this.mobileFire ? 245 : 202); } const switchFrame = this.uiFrames.get('mobile_switch'); if (switchFrame) { if (!this.touchSwitchSprite) { this.touchSwitchSprite = this.createSprite('TouchSwitch', this.uiArtLayer); this.bindTouchControlNode( this.touchSwitchSprite.node, () => this.tryCycleWeapon(), () => {}, false ); } const pos = this.switchButtonCenter(); const active = this.weaponSwitchTimer > 0; this.touchSwitchSprite.spriteFrame = switchFrame; this.touchSwitchSprite.node.getComponent(UITransform)?.setContentSize(U(112), U(112)); this.touchSwitchSprite.node.setPosition(pos.x, pos.y, 18); this.touchSwitchSprite.node.setScale(new Vec3(active ? 1.08 : 1, active ? 1.08 : 1, 1)); this.touchSwitchSprite.node.active = visible; this.setSpriteOpacity(this.touchSwitchSprite, active ? 245 : 202); } } private syncMobileButton( sprite: Sprite | null, name: string, frame: SpriteFrame | undefined, pos: { x: number; y: number }, active: boolean, visible: boolean, onPress: () => void, onRelease: () => void, repeatOnMove = true ) { if (!frame) return sprite; if (!sprite) { sprite = this.createSprite(name, this.uiArtLayer); sprite.sizeMode = Sprite.SizeMode.CUSTOM; this.bindTouchControlNode(sprite.node, onPress, onRelease, repeatOnMove); } sprite.spriteFrame = frame; sprite.node.getComponent(UITransform)?.setContentSize(U(116), U(116)); sprite.node.setPosition(pos.x, pos.y, 18); sprite.node.setScale(new Vec3(active ? 1.08 : 1, active ? 1.08 : 1, 1)); sprite.node.active = visible; this.setSpriteOpacity(sprite, active ? 245 : 202); return sprite; } private bindTouchControlNode( node: Node, onPress: (event: EventTouch | EventMouse) => void, onRelease: (event: EventTouch | EventMouse) => void, repeatOnMove = true ) { const bindState = node as unknown as { __steelTouchBound?: boolean }; if (bindState.__steelTouchBound) return; bindState.__steelTouchBound = true; const press = (event: EventTouch | EventMouse) => { if (this.state !== 'playing') return; this.unlockAudio(); onPress(event); this.stopTouchEvent(event); }; const release = (event: EventTouch | EventMouse) => { onRelease(event); this.stopTouchEvent(event); }; node.on(Node.EventType.TOUCH_START, press, this); if (repeatOnMove) node.on(Node.EventType.TOUCH_MOVE, press, this); node.on(Node.EventType.TOUCH_END, release, this); node.on(Node.EventType.TOUCH_CANCEL, release, this); node.on(Node.EventType.MOUSE_DOWN, press, this); if (repeatOnMove) node.on(Node.EventType.MOUSE_MOVE, press, this); node.on(Node.EventType.MOUSE_UP, release, this); node.on(Node.EventType.MOUSE_LEAVE, release, this); } private bindOutcomeRestartNode(node: Node) { const bindState = node as unknown as { __steelOutcomeBound?: boolean }; if (bindState.__steelOutcomeBound) return; bindState.__steelOutcomeBound = true; const restart = (event: EventTouch | EventMouse) => { if (!this.isOutcomeState()) return; this.unlockAudio(); if (this.handleOutcomeAction(event)) this.stopTouchEvent(event); }; node.on(Node.EventType.TOUCH_START, restart, this); node.on(Node.EventType.MOUSE_DOWN, restart, this); } private stopTouchEvent(event: EventTouch | EventMouse) { const stoppable = event as unknown as { stopPropagation?: () => void; propagationStopped?: boolean }; stoppable.stopPropagation?.(); stoppable.propagationStopped = true; } private handleOutcomeAction(event: EventTouch | EventMouse) { const points = this.pointerWorldPointCandidates(event); if (this.state === 'win') { if (this.anyPointInRect(points, this.outcomeNextRect())) { this.state = 'next'; this.clearMobileControls(); return true; } if (this.anyPointInRect(points, this.outcomeRestartRect())) { this.reset(); return true; } return false; } if ((this.state === 'lose' || this.state === 'next') && this.anyPointInRect(points, this.outcomeSingleRestartRect())) { this.reset(); return true; } return false; } private anyPointInRect(points: Array<{ x: number; y: number }>, rect: UiRect) { return points.some((point) => this.pointInRect(point.x, point.y, rect)); } private pointInRect(x: number, y: number, rect: UiRect) { return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height; } private setMobileDirectionFromTouchPoint(event: EventTouch | EventMouse) { this.clearMobileMove(); const action = this.resolveDirectionalButtonFromPoints(this.pointerWorldPointCandidates(event)); if (action) this.applyMobileAction(action, true); } private setSpriteOpacity(sprite: Sprite, opacity: number) { const uiOpacity = sprite.node.getComponent(UIOpacity) ?? sprite.node.addComponent(UIOpacity); uiOpacity.opacity = opacity; } private syncHpArt() { if (this.hpPanelSprite) { this.hpPanelSprite.node.active = this.state === 'playing'; for (const sprite of this.hpSprites) sprite.node.active = false; this.hpHud.string = this.state === 'playing' ? `生命 ${Math.max(0, this.player.hp)}/6` : ''; return; } const hpFrame = this.uiFrames.get('hp_capsule'); const emptyFrame = this.uiFrames.get('hp_empty') ?? hpFrame; if (!hpFrame) return; while (this.hpSprites.length < 6) { const sprite = this.createSprite(`HpCapsule_${this.hpSprites.length}`, this.uiArtLayer); this.hpSprites.push(sprite); } const startX = -this.visibleWorldWidth() / 2 + U(46); for (let i = 0; i < this.hpSprites.length; i++) { const sprite = this.hpSprites[i]; sprite.spriteFrame = i < this.player.hp ? hpFrame : emptyFrame; sprite.node.setPosition(startX + i * U(38), H / 2 - U(42), 5); sprite.node.setScale(new Vec3(0.19, 0.19, 1)); sprite.node.active = this.state === 'playing'; } } private syncWeaponArt() { if (this.weaponSprite) this.weaponSprite.node.active = false; const ids = Object.keys(WEAPONS) as WeaponId[]; const panel = this.scoreWeaponPanelRect(); for (let index = 0; index < ids.length; index++) { const id = ids[index]; const frame = this.uiFrames.get(`weapon_${id}`); if (!frame) continue; let sprite = this.weaponSlotSprites.get(id); if (!sprite) { sprite = this.createSprite(`WeaponSlot_${id}`, this.uiArtLayer); this.weaponSlotSprites.set(id, sprite); } const active = id === this.weapon; sprite.spriteFrame = frame; sprite.node.setPosition(panel.x + U(68) + index * U(64), panel.y + U(36) + (active ? 3 : 0), 20); sprite.node.setScale(new Vec3(active ? US(0.146) : US(0.125), active ? US(0.146) : US(0.125), 1)); sprite.node.active = this.state === 'playing'; } } private syncRetryArt() { const frame = this.uiFrames.get('retry_button'); if (!frame) return; if (!this.retrySprite) this.retrySprite = this.createSprite('RetryButton', this.uiArtLayer); this.retrySprite.spriteFrame = frame; this.retrySprite.node.setPosition(0, U(-118), 20); this.retrySprite.node.setScale(new Vec3(US(0.52), US(0.52), 1)); this.retrySprite.node.active = false; } private syncOutcomePanelArt() { const visible = this.isOutcomeState(); const frame = this.uiFrames.get(this.state === 'lose' ? 'outcome_fail_panel' : 'outcome_win_panel'); if (!frame) { if (this.outcomePanelSprite) this.outcomePanelSprite.node.active = false; return; } if (!this.outcomePanelSprite) { this.outcomePanelSprite = this.createSprite('OutcomePanel', this.uiArtLayer); this.bindOutcomeRestartNode(this.outcomePanelSprite.node); } this.outcomePanelSprite.spriteFrame = frame; this.outcomePanelSprite.sizeMode = Sprite.SizeMode.CUSTOM; this.outcomePanelSprite.node.getComponent(UITransform)?.setContentSize(U(760), U(450)); this.outcomePanelSprite.node.setPosition(0, U(-8), 20); this.outcomePanelSprite.node.active = visible; } private getHeroFrame() { if (!this.player.grounded) return this.player.vy > 0 ? this.heroFrames[5] : this.heroFrames[6]; if (Math.abs(this.player.vx) > 1) return this.heroFrames[1 + (Math.floor(this.elapsed * 10) % 4)]; return this.heroFrames[0]; } private getEnemyFrame(enemy: Enemy) { if (enemy.type === 'spore') return this.sporeFrames[Math.floor(this.elapsed * 7) % 3]; if (enemy.type === 'wing') return this.wingFrames[Math.floor(this.elapsed * 10) % 3]; if (enemy.type === 'turret') return this.turretFrames[enemy.cd > 0.82 ? 2 : Math.floor(this.elapsed * 2) % 2]; if (enemy.hp <= enemy.maxHp * BOSS_ENRAGE_RATIO) return this.bossFrames[3]; if (enemy.cd > 0.72) return this.bossFrames[2]; return this.bossFrames[Math.floor(this.elapsed * 2) % 2]; } private getProjectileFrame(bullet: Bullet) { const pulse = Math.floor(this.elapsed * 18) % 4; if (bullet.kind === 'rifle') return this.projectileFrames[pulse]; if (bullet.kind === 'flame') return this.projectileFrames[4 + pulse]; if (bullet.kind === 'rail') return this.projectileFrames[8 + (Math.floor(this.elapsed * 12) % 2)]; return this.projectileFrames[10 + pulse]; } private enemyVisualOffset(enemy: Enemy) { if (enemy.type === 'boss') return 0; return 0; } private enemyRenderY(enemy: Enemy) { return enemy.y + ACTOR_RENDER_Y_OFFSET + this.enemyVisualOffset(enemy); } private enemyShotOffset(enemy: Enemy) { return enemyVisualProjectileOffset(enemy.type); } private canEnemyShoot(enemy: Enemy, range: number) { if (enemy.type === 'boss') return Math.abs(enemy.x - this.player.x) < range && Math.abs(enemy.y - this.player.y) < 260; return Math.abs(enemy.x - this.player.x) < range && Math.abs(enemy.y - this.player.y) < 120; } private weaponColor(id: WeaponId, alpha = 255) { if (id === 'flame') return new Color(255, 96, 42, alpha); if (id === 'rail') return new Color(72, 236, 255, alpha); return new Color(255, 216, 92, alpha); } private enemyTouchesPlayer(enemy: Enemy) { const dx = Math.abs(this.player.x - enemy.x); const dy = Math.abs(this.player.y - enemy.y); if (enemy.type === 'boss') return dx < U(135) && dy < U(150); return dx < U(34) && dy < U(44); } private drawOutcomePanel(g: Graphics) { if (!this.isOutcomeState() || this.outcomePanelSprite?.node.active) return; g.fillColor = new Color(8, 20, 28, 225); g.fillRect(-330, -158, 660, 278); g.strokeColor = new Color(78, 232, 255, 245); g.lineWidth = 4; g.rect(-330, -158, 660, 278); g.stroke(); g.fillColor = new Color(255, 207, 72, 245); g.fillRect(-210, -132, 420, 58); g.fillColor = new Color(62, 238, 255, 210); g.fillRect(-330, 110, 660, 6); g.fillRect(-330, -158, 660, 6); } private syncOutcomeButtons() { if (!this.outcomeButtonGraphics) return; const g = this.outcomeButtonGraphics; g.clear(); const visible = this.isOutcomeState(); this.outcomeButtonLayer.active = visible; if (!visible) return; if (this.state === 'win') { this.drawOutcomeButton(g, this.outcomeNextRect(), new Color(255, 211, 88, 238), new Color(255, 248, 190, 255), new Color(24, 136, 169, 210)); this.drawOutcomeButton(g, this.outcomeRestartRect(), new Color(17, 48, 66, 238), new Color(86, 230, 255, 235), new Color(255, 185, 82, 215)); return; } const restartColor = this.state === 'lose' ? new Color(116, 25, 31, 238) : new Color(17, 48, 66, 238); const accentColor = this.state === 'lose' ? new Color(255, 86, 72, 235) : new Color(255, 185, 82, 215); this.drawOutcomeButton(g, this.outcomeSingleRestartRect(), restartColor, new Color(255, 226, 205, 235), accentColor); } private drawOutcomeButton(g: Graphics, rect: UiRect, fill: Color, stroke: Color, accent: Color) { g.fillColor = fill; g.fillRect(rect.x, rect.y, rect.width, rect.height); g.fillColor = accent; g.fillRect(rect.x, rect.y, U(7), rect.height); g.fillRect(rect.x + rect.width - U(7), rect.y, U(7), rect.height); g.strokeColor = stroke; g.lineWidth = 3; g.rect(rect.x, rect.y, rect.width, rect.height); g.stroke(); } private outcomeNextRect(): UiRect { return { x: U(-226), y: U(-130), width: U(196), height: U(58) }; } private outcomeRestartRect(): UiRect { return { x: U(30), y: U(-130), width: U(196), height: U(58) }; } private outcomeSingleRestartRect(): UiRect { return { x: U(-118), y: U(-130), width: U(236), height: U(58) }; } private syncOutcomeText() { const visible = this.isOutcomeState(); const win = this.state === 'win'; this.outcomeTitle.node.active = visible; this.outcomeStats.node.active = visible; this.outcomeAction.node.active = visible && !win; this.outcomeNextAction.node.active = visible && win; this.outcomeRestartAction.node.active = visible && win; if (!visible) return; if (this.state === 'win') { this.outcomeTitle.string = '通关成功'; this.outcomeStats.string = `得分 ${this.score.toString().padStart(5, '0')} 生命 ${Math.max(0, this.player.hp)}/6`; this.outcomeAction.string = ''; this.outcomeNextAction.string = '选择下一关'; this.outcomeRestartAction.string = '重新开始'; this.outcomeNextAction.color = new Color(22, 31, 36, 255); this.outcomeRestartAction.color = new Color(232, 252, 255, 255); this.outcomeTitle.color = new Color(255, 232, 126, 255); this.outcomeStats.color = new Color(225, 250, 255, 255); this.outcomeAction.color = new Color(255, 238, 154, 255); return; } if (this.state === 'lose') { this.outcomeTitle.string = '挑战失败'; this.outcomeStats.string = `得分 ${this.score.toString().padStart(5, '0')}`; this.outcomeAction.string = '重新开始'; this.outcomeAction.node.setPosition(0, U(-100), 28); this.outcomeTitle.color = new Color(255, 92, 82, 255); this.outcomeStats.color = new Color(225, 250, 255, 255); this.outcomeAction.color = new Color(255, 226, 205, 255); return; } this.outcomeTitle.string = '下一关筹备中'; this.outcomeStats.string = '请先返回第一关继续体验'; this.outcomeAction.string = '重新开始'; this.outcomeAction.node.setPosition(0, U(-100), 28); } private isOutcomeState() { return this.state === 'win' || this.state === 'next' || this.state === 'lose'; } private drawHud() { let status = this.boss && this.boss.hp > 0 ? `BOSS ${Math.ceil(this.boss.hp)}/${this.boss.maxHp}` : ''; if (this.isOutcomeState()) status = ''; this.hud.string = status; this.hudNode.setPosition(0, this.state === 'lose' ? -40 : H / 2 - U(34), 12); this.hud.fontSize = this.state === 'lose' ? 30 : 28; this.hud.lineHeight = this.state === 'lose' ? 34 : 32; this.hud.color = this.state === 'lose' ? new Color(255, 240, 180, 255) : new Color(255, 255, 255, 255); this.scoreHud.string = this.state === 'playing' ? `得分 ${this.score.toString().padStart(5, '0')}` : ''; this.hpHud.string = this.state === 'playing' ? `生命 ${Math.max(0, this.player.hp)}/6` : ''; this.weaponHud.string = ''; } private hit(ax: number, ay: number, bx: number, by: number, radius: number) { const dx = ax - bx; const dy = ay - by; return dx * dx + dy * dy < radius * radius; } private onKeyDown(event: EventKeyboard) { this.unlockAudio(); if (event.keyCode === KeyCode.DIGIT_1) this.setWeapon('rifle'); if (event.keyCode === KeyCode.DIGIT_2) this.setWeapon('flame'); if (event.keyCode === KeyCode.DIGIT_3) this.setWeapon('rail'); if (event.keyCode === KeyCode.KEY_Q) this.tryCycleWeapon(); if (event.keyCode === KeyCode.KEY_R && this.state !== 'playing') this.reset(); this.keys.add(event.keyCode); } private onTouchStart(event: EventTouch) { this.unlockAudio(); if (this.state === 'playing') { this.handlePointerStart(this.pointerIdFromTouch(event), event); return; } if (!this.isOutcomeState()) return; if (this.handleOutcomeAction(event)) this.stopTouchEvent(event); } private onTouchMove(event: EventTouch) { if (this.state !== 'playing') return; this.handlePointerMove(this.pointerIdFromTouch(event), event); } private onTouchEnd(event: EventTouch) { this.handlePointerEnd(this.pointerIdFromTouch(event)); } private onMouseDown(event: EventMouse) { if (event.getButton() !== EventMouse.BUTTON_LEFT) return; this.unlockAudio(); if (this.isOutcomeState()) { if (this.handleOutcomeAction(event)) this.stopTouchEvent(event); return; } if (this.state !== 'playing') return; this.handlePointerStart(-1, event); } private onMouseMove(event: EventMouse) { if (this.state !== 'playing') return; this.handlePointerMove(-1, event); } private onMouseUp(event: EventMouse) { this.handlePointerEnd(-1); } private pointerIdFromTouch(event: EventTouch) { return event.getID() ?? 0; } private handlePointerStart(id: number, event: EventTouch | EventMouse) { const action = this.resolveMobileActionFromEvent(event); if (!action) return; if (action === 'switch') { this.tryCycleWeapon(); return; } this.pointerActions.set(id, action); this.applyMobileAction(action, true); } private handlePointerMove(id: number, event: EventTouch | EventMouse) { const current = this.pointerActions.get(id); if (!current || current === 'fire') return; const next = this.resolveMobileActionFromEvent(event); if (next === 'left' || next === 'right' || next === 'up') { this.applyMobileAction(current, false); this.pointerActions.set(id, next); this.applyMobileAction(next, true); } } private handlePointerEnd(id: number) { const action = this.pointerActions.get(id); if (!action) return; this.applyMobileAction(action, false); this.pointerActions.delete(id); } private resolveMobileActionFromEvent(event: EventTouch | EventMouse): MobileAction | null { const points = this.pointerWorldPointCandidates(event); if (this.closestPointInCircle(points, this.fireButtonCenter(), U(64))) return 'fire'; if (this.closestPointInCircle(points, this.switchButtonCenter(), U(56))) return 'switch'; return this.resolveDirectionalButtonFromPoints(points); } private resolveDirectionalButtonFromPoints(points: Array<{ x: number; y: number }>): Extract | null { if (this.closestPointInCircle(points, this.jumpButtonCenter(), U(64))) return 'up'; if (this.closestPointInCircle(points, this.leftButtonCenter(), U(64))) return 'left'; if (this.closestPointInCircle(points, this.rightButtonCenter(), U(64))) return 'right'; return null; } private pointerToWorldPoint(event: EventTouch | EventMouse) { return this.pointerWorldPointCandidates(event)[0]; } private pointerWorldPointCandidates(event: EventTouch | EventMouse) { const visible = view.getVisibleSize(); const viewport = view.getViewportRect(); const frame = view.getFrameSize(); const spaces = [ { x: viewport.x, y: viewport.y, width: viewport.width, height: viewport.height }, { x: 0, y: 0, width: visible.width, height: visible.height }, { x: 0, y: 0, width: frame.width, height: frame.height } ]; const locations = [this.eventUiLocation(event), event.getLocation()]; const candidates: Array<{ x: number; y: number }> = []; const seen = new Set(); for (const space of spaces) { const width = Math.max(1, space.width); const height = Math.max(1, space.height); for (const location of locations) { const localX = location.x - space.x; const localY = location.y - space.y; for (const y of [localY, height - localY]) { const point = { x: (localX / width - 0.5) * this.visibleWorldWidth(), y: (y / height - 0.5) * H }; const key = `${Math.round(point.x)}:${Math.round(point.y)}`; if (!seen.has(key)) { seen.add(key); candidates.push(point); } } } } return candidates; } private anyPointInCircle(points: Array<{ x: number; y: number }>, center: { x: number; y: number }, radius: number) { return points.some((point) => this.hit(point.x, point.y, center.x, center.y, radius)); } private closestPointInCircle(points: Array<{ x: number; y: number }>, center: { x: number; y: number }, radius: number) { let closest: { x: number; y: number } | null = null; let closestDistance = radius * radius; for (const point of points) { const dx = point.x - center.x; const dy = point.y - center.y; const distance = dx * dx + dy * dy; if (distance <= closestDistance) { closest = point; closestDistance = distance; } } return closest; } private touchToGamePoint(event: EventTouch) { return this.pointerToWorldPoint(event); } private eventUiLocation(event: EventTouch | EventMouse) { const uiEvent = event as unknown as { getUILocation?: () => { x: number; y: number } }; return uiEvent.getUILocation?.() ?? event.getLocation(); } private tryJump() { if (this.state !== 'playing' || !this.player.grounded) return; this.player.vy = U(620); this.player.grounded = false; this.playSfx('jump', 0.72); } private tryFire() { if (this.state !== 'playing' || this.fireCd > 0) return; this.fire(); } private applyMobileAction(action: MobileAction, active: boolean) { if (action === 'left') { this.mobileLeft = active; if (active) this.mobileRight = false; } if (action === 'right') { this.mobileRight = active; if (active) this.mobileLeft = false; } if (action === 'up') { this.mobileUp = active; if (active) this.tryJump(); } if (action === 'fire') { this.mobileFire = active; if (active) this.tryFire(); } this.joystickTilt.x = this.mobileLeft ? -1 : this.mobileRight ? 1 : 0; this.joystickTilt.y = this.mobileUp ? 1 : 0; } private visibleWorldWidth() { const visible = view.getVisibleSize(); return Math.max(W, visible.width || this.viewportWidth || W); } private leftButtonCenter() { const half = this.visibleWorldWidth() / 2; return { x: -half + U(72), y: U(-208) }; } private rightButtonCenter() { const half = this.visibleWorldWidth() / 2; return { x: -half + U(214), y: U(-208) }; } private jumpButtonCenter() { const half = this.visibleWorldWidth() / 2; return { x: -half + U(143), y: U(-60) }; } private fireButtonCenter() { const half = this.visibleWorldWidth() / 2; return { x: half - U(100), y: U(-138) }; } private switchButtonCenter() { const half = this.visibleWorldWidth() / 2; return { x: half - U(225), y: U(-188) }; } private clearMobileMove() { this.mobileLeft = false; this.mobileRight = false; this.mobileUp = false; this.mobileDown = false; this.joystickTilt.x = 0; this.joystickTilt.y = 0; } private clearMobileControls() { this.pointerActions.clear(); this.clearMobileMove(); this.mobileFire = false; } private tryCycleWeapon() { if (this.switchInputLock > 0) return; this.switchInputLock = 0.18; this.cycleWeapon(); } private cycleWeapon() { this.setWeapon(this.weapon === 'rifle' ? 'flame' : this.weapon === 'flame' ? 'rail' : 'rifle'); } private setWeapon(next: WeaponId) { if (this.weapon === next && this.weaponSwitchTimer > 0) return; const changed = this.weapon !== next; this.weapon = next; this.weaponSwitchTimer = 0.22; this.fireCd = Math.min(this.fireCd, 0.08); if (changed) this.playSfx('switch_weapon', 0.78); } private onKeyUp(event: EventKeyboard) { this.keys.delete(event.keyCode); } private reset() { this.clearMobileControls(); this.weapon = 'rifle'; this.player = { x: U(-360), y: LANE_Y, vx: 0, vy: 0, hp: 6, face: 1, grounded: true }; this.enemies = []; this.bullets = []; this.spawned.clear(); this.boss = null; this.fireCd = 0; this.mercy = 0; this.cameraX = 0; this.score = 0; this.state = 'playing'; this.bossTime = 0; this.bossEnrageAnnounced = false; this.bossRoarTimer = 0; this.elapsed = 0; for (const sprite of this.enemySprites.values()) sprite.node.destroy(); for (const sprite of this.bulletSprites.values()) sprite.node.destroy(); this.enemySprites.clear(); this.bulletSprites.clear(); } }