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 = `

MAFIA

Mafia Tutorial

`; 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 = `${avatar.name} avatar`; } else if (step.role) { card.innerHTML = `${step.role} role card`; } else { card.innerHTML = 'Hidden role card'; } } } 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(); } }