From c51000e6906a17e84babe305e1123e0a03e20e20 Mon Sep 17 00:00:00 2001 From: TechEvolveAI Date: Fri, 14 Aug 2026 16:30:26 +0100 Subject: [PATCH] Improve Forest mobile rendering performance --- scripts/smoke-secondary-journeys.js | 54 +++++++- .../LevelTraversalQualityContract.test.js | 18 +++ src/scenes/levels/MythicalForestLevel.js | 127 ++++++++++++------ 3 files changed, 152 insertions(+), 47 deletions(-) diff --git a/scripts/smoke-secondary-journeys.js b/scripts/smoke-secondary-journeys.js index 66d7f95d..9a2db867 100644 --- a/scripts/smoke-secondary-journeys.js +++ b/scripts/smoke-secondary-journeys.js @@ -24,8 +24,8 @@ const SMOKE_VIEWPORT_WIDTH = Number(process.env.SMOKE_VIEWPORT_WIDTH) || 390; const SMOKE_VIEWPORT_HEIGHT = Number(process.env.SMOKE_VIEWPORT_HEIGHT) || 844; const CAMPAIGN_MOBILE_RENDER_BUDGETS = Object.freeze({ mythicalForest: Object.freeze({ - displayCount: 260, - activeTweenCount: 40, + displayCount: 255, + activeTweenCount: 18, performanceTier: 'mobile' }), crystalCaves: Object.freeze({ @@ -2354,6 +2354,48 @@ async function smokeLevel(session, route, sceneName, exceptions, { scene.forestWisps?.includes?.(target) ))).length } : null, + forestDecorationRendering: + scene?.scene?.key === 'MythicalForestLevel' ? (() => { + const tweens = scene?.tweens?.getTweens?.() || []; + const fragmentTargets = new Set( + (scene.starFragmentSprites || []) + .map(fragment => fragment?.sprite) + .filter(Boolean) + ); + const landingGuideTargets = new Set( + (scene.checkpointAnchors || []) + .map(checkpoint => checkpoint?.landingGuide) + .filter(Boolean) + ); + return { + starFragmentTweenCount: tweens.filter( + tween => (tween?.targets || []).some( + target => fragmentTargets.has(target) + ) + ).length, + landingGuideTweenCount: tweens.filter( + tween => (tween?.targets || []).some( + target => landingGuideTargets.has(target) + ) + ).length, + voidMoteTweenCount: tweens.filter( + tween => (tween?.targets || []).includes( + scene.forestVoidMoteLayer + ) + ).length, + arenaParticleTweenCount: tweens.filter( + tween => (tween?.targets || []).some( + target => target?.forestArenaAmbientParticle + ) + ).length, + arenaAmbientLayerCount: scene.children?.list?.filter( + item => item === scene.forestArenaAmbientLayer + ).length || 0, + arenaAmbientTimerActive: Boolean( + scene.forestArenaAmbientTimer?.active + ) + }; + })() : null, caveCoinRendering: Array.isArray(scene?.caveCoinPickups) ? { batchedCount: scene.caveCoinPickups.filter( coin => coin?.batched && !coin.collected @@ -2517,7 +2559,13 @@ async function smokeLevel(session, route, sceneName, exceptions, { state.forestEnemyRuntime?.groundEnemySupportIds || [] ).size !== 5 || state.forestEnemyRuntime?.unsupportedGroundEnemyIds?.length !== 0 || - state.forestEnemyRuntime?.airborneMotionTweenCount !== 0 + state.forestEnemyRuntime?.airborneMotionTweenCount !== 0 || + state.forestDecorationRendering?.starFragmentTweenCount !== 0 || + state.forestDecorationRendering?.landingGuideTweenCount !== 0 || + state.forestDecorationRendering?.voidMoteTweenCount !== 0 || + state.forestDecorationRendering?.arenaParticleTweenCount !== 0 || + state.forestDecorationRendering?.arenaAmbientLayerCount !== 1 || + state.forestDecorationRendering?.arenaAmbientTimerActive !== false ) ) { throw new Error( diff --git a/src/__tests__/LevelTraversalQualityContract.test.js b/src/__tests__/LevelTraversalQualityContract.test.js index 4e354448..2d7cccf2 100644 --- a/src/__tests__/LevelTraversalQualityContract.test.js +++ b/src/__tests__/LevelTraversalQualityContract.test.js @@ -841,6 +841,12 @@ describe('campaign traversal quality contracts', () => { expect(source).not.toContain('{ x: 700, y: this.levelHeight - 150 }'); expect(source).toContain('this.forestFoliageLayer.setAlpha(0.82);'); expect(source).toContain('layer.fillStyle(color, this.isMobile ? 0.72 : 0.6);'); + expect(source).toContain('shouldAnimateForestDecorations()'); + expect(source).toContain('{ depth: 84, animate: animateRouteDecorations }'); + expect(source).toContain('sprite.setAngle(index * 18);'); + expect(source).toContain('this.forestArenaAmbientLayer = ambient;'); + expect(source).toContain('this.forestArenaAmbientTimer = this.time.addEvent({'); + expect(source).toContain("'forestArenaAmbientLayer'"); expect(source).toMatch( /ensureForestCoinLayer\(\)[\s\S]*if \(!this\.isMobile\) \{[\s\S]*this\.forestCoinLayerTween/ ); @@ -2608,6 +2614,18 @@ describe('campaign traversal quality contracts', () => { expect(smoke).toContain('unsupportedGroundEnemyIds'); expect(smoke).toContain('groundEnemySupportIds'); expect(smoke).toContain('state.forestEnemyRuntime?.airborneMotionTweenCount !== 0'); + expect(smoke).toContain( + 'state.forestDecorationRendering?.starFragmentTweenCount !== 0' + ); + expect(smoke).toContain( + 'state.forestDecorationRendering?.landingGuideTweenCount !== 0' + ); + expect(smoke).toContain( + 'state.forestDecorationRendering?.arenaParticleTweenCount !== 0' + ); + expect(smoke).toContain( + 'state.forestDecorationRendering?.arenaAmbientLayerCount !== 1' + ); expect(smoke).toContain('smokeForestSharedEnemyScheduler(session)'); expect(smoke).toContain('Forest shared enemy scheduler did not advance patrol AI'); expect(smoke).toContain('Forest grouped coin did not resolve exactly once'); diff --git a/src/scenes/levels/MythicalForestLevel.js b/src/scenes/levels/MythicalForestLevel.js index b2a751b6..026a50be 100644 --- a/src/scenes/levels/MythicalForestLevel.js +++ b/src/scenes/levels/MythicalForestLevel.js @@ -127,6 +127,8 @@ class MythicalForestLevel extends PlatformerLevelScene { this.forestBridgeLayer = null; this.forestEnemyTrailLayer = null; this.forestEnemyTrailTimer = null; + this.forestArenaAmbientLayer = null; + this.forestArenaAmbientTimer = null; this.forestEnemyAISchedulerActive = false; this.forestEnemyAICursor = 0; this.forestEnemyMotionNextAt = 0; @@ -248,6 +250,8 @@ class MythicalForestLevel extends PlatformerLevelScene { this.forestBridgeLayer = null; this.forestEnemyTrailLayer = null; this.forestEnemyTrailTimer = null; + this.forestArenaAmbientLayer = null; + this.forestArenaAmbientTimer = null; this.forestEnemyAISchedulerActive = false; this.forestEnemyAICursor = 0; this.forestEnemyMotionNextAt = 0; @@ -1251,7 +1255,14 @@ class MythicalForestLevel extends PlatformerLevelScene { console.log('[MythicalForestLevel] Level content created!'); } + shouldAnimateForestDecorations() { + const width = Number(this.cameras?.main?.width) || 0; + const height = Number(this.cameras?.main?.height) || 0; + return !(this.isMobile || width <= 480 || height < 620); + } + createBeaconCheckpoints() { + const animateRouteDecorations = this.shouldAnimateForestDecorations(); const groundY = this.levelHeight - 100; const anchors = [ { @@ -1313,7 +1324,7 @@ class MythicalForestLevel extends PlatformerLevelScene { landingGuide: this.createTraversalLandingGuide( supportId, 0x8FE3CF, - { depth: 84 } + { depth: 84, animate: animateRouteDecorations } ), activated: false, respawnY: supportCheckpoint.y @@ -1682,15 +1693,19 @@ class MythicalForestLevel extends PlatformerLevelScene { ); } }); - this.tweens.add({ - targets: moteLayer, - y: { from: 8, to: -16 }, - alpha: { from: 0.35, to: 0.75 }, - duration: 1800, - yoyo: true, - repeat: -1, - ease: 'Sine.easeInOut' - }); + if (this.shouldAnimateForestDecorations()) { + this.tweens.add({ + targets: moteLayer, + y: { from: 8, to: -16 }, + alpha: { from: 0.35, to: 0.75 }, + duration: 1800, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut' + }); + } else { + moteLayer.setPosition(0, -4).setAlpha(0.58); + } } /** @@ -3015,16 +3030,21 @@ class MythicalForestLevel extends PlatformerLevelScene { sprite.setDepth(140); sprite.optionalRouteId = optionalRouteId; - // Rotation drives the bob as well, halving the persistent tween count. - this.tweens.add({ - targets: sprite, - angle: 360, - duration: 3000, - repeat: -1, - onUpdate: tween => { - sprite.y = y - Math.sin(tween.progress * Math.PI) * 8; - } - }); + // Keep collectible silhouettes strong on compact screens without five + // permanent transforms competing with movement and enemy updates. + if (this.shouldAnimateForestDecorations()) { + this.tweens.add({ + targets: sprite, + angle: 360, + duration: 3000, + repeat: -1, + onUpdate: tween => { + sprite.y = y - Math.sin(tween.progress * Math.PI) * 8; + } + }); + } else { + sprite.setAngle(index * 18); + } // Sparkle particles around it if (!this.isMobile) { @@ -3608,30 +3628,46 @@ class MythicalForestLevel extends PlatformerLevelScene { }); } - // Add atmospheric particles in arena - this.time.addEvent({ - delay: 200, - callback: () => { - if (!this.scene.isActive()) return; - const particle = this.add.graphics(); - particle.fillStyle(0x9370DB, 0.4); - particle.fillCircle(0, 0, 2 + Math.random() * 2); - particle.setPosition( - arenaX + Math.random() * arenaWidth, - groundY - 10 - Math.random() * 50 - ); - particle.setDepth(9); + // The arena is several screens away at spawn. Phones keep one batched + // field instead of allocating and destroying offscreen particles for + // the whole expedition; desktop retains the moving treatment. + if (this.shouldAnimateForestDecorations()) { + this.forestArenaAmbientTimer = this.time.addEvent({ + delay: 200, + callback: () => { + if (!this.scene.isActive()) return; + const particle = this.add.graphics(); + particle.forestArenaAmbientParticle = true; + particle.fillStyle(0x9370DB, 0.4); + particle.fillCircle(0, 0, 2 + Math.random() * 2); + particle.setPosition( + arenaX + Math.random() * arenaWidth, + groundY - 10 - Math.random() * 50 + ); + particle.setDepth(9); - this.tweens.add({ - targets: particle, - y: particle.y - 60, - alpha: 0, - duration: 1500, - onComplete: () => particle.destroy() - }); - }, - loop: true - }); + this.tweens.add({ + targets: particle, + y: particle.y - 60, + alpha: 0, + duration: 1500, + onComplete: () => particle.destroy() + }); + }, + loop: true + }); + } else { + const ambient = this.add.graphics().setDepth(9).setAlpha(0.68); + for (let index = 0; index < 16; index += 1) { + ambient.fillStyle(0x9370DB, 0.22 + (index % 3) * 0.07); + ambient.fillCircle( + arenaX + 52 + index * 88, + groundY - 18 - (index % 4) * 13, + 2 + (index % 2) + ); + } + this.forestArenaAmbientLayer = ambient; + } } /** @@ -5113,13 +5149,16 @@ class MythicalForestLevel extends PlatformerLevelScene { this.forestFoliageLayer = null; this.forestEnemyTrailTimer?.remove?.(); this.forestEnemyTrailTimer = null; + this.forestArenaAmbientTimer?.remove?.(); + this.forestArenaAmbientTimer = null; [ 'forestGroundLayer', 'forestVoidLayer', 'forestVoidMoteLayer', 'forestTreeStructureLayer', 'forestBridgeLayer', - 'forestEnemyTrailLayer' + 'forestEnemyTrailLayer', + 'forestArenaAmbientLayer' ].forEach(property => { this.tweens?.killTweensOf?.(this[property]); this[property]?.destroy?.();