From 3dd519fef858d3bcfcde68b14215844bc18e013f Mon Sep 17 00:00:00 2001 From: TechEvolveAI Date: Fri, 14 Aug 2026 13:06:36 +0100 Subject: [PATCH] Make expedition briefs fit short phones --- .../MobileControlDockContract.test.js | 61 ++++++++++++- src/__tests__/PowerupCombatLoop.test.js | 3 +- src/scenes/PlatformerLevelScene.js | 41 ++++++++- src/scenes/levels/AuroraDepthsLevel.js | 6 ++ src/scenes/levels/CrystalCavesLevel.js | 9 +- src/scenes/levels/FinalVoidLevel.js | 6 ++ src/scenes/levels/MythicalForestLevel.js | 7 ++ src/scenes/levels/ReefLevel.js | 7 ++ src/scenes/levels/VoidPeaksLevel.js | 6 ++ src/systems/MobileControlLayout.js | 85 +++++++++++++++++++ 10 files changed, 227 insertions(+), 4 deletions(-) diff --git a/src/__tests__/MobileControlDockContract.test.js b/src/__tests__/MobileControlDockContract.test.js index b5f30884..22378f46 100644 --- a/src/__tests__/MobileControlDockContract.test.js +++ b/src/__tests__/MobileControlDockContract.test.js @@ -6,7 +6,7 @@ function loadControlMath() { const filePath = path.join(__dirname, '../systems/MobileControlLayout.js'); const source = fs.readFileSync(filePath, 'utf8') .replace(/export function /g, 'function ') - .concat('\nmodule.exports = { getMobileControlLayout, getMobileInteractionPromptLayout, getCampaignObjectiveLayout, getJoystickVector };\n'); + .concat('\nmodule.exports = { getMobileControlLayout, getMobileInteractionPromptLayout, getCampaignObjectiveLayout, getCampaignEntryStackLayout, getJoystickVector };\n'); const sandbox = { module: { exports: {} }, exports: {}, @@ -22,6 +22,7 @@ describe('shared mobile control dock', () => { getMobileControlLayout, getMobileInteractionPromptLayout, getCampaignObjectiveLayout, + getCampaignEntryStackLayout, getJoystickVector } = loadControlMath(); const viewports = [ @@ -182,6 +183,45 @@ describe('shared mobile control dock', () => { expect(vector.y).toBe(0); }); + test('lays out measured entry content without overlap', () => { + const heights = [34, 34, 14, 42, 34, 18, 42]; + const layout = getCampaignEntryStackLayout({ + top: 100, + bottom: 450, + itemHeights: heights, + gaps: [8, 10, 8, 10, 6, 12] + }); + + expect(layout.overflow).toBe(0); + expect(layout.positions[0]).toBeGreaterThanOrEqual(layout.innerTop); + heights.slice(0, -1).forEach((height, index) => { + expect(layout.positions[index] + height) + .toBeLessThanOrEqual(layout.positions[index + 1]); + }); + expect(layout.positions.at(-1) + heights.at(-1)) + .toBeLessThanOrEqual(layout.innerBottom); + }); + + test('contracts entry whitespace before content can collide', () => { + const heights = [34, 34, 22, 44, 36, 42]; + const layout = getCampaignEntryStackLayout({ + top: 0, + bottom: 250, + itemHeights: heights, + gaps: 12, + topPadding: 10, + bottomPadding: 10, + minGap: 4 + }); + + expect(layout.overflow).toBe(0); + expect(layout.gaps.every((gap) => gap >= 0 && gap < 12)).toBe(true); + heights.slice(0, -1).forEach((height, index) => { + expect(layout.positions[index] + height) + .toBeLessThanOrEqual(layout.positions[index + 1]); + }); + }); + test('both gameplay modes consume the shared geometry', () => { const mobileSource = fs.readFileSync( path.join(__dirname, '../systems/MobileControls.js'), @@ -248,6 +288,25 @@ describe('shared mobile control dock', () => { }); }); + test('all campaign entry briefs use measured responsive stacking', () => { + const levelFiles = [ + 'MythicalForestLevel.js', + 'CrystalCavesLevel.js', + 'ReefLevel.js', + 'VoidPeaksLevel.js', + 'AuroraDepthsLevel.js', + 'FinalVoidLevel.js' + ]; + + levelFiles.forEach((fileName) => { + const source = fs.readFileSync( + path.join(__dirname, '../scenes/levels', fileName), + 'utf8' + ); + expect(source).toContain('this.layoutCampaignEntryContent('); + }); + }); + test('the sanctuary camera reserves room above the mobile control dock', () => { const gameSource = fs.readFileSync( path.join(__dirname, '../scenes/GameScene.js'), diff --git a/src/__tests__/PowerupCombatLoop.test.js b/src/__tests__/PowerupCombatLoop.test.js index 0d1dcb54..fda07913 100644 --- a/src/__tests__/PowerupCombatLoop.test.js +++ b/src/__tests__/PowerupCombatLoop.test.js @@ -107,7 +107,8 @@ function loadPlatformerLevelScene(sceneWindow = {}) { ) .replace("import '../systems/ProjectBeaconFieldKit.js';", '') .replace( - "import { getCampaignObjectiveLayout, getMobileControlLayout, getSafeAreaInsets } from '../systems/MobileControlLayout.js';", + "import { getCampaignEntryStackLayout, getCampaignObjectiveLayout, getMobileControlLayout, getSafeAreaInsets } from '../systems/MobileControlLayout.js';", + 'const getCampaignEntryStackLayout = () => ({ positions: [], overflow: 0 });\n' + 'const getCampaignObjectiveLayout = () => ({});\n' + 'const getMobileControlLayout = () => ({});\n' + 'const getSafeAreaInsets = () => ({ top: 0, right: 0, bottom: 0, left: 0 });' diff --git a/src/scenes/PlatformerLevelScene.js b/src/scenes/PlatformerLevelScene.js index 1dcc6999..6e9ceb62 100644 --- a/src/scenes/PlatformerLevelScene.js +++ b/src/scenes/PlatformerLevelScene.js @@ -5,7 +5,7 @@ import { } from '../systems/ProjectBeaconStory.js'; import ExpeditionAstronaut from '../systems/ExpeditionAstronaut.js'; import '../systems/ProjectBeaconFieldKit.js'; -import { getCampaignObjectiveLayout, getMobileControlLayout, getSafeAreaInsets } from '../systems/MobileControlLayout.js'; +import { getCampaignEntryStackLayout, getCampaignObjectiveLayout, getMobileControlLayout, getSafeAreaInsets } from '../systems/MobileControlLayout.js'; import bossConfigs from '../config/bosses.json'; import { analyzeTraversalTopology } from '../systems/TraversalTopology.js'; import KatanaArtifactModal, { prefetchKatanaArtifactArtwork } from '../ui/KatanaArtifactModal.js'; @@ -552,6 +552,45 @@ class PlatformerLevelScene extends Phaser.Scene { }; } + layoutCampaignEntryContent(layout, entries, { + gaps = 8, + topPadding = 18, + bottomPadding = 18, + minGap = 4 + } = {}) { + if (!layout?.isCompact) return null; + + const elements = entries.filter((element) => element && element.active !== false); + const itemHeights = elements.map((element) => { + const boundsHeight = element.getBounds?.()?.height; + const measuredHeight = Number(boundsHeight || element.displayHeight || element.height); + return Number.isFinite(measuredHeight) ? Math.max(0, measuredHeight) : 0; + }); + const stack = getCampaignEntryStackLayout({ + top: layout.panelY, + bottom: layout.panelY + layout.panelHeight, + itemHeights, + gaps, + topPadding, + bottomPadding, + minGap + }); + + elements.forEach((element, index) => { + const originY = Number.isFinite(Number(element.originY)) + ? Number(element.originY) + : 0; + element.setY(stack.positions[index] + itemHeights[index] * originY); + }); + + if (stack.overflow > 0.5) { + console.warn( + `[PlatformerLevel] Campaign entry content exceeds its panel by ${stack.overflow.toFixed(1)}px` + ); + } + return stack; + } + /** * Reset all game state - called on init() for restart support */ diff --git a/src/scenes/levels/AuroraDepthsLevel.js b/src/scenes/levels/AuroraDepthsLevel.js index cbcad694..2693169f 100644 --- a/src/scenes/levels/AuroraDepthsLevel.js +++ b/src/scenes/levels/AuroraDepthsLevel.js @@ -390,6 +390,12 @@ class AuroraDepthsLevel extends PlatformerLevelScene { ).setOrigin(0.5).setScrollFactor(0).setDepth(3002).setInteractive({ cursor: 'pointer' }); entryElements.push(enterBtn); + this.layoutCampaignEntryContent( + layout, + [title, subtitle, mission, mainObj, obj1, enterBtn], + { gaps: [8, 10, 10, 10, 14] } + ); + enterBtn.on('pointerover', () => enterBtn.setColor('#7FFFD4')); enterBtn.on('pointerout', () => enterBtn.setColor('#00E676')); diff --git a/src/scenes/levels/CrystalCavesLevel.js b/src/scenes/levels/CrystalCavesLevel.js index f0b7e726..55ea4734 100644 --- a/src/scenes/levels/CrystalCavesLevel.js +++ b/src/scenes/levels/CrystalCavesLevel.js @@ -338,7 +338,7 @@ class CrystalCavesLevel extends PlatformerLevelScene { this.levelEntryDismissing = false; const layout = this.getLevelModalLayout({ maxWidth: 450, maxHeight: 400 }); const { - width, height, panelWidth, panelHeight, panelX, panelY, + width, height, isCompact, panelWidth, panelHeight, panelX, panelY, contentWidth, contentLeft, contentRight, y, font, buttonPadding } = layout; const resume = this.getExpeditionResumePresentation(); @@ -454,6 +454,13 @@ class CrystalCavesLevel extends PlatformerLevelScene { } ).setOrigin(0.5).setScrollFactor(0).setDepth(3002).setInteractive({ cursor: 'pointer' }); + divider.setVisible(!isCompact); + this.layoutCampaignEntryContent( + layout, + [title, subtitle, objHeader, mainObj, anchors, grove, relic, enterBtn], + { gaps: [8, 10, 8, 10, 5, 5, 12] } + ); + // Button hover effects enterBtn.on('pointerover', () => { enterBtn.setColor('#E040FB'); diff --git a/src/scenes/levels/FinalVoidLevel.js b/src/scenes/levels/FinalVoidLevel.js index 8a84fb61..db2f084b 100644 --- a/src/scenes/levels/FinalVoidLevel.js +++ b/src/scenes/levels/FinalVoidLevel.js @@ -452,6 +452,12 @@ class FinalVoidLevel extends PlatformerLevelScene { ).setOrigin(0.5).setScrollFactor(0).setDepth(3002).setInteractive({ cursor: 'pointer' }); entryElements.push(enterBtn); + this.layoutCampaignEntryContent( + layout, + [title, subtitle, mission, objective, checklist, enterBtn], + { gaps: [8, 10, 10, 10, 14] } + ); + enterBtn.on('pointerover', () => enterBtn.setColor('#FF00FF')); enterBtn.on('pointerout', () => enterBtn.setColor('#9400D3')); diff --git a/src/scenes/levels/MythicalForestLevel.js b/src/scenes/levels/MythicalForestLevel.js index 36531014..542d2531 100644 --- a/src/scenes/levels/MythicalForestLevel.js +++ b/src/scenes/levels/MythicalForestLevel.js @@ -678,6 +678,13 @@ class MythicalForestLevel extends PlatformerLevelScene { ).setOrigin(0.5).setScrollFactor(0).setDepth(3002).setInteractive({ cursor: 'pointer' }); entryElements.push(enterBtn); + divider.setVisible(!isCompact); + this.layoutCampaignEntryContent( + layout, + [title, subtitle, objHeader, mainObj, obj1, obj2, enterBtn], + { gaps: [8, 10, 8, 10, 6, 12] } + ); + enterBtn.on('pointerover', () => enterBtn.setColor('#90EE90')); enterBtn.on('pointerout', () => enterBtn.setColor('#228B22')); diff --git a/src/scenes/levels/ReefLevel.js b/src/scenes/levels/ReefLevel.js index b3e33002..56d27d99 100644 --- a/src/scenes/levels/ReefLevel.js +++ b/src/scenes/levels/ReefLevel.js @@ -492,6 +492,13 @@ class ReefLevel extends PlatformerLevelScene { } ).setOrigin(0.5).setScrollFactor(0).setDepth(3002).setInteractive({ cursor: 'pointer' }); + divider.setVisible(!isCompact); + this.layoutCampaignEntryContent( + layout, + [title, subtitle, storyText, objHeader, mainObj, shipObj, relicObj, controlsHint, enterBtn], + { gaps: [7, 7, 10, 7, 6, 6, 8, 12], topPadding: 16, bottomPadding: 16 } + ); + enterBtn.on('pointerover', () => { enterBtn.setColor('#FF88FF'); enterBtn.setScale(1.05); diff --git a/src/scenes/levels/VoidPeaksLevel.js b/src/scenes/levels/VoidPeaksLevel.js index 60c03d06..40f4ce7c 100644 --- a/src/scenes/levels/VoidPeaksLevel.js +++ b/src/scenes/levels/VoidPeaksLevel.js @@ -340,6 +340,12 @@ class VoidPeaksLevel extends PlatformerLevelScene { ).setOrigin(0.5).setScrollFactor(0).setDepth(3002).setInteractive({ cursor: 'pointer' }); entryElements.push(enterBtn); + this.layoutCampaignEntryContent( + layout, + [title, subtitle, mission, objective, checklist, enterBtn], + { gaps: [8, 10, 10, 10, 14] } + ); + enterBtn.on('pointerover', () => enterBtn.setColor('#FFD700')); enterBtn.on('pointerout', () => enterBtn.setColor('#FF6B35')); diff --git a/src/systems/MobileControlLayout.js b/src/systems/MobileControlLayout.js index 5e87d8da..9f297dbe 100644 --- a/src/systems/MobileControlLayout.js +++ b/src/systems/MobileControlLayout.js @@ -199,3 +199,88 @@ export function getCampaignObjectiveLayout({ fontSize: compact ? 12 : 15 }; } + +/** + * Position measured campaign-entry blocks without assuming wrapped text has a + * fixed height. Preferred whitespace contracts before content can collide. + */ +export function getCampaignEntryStackLayout({ + top, + bottom, + itemHeights = [], + gaps = 8, + topPadding = 18, + bottomPadding = 18, + minGap = 4 +}) { + const safeTop = Number.isFinite(Number(top)) ? Number(top) : 0; + const safeBottom = Number.isFinite(Number(bottom)) + ? Math.max(safeTop, Number(bottom)) + : safeTop; + const availableHeight = safeBottom - safeTop; + const safeTopPadding = clamp(Number(topPadding) || 0, 0, availableHeight); + const safeBottomPadding = clamp( + Number(bottomPadding) || 0, + 0, + Math.max(0, availableHeight - safeTopPadding) + ); + const innerTop = safeTop + safeTopPadding; + const innerHeight = Math.max( + 0, + availableHeight - safeTopPadding - safeBottomPadding + ); + const heights = itemHeights.map((height) => ( + Number.isFinite(Number(height)) ? Math.max(0, Number(height)) : 0 + )); + const gapCount = Math.max(0, heights.length - 1); + const gapValueAt = (index) => ( + Array.isArray(gaps) ? gaps[index] : gaps + ); + const preferredGaps = Array.from({ length: gapCount }, (_, index) => { + const value = Number(gapValueAt(index)); + return Number.isFinite(value) ? Math.max(0, value) : 8; + }); + const minimumGap = Number.isFinite(Number(minGap)) + ? Math.max(0, Number(minGap)) + : 0; + const minimumGaps = preferredGaps.map((gap) => Math.min(gap, minimumGap)); + const itemHeight = heights.reduce((total, height) => total + height, 0); + const preferredGapHeight = preferredGaps.reduce((total, gap) => total + gap, 0); + const minimumGapHeight = minimumGaps.reduce((total, gap) => total + gap, 0); + const gapBudget = Math.max(0, innerHeight - itemHeight); + + let resolvedGaps = preferredGaps; + if (minimumGapHeight > gapBudget && minimumGapHeight > 0) { + const scale = gapBudget / minimumGapHeight; + resolvedGaps = minimumGaps.map((gap) => gap * scale); + } else if (preferredGapHeight > gapBudget && preferredGapHeight > minimumGapHeight) { + const interpolation = clamp( + (gapBudget - minimumGapHeight) / (preferredGapHeight - minimumGapHeight), + 0, + 1 + ); + resolvedGaps = preferredGaps.map((gap, index) => ( + minimumGaps[index] + (gap - minimumGaps[index]) * interpolation + )); + } + + const resolvedGapHeight = resolvedGaps.reduce((total, gap) => total + gap, 0); + const usedHeight = itemHeight + resolvedGapHeight; + const leadingSpace = Math.max(0, (innerHeight - usedHeight) / 2); + const positions = []; + let cursor = innerTop + leadingSpace; + + heights.forEach((height, index) => { + positions.push(cursor); + cursor += height + (resolvedGaps[index] || 0); + }); + + return { + positions, + gaps: resolvedGaps, + usedHeight, + innerTop, + innerBottom: innerTop + innerHeight, + overflow: Math.max(0, usedHeight - innerHeight) + }; +}