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:
parent
4eb4d7f278
commit
5a67484240
Binary file not shown.
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue