diff --git a/scripts/smoke-secondary-journeys.js b/scripts/smoke-secondary-journeys.js index 9a2db867..47867067 100644 --- a/scripts/smoke-secondary-journeys.js +++ b/scripts/smoke-secondary-journeys.js @@ -444,6 +444,7 @@ async function smokeForestBatchedCoinPickup(session) { scene.isInvincible = false; scene.player?.body?.reset?.(300, scene.levelHeight - 130); scene.player?.setVelocity?.(0, 0); + scene.updateForestEnemyActivation(true); return true; })()`); } @@ -463,13 +464,17 @@ async function smokeForestSharedEnemyScheduler(session) { scene.isInvincible = true; const playerStart = { x: scene.player.x, y: scene.player.y }; const chaserStart = { x: chaser.x, y: chaser.y }; - chaser.forestNextAiAt = scene.time.now; - crawler.forestNextAiAt = scene.time.now; chaser.body.reset(chaser.forestPatrolLeft, chaserStart.y); scene.player.body.reset( chaser.forestPatrolRight, scene.levelHeight - 130 ); + scene.updateForestEnemyActivation(true); + scene.forestProximityEnemies.forEach(enemy => { + enemy.forestNextAiAt = scene.time.now + 5000; + }); + chaser.forestNextAiAt = scene.time.now; + crawler.forestNextAiAt = scene.time.now; chaser.setVelocity(0, 0); scene.player.setVelocity(0, 0); return { @@ -535,6 +540,173 @@ async function smokeForestSharedEnemyScheduler(session) { ${staged.playerStart.y} ); scene.player?.setVelocity?.(0, 0); + scene.updateForestEnemyActivation(true); + return true; + })()`); + } +} + +async function smokeForestEnemyActivationWindow(session) { + const staged = await evaluate(session, `(() => { + const scene = window.mythicalGame.scene.getScene('MythicalForestLevel'); + const target = [...(scene.voidSprites || [])] + .filter(enemy => enemy?.active && enemy?.body) + .sort((left, right) => right.x - left.x)[0]; + if (!scene.player?.body || !target?.forestSupportId) return null; + + const camera = scene.cameras.main; + const playerStart = { + x: scene.player.x, + y: scene.player.y, + invincible: scene.isInvincible === true + }; + const cameraStart = { x: camera.scrollX, y: camera.scrollY }; + const targetStart = { + x: target.x, + y: target.y, + nextAiAt: target.forestNextAiAt, + isChasing: target.isChasing === true + }; + + scene.isInvincible = true; + scene.player.body.reset(300, scene.levelHeight - 130); + scene.player.setVelocity(0, 0); + camera.centerOn(scene.player.x, scene.player.y); + camera.preRender?.(); + scene.updateForestEnemyActivation(true); + + return { + targetSupportId: target.forestSupportId, + totalEnemyCount: (scene.enemies?.getChildren?.() || []).filter( + enemy => enemy?.active + ).length, + playerStart, + cameraStart, + targetStart, + far: { + proximityActive: target.forestProximityActive === true, + bodyEnabled: target.body.enable === true, + visible: target.visible === true, + nearby: scene.forestProximityEnemies.includes(target), + activeCount: scene.forestProximityEnemies.length + } + }; + })()`); + if (!staged) { + throw new Error('Forest enemy activation window could not be staged'); + } + + try { + const awakened = await evaluate(session, `(() => { + const scene = window.mythicalGame.scene.getScene('MythicalForestLevel'); + const target = (scene.voidSprites || []).find( + enemy => enemy?.forestSupportId === ${JSON.stringify(staged.targetSupportId)} + ); + 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.updateForestEnemyActivation(true); + scene.forestProximityEnemies.forEach(enemy => { + enemy.forestNextAiAt = scene.time.now + 5000; + }); + target.forestNextAiAt = scene.time.now; + scene.updateForestEnemyAI(); + + return { + proximityActive: target.forestProximityActive === true, + bodyEnabled: target.body.enable === true, + visible: target.visible === true, + nearby: scene.forestProximityEnemies.includes(target), + isChasing: target.isChasing === true, + velocityX: Number(target.body.velocity?.x) || 0, + activeCount: scene.forestProximityEnemies.length + }; + })()`); + if ( + staged.totalEnemyCount !== 23 || + staged.far.proximityActive !== false || + staged.far.bodyEnabled !== false || + staged.far.visible !== false || + staged.far.nearby !== false || + awakened?.proximityActive !== true || + awakened?.bodyEnabled !== true || + awakened?.visible !== true || + awakened?.nearby !== true || + awakened?.isChasing !== true || + Math.abs(awakened?.velocityX || 0) < 1 + ) { + throw new Error( + `Forest enemy activation did not wake before contact: ${JSON.stringify({ + staged, + awakened + })}` + ); + } + + const slept = await evaluate(session, `(() => { + const scene = window.mythicalGame.scene.getScene('MythicalForestLevel'); + const target = (scene.voidSprites || []).find( + enemy => enemy?.forestSupportId === ${JSON.stringify(staged.targetSupportId)} + ); + 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.updateForestEnemyActivation(true); + return { + proximityActive: target.forestProximityActive === true, + bodyEnabled: target.body.enable === true, + visible: target.visible === true, + nearby: scene.forestProximityEnemies.includes(target), + activeCount: scene.forestProximityEnemies.length + }; + })()`); + if ( + slept?.proximityActive !== false || + slept?.bodyEnabled !== false || + slept?.visible !== false || + slept?.nearby !== false + ) { + throw new Error( + `Forest 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('MythicalForestLevel'); + const target = (scene.voidSprites || []).find( + enemy => enemy?.forestSupportId === ${JSON.stringify(staged.targetSupportId)} + ); + target?.body?.reset?.(${staged.targetStart.x}, ${staged.targetStart.y}); + target?.setVelocity?.(0, 0); + if (target) { + target.forestNextAiAt = ${Number(staged.targetStart.nextAiAt) || 0}; + target.isChasing = ${staged.targetStart.isChasing === true}; + } + scene.player?.body?.reset?.( + ${staged.playerStart.x}, + ${staged.playerStart.y} + ); + scene.player?.setVelocity?.(0, 0); + scene.cameras.main.setScroll( + ${staged.cameraStart.x}, + ${staged.cameraStart.y} + ); + scene.cameras.main.preRender?.(); + scene.isInvincible = ${staged.playerStart.invincible === true}; + scene.updateForestEnemyActivation(true); return true; })()`); } @@ -2332,6 +2504,24 @@ async function smokeLevel(session, route, sceneName, exceptions, { aiSchedulerActive: Boolean( scene?.forestEnemyAISchedulerActive ), + proximityActiveCount: ( + scene?.enemies?.getChildren?.() || [] + ).filter(enemy => enemy?.forestProximityActive === true).length, + sleepingEnemyCount: ( + scene?.enemies?.getChildren?.() || [] + ).filter(enemy => enemy?.forestProximityActive === false).length, + enabledBodyCount: ( + scene?.enemies?.getChildren?.() || [] + ).filter(enemy => enemy?.body?.enable === true).length, + visibleEnemyCount: ( + scene?.enemies?.getChildren?.() || [] + ).filter(enemy => enemy?.visible === true).length, + activationBounds: scene.forestEnemyActivationBounds ? { + horizontalMargin: + scene.forestEnemyActivationBounds.horizontalMargin, + verticalMargin: + scene.forestEnemyActivationBounds.verticalMargin + } : null, groundEnemySupportIds: (scene.voidSprites || []).map( enemy => enemy?.forestSupportId || null ), @@ -2554,6 +2744,16 @@ async function smokeLevel(session, route, sceneName, exceptions, { state.forestEnemyRuntime?.scheduledEnemyCount !== 23 || state.forestEnemyRuntime?.individualTimerCount !== 0 || state.forestEnemyRuntime?.aiSchedulerActive !== true || + state.forestEnemyRuntime?.proximityActiveCount > 10 || + state.forestEnemyRuntime?.sleepingEnemyCount < 13 || + state.forestEnemyRuntime?.proximityActiveCount + + state.forestEnemyRuntime?.sleepingEnemyCount !== 23 || + state.forestEnemyRuntime?.enabledBodyCount !== + state.forestEnemyRuntime?.proximityActiveCount || + state.forestEnemyRuntime?.visibleEnemyCount !== + state.forestEnemyRuntime?.proximityActiveCount || + state.forestEnemyRuntime?.activationBounds?.horizontalMargin !== 520 || + state.forestEnemyRuntime?.activationBounds?.verticalMargin !== 280 || state.forestEnemyRuntime?.groundEnemySupportIds?.length !== 5 || new Set( state.forestEnemyRuntime?.groundEnemySupportIds || [] @@ -2813,6 +3013,9 @@ async function smokeLevel(session, route, sceneName, exceptions, { JSON.stringify(framePacing.graphicsTweenDepths) ); } + const forestEnemyActivation = route === 'mythicalForest' + ? await smokeForestEnemyActivationWindow(session) + : null; const forestEnemyScheduler = route === 'mythicalForest' ? await smokeForestSharedEnemyScheduler(session) : null; @@ -5971,6 +6174,7 @@ async function smokeLevel(session, route, sceneName, exceptions, { reefWaypointSupports, forestAnchorSupports, framePacing, + forestEnemyActivation, forestEnemyScheduler, forestCoinPickup, caveCoinPickup, diff --git a/src/__tests__/LevelTraversalQualityContract.test.js b/src/__tests__/LevelTraversalQualityContract.test.js index 2d7cccf2..d6e6c53a 100644 --- a/src/__tests__/LevelTraversalQualityContract.test.js +++ b/src/__tests__/LevelTraversalQualityContract.test.js @@ -825,6 +825,17 @@ describe('campaign traversal quality contracts', () => { expect(source).not.toContain('this.physics.add.overlap(this.player, sprite'); expect(source).toContain('startForestEnemyAIScheduler()'); expect(source).toContain('if (this.forestEnemyAISchedulerActive) this.updateForestEnemyAI();'); + expect(source).toContain('updateForestEnemyActivation()'); + expect(source).toContain('getForestEnemyActivationBounds()'); + expect(source).toContain('isForestEnemyReadyForSuspension(enemy)'); + expect(source).toContain('enemy.forestSettledForStreaming = true;'); + expect(source).toContain('const shouldStayActive = inWindow ||'); + expect(source).toContain('const horizontalMargin = this.isMobile ? 520 : 800;'); + expect(source).toContain('const verticalMargin = this.isMobile ? 280 : 420;'); + expect(source).toContain('enemy.body.enable = false;'); + expect(source).toContain('enemy.body.enable = true;'); + expect(source).toContain('const enemies = this.forestProximityEnemies || [];'); + expect(source).toContain('candidate => candidate !== enemy'); expect(source).toContain('const actionBudget = this.isMobile ? 3 : 5;'); expect(source).toContain('actionCount < actionBudget'); expect(source).toContain('this.forestEnemyAICursor + scannedCount'); @@ -2609,6 +2620,13 @@ describe('campaign traversal quality contracts', () => { expect(smoke).toContain('state.coinRendering?.pickupBodyCount !== 0'); expect(smoke).toContain('state.forestEnemyRuntime?.scheduledEnemyCount !== 23'); expect(smoke).toContain('state.forestEnemyRuntime?.individualTimerCount !== 0'); + expect(smoke).toContain('smokeForestEnemyActivationWindow(session)'); + expect(smoke).toContain('Forest enemy activation did not wake before contact'); + expect(smoke).toContain('Forest enemy activation did not suspend after departure'); + expect(smoke).toContain('state.forestEnemyRuntime?.proximityActiveCount > 10'); + expect(smoke).toContain('state.forestEnemyRuntime?.sleepingEnemyCount < 13'); + expect(smoke).toContain('state.forestEnemyRuntime?.sleepingEnemyCount !== 23'); + expect(smoke).toContain('state.forestEnemyRuntime?.enabledBodyCount !=='); expect(smoke).toContain('state.forestEnemyRuntime?.groundEnemySupportIds?.length !== 5'); expect(smoke).toContain('state.forestEnemyRuntime?.unsupportedGroundEnemyIds?.length !== 0'); expect(smoke).toContain('unsupportedGroundEnemyIds'); diff --git a/src/scenes/levels/MythicalForestLevel.js b/src/scenes/levels/MythicalForestLevel.js index 026a50be..222bbe95 100644 --- a/src/scenes/levels/MythicalForestLevel.js +++ b/src/scenes/levels/MythicalForestLevel.js @@ -132,6 +132,9 @@ class MythicalForestLevel extends PlatformerLevelScene { this.forestEnemyAISchedulerActive = false; this.forestEnemyAICursor = 0; this.forestEnemyMotionNextAt = 0; + this.forestEnemyActivationNextAt = 0; + this.forestProximityEnemies = []; + this.forestEnemyActivationBounds = null; this.forestEnemyOverlap = null; this.forestCoinLayer = null; this.forestCoinLayerTween = null; @@ -255,6 +258,9 @@ class MythicalForestLevel extends PlatformerLevelScene { this.forestEnemyAISchedulerActive = false; this.forestEnemyAICursor = 0; this.forestEnemyMotionNextAt = 0; + this.forestEnemyActivationNextAt = 0; + this.forestProximityEnemies = []; + this.forestEnemyActivationBounds = null; this.forestEnemyOverlap = null; this.forestCoinLayer = null; this.forestCoinLayerTween = null; @@ -1165,6 +1171,7 @@ class MythicalForestLevel extends PlatformerLevelScene { update(time, delta) { super.update(time, delta); if (this.levelCompletionActive) return; + this.updateForestEnemyActivation(); if (this.forestEnemyAISchedulerActive) this.updateForestEnemyAI(); this.updateForestEnemyMotion(time); this.updateForestCoinPickups(); @@ -2002,6 +2009,8 @@ class MythicalForestLevel extends PlatformerLevelScene { retireForestPatrolsForElder() { const patrols = [...(this.enemies?.getChildren?.() || [])]; this.forestEnemyAISchedulerActive = false; + this.forestProximityEnemies = []; + this.forestEnemyActivationBounds = null; const retirement = this.retireRouteEnemies(patrols); this.forestEnemyOverlap?.destroy?.(); @@ -2027,13 +2036,112 @@ class MythicalForestLevel extends PlatformerLevelScene { startForestEnemyAIScheduler() { this.forestEnemyAICursor = 0; this.forestEnemyAISchedulerActive = true; + this.updateForestEnemyActivation(true); + } + + getForestEnemyActivationBounds() { + 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 + }; + } + + setForestEnemyProximityActive(enemy, enabled) { + if (!enemy?.active || !enemy.body) return false; + const nextState = enabled === true; + if (enemy.forestProximityActive === nextState) return nextState; + + enemy.forestProximityActive = nextState; + if (nextState) { + enemy.body.enable = true; + enemy.body.updateFromGameObject?.(); + enemy.setVisible?.(true); + enemy.forestNextAiAt = Math.min( + Number(enemy.forestNextAiAt) || Number.POSITIVE_INFINITY, + (Number(this.time?.now) || 0) + 40 + ); + } else { + enemy.setVelocity?.(0, 0); + enemy.body.enable = false; + enemy.setVisible?.(false); + enemy.forestTrail = []; + enemy.combatCue?.setVisible?.(false); + enemy.instructionLabel?.setVisible?.(false); + } + return nextState; + } + + isForestEnemyReadyForSuspension(enemy) { + if (!enemy?.body || enemy.enemyType !== 'voidSprite') return true; + if (enemy.forestSettledForStreaming) return true; + + const support = this.getTraversalSupport?.(enemy.forestSupportId); + const settled = 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 + ); + if (settled) enemy.forestSettledForStreaming = true; + return settled; + } + + updateForestEnemyActivation(force = false) { + if (!this.scene.isActive()) return 0; + const now = Number(this.time?.now) || 0; + if (!force && now < this.forestEnemyActivationNextAt) { + return this.forestProximityEnemies.length; + } + this.forestEnemyActivationNextAt = now + (this.isMobile ? 120 : 80); + + const bounds = this.getForestEnemyActivationBounds(); + const nearby = []; + (this.enemies?.getChildren?.() || []).forEach(enemy => { + if (!enemy?.active || !enemy.body) return; + const inWindow = + enemy.x >= bounds.left && + enemy.x <= bounds.right && + enemy.y >= bounds.top && + enemy.y <= bounds.bottom; + const shouldStayActive = inWindow || + !this.isForestEnemyReadyForSuspension(enemy); + this.setForestEnemyProximityActive(enemy, shouldStayActive); + if (shouldStayActive) nearby.push(enemy); + }); + this.forestProximityEnemies = nearby; + this.forestEnemyActivationBounds = bounds; + this.forestEnemyAICursor = nearby.length + ? this.forestEnemyAICursor % nearby.length + : 0; + return nearby.length; } updateForestEnemyAI() { if (!this.scene.isActive()) return; const now = Number(this.time?.now) || 0; - const enemies = this.enemies?.getChildren?.() || []; + const enemies = this.forestProximityEnemies || []; if (!enemies.length) return; const actionBudget = this.isMobile ? 3 : 5; let scannedCount = 0; @@ -2081,7 +2189,7 @@ class MythicalForestLevel extends PlatformerLevelScene { if (now < this.forestEnemyMotionNextAt) return; this.forestEnemyMotionNextAt = now + (this.isMobile ? 32 : 16); this.sporeDrifters.forEach(sprite => { - if (!sprite?.active) return; + if (!sprite?.active || sprite.forestProximityActive === false) return; const phase = ( (now + sprite.forestMotionOffset) % sprite.forestMotionDuration ) / sprite.forestMotionDuration; @@ -2095,7 +2203,11 @@ class MythicalForestLevel extends PlatformerLevelScene { }); this.forestWisps.forEach(sprite => { - if (!sprite?.active || sprite.forestTeleporting) return; + if ( + !sprite?.active || + sprite.forestProximityActive === false || + sprite.forestTeleporting + ) return; const phase = ( (now + sprite.forestMotionOffset) % 1600 ) / 1600; @@ -2120,7 +2232,7 @@ class MythicalForestLevel extends PlatformerLevelScene { layer.clear(); const view = this.cameras.main.worldView; this.voidSprites.forEach(sprite => { - if (!sprite?.active) return; + if (!sprite?.active || sprite.forestProximityActive === false) return; if ( sprite.x < view.left - 120 || sprite.x > view.right + 120 || @@ -2792,6 +2904,9 @@ class MythicalForestLevel extends PlatformerLevelScene { this.branchCrawlers = this.branchCrawlers.filter(e => e !== enemy); this.sporeDrifters = this.sporeDrifters.filter(e => e !== enemy); this.forestWisps = this.forestWisps.filter(e => e !== enemy); + this.forestProximityEnemies = this.forestProximityEnemies.filter( + candidate => candidate !== enemy + ); if (window.AudioManager) { window.AudioManager.playEnemyHit(); @@ -5131,6 +5246,9 @@ class MythicalForestLevel extends PlatformerLevelScene { this.forestEnemyOverlap?.destroy?.(); this.forestEnemyOverlap = null; this.forestEnemyAISchedulerActive = false; + this.forestProximityEnemies = []; + this.forestEnemyActivationBounds = null; + this.forestEnemyActivationNextAt = 0; // Clean up platforms this.branchPlatforms = [];