/** * ============================================================================ * BASE CHARACTER SELECT SCENE - Player character/avatar selection * ============================================================================ * * Displays a grid of selectable characters/avatars/personas. * Designed for UI-heavy games: card battlers (choose deck master), * visual novels (choose protagonist), quiz battles (choose avatar), etc. * * Stores the selected character data in Phaser registry for retrieval * by subsequent scenes. * * LIFECYCLE (Template Method Pattern): * create() calls in order: * 1. createBackground() -- HOOK: set background * 2. createTitle() -- HOOK: display title text * 3. getSelectableCharacters() -- HOOK (REQUIRED): define characters * 4. createCharacterGrid() -- builds interactive character cards * 5. createControlHints() -- HOOK: display control hints * 6. createCustomUI() -- HOOK: add extra UI elements * 7. setupInputs() -- keyboard/mouse bindings * 8. playBackgroundMusic() -- uses getBackgroundMusicKey() * * HOOK METHODS: * - getSelectableCharacters(): Return array of character configs (REQUIRED) * - createBackground(): Custom background * - createTitle(): Custom title display * - createControlHints(): Custom control hints * - createCustomUI(): Add extra UI elements (decorations, etc.) * - onSelectionChanged(character, index): Highlight changed * - onCharacterSelected(character): Character confirmed * - shouldAutoTransition(): Return false for PVP sequential pick mode * - triggerTransition(): Call manually after all picks in PVP mode * - getNextSceneKey(): Scene to transition to after selection * - getBackgroundMusicKey(): Audio key for background music * - getGridConfig(): Customize grid layout (columns, card size, spacing) * - createCharacterCard(container, character, cardW, cardH): Custom card rendering * - playSelectSound(): SFX when highlight changes * - playConfirmSound(): SFX when character is confirmed * * REGISTRY: 'selectedCharacter' stores the full SelectableCharacter object. * Retrieve in other scenes: this.registry.get('selectedCharacter') * * PVP SEQUENTIAL PICK PATTERN: * For games where two players each pick a character: * 1. Override shouldAutoTransition() to return false. * 2. Track whose turn it is (P1 or P2) in your subclass. * 3. In onCharacterSelected(): * - P1 turn: store P1 choice (registry.set('p1Character', char)), * call this.resetForNextPick(index) to gray out P1's card, * update titleText ("Player 2, choose!"). * - P2 turn: store P2 choice, call this.triggerTransition(). * * PROTECTED PROPERTIES (available to subclasses): * - this.characters: SelectableCharacter[] -- character data * - this.selectedIndex: number -- current highlight index * - this.isConfirming: boolean -- lock during transition * - this.titleText: Phaser.GameObjects.Text -- title (update for PVP) * - this.cardContainers: Container[] -- per-card containers * - this.cardBackgrounds: Rectangle[] -- per-card backgrounds * - this.disabledIndices: Set -- cards disabled for PVP * * Usage: * export class HeroSelectScene extends BaseCharacterSelectScene { * constructor() { super({ key: 'HeroSelectScene' }); } * * protected getSelectableCharacters(): SelectableCharacter[] { * return [ * { id: 'mage', name: 'Dark Mage', description: 'Master of arcane spells', * imageKey: 'mage_portrait', stats: { hp: 80, atk: 25, def: 10 } }, * { id: 'knight', name: 'Holy Knight', description: 'Armored defender', * imageKey: 'knight_portrait', stats: { hp: 120, atk: 15, def: 25 } }, * ]; * } * * protected getNextSceneKey(): string { * return 'Chapter1Scene'; * } * } */ import Phaser from 'phaser'; // ============================================================================ // TYPES & INTERFACES // ============================================================================ /** Character data for the selection screen */ export interface SelectableCharacter { /** Unique character identifier */ id: string; /** Display name */ name: string; /** Description text */ description?: string; /** Preview image texture key */ imageKey?: string; /** Character stats for display (e.g., { hp: 100, atk: 15, def: 10 }) */ stats?: Record; /** Extra metadata the game scene can read later */ metadata?: Record; } /** Grid layout configuration */ export interface GridConfig { /** Max columns in the grid (default: 4) */ maxColumns: number; /** Card width in pixels (default: 180) */ cardWidth: number; /** Card height in pixels (default: 240) */ cardHeight: number; /** Horizontal gap between cards (default: 20) */ gapX: number; /** Vertical gap between cards (default: 20) */ gapY: number; } // ============================================================================ // BASE CLASS // ============================================================================ export class BaseCharacterSelectScene extends Phaser.Scene { // -- State -- protected characters: SelectableCharacter[] = []; protected selectedIndex: number = 0; protected isConfirming: boolean = false; /** Indices of disabled cards (PVP: already-picked characters). */ protected disabledIndices: Set = new Set(); // -- UI element tracking (protected for PVP subclass access) -- protected titleText?: Phaser.GameObjects.Text; protected cardContainers: Phaser.GameObjects.Container[] = []; protected cardBackgrounds: Phaser.GameObjects.Rectangle[] = []; private highlightGraphics?: Phaser.GameObjects.Graphics; // -- Scroll state (for many characters) -- private scrollOffset: number = 0; private gridConfig!: GridConfig; // -- Audio -- protected backgroundMusic?: Phaser.Sound.BaseSound; // ============================================================================ // CONSTRUCTOR // IMPORTANT: Subclasses pass their own key via super({ key: 'MyScene' }). // The "config ??" fallback ensures the base class still works standalone. // Do NOT remove the "config ??" part -- it enables subclass scene key override. // ============================================================================ constructor(config?: Phaser.Types.Scenes.SettingsConfig) { super(config ?? { key: 'CharacterSelectScene' }); } // ============================================================================ // LIFECYCLE // ============================================================================ create(): void { // Reset mutable state (Phaser reuses scene instances) this.selectedIndex = 0; this.isConfirming = false; this.disabledIndices = new Set(); this.titleText = undefined; this.cardContainers = []; this.cardBackgrounds = []; this.highlightGraphics = undefined; this.scrollOffset = 0; // Stop music before clearing the reference to prevent orphaned playback if (this.backgroundMusic?.isPlaying) { this.backgroundMusic.stop(); } this.backgroundMusic = undefined; this.gridConfig = this.getGridConfig(); this.characters = this.getSelectableCharacters(); this.createBackground(); this.createTitle(); this.buildCharacterGrid(); this.createControlHints(); this.createCustomUI(); this.setupInputs(); this.playBackgroundMusic(); // Initial highlight this.updateHighlight(); } update(time: number, delta: number): void { this.onUpdate(time, delta); } // ============================================================================ // HOOKS - REQUIRED // ============================================================================ /** * HOOK (REQUIRED): Define the characters available for selection. * Override in subclass to return your game's characters. */ protected getSelectableCharacters(): SelectableCharacter[] { return []; } // ============================================================================ // HOOKS WITH DEFAULT IMPLEMENTATION // ============================================================================ /** HOOK: Create the scene background. Default: dark solid background. */ protected createBackground(): void { const cam = this.cameras.main; this.add.rectangle( cam.width / 2, cam.height / 2, cam.width, cam.height, 0x1a1a2e, ); } /** * HOOK: Create the title text. Default: centered title at top. * The created text is stored in this.titleText for PVP title updates. * If you override, remember to assign this.titleText = yourText. */ protected createTitle(): void { const cam = this.cameras.main; this.titleText = this.add .text(cam.width / 2, 50, 'SELECT CHARACTER', { fontSize: '36px', fontFamily: 'Arial', color: '#ffffff', stroke: '#000000', strokeThickness: 4, fontStyle: 'bold', }) .setOrigin(0.5); } /** HOOK: Create control hints text. Default: bottom-center instructions. */ protected createControlHints(): void { const cam = this.cameras.main; const hint = this.add .text( cam.width / 2, cam.height - 40, 'Arrow Keys / Click: Select Enter / Space: Confirm', { fontSize: '14px', fontFamily: 'Arial', color: '#aaaaaa', }, ) .setOrigin(0.5); this.tweens.add({ targets: hint, alpha: 0.4, duration: 1000, yoyo: true, repeat: -1, }); } /** HOOK: Add custom UI elements (decorations, panels, etc.). */ protected createCustomUI(): void {} /** * HOOK: Return grid layout configuration. * Override to change card sizes and grid layout. */ protected getGridConfig(): GridConfig { return { maxColumns: 4, cardWidth: 180, cardHeight: 240, gapX: 20, gapY: 20, }; } /** * HOOK: Create a single character card's visual content. * * DEFAULT: Creates image + name + description + stats inside the container. * Override to fully customize card appearance. * * @param container - The card container to add children to * @param character - Character data * @param cardW - Card width * @param cardH - Card height */ protected createCharacterCard( container: Phaser.GameObjects.Container, character: SelectableCharacter, cardW: number, cardH: number, ): void { // Character image (top portion) if (character.imageKey && this.textures.exists(character.imageKey)) { const img = this.add.image(0, -cardH / 2 + 70, character.imageKey); const scale = Math.min((cardW - 20) / img.width, 100 / img.height); img.setScale(scale).setOrigin(0.5); container.add(img); } else { // Placeholder silhouette const placeholder = this.add.rectangle( 0, -cardH / 2 + 70, 60, 80, 0x444466, ); container.add(placeholder); const questionMark = this.add .text(0, -cardH / 2 + 70, '?', { fontSize: '32px', color: '#888888', fontFamily: 'Arial', }) .setOrigin(0.5); container.add(questionMark); } // Character name const nameText = this.add .text(0, 10, character.name, { fontSize: '16px', fontFamily: 'Arial', fontStyle: 'bold', color: '#ffffff', stroke: '#000000', strokeThickness: 2, align: 'center', }) .setOrigin(0.5); container.add(nameText); // Description if (character.description) { const desc = this.add .text(0, 35, character.description, { fontSize: '11px', fontFamily: 'Arial', color: '#cccccc', align: 'center', wordWrap: { width: cardW - 20 }, lineSpacing: 2, }) .setOrigin(0.5, 0); container.add(desc); } // Stats (if provided) if (character.stats && Object.keys(character.stats).length > 0) { const statEntries = Object.entries(character.stats); const statY = cardH / 2 - 15 - statEntries.length * 14; statEntries.forEach(([key, value], i) => { const label = this.add.text( -cardW / 2 + 15, statY + i * 14, `${key.toUpperCase()}`, { fontSize: '10px', fontFamily: 'Arial', color: '#aaaaaa' }, ); container.add(label); const val = this.add .text(cardW / 2 - 15, statY + i * 14, `${value}`, { fontSize: '10px', fontFamily: 'Arial', color: '#ffffff', }) .setOrigin(1, 0); container.add(val); }); } } /** * HOOK: Called when the keyboard/mouse highlight changes to a different character. * Use for sound effects, description panel updates, etc. */ protected onSelectionChanged( character: SelectableCharacter, index: number, ): void {} /** * HOOK: Called when a character is confirmed. * Default: store in registry and transition to next scene. * Override for custom post-selection logic (e.g., team building, animation). */ protected onCharacterSelected(character: SelectableCharacter): void {} /** * HOOK: Return the scene key to transition to after character selection. * Default: 'ChapterSelectScene' (go to chapter/level select). */ protected getNextSceneKey(): string { return 'ChapterSelectScene'; } /** HOOK: Return audio key for background music. */ protected getBackgroundMusicKey(): string | undefined { return undefined; } /** HOOK: Play SFX when highlight moves to a different character. */ protected playSelectSound(): void {} /** HOOK: Play SFX when a character is confirmed. */ protected playConfirmSound(): void {} /** HOOK: Per-frame logic. */ protected onUpdate(time: number, delta: number): void {} // ============================================================================ // GRID BUILDING (internal) // ============================================================================ private buildCharacterGrid(): void { if (this.characters.length === 0) return; const cam = this.cameras.main; const gc = this.gridConfig; const cols = Math.min(gc.maxColumns, this.characters.length); const rows = Math.ceil(this.characters.length / cols); // Center the grid const totalW = cols * gc.cardWidth + (cols - 1) * gc.gapX; const totalH = rows * gc.cardHeight + (rows - 1) * gc.gapY; const startX = (cam.width - totalW) / 2 + gc.cardWidth / 2; const startY = (cam.height - totalH) / 2 + gc.cardHeight / 2 + 20; // +20 for title offset // Highlight graphics (drawn behind cards) this.highlightGraphics = this.add.graphics(); this.highlightGraphics.setDepth(99); this.characters.forEach((character, i) => { const col = i % cols; const row = Math.floor(i / cols); const x = startX + col * (gc.cardWidth + gc.gapX); const y = startY + row * (gc.cardHeight + gc.gapY); // Card background const bg = this.add.rectangle( 0, 0, gc.cardWidth, gc.cardHeight, 0x222244, 0.85, ); bg.setStrokeStyle(2, 0x444466); // Container const container = this.add.container(x, y, [bg]); container.setSize(gc.cardWidth, gc.cardHeight); container.setDepth(100); // Let hook fill the card content this.createCharacterCard( container, character, gc.cardWidth, gc.cardHeight, ); // Make interactive bg.setInteractive({ useHandCursor: true }); bg.on('pointerdown', () => { if (!this.isConfirming && !this.disabledIndices.has(i)) { this.selectedIndex = i; this.updateHighlight(); this.confirmSelection(); } }); bg.on('pointerover', () => { if ( !this.isConfirming && !this.disabledIndices.has(i) && i !== this.selectedIndex ) { bg.setFillStyle(0x333366, 0.9); } }); bg.on('pointerout', () => { if ( !this.isConfirming && !this.disabledIndices.has(i) && i !== this.selectedIndex ) { bg.setFillStyle(0x222244, 0.85); } }); this.cardContainers.push(container); this.cardBackgrounds.push(bg); }); } // ============================================================================ // SELECTION LOGIC (internal) // ============================================================================ /** * Update the visual highlight to reflect the current selectedIndex. * Protected so PVP subclasses can call after resetting selectedIndex. */ protected updateHighlight(): void { if (!this.highlightGraphics || this.characters.length === 0) return; this.highlightGraphics.clear(); // Reset all card backgrounds this.cardBackgrounds.forEach((bg, i) => { if (this.disabledIndices.has(i)) { // Disabled card (already picked in PVP) bg.setFillStyle(0x111122, 0.5); bg.setStrokeStyle(2, 0x333344); } else if (i === this.selectedIndex) { bg.setFillStyle(0x334488, 0.95); bg.setStrokeStyle(3, 0xffcc00); } else { bg.setFillStyle(0x222244, 0.85); bg.setStrokeStyle(2, 0x444466); } }); // Dim disabled card containers this.cardContainers.forEach((container, i) => { container.setAlpha(this.disabledIndices.has(i) ? 0.4 : 1); }); // Notify hook if (this.characters[this.selectedIndex]) { this.onSelectionChanged( this.characters[this.selectedIndex], this.selectedIndex, ); } } private confirmSelection(): void { if (this.isConfirming || this.characters.length === 0) return; // PVP: Prevent selecting a disabled (already-picked) card if (this.disabledIndices.has(this.selectedIndex)) return; const selected = this.characters[this.selectedIndex]; // Play confirm SFX this.playConfirmSound(); // Store in registry for other scenes to read this.registry.set('selectedCharacter', selected); // Notify hook this.onCharacterSelected(selected); // Check if we should auto-transition (default: true). // Override shouldAutoTransition() to return false for PVP sequential pick: // P1 picks -> store P1 choice -> resetForNextPick() -> P2 picks -> transition if (!this.shouldAutoTransition()) return; this.isConfirming = true; // Selection animation: flash the selected card const container = this.cardContainers[this.selectedIndex]; if (container) { this.tweens.add({ targets: container, scaleX: 1.1, scaleY: 1.1, duration: 200, yoyo: true, onComplete: () => { this.triggerTransition(); }, }); } else { this.triggerTransition(); } } /** * HOOK: Whether to auto-transition to next scene after a character is selected. * * Default: true (immediately transition after one selection). * Override to return false for PVP games where both players pick sequentially. * When returning false, call this.triggerTransition() manually when all * players have made their selections. */ protected shouldAutoTransition(): boolean { return true; } /** * Trigger the scene fade-out and transition to the next scene. * Call this manually if shouldAutoTransition() returns false. */ protected triggerTransition(): void { this.isConfirming = true; // Stop music if (this.backgroundMusic) { this.backgroundMusic.stop(); } // Fade out and go to next scene this.cameras.main.fadeOut(400, 0, 0, 0); this.cameras.main.once('camerafadeoutcomplete', () => { this.scene.start(this.getNextSceneKey()); }); } /** * PVP HELPER: Reset the selection state for the next player's pick. * Disables the card at disableIndex (grayed out, non-interactive), * resets selectedIndex to the first non-disabled card, and updates highlight. * * Call this from onCharacterSelected() in PVP sequential pick mode. * * @param disableIndex - Index of the card to disable (the just-picked card). * Pass undefined to skip disabling (e.g., if same character can be picked twice). */ protected resetForNextPick(disableIndex?: number): void { // Disable the picked card if ( disableIndex !== undefined && disableIndex >= 0 && disableIndex < this.characters.length ) { this.disabledIndices.add(disableIndex); } // Move selectedIndex to first non-disabled card this.selectedIndex = 0; while ( this.disabledIndices.has(this.selectedIndex) && this.selectedIndex < this.characters.length ) { this.selectedIndex++; } // Safety: if all cards are disabled, clamp to 0 if (this.selectedIndex >= this.characters.length) { this.selectedIndex = 0; } this.updateHighlight(); } // ============================================================================ // INPUT (internal) // ============================================================================ private setupInputs(): void { if (this.characters.length === 0) return; const cols = Math.min(this.gridConfig.maxColumns, this.characters.length); // Arrow keys for grid navigation const leftKey = this.input.keyboard?.addKey( Phaser.Input.Keyboard.KeyCodes.LEFT, ); const rightKey = this.input.keyboard?.addKey( Phaser.Input.Keyboard.KeyCodes.RIGHT, ); const upKey = this.input.keyboard?.addKey( Phaser.Input.Keyboard.KeyCodes.UP, ); const downKey = this.input.keyboard?.addKey( Phaser.Input.Keyboard.KeyCodes.DOWN, ); const enterKey = this.input.keyboard?.addKey( Phaser.Input.Keyboard.KeyCodes.ENTER, ); const spaceKey = this.input.keyboard?.addKey( Phaser.Input.Keyboard.KeyCodes.SPACE, ); leftKey?.on('down', () => { if (this.isConfirming) return; let next = (this.selectedIndex - 1 + this.characters.length) % this.characters.length; // Skip disabled cards (PVP mode) let attempts = this.characters.length; while (this.disabledIndices.has(next) && attempts > 0) { next = (next - 1 + this.characters.length) % this.characters.length; attempts--; } this.selectedIndex = next; this.updateHighlight(); this.playSelectSound(); }); rightKey?.on('down', () => { if (this.isConfirming) return; let next = (this.selectedIndex + 1) % this.characters.length; // Skip disabled cards (PVP mode) let attempts = this.characters.length; while (this.disabledIndices.has(next) && attempts > 0) { next = (next + 1) % this.characters.length; attempts--; } this.selectedIndex = next; this.updateHighlight(); this.playSelectSound(); }); upKey?.on('down', () => { if (this.isConfirming) return; const newIdx = this.selectedIndex - cols; if (newIdx >= 0 && !this.disabledIndices.has(newIdx)) { this.selectedIndex = newIdx; this.updateHighlight(); this.playSelectSound(); } }); downKey?.on('down', () => { if (this.isConfirming) return; const newIdx = this.selectedIndex + cols; if ( newIdx < this.characters.length && !this.disabledIndices.has(newIdx) ) { this.selectedIndex = newIdx; this.updateHighlight(); this.playSelectSound(); } }); enterKey?.on('down', () => { if (!this.isConfirming) this.confirmSelection(); }); spaceKey?.on('down', () => { if (!this.isConfirming) this.confirmSelection(); }); } // ============================================================================ // AUDIO (internal) // ============================================================================ private playBackgroundMusic(): void { const key = this.getBackgroundMusicKey(); if (!key) return; try { if (this.sound.get(key)) { this.backgroundMusic = this.sound.get(key); } else { this.backgroundMusic = this.sound.add(key, { loop: true, volume: 0 }); } this.backgroundMusic?.play(); this.tweens.add({ targets: this.backgroundMusic, volume: 0.4, duration: 800, }); } catch (e) { console.warn('[BaseCharacterSelectScene] Could not play music:', e); } } }