Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 64 additions & 2 deletions scripts/smoke-secondary-journeys.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down
81 changes: 81 additions & 0 deletions src/__tests__/LevelTraversalQualityContract.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
33 changes: 32 additions & 1 deletion src/scenes/PlatformerLevelScene.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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
});
}
}

Expand Down
11 changes: 4 additions & 7 deletions src/scenes/levels/AuroraDepthsLevel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -1327,7 +1324,7 @@ class AuroraDepthsLevel extends PlatformerLevelScene {
onRestored: () => {
this.restoreAuroraRouteChoice(resume?.routeState);
this.refreshPrismRouteReadability();
this.objectiveDisplay?.setText?.(this.getAuroraObjectiveText());
this.syncCampaignObjectiveDisplay();
}
});
return restored;
Expand Down
12 changes: 4 additions & 8 deletions src/scenes/levels/CrystalCavesLevel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
12 changes: 5 additions & 7 deletions src/scenes/levels/FinalVoidLevel.js
Original file line number Diff line number Diff line change
Expand Up @@ -1157,15 +1157,15 @@ class FinalVoidLevel extends PlatformerLevelScene {
},
onRestored: () => {
this.refreshBondRouteReadability();
this.objectiveDisplay?.setText?.(this.getFinalObjectiveText());
this.syncCampaignObjectiveDisplay();
}
});
if (!signalsRestored) return false;

this.restoreFinalRouteState(resume.routeState, {
rejoined: Number(resume.checkpointIndex) >= 2
});
this.objectiveDisplay?.setText?.(this.getFinalObjectiveText());
this.syncCampaignObjectiveDisplay();
return true;
}

Expand Down Expand Up @@ -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();

Expand Down
12 changes: 5 additions & 7 deletions src/scenes/levels/MythicalForestLevel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -1471,15 +1469,15 @@ class MythicalForestLevel extends PlatformerLevelScene {
},
onRestored: () => {
this.refreshForestRouteReadability();
this.objectiveDisplay?.setText?.(this.getForestObjectiveText());
this.syncCampaignObjectiveDisplay();
}
});
if (!signalsRestored) return false;

this.restoreForestRouteState(resume.routeState, {
rejoined: Number(resume.checkpointIndex) >= 2
});
this.objectiveDisplay?.setText?.(this.getForestObjectiveText());
this.syncCampaignObjectiveDisplay();
return true;
}

Expand Down
13 changes: 5 additions & 8 deletions src/scenes/levels/ReefLevel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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)
});
}

/**
Expand Down
Loading