SteelAssaultGame.ts 65 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874
  1. import {
  2. _decorator,
  3. AudioClip,
  4. AudioSource,
  5. Camera,
  6. Canvas,
  7. Color,
  8. Component,
  9. EventKeyboard,
  10. EventMouse,
  11. EventTouch,
  12. Graphics,
  13. ImageAsset,
  14. input,
  15. Input,
  16. KeyCode,
  17. Label,
  18. Layers,
  19. Node,
  20. resources,
  21. ResolutionPolicy,
  22. Sprite,
  23. SpriteFrame,
  24. UIOpacity,
  25. UITransform,
  26. Vec3,
  27. view
  28. } from 'cc';
  29. import { HP_PANEL_DATA_URI, LEVEL1_BACKGROUND_DATA_URI } from './generatedLevelArtV2';
  30. const { ccclass, property } = _decorator;
  31. type WeaponId = 'rifle' | 'flame' | 'rail';
  32. type EnemyType = 'spore' | 'wing' | 'turret' | 'boss';
  33. type AudioId = 'bgm_level1_loop' | 'shot_rifle' | 'shot_flame' | 'shot_rail' | 'jump' | 'switch_weapon' | 'boss_alert';
  34. type MobileAction = 'left' | 'right' | 'up' | 'fire' | 'switch';
  35. interface Enemy {
  36. type: EnemyType;
  37. x: number;
  38. y: number;
  39. hp: number;
  40. maxHp: number;
  41. vx: number;
  42. cd: number;
  43. }
  44. interface Obstacle {
  45. x: number;
  46. y: number;
  47. width: number;
  48. height: number;
  49. }
  50. interface Bullet {
  51. x: number;
  52. y: number;
  53. vx: number;
  54. vy: number;
  55. life: number;
  56. damage: number;
  57. player: boolean;
  58. pierce: boolean;
  59. kind: WeaponId | 'enemy';
  60. homing?: boolean;
  61. }
  62. const W = 1280;
  63. const H = 720;
  64. const SCALE = H / 540;
  65. const U = (value: number) => Math.round(value * SCALE);
  66. const US = (value: number) => value * SCALE;
  67. const GROUND = U(-174);
  68. const LANE_Y = GROUND + U(62);
  69. const BOSS_Y = LANE_Y + U(76);
  70. const END_X = U(3320);
  71. const ACTOR_RENDER_Y_OFFSET = 0;
  72. const VISUAL_LANE_Y = LANE_Y + ACTOR_RENDER_Y_OFFSET;
  73. const ENEMY_BULLET_HIT_RADIUS = U(24);
  74. const WEAPONS = {
  75. rifle: { name: 'RIFLE', cd: 0.11, damage: 4, speed: U(880) },
  76. flame: { name: 'FLAME', cd: 0.055, damage: 1.4, speed: U(520) },
  77. rail: { name: 'RAIL', cd: 0.58, damage: 18, speed: U(1250) }
  78. };
  79. function enemyVisualProjectileOffset(type: EnemyType) {
  80. if (type === 'boss') return U(18);
  81. return U(8);
  82. }
  83. @ccclass('SteelAssaultGame')
  84. export class SteelAssaultGame extends Component {
  85. @property
  86. autoStart = true;
  87. private cameraNode = new Node('RuntimeCamera');
  88. private backgroundLayer = new Node('RuntimeBackground');
  89. private renderNode = new Node('RuntimeGraphics');
  90. private spriteLayer = new Node('RuntimeSprites');
  91. private propLayer = new Node('RuntimeProps');
  92. private uiArtLayer = new Node('RuntimeUIArt');
  93. private hudNode = new Node('RuntimeHUD');
  94. private graphics!: Graphics;
  95. private hud!: Label;
  96. private hpHud!: Label;
  97. private scoreHud!: Label;
  98. private weaponHud!: Label;
  99. private outcomeTitle!: Label;
  100. private outcomeStats!: Label;
  101. private outcomeAction!: Label;
  102. private keys = new Set<KeyCode>();
  103. private weapon: WeaponId = 'rifle';
  104. private player = { x: U(-360), y: LANE_Y, vx: 0, vy: 0, hp: 6, face: 1, grounded: true };
  105. private previousPlayerY = LANE_Y;
  106. private enemies: Enemy[] = [];
  107. private bullets: Bullet[] = [];
  108. private spawned = new Set<number>();
  109. private boss: Enemy | null = null;
  110. private fireCd = 0;
  111. private mercy = 0;
  112. private cameraX = 0;
  113. private score = 0;
  114. private state: 'playing' | 'win' | 'next' | 'lose' = 'playing';
  115. private bossTime = 0;
  116. private elapsed = 0;
  117. private spritesReady = false;
  118. private heroSprite: Sprite | null = null;
  119. private enemySprites = new Map<Enemy, Sprite>();
  120. private bulletSprites = new Map<Bullet, Sprite>();
  121. private heroFrames: SpriteFrame[] = [];
  122. private sporeFrames: SpriteFrame[] = [];
  123. private wingFrames: SpriteFrame[] = [];
  124. private turretFrames: SpriteFrame[] = [];
  125. private bossFrames: SpriteFrame[] = [];
  126. private projectileFrames: SpriteFrame[] = [];
  127. private sceneFrames = new Map<string, SpriteFrame>();
  128. private uiFrames = new Map<string, SpriteFrame>();
  129. private backgroundSprite: Sprite | null = null;
  130. private backgroundSprites: Sprite[] = [];
  131. private failOverlaySprite: Sprite | null = null;
  132. private outcomePanelSprite: Sprite | null = null;
  133. private hpPanelSprite: Sprite | null = null;
  134. private weaponSprite: Sprite | null = null;
  135. private heroWeaponSprite: Sprite | null = null;
  136. private weaponSlotSprites = new Map<WeaponId, Sprite>();
  137. private touchLeftSprite: Sprite | null = null;
  138. private touchRightSprite: Sprite | null = null;
  139. private touchJumpSprite: Sprite | null = null;
  140. private touchFireSprite: Sprite | null = null;
  141. private touchSwitchSprite: Sprite | null = null;
  142. private retrySprite: Sprite | null = null;
  143. private hpSprites: Sprite[] = [];
  144. private propSprites: Array<{ sprite: Sprite; worldX: number; y: number; scale: number }> = [];
  145. private createdProps = new Set<string>();
  146. private weaponSwitchTimer = 0;
  147. private musicSource!: AudioSource;
  148. private sfxSource!: AudioSource;
  149. private audioClips = new Map<AudioId, AudioClip>();
  150. private audioUnlocked = false;
  151. private musicStarted = false;
  152. private mobileLeft = false;
  153. private mobileRight = false;
  154. private mobileUp = false;
  155. private mobileDown = false;
  156. private mobileFire = false;
  157. private joystickTilt = { x: 0, y: 0 };
  158. private pointerActions = new Map<number, MobileAction>();
  159. private switchInputLock = 0;
  160. private viewportWidth = W;
  161. private sceneReady = false;
  162. private waves = [
  163. { x: U(420) },
  164. { x: U(860) },
  165. { x: U(1280) },
  166. { x: U(1680) },
  167. { x: U(2200) },
  168. { x: U(2700) }
  169. ];
  170. private obstacles: Obstacle[] = [
  171. { x: U(520), y: LANE_Y, width: U(118), height: U(58) },
  172. { x: U(1120), y: LANE_Y, width: U(138), height: U(72) },
  173. { x: U(2220), y: LANE_Y, width: U(132), height: U(64) },
  174. { x: U(2650), y: LANE_Y, width: U(122), height: U(70) },
  175. { x: U(2860), y: LANE_Y, width: U(112), height: U(58) },
  176. { x: U(3025), y: LANE_Y, width: U(132), height: U(92) },
  177. { x: U(3160), y: LANE_Y, width: U(112), height: U(126) }
  178. ];
  179. onLoad() {
  180. console.log('[SteelAssaultGame] boot');
  181. this.bootstrapScene();
  182. input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this);
  183. input.on(Input.EventType.KEY_UP, this.onKeyUp, this);
  184. input.on(Input.EventType.TOUCH_START, this.onTouchStart, this);
  185. input.on(Input.EventType.TOUCH_MOVE, this.onTouchMove, this);
  186. input.on(Input.EventType.TOUCH_END, this.onTouchEnd, this);
  187. input.on(Input.EventType.TOUCH_CANCEL, this.onTouchEnd, this);
  188. input.on(Input.EventType.MOUSE_DOWN, this.onMouseDown, this);
  189. input.on(Input.EventType.MOUSE_MOVE, this.onMouseMove, this);
  190. input.on(Input.EventType.MOUSE_UP, this.onMouseUp, this);
  191. }
  192. onDestroy() {
  193. view.setResizeCallback(null);
  194. input.off(Input.EventType.KEY_DOWN, this.onKeyDown, this);
  195. input.off(Input.EventType.KEY_UP, this.onKeyUp, this);
  196. input.off(Input.EventType.TOUCH_START, this.onTouchStart, this);
  197. input.off(Input.EventType.TOUCH_MOVE, this.onTouchMove, this);
  198. input.off(Input.EventType.TOUCH_END, this.onTouchEnd, this);
  199. input.off(Input.EventType.TOUCH_CANCEL, this.onTouchEnd, this);
  200. input.off(Input.EventType.MOUSE_DOWN, this.onMouseDown, this);
  201. input.off(Input.EventType.MOUSE_MOVE, this.onMouseMove, this);
  202. input.off(Input.EventType.MOUSE_UP, this.onMouseUp, this);
  203. }
  204. update(dt: number) {
  205. this.elapsed += dt;
  206. if (this.state === 'playing') {
  207. this.updatePlayer(dt);
  208. this.updateObstacleHits();
  209. this.updateWaves();
  210. this.updateEnemies(dt);
  211. this.updateBullets(dt);
  212. this.weaponSwitchTimer = Math.max(0, this.weaponSwitchTimer - dt);
  213. this.switchInputLock = Math.max(0, this.switchInputLock - dt);
  214. this.cameraX = Math.max(0, Math.min(END_X - 600, this.player.x - 120));
  215. }
  216. this.draw();
  217. }
  218. private bootstrapScene() {
  219. this.hideBootCanvas();
  220. this.node.layer = Layers.Enum.UI_2D;
  221. const transform = this.node.getComponent(UITransform) ?? this.node.addComponent(UITransform);
  222. transform.setContentSize(this.viewportWidth, H);
  223. const canvas = this.node.getComponent(Canvas) ?? this.node.addComponent(Canvas);
  224. canvas.alignCanvasWithScreen = true;
  225. this.node.addChild(this.cameraNode);
  226. this.cameraNode.layer = Layers.Enum.UI_2D;
  227. this.cameraNode.setPosition(0, 0, 1000);
  228. const camera = this.cameraNode.addComponent(Camera);
  229. //camera.orthoHeight = H / 2;
  230. camera.visibility = Layers.Enum.UI_2D;
  231. camera.clearColor = new Color(7, 12, 24, 255);
  232. canvas.cameraComponent = camera;
  233. const musicNode = new Node('RuntimeMusic');
  234. musicNode.layer = Layers.Enum.UI_2D;
  235. this.node.addChild(musicNode);
  236. this.musicSource = musicNode.addComponent(AudioSource);
  237. this.musicSource.loop = true;
  238. this.musicSource.volume = 0.22;
  239. const sfxNode = new Node('RuntimeSfx');
  240. sfxNode.layer = Layers.Enum.UI_2D;
  241. this.node.addChild(sfxNode);
  242. this.sfxSource = sfxNode.addComponent(AudioSource);
  243. this.sfxSource.volume = 0.9;
  244. this.node.addChild(this.backgroundLayer);
  245. this.backgroundLayer.layer = Layers.Enum.UI_2D;
  246. this.backgroundLayer.addComponent(UITransform).setContentSize(this.viewportWidth, H);
  247. this.node.addChild(this.renderNode);
  248. this.renderNode.layer = Layers.Enum.UI_2D;
  249. this.renderNode.addComponent(UITransform).setContentSize(this.viewportWidth, H);
  250. this.graphics = this.renderNode.addComponent(Graphics);
  251. this.node.addChild(this.propLayer);
  252. this.propLayer.layer = Layers.Enum.UI_2D;
  253. this.propLayer.addComponent(UITransform).setContentSize(this.viewportWidth, H);
  254. this.node.addChild(this.spriteLayer);
  255. this.spriteLayer.layer = Layers.Enum.UI_2D;
  256. this.spriteLayer.addComponent(UITransform).setContentSize(this.viewportWidth, H);
  257. this.node.addChild(this.uiArtLayer);
  258. this.uiArtLayer.layer = Layers.Enum.UI_2D;
  259. this.uiArtLayer.addComponent(UITransform).setContentSize(this.viewportWidth, H);
  260. this.node.addChild(this.hudNode);
  261. this.hudNode.layer = Layers.Enum.UI_2D;
  262. this.hudNode.addComponent(UITransform).setContentSize(W, U(64));
  263. this.hudNode.setPosition(0, H / 2 - U(34), 0);
  264. this.hud = this.hudNode.addComponent(Label);
  265. this.hud.fontSize = 22;
  266. this.hud.lineHeight = 28;
  267. this.hud.color = new Color(230, 250, 255, 255);
  268. this.hud.string = 'STEEL ASSAULT';
  269. const half = this.visibleWorldWidth() / 2;
  270. const hpNode = new Node('HpHud');
  271. hpNode.layer = Layers.Enum.UI_2D;
  272. this.node.addChild(hpNode);
  273. hpNode.addComponent(UITransform).setContentSize(U(210), U(52));
  274. hpNode.setPosition(-half + U(112), H / 2 - U(30), 8);
  275. this.hpHud = hpNode.addComponent(Label);
  276. this.hpHud.fontSize = 30;
  277. this.hpHud.lineHeight = 34;
  278. this.hpHud.color = new Color(255, 255, 255, 255);
  279. const scoreNode = new Node('ScoreHud');
  280. scoreNode.layer = Layers.Enum.UI_2D;
  281. this.node.addChild(scoreNode);
  282. scoreNode.addComponent(UITransform).setContentSize(U(300), U(52));
  283. scoreNode.setPosition(half - U(126), H / 2 - U(30), 8);
  284. this.scoreHud = scoreNode.addComponent(Label);
  285. this.scoreHud.fontSize = 30;
  286. this.scoreHud.lineHeight = 34;
  287. this.scoreHud.color = new Color(255, 244, 165, 255);
  288. const weaponNode = new Node('WeaponHud');
  289. weaponNode.layer = Layers.Enum.UI_2D;
  290. this.node.addChild(weaponNode);
  291. weaponNode.addComponent(UITransform).setContentSize(U(276), U(28));
  292. weaponNode.setPosition(half - U(126), H / 2 - U(60), 24);
  293. this.weaponHud = weaponNode.addComponent(Label);
  294. this.weaponHud.fontSize = 16;
  295. this.weaponHud.lineHeight = 20;
  296. this.weaponHud.color = new Color(230, 255, 255, 255);
  297. const outcomeTitleNode = new Node('OutcomeTitle');
  298. outcomeTitleNode.layer = Layers.Enum.UI_2D;
  299. this.node.addChild(outcomeTitleNode);
  300. outcomeTitleNode.addComponent(UITransform).setContentSize(U(720), U(72));
  301. outcomeTitleNode.setPosition(0, U(70), 28);
  302. this.outcomeTitle = outcomeTitleNode.addComponent(Label);
  303. this.outcomeTitle.fontSize = 46;
  304. this.outcomeTitle.lineHeight = 54;
  305. this.outcomeTitle.color = new Color(255, 244, 168, 255);
  306. const outcomeStatsNode = new Node('OutcomeStats');
  307. outcomeStatsNode.layer = Layers.Enum.UI_2D;
  308. this.node.addChild(outcomeStatsNode);
  309. outcomeStatsNode.addComponent(UITransform).setContentSize(U(720), U(56));
  310. outcomeStatsNode.setPosition(0, U(10), 28);
  311. this.outcomeStats = outcomeStatsNode.addComponent(Label);
  312. this.outcomeStats.fontSize = 28;
  313. this.outcomeStats.lineHeight = 34;
  314. this.outcomeStats.color = new Color(225, 250, 255, 255);
  315. const outcomeActionNode = new Node('OutcomeAction');
  316. outcomeActionNode.layer = Layers.Enum.UI_2D;
  317. this.node.addChild(outcomeActionNode);
  318. outcomeActionNode.addComponent(UITransform).setContentSize(U(620), U(90));
  319. outcomeActionNode.setPosition(0, U(-92), 28);
  320. this.outcomeAction = outcomeActionNode.addComponent(Label);
  321. this.outcomeAction.fontSize = 28;
  322. this.outcomeAction.lineHeight = 36;
  323. this.outcomeAction.color = new Color(14, 29, 36, 255);
  324. view.resizeWithBrowserSize(true);
  325. this.applyFullscreenLandscapeLayout();
  326. view.setResizeCallback(() => {
  327. this.applyFullscreenLandscapeLayout();
  328. this.draw();
  329. });
  330. this.loadSpriteSheets();
  331. this.loadSceneUiArt();
  332. this.loadAudioAssets();
  333. this.sceneReady = true;
  334. this.draw();
  335. }
  336. private hideBootCanvas() {
  337. const bootCanvas = this.node.scene?.getChildByName('BootCanvas');
  338. if (bootCanvas) bootCanvas.active = false;
  339. }
  340. private applyFullscreenLandscapeLayout() {
  341. // 横屏游戏标准方案:固定高度,宽度按屏幕比例自动扩展
  342. view.setDesignResolutionSize(W, H, ResolutionPolicy.FIXED_HEIGHT);
  343. const visible = view.getVisibleSize();
  344. const worldWidth = Math.max(W, visible.width || W);
  345. this.viewportWidth = worldWidth;
  346. const camera = this.cameraNode.getComponent(Camera);
  347. if (camera) {
  348. camera.orthoHeight = H / 2;
  349. }
  350. for (const node of [
  351. this.node,
  352. this.backgroundLayer,
  353. this.renderNode,
  354. this.propLayer,
  355. this.spriteLayer,
  356. this.uiArtLayer,
  357. this.hudNode
  358. ]) {
  359. const transform = node.getComponent(UITransform);
  360. if (transform) {
  361. transform.setContentSize(worldWidth, H);
  362. }
  363. }
  364. this.resizeFullscreenBackground();
  365. console.log(
  366. '[SteelAssaultGame] layout',
  367. 'frame:', view.getFrameSize(),
  368. 'visible:', view.getVisibleSize(),
  369. 'viewport:', view.getViewportRect(),
  370. 'worldWidth:', this.viewportWidth
  371. );
  372. }
  373. private resizeFullscreenBackground() {
  374. if (!this.backgroundSprite) return;
  375. if (!this.backgroundSprite) return;
  376. const width = this.visibleWorldWidth();
  377. this.backgroundSprite.sizeMode = Sprite.SizeMode.CUSTOM;
  378. const transform = this.backgroundSprite.node.getComponent(UITransform);
  379. if (transform) {
  380. transform.setContentSize(width, H);
  381. }
  382. this.backgroundSprite.node.setPosition(0, 0, 0);
  383. this.backgroundSprite.node.setScale(new Vec3(1, 1, 1));
  384. }
  385. private loadSpriteSheets() {
  386. const hero = [
  387. 'hero_idle',
  388. 'hero_walk_1',
  389. 'hero_walk_2',
  390. 'hero_walk_3',
  391. 'hero_walk_4',
  392. 'hero_jump_rise',
  393. 'hero_jump_fall'
  394. ];
  395. const enemies = [
  396. 'spore_idle',
  397. 'spore_walk_1',
  398. 'spore_walk_2',
  399. 'wing_idle',
  400. 'wing_flap_1',
  401. 'wing_flap_2',
  402. 'turret_idle',
  403. 'turret_aim',
  404. 'turret_fire'
  405. ];
  406. const boss = [
  407. 'boss_idle_closed',
  408. 'boss_idle_pulse',
  409. 'boss_attack_charge',
  410. 'boss_core_enraged'
  411. ];
  412. const projectiles = [
  413. 'rifle_1',
  414. 'rifle_2',
  415. 'rifle_3',
  416. 'rifle_4',
  417. 'flame_1',
  418. 'flame_2',
  419. 'flame_3',
  420. 'flame_4',
  421. 'rail_1',
  422. 'rail_2',
  423. 'acid_1',
  424. 'acid_2',
  425. 'acid_3',
  426. 'acid_4'
  427. ];
  428. this.heroFrames = new Array<SpriteFrame>(hero.length);
  429. const enemyFrames = new Array<SpriteFrame>(enemies.length);
  430. this.bossFrames = new Array<SpriteFrame>(boss.length);
  431. this.projectileFrames = new Array<SpriteFrame>(projectiles.length);
  432. const jobs: Array<{ name: string; frames: SpriteFrame[]; index: number }> = [];
  433. hero.forEach((name, index) => jobs.push({ name, frames: this.heroFrames, index }));
  434. enemies.forEach((name, index) => jobs.push({ name, frames: enemyFrames, index }));
  435. boss.forEach((name, index) => jobs.push({ name, frames: this.bossFrames, index }));
  436. projectiles.forEach((name, index) => jobs.push({ name, frames: this.projectileFrames, index }));
  437. let pending = jobs.length;
  438. let failed = false;
  439. const done = () => {
  440. pending -= 1;
  441. if (pending === 0 && !failed) {
  442. this.sporeFrames = enemyFrames.slice(0, 3);
  443. this.wingFrames = enemyFrames.slice(3, 6);
  444. this.turretFrames = enemyFrames.slice(6, 9);
  445. this.spritesReady = true;
  446. console.log('[SteelAssaultGame] imagegen frame assets ready');
  447. }
  448. };
  449. for (const job of jobs) {
  450. resources.load(`sprites/imagegen/frames/${job.name}`, ImageAsset, (err, image) => {
  451. if (err || !image) {
  452. failed = true;
  453. console.warn(`[SteelAssaultGame] sprite frame fallback for ${job.name}`, err);
  454. done();
  455. return;
  456. }
  457. job.frames[job.index] = SpriteFrame.createWithImage(image);
  458. done();
  459. });
  460. }
  461. }
  462. private loadSceneUiArt() {
  463. this.loadDataUriFrame(LEVEL1_BACKGROUND_DATA_URI, (frame) => {
  464. this.sceneFrames.set('background', frame);
  465. this.backgroundSprites = [];
  466. const sprite = this.createSprite('Level1Background', this.backgroundLayer);
  467. sprite.spriteFrame = frame;
  468. sprite.sizeMode = Sprite.SizeMode.CUSTOM;
  469. sprite.node.getComponent(UITransform)?.setContentSize(this.visibleWorldWidth(), H);
  470. sprite.node.setPosition(0, 0, 0);
  471. sprite.node.setScale(new Vec3(1, 1, 1));
  472. const opacity = sprite.node.getComponent(UIOpacity) ?? sprite.node.addComponent(UIOpacity);
  473. opacity.opacity = 255;
  474. this.backgroundSprite = sprite;
  475. this.backgroundSprites.push(sprite);
  476. this.resizeFullscreenBackground();
  477. });
  478. this.loadDataUriFrame(HP_PANEL_DATA_URI, (frame) => {
  479. this.uiFrames.set('hp_numeric_plaque_v2', frame);
  480. this.hpPanelSprite = this.createSprite('HpNumericPanel', this.uiArtLayer);
  481. this.hpPanelSprite.spriteFrame = frame;
  482. this.hpPanelSprite.node.setPosition(-this.visibleWorldWidth() / 2 + 112, H / 2 - U(30), 6);
  483. this.hpPanelSprite.node.setScale(new Vec3(US(0.5), US(0.5), 1));
  484. });
  485. const ui = [
  486. 'weapon_rifle',
  487. 'weapon_flame',
  488. 'weapon_rail',
  489. 'retry_button',
  490. 'outcome_fail_panel',
  491. 'outcome_win_panel',
  492. 'mobile_left',
  493. 'mobile_right',
  494. 'mobile_jump',
  495. 'mobile_fire',
  496. 'mobile_switch'
  497. ];
  498. for (const name of ui) {
  499. this.loadFrame(`sprites/imagegen/ui_scene/ui/${name}`, (frame) => this.uiFrames.set(name, frame));
  500. }
  501. }
  502. private loadFrame(path: string, use: (frame: SpriteFrame) => void) {
  503. resources.load(path, ImageAsset, (err, image) => {
  504. if (err || !image) {
  505. console.warn(`[SteelAssaultGame] art fallback for ${path}`, err);
  506. return;
  507. }
  508. use(SpriteFrame.createWithImage(image));
  509. });
  510. }
  511. private loadDataUriFrame(dataUri: string, use: (frame: SpriteFrame) => void) {
  512. const image = new Image();
  513. image.onload = () => {
  514. use(SpriteFrame.createWithImage(image));
  515. };
  516. image.onerror = () => {
  517. console.warn('[SteelAssaultGame] embedded image asset failed');
  518. };
  519. image.src = dataUri;
  520. }
  521. private loadAudioAssets() {
  522. const ids: AudioId[] = [
  523. 'bgm_level1_loop',
  524. 'shot_rifle',
  525. 'shot_flame',
  526. 'shot_rail',
  527. 'jump',
  528. 'switch_weapon',
  529. 'boss_alert'
  530. ];
  531. for (const id of ids) {
  532. resources.load(`audio/${id}`, AudioClip, (err, clip) => {
  533. if (err || !clip) {
  534. console.warn(`[SteelAssaultGame] audio fallback for ${id}`, err);
  535. return;
  536. }
  537. this.audioClips.set(id, clip);
  538. if (id === 'bgm_level1_loop' && this.audioUnlocked) this.startMusic();
  539. });
  540. }
  541. }
  542. private unlockAudio() {
  543. this.audioUnlocked = true;
  544. this.startMusic();
  545. }
  546. private startMusic() {
  547. if (this.musicStarted || !this.musicSource) return;
  548. const clip = this.audioClips.get('bgm_level1_loop');
  549. if (!clip) return;
  550. this.musicSource.clip = clip;
  551. this.musicSource.loop = true;
  552. this.musicSource.volume = 0.22;
  553. this.musicSource.play();
  554. this.musicStarted = true;
  555. }
  556. private playSfx(id: AudioId, volume = 0.85) {
  557. const clip = this.audioClips.get(id);
  558. if (!clip || !this.sfxSource) return;
  559. this.sfxSource.playOneShot(clip, volume);
  560. }
  561. private updatePlayer(dt: number) {
  562. this.previousPlayerY = this.player.y;
  563. const left = this.keys.has(KeyCode.KEY_A) || this.keys.has(KeyCode.ARROW_LEFT);
  564. const right = this.keys.has(KeyCode.KEY_D) || this.keys.has(KeyCode.ARROW_RIGHT);
  565. const jump = this.keys.has(KeyCode.SPACE) || this.keys.has(KeyCode.KEY_W) || this.keys.has(KeyCode.ARROW_UP);
  566. const down = this.keys.has(KeyCode.KEY_S) || this.keys.has(KeyCode.ARROW_DOWN) || this.mobileDown;
  567. const moveLeft = left || this.mobileLeft;
  568. const moveRight = right || this.mobileRight;
  569. const fire = this.keys.has(KeyCode.KEY_J) || this.keys.has(KeyCode.ENTER) || this.mobileFire;
  570. this.player.vx = moveLeft === moveRight ? 0 : moveLeft ? -U(270) : U(270);
  571. if (this.player.vx !== 0) this.player.face = this.player.vx > 0 ? 1 : -1;
  572. if (jump || this.mobileUp) this.tryJump();
  573. if (down && !this.player.grounded) this.player.vy -= U(1700) * dt;
  574. this.player.vy -= U(1450) * dt;
  575. this.player.x = Math.max(U(-410), Math.min(END_X - U(180), this.player.x + this.player.vx * dt));
  576. this.player.y += this.player.vy * dt;
  577. if (this.player.y <= LANE_Y) {
  578. this.player.y = LANE_Y;
  579. this.player.vy = 0;
  580. this.player.grounded = true;
  581. }
  582. this.fireCd -= dt;
  583. this.mercy = Math.max(0, this.mercy - dt);
  584. if (fire) this.tryFire();
  585. if (this.player.x > U(3090) && !this.boss) this.spawnBoss();
  586. }
  587. private updateWaves() {
  588. for (let index = 0; index < this.waves.length; index++) {
  589. const wave = this.waves[index];
  590. const activeNormalEnemies = this.enemies.filter((enemy) => enemy.type !== 'boss').length;
  591. if (!this.spawned.has(index) && this.player.x > wave.x - U(520) && activeNormalEnemies === 0) {
  592. this.spawned.add(index);
  593. this.spawnEnemy(this.randomEnemyType(index), wave.x + U(120), index);
  594. }
  595. }
  596. }
  597. private randomEnemyType(seed: number): Exclude<EnemyType, 'boss'> {
  598. const types: Array<Exclude<EnemyType, 'boss'>> = ['spore', 'wing', 'turret'];
  599. const value = Math.abs(Math.sin(seed * 12.9898 + this.score * 0.01 + this.player.x * 0.017));
  600. return types[Math.floor(value * types.length) % types.length];
  601. }
  602. private updateObstacleHits() {
  603. const playerHalfWidth = U(22);
  604. const landingTolerance = U(14);
  605. for (const obstacle of this.obstacles) {
  606. const left = obstacle.x - obstacle.width / 2;
  607. const right = obstacle.x + obstacle.width / 2;
  608. const top = obstacle.y + obstacle.height;
  609. const horizontalOverlap = this.player.x + playerHalfWidth > left && this.player.x - playerHalfWidth < right;
  610. if (!horizontalOverlap) continue;
  611. const wasNearOrAboveTop = this.previousPlayerY >= top - landingTolerance;
  612. const crossedOrRestingOnTop = this.player.y <= top + landingTolerance && this.player.y >= top - landingTolerance;
  613. const fallingOntoTop = this.player.vy <= 0 && wasNearOrAboveTop && crossedOrRestingOnTop;
  614. const alreadyOnTop = this.player.grounded && Math.abs(this.player.y - top) <= landingTolerance;
  615. if (fallingOntoTop || alreadyOnTop) {
  616. this.player.y = top;
  617. this.player.vy = 0;
  618. this.player.grounded = true;
  619. return;
  620. }
  621. const nearObstacleBody = this.player.y < top - U(10) && this.player.y > obstacle.y - U(4);
  622. if (nearObstacleBody) {
  623. const pushLeft = Math.abs(this.player.x - left);
  624. const pushRight = Math.abs(this.player.x - right);
  625. this.player.x = pushLeft < pushRight ? left - playerHalfWidth : right + playerHalfWidth;
  626. this.player.vx = 0;
  627. return;
  628. }
  629. }
  630. }
  631. private updateEnemies(dt: number) {
  632. for (const enemy of this.enemies) {
  633. enemy.cd -= dt;
  634. if (enemy.type === 'spore') {
  635. enemy.y = LANE_Y;
  636. enemy.vx = enemy.x > this.player.x ? -U(82) : U(82);
  637. }
  638. if (enemy.type === 'wing') {
  639. enemy.vx = enemy.x > this.player.x ? -U(70) : U(70);
  640. enemy.y = LANE_Y + Math.sin(this.elapsed * 3 + enemy.x * 0.02) * U(4);
  641. if (enemy.cd <= 0 && this.canEnemyShoot(enemy, U(360))) {
  642. this.enemyShot(enemy.x, enemy.y + this.enemyShotOffset(enemy), this.player.x > enemy.x ? 1 : -1, U(130));
  643. enemy.cd = 3.4;
  644. }
  645. }
  646. if (enemy.type === 'turret') {
  647. enemy.y = LANE_Y;
  648. enemy.vx = 0;
  649. if (enemy.cd <= 0 && this.canEnemyShoot(enemy, U(420))) {
  650. this.enemyShot(enemy.x - U(20), enemy.y + this.enemyShotOffset(enemy) + U(8), this.player.x > enemy.x ? 1 : -1, U(135));
  651. enemy.cd = 3.1;
  652. }
  653. }
  654. if (enemy.type === 'boss') {
  655. this.bossTime += dt;
  656. enemy.vx = Math.sin(this.bossTime * 0.7) * U(28);
  657. enemy.y = BOSS_Y + Math.sin(this.bossTime * 1.1) * U(10);
  658. const phase = enemy.hp > 115 ? 1 : enemy.hp > 55 ? 2 : 3;
  659. if (enemy.cd <= 0 && this.canEnemyShoot(enemy, U(980))) {
  660. const shotX = enemy.x - U(115);
  661. const targetX = this.player.x;
  662. const targetY = this.player.y + U(12);
  663. this.enemyShotAt(shotX, enemy.y + this.enemyShotOffset(enemy) + U(46), targetX, targetY, phase >= 3 ? U(255) : U(220), 2.6);
  664. if (phase >= 2) this.enemyShotAt(shotX, enemy.y + this.enemyShotOffset(enemy) - U(18), targetX, targetY + U(34), U(205), 2.45);
  665. if (phase >= 3) this.enemyShotAt(shotX, enemy.y + this.enemyShotOffset(enemy) + U(88), targetX, targetY - U(28), U(190), 2.35);
  666. if (phase >= 3 && this.enemies.filter((e) => e.type === 'spore').length < 4) this.spawnEnemy('spore', enemy.x - U(140), 0);
  667. enemy.cd = phase === 1 ? 1.75 : phase === 2 ? 1.45 : 1.18;
  668. }
  669. }
  670. enemy.x += enemy.vx * dt;
  671. if (this.enemyTouchesPlayer(enemy)) this.damagePlayer();
  672. }
  673. this.enemies = this.enemies.filter((enemy) => {
  674. if (enemy.hp > 0) return true;
  675. this.score += enemy.type === 'boss' ? 5000 : 100;
  676. if (enemy.type === 'boss') this.state = 'win';
  677. return false;
  678. });
  679. }
  680. private updateBullets(dt: number) {
  681. for (const bullet of this.bullets) {
  682. bullet.life -= dt;
  683. if (!bullet.player && bullet.homing) {
  684. const speed = Math.max(1, Math.sqrt(bullet.vx * bullet.vx + bullet.vy * bullet.vy));
  685. const dx = this.player.x - bullet.x;
  686. const dy = this.player.y + U(12) - bullet.y;
  687. const length = Math.max(1, Math.sqrt(dx * dx + dy * dy));
  688. const blend = Math.min(1, dt * 1.45);
  689. bullet.vx = bullet.vx * (1 - blend) + (dx / length) * speed * blend;
  690. bullet.vy = bullet.vy * (1 - blend) + (dy / length) * speed * blend;
  691. }
  692. bullet.x += bullet.vx * dt;
  693. bullet.y += bullet.vy * dt;
  694. if (bullet.player) {
  695. for (const enemy of this.enemies) {
  696. const radius = enemy.type === 'boss' ? U(155) : U(44);
  697. if (enemy.hp > 0 && this.hit(bullet.x, bullet.y, enemy.x, enemy.y, radius)) {
  698. enemy.hp -= bullet.damage;
  699. if (!bullet.pierce) bullet.life = 0;
  700. }
  701. }
  702. } else if (this.hit(bullet.x, bullet.y, this.player.x, this.player.y, ENEMY_BULLET_HIT_RADIUS)) {
  703. bullet.life = 0;
  704. this.damagePlayer();
  705. }
  706. }
  707. this.bullets = this.bullets.filter((bullet) => bullet.life > 0 && Math.abs(bullet.x - this.player.x) < U(1320));
  708. }
  709. private fire() {
  710. const spec = WEAPONS[this.weapon];
  711. this.fireCd = spec.cd;
  712. this.playSfx(this.weapon === 'flame' ? 'shot_flame' : this.weapon === 'rail' ? 'shot_rail' : 'shot_rifle', this.weapon === 'rail' ? 0.86 : 0.74);
  713. if (this.weapon === 'flame') {
  714. for (let i = 0; i < 2; i++) {
  715. this.bullets.push({
  716. x: this.player.x + this.player.face * U(46),
  717. y: this.player.y + U(12),
  718. vx: this.player.face * spec.speed,
  719. vy: i ? U(70) : -U(40),
  720. life: 0.24,
  721. damage: spec.damage,
  722. player: true,
  723. pierce: false,
  724. kind: 'flame'
  725. });
  726. }
  727. return;
  728. }
  729. this.bullets.push({
  730. x: this.player.x + this.player.face * U(46),
  731. y: this.player.y + U(12),
  732. vx: this.player.face * spec.speed,
  733. vy: 0,
  734. life: 0.85,
  735. damage: spec.damage,
  736. player: true,
  737. pierce: this.weapon === 'rail',
  738. kind: this.weapon
  739. });
  740. }
  741. private enemyShot(x: number, y: number, dir: number, speed = U(155), life = 1.35) {
  742. this.bullets.push({ x, y, vx: dir * speed, vy: 0, life, damage: 1, player: false, pierce: false, kind: 'enemy' });
  743. }
  744. private enemyShotAt(x: number, y: number, targetX: number, targetY: number, speed: number, life = 2.4) {
  745. const dx = targetX - x;
  746. const dy = targetY - y;
  747. const length = Math.max(1, Math.sqrt(dx * dx + dy * dy));
  748. this.bullets.push({
  749. x,
  750. y,
  751. vx: (dx / length) * speed,
  752. vy: (dy / length) * speed,
  753. life,
  754. damage: 1,
  755. player: false,
  756. pierce: false,
  757. kind: 'enemy',
  758. homing: true
  759. });
  760. }
  761. private spawnEnemy(type: Exclude<EnemyType, 'boss'>, x: number, seed: number) {
  762. const hp = type === 'turret' ? 24 : type === 'wing' ? 16 : 12;
  763. this.enemies.push({
  764. type,
  765. x,
  766. y: LANE_Y,
  767. hp,
  768. maxHp: hp,
  769. vx: 0,
  770. cd: 1.25 + seed * 0.28
  771. });
  772. }
  773. private spawnBoss() {
  774. const boss = { type: 'boss' as const, x: U(3290), y: BOSS_Y, hp: 180, maxHp: 180, vx: 0, cd: 0.5 };
  775. this.boss = boss;
  776. this.enemies.push(boss);
  777. this.playSfx('boss_alert', 0.95);
  778. }
  779. private damagePlayer() {
  780. if (this.mercy > 0) return;
  781. this.player.hp = Math.max(0, this.player.hp - 1);
  782. this.mercy = 0.55;
  783. if (this.player.hp <= 0) {
  784. this.state = 'lose';
  785. this.clearMobileControls();
  786. }
  787. }
  788. private draw() {
  789. if (!this.sceneReady || !this.graphics) return;
  790. const g = this.graphics;
  791. g.clear();
  792. if (!this.backgroundSprite) {
  793. const half = this.visibleWorldWidth() / 2;
  794. g.fillColor = new Color(8, 18, 37, 255);
  795. g.fillRect(-half, -H / 2, half * 2, H);
  796. }
  797. this.drawWorld(g);
  798. this.drawHudBackplates(g);
  799. this.syncEnvironmentProps();
  800. this.drawObstacleSolids(g);
  801. this.drawActors(g);
  802. this.drawControlButtons(g);
  803. this.drawOutcomePanel(g);
  804. this.syncUiArt();
  805. this.drawHud();
  806. }
  807. private drawHudBackplates(g: Graphics) {
  808. if (this.state === 'lose' || this.state === 'win' || this.state === 'next') {
  809. g.fillColor = new Color(0, 0, 0, 145);
  810. const half = this.visibleWorldWidth() / 2;
  811. g.fillRect(-half, -H / 2, half * 2, H);
  812. return;
  813. }
  814. const half = this.visibleWorldWidth() / 2;
  815. const hpX = -half + 18;
  816. const scoreX = half - 246;
  817. g.fillColor = new Color(5, 12, 22, 205);
  818. g.fillRect(hpX, H / 2 - U(54), 232, 56);
  819. g.fillRect(scoreX, H / 2 - U(54), 228, 56);
  820. g.fillColor = new Color(255, 207, 72, 230);
  821. g.fillRect(hpX, H / 2 - U(54), 4, 56);
  822. g.fillRect(scoreX, H / 2 - U(54), 4, 56);
  823. g.strokeColor = new Color(64, 238, 255, 190);
  824. g.lineWidth = 2;
  825. g.rect(hpX, H / 2 - U(54), 232, 56);
  826. g.rect(scoreX, H / 2 - U(54), 228, 56);
  827. g.stroke();
  828. }
  829. private drawWorld(g: Graphics) {
  830. const offset = this.cameraX;
  831. const half = this.visibleWorldWidth() / 2;
  832. if (this.backgroundSprite) return;
  833. if (!this.backgroundSprite) {
  834. g.fillColor = new Color(18, 52, 50, 255);
  835. g.fillRect(-half, GROUND - 38, half * 2, 110);
  836. for (let i = 0; i < 46; i++) {
  837. const x = i * 120 - offset - 520;
  838. g.fillColor = i % 2 ? new Color(33, 112, 78, 190) : new Color(39, 92, 104, 170);
  839. g.circle(x, -84 + (i % 4) * 10, 58 + (i % 3) * 9);
  840. g.fill();
  841. }
  842. g.fillColor = new Color(90, 240, 255, 150);
  843. for (let i = 0; i < 18; i++) {
  844. const x = i * 235 - offset - 430;
  845. g.moveTo(x, GROUND + 10);
  846. g.lineTo(x + 30, GROUND + 88);
  847. g.lineTo(x + 62, GROUND + 10);
  848. g.close();
  849. g.fill();
  850. }
  851. }
  852. g.fillColor = new Color(42, 112, 70, 255);
  853. g.fillRect(-half, GROUND - 70, half * 2, 44);
  854. g.fillColor = new Color(105, 224, 141, 255);
  855. for (let x = -half; x < half; x += 24) {
  856. g.moveTo(x, GROUND - 28);
  857. g.lineTo(x + 9, GROUND + 4);
  858. g.lineTo(x + 18, GROUND - 28);
  859. g.close();
  860. g.fill();
  861. }
  862. }
  863. private syncEnvironmentProps() {
  864. this.hideEnvironmentProps();
  865. for (const prop of this.propSprites) {
  866. prop.sprite.node.setPosition(prop.worldX - this.cameraX, prop.y, 1);
  867. prop.sprite.node.active = false;
  868. }
  869. if (this.backgroundSprite) {
  870. const maxDrift = 0;
  871. const drift = -Math.min(maxDrift, (this.cameraX / Math.max(1, END_X - 600)) * maxDrift);
  872. for (let i = 0; i < this.backgroundSprites.length; i++) {
  873. this.backgroundSprites[i].node.setPosition(drift, 0, 0);
  874. }
  875. }
  876. }
  877. private hideEnvironmentProps() {
  878. for (const sprite of this.propSprites) sprite.sprite.node.active = false;
  879. }
  880. private drawObstacleSolids(g: Graphics) {
  881. if (this.state !== 'playing') return;
  882. for (const obstacle of this.obstacles) {
  883. const x = obstacle.x - this.cameraX;
  884. const half = this.visibleWorldWidth() / 2;
  885. if (x < -half - 180 || x > half + 180) continue;
  886. const y = obstacle.y + ACTOR_RENDER_Y_OFFSET;
  887. const left = x - obstacle.width / 2;
  888. g.fillColor = new Color(15, 28, 32, 235);
  889. g.fillRect(left, y, obstacle.width, obstacle.height);
  890. g.strokeColor = new Color(255, 212, 66, 255);
  891. g.lineWidth = 4;
  892. g.rect(left, y, obstacle.width, obstacle.height);
  893. g.stroke();
  894. g.fillColor = new Color(255, 73, 44, 235);
  895. for (let stripe = left + 8; stripe < left + obstacle.width - 8; stripe += 26) {
  896. g.moveTo(stripe, y + 8);
  897. g.lineTo(stripe + 14, y + 8);
  898. g.lineTo(stripe + 4, y + obstacle.height - 8);
  899. g.lineTo(stripe - 10, y + obstacle.height - 8);
  900. g.close();
  901. g.fill();
  902. }
  903. g.fillColor = new Color(64, 238, 255, 245);
  904. g.fillRect(left + 8, y + obstacle.height - 8, obstacle.width - 16, 5);
  905. }
  906. }
  907. private drawControlButtons(g: Graphics) {
  908. if (this.state !== 'playing') return;
  909. const x = this.visibleWorldWidth() / 2 - 228;
  910. const y = H / 2 - 144;
  911. const slotWidth = 60;
  912. const slotGap = 7;
  913. g.fillColor = new Color(8, 21, 29, 220);
  914. g.fillRect(x, y, 204, 72);
  915. g.strokeColor = new Color(64, 238, 255, 230);
  916. g.lineWidth = 3;
  917. g.rect(x, y, 204, 72);
  918. g.stroke();
  919. (Object.keys(WEAPONS) as WeaponId[]).forEach((id, index) => {
  920. const sx = x + 14 + index * (slotWidth + slotGap);
  921. const active = this.weapon === id;
  922. g.fillColor = active ? this.weaponColor(id, 235) : new Color(17, 34, 42, 225);
  923. g.fillRect(sx, y + 14, slotWidth, 42);
  924. g.strokeColor = active ? new Color(255, 255, 255, 245) : new Color(64, 238, 255, 120);
  925. g.lineWidth = active ? 4 : 2;
  926. g.rect(sx, y + 14, slotWidth, 42);
  927. g.stroke();
  928. });
  929. }
  930. private drawActors(g: Graphics) {
  931. if (this.spritesReady) {
  932. this.drawSpriteActors(g);
  933. return;
  934. }
  935. const ox = this.cameraX;
  936. const px = this.player.x - ox;
  937. const py = this.player.y + ACTOR_RENDER_Y_OFFSET;
  938. g.fillColor = this.mercy > 0 ? new Color(255, 255, 255, 220) : new Color(38, 56, 74, 255);
  939. g.fillRect(px - 22, py - 36, 44, 62);
  940. g.fill();
  941. g.fillColor = new Color(70, 217, 255, 255);
  942. g.fillRect(px - 16, py + 17, 32, 24);
  943. g.fill();
  944. g.fillColor = new Color(247, 209, 106, 255);
  945. g.fillRect(px + this.player.face * 6, py - 8, this.player.face * 42, 10);
  946. for (const enemy of this.enemies) {
  947. const x = enemy.x - ox;
  948. const y = this.enemyRenderY(enemy);
  949. if (enemy.type === 'spore') {
  950. g.fillColor = new Color(124, 48, 164, 255);
  951. g.circle(x, y, 30);
  952. g.fill();
  953. g.fillColor = new Color(168, 255, 92, 255);
  954. g.circle(x - 9, y + 8, 5);
  955. g.fill();
  956. g.circle(x + 11, y + 6, 5);
  957. g.fill();
  958. } else if (enemy.type === 'wing') {
  959. g.fillColor = new Color(80, 244, 255, U(130));
  960. g.moveTo(x - 45, y + 4);
  961. g.lineTo(x - 10, y + 28);
  962. g.lineTo(x - 12, y - 18);
  963. g.close();
  964. g.fill();
  965. g.moveTo(x + 45, y + 4);
  966. g.lineTo(x + 10, y + 28);
  967. g.lineTo(x + 12, y - 18);
  968. g.close();
  969. g.fill();
  970. g.fillColor = new Color(122, 51, 200, 255);
  971. g.circle(x, y, 22);
  972. g.fill();
  973. } else if (enemy.type === 'turret') {
  974. g.fillColor = new Color(36, 62, 81, 255);
  975. g.fillRect(x - 38, y - 22, 76, 42);
  976. g.fillColor = new Color(146, 251, 255, 255);
  977. g.fillRect(x - 52, y + 4, 48, 10);
  978. } else {
  979. const ratio = Math.max(0, enemy.hp / enemy.maxHp);
  980. g.fillColor = new Color(58, 28, 100, 255);
  981. g.circle(x, y, 104);
  982. g.fill();
  983. g.fillColor = new Color(255, 79, 174, 255);
  984. g.circle(x, y + 8, 56);
  985. g.fill();
  986. g.fillColor = new Color(249, 255, 144, 255);
  987. g.circle(x, y + 16, 20);
  988. g.fill();
  989. g.fillColor = new Color(255, 79, 174, 255);
  990. g.fillRect(-140, H / 2 - U(70), 280 * ratio, 10);
  991. }
  992. }
  993. for (const bullet of this.bullets) {
  994. g.fillColor = bullet.player
  995. ? this.weaponColor(bullet.kind as WeaponId)
  996. : new Color(185, 84, 255, 255);
  997. const x = bullet.x - ox;
  998. const y = bullet.y + ACTOR_RENDER_Y_OFFSET;
  999. if (bullet.pierce) g.fillRect(x - 32, y - 4, 64, 8);
  1000. else {
  1001. g.circle(x, y, bullet.player ? 8 : 11);
  1002. g.fill();
  1003. }
  1004. }
  1005. }
  1006. private drawSpriteActors(g: Graphics) {
  1007. this.syncHeroSprite();
  1008. this.syncEnemySprites();
  1009. this.syncBulletSprites();
  1010. if (this.boss && this.boss.hp > 0) {
  1011. const ratio = Math.max(0, this.boss.hp / this.boss.maxHp);
  1012. g.fillColor = new Color(255, 79, 174, 255);
  1013. g.fillRect(-140, H / 2 - U(70), 280 * ratio, 10);
  1014. }
  1015. }
  1016. private syncHeroSprite() {
  1017. if (!this.heroSprite) this.heroSprite = this.createSprite('HeroSprite');
  1018. const frame = this.getHeroFrame();
  1019. if (frame) this.heroSprite.spriteFrame = frame;
  1020. const x = this.player.x - this.cameraX;
  1021. const blink = this.mercy > 0 && Math.floor(this.elapsed * 18) % 2 === 0;
  1022. const switchLift = this.weaponSwitchTimer > 0 ? Math.sin((this.weaponSwitchTimer / 0.22) * Math.PI) : 0;
  1023. this.heroSprite.node.active = !blink;
  1024. this.heroSprite.node.setPosition(x, this.player.y + ACTOR_RENDER_Y_OFFSET + switchLift * U(5), 0);
  1025. this.heroSprite.node.setScale(new Vec3((US(0.53) + switchLift * US(0.035)) * this.player.face, US(0.53) - switchLift * US(0.014), 1));
  1026. this.syncHeroWeaponSprite(x, switchLift, blink);
  1027. }
  1028. private syncHeroWeaponSprite(heroX: number, switchLift: number, blink: boolean) {
  1029. const frame = this.uiFrames.get(`weapon_${this.weapon}`);
  1030. if (!frame) {
  1031. if (this.heroWeaponSprite) this.heroWeaponSprite.node.active = false;
  1032. return;
  1033. }
  1034. if (!this.heroWeaponSprite) this.heroWeaponSprite = this.createSprite('HeroWeaponSprite');
  1035. this.heroWeaponSprite.spriteFrame = frame;
  1036. const recoil = this.weaponSwitchTimer > 0 ? Math.sin((this.weaponSwitchTimer / 0.22) * Math.PI) * U(12) : 0;
  1037. const x = heroX + this.player.face * (U(34) - recoil);
  1038. const y = this.player.y + ACTOR_RENDER_Y_OFFSET + 8 + switchLift * U(18);
  1039. this.heroWeaponSprite.node.setPosition(x, y, 1);
  1040. this.heroWeaponSprite.node.setScale(new Vec3(US(0.22) * this.player.face, US(0.22), 1));
  1041. this.heroWeaponSprite.node.active = this.state === 'playing' && !blink;
  1042. }
  1043. private syncEnemySprites() {
  1044. for (const [enemy, sprite] of this.enemySprites) {
  1045. if (!this.enemies.includes(enemy)) {
  1046. sprite.node.destroy();
  1047. this.enemySprites.delete(enemy);
  1048. }
  1049. }
  1050. for (const enemy of this.enemies) {
  1051. let sprite = this.enemySprites.get(enemy);
  1052. if (!sprite) {
  1053. sprite = this.createSprite(`Enemy_${enemy.type}`);
  1054. this.enemySprites.set(enemy, sprite);
  1055. }
  1056. const frame = this.getEnemyFrame(enemy);
  1057. if (frame) sprite.spriteFrame = frame;
  1058. sprite.node.setPosition(enemy.x - this.cameraX, this.enemyRenderY(enemy), 0);
  1059. const face = enemy.x > this.player.x ? -1 : 1;
  1060. const scale = enemy.type === 'boss' ? US(1.06) : enemy.type === 'turret' ? US(0.44) : enemy.type === 'wing' ? US(0.41) : US(0.51);
  1061. sprite.node.setScale(new Vec3(scale * face, scale, 1));
  1062. sprite.node.active = true;
  1063. }
  1064. }
  1065. private syncBulletSprites() {
  1066. for (const [bullet, sprite] of this.bulletSprites) {
  1067. if (!this.bullets.includes(bullet)) {
  1068. sprite.node.destroy();
  1069. this.bulletSprites.delete(bullet);
  1070. }
  1071. }
  1072. for (const bullet of this.bullets) {
  1073. let sprite = this.bulletSprites.get(bullet);
  1074. if (!sprite) {
  1075. sprite = this.createSprite('Projectile');
  1076. this.bulletSprites.set(bullet, sprite);
  1077. }
  1078. const frame = this.getProjectileFrame(bullet);
  1079. if (frame) sprite.spriteFrame = frame;
  1080. const scale = bullet.kind === 'rail' ? US(0.27) : bullet.kind === 'flame' ? US(0.24) : US(0.22);
  1081. const face = bullet.vx >= 0 ? 1 : -1;
  1082. sprite.node.setPosition(bullet.x - this.cameraX, bullet.y + ACTOR_RENDER_Y_OFFSET, 0);
  1083. sprite.node.setScale(new Vec3(scale * face, scale, 1));
  1084. sprite.node.active = true;
  1085. }
  1086. }
  1087. private createSprite(name: string, parent: Node = this.spriteLayer) {
  1088. const node = new Node(name);
  1089. node.layer = Layers.Enum.UI_2D;
  1090. parent.addChild(node);
  1091. node.addComponent(UITransform);
  1092. const sprite = node.addComponent(Sprite);
  1093. sprite.type = Sprite.Type.SIMPLE;
  1094. sprite.sizeMode = Sprite.SizeMode.RAW;
  1095. return sprite;
  1096. }
  1097. private syncUiArt() {
  1098. this.syncResponsiveUiLayout();
  1099. this.syncHpArt();
  1100. this.syncWeaponArt();
  1101. this.syncTouchControls();
  1102. this.syncOutcomePanelArt();
  1103. this.syncRetryArt();
  1104. this.syncOutcomeText();
  1105. if (this.failOverlaySprite) this.failOverlaySprite.node.active = this.state === 'lose';
  1106. }
  1107. private syncResponsiveUiLayout() {
  1108. const half = this.visibleWorldWidth() / 2;
  1109. this.hpHud.node.setPosition(-half + U(112), H / 2 - U(30), 8);
  1110. this.scoreHud.node.setPosition(half - U(210), H / 2 - U(38), 8);
  1111. this.weaponHud.node.setPosition(half - U(210), H / 2 - U(70), 24);
  1112. if (this.hpPanelSprite) this.hpPanelSprite.node.setPosition(-half + U(112), H / 2 - U(30), 6);
  1113. }
  1114. private syncTouchControls() {
  1115. const visible = this.state === 'playing';
  1116. this.touchLeftSprite = this.syncMobileButton(
  1117. this.touchLeftSprite,
  1118. 'TouchLeft',
  1119. this.uiFrames.get('mobile_left'),
  1120. this.leftButtonCenter(),
  1121. this.mobileLeft,
  1122. visible,
  1123. () => this.applyMobileAction('left', true),
  1124. () => this.applyMobileAction('left', false)
  1125. );
  1126. this.touchRightSprite = this.syncMobileButton(
  1127. this.touchRightSprite,
  1128. 'TouchRight',
  1129. this.uiFrames.get('mobile_right'),
  1130. this.rightButtonCenter(),
  1131. this.mobileRight,
  1132. visible,
  1133. () => this.applyMobileAction('right', true),
  1134. () => this.applyMobileAction('right', false)
  1135. );
  1136. this.touchJumpSprite = this.syncMobileButton(
  1137. this.touchJumpSprite,
  1138. 'TouchJump',
  1139. this.uiFrames.get('mobile_jump'),
  1140. this.jumpButtonCenter(),
  1141. this.mobileUp,
  1142. visible,
  1143. () => this.applyMobileAction('up', true),
  1144. () => this.applyMobileAction('up', false),
  1145. false
  1146. );
  1147. const fireFrame = this.uiFrames.get('mobile_fire');
  1148. if (fireFrame) {
  1149. if (!this.touchFireSprite) {
  1150. this.touchFireSprite = this.createSprite('TouchFire', this.uiArtLayer);
  1151. this.bindTouchControlNode(
  1152. this.touchFireSprite.node,
  1153. () => {
  1154. this.mobileFire = true;
  1155. },
  1156. () => {
  1157. this.mobileFire = false;
  1158. }
  1159. );
  1160. }
  1161. const pos = this.fireButtonCenter();
  1162. this.touchFireSprite.spriteFrame = fireFrame;
  1163. this.touchFireSprite.node.getComponent(UITransform)?.setContentSize(U(128), U(128));
  1164. this.touchFireSprite.node.setPosition(pos.x, pos.y, 18);
  1165. this.touchFireSprite.node.setScale(new Vec3(this.mobileFire ? 1.08 : 1, this.mobileFire ? 1.08 : 1, 1));
  1166. this.touchFireSprite.node.active = visible;
  1167. this.setSpriteOpacity(this.touchFireSprite, this.mobileFire ? 245 : 202);
  1168. }
  1169. const switchFrame = this.uiFrames.get('mobile_switch');
  1170. if (switchFrame) {
  1171. if (!this.touchSwitchSprite) {
  1172. this.touchSwitchSprite = this.createSprite('TouchSwitch', this.uiArtLayer);
  1173. this.bindTouchControlNode(
  1174. this.touchSwitchSprite.node,
  1175. () => this.tryCycleWeapon(),
  1176. () => {},
  1177. false
  1178. );
  1179. }
  1180. const pos = this.switchButtonCenter();
  1181. const active = this.weaponSwitchTimer > 0;
  1182. this.touchSwitchSprite.spriteFrame = switchFrame;
  1183. this.touchSwitchSprite.node.getComponent(UITransform)?.setContentSize(U(112), U(112));
  1184. this.touchSwitchSprite.node.setPosition(pos.x, pos.y, 18);
  1185. this.touchSwitchSprite.node.setScale(new Vec3(active ? 1.08 : 1, active ? 1.08 : 1, 1));
  1186. this.touchSwitchSprite.node.active = visible;
  1187. this.setSpriteOpacity(this.touchSwitchSprite, active ? 245 : 202);
  1188. }
  1189. }
  1190. private syncMobileButton(
  1191. sprite: Sprite | null,
  1192. name: string,
  1193. frame: SpriteFrame | undefined,
  1194. pos: { x: number; y: number },
  1195. active: boolean,
  1196. visible: boolean,
  1197. onPress: () => void,
  1198. onRelease: () => void,
  1199. repeatOnMove = true
  1200. ) {
  1201. if (!frame) return sprite;
  1202. if (!sprite) {
  1203. sprite = this.createSprite(name, this.uiArtLayer);
  1204. this.bindTouchControlNode(sprite.node, onPress, onRelease, repeatOnMove);
  1205. }
  1206. sprite.spriteFrame = frame;
  1207. sprite.node.getComponent(UITransform)?.setContentSize(U(116), U(116));
  1208. sprite.node.setPosition(pos.x, pos.y, 18);
  1209. sprite.node.setScale(new Vec3(active ? 1.08 : 1, active ? 1.08 : 1, 1));
  1210. sprite.node.active = visible;
  1211. this.setSpriteOpacity(sprite, active ? 245 : 202);
  1212. return sprite;
  1213. }
  1214. private bindTouchControlNode(
  1215. node: Node,
  1216. onPress: (event: EventTouch | EventMouse) => void,
  1217. onRelease: (event: EventTouch | EventMouse) => void,
  1218. repeatOnMove = true
  1219. ) {
  1220. const bindState = node as unknown as { __steelTouchBound?: boolean };
  1221. if (bindState.__steelTouchBound) return;
  1222. bindState.__steelTouchBound = true;
  1223. const press = (event: EventTouch | EventMouse) => {
  1224. if (this.state !== 'playing') return;
  1225. this.unlockAudio();
  1226. onPress(event);
  1227. this.stopTouchEvent(event);
  1228. };
  1229. const release = (event: EventTouch | EventMouse) => {
  1230. onRelease(event);
  1231. this.stopTouchEvent(event);
  1232. };
  1233. node.on(Node.EventType.TOUCH_START, press, this);
  1234. if (repeatOnMove) node.on(Node.EventType.TOUCH_MOVE, press, this);
  1235. node.on(Node.EventType.TOUCH_END, release, this);
  1236. node.on(Node.EventType.TOUCH_CANCEL, release, this);
  1237. node.on(Node.EventType.MOUSE_DOWN, press, this);
  1238. if (repeatOnMove) node.on(Node.EventType.MOUSE_MOVE, press, this);
  1239. node.on(Node.EventType.MOUSE_UP, release, this);
  1240. node.on(Node.EventType.MOUSE_LEAVE, release, this);
  1241. }
  1242. private bindOutcomeRestartNode(node: Node) {
  1243. const bindState = node as unknown as { __steelOutcomeBound?: boolean };
  1244. if (bindState.__steelOutcomeBound) return;
  1245. bindState.__steelOutcomeBound = true;
  1246. const restart = (event: EventTouch | EventMouse) => {
  1247. if (!this.isOutcomeState()) return;
  1248. this.unlockAudio();
  1249. this.reset();
  1250. this.stopTouchEvent(event);
  1251. };
  1252. node.on(Node.EventType.TOUCH_START, restart, this);
  1253. node.on(Node.EventType.MOUSE_DOWN, restart, this);
  1254. }
  1255. private stopTouchEvent(event: EventTouch | EventMouse) {
  1256. const stoppable = event as unknown as { stopPropagation?: () => void; propagationStopped?: boolean };
  1257. stoppable.stopPropagation?.();
  1258. stoppable.propagationStopped = true;
  1259. }
  1260. private setMobileDirectionFromTouchPoint(event: EventTouch | EventMouse) {
  1261. this.clearMobileMove();
  1262. const action = this.resolveDirectionalButtonFromPoints(this.pointerWorldPointCandidates(event));
  1263. if (action) this.applyMobileAction(action, true);
  1264. }
  1265. private setSpriteOpacity(sprite: Sprite, opacity: number) {
  1266. const uiOpacity = sprite.node.getComponent(UIOpacity) ?? sprite.node.addComponent(UIOpacity);
  1267. uiOpacity.opacity = opacity;
  1268. }
  1269. private syncHpArt() {
  1270. if (this.hpPanelSprite) {
  1271. this.hpPanelSprite.node.active = this.state === 'playing';
  1272. for (const sprite of this.hpSprites) sprite.node.active = false;
  1273. this.hpHud.string = this.state === 'playing' ? `生命 ${Math.max(0, this.player.hp)}/6` : '';
  1274. return;
  1275. }
  1276. const hpFrame = this.uiFrames.get('hp_capsule');
  1277. const emptyFrame = this.uiFrames.get('hp_empty') ?? hpFrame;
  1278. if (!hpFrame) return;
  1279. while (this.hpSprites.length < 6) {
  1280. const sprite = this.createSprite(`HpCapsule_${this.hpSprites.length}`, this.uiArtLayer);
  1281. this.hpSprites.push(sprite);
  1282. }
  1283. const startX = -this.visibleWorldWidth() / 2 + U(46);
  1284. for (let i = 0; i < this.hpSprites.length; i++) {
  1285. const sprite = this.hpSprites[i];
  1286. sprite.spriteFrame = i < this.player.hp ? hpFrame : emptyFrame;
  1287. sprite.node.setPosition(startX + i * U(38), H / 2 - U(42), 5);
  1288. sprite.node.setScale(new Vec3(0.19, 0.19, 1));
  1289. sprite.node.active = this.state === 'playing';
  1290. }
  1291. }
  1292. private syncWeaponArt() {
  1293. if (this.weaponSprite) this.weaponSprite.node.active = false;
  1294. const ids = Object.keys(WEAPONS) as WeaponId[];
  1295. for (let index = 0; index < ids.length; index++) {
  1296. const id = ids[index];
  1297. const frame = this.uiFrames.get(`weapon_${id}`);
  1298. if (!frame) continue;
  1299. let sprite = this.weaponSlotSprites.get(id);
  1300. if (!sprite) {
  1301. sprite = this.createSprite(`WeaponSlot_${id}`, this.uiArtLayer);
  1302. this.weaponSlotSprites.set(id, sprite);
  1303. }
  1304. const active = id === this.weapon;
  1305. sprite.spriteFrame = frame;
  1306. sprite.node.setPosition(this.visibleWorldWidth() / 2 - U(184) + index * U(67), H / 2 - U(110) + (active ? 3 : 0), 20);
  1307. sprite.node.setScale(new Vec3(active ? US(0.146) : US(0.125), active ? US(0.146) : US(0.125), 1));
  1308. sprite.node.active = this.state === 'playing';
  1309. }
  1310. }
  1311. private syncRetryArt() {
  1312. const frame = this.uiFrames.get('retry_button');
  1313. if (!frame) return;
  1314. if (!this.retrySprite) this.retrySprite = this.createSprite('RetryButton', this.uiArtLayer);
  1315. this.retrySprite.spriteFrame = frame;
  1316. this.retrySprite.node.setPosition(0, U(-118), 20);
  1317. this.retrySprite.node.setScale(new Vec3(US(0.52), US(0.52), 1));
  1318. this.retrySprite.node.active = false;
  1319. }
  1320. private syncOutcomePanelArt() {
  1321. const visible = this.isOutcomeState();
  1322. const frame = this.uiFrames.get(this.state === 'lose' ? 'outcome_fail_panel' : 'outcome_win_panel');
  1323. if (!frame) {
  1324. if (this.outcomePanelSprite) this.outcomePanelSprite.node.active = false;
  1325. return;
  1326. }
  1327. if (!this.outcomePanelSprite) {
  1328. this.outcomePanelSprite = this.createSprite('OutcomePanel', this.uiArtLayer);
  1329. this.bindOutcomeRestartNode(this.outcomePanelSprite.node);
  1330. }
  1331. this.outcomePanelSprite.spriteFrame = frame;
  1332. this.outcomePanelSprite.sizeMode = Sprite.SizeMode.CUSTOM;
  1333. this.outcomePanelSprite.node.getComponent(UITransform)?.setContentSize(U(760), U(450));
  1334. this.outcomePanelSprite.node.setPosition(0, U(-8), 20);
  1335. this.outcomePanelSprite.node.active = visible;
  1336. }
  1337. private getHeroFrame() {
  1338. if (!this.player.grounded) return this.player.vy > 0 ? this.heroFrames[5] : this.heroFrames[6];
  1339. if (Math.abs(this.player.vx) > 1) return this.heroFrames[1 + (Math.floor(this.elapsed * 10) % 4)];
  1340. return this.heroFrames[0];
  1341. }
  1342. private getEnemyFrame(enemy: Enemy) {
  1343. if (enemy.type === 'spore') return this.sporeFrames[Math.floor(this.elapsed * 7) % 3];
  1344. if (enemy.type === 'wing') return this.wingFrames[Math.floor(this.elapsed * 10) % 3];
  1345. if (enemy.type === 'turret') return this.turretFrames[enemy.cd > 0.82 ? 2 : Math.floor(this.elapsed * 2) % 2];
  1346. if (enemy.hp < enemy.maxHp * 0.33) return this.bossFrames[3];
  1347. if (enemy.cd > 0.72) return this.bossFrames[2];
  1348. return this.bossFrames[Math.floor(this.elapsed * 2) % 2];
  1349. }
  1350. private getProjectileFrame(bullet: Bullet) {
  1351. const pulse = Math.floor(this.elapsed * 18) % 4;
  1352. if (bullet.kind === 'rifle') return this.projectileFrames[pulse];
  1353. if (bullet.kind === 'flame') return this.projectileFrames[4 + pulse];
  1354. if (bullet.kind === 'rail') return this.projectileFrames[8 + (Math.floor(this.elapsed * 12) % 2)];
  1355. return this.projectileFrames[10 + pulse];
  1356. }
  1357. private enemyVisualOffset(enemy: Enemy) {
  1358. if (enemy.type === 'boss') return 0;
  1359. return 0;
  1360. }
  1361. private enemyRenderY(enemy: Enemy) {
  1362. return enemy.y + ACTOR_RENDER_Y_OFFSET + this.enemyVisualOffset(enemy);
  1363. }
  1364. private enemyShotOffset(enemy: Enemy) {
  1365. return enemyVisualProjectileOffset(enemy.type);
  1366. }
  1367. private canEnemyShoot(enemy: Enemy, range: number) {
  1368. if (enemy.type === 'boss') return Math.abs(enemy.x - this.player.x) < range && Math.abs(enemy.y - this.player.y) < 260;
  1369. return Math.abs(enemy.x - this.player.x) < range && Math.abs(enemy.y - this.player.y) < 120;
  1370. }
  1371. private weaponColor(id: WeaponId, alpha = 255) {
  1372. if (id === 'flame') return new Color(255, 96, 42, alpha);
  1373. if (id === 'rail') return new Color(72, 236, 255, alpha);
  1374. return new Color(255, 216, 92, alpha);
  1375. }
  1376. private enemyTouchesPlayer(enemy: Enemy) {
  1377. const dx = Math.abs(this.player.x - enemy.x);
  1378. const dy = Math.abs(this.player.y - enemy.y);
  1379. if (enemy.type === 'boss') return dx < U(135) && dy < U(150);
  1380. return dx < U(34) && dy < U(44);
  1381. }
  1382. private drawOutcomePanel(g: Graphics) {
  1383. if (!this.isOutcomeState() || this.outcomePanelSprite?.node.active) return;
  1384. g.fillColor = new Color(8, 20, 28, 225);
  1385. g.fillRect(-330, -158, 660, 278);
  1386. g.strokeColor = new Color(78, 232, 255, 245);
  1387. g.lineWidth = 4;
  1388. g.rect(-330, -158, 660, 278);
  1389. g.stroke();
  1390. g.fillColor = new Color(255, 207, 72, 245);
  1391. g.fillRect(-210, -132, 420, 58);
  1392. g.fillColor = new Color(62, 238, 255, 210);
  1393. g.fillRect(-330, 110, 660, 6);
  1394. g.fillRect(-330, -158, 660, 6);
  1395. }
  1396. private syncOutcomeText() {
  1397. const visible = this.isOutcomeState();
  1398. this.outcomeTitle.node.active = visible;
  1399. this.outcomeStats.node.active = visible;
  1400. this.outcomeAction.node.active = visible;
  1401. if (!visible) return;
  1402. if (this.state === 'win') {
  1403. this.outcomeTitle.string = 'MISSION CLEAR';
  1404. this.outcomeStats.string = `SCORE ${this.score.toString().padStart(5, '0')} HP ${Math.max(0, this.player.hp)}/6`;
  1405. this.outcomeAction.string = 'TAP TO RESTART';
  1406. this.outcomeTitle.color = new Color(255, 232, 126, 255);
  1407. this.outcomeStats.color = new Color(225, 250, 255, 255);
  1408. this.outcomeAction.color = new Color(14, 29, 36, 255);
  1409. return;
  1410. }
  1411. if (this.state === 'lose') {
  1412. this.outcomeTitle.string = 'MISSION FAILED';
  1413. this.outcomeStats.string = `SCORE ${this.score.toString().padStart(5, '0')}`;
  1414. this.outcomeAction.string = 'TAP TO RESTART';
  1415. this.outcomeTitle.color = new Color(255, 92, 82, 255);
  1416. this.outcomeStats.color = new Color(225, 250, 255, 255);
  1417. this.outcomeAction.color = new Color(255, 226, 205, 255);
  1418. return;
  1419. }
  1420. this.outcomeTitle.string = 'READY';
  1421. this.outcomeStats.string = 'LEVEL 1';
  1422. this.outcomeAction.string = 'TAP TO RESTART';
  1423. }
  1424. private isOutcomeState() {
  1425. return this.state === 'win' || this.state === 'next' || this.state === 'lose';
  1426. }
  1427. private drawHud() {
  1428. let status = this.boss && this.boss.hp > 0 ? `BOSS ${Math.ceil(this.boss.hp)}/180` : '';
  1429. if (this.isOutcomeState()) status = '';
  1430. this.hud.string = status;
  1431. this.hudNode.setPosition(0, this.state === 'lose' ? -40 : H / 2 - U(34), 12);
  1432. this.hud.fontSize = this.state === 'lose' ? 30 : 28;
  1433. this.hud.lineHeight = this.state === 'lose' ? 34 : 32;
  1434. this.hud.color = this.state === 'lose' ? new Color(255, 240, 180, 255) : new Color(255, 255, 255, 255);
  1435. this.scoreHud.string = this.state === 'playing' ? `得分 ${this.score.toString().padStart(5, '0')}` : '';
  1436. this.hpHud.string = this.state === 'playing' ? `生命 ${Math.max(0, this.player.hp)}/6` : '';
  1437. this.weaponHud.string = this.state === 'playing'
  1438. ? '按Q切换'
  1439. : '';
  1440. }
  1441. private hit(ax: number, ay: number, bx: number, by: number, radius: number) {
  1442. const dx = ax - bx;
  1443. const dy = ay - by;
  1444. return dx * dx + dy * dy < radius * radius;
  1445. }
  1446. private onKeyDown(event: EventKeyboard) {
  1447. this.unlockAudio();
  1448. if (event.keyCode === KeyCode.DIGIT_1) this.setWeapon('rifle');
  1449. if (event.keyCode === KeyCode.DIGIT_2) this.setWeapon('flame');
  1450. if (event.keyCode === KeyCode.DIGIT_3) this.setWeapon('rail');
  1451. if (event.keyCode === KeyCode.KEY_Q) this.tryCycleWeapon();
  1452. if (event.keyCode === KeyCode.KEY_R && this.state !== 'playing') this.reset();
  1453. this.keys.add(event.keyCode);
  1454. }
  1455. private onTouchStart(event: EventTouch) {
  1456. this.unlockAudio();
  1457. const { x, y } = this.touchToGamePoint(event);
  1458. if (this.state === 'playing') {
  1459. this.handlePointerStart(this.pointerIdFromTouch(event), event);
  1460. return;
  1461. }
  1462. if (!this.isOutcomeState()) return;
  1463. const inOutcome = Math.abs(x) < U(410) && y > U(-240) && y < U(190);
  1464. if (inOutcome) {
  1465. this.reset();
  1466. }
  1467. }
  1468. private onTouchMove(event: EventTouch) {
  1469. if (this.state !== 'playing') return;
  1470. this.handlePointerMove(this.pointerIdFromTouch(event), event);
  1471. }
  1472. private onTouchEnd(event: EventTouch) {
  1473. this.handlePointerEnd(this.pointerIdFromTouch(event));
  1474. }
  1475. private onMouseDown(event: EventMouse) {
  1476. if (event.getButton() !== EventMouse.BUTTON_LEFT) return;
  1477. if (this.state !== 'playing') return;
  1478. this.unlockAudio();
  1479. this.handlePointerStart(-1, event);
  1480. }
  1481. private onMouseMove(event: EventMouse) {
  1482. if (this.state !== 'playing') return;
  1483. this.handlePointerMove(-1, event);
  1484. }
  1485. private onMouseUp(event: EventMouse) {
  1486. this.handlePointerEnd(-1);
  1487. }
  1488. private pointerIdFromTouch(event: EventTouch) {
  1489. return event.getID() ?? 0;
  1490. }
  1491. private handlePointerStart(id: number, event: EventTouch | EventMouse) {
  1492. const action = this.resolveMobileActionFromEvent(event);
  1493. if (!action) return;
  1494. if (action === 'switch') {
  1495. this.tryCycleWeapon();
  1496. return;
  1497. }
  1498. this.pointerActions.set(id, action);
  1499. this.applyMobileAction(action, true);
  1500. }
  1501. private handlePointerMove(id: number, event: EventTouch | EventMouse) {
  1502. const current = this.pointerActions.get(id);
  1503. if (!current || current === 'fire') return;
  1504. const next = this.resolveMobileActionFromEvent(event);
  1505. if (next === 'left' || next === 'right' || next === 'up') {
  1506. this.applyMobileAction(current, false);
  1507. this.pointerActions.set(id, next);
  1508. this.applyMobileAction(next, true);
  1509. }
  1510. }
  1511. private handlePointerEnd(id: number) {
  1512. const action = this.pointerActions.get(id);
  1513. if (!action) return;
  1514. this.applyMobileAction(action, false);
  1515. this.pointerActions.delete(id);
  1516. }
  1517. private resolveMobileActionFromEvent(event: EventTouch | EventMouse): MobileAction | null {
  1518. const points = this.pointerWorldPointCandidates(event);
  1519. if (this.closestPointInCircle(points, this.fireButtonCenter(), U(64))) return 'fire';
  1520. if (this.closestPointInCircle(points, this.switchButtonCenter(), U(56))) return 'switch';
  1521. return this.resolveDirectionalButtonFromPoints(points);
  1522. }
  1523. private resolveDirectionalButtonFromPoints(points: Array<{ x: number; y: number }>): Extract<MobileAction, 'left' | 'right' | 'up'> | null {
  1524. if (this.closestPointInCircle(points, this.jumpButtonCenter(), U(58))) return 'up';
  1525. if (this.closestPointInCircle(points, this.leftButtonCenter(), U(58))) return 'left';
  1526. if (this.closestPointInCircle(points, this.rightButtonCenter(), U(58))) return 'right';
  1527. return null;
  1528. }
  1529. private pointerToWorldPoint(event: EventTouch | EventMouse) {
  1530. return this.pointerWorldPointCandidates(event)[0];
  1531. }
  1532. private pointerWorldPointCandidates(event: EventTouch | EventMouse) {
  1533. const visible = view.getVisibleSize();
  1534. const viewport = view.getViewportRect();
  1535. const frame = view.getFrameSize();
  1536. const spaces = [
  1537. { x: viewport.x, y: viewport.y, width: viewport.width, height: viewport.height },
  1538. { x: 0, y: 0, width: visible.width, height: visible.height },
  1539. { x: 0, y: 0, width: frame.width, height: frame.height }
  1540. ];
  1541. const locations = [this.eventUiLocation(event), event.getLocation()];
  1542. const candidates: Array<{ x: number; y: number }> = [];
  1543. const seen = new Set<string>();
  1544. for (const space of spaces) {
  1545. const width = Math.max(1, space.width);
  1546. const height = Math.max(1, space.height);
  1547. for (const location of locations) {
  1548. const localX = location.x - space.x;
  1549. const localY = location.y - space.y;
  1550. for (const y of [localY, height - localY]) {
  1551. const point = {
  1552. x: (localX / width - 0.5) * this.visibleWorldWidth(),
  1553. y: (y / height - 0.5) * H
  1554. };
  1555. const key = `${Math.round(point.x)}:${Math.round(point.y)}`;
  1556. if (!seen.has(key)) {
  1557. seen.add(key);
  1558. candidates.push(point);
  1559. }
  1560. }
  1561. }
  1562. }
  1563. return candidates;
  1564. }
  1565. private anyPointInCircle(points: Array<{ x: number; y: number }>, center: { x: number; y: number }, radius: number) {
  1566. return points.some((point) => this.hit(point.x, point.y, center.x, center.y, radius));
  1567. }
  1568. private closestPointInCircle(points: Array<{ x: number; y: number }>, center: { x: number; y: number }, radius: number) {
  1569. let closest: { x: number; y: number } | null = null;
  1570. let closestDistance = radius * radius;
  1571. for (const point of points) {
  1572. const dx = point.x - center.x;
  1573. const dy = point.y - center.y;
  1574. const distance = dx * dx + dy * dy;
  1575. if (distance <= closestDistance) {
  1576. closest = point;
  1577. closestDistance = distance;
  1578. }
  1579. }
  1580. return closest;
  1581. }
  1582. private touchToGamePoint(event: EventTouch) {
  1583. return this.pointerToWorldPoint(event);
  1584. }
  1585. private eventUiLocation(event: EventTouch | EventMouse) {
  1586. const uiEvent = event as unknown as { getUILocation?: () => { x: number; y: number } };
  1587. return uiEvent.getUILocation?.() ?? event.getLocation();
  1588. }
  1589. private tryJump() {
  1590. if (this.state !== 'playing' || !this.player.grounded) return;
  1591. this.player.vy = U(620);
  1592. this.player.grounded = false;
  1593. this.playSfx('jump', 0.72);
  1594. }
  1595. private tryFire() {
  1596. if (this.state !== 'playing' || this.fireCd > 0) return;
  1597. this.fire();
  1598. }
  1599. private applyMobileAction(action: MobileAction, active: boolean) {
  1600. if (action === 'left') {
  1601. this.mobileLeft = active;
  1602. if (active) this.mobileRight = false;
  1603. }
  1604. if (action === 'right') {
  1605. this.mobileRight = active;
  1606. if (active) this.mobileLeft = false;
  1607. }
  1608. if (action === 'up') {
  1609. this.mobileUp = active;
  1610. if (active) this.tryJump();
  1611. }
  1612. if (action === 'fire') {
  1613. this.mobileFire = active;
  1614. if (active) this.tryFire();
  1615. }
  1616. this.joystickTilt.x = this.mobileLeft ? -1 : this.mobileRight ? 1 : 0;
  1617. this.joystickTilt.y = this.mobileUp ? 1 : 0;
  1618. }
  1619. private visibleWorldWidth() {
  1620. const visible = view.getVisibleSize();
  1621. return Math.max(W, visible.width || this.viewportWidth || W);
  1622. }
  1623. private leftButtonCenter() {
  1624. const half = this.visibleWorldWidth() / 2;
  1625. return { x: -half + U(96), y: U(-188) };
  1626. }
  1627. private rightButtonCenter() {
  1628. const half = this.visibleWorldWidth() / 2;
  1629. return { x: -half + U(190), y: U(-188) };
  1630. }
  1631. private jumpButtonCenter() {
  1632. const half = this.visibleWorldWidth() / 2;
  1633. return { x: -half + U(143), y: U(-88) };
  1634. }
  1635. private fireButtonCenter() {
  1636. const half = this.visibleWorldWidth() / 2;
  1637. return { x: half - U(100), y: U(-138) };
  1638. }
  1639. private switchButtonCenter() {
  1640. const half = this.visibleWorldWidth() / 2;
  1641. return { x: half - U(225), y: U(-188) };
  1642. }
  1643. private clearMobileMove() {
  1644. this.mobileLeft = false;
  1645. this.mobileRight = false;
  1646. this.mobileUp = false;
  1647. this.mobileDown = false;
  1648. this.joystickTilt.x = 0;
  1649. this.joystickTilt.y = 0;
  1650. }
  1651. private clearMobileControls() {
  1652. this.pointerActions.clear();
  1653. this.clearMobileMove();
  1654. this.mobileFire = false;
  1655. }
  1656. private tryCycleWeapon() {
  1657. if (this.switchInputLock > 0) return;
  1658. this.switchInputLock = 0.18;
  1659. this.cycleWeapon();
  1660. }
  1661. private cycleWeapon() {
  1662. this.setWeapon(this.weapon === 'rifle' ? 'flame' : this.weapon === 'flame' ? 'rail' : 'rifle');
  1663. }
  1664. private setWeapon(next: WeaponId) {
  1665. if (this.weapon === next && this.weaponSwitchTimer > 0) return;
  1666. const changed = this.weapon !== next;
  1667. this.weapon = next;
  1668. this.weaponSwitchTimer = 0.22;
  1669. this.fireCd = Math.min(this.fireCd, 0.08);
  1670. if (changed) this.playSfx('switch_weapon', 0.78);
  1671. }
  1672. private onKeyUp(event: EventKeyboard) {
  1673. this.keys.delete(event.keyCode);
  1674. }
  1675. private reset() {
  1676. this.clearMobileControls();
  1677. this.weapon = 'rifle';
  1678. this.player = { x: U(-360), y: LANE_Y, vx: 0, vy: 0, hp: 6, face: 1, grounded: true };
  1679. this.enemies = [];
  1680. this.bullets = [];
  1681. this.spawned.clear();
  1682. this.boss = null;
  1683. this.fireCd = 0;
  1684. this.mercy = 0;
  1685. this.cameraX = 0;
  1686. this.score = 0;
  1687. this.state = 'playing';
  1688. this.bossTime = 0;
  1689. this.elapsed = 0;
  1690. for (const sprite of this.enemySprites.values()) sprite.node.destroy();
  1691. for (const sprite of this.bulletSprites.values()) sprite.node.destroy();
  1692. this.enemySprites.clear();
  1693. this.bulletSprites.clear();
  1694. }
  1695. }