From 42c9f38d707c2127f3768cfde1b148ad56430765 Mon Sep 17 00:00:00 2001 From: TechEvolveAI Date: Fri, 14 Aug 2026 14:12:47 +0100 Subject: [PATCH] Budget campaign objective rendering --- scripts/smoke-secondary-journeys.js | 66 ++++++++++++++- .../LevelTraversalQualityContract.test.js | 81 +++++++++++++++++++ src/scenes/PlatformerLevelScene.js | 33 +++++++- src/scenes/levels/AuroraDepthsLevel.js | 11 +-- src/scenes/levels/CrystalCavesLevel.js | 12 +-- src/scenes/levels/FinalVoidLevel.js | 12 ++- src/scenes/levels/MythicalForestLevel.js | 12 ++- src/scenes/levels/ReefLevel.js | 13 ++- src/scenes/levels/VoidPeaksLevel.js | 13 ++- 9 files changed, 205 insertions(+), 48 deletions(-) diff --git a/scripts/smoke-secondary-journeys.js b/scripts/smoke-secondary-journeys.js index b4901338..b0725360 100644 --- a/scripts/smoke-secondary-journeys.js +++ b/scripts/smoke-secondary-journeys.js @@ -198,6 +198,9 @@ async function sampleFramePacing(session, sceneName, { ); const intervals = []; const startedAt = performance.now(); + const objectiveTextureRevisionAtStart = Number( + scene?.campaignObjectiveTextureRevision + ) || 0; let previousAt = null; const percentile = (sorted, ratio) => { @@ -279,6 +282,16 @@ async function sampleFramePacing(session, sceneName, { (window.mythicalGame?.loop?.actualFps || 0).toFixed(2) ), displayCount: scene?.children?.list?.length || 0, + objectiveHudRendering: { + textureRevision: Number( + scene?.campaignObjectiveTextureRevision + ) || 0, + rebuildsDuringSample: Math.max( + 0, + (Number(scene?.campaignObjectiveTextureRevision) || 0) - + objectiveTextureRevisionAtStart + ) + }, activeTweenCount: scene?.tweens?.getTweens?.().length || 0, sharedAmbientFieldTweenCount: tweenTargets.filter( target => sharedAmbientFieldObjects.has(target) @@ -2193,6 +2206,33 @@ async function smokeLevel(session, route, sceneName, exceptions, { await startCampaignScene(session, { route, sceneName }); await delay(400); + if (route === 'mythicalForest') { + await waitFor( + () => evaluate(session, `(() => { + const scene = window.mythicalGame?.scene?.getScene?.( + 'MythicalForestLevel' + ); + const enemies = scene?.voidSprites || []; + return enemies.length === 5 && enemies.every(enemy => { + if (!enemy?.active || !enemy?.body) return false; + const support = scene.getTraversalSupport?.( + enemy.forestSupportId + ); + return Boolean( + support?.body && + enemy.body.right > support.body.left + 4 && + enemy.body.left < support.body.right - 4 && + Math.abs(enemy.body.bottom - support.body.top) <= 12 + ); + }); + })()`), + { + timeoutMs: 3500, + message: 'Forest authored enemies settled on their supports' + } + ); + } + const state = await evaluate(session, `(() => { const scene = window.mythicalGame.scene.getScene(${JSON.stringify(sceneName)}); return { @@ -2517,6 +2557,26 @@ async function smokeLevel(session, route, sceneName, exceptions, { `${sceneName} did not keep Peaks ambience batched: ${JSON.stringify(state)}` ); } + const renderBudget = CAMPAIGN_MOBILE_RENDER_BUDGETS[route]; + if (!renderBudget) { + throw new Error(`${sceneName} has no authored mobile render budget`); + } + await waitFor( + () => evaluate(session, `(() => { + const scene = window.mythicalGame?.scene?.getScene?.( + ${JSON.stringify(sceneName)} + ); + if (!scene?.scene?.isActive?.()) return false; + return ( + (scene.children?.list?.length || 0) <= ${renderBudget.displayCount} && + (scene.tweens?.getTweens?.().length || 0) <= ${renderBudget.activeTweenCount} + ); + })()`), + { + timeoutMs: 4500, + message: `${sceneName} entry effects retired within render budget` + } + ); let renderStability = null; const framePacing = await sampleFramePacing(session, sceneName); if (!framePacing?.sceneActive || framePacing.frameCount < 12) { @@ -2537,11 +2597,13 @@ async function smokeLevel(session, route, sceneName, exceptions, { performanceTier: framePacing.performanceTier }) + '\n' ); - const renderBudget = CAMPAIGN_MOBILE_RENDER_BUDGETS[route]; if ( - !renderBudget || framePacing.displayCount > renderBudget.displayCount || framePacing.activeTweenCount > renderBudget.activeTweenCount || + !Number.isFinite( + framePacing.objectiveHudRendering?.rebuildsDuringSample + ) || + framePacing.objectiveHudRendering?.rebuildsDuringSample > 2 || ( framePacing.performanceTier === 'mobile' && framePacing.sharedAmbientFieldTweenCount !== 0 diff --git a/src/__tests__/LevelTraversalQualityContract.test.js b/src/__tests__/LevelTraversalQualityContract.test.js index 8001d4c1..a332a944 100644 --- a/src/__tests__/LevelTraversalQualityContract.test.js +++ b/src/__tests__/LevelTraversalQualityContract.test.js @@ -62,6 +62,87 @@ function loadPlatformerLevelScene() { } describe('campaign traversal quality contracts', () => { + test('campaign objective HUD only rerasterizes when state changes', () => { + const PlatformerLevelScene = loadPlatformerLevelScene(); + const scene = new PlatformerLevelScene({ key: 'ObjectiveHudBudgetTest' }); + let objective = 'SIGNAL 1/3'; + scene.campaignObjectiveTextProvider = () => objective; + scene.objectiveDisplay = { + active: true, + text: objective, + visible: true, + setText: jest.fn(function setText(text) { + this.text = text; + return this; + }), + setVisible: jest.fn(function setVisible(visible) { + this.visible = visible; + return this; + }) + }; + + expect(scene.syncCampaignObjectiveDisplay()).toBe(false); + expect(scene.objectiveDisplay.setText).not.toHaveBeenCalled(); + expect(scene.objectiveDisplay.setVisible).not.toHaveBeenCalled(); + expect(scene.campaignObjectiveTextureRevision).toBeUndefined(); + + objective = 'SIGNAL 2/3'; + expect(scene.syncCampaignObjectiveDisplay()).toBe(true); + expect(scene.objectiveDisplay.setText).toHaveBeenCalledTimes(1); + expect(scene.objectiveDisplay.text).toBe('SIGNAL 2/3'); + expect(scene.campaignObjectiveTextureRevision).toBe(1); + + expect(scene.syncCampaignObjectiveDisplay({ visible: false })).toBe(true); + expect(scene.objectiveDisplay.setText).toHaveBeenCalledTimes(1); + expect(scene.objectiveDisplay.setVisible).toHaveBeenCalledWith(false); + + expect(scene.syncCampaignObjectiveDisplay({ + visible: false, + force: true + })).toBe(true); + expect(scene.objectiveDisplay.setText).toHaveBeenCalledTimes(2); + expect(scene.campaignObjectiveTextureRevision).toBe(2); + + expect(read('PlatformerLevelScene.js')).toContain( + 'visible: this.objectiveDisplay.visible' + ); + expect(read('../../scripts/smoke-secondary-journeys.js')).toContain( + 'objectiveHudRendering?.rebuildsDuringSample > 2' + ); + expect(read('../../scripts/smoke-secondary-journeys.js')).toContain( + '!Number.isFinite(' + ); + }); + + test('all campaign levels use the shared objective HUD render budget', () => { + [ + 'levels/MythicalForestLevel.js', + 'levels/CrystalCavesLevel.js', + 'levels/ReefLevel.js', + 'levels/VoidPeaksLevel.js', + 'levels/AuroraDepthsLevel.js', + 'levels/FinalVoidLevel.js' + ].forEach(relativePath => { + const source = read(relativePath); + expect(source).toContain('this.syncCampaignObjectiveDisplay({'); + expect(source).not.toMatch(/objectiveDisplay.*setText/); + }); + }); + + test('release smoke waits for steady authored runtime state', () => { + const smoke = read('../../scripts/smoke-secondary-journeys.js'); + + expect(smoke).toContain( + "message: 'Forest authored enemies settled on their supports'" + ); + expect(smoke).toContain('enemy.body.bottom - support.body.top'); + expect(smoke).toContain('timeoutMs: 3500'); + expect(smoke).toContain( + 'entry effects retired within render budget' + ); + expect(smoke).toContain('timeoutMs: 4500'); + }); + test('authored movement survives a scene reset', () => { const PlatformerLevelScene = loadPlatformerLevelScene(); const scene = new PlatformerLevelScene({ diff --git a/src/scenes/PlatformerLevelScene.js b/src/scenes/PlatformerLevelScene.js index 69ff2141..c4e2c3c0 100644 --- a/src/scenes/PlatformerLevelScene.js +++ b/src/scenes/PlatformerLevelScene.js @@ -4473,11 +4473,39 @@ class PlatformerLevelScene extends Phaser.Scene { }).setOrigin(layout.originX, layout.originY) .setScrollFactor(0) .setDepth(1000); + this.campaignObjectiveTextureRevision = 1; this.scale?.on?.('resize', this.layoutCampaignObjectiveDisplay, this); return this.objectiveDisplay; } + syncCampaignObjectiveDisplay({ visible = true, force = false } = {}) { + const display = this.objectiveDisplay; + if (!display?.active) return false; + + const providedText = typeof this.campaignObjectiveTextProvider === 'function' + ? this.campaignObjectiveTextProvider() + : this.campaignObjectiveTextProvider; + const nextText = String(providedText || ''); + const nextVisible = visible !== false; + let changed = false; + + // Phaser rerasterizes Text whenever setText runs. Campaign objectives are + // state-driven, so avoid rebuilding the same canvas texture every frame. + if (force || display.text !== nextText) { + display.setText(nextText); + this.campaignObjectiveTextureRevision = + (Number(this.campaignObjectiveTextureRevision) || 0) + 1; + changed = true; + } + if (force || display.visible !== nextVisible) { + display.setVisible(nextVisible); + changed = true; + } + + return changed; + } + layoutCampaignObjectiveDisplay(gameSize = this.scale?.gameSize) { if (!this.objectiveDisplay?.active) return; @@ -4497,7 +4525,10 @@ class PlatformerLevelScene extends Phaser.Scene { .setWordWrapWidth(layout.maxWidth); if (typeof this.campaignObjectiveTextProvider === 'function') { - this.objectiveDisplay.setText(this.campaignObjectiveTextProvider()); + this.syncCampaignObjectiveDisplay({ + visible: this.objectiveDisplay.visible, + force: true + }); } } diff --git a/src/scenes/levels/AuroraDepthsLevel.js b/src/scenes/levels/AuroraDepthsLevel.js index 2693169f..1faa3368 100644 --- a/src/scenes/levels/AuroraDepthsLevel.js +++ b/src/scenes/levels/AuroraDepthsLevel.js @@ -516,12 +516,9 @@ class AuroraDepthsLevel extends PlatformerLevelScene { super.update(time, delta); if (this.levelCompletionActive) return; - if (this.objectiveDisplay) { - this.objectiveDisplay.setText(this.getAuroraObjectiveText()); - this.objectiveDisplay.setVisible( - !(this.isCompactObjectiveHUD && this.bossFightActive) - ); - } + this.syncCampaignObjectiveDisplay({ + visible: !(this.isCompactObjectiveHUD && this.bossFightActive) + }); if (this.currentChargeAura?.active && this.player?.active) { this.currentChargeAura.setPosition(this.player.x, this.player.y); } @@ -1327,7 +1324,7 @@ class AuroraDepthsLevel extends PlatformerLevelScene { onRestored: () => { this.restoreAuroraRouteChoice(resume?.routeState); this.refreshPrismRouteReadability(); - this.objectiveDisplay?.setText?.(this.getAuroraObjectiveText()); + this.syncCampaignObjectiveDisplay(); } }); return restored; diff --git a/src/scenes/levels/CrystalCavesLevel.js b/src/scenes/levels/CrystalCavesLevel.js index 7d8c9bc6..19e2d863 100644 --- a/src/scenes/levels/CrystalCavesLevel.js +++ b/src/scenes/levels/CrystalCavesLevel.js @@ -1523,7 +1523,7 @@ class CrystalCavesLevel extends PlatformerLevelScene { this.refreshCaveRouteReadability(); this.refreshCrystalCoreLift(); this.refreshCrystalCoreHint(); - this.objectiveDisplay?.setText?.(this.getCrystalObjectiveText()); + this.syncCampaignObjectiveDisplay(); } }); if (!signalsRestored) return false; @@ -5465,13 +5465,9 @@ class CrystalCavesLevel extends PlatformerLevelScene { this.updateCaveCoinPickups(); - // Update objective display - if (this.objectiveDisplay) { - this.objectiveDisplay.setText(this.getCrystalObjectiveText()); - this.objectiveDisplay.setVisible( - !(this.isCompactObjectiveHUD && this.bossFightActive) - ); - } + this.syncCampaignObjectiveDisplay({ + visible: !(this.isCompactObjectiveHUD && this.bossFightActive) + }); // Update dynamic lighting this.updateLighting(); diff --git a/src/scenes/levels/FinalVoidLevel.js b/src/scenes/levels/FinalVoidLevel.js index db2f084b..0d97ecc6 100644 --- a/src/scenes/levels/FinalVoidLevel.js +++ b/src/scenes/levels/FinalVoidLevel.js @@ -1157,7 +1157,7 @@ class FinalVoidLevel extends PlatformerLevelScene { }, onRestored: () => { this.refreshBondRouteReadability(); - this.objectiveDisplay?.setText?.(this.getFinalObjectiveText()); + this.syncCampaignObjectiveDisplay(); } }); if (!signalsRestored) return false; @@ -1165,7 +1165,7 @@ class FinalVoidLevel extends PlatformerLevelScene { this.restoreFinalRouteState(resume.routeState, { rejoined: Number(resume.checkpointIndex) >= 2 }); - this.objectiveDisplay?.setText?.(this.getFinalObjectiveText()); + this.syncCampaignObjectiveDisplay(); return true; } @@ -1354,13 +1354,11 @@ class FinalVoidLevel extends PlatformerLevelScene { super.update(time, delta); if (this.levelCompletionActive) return; - if (this.objectiveDisplay?.active) { - this.objectiveDisplay.setText(this.getFinalObjectiveText()); - this.objectiveDisplay.setVisible( + this.syncCampaignObjectiveDisplay({ + visible: this.levelStarted && !(this.isCompactObjectiveHUD && this.bossFightActive) - ); - } + }); this.updateBossIndicator(); diff --git a/src/scenes/levels/MythicalForestLevel.js b/src/scenes/levels/MythicalForestLevel.js index 542d2531..b2a751b6 100644 --- a/src/scenes/levels/MythicalForestLevel.js +++ b/src/scenes/levels/MythicalForestLevel.js @@ -1165,13 +1165,11 @@ class MythicalForestLevel extends PlatformerLevelScene { this.updateForestEnemyMotion(time); this.updateForestCoinPickups(); this.updateFirstExpeditionDrill(); - if (this.objectiveDisplay) { - this.objectiveDisplay.setText(this.getForestObjectiveText()); - this.objectiveDisplay.setVisible( + this.syncCampaignObjectiveDisplay({ + visible: !this.firstExpeditionDrill?.panelVisible && !(this.isCompactObjectiveHUD && this.bossFightActive) - ); - } + }); } executeJump() { @@ -1471,7 +1469,7 @@ class MythicalForestLevel extends PlatformerLevelScene { }, onRestored: () => { this.refreshForestRouteReadability(); - this.objectiveDisplay?.setText?.(this.getForestObjectiveText()); + this.syncCampaignObjectiveDisplay(); } }); if (!signalsRestored) return false; @@ -1479,7 +1477,7 @@ class MythicalForestLevel extends PlatformerLevelScene { this.restoreForestRouteState(resume.routeState, { rejoined: Number(resume.checkpointIndex) >= 2 }); - this.objectiveDisplay?.setText?.(this.getForestObjectiveText()); + this.syncCampaignObjectiveDisplay(); return true; } diff --git a/src/scenes/levels/ReefLevel.js b/src/scenes/levels/ReefLevel.js index b6bd130b..72bc8f07 100644 --- a/src/scenes/levels/ReefLevel.js +++ b/src/scenes/levels/ReefLevel.js @@ -1854,14 +1854,14 @@ class ReefLevel extends PlatformerLevelScene { this.retireOpeningSignalCurrent(); } this.refreshBeaconRouteReadability(); - this.objectiveDisplay?.setText?.(this.getReefObjectiveText()); + this.syncCampaignObjectiveDisplay(); } }); if (!signalsRestored) return false; this.restoreReefRouteState(resume.routeState, { rejoined: Number(resume.checkpointIndex) >= 1 }); - this.objectiveDisplay?.setText?.(this.getReefObjectiveText()); + this.syncCampaignObjectiveDisplay(); return true; } @@ -4005,12 +4005,9 @@ class ReefLevel extends PlatformerLevelScene { // Update swim indicator this.updateSwimIndicator(); - if (this.objectiveDisplay) { - this.objectiveDisplay.setText(this.getReefObjectiveText()); - this.objectiveDisplay.setVisible( - !(this.isCompactObjectiveHUD && this.bossFightActive) - ); - } + this.syncCampaignObjectiveDisplay({ + visible: !(this.isCompactObjectiveHUD && this.bossFightActive) + }); } /** diff --git a/src/scenes/levels/VoidPeaksLevel.js b/src/scenes/levels/VoidPeaksLevel.js index 40f4ce7c..c36de1d7 100644 --- a/src/scenes/levels/VoidPeaksLevel.js +++ b/src/scenes/levels/VoidPeaksLevel.js @@ -1189,7 +1189,7 @@ class VoidPeaksLevel extends PlatformerLevelScene { if (restoredCount === this.beaconRelays.length) { this.showDistantReplyNetwork(relay); } - this.objectiveDisplay?.setText?.(this.getPeakObjectiveText()); + this.syncCampaignObjectiveDisplay(); } }); if (!signalsRestored) return false; @@ -1197,7 +1197,7 @@ class VoidPeaksLevel extends PlatformerLevelScene { this.restorePeakRouteState(resume.routeState, { rejoined: Number(resume.checkpointIndex) >= 2 }); - this.objectiveDisplay?.setText?.(this.getPeakObjectiveText()); + this.syncCampaignObjectiveDisplay(); return true; } @@ -1513,12 +1513,9 @@ class VoidPeaksLevel extends PlatformerLevelScene { }); } - if (this.objectiveDisplay) { - this.objectiveDisplay.setText(this.getPeakObjectiveText()); - this.objectiveDisplay.setVisible( - !(this.isCompactObjectiveHUD && this.bossFightActive) - ); - } + this.syncCampaignObjectiveDisplay({ + visible: !(this.isCompactObjectiveHUD && this.bossFightActive) + }); this.updateBossIndicator(); }