feat: implement comprehensive battle and campaign tutorial system

Adds a multi-stage tutorial system to guide new players through the core game mechanics:

- **Save System**: Bumps save version to 3 and adds `tutorialProgress` tracking with migration logic for existing saves.
- **MainMenuScene**: Introduces Stage 1 (Campaign Intro) overlay directing users to start the campaign.
- **CampaignSelectScene**: Adds Stage 2 overlay highlighting the "Raider Uprising" campaign selection.
- **CampaignScene**: Adds Stage 3 overlay explaining mission selection within a campaign.
- **BattleScene**: Implements Stages 4 & 5:
  - **Battle Intro**: Step-by-step walkthrough of Commander cards, explaining ATK, ARM, HP, DLY, and Skills.
  - **Battlefield Guide**: Visual explanation of the 4-lane system, card combat mechanics, unblocked attacks, and the "Next Turn" flow.

All tutorial overlays include interactive "Continue" or direct-action buttons, animated indicators, and depth management to prevent interaction with the underlying game state until the tutorial step is complete.
This commit is contained in:
Brian Fertig 2026-03-25 22:06:04 -06:00
parent 4eb4d7f278
commit 5a67484240
7 changed files with 776 additions and 1 deletions

Binary file not shown.

View File

@ -1,5 +1,5 @@
const SAVE_KEY = 'tyrants-edge-save';
const CURRENT_VERSION = 2;
const CURRENT_VERSION = 3;
export class SaveManager {
static defaultSave() {
@ -11,6 +11,7 @@ export class SaveManager {
decks: [],
campaignProgress: { completedMissions: [], currentCampaign: 'campaign_raider' },
unlockedCampaigns: ['campaign_raider'],
tutorialProgress: { completedStages: [] },
starterDeckReceived: false
};
}
@ -42,6 +43,10 @@ export class SaveManager {
data.campaignProgress = { completedMissions: [], currentCampaign: 'campaign_raider' };
}
}
// v2 → v3: add tutorialProgress
if (data.version < 3) {
data.tutorialProgress = data.tutorialProgress || { completedStages: [] };
}
data.version = CURRENT_VERSION;
return data;
}

View File

@ -0,0 +1,17 @@
import { SaveManager } from './SaveManager.js';
export class TutorialManager {
static STAGES = { CAMPAIGN_INTRO: 1, CAMPAIGN_SELECT: 2, BATTLE_INTRO: 3, BATTLEFIELD_GUIDE: 4 };
static isStageComplete(save, stageNum) {
return save.tutorialProgress?.completedStages?.includes(stageNum) ?? false;
}
static completeStage(save, stageNum) {
if (!save.tutorialProgress) save.tutorialProgress = { completedStages: [] };
if (!save.tutorialProgress.completedStages.includes(stageNum)) {
save.tutorialProgress.completedStages.push(stageNum);
}
SaveManager.save(save);
}
}

View File

@ -2,6 +2,7 @@ import { CombatEngine } from '../combat/CombatEngine.js';
import { CardObject } from '../objects/CardObject.js';
import { BattleField } from '../objects/BattleField.js';
import { SaveManager } from '../managers/SaveManager.js';
import { TutorialManager } from '../managers/TutorialManager.js';
// ── Battle background images by faction ───────────────────────────────────────
// Each faction with art has 3 numbered variants; one is picked at random per battle.
@ -227,6 +228,7 @@ export class BattleScene extends Phaser.Scene {
this._makeBackButton();
this._renderState();
this._showTutorial();
}
_buildCommanderDisplay() {
@ -256,7 +258,498 @@ export class BattleScene extends Phaser.Scene {
this.pDeckText = this.add.text(14, 540, '', { fontSize: '15px', color: '#aaaaaa', fontFamily: 'Audiowide' });
}
_showTutorial() {
const save = this.registry.get('save');
if (TutorialManager.isStageComplete(save, TutorialManager.STAGES.BATTLE_INTRO)) {
// Stage 3 done — check if Stage 4 still needs to run
if (!TutorialManager.isStageComplete(save, TutorialManager.STAGES.BATTLEFIELD_GUIDE)) {
this._showBattlefieldTutorial();
}
return;
}
const { width, height } = this.scale;
const DEPTH = 1000;
// Get commander card objects
const state = this.engine.getState();
const playerCmd = this.commanderObjects.get(state.player.commander.instanceId);
const enemyCmd = this.commanderObjects.get(state.opponent.commander.instanceId);
// Prevent battle actions during tutorial
this._tutorialActive = true;
// Commander card dimensions & stat positions
const cmdW = 240, cmdH = 336;
const scale = cmdW / 80; // 3
const bannerH = Math.round(cmdH * 0.12); // 40
const topBannerCY = -cmdH / 2 + bannerH / 2; // -148
const bottomBannerCY = cmdH / 2 - bannerH / 2; // 148
// Enemy commander screen position
const enemyCX = 150, enemyCY = 305;
// Stat screen positions on enemy commander
const statPositions = {
atk: { x: enemyCX - cmdW / 2 + Math.round(5 * scale), y: enemyCY + topBannerCY },
arm: { x: enemyCX + cmdW / 2 - Math.round(5 * scale), y: enemyCY + topBannerCY },
hp: { x: enemyCX - cmdW / 2 + Math.round(5 * scale), y: enemyCY + bottomBannerCY },
dly: { x: enemyCX + cmdW / 2 - Math.round(5 * scale), y: enemyCY + bottomBannerCY }
};
let tutorialElements = [];
let currentStep = 0;
let arrowTween = null;
const clearElements = () => {
if (arrowTween) { arrowTween.destroy(); arrowTween = null; }
tutorialElements.forEach(el => el.destroy());
tutorialElements = [];
playerCmd.setDepth(0);
enemyCmd.setDepth(0);
};
const makeArrow = (x, y) => {
const arrow = this.add.text(x, y, '\u25BC', {
fontSize: '48px', color: '#ffd700'
}).setOrigin(0.5).setDepth(DEPTH + 2);
arrowTween = this.tweens.add({
targets: arrow,
y: y + 15,
duration: 600,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut'
});
tutorialElements.push(arrow);
return arrow;
};
const makeText = (x, y, lines, originX = 0.5, originY = 0) => {
const text = this.add.text(x, y, lines.join('\n'), {
fontSize: '22px',
color: '#ffffff',
fontFamily: 'Audiowide',
align: originX === 0 ? 'left' : 'center',
lineSpacing: 8,
wordWrap: { width: 700 }
}).setOrigin(originX, originY).setDepth(DEPTH + 2);
tutorialElements.push(text);
return text;
};
const makeContinueBtn = (x, y) => {
const btnBg = this.add.rectangle(x, y, 320, 60, 0x225522)
.setDepth(DEPTH + 2)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(2, 0x44ff44);
const btnText = this.add.text(x, y, 'Continue Tutorial', {
fontSize: '24px', color: '#44ff44', fontFamily: 'Audiowide'
}).setOrigin(0.5).setDepth(DEPTH + 2);
btnBg.on('pointerover', () => btnBg.setFillStyle(0x338833));
btnBg.on('pointerout', () => btnBg.setFillStyle(0x225522));
btnBg.on('pointerdown', () => {
this.sound.play('sfx_menu_select', { volume: 0.7 });
currentStep++;
showStep();
});
tutorialElements.push(btnBg, btnText);
};
const showStep = () => {
clearElements();
// Step 7 = done (steps: 0=player cmd, 1=enemy cmd, 2=ATK, 3=ARM, 4=HP, 5=DLY, 6=Skills)
if (currentStep >= 7) {
TutorialManager.completeStage(save, TutorialManager.STAGES.BATTLE_INTRO);
// Chain directly into Stage 4 (battlefield guide)
this._showBattlefieldTutorial();
return;
}
// Dark overlay
const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.75)
.setDepth(DEPTH)
.setInteractive();
tutorialElements.push(overlay);
if (currentStep === 0) {
// Highlight player commander
playerCmd.setDepth(DEPTH + 1);
makeArrow(150, 715 - cmdH / 2 - 50);
makeText(width / 2, 715 - cmdH / 2 - 140, [
'This is your Commander card — Warlord Voss.',
'In each mission you battle another commander.',
'You win by reducing the enemy commander\'s HP to zero!'
]);
makeContinueBtn(width / 2, 715 + cmdH / 2 + 50);
} else if (currentStep === 1) {
// Highlight enemy commander
enemyCmd.setDepth(DEPTH + 1);
makeArrow(150, 305 - cmdH / 2 - 50);
makeText(width / 2, 305 - cmdH / 2 - 100, [
'This is the enemy commander.',
'Your goal is to defeat him! Let\'s learn about card stats.'
]);
makeContinueBtn(width / 2, 305 + cmdH / 2 + 50);
} else if (currentStep === 2) {
// ATK stat
enemyCmd.setDepth(DEPTH + 1);
makeArrow(statPositions.atk.x, statPositions.atk.y - 55);
makeText(400, statPositions.atk.y - 60, [
'ATK (Attack)',
'This is the card\'s attack power. Each turn, a card',
'deals damage equal to its ATK value to the card or',
'commander directly across from it. Higher ATK means',
'more damage dealt per turn.'
], 0, 0.5);
makeContinueBtn(width / 2, 305 + cmdH / 2 + 50);
} else if (currentStep === 3) {
// ARM stat
enemyCmd.setDepth(DEPTH + 1);
makeArrow(statPositions.arm.x, statPositions.arm.y - 55);
makeText(400, statPositions.arm.y - 60, [
'ARM (Armor)',
'Armor absorbs incoming damage before it hits HP.',
'When a card is attacked, the damage is first reduced',
'by the target\'s ARM value. For example, 5 ATK vs 2 ARM',
'means only 3 damage gets through to HP.'
], 0, 0.5);
makeContinueBtn(width / 2, 305 + cmdH / 2 + 50);
} else if (currentStep === 4) {
// HP stat
enemyCmd.setDepth(DEPTH + 1);
makeArrow(statPositions.hp.x, statPositions.hp.y - 55);
makeText(400, statPositions.hp.y - 60, [
'HP (Hit Points)',
'This is the card\'s health. When a card takes damage',
'that gets past its armor, HP is reduced. When HP',
'reaches zero, the card is destroyed. Reduce the enemy',
'commander\'s HP to zero to win the battle!'
], 0, 0.5);
makeContinueBtn(width / 2, 305 + cmdH / 2 + 50);
} else if (currentStep === 5) {
// DLY stat
enemyCmd.setDepth(DEPTH + 1);
makeArrow(statPositions.dly.x, statPositions.dly.y - 55);
makeText(400, statPositions.dly.y - 60, [
'DLY (Delay)',
'Delay determines how many turns a card must wait',
'before it can act. When first deployed, a card with',
'DLY 2 will wait 2 turns before attacking. Delay counts',
'down by 1 each turn. Once it reaches 0, the card is active.'
], 0, 0.5);
makeContinueBtn(width / 2, 305 + cmdH / 2 + 50);
} else if (currentStep === 6) {
// Skills section
enemyCmd.setDepth(DEPTH + 1);
// Skill text is in the content area at roughly y = cardCenter + 94
const skillScreenY = enemyCY + 94;
makeArrow(150, skillScreenY - 55);
makeText(400, skillScreenY - 80, [
'Skills',
'Each card has Skills that provide additional ways to',
'attack, defend, strengthen allies, weaken enemies and',
'more. Each faction has primary skills that help define',
'their play style and provide the strategy and synergies',
'you\'ll need to build the best deck in the game.',
'',
'Hover over any card to see full details on its',
'attributes, skills, and what each skill does!'
], 0, 0.5);
makeContinueBtn(width / 2, 305 + cmdH / 2 + 50);
}
};
showStep();
}
_showBattlefieldTutorial() {
const save = this.registry.get('save');
if (TutorialManager.isStageComplete(save, TutorialManager.STAGES.BATTLEFIELD_GUIDE)) return;
const { width, height } = this.scale;
const DEPTH = 1000;
this._tutorialActive = true;
// Lane layout constants
const laneXs = [430, 740, 1050, 1360];
const laneW = 300;
const opponentY = 305;
const playerY = 715;
const slotH = 370; // card slot height region
const midY = 510;
let tutorialElements = [];
let currentStep = 0;
let arrowTween = null;
const clearElements = () => {
if (arrowTween) { arrowTween.destroy(); arrowTween = null; }
tutorialElements.forEach(el => el.destroy());
tutorialElements = [];
// Reset commander depths that may have been raised
const state = this.engine.getState();
this.commanderObjects.get(state.opponent.commander.instanceId)?.setDepth(0);
this.commanderObjects.get(state.player.commander.instanceId)?.setDepth(0);
};
const makeArrow = (x, y) => {
const arrow = this.add.text(x, y, '\u25BC', {
fontSize: '48px', color: '#ffd700'
}).setOrigin(0.5).setDepth(DEPTH + 2);
arrowTween = this.tweens.add({
targets: arrow,
y: y + 15,
duration: 600,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut'
});
tutorialElements.push(arrow);
return arrow;
};
const makeText = (x, y, lines, opts = {}) => {
const text = this.add.text(x, y, lines.join('\n'), {
fontSize: opts.fontSize || '22px',
color: opts.color || '#ffffff',
fontFamily: 'Audiowide',
align: opts.align || 'center',
lineSpacing: 8,
wordWrap: { width: opts.wrapWidth || 700 }
}).setOrigin(opts.originX ?? 0.5, opts.originY ?? 0).setDepth(DEPTH + 2);
tutorialElements.push(text);
return text;
};
const makeContinueBtn = (x, y) => {
const btnBg = this.add.rectangle(x, y, 320, 60, 0x225522)
.setDepth(DEPTH + 2)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(2, 0x44ff44);
const btnText = this.add.text(x, y, 'Continue Tutorial', {
fontSize: '24px', color: '#44ff44', fontFamily: 'Audiowide'
}).setOrigin(0.5).setDepth(DEPTH + 2);
btnBg.on('pointerover', () => btnBg.setFillStyle(0x338833));
btnBg.on('pointerout', () => btnBg.setFillStyle(0x225522));
btnBg.on('pointerdown', () => {
this.sound.play('sfx_menu_select', { volume: 0.7 });
currentStep++;
showStep();
});
tutorialElements.push(btnBg, btnText);
};
const showStep = () => {
clearElements();
// Steps: 0=lanes overview, 1=cards fight across, 2=unblocked hits commander, 3=next turn button
if (currentStep >= 4) {
TutorialManager.completeStage(save, TutorialManager.STAGES.BATTLEFIELD_GUIDE);
this._tutorialActive = false;
return;
}
// Dark overlay
const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.75)
.setDepth(DEPTH)
.setInteractive();
tutorialElements.push(overlay);
if (currentStep === 0) {
// ── Lanes overview: show all 4 lanes with labels ──
for (let i = 0; i < 4; i++) {
const lx = laneXs[i];
// Opponent slot (top half of lane)
const oSlot = this.add.rectangle(lx, opponentY, laneW, slotH, 0x441111, 0.3)
.setDepth(DEPTH + 1).setStrokeStyle(2, 0xff6666);
tutorialElements.push(oSlot);
// Player slot (bottom half of lane)
const pSlot = this.add.rectangle(lx, playerY, laneW, slotH, 0x112244, 0.3)
.setDepth(DEPTH + 1).setStrokeStyle(2, 0x6688ff);
tutorialElements.push(pSlot);
// Lane number label
const laneLabel = this.add.text(lx, midY, `Lane ${i + 1}`, {
fontSize: '20px', color: '#ffffff', fontFamily: 'Audiowide',
backgroundColor: '#000000aa', padding: { x: 8, y: 4 }
}).setOrigin(0.5).setDepth(DEPTH + 2);
tutorialElements.push(laneLabel);
}
// Side labels on lanes
makeText(900, 100, ['ENEMY SIDE'], { fontSize: '28px', color: '#ff8888' });
makeText(900, 890, ['YOUR SIDE'], { fontSize: '28px', color: '#88aaff' });
makeText(width / 2, midY + 40, [
'This is the battlefield. It has 4 lanes.',
'Your cards are deployed to the bottom slots (blue).',
'Enemy cards occupy the top slots (red).',
'Each lane holds one card per side.'
], { originY: 0 });
makeContinueBtn(width / 2, midY + 200);
} else if (currentStep === 1) {
// ── Cards fight across: highlight one lane with arrows ──
const lx = laneXs[0]; // Lane 1 for demonstration
// Opponent slot
const oSlot = this.add.rectangle(lx, opponentY, laneW, slotH, 0x441111, 0.3)
.setDepth(DEPTH + 1).setStrokeStyle(2, 0xff6666);
tutorialElements.push(oSlot);
// Player slot
const pSlot = this.add.rectangle(lx, playerY, laneW, slotH, 0x112244, 0.3)
.setDepth(DEPTH + 1).setStrokeStyle(2, 0x6688ff);
tutorialElements.push(pSlot);
// Card placeholders
const enemyCard = this.add.rectangle(lx, opponentY, 200, 280, 0x661111)
.setDepth(DEPTH + 1).setStrokeStyle(2, 0xff4444);
tutorialElements.push(enemyCard);
const enemyLabel = this.add.text(lx, opponentY, 'Enemy\nCard', {
fontSize: '20px', color: '#ff8888', fontFamily: 'Audiowide', align: 'center'
}).setOrigin(0.5).setDepth(DEPTH + 2);
tutorialElements.push(enemyLabel);
const playerCard = this.add.rectangle(lx, playerY, 200, 280, 0x112266)
.setDepth(DEPTH + 1).setStrokeStyle(2, 0x4488ff);
tutorialElements.push(playerCard);
const playerLabel = this.add.text(lx, playerY, 'Your\nCard', {
fontSize: '20px', color: '#88aaff', fontFamily: 'Audiowide', align: 'center'
}).setOrigin(0.5).setDepth(DEPTH + 2);
tutorialElements.push(playerLabel);
// Battle arrows between cards (up and down)
const arrowUp = this.add.text(lx - 30, midY, '\u25B2', {
fontSize: '36px', color: '#88aaff'
}).setOrigin(0.5).setDepth(DEPTH + 2);
tutorialElements.push(arrowUp);
const arrowDown = this.add.text(lx + 30, midY, '\u25BC', {
fontSize: '36px', color: '#ff8888'
}).setOrigin(0.5).setDepth(DEPTH + 2);
tutorialElements.push(arrowDown);
// Animate the battle arrows pulsing toward each other
this.tweens.add({ targets: arrowUp, y: midY - 15, duration: 500, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
this.tweens.add({ targets: arrowDown, y: midY + 15, duration: 500, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
makeText(lx + 220, midY - 50, [
'When two cards occupy the same lane,',
'they fight each other! Each turn, both',
'cards deal their ATK damage to the card',
'across from them. This continues until',
'one card is destroyed.'
], { align: 'left', originX: 0, originY: 0 });
makeContinueBtn(width / 2, midY + 200);
} else if (currentStep === 2) {
// ── Unblocked card hits commander ──
const lx = laneXs[1]; // Lane 2 for demonstration
// Show empty opponent slot
const oSlot = this.add.rectangle(lx, opponentY, laneW, slotH, 0x441111, 0.15)
.setDepth(DEPTH + 1).setStrokeStyle(1, 0x664444);
tutorialElements.push(oSlot);
const emptyLabel = this.add.text(lx, opponentY, 'Empty', {
fontSize: '20px', color: '#664444', fontFamily: 'Audiowide'
}).setOrigin(0.5).setDepth(DEPTH + 2);
tutorialElements.push(emptyLabel);
// Player slot with card
const pSlot = this.add.rectangle(lx, playerY, laneW, slotH, 0x112244, 0.3)
.setDepth(DEPTH + 1).setStrokeStyle(2, 0x6688ff);
tutorialElements.push(pSlot);
const playerCard = this.add.rectangle(lx, playerY, 200, 280, 0x112266)
.setDepth(DEPTH + 1).setStrokeStyle(2, 0x4488ff);
tutorialElements.push(playerCard);
const playerLabel = this.add.text(lx, playerY, 'Your\nCard', {
fontSize: '20px', color: '#88aaff', fontFamily: 'Audiowide', align: 'center'
}).setOrigin(0.5).setDepth(DEPTH + 2);
tutorialElements.push(playerLabel);
// Show enemy commander highlighted
const enemyCmd = this.commanderObjects.get(
this.engine.getState().opponent.commander.instanceId
);
enemyCmd.setDepth(DEPTH + 1);
// Arrow from card toward commander
const strikeArrow = this.add.text(lx - 140, opponentY - 50, '\u25C4', {
fontSize: '48px', color: '#ffd700'
}).setOrigin(0.5).setDepth(DEPTH + 2);
tutorialElements.push(strikeArrow);
this.tweens.add({
targets: strikeArrow,
x: lx - 180,
duration: 600,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut'
});
makeText(lx + 50, midY - 60, [
'If there is no enemy card in a lane,',
'your card attacks the enemy commander',
'directly! This is how you deal damage',
'to win the battle. The same applies to',
'enemy cards — protect your commander!'
], { align: 'left', originX: 0, originY: 0 });
makeContinueBtn(width / 2, midY + 200);
} else if (currentStep === 3) {
// ── Point at Next Turn button ──
// Duplicate Next Turn button on top of overlay
const btnX = 1840, btnY = 35;
const nextBtnBg = this.add.rectangle(btnX, btnY, 180, 44, 0x1a3a5c)
.setDepth(DEPTH + 1)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(1, 0x4488ff);
const nextBtnText = this.add.text(btnX, btnY, 'Next Turn', {
fontSize: '18px', color: '#ffffff', fontFamily: 'Audiowide'
}).setOrigin(0.5).setDepth(DEPTH + 1);
tutorialElements.push(nextBtnBg, nextBtnText);
nextBtnBg.on('pointerover', () => nextBtnBg.setFillStyle(0x2a5a8c));
nextBtnBg.on('pointerout', () => nextBtnBg.setFillStyle(0x1a3a5c));
// Arrow pointing at button
makeArrow(btnX, btnY + 50);
makeText(width / 2, height / 2 - 50, [
'You\'re ready for battle!',
'Click "Next Turn" to begin your first turn.',
'You\'ll be able to select a card from your hand to deploy.'
]);
// Click completes Stage 4 and triggers actual turn
nextBtnBg.on('pointerdown', () => {
this.sound.play('sfx_menu_select', { volume: 0.7 });
TutorialManager.completeStage(save, TutorialManager.STAGES.BATTLEFIELD_GUIDE);
clearElements();
this._tutorialActive = false;
this._beginTurn();
});
}
};
showStep();
}
_beginTurn() {
if (this._tutorialActive) return;
if (this.waitingForPick || this.isAnimating) return;
if (this.engine.winner) { this._showResult(); return; }

View File

@ -1,4 +1,5 @@
import { SaveManager } from '../managers/SaveManager.js';
import { TutorialManager } from '../managers/TutorialManager.js';
export class CampaignScene extends Phaser.Scene {
constructor() { super('CampaignScene'); }
@ -85,6 +86,100 @@ export class CampaignScene extends Phaser.Scene {
}).setOrigin(0.5);
this._makeBackButton();
this._showTutorial(missions, campaign, save);
}
_showTutorial(missions, campaign, save) {
if (TutorialManager.isStageComplete(save, TutorialManager.STAGES.CAMPAIGN_SELECT)) return;
if (this.campaignId !== 'campaign_raider') return;
const { width, height } = this.scale;
const DEPTH = 1000;
// Mission 1 position (index 0 in the layout grid)
const missionX = 300;
const missionY = 380;
const mission = missions[0];
if (!mission) return;
// Dark overlay
const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.75)
.setDepth(DEPTH)
.setInteractive();
// Duplicate Mission 1 box on top
const boxBg = this.add.rectangle(missionX, missionY, 300, 220, 0x1a3a5c)
.setDepth(DEPTH + 1)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(2, 0x4488ff);
// Mission name
const nameText = this.add.text(missionX, missionY - 70, mission.name, {
fontSize: '20px', color: '#ffffff',
wordWrap: { width: 270 }, align: 'center', fontFamily: 'Audiowide'
}).setOrigin(0.5).setDepth(DEPTH + 1);
// Mission number
const numText = this.add.text(missionX, missionY - 35, 'Mission 1', {
fontSize: '16px', color: '#888888', fontFamily: 'Audiowide'
}).setOrigin(0.5).setDepth(DEPTH + 1);
// Status
const statusText = this.add.text(missionX, missionY, 'Click to battle', {
fontSize: '16px', color: '#aaffaa', fontFamily: 'Audiowide'
}).setOrigin(0.5).setDepth(DEPTH + 1);
// Rewards
const goldText = this.add.text(missionX, missionY + 30, `+${mission.rewards.gold} gold`, {
fontSize: '14px', color: '#ffd700', fontFamily: 'Audiowide'
}).setOrigin(0.5).setDepth(DEPTH + 1);
const cardText = (mission.rewards.cards && mission.rewards.cards.length > 0)
? this.add.text(missionX, missionY + 55, 'Card reward available', {
fontSize: '14px', color: '#aaffaa', fontFamily: 'Audiowide'
}).setOrigin(0.5).setDepth(DEPTH + 1)
: null;
boxBg.on('pointerover', () => boxBg.setFillStyle(0x2a5a8c));
boxBg.on('pointerout', () => boxBg.setFillStyle(0x1a3a5c));
// Bobbing arrow
const arrow = this.add.text(missionX, missionY - 140, '\u25BC', {
fontSize: '48px', color: '#ffd700'
}).setOrigin(0.5).setDepth(DEPTH + 1);
this.tweens.add({
targets: arrow,
y: missionY - 125,
duration: 600,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut'
});
// Instructional text (to the right of the mission box)
const infoText = this.add.text(missionX + 220, missionY, [
'Select a mission to begin battle!',
'Further missions will unlock as',
'you complete each one.'
].join('\n'), {
fontSize: '20px',
color: '#ffffff',
fontFamily: 'Audiowide',
align: 'left',
lineSpacing: 6
}).setOrigin(0, 0.5).setDepth(DEPTH + 1);
// Click — complete Stage 2 and start mission
const tutorialElements = [overlay, boxBg, nameText, numText, statusText,
goldText, arrow, infoText];
if (cardText) tutorialElements.push(cardText);
boxBg.on('pointerdown', () => {
TutorialManager.completeStage(save, TutorialManager.STAGES.CAMPAIGN_SELECT);
tutorialElements.forEach(el => el.destroy());
this._startMission(mission, campaign);
});
}
_startMission(mission, campaign) {

View File

@ -1,3 +1,5 @@
import { TutorialManager } from '../managers/TutorialManager.js';
export class CampaignSelectScene extends Phaser.Scene {
constructor() { super('CampaignSelectScene'); }
@ -120,6 +122,102 @@ export class CampaignSelectScene extends Phaser.Scene {
});
this._makeBackButton();
this._showTutorial(campaigns, save);
}
_showTutorial(campaigns, save) {
if (TutorialManager.isStageComplete(save, TutorialManager.STAGES.CAMPAIGN_SELECT)) return;
const { width, height } = this.scale;
const DEPTH = 1000;
// Find Raider Uprising position (matches the layout logic in create)
const spacing = 340;
const startX = width / 2 - (campaigns.length - 1) * spacing / 2;
const raiderIdx = campaigns.findIndex(c => c.id === 'campaign_raider');
if (raiderIdx < 0) return;
const boxX = startX + raiderIdx * spacing;
const boxY = height / 2 + 20;
// Dark overlay
const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.75)
.setDepth(DEPTH)
.setInteractive();
// Duplicate campaign box on top
const boxBg = this.add.rectangle(boxX, boxY, 300, 400, 0x1a3a5c)
.setDepth(DEPTH + 1)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(2, 0x4488ff);
// Campaign name
const nameText = this.add.text(boxX, boxY - 150, 'Raider Uprising', {
fontSize: '22px', color: '#ffffff', fontStyle: 'bold',
wordWrap: { width: 270 }, align: 'center', fontFamily: 'Audiowide'
}).setOrigin(0.5).setDepth(DEPTH + 1);
// Faction label
const factionText = this.add.text(boxX, boxY - 115, 'RAIDER', {
fontSize: '14px', color: '#cc4444', fontFamily: 'Audiowide'
}).setOrigin(0.5).setDepth(DEPTH + 1);
// Level
const levelText = this.add.text(boxX, boxY - 85, 'Level 1', {
fontSize: '16px', color: '#aaaaaa', fontFamily: 'Audiowide'
}).setOrigin(0.5).setDepth(DEPTH + 1);
// Progress
const completedMissions = (save.campaignProgress?.completedMissions || [])
.filter(mId => campaigns[raiderIdx].missions.includes(mId));
const progressText = this.add.text(boxX, boxY - 20,
`${completedMissions.length} / ${campaigns[raiderIdx].missions.length} missions`, {
fontSize: '16px', color: '#aaffaa', fontFamily: 'Audiowide'
}).setOrigin(0.5).setDepth(DEPTH + 1);
const clickText = this.add.text(boxX, boxY + 30, 'Click to play', {
fontSize: '14px', color: '#88aaff', fontFamily: 'Audiowide'
}).setOrigin(0.5).setDepth(DEPTH + 1);
boxBg.on('pointerover', () => boxBg.setFillStyle(0x2a5a8c));
boxBg.on('pointerout', () => boxBg.setFillStyle(0x1a3a5c));
// Bobbing arrow
const arrow = this.add.text(boxX, boxY - 220, '\u25BC', {
fontSize: '48px', color: '#ffd700'
}).setOrigin(0.5).setDepth(DEPTH + 1);
this.tweens.add({
targets: arrow,
y: boxY - 205,
duration: 600,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut'
});
// Instructional text
const infoText = this.add.text(boxX, boxY + 140, [
'Select the campaign you wish to play.',
'Other campaigns will unlock as you',
'defeat each one. Let\'s begin with',
'Raider Uprising!'
].join('\n'), {
fontSize: '20px',
color: '#ffffff',
fontFamily: 'Audiowide',
align: 'center',
lineSpacing: 6
}).setOrigin(0.5, 0).setDepth(DEPTH + 1);
// Click — navigate to campaign (stage completes after mission select)
const tutorialElements = [overlay, boxBg, nameText, factionText, levelText,
progressText, clickText, arrow, infoText];
boxBg.on('pointerdown', () => {
this.sound.play('sfx_menu_select', { volume: 0.7 });
tutorialElements.forEach(el => el.destroy());
this.scene.start('CampaignScene', { campaignId: 'campaign_raider' });
});
}
_makeBackButton() {

View File

@ -1,4 +1,5 @@
import { SaveManager } from '../managers/SaveManager.js';
import { TutorialManager } from '../managers/TutorialManager.js';
export class MainMenuScene extends Phaser.Scene {
constructor() { super('MainMenuScene'); }
@ -132,6 +133,72 @@ export class MainMenuScene extends Phaser.Scene {
// Save / Restore — subtle utility buttons, upper-left
this._makeSaveRestoreButtons();
// Tutorial overlay (if applicable)
this._showTutorial();
}
_showTutorial() {
const save = this.registry.get('save');
if (TutorialManager.isStageComplete(save, TutorialManager.STAGES.CAMPAIGN_INTRO)) return;
const { width, height } = this.scale;
const DEPTH = 1000;
// Dark overlay — blocks all clicks beneath
const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.75)
.setDepth(DEPTH)
.setInteractive();
// Duplicate Campaign button on top of overlay
const btnX = width / 2;
const btnY = 310;
const btnBg = this.add.rectangle(btnX, btnY, 400, 65, 0x1a3a5c)
.setDepth(DEPTH + 1)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(2, 0x4488ff);
const btnText = this.add.text(btnX, btnY, 'Campaign', {
fontSize: '28px', color: '#ffffff', fontFamily: 'Audiowide'
}).setOrigin(0.5).setDepth(DEPTH + 1);
btnBg.on('pointerover', () => { this.sound.play('sfx_menu_hover', { volume: 0.5 }); btnBg.setFillStyle(0x2a5a8c); });
btnBg.on('pointerout', () => btnBg.setFillStyle(0x1a3a5c));
// Bobbing arrow above button
const arrow = this.add.text(btnX, btnY - 60, '\u25BC', {
fontSize: '48px', color: '#ffd700'
}).setOrigin(0.5).setDepth(DEPTH + 1);
this.tweens.add({
targets: arrow,
y: btnY - 45,
duration: 600,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut'
});
// Instructional text
const infoText = this.add.text(btnX, btnY + 55, [
'You are starting the game as Warlord Voss.',
'Your first mission: battle The Raiders!',
'Click Campaign to begin.'
].join('\n'), {
fontSize: '22px',
color: '#ffffff',
fontFamily: 'Audiowide',
align: 'center',
lineSpacing: 8
}).setOrigin(0.5, 0).setDepth(DEPTH + 1);
// Click — complete stage, clean up, navigate
const tutorialElements = [overlay, btnBg, btnText, arrow, infoText];
btnBg.on('pointerdown', () => {
this.sound.play('sfx_menu_select', { volume: 0.7 });
TutorialManager.completeStage(save, TutorialManager.STAGES.CAMPAIGN_INTRO);
tutorialElements.forEach(el => el.destroy());
this.scene.start('CampaignSelectScene');
});
}
_makeButton(x, y, label, callback) {