268 lines
9.3 KiB
TypeScript
268 lines
9.3 KiB
TypeScript
import Phaser from 'phaser';
|
|
import type { NightState } from '../../data/types';
|
|
import { DAZZA_SIGNOFF, FAIL_TEXT, SUMMARY_UI } from '../../data/strings/door';
|
|
import { MAX_HEAT_STRIKES, NIGHT_DATES, type NightLog, type RunInfo } from './NightScene';
|
|
import { DOOR_PALETTE, MONO, panel, text } from './ui';
|
|
|
|
// Phase-2: run-aware summary. Still owes Phase 3 the incident-report form and
|
|
// the cross-night audit.
|
|
|
|
const W = 640;
|
|
const H = 360;
|
|
|
|
const NIGHT_NAMES = ['thursday', 'friday', 'saturday'] as const;
|
|
|
|
export function nightCash(log: NightLog, state: NightState): number {
|
|
// Wage plus a hype bonus, minus a strike deduction. Vibe is not paid directly —
|
|
// Dazza pays for the room feeling busy, not for you being right.
|
|
const wage = log.wage;
|
|
const hypeBonus = Math.round((log.hypePeak - 1) * 90);
|
|
const doorBonus = log.admitted * 2;
|
|
const strikes = state.heatStrikes.length * 60;
|
|
return Math.max(0, wage + hypeBonus + doorBonus - strikes) + log.floorCash;
|
|
}
|
|
|
|
export function signoffFor(vibe: number, strikes: number): string {
|
|
if (strikes >= 2) return DAZZA_SIGNOFF[0]!;
|
|
const idx = vibe < 25 ? 0 : vibe < 50 ? 1 : vibe < 75 ? 2 : 3;
|
|
return DAZZA_SIGNOFF[idx]!;
|
|
}
|
|
|
|
export class NightSummaryScene extends Phaser.Scene {
|
|
private log!: NightLog;
|
|
private state!: NightState;
|
|
private run: RunInfo | null = null;
|
|
|
|
constructor() {
|
|
super('NightSummary');
|
|
}
|
|
|
|
init(data: { log: NightLog; state: NightState; run?: RunInfo }): void {
|
|
this.log = data.log;
|
|
this.state = data.state;
|
|
this.run = data.run ?? null;
|
|
}
|
|
|
|
create(): void {
|
|
const reason = this.log.endReason;
|
|
const failed = reason !== 'clock';
|
|
this.add.rectangle(W / 2, H / 2, W, H, failed ? 0x160a0e : 0x0a0a12);
|
|
|
|
const title = reason !== 'clock' ? FAIL_TEXT[reason].title : SUMMARY_UI.title;
|
|
const nightName = this.run ? (NIGHT_NAMES[this.run.nightIndex] ?? '') : '';
|
|
|
|
this.add
|
|
.text(W / 2, 26, title, {
|
|
fontFamily: MONO,
|
|
fontSize: '18px',
|
|
color: failed ? '#ff8080' : '#f0e4d0',
|
|
})
|
|
.setOrigin(0.5);
|
|
this.add
|
|
.text(
|
|
W / 2,
|
|
46,
|
|
failed
|
|
? `${this.run?.venue.name ?? this.state.venueId} · ${nightName} · night over early`
|
|
: `${SUMMARY_UI.subtitle}${nightName ? ` · ${nightName}` : ''} · shift: ${this.log.role.toLowerCase()} $${this.log.wage}`,
|
|
{
|
|
fontFamily: MONO,
|
|
fontSize: '8px',
|
|
color: DOOR_PALETTE.inkDim,
|
|
},
|
|
)
|
|
.setOrigin(0.5);
|
|
|
|
this.drawVibeGraph(30, 64, 300, 88);
|
|
this.drawIncidents(30, 158, 300, 82);
|
|
this.drawStats(354, 64);
|
|
this.drawDazza(30, 250);
|
|
|
|
const { prompt, next } = this.nextAction();
|
|
this.add
|
|
.text(W / 2, H - 14, `${prompt} · seed ${this.log.seed}`, {
|
|
fontFamily: MONO,
|
|
fontSize: '8px',
|
|
color: this.run?.runOver ? '#ff8080' : DOOR_PALETTE.inkDim,
|
|
})
|
|
.setOrigin(0.5);
|
|
|
|
// The paperwork sits between the night and the next shift (design §3.3).
|
|
const toReport = (): void => {
|
|
this.scene.start('IncidentReport', {
|
|
state: this.state,
|
|
run: this.run?.game ?? null,
|
|
seed: this.log.seed,
|
|
next,
|
|
});
|
|
};
|
|
this.input.once('pointerdown', toReport);
|
|
this.input.keyboard?.once('keydown-SPACE', toReport);
|
|
}
|
|
|
|
/** What clicking through leads to: the next shift, or a fresh run. */
|
|
private nextAction(): { prompt: string; next: () => void } {
|
|
const r = this.run;
|
|
if (!r) {
|
|
return {
|
|
prompt: SUMMARY_UI.replay,
|
|
next: () => this.scene.start('Roster', { seed: this.log.seed + 1 }),
|
|
};
|
|
}
|
|
if (r.runOver) {
|
|
return {
|
|
prompt: 'licence gone · click to start a fresh week somewhere that has not heard of you',
|
|
next: () => this.scene.start('Roster', { freshRun: true, seed: this.log.seed + 13 }),
|
|
};
|
|
}
|
|
if (r.seasonDone) {
|
|
return {
|
|
prompt: 'THE SEASON IS DONE. every door in the valley, held. click for a fresh run',
|
|
next: () => this.scene.start('Roster', { freshRun: true, seed: this.log.seed + 7 }),
|
|
};
|
|
}
|
|
if (r.weekDone && r.promotedTo) {
|
|
const v = r.promotedTo;
|
|
return {
|
|
// Dazza's pitch sells the next rung — the ladder is the reward.
|
|
prompt: `WEEK SURVIVED — PROMOTED: ${v.name.toUpperCase()}. "${v.pitch}"`,
|
|
next: () => this.scene.start('Roster', {}),
|
|
};
|
|
}
|
|
const failedNight = this.log.endReason !== 'clock';
|
|
const nextName = failedNight
|
|
? (NIGHT_NAMES[r.nightIndex] ?? 'the same night')
|
|
: (NIGHT_NAMES[r.nightIndex + 1] ?? 'the next night');
|
|
return {
|
|
prompt: failedNight ? `click to run ${nextName} back` : `click for ${nextName} · ${NIGHT_DATES[r.nightIndex + 1] ?? ''}`,
|
|
next: () => this.scene.start('Roster', {}),
|
|
};
|
|
}
|
|
|
|
private drawVibeGraph(x: number, y: number, w: number, h: number): void {
|
|
panel(this, x, y, w, h, 0x11111a, 0x2a2a38);
|
|
text(this, x + 5, y + 4, 'VIBE ACROSS THE NIGHT', 7, DOOR_PALETTE.inkDim);
|
|
|
|
// 50 is the line you started on — crossing below it is the story of the night
|
|
const mid = y + h - 14 - (h - 34) * 0.5;
|
|
this.add.rectangle(x + w / 2, mid, w - 10, 1, 0x33333f);
|
|
|
|
const pts = this.log.vibeHistory;
|
|
if (pts.length < 2) return;
|
|
const g = this.add.graphics();
|
|
g.lineStyle(2, DOOR_PALETTE.greenHot, 1);
|
|
g.beginPath();
|
|
pts.forEach((v, i) => {
|
|
const px = x + 5 + ((w - 10) * i) / (pts.length - 1);
|
|
const py = y + h - 14 - ((h - 34) * Math.max(0, Math.min(100, v))) / 100;
|
|
if (i === 0) g.moveTo(px, py);
|
|
else g.lineTo(px, py);
|
|
});
|
|
g.strokePath();
|
|
|
|
text(this, x + 5, y + h - 12, '9PM', 7, DOOR_PALETTE.inkDim);
|
|
text(this, x + w - 30, y + h - 12, '3AM', 7, DOOR_PALETTE.inkDim);
|
|
}
|
|
|
|
private drawStats(x: number, y: number): void {
|
|
const w = 256;
|
|
panel(this, x, y, w, 168, 0x11111a, 0x2a2a38);
|
|
|
|
const cash = nightCash(this.log, this.state);
|
|
const rows: Array<[string, string, string?]> = [
|
|
['admitted', String(this.log.admitted)],
|
|
['knocked back', String(this.log.denied)],
|
|
['sobriety tests', String(this.log.tested)],
|
|
['pat-downs', String(this.log.pattedDown)],
|
|
['peak hype', `x${this.log.hypePeak.toFixed(1)}`],
|
|
['final vibe', String(Math.round(this.state.vibe))],
|
|
['final aggro', String(Math.round(this.state.aggro))],
|
|
[
|
|
'clicker vs reality',
|
|
`${this.state.capacity.clickerShown} vs ${this.state.capacity.inside}`,
|
|
this.state.capacity.clickerShown === this.state.capacity.inside ? undefined : '#ffc060',
|
|
],
|
|
[
|
|
'heat strikes',
|
|
`${this.state.heatStrikes.length}/3`,
|
|
this.state.heatStrikes.length > 0 ? '#ff8080' : undefined,
|
|
],
|
|
['floor score', `$${this.log.floorCash}`, this.log.floorCash > 0 ? '#f0d060' : undefined],
|
|
];
|
|
|
|
rows.forEach(([label, value, colour], i) => {
|
|
const ry = y + 10 + i * 13;
|
|
text(this, x + 8, ry, label, 8, DOOR_PALETTE.inkDim);
|
|
this.add
|
|
.text(x + w - 8, ry, value, {
|
|
fontFamily: MONO,
|
|
fontSize: '8px',
|
|
color: colour ?? DOOR_PALETTE.inkHot,
|
|
})
|
|
.setOrigin(1, 0);
|
|
});
|
|
|
|
this.add.rectangle(x + w / 2, y + 138, w - 16, 1, 0x2a2a38);
|
|
text(this, x + 8, y + 145, 'PAY', 9, '#d0b060');
|
|
this.add
|
|
.text(x + w - 8, y + 143, `$${cash}`, { fontFamily: MONO, fontSize: '14px', color: '#f0d060' })
|
|
.setOrigin(1, 0);
|
|
|
|
}
|
|
|
|
/**
|
|
* The strikes spelled out. A count alone tells you the run is over; the
|
|
* reasons tell you which call did it, which is the only way the next night is
|
|
* played differently. Lives in its own panel because the reasons are long
|
|
* sentences and used to run straight off the right edge of the stats table.
|
|
*/
|
|
private drawIncidents(x: number, y: number, w: number, h: number): void {
|
|
panel(this, x, y, w, h, 0x11111a, 0x2a2a38);
|
|
const strikes = this.state.heatStrikes;
|
|
text(this, x + 5, y + 4, strikes.length > 0 ? 'THE INSPECTOR WROTE UP' : 'NOTHING ON THE RECORD', 7,
|
|
strikes.length > 0 ? '#ff8080' : DOOR_PALETTE.inkDim);
|
|
|
|
if (strikes.length === 0) {
|
|
this.add.text(x + 5, y + 18, 'a clean sheet. no strikes, no questions.\nthe licence survives to friday.', {
|
|
fontFamily: MONO, fontSize: '7px', color: DOOR_PALETTE.inkDim, wordWrap: { width: w - 12 }, lineSpacing: 2,
|
|
});
|
|
return;
|
|
}
|
|
|
|
let ry = y + 16;
|
|
for (const s of strikes.slice(0, MAX_HEAT_STRIKES)) {
|
|
const line = this.add.text(x + 5, ry, `· ${s.reason}`, {
|
|
fontFamily: MONO, fontSize: '7px', color: '#ff9090', wordWrap: { width: w - 12 },
|
|
});
|
|
ry += line.height + 3;
|
|
}
|
|
}
|
|
|
|
private drawDazza(x: number, y: number): void {
|
|
const w = W - 60;
|
|
panel(this, x, y, w, 74, 0x0d1116, 0x2c4a38);
|
|
text(this, x + 8, y + 6, 'DAZZA', 8, '#7de08a');
|
|
|
|
const reason = this.log.endReason;
|
|
const body =
|
|
reason !== 'clock'
|
|
? FAIL_TEXT[reason].dazza
|
|
: signoffFor(this.state.vibe, this.state.heatStrikes.length);
|
|
|
|
this.add.text(x + 8, y + 20, body, {
|
|
fontFamily: MONO,
|
|
fontSize: '9px',
|
|
color: reason !== 'clock' ? '#ffb0b0' : '#bfe8c8',
|
|
wordWrap: { width: w - 16 },
|
|
});
|
|
|
|
if (this.state.heatStrikes.length >= 3) {
|
|
this.add.text(x + 8, y + 56, 'THREE STRIKES. THE LICENCE IS GONE. RUN OVER.', {
|
|
fontFamily: MONO,
|
|
fontSize: '9px',
|
|
color: '#ff6060',
|
|
});
|
|
}
|
|
}
|
|
}
|