SteelAssaultGame.ts 73 KB

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