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
54 changes: 51 additions & 3 deletions scripts/smoke-secondary-journeys.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
18 changes: 18 additions & 0 deletions src/__tests__/LevelTraversalQualityContract.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/
);
Expand Down Expand Up @@ -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');
Expand Down
127 changes: 83 additions & 44 deletions src/scenes/levels/MythicalForestLevel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 = [
{
Expand Down Expand Up @@ -1313,7 +1324,7 @@ class MythicalForestLevel extends PlatformerLevelScene {
landingGuide: this.createTraversalLandingGuide(
supportId,
0x8FE3CF,
{ depth: 84 }
{ depth: 84, animate: animateRouteDecorations }
),
activated: false,
respawnY: supportCheckpoint.y
Expand Down Expand Up @@ -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);
}
}

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

/**
Expand Down Expand Up @@ -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?.();
Expand Down