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
61 changes: 60 additions & 1 deletion src/__tests__/MobileControlDockContract.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
Expand All @@ -22,6 +22,7 @@ describe('shared mobile control dock', () => {
getMobileControlLayout,
getMobileInteractionPromptLayout,
getCampaignObjectiveLayout,
getCampaignEntryStackLayout,
getJoystickVector
} = loadControlMath();
const viewports = [
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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'),
Expand Down
3 changes: 2 additions & 1 deletion src/__tests__/PowerupCombatLoop.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });'
Expand Down
41 changes: 40 additions & 1 deletion src/scenes/PlatformerLevelScene.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
*/
Expand Down
6 changes: 6 additions & 0 deletions src/scenes/levels/AuroraDepthsLevel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'));

Expand Down
9 changes: 8 additions & 1 deletion src/scenes/levels/CrystalCavesLevel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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');
Expand Down
6 changes: 6 additions & 0 deletions src/scenes/levels/FinalVoidLevel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'));

Expand Down
7 changes: 7 additions & 0 deletions src/scenes/levels/MythicalForestLevel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'));

Expand Down
7 changes: 7 additions & 0 deletions src/scenes/levels/ReefLevel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions src/scenes/levels/VoidPeaksLevel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'));

Expand Down
85 changes: 85 additions & 0 deletions src/systems/MobileControlLayout.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
};
}