tyrants-edge/src/scenes/CampaignScene.js

785 lines
32 KiB
JavaScript

import { SaveManager } from '../managers/SaveManager.js';
import { TutorialManager } from '../managers/TutorialManager.js';
// ── Layout Constants ────────────────────────────────────────────────────────────
const LEFT_PANEL_W = 420;
const DIVIDER_X = 420;
const MISSION_BTN_X = 210;
const MISSION_BTN_W = 360;
const MISSION_BTN_H = 90;
const MISSION_BTN_GAP = 10;
const MISSION_LIST_TOP = 130;
const TERM_PAD = 30;
const TERM_X = LEFT_PANEL_W + TERM_PAD;
const TERM_Y = 120;
const TERM_W = 1920 - LEFT_PANEL_W - TERM_PAD * 2;
const TERM_H = 940;
const VIDEO_W = 640;
const VIDEO_H = 360;
// ── Amber CRT Palette ───────────────────────────────────────────────────────────
const AMBER = '#ffaa33';
const AMBER_DIM = '#cc8822';
const AMBER_BRIGHT = '#ff8800';
const AMBER_HEX = 0xffaa33;
const AMBER_DARK_HEX = 0x332200;
const AMBER_BG_HEX = 0x1a1100;
const TERM_BG_HEX = 0x0a0800;
const FACTION_HEX = {
raider: 0xcc4444,
bloodthirsty: 0xcc4488,
xeno: 0x44cc66,
righteous: 0xcccc44,
imperial: 0x4466cc
};
const FACTION_COLORS = {
raider: '#cc4444',
bloodthirsty: '#cc4488',
xeno: '#44cc66',
righteous: '#cccc44',
imperial: '#4466cc'
};
export class CampaignScene extends Phaser.Scene {
constructor() { super('CampaignScene'); }
init(data) {
this.campaignId = data.campaignId || 'campaign_raider';
}
preload() {
this.load.video('corridors', 'assets/video/corridors.mp4');
}
create() {
const allMissions = this.registry.get('missions');
const campaigns = this.registry.get('campaigns');
const save = this.registry.get('save');
const { width, height } = this.scale;
const campaign = campaigns.find(c => c.id === this.campaignId);
this._campaign = campaign;
this._missions = allMissions.filter(m => m.campaignId === this.campaignId);
this._completed = save.campaignProgress.completedMissions;
this._save = save;
this._typingEvents = [];
this._terminalTextObjects = [];
this._terminalLines = [];
this._currentTermLine = 0;
this._selectedMission = null;
this._selectedIndex = -1;
this._missionButtons = [];
this._briefingElements = [];
// Check if campaign is fully complete
const allDone = this._missions.length > 0 &&
this._missions.every(m => this._completed.includes(m.id));
if (allDone && campaign) {
this._checkCampaignRewards(campaign, save, campaigns);
}
// ── Video background ──────────────────────────────────────────────────────
const bg = this.add.video(width / 2, height / 2, 'corridors');
bg.setMute(true);
bg.on('created', () => {
if (!bg.scene) return;
bg.setDisplaySize(width, height);
const t = this.registry.get('corridors_time');
if (t > 0) bg.setCurrentTime(t);
});
bg.play(true);
this.events.on('shutdown', () => {
if (bg.scene) this.registry.set('corridors_time', bg.getCurrentTime());
this._clearTerminal();
});
// Dark overlay
this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.55);
// Scanlines
const scanlines = this.add.graphics();
for (let sy = 0; sy < height; sy += 3) {
scanlines.fillStyle(0x000000, 0.22);
scanlines.fillRect(0, sy, width, 1);
}
// ── Header ────────────────────────────────────────────────────────────────
this.add.text(width / 2, 45, campaign ? campaign.name : 'Campaign', {
fontSize: '42px', color: '#d4af37', fontFamily: 'RaiderCrusader'
}).setOrigin(0.5);
if (campaign) {
const fColor = FACTION_COLORS[campaign.faction] || '#888888';
this.add.text(width / 2, 90, `Level ${campaign.level}${campaign.faction.toUpperCase()}`, {
fontSize: '18px', color: fColor, fontFamily: 'Audiowide'
}).setOrigin(0.5);
}
// ── Faction-colored divider ───────────────────────────────────────────────
const divColor = FACTION_HEX[campaign?.faction] || 0x444444;
const divider = this.add.graphics();
divider.lineStyle(2, divColor, 0.6);
divider.lineBetween(DIVIDER_X, 115, DIVIDER_X, height - 10);
// ── Build panels ──────────────────────────────────────────────────────────
this._buildMissionList();
this._buildBriefingTerminal();
this._makeBackButton();
this._showTutorial();
}
// ════════════════════════════════════════════════════════════════════════════
// ── LEFT PANEL: Mission List ───────────────────────────────────────────────
// ════════════════════════════════════════════════════════════════════════════
_buildMissionList() {
const missions = this._missions;
const completed = this._completed;
missions.forEach((mission, i) => {
const isUnlocked = !mission.unlockCondition || completed.includes(mission.unlockCondition);
const isDone = completed.includes(mission.id);
const x = MISSION_BTN_X;
const y = MISSION_LIST_TOP + i * (MISSION_BTN_H + MISSION_BTN_GAP);
// Button background
const bgColor = isDone ? 0x1a3322 : isUnlocked ? 0x1a2a3c : 0x222222;
const node = this.add.rectangle(x, y, MISSION_BTN_W, MISSION_BTN_H, bgColor)
.setStrokeStyle(1, isDone ? 0x336633 : isUnlocked ? 0x334466 : 0x333333);
// Faction accent stripe (left edge)
const factionColor = FACTION_HEX[this._campaign?.faction] || 0x444444;
const stripe = this.add.rectangle(
x - MISSION_BTN_W / 2 + 2, y,
4, MISSION_BTN_H,
isDone ? factionColor : isUnlocked ? 0x4488ff : 0x333333
);
// Mission number (small, dim)
const numText = this.add.text(x - MISSION_BTN_W / 2 + 16, y - 18, `MISSION ${i + 1}`, {
fontSize: '12px', color: '#666666', fontFamily: 'Audiowide'
});
// Mission name
const nameColor = isDone ? '#aaffaa' : isUnlocked ? '#ffffff' : '#555555';
const nameText = this.add.text(x - MISSION_BTN_W / 2 + 16, y + 2, mission.name, {
fontSize: '16px', color: nameColor, fontFamily: 'Audiowide',
wordWrap: { width: MISSION_BTN_W - 60 }
});
// Status marker (right side)
const marker = isDone ? '[\u2713]' : !isUnlocked ? '[\ud83d\udd12]' : '';
const markerColor = isDone ? '#44ff44' : '#555555';
if (marker) {
this.add.text(x + MISSION_BTN_W / 2 - 16, y, marker, {
fontSize: '16px', color: markerColor, fontFamily: 'Audiowide'
}).setOrigin(1, 0.5);
}
// Interactivity
if (isUnlocked) {
node.setInteractive({ useHandCursor: true });
node.on('pointerover', () => {
if (this._selectedIndex !== i) {
node.setFillStyle(isDone ? 0x2a4433 : 0x2a4466);
}
});
node.on('pointerout', () => {
if (this._selectedIndex !== i) {
node.setFillStyle(bgColor);
}
});
node.on('pointerdown', () => {
this.sound.play('sfx_menu_select', { volume: 0.7 });
this._selectMission(mission, i);
});
}
this._missionButtons.push({ node, bgColor, isDone, isUnlocked, index: i });
});
}
// ════════════════════════════════════════════════════════════════════════════
// ── RIGHT PANEL: Tactical Operations Terminal ──────────────────────────────
// ════════════════════════════════════════════════════════════════════════════
_buildBriefingTerminal() {
this._termContainer = this.add.container(0, 0);
const tx = TERM_X;
const ty = TERM_Y;
const tw = TERM_W;
const th = TERM_H;
this._termBoundsX = tx;
this._termBoundsY = ty;
this._termBoundsW = tw;
this._termBoundsH = th;
// ── Outer border ──────────────────────────────────────────────────────────
const termBg = this.add.rectangle(tx + tw / 2, ty + th / 2, tw, th, TERM_BG_HEX, 0.95)
.setStrokeStyle(2, AMBER_DARK_HEX);
this._termContainer.add(termBg);
// Inner border (shadow effect)
const innerBorder = this.add.rectangle(tx + tw / 2, ty + th / 2, tw - 8, th - 8)
.setStrokeStyle(1, AMBER_BG_HEX)
.setFillStyle(0x000000, 0);
this._termContainer.add(innerBorder);
// ── Terminal header bar ───────────────────────────────────────────────────
const headerBar = this.add.rectangle(tx + tw / 2, ty + 18, tw - 12, 28, AMBER_DARK_HEX, 0.3);
this._termContainer.add(headerBar);
const headerText = this.add.text(tx + 16, ty + 10, '\u2588 TACTICAL OPERATIONS TERMINAL v2.7', {
fontSize: '14px', color: AMBER_DIM, fontFamily: 'monospace'
});
this._termContainer.add(headerText);
// Status indicator (right side of header)
this._statusIndicator = this.add.text(tx + tw - 16, ty + 10, '[\u25CF ONLINE]', {
fontSize: '14px', color: '#44aa33', fontFamily: 'monospace'
}).setOrigin(1, 0);
this._termContainer.add(this._statusIndicator);
// ── Tactical grid overlay ─────────────────────────────────────────────────
const grid = this.add.graphics();
grid.lineStyle(1, 0x331100, 0.06);
for (let gx = tx; gx < tx + tw; gx += 40) {
grid.lineBetween(gx, ty, gx, ty + th);
}
for (let gy = ty; gy < ty + th; gy += 40) {
grid.lineBetween(tx, gy, tx + tw, gy);
}
this._termContainer.add(grid);
// ── Scanlines ─────────────────────────────────────────────────────────────
const scanlines = this.add.graphics();
scanlines.fillStyle(0x000000, 0.1);
for (let y = ty; y < ty + th; y += 3) {
scanlines.fillRect(tx, y, tw, 1);
}
this._termContainer.add(scanlines);
// ── Vignette ──────────────────────────────────────────────────────────────
this._buildVignette(tx, ty, tw, th);
// ── Flicker effect ────────────────────────────────────────────────────────
this.time.addEvent({
delay: 100,
loop: true,
callback: () => {
if (this._termContainer) {
this._termContainer.alpha = 0.96 + Math.random() * 0.04;
}
}
});
// ── Status indicator blink ────────────────────────────────────────────────
this.time.addEvent({
delay: 800,
loop: true,
callback: () => {
if (this._statusIndicator) {
this._statusIndicator.setVisible(!this._statusIndicator.visible);
}
}
});
// ── Text content area ─────────────────────────────────────────────────────
this._termTextX = tx + 20;
this._termTextY = ty + 44;
this._termMaxW = tw - 40;
this._termLineH = 22;
// ── Cursor ────────────────────────────────────────────────────────────────
this._cursor = this.add.text(this._termTextX, this._termTextY, '\u2588', {
fontSize: '18px', color: AMBER, fontFamily: 'monospace'
});
this._termContainer.add(this._cursor);
this._cursor.setVisible(false);
this._cursorTimer = this.time.addEvent({
delay: 400,
loop: true,
callback: () => {
if (this._cursor) this._cursor.setVisible(!this._cursor.visible);
}
});
// ── Boot sequence ─────────────────────────────────────────────────────────
const campaignName = this._campaign ? this._campaign.name.toUpperCase() : 'UNKNOWN';
this._typeLines([
`> TACTICAL OPERATIONS TERMINAL v2.7`,
`> INITIALIZING SECURE UPLINK...`,
`> CAMPAIGN: ${campaignName}`,
`> FACTION THREAT LEVEL: ${this._campaign?.faction?.toUpperCase() || 'UNKNOWN'}`,
'',
`> ${this._missions.length} MISSIONS LOADED.`,
`> ${this._completed.filter(id => this._missions.some(m => m.id === id)).length} / ${this._missions.length} MISSIONS COMPLETE.`,
'',
'> STATUS: AWAITING MISSION SELECTION...',
'> SELECT A MISSION TO VIEW BRIEFING.'
], 0);
}
_buildVignette(tx, ty, tw, th) {
const vig = this.add.graphics();
// Top
for (let i = 0; i < 30; i++) {
vig.fillStyle(0x000000, 0.3 * (1 - i / 30));
vig.fillRect(tx, ty + i, tw, 1);
}
// Bottom
for (let i = 0; i < 30; i++) {
vig.fillStyle(0x000000, 0.3 * (1 - i / 30));
vig.fillRect(tx, ty + th - i, tw, 1);
}
// Left
for (let i = 0; i < 20; i++) {
vig.fillStyle(0x000000, 0.25 * (1 - i / 20));
vig.fillRect(tx + i, ty, 1, th);
}
// Right
for (let i = 0; i < 20; i++) {
vig.fillStyle(0x000000, 0.25 * (1 - i / 20));
vig.fillRect(tx + tw - i, ty, 1, th);
}
this._termContainer.add(vig);
}
// ════════════════════════════════════════════════════════════════════════════
// ── Mission Selection ──────────────────────────────────────────────────────
// ════════════════════════════════════════════════════════════════════════════
_selectMission(mission, index) {
// Update button highlights
for (const btn of this._missionButtons) {
if (btn.index === index) {
btn.node.setStrokeStyle(2, AMBER_HEX);
btn.node.setFillStyle(btn.isDone ? 0x2a4433 : 0x2a3a4c);
} else {
btn.node.setStrokeStyle(1, btn.isDone ? 0x336633 : btn.isUnlocked ? 0x334466 : 0x333333);
btn.node.setFillStyle(btn.bgColor);
}
}
this._selectedMission = mission;
this._selectedIndex = index;
// Destroy previous briefing elements
for (const el of this._briefingElements) {
if (el && el.scene) el.destroy();
}
this._briefingElements = [];
this._renderMissionBriefing(mission, index);
}
// ════════════════════════════════════════════════════════════════════════════
// ── Mission Briefing Renderer ──────────────────────────────────────────────
// ════════════════════════════════════════════════════════════════════════════
_renderMissionBriefing(mission, index) {
this._clearTerminal();
const tx = this._termBoundsX;
const ty = this._termBoundsY;
const tw = this._termBoundsW;
const th = this._termBoundsH;
// ── Mission Title ─────────────────────────────────────────────────────────
const titleStr = `[ MISSION ${index + 1}: ${mission.name.toUpperCase()} ]`;
const title = this.add.text(tx + tw / 2, ty + 52, titleStr, {
fontSize: '24px', color: AMBER_BRIGHT, fontFamily: 'monospace', fontStyle: 'bold'
}).setOrigin(0.5);
this._termContainer.add(title);
this._briefingElements.push(title);
// ── Video Placeholder ─────────────────────────────────────────────────────
const vidX = tx + tw / 2;
const vidY = ty + 100 + VIDEO_H / 2;
// Dark background
const vidBg = this.add.rectangle(vidX, vidY, VIDEO_W, VIDEO_H, 0x0a0a0a)
.setStrokeStyle(2, FACTION_HEX[this._campaign?.faction] || 0x444444);
this._termContainer.add(vidBg);
this._briefingElements.push(vidBg);
// Corner bracket decorations (L-shaped amber lines)
const bracketG = this.add.graphics();
const arm = 24;
const bPad = 4;
const left = vidX - VIDEO_W / 2 - bPad;
const right = vidX + VIDEO_W / 2 + bPad;
const top = vidY - VIDEO_H / 2 - bPad;
const bottom = vidY + VIDEO_H / 2 + bPad;
bracketG.lineStyle(2, AMBER_HEX, 0.7);
// Top-left
bracketG.lineBetween(left, top, left + arm, top);
bracketG.lineBetween(left, top, left, top + arm);
// Top-right
bracketG.lineBetween(right, top, right - arm, top);
bracketG.lineBetween(right, top, right, top + arm);
// Bottom-left
bracketG.lineBetween(left, bottom, left + arm, bottom);
bracketG.lineBetween(left, bottom, left, bottom - arm);
// Bottom-right
bracketG.lineBetween(right, bottom, right - arm, bottom);
bracketG.lineBetween(right, bottom, right, bottom - arm);
this._termContainer.add(bracketG);
this._briefingElements.push(bracketG);
// Placeholder text
const offlineText = this.add.text(vidX, vidY - 15, 'SURVEILLANCE FEED OFFLINE', {
fontSize: '18px', color: AMBER_DIM, fontFamily: 'monospace'
}).setOrigin(0.5);
this._termContainer.add(offlineText);
this._briefingElements.push(offlineText);
const fileText = this.add.text(vidX, vidY + 15, `Expected: ${mission.id}.mp4`, {
fontSize: '13px', color: '#665522', fontFamily: 'monospace'
}).setOrigin(0.5);
this._termContainer.add(fileText);
this._briefingElements.push(fileText);
// Attempt to load actual video
const vidKey = `vid_mission_${mission.id}`;
try {
if (this.cache.video.has(mission.id)) {
// If video was previously loaded, show it
offlineText.setVisible(false);
fileText.setVisible(false);
const vid = this.add.video(vidX, vidY, mission.id);
vid.setMute(true);
vid.on('created', () => { if (vid.scene) vid.setDisplaySize(VIDEO_W, VIDEO_H); });
vid.play(true);
this._termContainer.add(vid);
this._briefingElements.push(vid);
}
} catch (e) { /* Video not available — placeholder stays */ }
// ── DEPLOY Button ─────────────────────────────────────────────────────────
const deployX = tx + tw / 2;
const deployY = ty + th - 50;
const deployBg = this.add.rectangle(deployX, deployY, 280, 55, 0x1a1100)
.setStrokeStyle(2, AMBER_HEX)
.setInteractive({ useHandCursor: true });
this._briefingElements.push(deployBg);
const deployText = this.add.text(deployX, deployY, '>> DEPLOY <<', {
fontSize: '22px', color: AMBER, fontFamily: 'monospace', fontStyle: 'bold'
}).setOrigin(0.5);
this._briefingElements.push(deployText);
// Deploy button glow pulse
this.tweens.add({
targets: deployBg,
alpha: { from: 1, to: 0.7 },
duration: 1200,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut'
});
deployBg.on('pointerover', () => {
deployBg.setFillStyle(0x332200);
deployBg.setStrokeStyle(3, 0xffcc44);
});
deployBg.on('pointerout', () => {
deployBg.setFillStyle(0x1a1100);
deployBg.setStrokeStyle(2, AMBER_HEX);
});
deployBg.on('pointerdown', () => {
this._startMission(this._selectedMission, this._campaign);
});
// ── Typed Briefing Text ───────────────────────────────────────────────────
// Position typing below the video
this._termTextY = vidY + VIDEO_H / 2 + 24;
this._currentTermLine = 0;
// Resolve card reward names
const cardManager = this.registry.get('cardManager');
const rewardCards = (mission.rewards.cards || []).map(id => {
const card = cardManager?.getCard?.(id);
return card ? card.name : id;
});
const lines = [
`> \u2500\u2500\u2500 SITUATION BRIEFING \u2500\u2500\u2500`,
`> ${mission.description}`,
'',
`> \u2500\u2500\u2500 INTELLIGENCE REPORT \u2500\u2500\u2500`,
`> ${mission.lore}`,
'',
`> \u2500\u2500\u2500 REWARDS MANIFEST \u2500\u2500\u2500`,
`> GOLD: +${mission.rewards.gold} credits`
];
for (const cardName of rewardCards) {
lines.push(`> CARD: ${cardName}`);
}
lines.push('');
lines.push(`> \u2500\u2500\u2500 END TRANSMISSION \u2500\u2500\u2500`);
this._typeLines(lines, 0);
}
// ════════════════════════════════════════════════════════════════════════════
// ── Terminal Typing System (ported from CollectionScene, amber palette) ────
// ════════════════════════════════════════════════════════════════════════════
_clearTerminal() {
for (const evt of this._typingEvents) {
if (evt && evt.remove) evt.remove(false);
}
this._typingEvents = [];
this._terminalLines = [];
for (const t of this._terminalTextObjects) {
if (t && t.scene) t.destroy();
}
this._terminalTextObjects = [];
if (this._cursor) {
this._cursor.setPosition(this._termTextX, this._termTextY);
this._cursor.setVisible(false);
}
this._currentTermLine = 0;
}
_typeLines(lines, lineIdx) {
if (lineIdx >= lines.length) return;
const line = lines[lineIdx];
if (line === '') {
this._currentTermLine = (this._currentTermLine || 0) + 1;
const evt = this.time.delayedCall(20, () => this._typeLines(lines, lineIdx + 1));
this._typingEvents.push(evt);
return;
}
this._currentTermLine = this._currentTermLine || 0;
const y = this._termTextY + this._currentTermLine * this._termLineH;
// Check overflow
const maxY = this._termBoundsY + this._termBoundsH - 80; // Leave room for deploy button
if (y + this._termLineH > maxY) {
this._scrollTerminal();
this._typeLines(lines, lineIdx);
return;
}
const textObj = this.add.text(this._termTextX, y, '', {
fontSize: '16px', color: AMBER, fontFamily: 'monospace',
wordWrap: { width: this._termMaxW }
});
this._termContainer.add(textObj);
this._terminalTextObjects.push(textObj);
let charIdx = 0;
const typeChar = () => {
charIdx = Math.min(charIdx + 5, line.length);
textObj.setText(line.substring(0, charIdx));
if (charIdx % 15 === 0) this._playKeyclick();
this._updateCursorPos(textObj);
if (charIdx >= line.length) {
const wrappedCount = Math.max(1, Math.round(textObj.height / this._termLineH));
this._currentTermLine += wrappedCount;
const evt = this.time.delayedCall(24, () => this._typeLines(lines, lineIdx + 1));
this._typingEvents.push(evt);
return;
}
const evt = this.time.delayedCall(4, typeChar);
this._typingEvents.push(evt);
};
if (this._cursor) this._cursor.setVisible(true);
typeChar();
}
_scrollTerminal() {
if (this._terminalTextObjects.length > 0) {
const oldest = this._terminalTextObjects.shift();
if (oldest && oldest.scene) oldest.destroy();
}
for (const t of this._terminalTextObjects) {
t.y -= this._termLineH;
}
this._currentTermLine--;
}
_updateCursorPos(textObj) {
if (!this._cursor || !textObj) return;
this._cursor.setPosition(textObj.x + textObj.width + 2, textObj.y);
this._cursor.setVisible(true);
}
_playKeyclick() {
try {
const ctx = this.sound.context;
if (!ctx) return;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 800 + Math.random() * 200;
gain.gain.value = 0.04;
osc.start(ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.03);
osc.stop(ctx.currentTime + 0.03);
} catch (e) { /* WebAudio not available */ }
}
// ════════════════════════════════════════════════════════════════════════════
// ── Tutorial ───────────────────────────────────────────────────────────────
// ════════════════════════════════════════════════════════════════════════════
_showTutorial() {
const save = this._save;
if (TutorialManager.isStageComplete(save, TutorialManager.STAGES.CAMPAIGN_SELECT)) return;
if (this.campaignId !== 'campaign_raider') return;
const missions = this._missions;
const { width, height } = this.scale;
const DEPTH = 1000;
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();
// Mission 1 button position (matches _buildMissionList layout)
const btnX = MISSION_BTN_X;
const btnY = MISSION_LIST_TOP;
// Duplicate Mission 1 button
const boxBg = this.add.rectangle(btnX, btnY, MISSION_BTN_W, MISSION_BTN_H, 0x1a2a3c)
.setDepth(DEPTH + 1)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(2, 0x4488ff);
const numText = this.add.text(btnX - MISSION_BTN_W / 2 + 16, btnY - 18, 'MISSION 1', {
fontSize: '12px', color: '#666666', fontFamily: 'Audiowide'
}).setDepth(DEPTH + 1);
const nameText = this.add.text(btnX - MISSION_BTN_W / 2 + 16, btnY + 2, mission.name, {
fontSize: '16px', color: '#ffffff', fontFamily: 'Audiowide',
wordWrap: { width: MISSION_BTN_W - 60 }
}).setDepth(DEPTH + 1);
boxBg.on('pointerover', () => boxBg.setFillStyle(0x2a4466));
boxBg.on('pointerout', () => boxBg.setFillStyle(0x1a2a3c));
// Bobbing arrow
const arrow = this.add.text(btnX, btnY - 70, '\u25BC', {
fontSize: '48px', color: '#ffd700'
}).setOrigin(0.5).setDepth(DEPTH + 1);
this.tweens.add({
targets: arrow,
y: btnY - 55,
duration: 600,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut'
});
// Instructional text (to the right)
const infoText = this.add.text(btnX + MISSION_BTN_W / 2 + 30, btnY, [
'Select a mission to view its briefing!',
'Then click DEPLOY to begin battle.'
].join('\n'), {
fontSize: '20px',
color: '#ffffff',
fontFamily: 'Audiowide',
align: 'left',
lineSpacing: 6
}).setOrigin(0, 0.5).setDepth(DEPTH + 1);
const tutorialElements = [overlay, boxBg, numText, nameText, arrow, infoText];
boxBg.on('pointerdown', () => {
this.sound.play('sfx_menu_select', { volume: 0.7 });
TutorialManager.completeStage(save, TutorialManager.STAGES.CAMPAIGN_SELECT);
tutorialElements.forEach(el => el.destroy());
this._selectMission(mission, 0);
});
}
// ════════════════════════════════════════════════════════════════════════════
// ── Existing Methods (preserved) ───────────────────────────────────────────
// ════════════════════════════════════════════════════════════════════════════
_startMission(mission, campaign) {
const save = this.registry.get('save');
if (!save.decks || save.decks.length === 0) {
// Show error in terminal area
this._clearTerminal();
this._typeLines([
'> \u2500\u2500\u2500 ERROR \u2500\u2500\u2500',
'> NO DECK DETECTED.',
'> CREATE A DECK IN DECK BUILDER FIRST.',
'> DEPLOY ABORTED.'
], 0);
return;
}
this.sound.play('sfx_menu_select', { volume: 0.7 });
this.scene.start('BattleScene', {
mission,
deck: save.decks[0],
playerLevel: save.level || 1,
enemyLevel: save.level || 1,
campaignId: this.campaignId
});
}
_checkCampaignRewards(campaign, save, allCampaigns) {
const rewardKey = `campaignReward_${campaign.id}`;
if (campaign.starterReward?.cards?.length > 0 && !save[rewardKey]) {
for (const cardId of campaign.starterReward.cards) {
save.collection[cardId] = (save.collection[cardId] || 0) + 1;
}
save[rewardKey] = true;
if (campaign.level >= save.level && save.level < 5) {
save.level = Math.min(5, campaign.level + 1);
}
const nextCampaign = allCampaigns.find(c => c.unlockCondition === campaign.id);
if (nextCampaign && !save.unlockedCampaigns.includes(nextCampaign.id)) {
save.unlockedCampaigns.push(nextCampaign.id);
}
SaveManager.save(save);
}
}
_makeBackButton() {
const bg = this.add.rectangle(80, 45, 160, 50, 0x333333)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(1, 0x888888);
this.add.text(80, 45, 'Back', { fontSize: '18px', color: '#ffffff', fontFamily: 'Audiowide' }).setOrigin(0.5);
bg.on('pointerdown', () => {
this.sound.play('sfx_menu_select', { volume: 0.7 });
this.scene.start('CampaignSelectScene');
});
}
}