From 8ce72d3fe38f44cf4670c8dbdf84e5a0900e00fe Mon Sep 17 00:00:00 2001 From: TechEvolveAI Date: Fri, 14 Aug 2026 20:48:53 +0100 Subject: [PATCH] Stream dormant Aurora patrols --- scripts/smoke-secondary-journeys.js | 280 +++++++++++++++++- .../LevelTraversalQualityContract.test.js | 43 ++- src/scenes/PlatformerLevelScene.js | 15 +- src/scenes/levels/AuroraDepthsLevel.js | 222 ++++++++++++-- 4 files changed, 525 insertions(+), 35 deletions(-) diff --git a/scripts/smoke-secondary-journeys.js b/scripts/smoke-secondary-journeys.js index 8f75310b..f9d56d2f 100644 --- a/scripts/smoke-secondary-journeys.js +++ b/scripts/smoke-secondary-journeys.js @@ -44,8 +44,8 @@ const CAMPAIGN_MOBILE_RENDER_BUDGETS = Object.freeze({ performanceTier: 'mobile' }), auroraDepths: Object.freeze({ - displayCount: 185, - activeTweenCount: 22, + displayCount: 160, + activeTweenCount: 15, performanceTier: 'mobile' }), finalVoid: Object.freeze({ @@ -207,6 +207,9 @@ async function sampleFramePacing(session, sceneName, { const peakEnemyPatrolUpdateCountAtStart = Number( scene?.peakEnemyPatrolUpdateCount ) || 0; + const auroraEnemyPatrolUpdateCountAtStart = Number( + scene?.auroraEnemyPatrolUpdateCount + ) || 0; let previousAt = null; const percentile = (sorted, ratio) => { @@ -331,6 +334,15 @@ async function sampleFramePacing(session, sceneName, { peakEnemyPatrolUpdateCountAtStart ) } : null, + auroraRuntime: scene?.scene?.key === 'AuroraDepthsLevel' ? { + patrolUpdatesDuringSample: Math.max( + 0, + (Number(scene?.auroraEnemyPatrolUpdateCount) || 0) - + auroraEnemyPatrolUpdateCountAtStart + ), + runtimeEnemyCount: + scene?.getRuntimePatrolEnemies?.().length || 0 + } : null, activeTweenCount: scene?.tweens?.getTweens?.().length || 0, landingDustTweenCount: ( scene?.tweens?.getTweens?.() || [] @@ -932,6 +944,145 @@ async function smokeReefTrailBudget(session) { return result; } +async function smokeAuroraEnemyActivationWindow(session) { + const staged = await evaluate(session, `(() => { + const scene = window.mythicalGame.scene.getScene('AuroraDepthsLevel'); + const target = [...(scene.enemies?.getChildren?.() || [])] + .filter(enemy => enemy?.active && enemy?.body) + .sort((left, right) => right.x - left.x)[0]; + if (!scene.player?.body || !target) return null; + + const playerStart = { x: scene.player.x, y: scene.player.y }; + const cameraStart = { + scrollX: scene.cameras.main.scrollX, + scrollY: scene.cameras.main.scrollY + }; + const targetStart = { x: target.x, y: target.y }; + scene.isInvincible = true; + scene.player.body.reset(300, scene.levelHeight - 130); + scene.player.setVelocity(0, 0); + scene.cameras.main.centerOn(scene.player.x, scene.player.y); + scene.cameras.main.preRender?.(); + scene.updateAuroraEnemyActivation(true); + return { + playerStart, + cameraStart, + targetStart, + enemyType: target.enemyType, + encounterBeat: target.encounterBeat, + far: { + proximityActive: target.auroraProximityActive === true, + bodyEnabled: target.body.enable === true, + renderAttached: target.displayList === scene.children, + cueAttached: target.combatCue?.displayList === scene.children, + runtimeIncludes: scene.getRuntimePatrolEnemies().includes(target) + } + }; + })()`); + if (!staged) throw new Error('Aurora enemy activation window could not be staged'); + + try { + const awakened = await evaluate(session, `(() => { + const scene = window.mythicalGame.scene.getScene('AuroraDepthsLevel'); + const target = (scene.enemies?.getChildren?.() || []).find( + enemy => enemy?.encounterBeat === ${JSON.stringify(staged.encounterBeat)} + ); + if (!target?.body || !scene.player?.body) return null; + scene.player.body.reset(target.x - 180, target.y); + scene.player.setVelocity(0, 0); + scene.cameras.main.centerOn(scene.player.x, scene.player.y); + scene.cameras.main.preRender?.(); + scene.updateAuroraEnemyActivation(true); + scene.auroraEnemyPatrolNextAt = 0; + scene.updatePatrolEnemyMovement(); + return { + proximityActive: target.auroraProximityActive === true, + bodyEnabled: target.body.enable === true, + renderAttached: target.displayList === scene.children, + cueAttached: target.combatCue?.displayList === scene.children, + runtimeIncludes: scene.getRuntimePatrolEnemies().includes(target), + velocityX: Number(target.body.velocity?.x) || 0 + }; + })()`); + if ( + staged.far.proximityActive !== false || + staged.far.bodyEnabled !== false || + staged.far.renderAttached !== false || + staged.far.cueAttached !== false || + staged.far.runtimeIncludes !== false || + awakened?.proximityActive !== true || + awakened?.bodyEnabled !== true || + awakened?.renderAttached !== true || + awakened?.cueAttached !== true || + awakened?.runtimeIncludes !== true || + Math.abs(awakened?.velocityX || 0) < 1 + ) { + throw new Error( + `Aurora enemy activation did not wake before contact: ${JSON.stringify({ + staged, + awakened + })}` + ); + } + + const slept = await evaluate(session, `(() => { + const scene = window.mythicalGame.scene.getScene('AuroraDepthsLevel'); + const target = (scene.enemies?.getChildren?.() || []).find( + enemy => enemy?.encounterBeat === ${JSON.stringify(staged.encounterBeat)} + ); + if (!target?.body || !scene.player?.body) return null; + target.body.reset(${staged.targetStart.x}, ${staged.targetStart.y}); + target.setVelocity(0, 0); + scene.player.body.reset(300, scene.levelHeight - 130); + scene.player.setVelocity(0, 0); + scene.cameras.main.centerOn(scene.player.x, scene.player.y); + scene.cameras.main.preRender?.(); + scene.updateAuroraEnemyActivation(true); + return { + proximityActive: target.auroraProximityActive === true, + bodyEnabled: target.body.enable === true, + renderAttached: target.displayList === scene.children, + cueAttached: target.combatCue?.displayList === scene.children, + runtimeIncludes: scene.getRuntimePatrolEnemies().includes(target) + }; + })()`); + if ( + slept?.proximityActive !== false || + slept?.bodyEnabled !== false || + slept?.renderAttached !== false || + slept?.cueAttached !== false || + slept?.runtimeIncludes !== false + ) { + throw new Error( + `Aurora enemy activation did not suspend after departure: ${JSON.stringify({ + staged, + awakened, + slept + })}` + ); + } + return { staged, awakened, slept }; + } finally { + await evaluate(session, `(() => { + const scene = window.mythicalGame.scene.getScene('AuroraDepthsLevel'); + scene.isInvincible = false; + scene.player?.body?.reset?.( + ${staged.playerStart.x}, + ${staged.playerStart.y} + ); + scene.player?.setVelocity?.(0, 0); + scene.cameras.main.setScroll( + ${staged.cameraStart.scrollX}, + ${staged.cameraStart.scrollY} + ); + scene.cameras.main.preRender?.(); + scene.updateAuroraEnemyActivation(true); + return true; + })()`); + await delay(120); + } +} + async function navigate(session, url) { // A document navigation ends every browser touch stream. Reusing the // previous document's synthetic identifier can make Chromium discard the @@ -2825,6 +2976,49 @@ async function smokeLevel(session, route, sceneName, exceptions, { ).length }; })() : null, + auroraEnemyRuntime: + scene?.scene?.key === 'AuroraDepthsLevel' ? (() => { + const enemies = scene?.enemies?.getChildren?.() || []; + return { + scheduledEnemyCount: enemies.filter( + enemy => typeof enemy?.auroraProximityActive === 'boolean' + ).length, + aiSchedulerActive: Boolean( + scene.auroraEnemyAISchedulerActive + ), + proximityActiveCount: enemies.filter( + enemy => enemy?.auroraProximityActive === true + ).length, + sleepingEnemyCount: enemies.filter( + enemy => enemy?.auroraProximityActive === false + ).length, + enabledBodyCount: enemies.filter( + enemy => enemy?.body?.enable === true + ).length, + renderAttachedEnemyCount: enemies.filter( + enemy => enemy?.displayList === scene.children + ).length, + renderAttachedCueCount: enemies.filter( + enemy => enemy?.combatCue?.displayList === scene.children + ).length, + sleepingDetachedCount: enemies.filter(enemy => ( + enemy?.auroraProximityActive === false && + enemy?.displayList !== scene.children && + enemy?.combatCue?.displayList !== scene.children + )).length, + runtimePatrolCount: + scene.getRuntimePatrolEnemies?.().length || 0, + activationBounds: scene.auroraEnemyActivationBounds ? { + horizontalMargin: + scene.auroraEnemyActivationBounds.horizontalMargin, + verticalMargin: + scene.auroraEnemyActivationBounds.verticalMargin + } : null, + patrolUpdateCount: Number( + scene.auroraEnemyPatrolUpdateCount + ) || 0 + }; + })() : null, forestDecorationRendering: scene?.scene?.key === 'MythicalForestLevel' ? (() => { const tweens = scene?.tweens?.getTweens?.() || []; @@ -3007,6 +3201,29 @@ async function smokeLevel(session, route, sceneName, exceptions, { scene.tweens?.getTweens?.() || [] ).filter( tween => tween === scene.auroraFragmentTween + ).length, + landingGuideTweenCount: (() => { + const guides = new Set([ + ...(scene.signalPrisms || []).map( + prism => prism?.landingGuide + ), + scene.phoenixLandingGuide + ].filter(Boolean)); + return (scene.tweens?.getTweens?.() || []).filter( + tween => (tween?.targets || []).some( + target => guides.has(target) + ) + ).length; + })(), + quietRouteTweenCount: ( + scene.tweens?.getTweens?.() || [] + ).filter(tween => (tween?.targets || []).includes( + scene.quietLightRouteVisual + )).length, + optionalPickupTweenCount: ( + scene.tweens?.getTweens?.() || [] + ).filter( + tween => tween === scene.optionalRoutePickupTween ).length } : null, routeGuidance: (() => { @@ -3213,7 +3430,29 @@ async function smokeLevel(session, route, sceneName, exceptions, { state.auroraAmbientRendering?.shadowCurrentLabelCount !== 3 || state.auroraAmbientRendering?.shadowPulseTweenCount !== 1 || state.auroraAmbientRendering?.fragmentCount !== 5 || - state.auroraAmbientRendering?.fragmentPulseTweenCount !== 1 + state.auroraAmbientRendering?.fragmentPulseTweenCount !== 0 || + state.auroraAmbientRendering?.landingGuideTweenCount !== 0 || + state.auroraAmbientRendering?.quietRouteTweenCount !== 0 || + state.auroraAmbientRendering?.optionalPickupTweenCount !== 0 || + state.auroraEnemyRuntime?.scheduledEnemyCount !== 8 || + state.auroraEnemyRuntime?.aiSchedulerActive !== true || + state.auroraEnemyRuntime?.proximityActiveCount > 3 || + state.auroraEnemyRuntime?.sleepingEnemyCount < 5 || + state.auroraEnemyRuntime?.proximityActiveCount + + state.auroraEnemyRuntime?.sleepingEnemyCount !== 8 || + state.auroraEnemyRuntime?.enabledBodyCount !== + state.auroraEnemyRuntime?.proximityActiveCount || + state.auroraEnemyRuntime?.renderAttachedEnemyCount !== + state.auroraEnemyRuntime?.proximityActiveCount || + state.auroraEnemyRuntime?.renderAttachedCueCount !== + state.auroraEnemyRuntime?.proximityActiveCount || + state.auroraEnemyRuntime?.sleepingDetachedCount !== + state.auroraEnemyRuntime?.sleepingEnemyCount || + state.auroraEnemyRuntime?.runtimePatrolCount !== + state.auroraEnemyRuntime?.proximityActiveCount || + state.auroraEnemyRuntime?.activationBounds?.horizontalMargin !== 520 || + state.auroraEnemyRuntime?.activationBounds?.verticalMargin !== 280 || + state.auroraEnemyRuntime?.patrolUpdateCount < 1 ) ) { throw new Error( @@ -3405,6 +3644,32 @@ async function smokeLevel(session, route, sceneName, exceptions, { JSON.stringify(framePacing.graphicsTweenDepths) ); } + if ( + route === 'auroraDepths' && + ( + framePacing.auroraRuntime?.patrolUpdatesDuringSample < 10 || + framePacing.auroraRuntime?.patrolUpdatesDuringSample > 28 || + framePacing.auroraRuntime?.runtimeEnemyCount > 3 + ) + ) { + throw new Error( + `${sceneName} did not keep Aurora patrol work bounded: ` + + JSON.stringify(framePacing.auroraRuntime) + ); + } + if ( + route === 'auroraDepths' && + (state.canvasWidth <= 480 || state.canvasHeight < 620) && + [ + 'depth:105:visible', + 'depth:179:visible' + ].some(depth => framePacing.graphicsTweenDepths?.[depth]) + ) { + throw new Error( + `${sceneName} kept whole-route Aurora guidance animating on mobile: ` + + JSON.stringify(framePacing.graphicsTweenDepths) + ); + } const forestEnemyActivation = route === 'mythicalForest' ? await smokeForestEnemyActivationWindow(session) : null; @@ -3420,6 +3685,9 @@ async function smokeLevel(session, route, sceneName, exceptions, { const reefTrailBudget = route === 'reef' ? await smokeReefTrailBudget(session) : null; + const auroraEnemyActivation = route === 'auroraDepths' + ? await smokeAuroraEnemyActivationWindow(session) + : null; if ( [ 'mythicalForest', @@ -6307,6 +6575,9 @@ async function smokeLevel(session, route, sceneName, exceptions, { caveEnemyAISchedulerActive: Boolean( scene.caveEnemyAISchedulerActive ), + auroraEnemyAISchedulerActive: Boolean( + scene.auroraEnemyAISchedulerActive + ), persistedId: persisted?.checkpointId || null, persistedIndex: persisted?.checkpointIndex ?? null }; @@ -6340,6 +6611,8 @@ async function smokeLevel(session, route, sceneName, exceptions, { guardianEntry.runtimeDisposals?.timerCount !== 2) || (route === 'crystalCaves' && guardianEntry.caveEnemyAISchedulerActive !== false) || + (route === 'auroraDepths' && + guardianEntry.auroraEnemyAISchedulerActive !== false) || guardianEntry.persistedId !== guardianEntrySetup.persistedId || guardianEntry.persistedIndex !== guardianEntrySetup.persistedIndex ) { @@ -6595,6 +6868,7 @@ async function smokeLevel(session, route, sceneName, exceptions, { forestCoinPickup, caveCoinPickup, reefTrailBudget, + auroraEnemyActivation, renderStability, combatFeedback, liveStomp, diff --git a/src/__tests__/LevelTraversalQualityContract.test.js b/src/__tests__/LevelTraversalQualityContract.test.js index 5bfa2552..96f0d0a1 100644 --- a/src/__tests__/LevelTraversalQualityContract.test.js +++ b/src/__tests__/LevelTraversalQualityContract.test.js @@ -1056,6 +1056,41 @@ describe('campaign traversal quality contracts', () => { expect(source).not.toContain('targets: ember,'); }); + test('Aurora Depths streams patrols and keeps mobile route cues static', () => { + const source = read('levels/AuroraDepthsLevel.js'); + const platformerSource = read('PlatformerLevelScene.js'); + const smoke = read('../../scripts/smoke-secondary-journeys.js'); + + expect(platformerSource).toContain('getRuntimePatrolEnemies()'); + expect(platformerSource).toContain('const enemies = this.getRuntimePatrolEnemies();'); + expect(source).toContain('startAuroraEnemyScheduler()'); + expect(source).toContain('updateAuroraEnemyActivation(force = false)'); + expect(source).toContain('setAuroraEnemyRenderAttached(enemy, attached)'); + expect(source).toContain('getRuntimePatrolEnemies()'); + expect(source).toContain('this.auroraProximityEnemies'); + expect(source).toContain('this.isMobile ? 80 : 40'); + expect(source).toContain('shouldAnimateAuroraDecorations()'); + expect(source).toMatch( + /createQuietLightRoute\(\)[\s\S]*if \(this\.shouldAnimateAuroraDecorations\(\)\) \{[\s\S]*targets: route,/ + ); + expect(source).toMatch( + /createAuroraFragments\(\)[\s\S]*if \(this\.shouldAnimateAuroraDecorations\(\)\) \{[\s\S]*targets: fragmentTargets,/ + ); + expect(source).toContain('{ animate: this.shouldAnimateAuroraDecorations() }'); + expect(smoke).toContain('state.auroraEnemyRuntime?.scheduledEnemyCount !== 8'); + expect(smoke).toContain('state.auroraEnemyRuntime?.proximityActiveCount > 3'); + expect(smoke).toContain('state.auroraEnemyRuntime?.runtimePatrolCount !=='); + expect(smoke).toContain('guardianEntry.auroraEnemyAISchedulerActive !== false'); + expect(smoke).toContain('framePacing.auroraRuntime?.patrolUpdatesDuringSample > 28'); + expect(smoke).toContain('smokeAuroraEnemyActivationWindow(session)'); + expect(smoke).toContain('Aurora enemy activation did not wake before contact'); + expect(smoke).toContain('Aurora enemy activation did not suspend after departure'); + expect(smoke).toContain("'depth:105:visible'"); + expect(smoke).toContain("'depth:179:visible'"); + expect(smoke).toContain('displayCount: 160'); + expect(smoke).toContain('activeTweenCount: 15'); + }); + test('shared route guidance resets before the first invalid contact', () => { const source = read('PlatformerLevelScene.js'); @@ -1464,7 +1499,7 @@ describe('campaign traversal quality contracts', () => { expect(source).toContain("this.grantOptionalRouteGuard('QUIET LIGHT WARD', 1)"); expect(source).toContain("this.selectAuroraRoute('shadow_current')"); expect(source).toContain('LAND + ALIGN'); - expect(source).toContain("this.createTraversalLandingGuide('aurora-phoenix-gate'"); + expect(source).toContain('this.phoenixLandingGuide = this.createTraversalLandingGuide('); expect(source).toContain("this.isPlayerGroundedOnTraversalSupport(\n 'aurora-quiet-step-3'"); expect(source).toContain('const routeBonus = this.consumeCurrentCharge();'); expect(source).toContain('this.currentChargeAuraTween?.remove?.();'); @@ -2029,6 +2064,7 @@ describe('campaign traversal quality contracts', () => { expect(source).toContain("role: airborne ? 'flyer'"); expect(source).toContain('updatePatrolEnemyMovement()'); expect(source).toContain('this.updatePatrolEnemyMovement();'); + expect(source).toContain('getRuntimePatrolEnemies()'); }); test('the shared route contract identifies the next objective without blocking input', () => { @@ -2759,10 +2795,11 @@ describe('campaign traversal quality contracts', () => { expect(smoke).toContain('framePacing.peaksRuntime?.emberRedrawsDuringSample > 4'); expect(smoke).toContain('framePacing.peaksRuntime?.patrolUpdatesDuringSample > 28'); expect(smoke).toContain('did not keep Peaks runtime work bounded'); - expect(smoke).toContain('activeTweenCount: 22'); + expect(smoke).toContain('activeTweenCount: 15'); expect(smoke).toContain('state.auroraAmbientRendering?.shadowCurrentLabelCount !== 3'); expect(smoke).toContain('state.auroraAmbientRendering?.shadowPulseTweenCount !== 1'); - expect(smoke).toContain('state.auroraAmbientRendering?.fragmentPulseTweenCount !== 1'); + expect(smoke).toContain('state.auroraAmbientRendering?.fragmentPulseTweenCount !== 0'); + expect(smoke).toContain('state.auroraAmbientRendering?.landingGuideTweenCount !== 0'); expect(smoke).toContain('did not keep Aurora hazards readable and batched'); expect(smoke).toContain('smokeForestBatchedCoinPickup(session)'); expect(smoke).toContain('state.coinRendering?.legacyVisualCount !== 0'); diff --git a/src/scenes/PlatformerLevelScene.js b/src/scenes/PlatformerLevelScene.js index d523a607..e3f25272 100644 --- a/src/scenes/PlatformerLevelScene.js +++ b/src/scenes/PlatformerLevelScene.js @@ -4237,10 +4237,15 @@ class PlatformerLevelScene extends Phaser.Scene { graphics.destroy(); } + getRuntimePatrolEnemies() { + return this.enemies?.getChildren?.() || []; + } + updatePatrolEnemyMovement() { - if (!this.enemies?.getChildren) return false; + const enemies = this.getRuntimePatrolEnemies(); + if (!enemies.length) return false; - this.enemies.getChildren().forEach(enemy => { + enemies.forEach(enemy => { if ( enemy?.active === false || !enemy?.body || @@ -4327,9 +4332,11 @@ class PlatformerLevelScene extends Phaser.Scene { } updateEnemyCombatReadability() { - if (!this.player?.active || !this.enemies?.getChildren) return false; + if (!this.player?.active) return false; + const enemies = this.getRuntimePatrolEnemies(); + if (!enemies.length) return false; - this.enemies.getChildren().forEach(enemy => { + enemies.forEach(enemy => { const cue = enemy?.combatCue; if (!cue?.active) return; const visible = enemy.active !== false && Phaser.Math.Distance.Between( diff --git a/src/scenes/levels/AuroraDepthsLevel.js b/src/scenes/levels/AuroraDepthsLevel.js index 50620d86..7ae64574 100644 --- a/src/scenes/levels/AuroraDepthsLevel.js +++ b/src/scenes/levels/AuroraDepthsLevel.js @@ -178,6 +178,14 @@ class AuroraDepthsLevel extends PlatformerLevelScene { this.currentChargeAura = null; this.currentChargeAuraTween = null; this.auroraEncounterRhythm = []; + this.auroraProximityEnemies = []; + this.auroraEnemyAISchedulerActive = false; + this.auroraEnemyActivationBounds = null; + this.auroraEnemyActivationNextAt = 0; + this.auroraEnemyPatrolNextAt = 0; + this.auroraEnemyPatrolUpdateCount = 0; + this.quietLightRouteVisual = null; + this.phoenixLandingGuide = null; this.objectiveDisplay = null; this.levelEntryDismissing = false; this.levelEntryKeyHandler = null; @@ -243,6 +251,14 @@ class AuroraDepthsLevel extends PlatformerLevelScene { this.currentChargeAura = null; this.currentChargeAuraTween = null; this.auroraEncounterRhythm = []; + this.auroraProximityEnemies = []; + this.auroraEnemyAISchedulerActive = false; + this.auroraEnemyActivationBounds = null; + this.auroraEnemyActivationNextAt = 0; + this.auroraEnemyPatrolNextAt = 0; + this.auroraEnemyPatrolUpdateCount = 0; + this.quietLightRouteVisual = null; + this.phoenixLandingGuide = null; this.objectiveDisplay = null; this.levelEntryDismissing = false; this.clearLevelEntryKeyHandler(); @@ -517,6 +533,7 @@ class AuroraDepthsLevel extends PlatformerLevelScene { this.bossBody.setPosition(this.boss.x, this.boss.y + 35); } + this.updateAuroraEnemyActivation(); super.update(time, delta); if (this.levelCompletionActive) return; @@ -580,21 +597,158 @@ class AuroraDepthsLevel extends PlatformerLevelScene { enemy.encounterBeat = encounter.beat; enemy.encounterLane = encounter.lane; enemy.encounterSupportId = encounter.supportId; + enemy.auroraProximityActive = null; return enemy; }); + this.startAuroraEnemyScheduler(); return this.auroraEncounterRhythm; } retireAuroraPatrolsForPhoenix() { const patrols = [...(this.enemies?.getChildren?.() || [])]; + this.auroraEnemyAISchedulerActive = false; + this.auroraProximityEnemies = []; + this.auroraEnemyActivationBounds = null; const retirement = this.retireRouteEnemies(patrols); this.auroraEncounterRhythm = []; return retirement.enemyCount; } + shouldAnimateAuroraDecorations() { + const width = Number(this.cameras?.main?.width) || 0; + const height = Number(this.cameras?.main?.height) || 0; + return !(this.isMobile || width <= 480 || height < 620); + } + + startAuroraEnemyScheduler() { + this.auroraEnemyAISchedulerActive = true; + this.auroraEnemyActivationNextAt = 0; + this.auroraEnemyPatrolNextAt = 0; + this.auroraEnemyPatrolUpdateCount = 0; + this.updateAuroraEnemyActivation(true); + } + + getAuroraEnemyActivationBounds() { + const view = this.cameras?.main?.worldView; + const playerX = Number(this.player?.x) || 0; + const playerY = Number(this.player?.y) || 0; + const width = Math.max( + 320, + Number(view?.width) || Number(this.cameras?.main?.width) || 390 + ); + const height = Math.max( + 320, + Number(view?.height) || Number(this.cameras?.main?.height) || 720 + ); + const horizontalMargin = this.isMobile ? 520 : 800; + const verticalMargin = this.isMobile ? 280 : 420; + const viewLeft = Number(view?.left) || 0; + const viewRight = Number(view?.right) || width; + const viewTop = Number(view?.top) || 0; + const viewBottom = Number(view?.bottom) || height; + return { + left: Math.min(viewLeft, playerX - width / 2) - horizontalMargin, + right: Math.max(viewRight, playerX + width / 2) + horizontalMargin, + top: Math.min(viewTop, playerY - height / 2) - verticalMargin, + bottom: Math.max(viewBottom, playerY + height / 2) + verticalMargin, + horizontalMargin, + verticalMargin + }; + } + + setAuroraEnemyRenderAttached(enemy, attached) { + if (!enemy || !this.children) return 0; + const targets = [ + enemy, + enemy.combatCue, + enemy.instructionLabel + ].filter(target => Boolean(target) && target.active !== false); + let changedCount = 0; + targets.forEach(target => { + const isAttached = target.displayList === this.children; + if (attached && !isAttached) { + this.children.add(target); + changedCount += 1; + } else if (!attached && isAttached) { + this.children.remove(target); + changedCount += 1; + } + }); + return changedCount; + } + + setAuroraEnemyProximityActive(enemy, enabled) { + if (!enemy?.active || !enemy.body) return false; + const nextState = enabled === true; + if (enemy.auroraProximityActive === nextState) return nextState; + + enemy.auroraProximityActive = nextState; + if (nextState) { + this.setAuroraEnemyRenderAttached(enemy, true); + enemy.setVisible(true); + enemy.body.enable = true; + enemy.body.updateFromGameObject?.(); + const patrolSpeed = Math.max(25, Number(enemy.patrolSpeed) || 42); + enemy.setVelocityX(enemy.flipX ? -patrolSpeed : patrolSpeed); + } else { + enemy.setVelocity?.(0, 0); + enemy.body.updateFromGameObject?.(); + enemy.body.enable = false; + enemy.setVisible(false); + enemy.combatCue?.setVisible?.(false); + enemy.instructionLabel?.setVisible?.(false); + this.setAuroraEnemyRenderAttached(enemy, false); + } + return nextState; + } + + updateAuroraEnemyActivation(force = false) { + if (!this.auroraEnemyAISchedulerActive || !this.scene.isActive()) return 0; + const now = Number(this.time?.now) || 0; + if (!force && now < this.auroraEnemyActivationNextAt) { + return this.auroraProximityEnemies.length; + } + this.auroraEnemyActivationNextAt = now + (this.isMobile ? 120 : 80); + + const bounds = this.getAuroraEnemyActivationBounds(); + const nearby = []; + (this.enemies?.getChildren?.() || []).forEach(enemy => { + if (!enemy?.active || !enemy.body) return; + const shouldWake = + enemy.x >= bounds.left && + enemy.x <= bounds.right && + enemy.y >= bounds.top && + enemy.y <= bounds.bottom; + this.setAuroraEnemyProximityActive(enemy, shouldWake); + if (shouldWake) nearby.push(enemy); + }); + this.auroraProximityEnemies = nearby; + this.auroraEnemyActivationBounds = bounds; + return nearby.length; + } + + getRuntimePatrolEnemies() { + if (!this.auroraEnemyAISchedulerActive) { + return super.getRuntimePatrolEnemies(); + } + return this.auroraProximityEnemies; + } + + updatePatrolEnemyMovement() { + if (!this.auroraEnemyAISchedulerActive) { + return super.updatePatrolEnemyMovement(); + } + const now = Number(this.time?.now) || 0; + if (now < this.auroraEnemyPatrolNextAt) return true; + this.auroraEnemyPatrolNextAt = now + (this.isMobile ? 80 : 40); + this.auroraEnemyPatrolUpdateCount += 1; + return super.updatePatrolEnemyMovement(); + } + createQuietLightRoute() { const groundY = this.levelHeight - 50; const route = this.add.graphics().setDepth(105); + this.quietLightRouteVisual = route; route.lineStyle(5, 0x7FFFD4, 0.58); route.beginPath(); @@ -611,13 +765,15 @@ class AuroraDepthsLevel extends PlatformerLevelScene { route.fillCircle(x, y, index === 2 ? 7 : 5); }); - this.tweens.add({ - targets: route, - alpha: { from: 0.55, to: 1 }, - duration: 1100, - yoyo: true, - repeat: -1 - }); + if (this.shouldAnimateAuroraDecorations()) { + this.tweens.add({ + targets: route, + alpha: { from: 0.55, to: 1 }, + duration: 1100, + yoyo: true, + repeat: -1 + }); + } const directRouteMarker = this.add.text(2820, groundY - 82, '', { fontSize: '11px', @@ -706,14 +862,16 @@ class AuroraDepthsLevel extends PlatformerLevelScene { ).setOrigin(0.5).setDepth(721); this.optionalRoutePickupLabel = shelterLabel; - this.optionalRoutePickupTween = this.tweens.add({ - targets: [shelter, shelterLabel], - y: '-=10', - duration: 900, - yoyo: true, - repeat: -1, - ease: 'Sine.easeInOut' - }); + if (this.shouldAnimateAuroraDecorations()) { + this.optionalRoutePickupTween = this.tweens.add({ + targets: [shelter, shelterLabel], + y: '-=10', + duration: 900, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut' + }); + } this.optionalRoutePickupOverlap = this.physics.add.overlap( this.player, @@ -1048,14 +1206,17 @@ class AuroraDepthsLevel extends PlatformerLevelScene { }); this.auroraFragmentTween?.remove?.(); - this.auroraFragmentTween = this.tweens.add({ - targets: fragmentTargets, - angle: 360, - y: '-=12', - duration: 1600, - repeat: -1, - yoyo: true - }); + this.auroraFragmentTween = null; + if (this.shouldAnimateAuroraDecorations()) { + this.auroraFragmentTween = this.tweens.add({ + targets: fragmentTargets, + angle: 360, + y: '-=12', + duration: 1600, + repeat: -1, + yoyo: true + }); + } this.physics.add.overlap( this.player, @@ -1171,7 +1332,9 @@ class AuroraDepthsLevel extends PlatformerLevelScene { label, zone, landingGuide: this.createTraversalLandingGuide( - prism.activationSupportIds[0] + prism.activationSupportIds[0], + 0x7FFFD4, + { animate: this.shouldAnimateAuroraDecorations() } ), aligned: false }; @@ -1392,7 +1555,11 @@ class AuroraDepthsLevel extends PlatformerLevelScene { color: 0x00E676, readyColor: 0xF2C94C }); - this.createTraversalLandingGuide('aurora-phoenix-gate', 0xF2C94C); + this.phoenixLandingGuide = this.createTraversalLandingGuide( + 'aurora-phoenix-gate', + 0xF2C94C, + { animate: this.shouldAnimateAuroraDecorations() } + ); if (this.player) { this.physics.add.overlap(this.player, triggerZone, () => { @@ -2679,6 +2846,11 @@ class AuroraDepthsLevel extends PlatformerLevelScene { // Clearing it here can run after the physics world has already disposed // the group's body set when campaign scenes are stopped in quick succession. this.auroraFragments = null; + this.auroraEnemyAISchedulerActive = false; + this.auroraProximityEnemies = []; + this.auroraEnemyActivationBounds = null; + this.quietLightRouteVisual = null; + this.phoenixLandingGuide = null; this.objectiveDisplay?.destroy?.(); this.objectiveDisplay = null;