mafia / frontend /src /scenes /BaseCharacterSelectScene.ts
Alfaxad's picture
Migrate Mafia game to ZeroGPU Gradio Space
c670567 verified
Raw
History Blame Contribute Delete
25.1 kB
/**
* ============================================================================
* 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<number> -- 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<string, number>;
/** Extra metadata the game scene can read later */
metadata?: Record<string, any>;
}
/** 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<number> = 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);
}
}
}