import Phaser from 'phaser';
import { BackendClient } from '../game/BackendClient';
import * as utils from '../utils';
type TutorialStep = {
title: string;
body: string;
role?: 'Mafia' | 'Detective' | 'Doctor' | 'Villager';
};
type AvatarOption = {
id: string;
name: string;
asset: string;
description: string;
};
const STEPS: TutorialStep[] = [
{
title: 'Welcome To the Town',
body: 'Seven seats. One human. Six AI guests. Two Mafia hide inside the room while Town tries to expose them.',
},
{
title: 'Mafia',
role: 'Mafia',
body: 'Two Mafia know each other. At night they choose a victim. By day they bluff, deflect, and survive the vote.',
},
{
title: 'Detective',
role: 'Detective',
body: 'The Detective investigates one living player each night and privately learns whether that player is Mafia.',
},
{
title: 'Doctor',
role: 'Doctor',
body: 'The Doctor protects one player each night. If the Mafia chose that same target, the kill fails.',
},
{
title: 'Villagers',
role: 'Villager',
body: 'Villagers have no night power. Their tools are memory, pressure, defense, claims, and public votes.',
},
{
title: 'How The Table Moves',
body: 'Night actions resolve first. Dawn reports deaths. Day discussion is moderated. Accusations can put a player on the hot seat, then the living vote publicly.',
},
{
title: 'Win Conditions',
body: 'Town wins when both Mafia are eliminated. Mafia wins once they equal or outnumber the living Town.',
},
];
const AVATARS: AvatarOption[] = [
{
id: 'player',
name: 'AVATAR 1',
asset: 'player_neutral',
description: '',
},
{
id: 'player_female_generated',
name: 'AVATAR 2',
asset: 'player_female_generated_neutral',
description: '',
},
];
const AVATAR_STEP = STEPS.length;
const SETUP_STEP = STEPS.length + 1;
export class TitleScreen extends Phaser.Scene {
private uiContainer?: Phaser.GameObjects.DOMElement;
private backgroundMusic?: Phaser.Sound.BaseSound;
private client = new BackendClient();
private step = 0;
private selectedAvatarIndex = 0;
private isSettingUp = false;
private isReady = false;
private playerName = 'TruthSeeker';
private error = '';
private keydownHandler?: (event: KeyboardEvent) => void;
constructor() {
super({ key: 'TitleScreen' });
}
create(): void {
this.step = 0;
this.isSettingUp = false;
this.isReady = false;
this.error = '';
this.createBackground();
this.backgroundMusic = utils.safeAddSound(this, 'role_reveal_bgm', { volume: 0.3, loop: true });
this.backgroundMusic?.play();
this.createDOMUI();
this.bindKeys();
this.events.once('shutdown', () => this.cleanup());
}
private createBackground(): void {
const cam = this.cameras.main;
if (utils.textureExists(this, 'role_reveal_bg')) {
const bg = this.add.image(cam.width / 2, cam.height / 2, 'role_reveal_bg');
bg.setDisplaySize(cam.width, cam.height);
this.add.rectangle(cam.width / 2, cam.height / 2, cam.width, cam.height, 0x000000, 0.34);
} else {
this.add.rectangle(cam.width / 2, cam.height / 2, cam.width, cam.height, 0x080604, 1);
}
}
private createDOMUI(): void {
const html = `
`;
this.uiContainer = utils.initUIDom(this, html);
this.renderTutorial();
this.bindDOMControls();
}
private bindDOMControls(): void {
const form = document.getElementById('setup-form') as HTMLFormElement | null;
form?.addEventListener('submit', (event) => {
event.preventDefault();
event.stopPropagation();
if (this.isReady) {
this.playClick();
this.startGame();
return;
}
this.playClick();
void this.setupRoom();
});
document.getElementById('player-name-input')?.addEventListener('input', (event) => {
const target = event.target as HTMLInputElement;
this.playerName = target.value.trim() || 'You';
});
document.getElementById('avatar-prev')?.addEventListener('click', (event) => {
event.stopPropagation();
this.playClick();
this.rotateAvatar(-1);
});
document.getElementById('avatar-next')?.addEventListener('click', (event) => {
event.stopPropagation();
this.playClick();
this.rotateAvatar(1);
});
document.getElementById('tutorial-back')?.addEventListener('click', (event) => {
event.stopPropagation();
if (this.step > 0 && !this.isSettingUp) this.playClick();
this.goBack();
});
}
private bindKeys(): void {
this.keydownHandler = (event: KeyboardEvent) => {
const active = document.activeElement;
if (active instanceof HTMLInputElement && !active.readOnly) return;
if (event.code === 'Escape') {
event.preventDefault();
if (this.step > 0 && !this.isSettingUp) this.playClick();
this.goBack();
return;
}
if (this.step === AVATAR_STEP && (event.code === 'ArrowLeft' || event.code === 'ArrowRight')) {
event.preventDefault();
this.playClick();
this.rotateAvatar(event.code === 'ArrowLeft' ? -1 : 1);
return;
}
if (event.code !== 'Enter' && event.code !== 'Space') return;
event.preventDefault();
if (this.isReady) {
this.playClick();
this.startGame();
} else if (this.step < SETUP_STEP) {
this.playClick();
this.step += 1;
this.renderTutorial();
}
};
document.addEventListener('keydown', this.keydownHandler);
}
private get selectedAvatar(): AvatarOption {
return AVATARS[this.selectedAvatarIndex] ?? AVATARS[0];
}
private rotateAvatar(delta: number): void {
this.selectedAvatarIndex = (this.selectedAvatarIndex + delta + AVATARS.length) % AVATARS.length;
this.renderTutorial();
}
private goBack(): void {
if (this.isSettingUp) return;
if (this.step > 0) {
this.isReady = false;
this.error = '';
this.step -= 1;
this.renderTutorial();
}
}
private renderTutorial(): void {
const avatarStep = this.step === AVATAR_STEP;
const setup = this.step >= SETUP_STEP;
const step = STEPS[Math.min(this.step, STEPS.length - 1)];
const avatar = this.selectedAvatar;
const title = document.getElementById('tutorial-title');
const body = document.getElementById('tutorial-body');
const kicker = document.getElementById('tutorial-kicker');
const progress = document.getElementById('tutorial-progress');
const hint = document.getElementById('tutorial-hint');
const form = document.getElementById('setup-form') as HTMLFormElement | null;
const avatarControls = document.getElementById('avatar-controls');
const avatarName = document.getElementById('avatar-name');
const avatarDescription = document.getElementById('avatar-description');
const card = document.getElementById('tutorial-role-card');
const status = document.getElementById('setup-status');
const error = document.getElementById('tutorial-error');
const back = document.getElementById('tutorial-back') as HTMLButtonElement | null;
const setupButton = document.getElementById('setup-room-button') as HTMLButtonElement | null;
if (title) title.textContent = setup ? 'Take Your Seat' : avatarStep ? 'Choose Your Face' : step.title;
if (body) {
body.textContent = setup
? 'The Moderator will let you in soon...'
: avatarStep
? 'Pick the table portrait other players will see. Appearance never reveals your role.'
: step.body;
}
if (kicker) kicker.textContent = setup ? 'Room Setup' : avatarStep ? 'Avatar Selection' : 'Mafia Tutorial';
if (progress) progress.textContent = setup ? 'Online room check' : `${Math.min(this.step + 1, SETUP_STEP + 1)} / ${SETUP_STEP + 1}`;
if (hint) hint.textContent = this.isReady ? 'READY - Press Enter to play' : setup ? 'Set up the room to continue' : 'Press Enter to continue';
if (form) form.style.display = setup ? 'grid' : 'none';
if (avatarControls) avatarControls.style.display = avatarStep ? 'grid' : 'none';
if (avatarName) avatarName.textContent = avatar.name;
if (avatarDescription) avatarDescription.textContent = avatar.description;
if (back) {
back.disabled = this.step === 0 || this.isSettingUp;
back.style.visibility = this.step === 0 ? 'hidden' : 'visible';
}
if (setupButton) {
setupButton.disabled = this.isSettingUp;
setupButton.textContent = this.isSettingUp ? 'Setting up the room...' : this.isReady ? 'Ready' : 'Set up the room';
}
if (status) {
status.textContent = this.isSettingUp
? 'Setting up the room..'
: this.isReady
? 'Room ready. Roles are sealed.'
: '';
}
if (error) error.textContent = this.error;
if (card) {
if (setup || avatarStep) {
card.innerHTML = `
`;
} else if (step.role) {
card.innerHTML = `
`;
} else {
card.innerHTML = '
';
}
}
}
private async setupRoom(): Promise {
if (this.isSettingUp) return;
this.isSettingUp = true;
this.isReady = false;
this.error = '';
this.renderTutorial();
try {
const ready = await this.client.ready('Online');
window.__MAFIA_READY__ = ready;
if (!ready.ready) {
const failed = ready.checks.find((check) => !check.ready);
throw new Error(failed?.error ? `${failed.name}: ${failed.error}` : 'Online services are not ready.');
}
const seedBytes = new Uint32Array(1);
window.crypto?.getRandomValues(seedBytes);
const seed = seedBytes[0] || Math.floor(Math.random() * 2_000_000_000) + 1;
const game = await this.client.newGame({
seed,
humanName: this.playerName,
humanRole: 'Random',
agentMode: 'Online',
humanAvatar: this.selectedAvatar.id,
});
window.__MAFIA_VIEW__ = game;
window.__MAFIA_PLAYER_NAME__ = this.playerName;
window.__MAFIA_AGENT_MODE__ = 'Online';
window.__MAFIA_AVATAR_ID__ = this.selectedAvatar.id;
this.isReady = true;
utils.safeAddSound(this, 'correct_sfx', { volume: 0.45 })?.play();
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
utils.safeAddSound(this, 'wrong_sfx', { volume: 0.35 })?.play();
} finally {
this.isSettingUp = false;
this.renderTutorial();
}
}
private startGame(): void {
if (!this.isReady) return;
this.cleanup();
this.backgroundMusic?.stop();
this.cameras.main.fadeOut(450, 0, 0, 0);
this.time.delayedCall(450, () => this.scene.start('RoleRevealScene'));
}
private playClick(volume = 0.32): void {
utils.safeAddSound(this, 'click_sfx', { volume })?.play();
}
private cleanup(): void {
if (this.keydownHandler) document.removeEventListener('keydown', this.keydownHandler);
this.uiContainer?.destroy();
}
}