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