From 404f1b2e369ee2549b5fd59f66a2cffabd31dd28 Mon Sep 17 00:00:00 2001 From: TechEvolveAI Date: Fri, 14 Aug 2026 20:05:53 +0100 Subject: [PATCH] Stream dormant Crystal Caves enemies --- scripts/smoke-secondary-journeys.js | 92 ++++++- .../LevelTraversalQualityContract.test.js | 22 +- src/scenes/levels/CrystalCavesLevel.js | 227 +++++++++++++++--- 3 files changed, 306 insertions(+), 35 deletions(-) diff --git a/scripts/smoke-secondary-journeys.js b/scripts/smoke-secondary-journeys.js index ff17842a..52177a08 100644 --- a/scripts/smoke-secondary-journeys.js +++ b/scripts/smoke-secondary-journeys.js @@ -29,8 +29,8 @@ const CAMPAIGN_MOBILE_RENDER_BUDGETS = Object.freeze({ performanceTier: 'mobile' }), crystalCaves: Object.freeze({ - displayCount: 225, - activeTweenCount: 30, + displayCount: 185, + activeTweenCount: 12, performanceTier: 'mobile' }), reef: Object.freeze({ @@ -2674,6 +2674,58 @@ async function smokeLevel(session, route, sceneName, exceptions, { scene.forestWisps?.includes?.(target) ))).length } : null, + caveEnemyRuntime: scene?.scene?.key === 'CrystalCavesLevel' ? { + scheduledEnemyCount: ( + scene?.enemies?.getChildren?.() || [] + ).filter(enemy => Number.isFinite(enemy?.caveNextAiAt)).length, + individualTimerCount: ( + scene?.enemies?.getChildren?.() || [] + ).reduce( + (total, enemy) => total + (enemy?.runtimeTimers?.size || 0), + 0 + ), + aiSchedulerActive: Boolean(scene.caveEnemyAISchedulerActive), + proximityActiveCount: ( + scene?.enemies?.getChildren?.() || [] + ).filter(enemy => enemy?.caveProximityActive === true).length, + sleepingEnemyCount: ( + scene?.enemies?.getChildren?.() || [] + ).filter(enemy => enemy?.caveProximityActive === false).length, + enabledBodyCount: ( + scene?.enemies?.getChildren?.() || [] + ).filter(enemy => enemy?.body?.enable === true).length, + renderAttachedEnemyCount: ( + scene?.enemies?.getChildren?.() || [] + ).filter(enemy => enemy?.displayList === scene.children).length, + renderAttachedCueCount: ( + scene?.enemies?.getChildren?.() || [] + ).filter( + enemy => enemy?.combatCue?.displayList === scene.children + ).length, + sleepingDetachedCount: ( + scene?.enemies?.getChildren?.() || [] + ).filter(enemy => ( + enemy?.caveProximityActive === false && + enemy?.displayList !== scene.children && + enemy?.combatCue?.displayList !== scene.children + )).length, + activationBounds: scene.caveEnemyActivationBounds ? { + horizontalMargin: + scene.caveEnemyActivationBounds.horizontalMargin, + verticalMargin: + scene.caveEnemyActivationBounds.verticalMargin + } : null, + spiderTimersPaused: Boolean( + scene.crystalSpider?.caveProximityActive === false && + scene.spiderAttackTimer?.paused && + scene.spiderWebSprayTimer?.paused + ), + batMotionTweenCount: ( + scene?.tweens?.getTweens?.() || [] + ).filter(tween => (tween?.targets || []).some( + target => target?.enemyType === 'shadowBat' + )).length + } : null, forestDecorationRendering: scene?.scene?.key === 'MythicalForestLevel' ? (() => { const tweens = scene?.tweens?.getTweens?.() || []; @@ -2946,6 +2998,25 @@ async function smokeLevel(session, route, sceneName, exceptions, { state.caveAmbientRendering?.parallaxLayerCount !== 2 || state.caveAmbientRendering?.storyDecorationTweenCount !== 0 || state.caveAmbientRendering?.coinLayerTweenCount !== 0 || + state.caveEnemyRuntime?.scheduledEnemyCount !== 8 || + state.caveEnemyRuntime?.individualTimerCount !== 2 || + state.caveEnemyRuntime?.aiSchedulerActive !== true || + state.caveEnemyRuntime?.proximityActiveCount > 3 || + state.caveEnemyRuntime?.sleepingEnemyCount < 5 || + state.caveEnemyRuntime?.proximityActiveCount + + state.caveEnemyRuntime?.sleepingEnemyCount !== 8 || + state.caveEnemyRuntime?.enabledBodyCount !== + state.caveEnemyRuntime?.proximityActiveCount || + state.caveEnemyRuntime?.renderAttachedEnemyCount !== + state.caveEnemyRuntime?.proximityActiveCount || + state.caveEnemyRuntime?.renderAttachedCueCount !== + state.caveEnemyRuntime?.proximityActiveCount || + state.caveEnemyRuntime?.sleepingDetachedCount !== + state.caveEnemyRuntime?.sleepingEnemyCount || + state.caveEnemyRuntime?.activationBounds?.horizontalMargin !== 520 || + state.caveEnemyRuntime?.activationBounds?.verticalMargin !== 280 || + state.caveEnemyRuntime?.spiderTimersPaused !== true || + state.caveEnemyRuntime?.batMotionTweenCount !== 0 || state.currentEcologyPlacement?.supportId !== 'caves-chamber-bridge' || state.currentEcologyPlacement?.x < @@ -6034,6 +6105,14 @@ async function smokeLevel(session, route, sceneName, exceptions, { stagedEnemyTimerCount = wisp.runtimeTimers?.size || 0; } } + if (${JSON.stringify(route)} === 'crystalCaves') { + stagedEnemyTimerCount = ( + scene.enemies?.getChildren?.() || [] + ).reduce( + (total, enemy) => total + (enemy?.runtimeTimers?.size || 0), + 0 + ); + } if ([ 'auroraDepths', 'voidPeaks', @@ -6105,6 +6184,9 @@ async function smokeLevel(session, route, sceneName, exceptions, { forestEnemyAISchedulerActive: Boolean( scene.forestEnemyAISchedulerActive ), + caveEnemyAISchedulerActive: Boolean( + scene.caveEnemyAISchedulerActive + ), persistedId: persisted?.checkpointId || null, persistedIndex: persisted?.checkpointIndex ?? null }; @@ -6133,7 +6215,11 @@ async function smokeLevel(session, route, sceneName, exceptions, { guardianEntry.runtimeDisposals?.artifactCount < guardianEntrySetup.stagedEnemyArtifactCount) || (route === 'crystalCaves' && - guardianEntry.runtimeDisposals?.timerCount < 10) || + guardianEntrySetup.stagedEnemyTimerCount > 2) || + (route === 'crystalCaves' && + guardianEntry.runtimeDisposals?.timerCount !== 2) || + (route === 'crystalCaves' && + guardianEntry.caveEnemyAISchedulerActive !== false) || guardianEntry.persistedId !== guardianEntrySetup.persistedId || guardianEntry.persistedIndex !== guardianEntrySetup.persistedIndex ) { diff --git a/src/__tests__/LevelTraversalQualityContract.test.js b/src/__tests__/LevelTraversalQualityContract.test.js index c86adcc9..022c11e3 100644 --- a/src/__tests__/LevelTraversalQualityContract.test.js +++ b/src/__tests__/LevelTraversalQualityContract.test.js @@ -383,8 +383,18 @@ describe('campaign traversal quality contracts', () => { .toBeGreaterThanOrEqual(2); expect(forest).toContain('this.forestEnemyAISchedulerActive = true;'); expect(forest).toContain('this.forestEnemyAISchedulerActive = false;'); - expect((caves.match(/this\.trackEnemyTimer\(/g) || []).length) - .toBeGreaterThanOrEqual(5); + expect((caves.match(/this\.trackEnemyTimer\(/g) || []).length).toBe(2); + expect(caves).toContain('startCaveEnemyAIScheduler()'); + expect(caves).toContain('updateCaveEnemyActivation(force = false)'); + expect(caves).toContain('updateCaveEnemyAI(time, force = false)'); + expect(caves).toContain('setCaveEnemyRenderAttached(enemy, attached)'); + expect(caves).toContain('enemy.body.enable = false;'); + expect(caves).toContain('enemy.body.enable = true;'); + expect(caves).toContain('this.spiderAttackTimer.paused = !nextState;'); + expect(caves).toContain('this.spiderWebSprayTimer.paused = !nextState;'); + expect(caves).not.toContain('callback: () => this.updateBatPatrol(bat)'); + expect(caves).not.toContain('callback: () => this.updateCrawlerPatrol(crawler)'); + expect(caves).not.toContain('callback: () => this.updateCrystalSpiderAI()'); expect(forest).toContain('this.trackEnemyArtifact(sprite, projectile);'); }); @@ -2715,6 +2725,14 @@ describe('campaign traversal quality contracts', () => { expect(smoke).toContain('smokeCaveBatchedCoinPickup(session)'); expect(smoke).toContain('state.caveCoinRendering?.physicsCoinCount !== 0'); expect(smoke).toContain('state.caveCrystalRendering?.layerCount !== 1'); + expect(smoke).toContain('state.caveEnemyRuntime?.scheduledEnemyCount !== 8'); + expect(smoke).toContain('state.caveEnemyRuntime?.individualTimerCount !== 2'); + expect(smoke).toContain('state.caveEnemyRuntime?.proximityActiveCount > 3'); + expect(smoke).toContain('state.caveEnemyRuntime?.sleepingEnemyCount < 5'); + expect(smoke).toContain('state.caveEnemyRuntime?.spiderTimersPaused !== true'); + expect(smoke).toContain('state.caveEnemyRuntime?.batMotionTweenCount !== 0'); + expect(smoke).toContain('displayCount: 185'); + expect(smoke).toContain('activeTweenCount: 12'); expect(smoke).toContain('state.reefAmbientRendering?.nebulaLayerCount !== 2'); expect(smoke).toContain('state.reefAmbientRendering?.dustParticleCount > 6'); expect(smoke).toContain('state.reefAmbientRendering?.decorativeTweenCount !== 0'); diff --git a/src/scenes/levels/CrystalCavesLevel.js b/src/scenes/levels/CrystalCavesLevel.js index 74610aaf..1d683663 100644 --- a/src/scenes/levels/CrystalCavesLevel.js +++ b/src/scenes/levels/CrystalCavesLevel.js @@ -202,6 +202,11 @@ class CrystalCavesLevel extends PlatformerLevelScene { // Enemy spawns this.enemySpawns = []; this.caveEncounterRhythm = []; + this.caveProximityEnemies = []; + this.caveEnemyAISchedulerActive = false; + this.caveEnemyActivationBounds = null; + this.caveEnemyActivationNextAt = 0; + this.caveEnemyAINextAt = 0; // Ambient audio controller this.ambientAudio = null; @@ -256,6 +261,11 @@ class CrystalCavesLevel extends PlatformerLevelScene { this.bossFightActive = false; this.enemySpawns = []; this.caveEncounterRhythm = []; + this.caveProximityEnemies = []; + this.caveEnemyAISchedulerActive = false; + this.caveEnemyActivationBounds = null; + this.caveEnemyActivationNextAt = 0; + this.caveEnemyAINextAt = 0; // Reset boss state this.boss = null; @@ -1873,8 +1883,10 @@ class CrystalCavesLevel extends PlatformerLevelScene { enemy.encounterBeat = encounter.beat; enemy.encounterLane = encounter.lane; enemy.encounterSupportId = encounter.supportId; + this.registerCaveEnemyAI(enemy); return enemy; }); + this.startCaveEnemyAIScheduler(); console.log( `[CrystalCavesLevel] Created ${this.caveEncounterRhythm.length} ` + @@ -1885,6 +1897,9 @@ class CrystalCavesLevel extends PlatformerLevelScene { retireCavePatrolsForGolem() { const patrols = [...(this.enemies?.getChildren?.() || [])]; + this.caveEnemyAISchedulerActive = false; + this.caveProximityEnemies = []; + this.caveEnemyActivationBounds = null; const retirement = this.retireRouteEnemies(patrols); this.spiderAITimer = null; this.spiderAttackTimer = null; @@ -1900,6 +1915,183 @@ class CrystalCavesLevel extends PlatformerLevelScene { return retirement.enemyCount; } + registerCaveEnemyAI(enemy) { + if (!enemy) return null; + enemy.caveNextAiAt = Number(this.time?.now) || 0; + enemy.caveMotionOffset = Phaser.Math.Between(0, 900); + enemy.caveProximityActive = null; + return enemy; + } + + startCaveEnemyAIScheduler() { + this.caveEnemyAISchedulerActive = true; + this.caveEnemyActivationNextAt = 0; + this.caveEnemyAINextAt = 0; + this.updateCaveEnemyActivation(true); + } + + getCaveEnemyActivationBounds() { + 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 + }; + } + + setCaveEnemyRenderAttached(enemy, attached) { + const displayList = this.children; + if (!enemy || !displayList) return 0; + + const targets = [ + enemy, + enemy.combatCue, + enemy.instructionLabel, + enemy === this.crystalSpider ? this.spiderUI : null + ].filter(target => Boolean(target) && target.active !== false); + let changedCount = 0; + targets.forEach(target => { + const isAttached = target.displayList === displayList; + if (attached && !isAttached) { + displayList.add(target); + changedCount += 1; + } else if (!attached && isAttached) { + displayList.remove(target); + changedCount += 1; + } + }); + return changedCount; + } + + setCaveEnemyProximityActive(enemy, enabled) { + if (!enemy?.active || !enemy.body) return false; + const nextState = enabled === true; + if (enemy.caveProximityActive === nextState) return nextState; + + enemy.caveProximityActive = nextState; + if (nextState) { + this.setCaveEnemyRenderAttached(enemy, true); + enemy.body.enable = true; + enemy.body.updateFromGameObject?.(); + enemy.setVisible?.(true); + enemy.caveNextAiAt = Math.min( + Number(enemy.caveNextAiAt) || Number.POSITIVE_INFINITY, + (Number(this.time?.now) || 0) + 40 + ); + } else { + enemy.setVelocity?.(0, 0); + enemy.body.enable = false; + enemy.setVisible?.(false); + enemy.combatCue?.setVisible?.(false); + enemy.instructionLabel?.setVisible?.(false); + this.setCaveEnemyRenderAttached(enemy, false); + } + + if (enemy === this.crystalSpider) { + if (this.spiderAttackTimer) this.spiderAttackTimer.paused = !nextState; + if (this.spiderWebSprayTimer) this.spiderWebSprayTimer.paused = !nextState; + } + return nextState; + } + + isCaveEnemyReadyForSuspension(enemy) { + if (!enemy?.body || enemy.enemyType !== 'caveCrawler') return true; + if (enemy.caveSettledForStreaming) return true; + + const support = this.getTraversalSupport?.(enemy.encounterSupportId); + const grounded = Boolean( + enemy.body.blocked?.down || enemy.body.touching?.down + ); + const settled = Boolean( + grounded && + 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.caveSettledForStreaming = true; + return settled; + } + + updateCaveEnemyActivation(force = false) { + if (!this.caveEnemyAISchedulerActive || !this.scene.isActive()) return 0; + const now = Number(this.time?.now) || 0; + if (!force && now < this.caveEnemyActivationNextAt) { + return this.caveProximityEnemies.length; + } + this.caveEnemyActivationNextAt = now + (this.isMobile ? 120 : 80); + + const bounds = this.getCaveEnemyActivationBounds(); + 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 || + enemy.isAttacking === true || + !this.isCaveEnemyReadyForSuspension(enemy); + this.setCaveEnemyProximityActive(enemy, shouldStayActive); + if (shouldStayActive) nearby.push(enemy); + }); + this.caveProximityEnemies = nearby; + this.caveEnemyActivationBounds = bounds; + return nearby.length; + } + + updateCaveEnemyAI(time, force = false) { + if (!this.caveEnemyAISchedulerActive || !this.scene.isActive()) return 0; + const now = Number(time) || Number(this.time?.now) || 0; + if (!force && now < this.caveEnemyAINextAt) return 0; + this.caveEnemyAINextAt = now + (this.isMobile ? 50 : 34); + + let updatedCount = 0; + (this.caveProximityEnemies || []).forEach(enemy => { + if (!enemy?.active || enemy.caveProximityActive === false) return; + switch (enemy.enemyType) { + case 'shadowBat': + this.updateBatPatrol(enemy); + enemy.setScale( + enemy.scaleX < 0 ? -1 : 1, + 1 + Math.sin((now + enemy.caveMotionOffset) / 150) * 0.1 + ); + break; + case 'caveCrawler': + this.updateCrawlerPatrol(enemy); + break; + case 'crystalSpider': + this.updateCrystalSpiderAI(); + break; + default: + return; + } + enemy.caveNextAiAt = now + (this.isMobile ? 50 : 34); + updatedCount += 1; + }); + return updatedCount; + } + resolveCaveEncounterPlacement(encounter) { const support = this.getTraversalSupport(encounter.supportId); if (!support?.body) { @@ -2025,13 +2217,6 @@ class CrystalCavesLevel extends PlatformerLevelScene { // Create miniboss health bar this.createSpiderHealthBar(); - // Start spider AI - this.spiderAITimer = this.trackEnemyTimer(this.crystalSpider, this.time.addEvent({ - delay: 50, - callback: () => this.updateCrystalSpiderAI(), - loop: true - })); - // Attack timer this.spiderAttackTimer = this.trackEnemyTimer(this.crystalSpider, this.time.addEvent({ delay: 3000, @@ -2761,22 +2946,6 @@ class CrystalCavesLevel extends PlatformerLevelScene { cueOffsetY: -30 }); - // Flutter animation - this.tweens.add({ - targets: bat, - scaleY: { from: 0.9, to: 1.1 }, - duration: 150, - yoyo: true, - repeat: -1 - }); - - // Patrol behavior - this.trackEnemyTimer(bat, this.time.addEvent({ - delay: 50, - callback: () => this.updateBatPatrol(bat), - loop: true - })); - return bat; } @@ -2857,13 +3026,6 @@ class CrystalCavesLevel extends PlatformerLevelScene { cueOffsetY: -38 }); - // Patrol movement - this.trackEnemyTimer(crawler, this.time.addEvent({ - delay: 50, - callback: () => this.updateCrawlerPatrol(crawler), - loop: true - })); - return crawler; } @@ -5670,6 +5832,8 @@ class CrystalCavesLevel extends PlatformerLevelScene { super.update(time, delta); if (this.levelCompletionActive) return; + this.updateCaveEnemyActivation(); + this.updateCaveEnemyAI(time); this.updateCaveCoinPickups(); this.syncCampaignObjectiveDisplay({ @@ -5755,6 +5919,9 @@ class CrystalCavesLevel extends PlatformerLevelScene { this.caveCrystalFieldLayer = null; this.caveCrystalField = []; this.backgroundCrystals = []; + this.caveEnemyAISchedulerActive = false; + this.caveProximityEnemies = []; + this.caveEnemyActivationBounds = null; // Clean up Crystal Spider miniboss if (this.spiderAITimer) {