mafia / frontend /src /scenes /TitleScreen.ts
Alfaxad's picture
Migrate Mafia game to ZeroGPU Gradio Space
c670567 verified
Raw
History Blame Contribute Delete
17 kB
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 = `
<div id="title-root" class="fixed inset-0 z-[1000] pointer-events-auto" style="font-family: Georgia, 'Times New Roman', serif;">
<div class="onboarding-shell">
<div class="brand-lockup">
<div class="hat">◆</div>
<h1>MAFIA</h1>
</div>
<section class="tutorial-card">
<div id="tutorial-role-card" class="role-card-slot"></div>
<div class="copy">
<p id="tutorial-kicker" class="kicker">Mafia Tutorial</p>
<h2 id="tutorial-title"></h2>
<p id="tutorial-body"></p>
<div id="avatar-controls" class="avatar-controls" style="display:none;">
<button id="avatar-prev" type="button" aria-label="Previous avatar">‹</button>
<div>
<strong id="avatar-name"></strong>
<span id="avatar-description"></span>
</div>
<button id="avatar-next" type="button" aria-label="Next avatar">›</button>
</div>
<form id="setup-form" class="setup-form" style="display:none;">
<label for="player-name-input">What should the table call you?</label>
<input id="player-name-input" maxlength="32" autocomplete="name" spellcheck="false" value="${this.playerName}" />
<label for="agent-mode-display">Game Mode</label>
<input id="agent-mode-display" value="Online" readonly />
<button id="setup-room-button" type="submit">Set up the room</button>
</form>
<p id="setup-status" class="setup-status"></p>
<p id="tutorial-error" class="tutorial-error"></p>
</div>
</section>
<footer class="title-footer">
<button id="tutorial-back" type="button">Back</button>
<span id="tutorial-progress"></span>
<span id="tutorial-hint">Press Enter to continue</span>
</footer>
</div>
</div>
<style>
.onboarding-shell{position:absolute;inset:0;display:grid;grid-template-rows:auto 1fr auto;align-items:center;justify-items:center;padding:40px;color:#f4dfbd;text-shadow:0 2px 0 #000;}
.brand-lockup{text-align:center;}
.brand-lockup h1{margin:0;font-size:72px;letter-spacing:6px;color:#e8c87a;text-shadow:0 0 24px rgba(175,54,42,.45),4px 4px 0 #000;}
.hat{color:#b8443c;font-size:22px;margin-bottom:4px;}
.tutorial-card{width:min(1040px,78vw);min-height:420px;display:grid;grid-template-columns:280px 1fr;gap:34px;align-items:center;padding:30px 36px;background:linear-gradient(135deg,rgba(13,11,15,.9),rgba(24,14,10,.82));border:2px solid #6b5326;box-shadow:0 0 38px rgba(0,0,0,.65), inset 0 0 32px rgba(110,76,38,.18);}
.role-card-slot{height:360px;display:grid;place-items:center;overflow:hidden;}
.role-card-slot img{max-width:230px;max-height:340px;filter:drop-shadow(0 18px 26px rgba(0,0,0,.75));}
.role-card-slot img.avatar-preview{max-width:270px;max-height:360px;object-fit:contain;}
.copy{font-family:monospace;min-width:0;}
.kicker{margin:0 0 10px;color:#b8443c;text-transform:uppercase;letter-spacing:2px;font-size:14px;}
.copy h2{font-family:Georgia,'Times New Roman',serif;font-size:42px;margin:0 0 14px;color:#e8c87a;line-height:1.05;}
.copy p{font-size:18px;line-height:1.55;color:#e8dcc0;margin:0;max-width:68ch;}
.avatar-controls{margin-top:24px;grid-template-columns:52px 1fr 52px;gap:14px;align-items:center;max-width:520px;}
.avatar-controls button,.title-footer button{min-width:44px;min-height:44px;border:1px solid #6b5326;background:rgba(12,10,18,.88);color:#f4dfbd;font:700 24px monospace;cursor:pointer;}
.avatar-controls button:hover,.title-footer button:hover{background:#3a2418;}
.avatar-controls button:focus-visible,.title-footer button:focus-visible,.setup-form input:focus-visible,.setup-form button:focus-visible{outline:2px solid #e8c87a;outline-offset:3px;}
.avatar-controls strong{display:block;color:#e8c87a;font-size:16px;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;}
.avatar-controls span{display:block;color:#d6d2c4;font-size:14px;line-height:1.45;}
.setup-form{margin-top:24px;display:grid;grid-template-columns:1fr;gap:10px;max-width:460px;}
.setup-form label{font-size:13px;text-transform:uppercase;color:#b99b70;letter-spacing:1px;}
.setup-form input{height:42px;border:1px solid #6b5326;background:rgba(6,5,7,.88);color:#f4dfbd;padding:0 12px;font:16px monospace;outline:none;}
.setup-form input[readonly]{color:#e8c87a;background:rgba(24,18,12,.78);}
.setup-form button{height:48px;border:1px solid #a56a36;background:#4a1414;color:#f4dfbd;font:700 16px monospace;cursor:pointer;}
.setup-form button:hover{background:#7a2828;}
.setup-form button[disabled]{cursor:not-allowed;opacity:.65;}
.setup-status{margin-top:16px!important;color:#e8c87a!important;font-size:16px!important;}
.tutorial-error{margin-top:10px!important;color:#ff8c7d!important;font-size:14px!important;white-space:pre-wrap;}
.title-footer{width:min(1040px,78vw);display:grid;grid-template-columns:120px 1fr 300px;align-items:center;color:#e8c87a;font:17px monospace;padding-bottom:34px;gap:14px;}
#tutorial-progress{text-align:center;}
#tutorial-hint{text-align:right;animation:titlePulse 1s alternate infinite;}
@media (prefers-reduced-motion: reduce){#tutorial-hint{animation:none;}}
@keyframes titlePulse{from{opacity:.45}to{opacity:1}}
</style>
`;
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 = `<img class="avatar-preview" src="/assets/${avatar.asset}.png" alt="${avatar.name} avatar" />`;
} else if (step.role) {
card.innerHTML = `<img src="/assets/role_card_${step.role.toLowerCase()}.png" alt="${step.role} role card" />`;
} else {
card.innerHTML = '<img src="/assets/role_card_back.png" alt="Hidden role card" />';
}
}
}
private async setupRoom(): Promise<void> {
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();
}
}