221 lines
8.2 KiB
TypeScript
221 lines
8.2 KiB
TypeScript
// EndingScene:E1-E4 四结局演出 + 病历 + 「回到标题」按钮(PRD §9)。
|
||
|
||
import Phaser from 'phaser';
|
||
import { director } from '../core/CorruptionDirector';
|
||
import { audio } from '../audio/AudioEngine';
|
||
import { GAME_W, GAME_H, NAMED_NPCS, NPC_DEFS, type EndingId } from '../types';
|
||
import {
|
||
ENDING_TITLES, E1_SUBTITLES, E2_SUBTITLE, E4_SUBTITLE, E3_RECORD,
|
||
} from '../data/endings';
|
||
|
||
export class EndingScene extends Phaser.Scene {
|
||
private ending: EndingId = 'E1';
|
||
private cm: Phaser.FX.ColorMatrix | null = null;
|
||
|
||
constructor() {
|
||
super('EndingScene');
|
||
}
|
||
|
||
create(data: { ending?: EndingId }): void {
|
||
this.ending = data?.ending ?? 'E1';
|
||
if (this.scene.isActive('UIScene')) this.scene.stop('UIScene');
|
||
this.cameras.main.clearFX();
|
||
this.cm = this.cameras.main.postFX.addColorMatrix();
|
||
this.cameras.main.fadeIn(400, 0, 0, 0);
|
||
|
||
switch (this.ending) {
|
||
case 'E1': void this.playE1(); break;
|
||
case 'E2': void this.playE2(); break;
|
||
case 'E3': void this.playE3(); break;
|
||
case 'E4': void this.playE4(); break;
|
||
}
|
||
}
|
||
|
||
private wait(ms: number): Promise<void> {
|
||
return new Promise((resolve) => this.time.delayedCall(ms, resolve));
|
||
}
|
||
|
||
private subtitle(text: string, y = GAME_H - 24): Phaser.GameObjects.Text {
|
||
return this.add.text(GAME_W / 2, y, text, {
|
||
fontFamily: 'monospace', fontSize: '9px', color: '#ffffff',
|
||
stroke: '#000000', strokeThickness: 2,
|
||
}).setOrigin(0.5);
|
||
}
|
||
|
||
private endScreen(): void {
|
||
this.add.rectangle(GAME_W / 2, GAME_H - 9, GAME_W, 14, 0x000000, 0.7);
|
||
this.add.text(GAME_W / 2 - 40, GAME_H - 9, ENDING_TITLES[this.ending], {
|
||
fontFamily: 'monospace', fontSize: '8px', color: '#cccccc',
|
||
}).setOrigin(0.5);
|
||
const btn = this.add.text(GAME_W / 2 + 56, GAME_H - 9, '【回到标题】', {
|
||
fontFamily: 'monospace', fontSize: '8px', color: '#F5A623',
|
||
}).setOrigin(0.5).setInteractive({ useHandCursor: true });
|
||
btn.on('pointerdown', () => {
|
||
// 重开即从 Day 1 全新开始,无继承
|
||
this.cameras.main.fadeOut(300, 0, 0, 0);
|
||
this.cameras.main.once('camerafadeoutcomplete', () => {
|
||
this.scene.start('TitleScene');
|
||
});
|
||
});
|
||
}
|
||
|
||
/** E1 糖衣:全村微笑合影定格,饱和度缓慢推到 1.5 */
|
||
private async playE1(): Promise<void> {
|
||
this.cameras.main.setBackgroundColor('#8FD3FF');
|
||
this.add.rectangle(GAME_W / 2, GAME_H - 40, GAME_W, 80, 0x7dc95e);
|
||
this.add.rectangle(GAME_W / 2, 30, 200, 20, 0xf7b7cb).setStrokeStyle(2, 0xf5a623);
|
||
this.add.text(GAME_W / 2, 30, '第 413 届丰收庆典', {
|
||
fontFamily: 'monospace', fontSize: '10px', color: '#fff',
|
||
}).setOrigin(0.5);
|
||
|
||
// 全村微笑合影(7 组 NPC + 主角)
|
||
const all: string[] = [...NAMED_NPCS.map((id) => NPC_DEFS[id].spriteKey), 'player'];
|
||
all.forEach((key, i) => {
|
||
const x = 40 + i * 34;
|
||
this.add.sprite(x, GAME_H - 60, key, 0).setOrigin(0.5, 1).setScale(1.5);
|
||
});
|
||
// 主角头像永远同一帧微笑(支柱 2)
|
||
this.add.image(GAME_W / 2, GAME_H - 100, 'portrait_player_smile').setScale(2);
|
||
|
||
// 饱和度缓慢推到 1.5
|
||
let sat = 1.0;
|
||
this.tweens.addCounter({
|
||
from: 1.0, to: 1.5, duration: 5000,
|
||
onUpdate: (tw) => {
|
||
sat = tw.getValue() ?? 1;
|
||
this.cm?.reset();
|
||
this.cm?.saturate(sat);
|
||
},
|
||
});
|
||
|
||
await this.wait(3500);
|
||
this.subtitle(E1_SUBTITLES[0]);
|
||
await this.wait(2500);
|
||
this.subtitle(E1_SUBTITLES[1], GAME_H - 40);
|
||
await this.wait(2000);
|
||
this.endScreen();
|
||
}
|
||
|
||
/** E2 回收:画面骤停,负片 2 秒,心电滴一响;回到 Day 1 清晨的床上 */
|
||
private async playE2(): Promise<void> {
|
||
// 骤停:黑场
|
||
this.cameras.main.setBackgroundColor('#000000');
|
||
await this.wait(600);
|
||
|
||
// 负片 2 秒 + 心电滴声一响
|
||
this.cm?.negative();
|
||
audio.ecg();
|
||
await this.wait(2000);
|
||
this.cm?.reset();
|
||
|
||
// 回到 Day 1 清晨的床上(小屋地图做背景),日历翻回同一天
|
||
this.cameras.main.setBackgroundColor('#1a1a3a');
|
||
const map = this.make.tilemap({ key: 'map_house' });
|
||
const tsName = map.tilesets.length > 0 ? map.tilesets[0].name : 'candy';
|
||
const tiles = map.addTilesetImage(tsName, 'tileset');
|
||
if (tiles) {
|
||
map.createLayer('ground', tiles, 80, 30);
|
||
map.createLayer('obstacles', tiles, 80, 30);
|
||
}
|
||
this.add.sprite(120, 96, 'player', 0).setOrigin(0.5, 1);
|
||
// 日历(tiles16 帧 64)
|
||
this.add.image(150, 60, 'tiles16', 64);
|
||
this.tweens.add({
|
||
targets: this.add.image(150, 60, 'tiles16', 64).setAlpha(0.6),
|
||
alpha: 0, duration: 1500, yoyo: true, repeat: 1,
|
||
});
|
||
|
||
await this.wait(1800);
|
||
this.subtitle(E2_SUBTITLE);
|
||
await this.wait(2200);
|
||
this.endScreen();
|
||
}
|
||
|
||
/** E3 苏醒:病历特写 → 最后一帧微笑消失 + 窗外灰麻雀 */
|
||
private async playE3(): Promise<void> {
|
||
this.cameras.main.setBackgroundColor('#f5f5f5');
|
||
this.cameras.main.fadeIn(1000, 255, 255, 255);
|
||
await this.wait(1200);
|
||
|
||
// 病房背景:白床 + 监护仪
|
||
this.add.image(60, 100, 'tiles16', 87).setOrigin(0).setScale(2);
|
||
this.add.image(92, 100, 'tiles16', 88).setOrigin(0).setScale(2);
|
||
this.add.image(60, 132, 'tiles16', 89).setOrigin(0).setScale(2);
|
||
this.add.image(92, 132, 'tiles16', 90).setOrigin(0).setScale(2);
|
||
this.add.image(140, 96, 'tiles16', 92).setOrigin(0).setScale(2);
|
||
|
||
// 病历特写
|
||
const card = this.add.rectangle(GAME_W / 2, GAME_H / 2, 240, 120, 0xffffff)
|
||
.setStrokeStyle(2, 0xbdbdbd);
|
||
void card;
|
||
const lines = [
|
||
E3_RECORD.header,
|
||
'─────────────────',
|
||
`${E3_RECORD.labels.name}:${director.playerName}`,
|
||
`${E3_RECORD.labels.no}:${E3_RECORD.no}`,
|
||
`${E3_RECORD.labels.duration}:${director.playTimeFormatted}`,
|
||
];
|
||
this.add.text(GAME_W / 2, GAME_H / 2, lines.join('\n'), {
|
||
fontFamily: 'monospace', fontSize: '10px', color: '#333333',
|
||
align: 'center', lineSpacing: 6,
|
||
}).setOrigin(0.5);
|
||
await this.wait(5000);
|
||
|
||
// 最后一帧:微笑消失,变成一张疲惫而真实的脸(支柱 2 全流程唯一一次变化)
|
||
this.cameras.main.fadeOut(800, 0, 0, 0);
|
||
await this.wait(850);
|
||
this.children.removeAll();
|
||
this.cameras.main.setBackgroundColor('#dadada');
|
||
this.cameras.main.fadeIn(600, 0, 0, 0);
|
||
// 窗
|
||
this.add.rectangle(GAME_W / 2, 80, 120, 80, 0xf5f5f5).setStrokeStyle(2, 0x9e9e9e);
|
||
// 窗外,一只灰色的、真的麻雀
|
||
const sparrow = this.add.graphics();
|
||
sparrow.fillStyle(0x777777, 1);
|
||
sparrow.fillCircle(GAME_W / 2 + 30, 96, 3); // 身
|
||
sparrow.fillCircle(GAME_W / 2 + 33, 93, 2); // 头
|
||
sparrow.fillStyle(0x555555, 1);
|
||
sparrow.fillTriangle(GAME_W / 2 + 27, 96, GAME_W / 2 + 22, 94, GAME_W / 2 + 27, 99); // 尾
|
||
sparrow.fillStyle(0x333333, 1);
|
||
sparrow.fillCircle(GAME_W / 2 + 34, 92, 0.5); // 眼
|
||
// 真实的脸
|
||
this.add.image(GAME_W / 2, GAME_H - 60, 'portrait_player_real').setScale(2);
|
||
this.subtitle('(疗程结束。欢迎回来。)', GAME_H - 24);
|
||
await this.wait(3000);
|
||
this.endScreen();
|
||
}
|
||
|
||
/** E4 静默:空广场,彩带在没有风的空气里飘 */
|
||
private async playE4(): Promise<void> {
|
||
this.cameras.main.setBackgroundColor('#cfcfcf');
|
||
// 空广场(plaza 地图做背景,无 NPC)
|
||
const map = this.make.tilemap({ key: 'map_plaza' });
|
||
const tsName = map.tilesets.length > 0 ? map.tilesets[0].name : 'candy';
|
||
const tiles = map.addTilesetImage(tsName, 'tileset');
|
||
if (tiles) {
|
||
map.createLayer('ground', tiles, 0, -60);
|
||
map.createLayer('obstacles', tiles, 0, -60);
|
||
}
|
||
this.cm?.saturate(0.4);
|
||
|
||
// 彩带在没有风的空气里飘
|
||
const ribbons: Phaser.GameObjects.Image[] = [];
|
||
for (let i = 0; i < 6; i++) {
|
||
const r = this.add.image(40 + i * 50, 30 + (i % 3) * 20, 'tiles16', [29, 30, 31][i % 3]);
|
||
ribbons.push(r);
|
||
this.tweens.add({
|
||
targets: r, y: r.y + 6, duration: 2200 + i * 300,
|
||
yoyo: true, repeat: -1, ease: 'Sine.easeInOut',
|
||
});
|
||
}
|
||
|
||
await this.wait(3500);
|
||
this.subtitle(E4_SUBTITLE);
|
||
await this.wait(2500);
|
||
// 黑屏
|
||
this.cameras.main.fadeOut(1500, 0, 0, 0);
|
||
await this.wait(1600);
|
||
this.endScreen();
|
||
}
|
||
}
|