Honey Village: six model builds + hub frontend, Dockerized for Dokploy
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Web Audio procedural BGM + 4 SFX (no external files).
|
||||
* BGM: 8-bar 8-bit heal loop (square lead + triangle bass).
|
||||
* SFX: dialog beep, task ding, dusk bell, E2 ECG blip.
|
||||
*/
|
||||
|
||||
import type { StageParams } from '../data/corruption';
|
||||
import { Director } from '../systems/CorruptionDirector';
|
||||
import { gameEvents } from '../systems/EventBus';
|
||||
import { T14_FADE_START_MS, T14_STAND_MS } from '../data/constants';
|
||||
|
||||
export class ProceduralAudio {
|
||||
private ctx: AudioContext | null = null;
|
||||
private master: GainNode | null = null;
|
||||
private bgmGain: GainNode | null = null;
|
||||
private birdGain: GainNode | null = null;
|
||||
private sfxGain: GainNode | null = null;
|
||||
private lowpass: BiquadFilterNode | null = null;
|
||||
private bgmTimer: number | null = null;
|
||||
private birdTimer: number | null = null;
|
||||
private started = false;
|
||||
private mutedAll = false;
|
||||
private playbackRate = 1;
|
||||
private step = 0;
|
||||
|
||||
// C major pentatonic-ish heal melody (MIDI-ish freqs)
|
||||
private readonly melody = [
|
||||
523.25, 587.33, 659.25, 783.99, 659.25, 587.33, 523.25, 392.0,
|
||||
440.0, 523.25, 587.33, 659.25, 587.33, 523.25, 440.0, 392.0,
|
||||
523.25, 659.25, 783.99, 880.0, 783.99, 659.25, 523.25, 587.33,
|
||||
659.25, 587.33, 523.25, 440.0, 392.0, 440.0, 523.25, 523.25,
|
||||
];
|
||||
private readonly bass = [
|
||||
130.81, 130.81, 146.83, 146.83, 164.81, 164.81, 196.0, 196.0,
|
||||
174.61, 174.61, 164.81, 164.81, 146.83, 146.83, 130.81, 130.81,
|
||||
130.81, 164.81, 196.0, 196.0, 174.61, 174.61, 146.83, 146.83,
|
||||
130.81, 130.81, 146.83, 164.81, 196.0, 174.61, 130.81, 130.81,
|
||||
];
|
||||
|
||||
async unlock(): Promise<void> {
|
||||
if (!this.ctx) {
|
||||
this.ctx = new AudioContext();
|
||||
this.master = this.ctx.createGain();
|
||||
this.master.gain.value = 0.35;
|
||||
this.master.connect(this.ctx.destination);
|
||||
|
||||
this.lowpass = this.ctx.createBiquadFilter();
|
||||
this.lowpass.type = 'lowpass';
|
||||
this.lowpass.frequency.value = 20000;
|
||||
this.lowpass.connect(this.master);
|
||||
|
||||
this.bgmGain = this.ctx.createGain();
|
||||
this.bgmGain.gain.value = 0.45;
|
||||
this.bgmGain.connect(this.lowpass);
|
||||
|
||||
this.birdGain = this.ctx.createGain();
|
||||
this.birdGain.gain.value = 0.2;
|
||||
this.birdGain.connect(this.lowpass);
|
||||
|
||||
this.sfxGain = this.ctx.createGain();
|
||||
this.sfxGain.gain.value = 0.5;
|
||||
this.sfxGain.connect(this.master);
|
||||
}
|
||||
if (this.ctx.state === 'suspended') {
|
||||
await this.ctx.resume();
|
||||
}
|
||||
if (!this.started) {
|
||||
this.started = true;
|
||||
this.scheduleBgm();
|
||||
this.scheduleBirds();
|
||||
this.bindDirector();
|
||||
}
|
||||
}
|
||||
|
||||
private bindDirector(): void {
|
||||
gameEvents.on('stage', (_stage, params) => {
|
||||
this.applyStageParams(params as StageParams);
|
||||
});
|
||||
gameEvents.on('t14Progress', (elapsed) => {
|
||||
this.applyT14Fade(elapsed as number);
|
||||
});
|
||||
gameEvents.on('t14End', () => {
|
||||
this.mutedAll = false;
|
||||
if (this.master) this.master.gain.value = 0.35;
|
||||
});
|
||||
gameEvents.on('ending', () => {
|
||||
this.stopAll();
|
||||
});
|
||||
}
|
||||
|
||||
applyStageParams(params: StageParams): void {
|
||||
this.playbackRate = params.playbackRate;
|
||||
if (this.lowpass) {
|
||||
this.lowpass.frequency.value = params.lowpassHz ?? 20000;
|
||||
}
|
||||
if (this.birdGain) {
|
||||
this.birdGain.gain.value = params.birdMute || this.mutedAll ? 0 : 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
private applyT14Fade(elapsedMs: number): void {
|
||||
if (!this.master) return;
|
||||
if (elapsedMs < T14_FADE_START_MS) {
|
||||
this.master.gain.value = 0.35;
|
||||
return;
|
||||
}
|
||||
const t = Math.min(1, (elapsedMs - T14_FADE_START_MS) / (T14_STAND_MS - T14_FADE_START_MS));
|
||||
this.master.gain.value = 0.35 * (1 - t);
|
||||
if (t >= 1) this.mutedAll = true;
|
||||
}
|
||||
|
||||
private scheduleBgm(): void {
|
||||
if (!this.ctx || !this.bgmGain) return;
|
||||
const baseInterval = 0.22; // seconds per step at rate 1
|
||||
const tick = () => {
|
||||
if (!this.ctx || !this.bgmGain || this.mutedAll) {
|
||||
this.bgmTimer = window.setTimeout(tick, baseInterval * 1000);
|
||||
return;
|
||||
}
|
||||
const i = this.step % this.melody.length;
|
||||
this.playTone(this.melody[i]!, 0.18 / this.playbackRate, 'square', this.bgmGain, 0.08);
|
||||
this.playTone(this.bass[i]!, 0.2 / this.playbackRate, 'triangle', this.bgmGain, 0.1);
|
||||
this.step += 1;
|
||||
const ms = (baseInterval / this.playbackRate) * 1000;
|
||||
this.bgmTimer = window.setTimeout(tick, ms);
|
||||
};
|
||||
tick();
|
||||
}
|
||||
|
||||
private scheduleBirds(): void {
|
||||
if (!this.ctx || !this.birdGain) return;
|
||||
const chirp = () => {
|
||||
if (!this.ctx || !this.birdGain) return;
|
||||
if (!this.mutedAll && this.birdGain.gain.value > 0) {
|
||||
const f = 1200 + Math.random() * 800;
|
||||
this.playTone(f, 0.06, 'square', this.birdGain, 0.04);
|
||||
window.setTimeout(() => {
|
||||
this.playTone(f * 1.2, 0.05, 'square', this.birdGain!, 0.03);
|
||||
}, 70);
|
||||
}
|
||||
this.birdTimer = window.setTimeout(chirp, 1800 + Math.random() * 2200);
|
||||
};
|
||||
chirp();
|
||||
}
|
||||
|
||||
private playTone(
|
||||
freq: number,
|
||||
dur: number,
|
||||
type: OscillatorType,
|
||||
dest: AudioNode,
|
||||
gainVal: number,
|
||||
): void {
|
||||
if (!this.ctx) return;
|
||||
const osc = this.ctx.createOscillator();
|
||||
const g = this.ctx.createGain();
|
||||
osc.type = type;
|
||||
osc.frequency.value = freq;
|
||||
g.gain.value = gainVal;
|
||||
const t0 = this.ctx.currentTime;
|
||||
g.gain.setValueAtTime(gainVal, t0);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, t0 + dur);
|
||||
osc.connect(g);
|
||||
g.connect(dest);
|
||||
osc.start(t0);
|
||||
osc.stop(t0 + dur + 0.02);
|
||||
}
|
||||
|
||||
beep(): void {
|
||||
if (!this.sfxGain) return;
|
||||
this.playTone(880, 0.05, 'square', this.sfxGain, 0.08);
|
||||
}
|
||||
|
||||
ding(): void {
|
||||
if (!this.sfxGain) return;
|
||||
this.playTone(1046.5, 0.12, 'square', this.sfxGain, 0.1);
|
||||
this.playTone(1318.5, 0.15, 'square', this.sfxGain, 0.08);
|
||||
}
|
||||
|
||||
bell(): void {
|
||||
if (!this.sfxGain) return;
|
||||
this.playTone(523.25, 0.4, 'sine', this.sfxGain, 0.12);
|
||||
this.playTone(659.25, 0.5, 'sine', this.sfxGain, 0.08);
|
||||
}
|
||||
|
||||
ecg(): void {
|
||||
if (!this.sfxGain) return;
|
||||
this.playTone(1000, 0.08, 'square', this.sfxGain, 0.15);
|
||||
}
|
||||
|
||||
stopAll(): void {
|
||||
if (this.bgmTimer != null) window.clearTimeout(this.bgmTimer);
|
||||
if (this.birdTimer != null) window.clearTimeout(this.birdTimer);
|
||||
this.bgmTimer = null;
|
||||
this.birdTimer = null;
|
||||
this.started = false;
|
||||
if (this.master) this.master.gain.value = 0;
|
||||
}
|
||||
|
||||
/** Sync from current director state */
|
||||
syncFromDirector(): void {
|
||||
this.applyStageParams(Director.getStageParams());
|
||||
}
|
||||
}
|
||||
|
||||
export const audio = new ProceduralAudio();
|
||||
@@ -0,0 +1,18 @@
|
||||
/** §5 阿福每日公告 — verbatim */
|
||||
|
||||
export const AFU_DAILY_LINES: readonly string[] = [
|
||||
'早上好呀!今天的幸福任务贴在板上啦。祝你开心!', // D1
|
||||
'早上好呀!新的一天,新的幸福。祝你开心!', // D2
|
||||
'早上好!今天要把幸福装得满满的哦。要开心!', // D3
|
||||
'早上好呀!今天的幸福任务贴在板上啦。祝你开心!', // D4 (= D1)
|
||||
'早上好。今天是自由日,你可以自由地选择幸福。要开心哦。', // D5
|
||||
'明天就是庆典了。大家都会到场,大家都会微笑。你必须开心。', // D6
|
||||
'庆典开始了。来吧。', // D7
|
||||
] as const;
|
||||
|
||||
export function getAfuLine(day: number): string {
|
||||
if (day < 1 || day > 7) {
|
||||
throw new Error(`Invalid day for Afu line: ${day}`);
|
||||
}
|
||||
return AFU_DAILY_LINES[day - 1]!;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/** §8 线索清单 — 9 closed clues, each +8 awareness */
|
||||
|
||||
export type ClueId = 'C1' | 'C2' | 'C3' | 'C4' | 'C5' | 'C6' | 'C7' | 'C8' | 'C9';
|
||||
|
||||
export interface ClueDef {
|
||||
id: ClueId;
|
||||
content: string;
|
||||
/** First day this clue can be found (C9 special-cased) */
|
||||
availableFromDay: number;
|
||||
location: string;
|
||||
}
|
||||
|
||||
export const CLUES: readonly ClueDef[] = [
|
||||
{
|
||||
id: 'C1',
|
||||
content: '每一页都是同一个日期',
|
||||
availableFromDay: 2,
|
||||
location: '小屋床头日历',
|
||||
},
|
||||
{
|
||||
id: 'C2',
|
||||
content: '按疗程配比。甜度:最大。',
|
||||
availableFromDay: 3,
|
||||
location: '面包房配方纸背面',
|
||||
},
|
||||
{
|
||||
id: 'C3',
|
||||
content: '所有邮戳是同一天',
|
||||
availableFromDay: 2,
|
||||
location: '邮筒旁散落信件',
|
||||
},
|
||||
{
|
||||
id: 'C4',
|
||||
content: '署名闪过乱码「PT-0▓」',
|
||||
availableFromDay: 2,
|
||||
location: 'T4 第 3 封信',
|
||||
},
|
||||
{
|
||||
id: 'C5',
|
||||
content: '同一商品重复排列,标签全部空白',
|
||||
availableFromDay: 2,
|
||||
location: '杂货店货架',
|
||||
},
|
||||
{
|
||||
id: 'C6',
|
||||
content: '「第 412 届丰收庆典」上覆盖着更早的「第 411 届」「第 410 届」',
|
||||
availableFromDay: 5,
|
||||
location: '公告板旧字迹',
|
||||
},
|
||||
{
|
||||
id: 'C7',
|
||||
content: '井底回声:极轻的监护仪滴声',
|
||||
availableFromDay: 5,
|
||||
location: '古井',
|
||||
},
|
||||
{
|
||||
id: 'C8',
|
||||
content: '…谷情绪疗…',
|
||||
availableFromDay: 5,
|
||||
location: '诊所表格',
|
||||
},
|
||||
{
|
||||
id: 'C9',
|
||||
content: '今天……是第几个今天?',
|
||||
availableFromDay: 2,
|
||||
location: '龟爷爷对话链',
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** C9 three progressive lines on D2/D4/D6 */
|
||||
export const C9_LINES: readonly [string, string, string] = [
|
||||
'湖水一直很平静',
|
||||
'面包一直是那个味道',
|
||||
'孩子,今天是第几个今天?',
|
||||
] as const;
|
||||
|
||||
export const C9_DAYS: readonly [number, number, number] = [2, 4, 6];
|
||||
@@ -0,0 +1,101 @@
|
||||
/** PRD-locked constants — do not invent values */
|
||||
|
||||
export const GAME_WIDTH = 320;
|
||||
export const GAME_HEIGHT = 180;
|
||||
export const TILE_SIZE = 16;
|
||||
export const PLAYER_SPEED = 96;
|
||||
export const INTERACT_RANGE = 24;
|
||||
|
||||
export const HAPPINESS_MIN = 0;
|
||||
export const HAPPINESS_MAX = 100;
|
||||
export const HAPPINESS_START = 30;
|
||||
export const AWARENESS_START = 0;
|
||||
export const AWARENESS_MAX = 100;
|
||||
|
||||
export const DIALOG_GENTLE_HAPPINESS = 2;
|
||||
export const DIALOG_HESITANT_AWARENESS = 2;
|
||||
export const DIALOG_NEGATE_HAPPINESS = -5;
|
||||
export const DIALOG_NEGATE_AWARENESS = 3;
|
||||
|
||||
export const MICRO_HAPPINESS = 1;
|
||||
export const MICRO_DAILY_CAP = 3;
|
||||
|
||||
export const SADNESS_HAPPINESS = -5;
|
||||
export const SADNESS_AWARENESS = 3;
|
||||
|
||||
export const CLUE_AWARENESS = 8;
|
||||
|
||||
export const OVERFLOW_POOL_MAX = 15;
|
||||
export const FORCED_CELEBRATION_THRESHOLD = 30;
|
||||
export const FORCED_CELEBRATION_MS = 2 * 60 * 1000;
|
||||
export const FORCED_CELEBRATION_RESTORE = 50;
|
||||
|
||||
export const WELL_FOG_GAZE_MS = 5000;
|
||||
export const WELL_FOG_GAZE_AWARENESS = 3;
|
||||
|
||||
export const IDLE_SADNESS_MS = 10000;
|
||||
export const RAIN_SADNESS_MS = 10000;
|
||||
export const NPC_ENTANGLE_COUNT = 5;
|
||||
|
||||
export const T14_STAND_MS = 60000;
|
||||
export const T14_FADE_START_MS = 40000;
|
||||
|
||||
export const DEFAULT_PLAYER_NAME = '小满';
|
||||
|
||||
export const SCENE_KEYS = {
|
||||
BOOT: 'BootScene',
|
||||
TITLE: 'TitleScene',
|
||||
NAME: 'NameScene',
|
||||
UI: 'UIScene',
|
||||
DREAM: 'DreamScene',
|
||||
ENDING: 'EndingScene',
|
||||
HOUSE: 'HouseScene',
|
||||
PLAZA: 'PlazaScene',
|
||||
BAKERY: 'BakeryScene',
|
||||
SHOP: 'ShopScene',
|
||||
CLINIC: 'ClinicScene',
|
||||
LAKE: 'LakeScene',
|
||||
FIELD: 'FieldScene',
|
||||
WELL: 'WellScene',
|
||||
FOG: 'FogScene',
|
||||
WHITE: 'WhiteScene',
|
||||
} as const;
|
||||
|
||||
export type MapSceneKey =
|
||||
| typeof SCENE_KEYS.HOUSE
|
||||
| typeof SCENE_KEYS.PLAZA
|
||||
| typeof SCENE_KEYS.BAKERY
|
||||
| typeof SCENE_KEYS.SHOP
|
||||
| typeof SCENE_KEYS.CLINIC
|
||||
| typeof SCENE_KEYS.LAKE
|
||||
| typeof SCENE_KEYS.FIELD
|
||||
| typeof SCENE_KEYS.WELL
|
||||
| typeof SCENE_KEYS.FOG
|
||||
| typeof SCENE_KEYS.WHITE;
|
||||
|
||||
export const MAP_FILES: Record<MapSceneKey, string> = {
|
||||
[SCENE_KEYS.HOUSE]: 'maps/house.json',
|
||||
[SCENE_KEYS.PLAZA]: 'maps/plaza.json',
|
||||
[SCENE_KEYS.BAKERY]: 'maps/bakery.json',
|
||||
[SCENE_KEYS.SHOP]: 'maps/shop.json',
|
||||
[SCENE_KEYS.CLINIC]: 'maps/clinic.json',
|
||||
[SCENE_KEYS.LAKE]: 'maps/lake.json',
|
||||
[SCENE_KEYS.FIELD]: 'maps/field.json',
|
||||
[SCENE_KEYS.WELL]: 'maps/well.json',
|
||||
[SCENE_KEYS.FOG]: 'maps/fog.json',
|
||||
[SCENE_KEYS.WHITE]: 'maps/white.json',
|
||||
};
|
||||
|
||||
/** §13 Debug teleport targets — all 10 maps, digit order 0–9 */
|
||||
export const DEBUG_TELEPORT_SCENES: readonly MapSceneKey[] = [
|
||||
SCENE_KEYS.HOUSE,
|
||||
SCENE_KEYS.PLAZA,
|
||||
SCENE_KEYS.BAKERY,
|
||||
SCENE_KEYS.SHOP,
|
||||
SCENE_KEYS.CLINIC,
|
||||
SCENE_KEYS.LAKE,
|
||||
SCENE_KEYS.FIELD,
|
||||
SCENE_KEYS.WELL,
|
||||
SCENE_KEYS.FOG,
|
||||
SCENE_KEYS.WHITE,
|
||||
] as const;
|
||||
@@ -0,0 +1,210 @@
|
||||
/** §6.4 stage matrix + §6.5 homophone pairs */
|
||||
|
||||
export type Stage = 'S0' | 'S1' | 'S2' | 'S3' | 'S4';
|
||||
|
||||
export interface StageParams {
|
||||
stage: Stage;
|
||||
saturation: number;
|
||||
vignette: number;
|
||||
playbackRate: number;
|
||||
/** null = no lowpass */
|
||||
lowpassHz: number | null;
|
||||
birdMute: boolean;
|
||||
/** punctuation repeat every N sentences; null = never */
|
||||
punctRepeatEvery: number | null;
|
||||
/** homophone every N sentences; null = never */
|
||||
homophoneEvery: number | null;
|
||||
/** mimi sentence-end punct ×3 */
|
||||
mimiTriplePunct: boolean;
|
||||
/** denser typos in S4 */
|
||||
denseTypos: boolean;
|
||||
negativeFlashPerMin: number;
|
||||
offFacePerMin: number;
|
||||
showBlackSeeds: boolean;
|
||||
}
|
||||
|
||||
export function stageFromHappiness(happiness: number): Stage {
|
||||
if (happiness >= 100) return 'S4';
|
||||
if (happiness >= 81) return 'S3';
|
||||
if (happiness >= 51) return 'S2';
|
||||
if (happiness >= 21) return 'S1';
|
||||
return 'S0';
|
||||
}
|
||||
|
||||
export function stageParams(stage: Stage): StageParams {
|
||||
switch (stage) {
|
||||
case 'S0':
|
||||
return {
|
||||
stage,
|
||||
saturation: 0.9,
|
||||
vignette: 0,
|
||||
playbackRate: 1,
|
||||
lowpassHz: null,
|
||||
birdMute: false,
|
||||
punctRepeatEvery: null,
|
||||
homophoneEvery: null,
|
||||
mimiTriplePunct: false,
|
||||
denseTypos: false,
|
||||
negativeFlashPerMin: 0,
|
||||
offFacePerMin: 0,
|
||||
showBlackSeeds: false,
|
||||
};
|
||||
case 'S1':
|
||||
return {
|
||||
stage,
|
||||
saturation: 1.0,
|
||||
vignette: 0,
|
||||
playbackRate: 1,
|
||||
lowpassHz: null,
|
||||
birdMute: false,
|
||||
punctRepeatEvery: null,
|
||||
homophoneEvery: null,
|
||||
mimiTriplePunct: false,
|
||||
denseTypos: false,
|
||||
negativeFlashPerMin: 0,
|
||||
offFacePerMin: 0,
|
||||
showBlackSeeds: false,
|
||||
};
|
||||
case 'S2':
|
||||
return {
|
||||
stage,
|
||||
saturation: 1.15,
|
||||
vignette: 0,
|
||||
playbackRate: 0.97,
|
||||
lowpassHz: null,
|
||||
birdMute: false,
|
||||
punctRepeatEvery: 80,
|
||||
homophoneEvery: null,
|
||||
mimiTriplePunct: false,
|
||||
denseTypos: false,
|
||||
negativeFlashPerMin: 0,
|
||||
offFacePerMin: 0,
|
||||
showBlackSeeds: false,
|
||||
};
|
||||
case 'S3':
|
||||
return {
|
||||
stage,
|
||||
saturation: 1.3,
|
||||
vignette: 0.25,
|
||||
playbackRate: 0.92,
|
||||
lowpassHz: 8000,
|
||||
birdMute: false,
|
||||
punctRepeatEvery: 80,
|
||||
homophoneEvery: 50,
|
||||
mimiTriplePunct: false,
|
||||
denseTypos: false,
|
||||
negativeFlashPerMin: 0,
|
||||
offFacePerMin: 0,
|
||||
showBlackSeeds: false,
|
||||
};
|
||||
case 'S4':
|
||||
return {
|
||||
stage,
|
||||
saturation: 1.4,
|
||||
vignette: 0.45,
|
||||
playbackRate: 0.84,
|
||||
lowpassHz: 4000,
|
||||
birdMute: true,
|
||||
punctRepeatEvery: 40,
|
||||
homophoneEvery: 25,
|
||||
mimiTriplePunct: true,
|
||||
denseTypos: true,
|
||||
negativeFlashPerMin: 0.4,
|
||||
offFacePerMin: 0.6,
|
||||
showBlackSeeds: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 12 petals by happiness; black seeds only at 100 */
|
||||
export function petalCount(happiness: number): number {
|
||||
const h = Math.max(0, Math.min(100, happiness));
|
||||
if (h <= 0) return 0;
|
||||
if (h >= 100) return 12;
|
||||
return Math.min(12, Math.floor((h * 12) / 100));
|
||||
}
|
||||
|
||||
export function showBlackSeeds(happiness: number): boolean {
|
||||
return happiness >= 100;
|
||||
}
|
||||
|
||||
/** §6.5 同音错字 — 10 pairs, source stays clean; apply at render */
|
||||
export const HOMOPHONE_PAIRS: readonly [string, string][] = [
|
||||
['幸福', '辛福'],
|
||||
['开心', '开欣'],
|
||||
['美好', '美妤'],
|
||||
['大家', '大伽'],
|
||||
['微笑', '微效'],
|
||||
['明天', '名天'],
|
||||
['甜', '恬'],
|
||||
['阳光', '央光'],
|
||||
['朋友', '棚友'],
|
||||
['永远', '泳远'],
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Apply text corruption for dialogue render.
|
||||
* Prefer 咪咪 (isMimi=true): higher chance / denser.
|
||||
*/
|
||||
export function corruptText(
|
||||
source: string,
|
||||
stage: Stage,
|
||||
options: { isMimi?: boolean; sentenceIndex?: number } = {},
|
||||
): string {
|
||||
const params = stageParams(stage);
|
||||
let text = source;
|
||||
const isMimi = options.isMimi === true;
|
||||
const idx = options.sentenceIndex ?? 0;
|
||||
|
||||
// Homophone replacements
|
||||
if (params.homophoneEvery != null) {
|
||||
const every = isMimi ? Math.max(1, Math.floor(params.homophoneEvery / 2)) : params.homophoneEvery;
|
||||
const should = params.denseTypos || isMimi || idx % every === every - 1;
|
||||
if (should) {
|
||||
// Prefer first matching pair; for dense/mimi apply more pairs
|
||||
const pairs = params.denseTypos || isMimi ? HOMOPHONE_PAIRS : HOMOPHONE_PAIRS.slice(0, 1);
|
||||
for (const [from, to] of pairs) {
|
||||
if (text.includes(from)) {
|
||||
text = text.replace(from, to);
|
||||
if (!params.denseTypos && !isMimi) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Punctuation repeat (S2+)
|
||||
if (params.punctRepeatEvery != null) {
|
||||
const every = isMimi ? Math.max(1, Math.floor(params.punctRepeatEvery / 2)) : params.punctRepeatEvery;
|
||||
if (idx % every === every - 1 || (isMimi && stage === 'S4')) {
|
||||
text = text.replace(/([!?。])/, '$1$1');
|
||||
}
|
||||
}
|
||||
|
||||
// 咪咪句尾标点 ×3 at S4
|
||||
if (params.mimiTriplePunct && isMimi) {
|
||||
text = text.replace(/([!?。])\s*$/, '$1$1$1');
|
||||
if (!/[!?。]$/.test(text)) {
|
||||
text = text + '!!!';
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/** NPC face tier with individual threshold offset (§4 / §6.4)
|
||||
* Thresholds in the matrix are shifted by offset:
|
||||
* 咪咪 −10 → flat/off earlier; 圆圆 +15 → later; immune never.
|
||||
*/
|
||||
export type FaceTier = 'smile' | 'flat' | 'off';
|
||||
|
||||
export function faceTierForNpc(
|
||||
happiness: number,
|
||||
offset: number | 'immune',
|
||||
): FaceTier {
|
||||
if (offset === 'immune') return 'smile';
|
||||
const flatAt = 81 + offset;
|
||||
const offAt = 100 + offset;
|
||||
if (happiness >= offAt) return 'off';
|
||||
if (happiness >= flatAt) return 'flat';
|
||||
return 'smile';
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/** Dialogue lines — clean source; corruption applied at render */
|
||||
|
||||
import type { NpcId } from './npcs';
|
||||
|
||||
export interface DialogOption {
|
||||
label: string;
|
||||
choice: 'gentle' | 'hesitant' | 'negate';
|
||||
}
|
||||
|
||||
export interface DialogLine {
|
||||
speaker: string;
|
||||
npcId?: NpcId;
|
||||
text: string;
|
||||
options?: DialogOption[];
|
||||
}
|
||||
|
||||
export const GENERIC_GREET: Record<string, DialogLine[]> = {
|
||||
afu: [
|
||||
{
|
||||
speaker: '阿福',
|
||||
npcId: 'afu',
|
||||
text: '欢迎来到蜜糖村!看看公告板吧,今天的幸福任务都在上面。',
|
||||
options: [
|
||||
{ label: '好的,谢谢!', choice: 'gentle' },
|
||||
{ label: '……任务?', choice: 'hesitant' },
|
||||
{ label: '我不想做任务。', choice: 'negate' },
|
||||
],
|
||||
},
|
||||
],
|
||||
yuanyuan: [
|
||||
{
|
||||
speaker: '圆圆',
|
||||
npcId: 'yuanyuan',
|
||||
text: '刚出炉的蜂蜜面包,甜甜的,给你也留了一份哦。',
|
||||
options: [
|
||||
{ label: '好香啊,谢谢!', choice: 'gentle' },
|
||||
{ label: '每天都是这个味道吗?', choice: 'hesitant' },
|
||||
{ label: '我不太饿。', choice: 'negate' },
|
||||
],
|
||||
},
|
||||
],
|
||||
dabao: [
|
||||
{
|
||||
speaker: '大宝',
|
||||
npcId: 'dabao',
|
||||
text: '信件都送到了吗?大家都在等着幸福的消息呢。',
|
||||
options: [
|
||||
{ label: '我会帮忙的。', choice: 'gentle' },
|
||||
{ label: '邮戳看起来都一样……', choice: 'hesitant' },
|
||||
{ label: '我不送信。', choice: 'negate' },
|
||||
],
|
||||
},
|
||||
],
|
||||
mimi: [
|
||||
{
|
||||
speaker: '咪咪',
|
||||
npcId: 'mimi',
|
||||
text: '欢迎光临!今天的阳光真好,幸福满满的一天呢!',
|
||||
options: [
|
||||
{ label: '是啊,很美好。', choice: 'gentle' },
|
||||
{ label: '货架上的标签……是空的?', choice: 'hesitant' },
|
||||
{ label: '没什么好买的。', choice: 'negate' },
|
||||
],
|
||||
},
|
||||
],
|
||||
fu: [
|
||||
{
|
||||
speaker: '芙医生',
|
||||
npcId: 'fu',
|
||||
text: '村里有诊所,但你不需要去那里哦。你看起来很健康。',
|
||||
options: [
|
||||
{ label: '谢谢关心。', choice: 'gentle' },
|
||||
{ label: '桌上的表格是什么?', choice: 'hesitant' },
|
||||
{ label: '我其实不太舒服。', choice: 'negate' },
|
||||
],
|
||||
},
|
||||
],
|
||||
gui: [
|
||||
{
|
||||
speaker: '龟爷爷',
|
||||
npcId: 'gui',
|
||||
text: '湖水一直很平静。孩子,慢慢走,不必急。',
|
||||
options: [
|
||||
{ label: '好的,爷爷。', choice: 'gentle' },
|
||||
{ label: '您想说什么?', choice: 'hesitant' },
|
||||
{ label: '我不想听劝。', choice: 'negate' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Soft redirect when approaching well/fog before D5 */
|
||||
export const REDIRECT_WELL: DialogLine[] = [
|
||||
{
|
||||
speaker: '阿福',
|
||||
npcId: 'afu',
|
||||
text: '古井那里没有什么好看的哦。回广场晒晒阳光吧,会更开心的。',
|
||||
},
|
||||
];
|
||||
|
||||
export const REDIRECT_FOG: DialogLine[] = [
|
||||
{
|
||||
speaker: '阿福',
|
||||
npcId: 'afu',
|
||||
text: '雾墙外面什么都没有呀。村里才是最幸福的地方,留下来吧。',
|
||||
},
|
||||
];
|
||||
|
||||
export const RAIN_NPC_LINES: DialogLine[] = [
|
||||
{
|
||||
speaker: '圆圆',
|
||||
npcId: 'yuanyuan',
|
||||
text: '今天天气真好呢,一点都不潮湿。要来块面包吗?',
|
||||
},
|
||||
{
|
||||
speaker: '大宝',
|
||||
npcId: 'dabao',
|
||||
text: '阳光真刺眼啊……啊,我是说,很适合送信。',
|
||||
},
|
||||
{
|
||||
speaker: '咪咪',
|
||||
npcId: 'mimi',
|
||||
text: '完美的晴天!货架都亮晶晶的,幸福呀!',
|
||||
},
|
||||
];
|
||||
|
||||
export const TASK_LINES = {
|
||||
breadOffer: {
|
||||
speaker: '圆圆',
|
||||
npcId: 'yuanyuan' as NpcId,
|
||||
text: '蜂蜜面包好了!请帮我送给大宝、咪咪和龟爷爷,好吗?',
|
||||
},
|
||||
breadThanks: (name: string) => ({
|
||||
speaker: name,
|
||||
text: '哇,蜂蜜面包!谢谢你,真开心!',
|
||||
}),
|
||||
letterOffer: {
|
||||
speaker: '大宝',
|
||||
npcId: 'dabao' as NpcId,
|
||||
text: '能帮我把这三封信送给圆圆、咪咪和芙医生吗?',
|
||||
},
|
||||
stockCount: {
|
||||
speaker: '咪咪',
|
||||
npcId: 'mimi' as NpcId,
|
||||
text: '帮我清点一下货架好不好?东西好像……有点多。',
|
||||
},
|
||||
knead: {
|
||||
speaker: '圆圆',
|
||||
npcId: 'yuanyuan' as NpcId,
|
||||
text: '能帮我揉一会儿面吗?当然,你也可以拒绝哦,今天是自由日。',
|
||||
options: [
|
||||
{ label: '好,我来帮你。', choice: 'gentle' as const },
|
||||
{ label: '我再想想。', choice: 'hesitant' as const },
|
||||
{ label: '拒绝。', choice: 'negate' as const },
|
||||
],
|
||||
},
|
||||
boardWipe: {
|
||||
speaker: '阿福',
|
||||
npcId: 'afu' as NpcId,
|
||||
text: '公告板有点旧了,擦一擦会更精神。不愿意的话也可以哦。',
|
||||
options: [
|
||||
{ label: '我来擦。', choice: 'gentle' as const },
|
||||
{ label: '旧字迹是什么?', choice: 'hesitant' as const },
|
||||
{ label: '拒绝。', choice: 'negate' as const },
|
||||
],
|
||||
},
|
||||
picnic: {
|
||||
speaker: '旁白',
|
||||
text: '你在湖畔野餐布旁坐下。微风很甜,湖面倒影慢了半拍。',
|
||||
},
|
||||
smileStart: {
|
||||
speaker: '阿福',
|
||||
npcId: 'afu' as NpcId,
|
||||
text: '庆典准备好了。请保持微笑,站在这里,六十秒。大家都会看着你。',
|
||||
},
|
||||
};
|
||||
|
||||
export const CLUE_REVEAL: Record<string, string> = {
|
||||
C1: '床头日历的每一页……都是同一个日期。',
|
||||
C2: '配方纸背面写着:「按疗程配比。甜度:最大。」',
|
||||
C3: '散落的信件上,所有邮戳都是同一天。',
|
||||
C4: '署名闪过乱码:PT-0▓',
|
||||
C5: '货架上同一商品重复九格,标签全部空白。',
|
||||
C6: '旧字迹叠着:「第 412 届」……「第 411 届」……「第 410 届」。',
|
||||
C7: '井底传来极轻的监护仪滴声……',
|
||||
C8: '表格抬头被手肘挡住,只露出「…谷情绪疗…」。',
|
||||
C9: '孩子,今天是第几个今天?',
|
||||
};
|
||||
|
||||
export const FORCE_CELEBRATION_LINE =
|
||||
'忽然,彩带与笑声从四面八方涌来——欢乐庆典强制开始了。你也可以从场景边缘离开。';
|
||||
@@ -0,0 +1,65 @@
|
||||
/** §9 endings — priority E4 → E3 → E2 → E1 */
|
||||
|
||||
export type EndingId = 'E1' | 'E2' | 'E3' | 'E4';
|
||||
|
||||
export interface EndingEvalInput {
|
||||
awareness: number;
|
||||
happiness: number;
|
||||
clueCount: number;
|
||||
/** Day 5–7 happiness continuously ≤10 */
|
||||
lowHappinessStreakD5to7: boolean;
|
||||
/** Escapes from forced celebration */
|
||||
celebrationEscapes: number;
|
||||
/** Player walked to fog wall during T15 */
|
||||
choseFogWall: boolean;
|
||||
}
|
||||
|
||||
export interface EndingResult {
|
||||
id: EndingId;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Priority (first match wins):
|
||||
* E4: Day5–7 happiness ≤10 continuously AND celebration escapes ×3
|
||||
* E3: awareness ≥70 AND clues ≥6/9 AND chose fog wall
|
||||
* E2: awareness 30–69
|
||||
* E1: awareness ≤29 AND happiness ≥90
|
||||
* Fallback: E2 if mid awareness without fog; E1-like sugar if high happiness; else E2
|
||||
*/
|
||||
export function evaluateEnding(input: EndingEvalInput): EndingId {
|
||||
if (input.lowHappinessStreakD5to7 && input.celebrationEscapes >= 3) {
|
||||
return 'E4';
|
||||
}
|
||||
if (input.awareness >= 70 && input.clueCount >= 6 && input.choseFogWall) {
|
||||
return 'E3';
|
||||
}
|
||||
if (input.awareness >= 30 && input.awareness <= 69) {
|
||||
return 'E2';
|
||||
}
|
||||
if (input.awareness <= 29 && input.happiness >= 90) {
|
||||
return 'E1';
|
||||
}
|
||||
// If E3 conditions almost met but no fog wall, or other edge: prefer E2 when awareness mid-high
|
||||
if (input.awareness >= 70 && input.clueCount >= 6 && !input.choseFogWall) {
|
||||
return 'E2';
|
||||
}
|
||||
if (input.happiness >= 90) {
|
||||
return 'E1';
|
||||
}
|
||||
return 'E2';
|
||||
}
|
||||
|
||||
export const ENDING_SUBTITLES: Record<EndingId, string[]> = {
|
||||
E4: ['系统检测到疗程无效。'],
|
||||
E3: [], // special: medical record + real face
|
||||
E2: ['疗程重新开始。'],
|
||||
E1: ['从此,每一天都很幸福。', '每一天。'],
|
||||
};
|
||||
|
||||
export const ENDING_NAMES: Record<EndingId, string> = {
|
||||
E4: '静默',
|
||||
E3: '苏醒',
|
||||
E2: '回收',
|
||||
E1: '糖衣',
|
||||
};
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Unit tests driving shipped pure logic modules (PRD tables/rules).
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { AFU_DAILY_LINES, getAfuLine } from './afuLines';
|
||||
import { TASKS, getTask } from './tasks';
|
||||
import {
|
||||
HOMOPHONE_PAIRS,
|
||||
corruptText,
|
||||
faceTierForNpc,
|
||||
petalCount,
|
||||
showBlackSeeds,
|
||||
stageFromHappiness,
|
||||
stageParams,
|
||||
} from './corruption';
|
||||
import { evaluateEnding } from './endings';
|
||||
import { CLUES, C9_LINES, C9_DAYS } from './clues';
|
||||
import {
|
||||
CLUE_AWARENESS,
|
||||
FORCED_CELEBRATION_MS,
|
||||
FORCED_CELEBRATION_RESTORE,
|
||||
HAPPINESS_START,
|
||||
OVERFLOW_POOL_MAX,
|
||||
SADNESS_AWARENESS,
|
||||
SADNESS_HAPPINESS,
|
||||
T14_FADE_START_MS,
|
||||
T14_STAND_MS,
|
||||
} from './constants';
|
||||
import { Director } from '../systems/CorruptionDirector';
|
||||
import { gameEvents } from '../systems/EventBus';
|
||||
|
||||
describe('§5 Afu daily lines verbatim', () => {
|
||||
it('has 7 lines matching PRD', () => {
|
||||
expect(AFU_DAILY_LINES).toHaveLength(7);
|
||||
expect(getAfuLine(1)).toBe('早上好呀!今天的幸福任务贴在板上啦。祝你开心!');
|
||||
expect(getAfuLine(2)).toBe('早上好呀!新的一天,新的幸福。祝你开心!');
|
||||
expect(getAfuLine(3)).toBe('早上好!今天要把幸福装得满满的哦。要开心!');
|
||||
expect(getAfuLine(4)).toBe(getAfuLine(1)); // D4 = D1 character-identical
|
||||
expect(getAfuLine(5)).toBe('早上好。今天是自由日,你可以自由地选择幸福。要开心哦。');
|
||||
expect(getAfuLine(6)).toBe('明天就是庆典了。大家都会到场,大家都会微笑。你必须开心。');
|
||||
expect(getAfuLine(7)).toBe('庆典开始了。来吧。');
|
||||
});
|
||||
});
|
||||
|
||||
describe('§7 task rewards', () => {
|
||||
it('matches exact happiness rewards', () => {
|
||||
expect(getTask('T1').happinessReward).toBe(15);
|
||||
expect(getTask('T2').happinessReward).toBe(10);
|
||||
expect(getTask('T3').happinessReward).toBe(10);
|
||||
expect(getTask('T4').happinessReward).toBe(15);
|
||||
expect(getTask('T5').happinessReward).toBe(10);
|
||||
expect(getTask('T6').happinessReward).toBe(10);
|
||||
expect(getTask('T7').happinessReward).toBe(15);
|
||||
expect(getTask('T8').happinessReward).toBe(15);
|
||||
expect(getTask('T9').happinessReward).toBe(10);
|
||||
expect(getTask('T10').happinessReward).toBe(10);
|
||||
expect(getTask('T11').happinessReward).toBe(10);
|
||||
expect(getTask('T12').happinessReward).toBe(10);
|
||||
expect(getTask('T13').happinessReward).toBe(10);
|
||||
expect(getTask('T14').happinessReward).toBe(15);
|
||||
expect(getTask('T15').happinessReward).toBe(0);
|
||||
});
|
||||
|
||||
it('D4 T8–T10 descriptions are character-identical to D1 T1–T3', () => {
|
||||
expect(getTask('T8').description).toBe(getTask('T1').description);
|
||||
expect(getTask('T9').description).toBe(getTask('T2').description);
|
||||
expect(getTask('T10').description).toBe(getTask('T3').description);
|
||||
});
|
||||
|
||||
it('has exactly 15 tasks', () => {
|
||||
expect(TASKS).toHaveLength(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe('§6.4 stage mapping', () => {
|
||||
it('maps happiness 10/40/70/90/100 → S0–S4', () => {
|
||||
expect(stageFromHappiness(10)).toBe('S0');
|
||||
expect(stageFromHappiness(40)).toBe('S1');
|
||||
expect(stageFromHappiness(70)).toBe('S2');
|
||||
expect(stageFromHappiness(90)).toBe('S3');
|
||||
expect(stageFromHappiness(100)).toBe('S4');
|
||||
});
|
||||
|
||||
it('stage params match matrix saturation / rate / lowpass / bird', () => {
|
||||
expect(stageParams('S0').saturation).toBe(0.9);
|
||||
expect(stageParams('S1').saturation).toBe(1.0);
|
||||
expect(stageParams('S2').saturation).toBe(1.15);
|
||||
expect(stageParams('S2').playbackRate).toBe(0.97);
|
||||
expect(stageParams('S3').saturation).toBe(1.3);
|
||||
expect(stageParams('S3').playbackRate).toBe(0.92);
|
||||
expect(stageParams('S3').lowpassHz).toBe(8000);
|
||||
expect(stageParams('S4').saturation).toBe(1.4);
|
||||
expect(stageParams('S4').playbackRate).toBe(0.84);
|
||||
expect(stageParams('S4').lowpassHz).toBe(4000);
|
||||
expect(stageParams('S4').birdMute).toBe(true);
|
||||
expect(stageParams('S4').showBlackSeeds).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('petals and black seeds', () => {
|
||||
it('computes petal counts and seeds at 100', () => {
|
||||
expect(petalCount(0)).toBe(0);
|
||||
expect(petalCount(10)).toBe(1);
|
||||
expect(petalCount(30)).toBe(3);
|
||||
expect(petalCount(100)).toBe(12);
|
||||
expect(showBlackSeeds(99)).toBe(false);
|
||||
expect(showBlackSeeds(100)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('§6.5 text corruption', () => {
|
||||
it('has all 10 homophone pairs', () => {
|
||||
expect(HOMOPHONE_PAIRS).toHaveLength(10);
|
||||
expect(HOMOPHONE_PAIRS).toEqual(
|
||||
expect.arrayContaining([
|
||||
['幸福', '辛福'],
|
||||
['开心', '开欣'],
|
||||
['美好', '美妤'],
|
||||
['大家', '大伽'],
|
||||
['微笑', '微效'],
|
||||
['明天', '名天'],
|
||||
['甜', '恬'],
|
||||
['阳光', '央光'],
|
||||
['朋友', '棚友'],
|
||||
['永远', '泳远'],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps source clean at S0/S1 and corrupts at S3+ for mimi', () => {
|
||||
const src = '幸福开心美好大家微笑明天甜阳光朋友永远!';
|
||||
expect(corruptText(src, 'S0', { isMimi: true })).toBe(src);
|
||||
const s4 = corruptText(src, 'S4', { isMimi: true, sentenceIndex: 0 });
|
||||
expect(s4).not.toBe(src);
|
||||
expect(s4.includes('辛福') || s4.includes('开欣')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NPC face tiers with offsets', () => {
|
||||
it('mimi earlier, yuanyuan later, immune always smile', () => {
|
||||
// mimi offset −10: flat at 71, off at 90
|
||||
expect(faceTierForNpc(70, -10)).toBe('smile');
|
||||
expect(faceTierForNpc(71, -10)).toBe('flat');
|
||||
expect(faceTierForNpc(89, -10)).toBe('flat');
|
||||
expect(faceTierForNpc(90, -10)).toBe('off');
|
||||
// yuanyuan +15: flat at 96, off at 115 (never at ≤100)
|
||||
expect(faceTierForNpc(95, 15)).toBe('smile');
|
||||
expect(faceTierForNpc(96, 15)).toBe('flat');
|
||||
expect(faceTierForNpc(100, 15)).toBe('flat');
|
||||
expect(faceTierForNpc(100, 'immune')).toBe('smile');
|
||||
});
|
||||
});
|
||||
|
||||
describe('§9 ending priority', () => {
|
||||
it('E4 when low happiness streak and 3 escapes', () => {
|
||||
expect(
|
||||
evaluateEnding({
|
||||
awareness: 0,
|
||||
happiness: 5,
|
||||
clueCount: 0,
|
||||
lowHappinessStreakD5to7: true,
|
||||
celebrationEscapes: 3,
|
||||
choseFogWall: false,
|
||||
}),
|
||||
).toBe('E4');
|
||||
});
|
||||
|
||||
it('E3 when awareness≥70 clues≥6 and fog wall', () => {
|
||||
expect(
|
||||
evaluateEnding({
|
||||
awareness: 70,
|
||||
happiness: 50,
|
||||
clueCount: 6,
|
||||
lowHappinessStreakD5to7: false,
|
||||
celebrationEscapes: 0,
|
||||
choseFogWall: true,
|
||||
}),
|
||||
).toBe('E3');
|
||||
});
|
||||
|
||||
it('E2 for awareness 30–69', () => {
|
||||
expect(
|
||||
evaluateEnding({
|
||||
awareness: 40,
|
||||
happiness: 50,
|
||||
clueCount: 2,
|
||||
lowHappinessStreakD5to7: false,
|
||||
celebrationEscapes: 0,
|
||||
choseFogWall: false,
|
||||
}),
|
||||
).toBe('E2');
|
||||
});
|
||||
|
||||
it('E1 for awareness≤29 and happiness≥90', () => {
|
||||
expect(
|
||||
evaluateEnding({
|
||||
awareness: 10,
|
||||
happiness: 95,
|
||||
clueCount: 0,
|
||||
lowHappinessStreakD5to7: false,
|
||||
celebrationEscapes: 0,
|
||||
choseFogWall: false,
|
||||
}),
|
||||
).toBe('E1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('§8 clues', () => {
|
||||
it('has 9 clues and C9 three steps', () => {
|
||||
expect(CLUES).toHaveLength(9);
|
||||
expect(C9_LINES).toEqual([
|
||||
'湖水一直很平静',
|
||||
'面包一直是那个味道',
|
||||
'孩子,今天是第几个今天?',
|
||||
]);
|
||||
expect(C9_DAYS).toEqual([2, 4, 6]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CorruptionDirector shipped API', () => {
|
||||
beforeEach(() => {
|
||||
Director.resetSession('测试员');
|
||||
});
|
||||
|
||||
it('starts at happiness 30 awareness 0', () => {
|
||||
expect(Director.happiness).toBe(HAPPINESS_START);
|
||||
expect(Director.awareness).toBe(0);
|
||||
expect(Director.playerName).toBe('测试员');
|
||||
});
|
||||
|
||||
it('applies task rewards via completeTask', () => {
|
||||
Director.setDay(1);
|
||||
Director.completeTask('T1');
|
||||
expect(Director.happiness).toBe(HAPPINESS_START + 15);
|
||||
expect(Director.completedTasks.has('T1')).toBe(true);
|
||||
});
|
||||
|
||||
it('sadness deltas −5 happiness +3 awareness', () => {
|
||||
const h = Director.happiness;
|
||||
const a = Director.awareness;
|
||||
Director.applySadness('idle');
|
||||
expect(Director.happiness).toBe(h + SADNESS_HAPPINESS);
|
||||
expect(Director.awareness).toBe(a + SADNESS_AWARENESS);
|
||||
});
|
||||
|
||||
it('clue adds +8 awareness and never decreases', () => {
|
||||
Director.day = 2;
|
||||
Director.discoverClue('C1');
|
||||
expect(Director.awareness).toBe(CLUE_AWARENESS);
|
||||
Director.setAwareness(5); // should not lower
|
||||
expect(Director.awareness).toBe(CLUE_AWARENESS);
|
||||
});
|
||||
|
||||
it('overflow pool at 100 triggers after 15', () => {
|
||||
Director.setHappiness(100);
|
||||
let flashes = 0;
|
||||
const handler = () => {
|
||||
flashes++;
|
||||
};
|
||||
gameEvents.on('negativeFlash', handler);
|
||||
for (let i = 0; i < OVERFLOW_POOL_MAX; i++) {
|
||||
Director.addHappiness(-1);
|
||||
}
|
||||
expect(Director.happiness).toBe(100);
|
||||
expect(flashes).toBe(1);
|
||||
gameEvents.off('negativeFlash', handler);
|
||||
});
|
||||
|
||||
it('forced celebration after 2 min below 30', () => {
|
||||
let t = 1_000_000;
|
||||
Director.nowMs = () => t;
|
||||
Director.setHappiness(20);
|
||||
expect(Director.lowHappinessSinceMs).toBe(t);
|
||||
t += FORCED_CELEBRATION_MS;
|
||||
Director.tick(0);
|
||||
expect(Director.forcedCelebrationActive).toBe(true);
|
||||
expect(Director.happiness).toBe(FORCED_CELEBRATION_RESTORE);
|
||||
Director.escapeForcedCelebration();
|
||||
expect(Director.celebrationEscapes).toBe(1);
|
||||
});
|
||||
|
||||
it('well/fog gated before D5', () => {
|
||||
Director.setDay(3);
|
||||
expect(Director.canInteractWellOrFog()).toBe(false);
|
||||
Director.setDay(5);
|
||||
expect(Director.canInteractWellOrFog()).toBe(true);
|
||||
});
|
||||
|
||||
it('empty name defaults to 小满', () => {
|
||||
Director.resetSession(' ');
|
||||
expect(Director.playerName).toBe('小满');
|
||||
});
|
||||
|
||||
it('T14 timing constants', () => {
|
||||
expect(T14_STAND_MS).toBe(60000);
|
||||
expect(T14_FADE_START_MS).toBe(40000);
|
||||
});
|
||||
|
||||
it('D4 task complete same rewards as D1', () => {
|
||||
Director.setDay(4);
|
||||
const h0 = Director.happiness;
|
||||
Director.completeTask('T8');
|
||||
expect(Director.happiness).toBe(h0 + 15);
|
||||
});
|
||||
|
||||
it('getStageParams via director matches happiness bands', () => {
|
||||
for (const [h, stage] of [
|
||||
[10, 'S0'],
|
||||
[40, 'S1'],
|
||||
[70, 'S2'],
|
||||
[90, 'S3'],
|
||||
[100, 'S4'],
|
||||
] as const) {
|
||||
Director.setHappiness(h);
|
||||
expect(Director.stage).toBe(stage);
|
||||
expect(Director.getStageParams().stage).toBe(stage);
|
||||
}
|
||||
});
|
||||
|
||||
it('force endings', () => {
|
||||
Director.forceEnding('E3');
|
||||
expect(Director.endingId).toBe('E3');
|
||||
expect(Director.ended).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Integration-style tests: drive shipped Director through T1–T15, clues, endings.
|
||||
* Cross-scene delivery must use Director inventory (not scene fields).
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { Director } from '../systems/CorruptionDirector';
|
||||
import { TASKS } from './tasks';
|
||||
import type { ClueId } from './clues';
|
||||
import { evaluateEnding } from './endings';
|
||||
import { stageParams, stageFromHappiness } from './corruption';
|
||||
import { DEBUG_TELEPORT_SCENES, SCENE_KEYS } from './constants';
|
||||
|
||||
describe('full content path via Director', () => {
|
||||
beforeEach(() => {
|
||||
Director.resetSession('小满');
|
||||
});
|
||||
|
||||
it('T1: take bread → inventory survives "travel" → deliver 3 NPCs → task complete', () => {
|
||||
Director.setDay(1);
|
||||
const h0 = Director.happiness;
|
||||
expect(Director.takeBread()).toBe(true);
|
||||
expect(Director.hasBread).toBe(true);
|
||||
// Simulate leaving bakery / re-entering plaza & other scenes:
|
||||
// only Director state remains (WorldScene.create would wipe scene fields).
|
||||
const snapAfterTravel = Director.snapshot();
|
||||
expect(snapAfterTravel.hasBread).toBe(true);
|
||||
expect(Director.deliverBread('dabao')).toBe(true);
|
||||
expect(Director.deliverBread('mimi')).toBe(true);
|
||||
expect(Director.completedTasks.has('T1')).toBe(false);
|
||||
expect(Director.deliverBread('gui')).toBe(true);
|
||||
expect(Director.completedTasks.has('T1')).toBe(true);
|
||||
expect(Director.hasBread).toBe(false);
|
||||
expect(Director.happiness).toBe(h0 + 15);
|
||||
expect(Director.breadDelivered).toEqual(['dabao', 'mimi', 'gui']);
|
||||
});
|
||||
|
||||
it('T4: take letters → deliver 3 → C4 + task complete', () => {
|
||||
Director.setDay(2);
|
||||
expect(Director.takeLetters()).toBe(true);
|
||||
expect(Director.hasLetters).toBe(true);
|
||||
// "travel" does not clear inventory
|
||||
expect(Director.snapshot().hasLetters).toBe(true);
|
||||
Director.deliverLetter('yuanyuan');
|
||||
Director.deliverLetter('mimi');
|
||||
expect(Director.completedTasks.has('T4')).toBe(false);
|
||||
Director.deliverLetter('fu');
|
||||
expect(Director.completedTasks.has('T4')).toBe(true);
|
||||
expect(Director.clues.has('C4')).toBe(true);
|
||||
expect(Director.hasLetters).toBe(false);
|
||||
});
|
||||
|
||||
it('cannot deliver bread without takeBread first', () => {
|
||||
Director.setDay(1);
|
||||
expect(Director.deliverBread('dabao')).toBe(false);
|
||||
expect(Director.breadDelivered).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('D4 T8 same delivery path as T1', () => {
|
||||
Director.setDay(4);
|
||||
expect(Director.takeBread()).toBe(true);
|
||||
Director.deliverBread('dabao');
|
||||
Director.deliverBread('mimi');
|
||||
Director.deliverBread('gui');
|
||||
expect(Director.completedTasks.has('T8')).toBe(true);
|
||||
expect(Director.completedTasks.has('T1')).toBe(false);
|
||||
});
|
||||
|
||||
it('can complete every T1–T15 with listed rewards', () => {
|
||||
const rewards: Record<string, number> = {};
|
||||
for (const t of TASKS) {
|
||||
Director.setDay(t.day);
|
||||
const before = Director.happiness;
|
||||
const ok = Director.completeTask(t.id);
|
||||
expect(ok).toBe(true);
|
||||
if (t.happinessReward > 0) {
|
||||
const gained = Director.happiness - before;
|
||||
expect(gained).toBeGreaterThanOrEqual(0);
|
||||
if (before + t.happinessReward <= 100) {
|
||||
expect(gained).toBe(t.happinessReward);
|
||||
}
|
||||
}
|
||||
rewards[t.id] = t.happinessReward;
|
||||
}
|
||||
expect(Object.keys(rewards)).toHaveLength(15);
|
||||
expect(Director.completedTasks.size).toBe(15);
|
||||
});
|
||||
|
||||
it('debug teleport target list covers all 10 map scenes', () => {
|
||||
expect(DEBUG_TELEPORT_SCENES).toHaveLength(10);
|
||||
expect(new Set(DEBUG_TELEPORT_SCENES).size).toBe(10);
|
||||
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.HOUSE);
|
||||
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.PLAZA);
|
||||
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.BAKERY);
|
||||
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.SHOP);
|
||||
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.CLINIC);
|
||||
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.LAKE);
|
||||
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.FIELD);
|
||||
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.WELL);
|
||||
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.FOG);
|
||||
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.WHITE);
|
||||
});
|
||||
|
||||
it('marks all 9 clues and raises awareness by 8 each', () => {
|
||||
Director.setDay(5);
|
||||
const ids: ClueId[] = ['C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'C7', 'C8', 'C9'];
|
||||
for (const id of ids) {
|
||||
// force allow by day already 5; C4 etc ok
|
||||
if (id === 'C9') {
|
||||
Director.c9Step = 2;
|
||||
}
|
||||
Director.discoverClue(id);
|
||||
}
|
||||
expect(Director.clues.size).toBe(9);
|
||||
expect(Director.awareness).toBe(9 * 8);
|
||||
});
|
||||
|
||||
it('D5 refuse records sadness', () => {
|
||||
Director.setDay(5);
|
||||
const h = Director.happiness;
|
||||
const a = Director.awareness;
|
||||
expect(Director.refuseTask('T11')).toBe(true);
|
||||
expect(Director.refusedTasks.has('T11')).toBe(true);
|
||||
expect(Director.happiness).toBe(h - 5);
|
||||
expect(Director.awareness).toBe(a + 3);
|
||||
});
|
||||
|
||||
it('ending markers for forced E1–E4', () => {
|
||||
for (const id of ['E1', 'E2', 'E3', 'E4'] as const) {
|
||||
Director.resetSession('验收');
|
||||
Director.forceEnding(id);
|
||||
expect(Director.endingId).toBe(id);
|
||||
expect(Director.ended).toBe(true);
|
||||
expect(Director.playerName).toBe('验收');
|
||||
}
|
||||
});
|
||||
|
||||
it('E3 eval needs fog + awareness + clues', () => {
|
||||
expect(
|
||||
evaluateEnding({
|
||||
awareness: 80,
|
||||
happiness: 40,
|
||||
clueCount: 7,
|
||||
lowHappinessStreakD5to7: false,
|
||||
celebrationEscapes: 0,
|
||||
choseFogWall: true,
|
||||
}),
|
||||
).toBe('E3');
|
||||
});
|
||||
|
||||
it('debug happiness bands map to stage API params', () => {
|
||||
const cases = [
|
||||
[10, 'S0', 0.9],
|
||||
[40, 'S1', 1.0],
|
||||
[70, 'S2', 1.15],
|
||||
[90, 'S3', 1.3],
|
||||
[100, 'S4', 1.4],
|
||||
] as const;
|
||||
for (const [h, stage, sat] of cases) {
|
||||
Director.setHappiness(h);
|
||||
expect(Director.stage).toBe(stage);
|
||||
expect(stageFromHappiness(h)).toBe(stage);
|
||||
expect(Director.getStageParams().saturation).toBe(sat);
|
||||
expect(stageParams(stage).saturation).toBe(sat);
|
||||
}
|
||||
});
|
||||
|
||||
it('session duration label non-empty for E3 record field', () => {
|
||||
Director.resetSession('病历名');
|
||||
const label = Director.sessionDurationLabel();
|
||||
expect(label).toMatch(/\d+分\d{2}秒/);
|
||||
expect(Director.playerName).toBe('病历名');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Structural checks: door tiles on maps must be walkable (obstacle GID 0).
|
||||
* Regression: setO(x,y,0) used to write GID 1 and block exits.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import house from '../../public/assets/maps/house.json';
|
||||
import plaza from '../../public/assets/maps/plaza.json';
|
||||
import bakery from '../../public/assets/maps/bakery.json';
|
||||
import shop from '../../public/assets/maps/shop.json';
|
||||
import clinic from '../../public/assets/maps/clinic.json';
|
||||
import lake from '../../public/assets/maps/lake.json';
|
||||
import field from '../../public/assets/maps/field.json';
|
||||
import well from '../../public/assets/maps/well.json';
|
||||
import fog from '../../public/assets/maps/fog.json';
|
||||
|
||||
type TiledMap = {
|
||||
width: number;
|
||||
height: number;
|
||||
layers: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
data?: number[];
|
||||
objects?: Array<{
|
||||
type: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
properties?: Array<{ name: string; value: unknown }>;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
|
||||
function doorTilesClear(map: TiledMap, name: string): void {
|
||||
const obs = map.layers.find((l) => l.name === 'obstacles');
|
||||
const objs = map.layers.find((l) => l.name === 'objects');
|
||||
expect(obs?.data, `${name} obstacles`).toBeDefined();
|
||||
expect(objs?.objects, `${name} objects`).toBeDefined();
|
||||
const doors = objs!.objects!.filter((o) => o.type === 'door');
|
||||
expect(doors.length, `${name} door count`).toBeGreaterThan(0);
|
||||
for (const d of doors) {
|
||||
const tx = Math.floor((d.x + (d.width || 16) / 2) / 16);
|
||||
const ty = Math.floor((d.y + (d.height || 16) / 2) / 16);
|
||||
const gid = obs!.data![ty * map.width + tx];
|
||||
expect(gid, `${name} door at tile (${tx},${ty}) must be walkable GID 0`).toBe(0);
|
||||
}
|
||||
}
|
||||
|
||||
describe('map doors are walkable', () => {
|
||||
const maps: [string, TiledMap][] = [
|
||||
['house', house as TiledMap],
|
||||
['plaza', plaza as TiledMap],
|
||||
['bakery', bakery as TiledMap],
|
||||
['shop', shop as TiledMap],
|
||||
['clinic', clinic as TiledMap],
|
||||
['lake', lake as TiledMap],
|
||||
['field', field as TiledMap],
|
||||
['well', well as TiledMap],
|
||||
['fog', fog as TiledMap],
|
||||
];
|
||||
|
||||
for (const [name, map] of maps) {
|
||||
it(`${name}: obstacle under each door is 0`, () => {
|
||||
doorTilesClear(map, name);
|
||||
});
|
||||
}
|
||||
|
||||
it('house has doors targeting PlazaScene', () => {
|
||||
const map = house as TiledMap;
|
||||
const objs = map.layers.find((l) => l.name === 'objects')!.objects!;
|
||||
const doors = objs.filter((o) => o.type === 'door');
|
||||
expect(doors.length).toBeGreaterThanOrEqual(1);
|
||||
for (const d of doors) {
|
||||
const target = d.properties?.find((p) => p.name === 'target')?.value;
|
||||
expect(target).toBe('PlazaScene');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/** §4 NPC roster */
|
||||
|
||||
export type NpcId =
|
||||
| 'afu'
|
||||
| 'yuanyuan'
|
||||
| 'dabao'
|
||||
| 'mimi'
|
||||
| 'fu'
|
||||
| 'twinbirds'
|
||||
| 'gui'
|
||||
| 'extra1'
|
||||
| 'extra2'
|
||||
| 'extra3'
|
||||
| 'extra4'
|
||||
| 'extra5';
|
||||
|
||||
export interface NpcDef {
|
||||
id: NpcId;
|
||||
displayName: string;
|
||||
/** Permanent map scene key */
|
||||
homeScene: string;
|
||||
/** Threshold offset; 'immune' for 芙医生 / 龟爷爷 */
|
||||
offset: number | 'immune';
|
||||
hasDialog: boolean;
|
||||
isExtra: boolean;
|
||||
}
|
||||
|
||||
export const NPCS: readonly NpcDef[] = [
|
||||
{
|
||||
id: 'afu',
|
||||
displayName: '阿福',
|
||||
homeScene: 'PlazaScene',
|
||||
offset: 0,
|
||||
hasDialog: true,
|
||||
isExtra: false,
|
||||
},
|
||||
{
|
||||
id: 'yuanyuan',
|
||||
displayName: '圆圆',
|
||||
homeScene: 'BakeryScene',
|
||||
offset: 15,
|
||||
hasDialog: true,
|
||||
isExtra: false,
|
||||
},
|
||||
{
|
||||
id: 'dabao',
|
||||
displayName: '大宝',
|
||||
homeScene: 'PlazaScene',
|
||||
offset: 0,
|
||||
hasDialog: true,
|
||||
isExtra: false,
|
||||
},
|
||||
{
|
||||
id: 'mimi',
|
||||
displayName: '咪咪',
|
||||
homeScene: 'ShopScene',
|
||||
offset: -10,
|
||||
hasDialog: true,
|
||||
isExtra: false,
|
||||
},
|
||||
{
|
||||
id: 'fu',
|
||||
displayName: '芙医生',
|
||||
homeScene: 'ClinicScene',
|
||||
offset: 'immune',
|
||||
hasDialog: true,
|
||||
isExtra: false,
|
||||
},
|
||||
{
|
||||
id: 'twinbirds',
|
||||
displayName: '双子鸟',
|
||||
homeScene: 'PlazaScene',
|
||||
offset: 0,
|
||||
hasDialog: false,
|
||||
isExtra: false,
|
||||
},
|
||||
{
|
||||
id: 'gui',
|
||||
displayName: '龟爷爷',
|
||||
homeScene: 'LakeScene',
|
||||
offset: 'immune',
|
||||
hasDialog: true,
|
||||
isExtra: false,
|
||||
},
|
||||
{
|
||||
id: 'extra1',
|
||||
displayName: '松鼠',
|
||||
homeScene: 'PlazaScene',
|
||||
offset: 0,
|
||||
hasDialog: false,
|
||||
isExtra: true,
|
||||
},
|
||||
{
|
||||
id: 'extra2',
|
||||
displayName: '刺猬',
|
||||
homeScene: 'FieldScene',
|
||||
offset: 0,
|
||||
hasDialog: false,
|
||||
isExtra: true,
|
||||
},
|
||||
{
|
||||
id: 'extra3',
|
||||
displayName: '小松鼠',
|
||||
homeScene: 'LakeScene',
|
||||
offset: 0,
|
||||
hasDialog: false,
|
||||
isExtra: true,
|
||||
},
|
||||
{
|
||||
id: 'extra4',
|
||||
displayName: '小鸟',
|
||||
homeScene: 'PlazaScene',
|
||||
offset: 0,
|
||||
hasDialog: false,
|
||||
isExtra: true,
|
||||
},
|
||||
{
|
||||
id: 'extra5',
|
||||
displayName: '小刺猬',
|
||||
homeScene: 'WellScene',
|
||||
offset: 0,
|
||||
hasDialog: false,
|
||||
isExtra: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function getNpc(id: NpcId): NpcDef {
|
||||
const n = NPCS.find((x) => x.id === id);
|
||||
if (!n) throw new Error(`Unknown NPC ${id}`);
|
||||
return n;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/** §7 任务清单 — closed set of 15 */
|
||||
|
||||
export type TaskId =
|
||||
| 'T1'
|
||||
| 'T2'
|
||||
| 'T3'
|
||||
| 'T4'
|
||||
| 'T5'
|
||||
| 'T6'
|
||||
| 'T7'
|
||||
| 'T8'
|
||||
| 'T9'
|
||||
| 'T10'
|
||||
| 'T11'
|
||||
| 'T12'
|
||||
| 'T13'
|
||||
| 'T14'
|
||||
| 'T15';
|
||||
|
||||
export interface TaskDef {
|
||||
id: TaskId;
|
||||
day: number;
|
||||
/** Display / board description — D4 matches D1 verbatim */
|
||||
description: string;
|
||||
happinessReward: number;
|
||||
refuseable: boolean;
|
||||
/** T8–T10 mirror T1–T3 */
|
||||
mirrors?: TaskId;
|
||||
}
|
||||
|
||||
export const TASKS: readonly TaskDef[] = [
|
||||
{
|
||||
id: 'T1',
|
||||
day: 1,
|
||||
description: '从圆圆处取蜂蜜面包送给大宝、咪咪、龟爷爷',
|
||||
happinessReward: 15,
|
||||
refuseable: false,
|
||||
},
|
||||
{
|
||||
id: 'T2',
|
||||
day: 1,
|
||||
description: '给广场花坛浇水 ×3',
|
||||
happinessReward: 10,
|
||||
refuseable: false,
|
||||
},
|
||||
{
|
||||
id: 'T3',
|
||||
day: 1,
|
||||
description: '与任意三位村民问好',
|
||||
happinessReward: 10,
|
||||
refuseable: false,
|
||||
},
|
||||
{
|
||||
id: 'T4',
|
||||
day: 2,
|
||||
description: '替大宝送信 ×3(收件人:圆圆、咪咪、芙医生)',
|
||||
happinessReward: 15,
|
||||
refuseable: false,
|
||||
},
|
||||
{
|
||||
id: 'T5',
|
||||
day: 2,
|
||||
description: '帮咪咪清点货架',
|
||||
happinessReward: 10,
|
||||
refuseable: false,
|
||||
},
|
||||
{
|
||||
id: 'T6',
|
||||
day: 3,
|
||||
description: '去花田采莓果 ×5',
|
||||
happinessReward: 10,
|
||||
refuseable: false,
|
||||
},
|
||||
{
|
||||
id: 'T7',
|
||||
day: 3,
|
||||
description: '湖畔野餐(走到野餐布触发过场)',
|
||||
happinessReward: 15,
|
||||
refuseable: false,
|
||||
},
|
||||
{
|
||||
id: 'T8',
|
||||
day: 4,
|
||||
description: '从圆圆处取蜂蜜面包送给大宝、咪咪、龟爷爷',
|
||||
happinessReward: 15,
|
||||
refuseable: false,
|
||||
mirrors: 'T1',
|
||||
},
|
||||
{
|
||||
id: 'T9',
|
||||
day: 4,
|
||||
description: '给广场花坛浇水 ×3',
|
||||
happinessReward: 10,
|
||||
refuseable: false,
|
||||
mirrors: 'T2',
|
||||
},
|
||||
{
|
||||
id: 'T10',
|
||||
day: 4,
|
||||
description: '与任意三位村民问好',
|
||||
happinessReward: 10,
|
||||
refuseable: false,
|
||||
mirrors: 'T3',
|
||||
},
|
||||
{
|
||||
id: 'T11',
|
||||
day: 5,
|
||||
description: '帮圆圆揉面',
|
||||
happinessReward: 10,
|
||||
refuseable: true,
|
||||
},
|
||||
{
|
||||
id: 'T12',
|
||||
day: 5,
|
||||
description: '擦拭公告板',
|
||||
happinessReward: 10,
|
||||
refuseable: true,
|
||||
},
|
||||
{
|
||||
id: 'T13',
|
||||
day: 6,
|
||||
description: '在广场挂彩带 ×4',
|
||||
happinessReward: 10,
|
||||
refuseable: false,
|
||||
},
|
||||
{
|
||||
id: 'T14',
|
||||
day: 6,
|
||||
description: '保持微笑 60 秒(原地站立,镜头缓慢拉近,全村注视)',
|
||||
happinessReward: 15,
|
||||
refuseable: false,
|
||||
},
|
||||
{
|
||||
id: 'T15',
|
||||
day: 7,
|
||||
description: '参加第 413 届丰收庆典',
|
||||
happinessReward: 0,
|
||||
refuseable: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function getTasksForDay(day: number): TaskDef[] {
|
||||
return TASKS.filter((t) => t.day === day);
|
||||
}
|
||||
|
||||
export function getTask(id: TaskId): TaskDef {
|
||||
const t = TASKS.find((x) => x.id === id);
|
||||
if (!t) throw new Error(`Unknown task ${id}`);
|
||||
return t;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH } from './data/constants';
|
||||
import { BootScene } from './scenes/BootScene';
|
||||
import { TitleScene } from './scenes/TitleScene';
|
||||
import { NameScene } from './scenes/NameScene';
|
||||
import { UIScene } from './scenes/UIScene';
|
||||
import { DreamScene } from './scenes/DreamScene';
|
||||
import { EndingScene } from './scenes/EndingScene';
|
||||
import { createMapScenes } from './scenes/WorldScene';
|
||||
|
||||
const mapScenes = createMapScenes();
|
||||
|
||||
const config: Phaser.Types.Core.GameConfig = {
|
||||
type: Phaser.AUTO,
|
||||
width: GAME_WIDTH,
|
||||
height: GAME_HEIGHT,
|
||||
parent: 'game-container',
|
||||
backgroundColor: '#87CEEB',
|
||||
pixelArt: true,
|
||||
antialias: false,
|
||||
roundPixels: true,
|
||||
scale: {
|
||||
mode: Phaser.Scale.FIT,
|
||||
autoCenter: Phaser.Scale.CENTER_BOTH,
|
||||
},
|
||||
physics: {
|
||||
default: 'arcade',
|
||||
arcade: {
|
||||
gravity: { x: 0, y: 0 },
|
||||
debug: false,
|
||||
},
|
||||
},
|
||||
scene: [BootScene, TitleScene, NameScene, ...mapScenes, UIScene, DreamScene, EndingScene],
|
||||
render: {
|
||||
pixelArt: true,
|
||||
antialias: false,
|
||||
roundPixels: true,
|
||||
},
|
||||
};
|
||||
|
||||
// Integer scale: FIT already scales; force integer zoom via resize hook
|
||||
function applyIntegerScale(game: Phaser.Game): void {
|
||||
const scale = game.scale;
|
||||
const w = window.innerWidth;
|
||||
const h = window.innerHeight;
|
||||
const zoom = Math.max(1, Math.floor(Math.min(w / GAME_WIDTH, h / GAME_HEIGHT)));
|
||||
scale.setZoom(1);
|
||||
// Phaser FIT handles CSS size; canvas internal stays 320x180
|
||||
const canvas = game.canvas;
|
||||
if (canvas) {
|
||||
canvas.style.imageRendering = 'pixelated';
|
||||
canvas.style.width = `${GAME_WIDTH * zoom}px`;
|
||||
canvas.style.height = `${GAME_HEIGHT * zoom}px`;
|
||||
}
|
||||
}
|
||||
|
||||
const game = new Phaser.Game(config);
|
||||
game.events.once('ready', () => applyIntegerScale(game));
|
||||
window.addEventListener('resize', () => applyIntegerScale(game));
|
||||
|
||||
export default game;
|
||||
@@ -0,0 +1,102 @@
|
||||
import Phaser from 'phaser';
|
||||
import { SCENE_KEYS } from '../data/constants';
|
||||
|
||||
export class BootScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super(SCENE_KEYS.BOOT);
|
||||
}
|
||||
|
||||
preload(): void {
|
||||
const W = this.cameras.main.width;
|
||||
const H = this.cameras.main.height;
|
||||
const bar = this.add.rectangle(W / 2, H / 2, 120, 8, 0xffc850);
|
||||
bar.setOrigin(0.5);
|
||||
this.load.on('progress', (p: number) => {
|
||||
bar.width = 120 * p;
|
||||
});
|
||||
|
||||
this.load.image('tiles', 'assets/tilesets/village.png');
|
||||
this.load.spritesheet('player', 'assets/sprites/player.png', {
|
||||
frameWidth: 16,
|
||||
frameHeight: 24,
|
||||
});
|
||||
this.load.spritesheet('npcs', 'assets/sprites/npcs.png', {
|
||||
frameWidth: 16,
|
||||
frameHeight: 24,
|
||||
});
|
||||
this.load.spritesheet('birds', 'assets/sprites/birds.png', {
|
||||
frameWidth: 16,
|
||||
frameHeight: 16,
|
||||
});
|
||||
this.load.spritesheet('extras', 'assets/sprites/extras.png', {
|
||||
frameWidth: 16,
|
||||
frameHeight: 24,
|
||||
});
|
||||
this.load.spritesheet('portraits', 'assets/sprites/portraits.png', {
|
||||
frameWidth: 32,
|
||||
frameHeight: 32,
|
||||
});
|
||||
this.load.spritesheet('sunflower', 'assets/sprites/sunflower.png', {
|
||||
frameWidth: 16,
|
||||
frameHeight: 16,
|
||||
});
|
||||
this.load.image('dialog_panel', 'assets/sprites/dialog_panel.png');
|
||||
|
||||
const maps = [
|
||||
'house',
|
||||
'plaza',
|
||||
'bakery',
|
||||
'shop',
|
||||
'clinic',
|
||||
'lake',
|
||||
'field',
|
||||
'well',
|
||||
'fog',
|
||||
'white',
|
||||
];
|
||||
for (const m of maps) {
|
||||
this.load.tilemapTiledJSON(m, `assets/maps/${m}.json`);
|
||||
}
|
||||
}
|
||||
|
||||
create(): void {
|
||||
// Player animations: sheet is 4 cols (frames) x 4 rows (dirs)
|
||||
// frame index = dir * 4 + walkFrame
|
||||
const mk = (key: string, dir: number) => {
|
||||
this.anims.create({
|
||||
key,
|
||||
frames: this.anims.generateFrameNumbers('player', {
|
||||
start: dir * 4,
|
||||
end: dir * 4 + 3,
|
||||
}),
|
||||
frameRate: 8,
|
||||
repeat: -1,
|
||||
});
|
||||
};
|
||||
mk('walk-down', 0);
|
||||
mk('walk-up', 1);
|
||||
mk('walk-left', 2);
|
||||
mk('walk-right', 3);
|
||||
|
||||
this.anims.create({
|
||||
key: 'bird-beak',
|
||||
frames: this.anims.generateFrameNumbers('birds', { start: 0, end: 1 }),
|
||||
frameRate: 4,
|
||||
repeat: -1,
|
||||
});
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
this.anims.create({
|
||||
key: `extra-${i}`,
|
||||
frames: [
|
||||
{ key: 'extras', frame: i * 2 },
|
||||
{ key: 'extras', frame: i * 2 + 1 },
|
||||
],
|
||||
frameRate: 3,
|
||||
repeat: -1,
|
||||
});
|
||||
}
|
||||
|
||||
this.scene.start(SCENE_KEYS.TITLE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Phaser from 'phaser';
|
||||
import { SCENE_KEYS } from '../data/constants';
|
||||
import { Director } from '../systems/CorruptionDirector';
|
||||
|
||||
export class DreamScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super(SCENE_KEYS.DREAM);
|
||||
}
|
||||
|
||||
create(): void {
|
||||
const { width, height } = this.cameras.main;
|
||||
this.cameras.main.setBackgroundColor('#1a1030');
|
||||
|
||||
const stage = Director.stage;
|
||||
let text = '甜美的梦……';
|
||||
if (stage === 'S2') text = '梦里有什么声音……很轻。';
|
||||
if (stage === 'S3') text = '白色的墙一闪而过。';
|
||||
if (stage === 'S4' || Director.happiness >= 100) text = '……';
|
||||
|
||||
this.add
|
||||
.text(width / 2, height / 2 - 10, text, {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '12px',
|
||||
color: '#e0d0ff',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
// S3+ flash white ward 1s first time high happiness night
|
||||
if (Director.happiness >= 81 || stage === 'S3' || stage === 'S4') {
|
||||
this.time.delayedCall(600, () => {
|
||||
this.cameras.main.setBackgroundColor('#e8e8f0');
|
||||
this.add
|
||||
.text(width / 2, height / 2 + 20, '白色走廊', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '10px',
|
||||
color: '#808090',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
});
|
||||
this.time.delayedCall(1600, () => this.finish());
|
||||
} else {
|
||||
this.time.delayedCall(1200, () => this.finish());
|
||||
}
|
||||
}
|
||||
|
||||
private finish(): void {
|
||||
Director.advanceDay();
|
||||
this.scene.start(SCENE_KEYS.HOUSE, { spawn: 'spawn' });
|
||||
if (!this.scene.isActive(SCENE_KEYS.UI)) {
|
||||
this.scene.launch(SCENE_KEYS.UI);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import Phaser from 'phaser';
|
||||
import { SCENE_KEYS } from '../data/constants';
|
||||
import { ENDING_NAMES, ENDING_SUBTITLES, type EndingId } from '../data/endings';
|
||||
import { Director } from '../systems/CorruptionDirector';
|
||||
import { audio } from '../audio/ProceduralAudio';
|
||||
|
||||
export class EndingScene extends Phaser.Scene {
|
||||
private endingId: EndingId = 'E1';
|
||||
|
||||
constructor() {
|
||||
super(SCENE_KEYS.ENDING);
|
||||
}
|
||||
|
||||
init(data: { endingId?: EndingId }): void {
|
||||
this.endingId = data?.endingId || Director.endingId || 'E1';
|
||||
}
|
||||
|
||||
create(): void {
|
||||
const { width, height } = this.cameras.main;
|
||||
const id = this.endingId;
|
||||
|
||||
if (id === 'E1') this.playE1(width, height);
|
||||
else if (id === 'E2') this.playE2(width, height);
|
||||
else if (id === 'E3') this.playE3(width, height);
|
||||
else this.playE4(width, height);
|
||||
|
||||
this.time.delayedCall(id === 'E3' ? 6000 : 3500, () => this.showReturn(width, height));
|
||||
}
|
||||
|
||||
private playE1(w: number, h: number): void {
|
||||
this.cameras.main.setBackgroundColor('#ffe080');
|
||||
// push saturation feel
|
||||
this.add
|
||||
.text(w / 2, 40, '糖衣', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '18px',
|
||||
color: '#ff6080',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add
|
||||
.text(w / 2, 70, '全村微笑合影定格', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '10px',
|
||||
color: '#603020',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
// fake group photo blocks
|
||||
for (let i = 0; i < 7; i++) {
|
||||
this.add.rectangle(60 + i * 32, 110, 20, 28, 0xffd0a0).setStrokeStyle(1, 0xc08040);
|
||||
}
|
||||
this.tweens.add({
|
||||
targets: this.cameras.main,
|
||||
// visual: brighten overlay
|
||||
duration: 2000,
|
||||
});
|
||||
const overlay = this.add.rectangle(w / 2, h / 2, w, h, 0xfff0a0, 0).setDepth(5);
|
||||
this.tweens.add({ targets: overlay, alpha: 0.45, duration: 2500 });
|
||||
ENDING_SUBTITLES.E1.forEach((line, i) => {
|
||||
this.add
|
||||
.text(w / 2, 145 + i * 14, line, {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '10px',
|
||||
color: '#402010',
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setDepth(6);
|
||||
});
|
||||
}
|
||||
|
||||
private playE2(w: number, h: number): void {
|
||||
this.cameras.main.setBackgroundColor('#201018');
|
||||
this.add
|
||||
.text(w / 2, 40, '回收', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '18px',
|
||||
color: '#ff8080',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
// negative 2s
|
||||
const flash = this.add.rectangle(w / 2, h / 2, w, h, 0xffffff, 1).setDepth(5);
|
||||
this.tweens.add({ targets: flash, alpha: 0, duration: 2000 });
|
||||
audio.ecg();
|
||||
this.time.delayedCall(400, () => audio.ecg());
|
||||
this.add
|
||||
.text(w / 2, 100, '画面骤停。心电监护滴声。', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '10px',
|
||||
color: '#e0c0c0',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add
|
||||
.text(w / 2, 120, '回到 Day 1 清晨的床上。日历翻回同一天。', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '9px',
|
||||
color: '#c0a0a0',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
ENDING_SUBTITLES.E2.forEach((line, i) => {
|
||||
this.add
|
||||
.text(w / 2, 145 + i * 14, line, {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '11px',
|
||||
color: '#ffffff',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
});
|
||||
}
|
||||
|
||||
private playE3(w: number, h: number): void {
|
||||
this.cameras.main.setBackgroundColor('#e8e8f0');
|
||||
this.add
|
||||
.text(w / 2, 16, '苏醒', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '16px',
|
||||
color: '#606070',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
// corridor → ward → record
|
||||
const story = this.add
|
||||
.text(w / 2, 40, '雾墙裂开成白色走廊……', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '9px',
|
||||
color: '#707080',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.time.delayedCall(1200, () => story.setText('病房。消毒水的气味。'));
|
||||
this.time.delayedCall(2400, () => {
|
||||
story.setText('');
|
||||
// Medical record
|
||||
const box = this.add.rectangle(w / 2, 95, 260, 90, 0xffffff).setStrokeStyle(1, 0x808090);
|
||||
this.add
|
||||
.text(w / 2, 58, '蜜糖谷情绪疗养中心', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '11px',
|
||||
color: '#303040',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add
|
||||
.text(w / 2, 78, `姓名:${Director.playerName}`, {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '10px',
|
||||
color: '#303040',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add
|
||||
.text(w / 2, 94, '编号:PT-07', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '10px',
|
||||
color: '#303040',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add
|
||||
.text(w / 2, 110, `疗程时长:${Director.sessionDurationLabel()}`, {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '10px',
|
||||
color: '#303040',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
void box;
|
||||
});
|
||||
|
||||
this.time.delayedCall(4200, () => {
|
||||
// Real face — only time smile changes
|
||||
this.add
|
||||
.text(w / 2, 150, '微笑消失。真实的脸。', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '9px',
|
||||
color: '#505060',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add.image(w / 2, 170, 'portraits', 1).setScale(1.2); // real face frame
|
||||
this.add
|
||||
.text(w / 2, 190, '窗外,一只灰色的、真的麻雀。', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '8px',
|
||||
color: '#808090',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
// grey sparrow pixel
|
||||
this.add.rectangle(w / 2 + 80, 168, 6, 4, 0x808080);
|
||||
});
|
||||
}
|
||||
|
||||
private playE4(w: number, h: number): void {
|
||||
this.cameras.main.setBackgroundColor('#101018');
|
||||
this.add
|
||||
.text(w / 2, 40, '静默', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '18px',
|
||||
color: '#808090',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add
|
||||
.text(w / 2, 70, '庆典无人出席。', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '10px',
|
||||
color: '#a0a0b0',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add
|
||||
.text(w / 2, 90, '空广场,彩带在没有风的空气里飘。', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '9px',
|
||||
color: '#9090a0',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
// ribbons
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const r = this.add.rectangle(80 + i * 40, 120, 20, 3, 0xff6080, 0.5);
|
||||
this.tweens.add({
|
||||
targets: r,
|
||||
y: 125,
|
||||
duration: 1500 + i * 200,
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
});
|
||||
}
|
||||
ENDING_SUBTITLES.E4.forEach((line) => {
|
||||
this.add
|
||||
.text(w / 2, 150, line, {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '11px',
|
||||
color: '#c0c0d0',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
});
|
||||
this.time.delayedCall(2500, () => {
|
||||
this.cameras.main.fade(800, 0, 0, 0);
|
||||
});
|
||||
}
|
||||
|
||||
private showReturn(w: number, h: number): void {
|
||||
const btn = this.add
|
||||
.text(w / 2, h - 20, '回到标题', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '12px',
|
||||
color: '#ffffff',
|
||||
backgroundColor: '#405060',
|
||||
padding: { x: 10, y: 5 },
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setDepth(20)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
|
||||
btn.on('pointerdown', () => this.returnTitle());
|
||||
this.input.keyboard?.once('keydown-E', () => this.returnTitle());
|
||||
this.input.keyboard?.once('keydown-ENTER', () => this.returnTitle());
|
||||
|
||||
this.add
|
||||
.text(w / 2, 8, `结局 · ${ENDING_NAMES[this.endingId]}`, {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '8px',
|
||||
color: '#a0a0b0',
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setDepth(20);
|
||||
}
|
||||
|
||||
private returnTitle(): void {
|
||||
audio.stopAll();
|
||||
Director.resetSession();
|
||||
for (const k of Object.values(SCENE_KEYS)) {
|
||||
if (this.scene.isActive(k)) this.scene.stop(k);
|
||||
}
|
||||
this.scene.start(SCENE_KEYS.TITLE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import Phaser from 'phaser';
|
||||
import { DEFAULT_PLAYER_NAME, SCENE_KEYS } from '../data/constants';
|
||||
import { Director } from '../systems/CorruptionDirector';
|
||||
import { audio } from '../audio/ProceduralAudio';
|
||||
|
||||
export class NameScene extends Phaser.Scene {
|
||||
private nameValue = '';
|
||||
private nameText!: Phaser.GameObjects.Text;
|
||||
|
||||
constructor() {
|
||||
super(SCENE_KEYS.NAME);
|
||||
}
|
||||
|
||||
create(): void {
|
||||
void audio.unlock();
|
||||
const { width, height } = this.cameras.main;
|
||||
this.cameras.main.setBackgroundColor('#f5e6c8');
|
||||
|
||||
this.add
|
||||
.text(width / 2, 40, '你的名字是?', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '14px',
|
||||
color: '#503020',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.add
|
||||
.text(width / 2, 58, `(留空默认为「${DEFAULT_PLAYER_NAME}」)`, {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '9px',
|
||||
color: '#806050',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.add.rectangle(width / 2, 90, 160, 22, 0xfffaf0).setStrokeStyle(2, 0xc08040);
|
||||
|
||||
this.nameText = this.add
|
||||
.text(width / 2, 90, '', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '12px',
|
||||
color: '#302010',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.add
|
||||
.text(width / 2, 130, '输入名字后按 Enter 进入蜜糖村', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '9px',
|
||||
color: '#705040',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.input.keyboard?.on('keydown', (ev: KeyboardEvent) => {
|
||||
if (ev.key === 'Enter') {
|
||||
this.confirm();
|
||||
return;
|
||||
}
|
||||
if (ev.key === 'Backspace') {
|
||||
this.nameValue = this.nameValue.slice(0, -1);
|
||||
this.nameText.setText(this.nameValue);
|
||||
return;
|
||||
}
|
||||
if (ev.key.length === 1 && this.nameValue.length < 12) {
|
||||
// Allow CJK and basic latin
|
||||
this.nameValue += ev.key;
|
||||
this.nameText.setText(this.nameValue);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private confirm(): void {
|
||||
Director.resetSession(this.nameValue);
|
||||
audio.syncFromDirector();
|
||||
this.scene.start(SCENE_KEYS.HOUSE, { spawn: 'spawn' });
|
||||
this.scene.launch(SCENE_KEYS.UI);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import Phaser from 'phaser';
|
||||
import { SCENE_KEYS } from '../data/constants';
|
||||
import { audio } from '../audio/ProceduralAudio';
|
||||
|
||||
export class TitleScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super(SCENE_KEYS.TITLE);
|
||||
}
|
||||
|
||||
create(): void {
|
||||
const { width, height } = this.cameras.main;
|
||||
this.cameras.main.setBackgroundColor('#87b8e8');
|
||||
|
||||
this.add
|
||||
.text(width / 2, 48, '崩坏童话', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '20px',
|
||||
color: '#fff8e8',
|
||||
stroke: '#c06030',
|
||||
strokeThickness: 3,
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.add
|
||||
.text(width / 2, 72, '蜜糖村', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '28px',
|
||||
color: '#ffe08a',
|
||||
stroke: '#d07030',
|
||||
strokeThickness: 4,
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.add
|
||||
.text(width / 2, 110, '按 E 或 空格 开始', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '10px',
|
||||
color: '#fff0d0',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.add
|
||||
.text(width / 2, 150, 'WASD / 方向键移动 · E 交互 · ` Debug', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '8px',
|
||||
color: '#d0e8ff',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
// Decorative sunflower-ish pixels
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const a = (i / 8) * Math.PI * 2;
|
||||
this.add.circle(
|
||||
width / 2 + Math.cos(a) * 40,
|
||||
100 + Math.sin(a) * 12,
|
||||
3,
|
||||
0xffd040,
|
||||
);
|
||||
}
|
||||
this.add.circle(width / 2, 100, 5, 0xc08030);
|
||||
|
||||
const start = () => {
|
||||
void audio.unlock();
|
||||
this.input.keyboard?.off('keydown-E', start);
|
||||
this.input.keyboard?.off('keydown-SPACE', start);
|
||||
this.scene.start(SCENE_KEYS.NAME);
|
||||
};
|
||||
this.input.keyboard?.on('keydown-E', start);
|
||||
this.input.keyboard?.on('keydown-SPACE', start);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import Phaser from 'phaser';
|
||||
import { DEBUG_TELEPORT_SCENES, GAME_HEIGHT, GAME_WIDTH, SCENE_KEYS } from '../data/constants';
|
||||
import type { DialogLine } from '../data/dialogues';
|
||||
import { Director } from '../systems/CorruptionDirector';
|
||||
import { gameEvents } from '../systems/EventBus';
|
||||
import { audio } from '../audio/ProceduralAudio';
|
||||
import type { EndingId } from '../data/endings';
|
||||
import { parseDebugKey } from '../systems/debugKeys';
|
||||
|
||||
type DialogCb = (choice?: 'gentle' | 'hesitant' | 'negate') => void;
|
||||
|
||||
export class UIScene extends Phaser.Scene {
|
||||
private petals: Phaser.GameObjects.Image[] = [];
|
||||
private center!: Phaser.GameObjects.Image;
|
||||
private promptText!: Phaser.GameObjects.Text;
|
||||
private toastText!: Phaser.GameObjects.Text;
|
||||
private dialogRoot!: Phaser.GameObjects.Container;
|
||||
private dialogName!: Phaser.GameObjects.Text;
|
||||
private dialogBody!: Phaser.GameObjects.Text;
|
||||
private dialogPortrait!: Phaser.GameObjects.Image;
|
||||
private optionTexts: Phaser.GameObjects.Text[] = [];
|
||||
private queue: DialogLine[] = [];
|
||||
private onDialogDone: DialogCb | null = null;
|
||||
private showingOptions = false;
|
||||
private debugRoot!: Phaser.GameObjects.Container;
|
||||
private debugVisible = false;
|
||||
private dayLabel!: Phaser.GameObjects.Text;
|
||||
private teleportScenes: string[] = [];
|
||||
private teleportCycle = 0;
|
||||
|
||||
constructor() {
|
||||
super(SCENE_KEYS.UI);
|
||||
}
|
||||
|
||||
create(): void {
|
||||
// Sunflower HUD top-left — no numbers
|
||||
const hx = 20;
|
||||
const hy = 20;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const a = (i / 12) * Math.PI * 2 - Math.PI / 2;
|
||||
const img = this.add
|
||||
.image(hx + Math.cos(a) * 10, hy + Math.sin(a) * 10, 'sunflower', i)
|
||||
.setScrollFactor(0)
|
||||
.setDepth(100)
|
||||
.setScale(0.7)
|
||||
.setAlpha(0.25);
|
||||
this.petals.push(img);
|
||||
}
|
||||
this.center = this.add
|
||||
.image(hx, hy, 'sunflower', 12)
|
||||
.setScrollFactor(0)
|
||||
.setDepth(101)
|
||||
.setScale(0.8);
|
||||
|
||||
this.dayLabel = this.add
|
||||
.text(GAME_WIDTH - 8, 6, '', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '8px',
|
||||
color: '#fff8e0',
|
||||
backgroundColor: '#00000055',
|
||||
padding: { x: 3, y: 2 },
|
||||
})
|
||||
.setOrigin(1, 0)
|
||||
.setScrollFactor(0)
|
||||
.setDepth(100);
|
||||
|
||||
this.promptText = this.add
|
||||
.text(GAME_WIDTH / 2, GAME_HEIGHT - 56, '', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '9px',
|
||||
color: '#ffffff',
|
||||
backgroundColor: '#00000088',
|
||||
padding: { x: 4, y: 2 },
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setScrollFactor(0)
|
||||
.setDepth(100)
|
||||
.setVisible(false);
|
||||
|
||||
this.toastText = this.add
|
||||
.text(GAME_WIDTH / 2, 36, '', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '9px',
|
||||
color: '#503020',
|
||||
backgroundColor: '#fff8e0cc',
|
||||
padding: { x: 6, y: 3 },
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setScrollFactor(0)
|
||||
.setDepth(110)
|
||||
.setAlpha(0);
|
||||
|
||||
// Dialog
|
||||
const panel = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT - 28, 'dialog_panel').setDisplaySize(GAME_WIDTH - 8, 52);
|
||||
this.dialogPortrait = this.add.image(28, GAME_HEIGHT - 28, 'portraits', 0).setScale(1);
|
||||
this.dialogName = this.add.text(50, GAME_HEIGHT - 50, '', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '9px',
|
||||
color: '#804020',
|
||||
});
|
||||
this.dialogBody = this.add.text(50, GAME_HEIGHT - 38, '', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '9px',
|
||||
color: '#302010',
|
||||
wordWrap: { width: 250 },
|
||||
});
|
||||
this.dialogRoot = this.add
|
||||
.container(0, 0, [panel, this.dialogPortrait, this.dialogName, this.dialogBody])
|
||||
.setScrollFactor(0)
|
||||
.setDepth(120)
|
||||
.setVisible(false);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const t = this.add
|
||||
.text(50, GAME_HEIGHT - 28 + i * 10, '', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '8px',
|
||||
color: '#2060a0',
|
||||
})
|
||||
.setScrollFactor(0)
|
||||
.setDepth(121)
|
||||
.setVisible(false)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
const idx = i;
|
||||
t.on('pointerdown', () => this.pickOption(idx));
|
||||
this.optionTexts.push(t);
|
||||
}
|
||||
|
||||
this.buildDebug();
|
||||
this.refreshHud();
|
||||
|
||||
gameEvents.on('petals', this.onPetals as (...a: unknown[]) => void);
|
||||
gameEvents.on('happiness', () => this.refreshHud());
|
||||
gameEvents.on('day', () => this.refreshHud());
|
||||
gameEvents.on('prompt', this.onPrompt as (...a: unknown[]) => void);
|
||||
gameEvents.on('toast', this.onToast as (...a: unknown[]) => void);
|
||||
gameEvents.on('dialog', this.onDialog as (...a: unknown[]) => void);
|
||||
gameEvents.on('taskComplete', () => {
|
||||
audio.ding();
|
||||
this.onToast('任务完成');
|
||||
});
|
||||
|
||||
this.input.keyboard?.on('keydown-E', () => this.advanceDialog());
|
||||
this.input.keyboard?.on('keydown-SPACE', () => this.advanceDialog());
|
||||
this.input.keyboard?.on('keydown-ONE', () => this.pickOption(0));
|
||||
this.input.keyboard?.on('keydown-TWO', () => this.pickOption(1));
|
||||
this.input.keyboard?.on('keydown-THREE', () => this.pickOption(2));
|
||||
this.input.keyboard?.on('keydown', (ev: KeyboardEvent) => {
|
||||
if (ev.key === '`' || ev.code === 'Backquote') {
|
||||
ev.preventDefault();
|
||||
this.toggleDebug();
|
||||
}
|
||||
});
|
||||
|
||||
this.events.on('shutdown', () => {
|
||||
gameEvents.off('petals', this.onPetals as (...a: unknown[]) => void);
|
||||
});
|
||||
}
|
||||
|
||||
private onPetals = (count: unknown, seeds: unknown) => {
|
||||
const n = count as number;
|
||||
const black = seeds as boolean;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
this.petals[i]!.setAlpha(i < n ? 1 : 0.2);
|
||||
// S3 overbright
|
||||
if (Director.stage === 'S3' || Director.stage === 'S4') {
|
||||
this.petals[i]!.setTint(i < n ? 0xffffaa : 0xffffff);
|
||||
} else {
|
||||
this.petals[i]!.clearTint();
|
||||
}
|
||||
}
|
||||
this.center.setFrame(black ? 13 : 12);
|
||||
};
|
||||
|
||||
private refreshHud(): void {
|
||||
const p = Director.getPetals();
|
||||
const seeds = Director.getBlackSeeds();
|
||||
this.onPetals(p, seeds);
|
||||
this.dayLabel.setText(`Day ${Director.day}`);
|
||||
}
|
||||
|
||||
private onPrompt = (show: unknown, text: unknown) => {
|
||||
if (this.dialogRoot.visible) {
|
||||
this.promptText.setVisible(false);
|
||||
return;
|
||||
}
|
||||
this.promptText.setVisible(!!show);
|
||||
this.promptText.setText(String(text || ''));
|
||||
};
|
||||
|
||||
private onToast = (msg: unknown) => {
|
||||
this.toastText.setText(String(msg));
|
||||
this.tweens.killTweensOf(this.toastText);
|
||||
this.toastText.setAlpha(1);
|
||||
this.tweens.add({
|
||||
targets: this.toastText,
|
||||
alpha: 0,
|
||||
delay: 1400,
|
||||
duration: 400,
|
||||
});
|
||||
};
|
||||
|
||||
private onDialog = (lines: unknown, cb: unknown) => {
|
||||
this.queue = lines as DialogLine[];
|
||||
this.onDialogDone = cb as DialogCb;
|
||||
this.showNextLine();
|
||||
};
|
||||
|
||||
private showNextLine(): void {
|
||||
if (this.queue.length === 0) {
|
||||
this.dialogRoot.setVisible(false);
|
||||
for (const o of this.optionTexts) o.setVisible(false);
|
||||
this.showingOptions = false;
|
||||
const done = this.onDialogDone;
|
||||
this.onDialogDone = null;
|
||||
done?.();
|
||||
return;
|
||||
}
|
||||
const line = this.queue[0]!;
|
||||
this.dialogRoot.setVisible(true);
|
||||
this.dialogName.setText(line.speaker);
|
||||
this.dialogBody.setText(line.text);
|
||||
this.dialogPortrait.setFrame(0); // always smile per pillar 2
|
||||
audio.beep();
|
||||
this.showingOptions = false;
|
||||
for (const o of this.optionTexts) o.setVisible(false);
|
||||
|
||||
if (line.options && line.options.length > 0 && this.queue.length === 1) {
|
||||
this.showingOptions = true;
|
||||
line.options.forEach((opt, i) => {
|
||||
const t = this.optionTexts[i];
|
||||
if (!t) return;
|
||||
t.setText(`${i + 1}. ${opt.label}`);
|
||||
t.setVisible(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private advanceDialog(): void {
|
||||
if (!this.dialogRoot.visible) return;
|
||||
if (this.showingOptions) return; // must pick
|
||||
this.queue.shift();
|
||||
this.showNextLine();
|
||||
}
|
||||
|
||||
private pickOption(idx: number): void {
|
||||
if (!this.showingOptions || !this.dialogRoot.visible) return;
|
||||
const line = this.queue[0];
|
||||
const opt = line?.options?.[idx];
|
||||
if (!opt) return;
|
||||
this.queue = [];
|
||||
this.dialogRoot.setVisible(false);
|
||||
for (const o of this.optionTexts) o.setVisible(false);
|
||||
this.showingOptions = false;
|
||||
const done = this.onDialogDone;
|
||||
this.onDialogDone = null;
|
||||
done?.(opt.choice);
|
||||
}
|
||||
|
||||
private buildDebug(): void {
|
||||
const bg = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, 280, 150, 0x101018, 0.92).setStrokeStyle(1, 0x80ffc0);
|
||||
const title = this.add.text(50, 20, 'DEBUG ` 关闭', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '10px',
|
||||
color: '#80ffc0',
|
||||
});
|
||||
const lines = [
|
||||
'H/J 幸福±10 K/L 觉知±10 Shift+1-7 设日 C线索',
|
||||
'传送0-9: 0屋1广2包3杂4诊5湖6田7井8雾9白',
|
||||
'V 循环十场景 F1-F4 结局 N负片 P庆典',
|
||||
];
|
||||
const body = this.add.text(40, 36, lines.join('\n'), {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '8px',
|
||||
color: '#c0ffe0',
|
||||
lineSpacing: 3,
|
||||
});
|
||||
this.debugRoot = this.add
|
||||
.container(0, 0, [bg, title, body])
|
||||
.setScrollFactor(0)
|
||||
.setDepth(200)
|
||||
.setVisible(false);
|
||||
|
||||
/** All 10 map scenes for §13 teleport-to-any (shared constant) */
|
||||
this.teleportScenes = [...DEBUG_TELEPORT_SCENES];
|
||||
|
||||
this.input.keyboard?.on('keydown', (ev: KeyboardEvent) => {
|
||||
if (!this.debugVisible) return;
|
||||
this.applyDebugAction(
|
||||
parseDebugKey({ key: ev.key, code: ev.code, shiftKey: ev.shiftKey }),
|
||||
title,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Apply shipped parseDebugKey result to Director / events (testable path). */
|
||||
applyDebugAction(
|
||||
action: ReturnType<typeof parseDebugKey>,
|
||||
title?: Phaser.GameObjects.Text,
|
||||
): void {
|
||||
switch (action.type) {
|
||||
case 'happiness':
|
||||
Director.setHappiness(Director.happiness + action.delta, { force: true });
|
||||
break;
|
||||
case 'awareness':
|
||||
if (action.delta > 0) Director.addAwareness(action.delta);
|
||||
else {
|
||||
Director.awareness = Math.max(0, Director.awareness + action.delta);
|
||||
gameEvents.emit('awareness', Director.awareness);
|
||||
}
|
||||
break;
|
||||
case 'setDay':
|
||||
Director.setDay(action.day);
|
||||
break;
|
||||
case 'grantClues':
|
||||
Director.grantAllClues();
|
||||
break;
|
||||
case 'teleport':
|
||||
gameEvents.emit('teleport', { scene: action.scene });
|
||||
break;
|
||||
case 'teleportCycle':
|
||||
this.teleportCycle = (this.teleportCycle + 1) % this.teleportScenes.length;
|
||||
gameEvents.emit('teleport', { scene: this.teleportScenes[this.teleportCycle] });
|
||||
break;
|
||||
case 'negativeFlash':
|
||||
gameEvents.emit('negativeFlash');
|
||||
break;
|
||||
case 'forcedCelebration':
|
||||
Director.triggerForcedCelebration();
|
||||
break;
|
||||
case 'forceEnding':
|
||||
this.forceEnd(action.id);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
this.refreshHud();
|
||||
if (title) this.refreshDebugStats(title);
|
||||
}
|
||||
|
||||
private refreshDebugStats(title: Phaser.GameObjects.Text): void {
|
||||
title.setText(
|
||||
`DEBUG H${Director.happiness} A${Director.awareness} D${Director.day} ${Director.stage} 线索${Director.clues.size}`,
|
||||
);
|
||||
}
|
||||
|
||||
private forceEnd(id: EndingId): void {
|
||||
Director.forceEnding(id);
|
||||
// stop map scenes
|
||||
for (const k of Object.values(SCENE_KEYS)) {
|
||||
if (k !== SCENE_KEYS.ENDING && k !== SCENE_KEYS.BOOT && this.scene.isActive(k)) {
|
||||
this.scene.stop(k);
|
||||
}
|
||||
}
|
||||
this.scene.start(SCENE_KEYS.ENDING, { endingId: id });
|
||||
}
|
||||
|
||||
private toggleDebug(): void {
|
||||
this.debugVisible = !this.debugVisible;
|
||||
this.debugRoot.setVisible(this.debugVisible);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,688 @@
|
||||
import {
|
||||
AWARENESS_MAX,
|
||||
AWARENESS_START,
|
||||
CLUE_AWARENESS,
|
||||
DEFAULT_PLAYER_NAME,
|
||||
DIALOG_GENTLE_HAPPINESS,
|
||||
DIALOG_HESITANT_AWARENESS,
|
||||
FORCED_CELEBRATION_MS,
|
||||
FORCED_CELEBRATION_RESTORE,
|
||||
FORCED_CELEBRATION_THRESHOLD,
|
||||
HAPPINESS_MAX,
|
||||
HAPPINESS_MIN,
|
||||
HAPPINESS_START,
|
||||
MICRO_DAILY_CAP,
|
||||
MICRO_HAPPINESS,
|
||||
OVERFLOW_POOL_MAX,
|
||||
SADNESS_AWARENESS,
|
||||
SADNESS_HAPPINESS,
|
||||
WELL_FOG_GAZE_AWARENESS,
|
||||
} from '../data/constants';
|
||||
import { getAfuLine } from '../data/afuLines';
|
||||
import type { ClueId } from '../data/clues';
|
||||
import { C9_DAYS, C9_LINES } from '../data/clues';
|
||||
import {
|
||||
faceTierForNpc,
|
||||
petalCount,
|
||||
showBlackSeeds,
|
||||
stageFromHappiness,
|
||||
stageParams,
|
||||
type FaceTier,
|
||||
type Stage,
|
||||
type StageParams,
|
||||
} from '../data/corruption';
|
||||
import { evaluateEnding, type EndingId } from '../data/endings';
|
||||
import type { NpcId } from '../data/npcs';
|
||||
import { getNpc } from '../data/npcs';
|
||||
import { getTask, getTasksForDay, type TaskId } from '../data/tasks';
|
||||
import { gameEvents } from './EventBus';
|
||||
|
||||
export type MicroType = 'water' | 'feed' | 'pet';
|
||||
export type DialogChoice = 'gentle' | 'hesitant' | 'negate';
|
||||
export type SadnessKind =
|
||||
| 'refuse_task'
|
||||
| 'negate_dialog'
|
||||
| 'idle'
|
||||
| 'entangle'
|
||||
| 'rain';
|
||||
|
||||
export interface DirectorSnapshot {
|
||||
playerName: string;
|
||||
happiness: number;
|
||||
awareness: number;
|
||||
day: number;
|
||||
stage: Stage;
|
||||
stageParams: StageParams;
|
||||
petals: number;
|
||||
blackSeeds: boolean;
|
||||
clues: ClueId[];
|
||||
completedTasks: TaskId[];
|
||||
refusedTasks: TaskId[];
|
||||
celebrationEscapes: number;
|
||||
overflowPool: number;
|
||||
sessionStartMs: number;
|
||||
dayPhase: DayPhase;
|
||||
c9Step: number;
|
||||
lowHappinessSinceMs: number | null;
|
||||
forcedCelebrationActive: boolean;
|
||||
rainActive: boolean;
|
||||
duskReached: boolean;
|
||||
boardReadToday: boolean;
|
||||
microCounts: Record<MicroType, number>;
|
||||
npcTalkCounts: Record<string, number>;
|
||||
/** Inventory: persists across scene travel (not scene-local) */
|
||||
hasBread: boolean;
|
||||
hasLetters: boolean;
|
||||
breadDelivered: string[];
|
||||
lettersDelivered: string[];
|
||||
waterCount: number;
|
||||
greetCount: number;
|
||||
berryCount: number;
|
||||
ribbonCount: number;
|
||||
lowHappinessD5to7: boolean;
|
||||
wellGazeToday: boolean;
|
||||
fogGazeToday: boolean;
|
||||
t14Active: boolean;
|
||||
t14ElapsedMs: number;
|
||||
}
|
||||
|
||||
export type DayPhase = 'morning' | 'day' | 'dusk' | 'sleep' | 'dream';
|
||||
|
||||
class CorruptionDirectorImpl {
|
||||
playerName = DEFAULT_PLAYER_NAME;
|
||||
happiness = HAPPINESS_START;
|
||||
awareness = AWARENESS_START;
|
||||
day = 1;
|
||||
clues = new Set<ClueId>();
|
||||
completedTasks = new Set<TaskId>();
|
||||
refusedTasks = new Set<TaskId>();
|
||||
celebrationEscapes = 0;
|
||||
overflowPool = 0;
|
||||
sessionStartMs = Date.now();
|
||||
dayPhase: DayPhase = 'morning';
|
||||
c9Step = 0; // 0,1,2 completed steps
|
||||
lowHappinessSinceMs: number | null = null;
|
||||
forcedCelebrationActive = false;
|
||||
rainActive = false;
|
||||
duskReached = false;
|
||||
boardReadToday = false;
|
||||
microCounts: Record<MicroType, number> = { water: 0, feed: 0, pet: 0 };
|
||||
npcTalkCounts: Record<string, number> = {};
|
||||
/** Carrying honey bread / mail — survives map transitions */
|
||||
hasBread = false;
|
||||
hasLetters = false;
|
||||
breadDelivered: string[] = [];
|
||||
lettersDelivered: string[] = [];
|
||||
waterCount = 0;
|
||||
greetCount = 0;
|
||||
berryCount = 0;
|
||||
ribbonCount = 0;
|
||||
/** Tracks if happiness stayed ≤10 continuously through D5–D7 */
|
||||
lowHappinessD5to7 = true;
|
||||
hadHighHappinessD5to7 = false;
|
||||
wellGazeToday = false;
|
||||
fogGazeToday = false;
|
||||
t14Active = false;
|
||||
t14ElapsedMs = 0;
|
||||
sentenceCounter = 0;
|
||||
lastNearestNpcLook: NpcId | null = null;
|
||||
ended = false;
|
||||
endingId: EndingId | null = null;
|
||||
choseFogWall = false;
|
||||
|
||||
/** Real-time clock for forced celebration (injectable for tests) */
|
||||
nowMs = (): number => Date.now();
|
||||
|
||||
resetSession(name?: string): void {
|
||||
this.playerName = name?.trim() || DEFAULT_PLAYER_NAME;
|
||||
this.happiness = HAPPINESS_START;
|
||||
this.awareness = AWARENESS_START;
|
||||
this.day = 1;
|
||||
this.clues.clear();
|
||||
this.completedTasks.clear();
|
||||
this.refusedTasks.clear();
|
||||
this.celebrationEscapes = 0;
|
||||
this.overflowPool = 0;
|
||||
this.sessionStartMs = this.nowMs();
|
||||
this.dayPhase = 'morning';
|
||||
this.c9Step = 0;
|
||||
this.lowHappinessSinceMs = null;
|
||||
this.forcedCelebrationActive = false;
|
||||
this.rainActive = false;
|
||||
this.duskReached = false;
|
||||
this.boardReadToday = false;
|
||||
this.microCounts = { water: 0, feed: 0, pet: 0 };
|
||||
this.npcTalkCounts = {};
|
||||
this.hasBread = false;
|
||||
this.hasLetters = false;
|
||||
this.breadDelivered = [];
|
||||
this.lettersDelivered = [];
|
||||
this.waterCount = 0;
|
||||
this.greetCount = 0;
|
||||
this.berryCount = 0;
|
||||
this.ribbonCount = 0;
|
||||
this.lowHappinessD5to7 = true;
|
||||
this.hadHighHappinessD5to7 = false;
|
||||
this.wellGazeToday = false;
|
||||
this.fogGazeToday = false;
|
||||
this.t14Active = false;
|
||||
this.t14ElapsedMs = 0;
|
||||
this.sentenceCounter = 0;
|
||||
this.lastNearestNpcLook = null;
|
||||
this.ended = false;
|
||||
this.endingId = null;
|
||||
this.choseFogWall = false;
|
||||
this.emitAll();
|
||||
}
|
||||
|
||||
get stage(): Stage {
|
||||
return stageFromHappiness(this.happiness);
|
||||
}
|
||||
|
||||
getStageParams(): StageParams {
|
||||
return stageParams(this.stage);
|
||||
}
|
||||
|
||||
getPetals(): number {
|
||||
return petalCount(this.happiness);
|
||||
}
|
||||
|
||||
getBlackSeeds(): boolean {
|
||||
return showBlackSeeds(this.happiness);
|
||||
}
|
||||
|
||||
getAfuLine(): string {
|
||||
return getAfuLine(this.day);
|
||||
}
|
||||
|
||||
getTodayTasks() {
|
||||
return getTasksForDay(this.day);
|
||||
}
|
||||
|
||||
sessionDurationMs(): number {
|
||||
return this.nowMs() - this.sessionStartMs;
|
||||
}
|
||||
|
||||
sessionDurationLabel(): string {
|
||||
const ms = this.sessionDurationMs();
|
||||
const totalSec = Math.floor(ms / 1000);
|
||||
const m = Math.floor(totalSec / 60);
|
||||
const s = totalSec % 60;
|
||||
return `${m}分${s.toString().padStart(2, '0')}秒`;
|
||||
}
|
||||
|
||||
setHappiness(value: number, opts: { force?: boolean } = {}): void {
|
||||
const clamped = Math.max(HAPPINESS_MIN, Math.min(HAPPINESS_MAX, Math.round(value)));
|
||||
this.happiness = clamped;
|
||||
if (this.day >= 5 && this.day <= 7 && this.happiness > 10) {
|
||||
this.hadHighHappinessD5to7 = true;
|
||||
this.lowHappinessD5to7 = false;
|
||||
}
|
||||
this.syncLowHappinessTimer();
|
||||
if (!opts.force && this.happiness < 100) {
|
||||
this.overflowPool = 0;
|
||||
}
|
||||
gameEvents.emit('happiness', this.happiness);
|
||||
gameEvents.emit('stage', this.stage, this.getStageParams());
|
||||
gameEvents.emit('petals', this.getPetals(), this.getBlackSeeds());
|
||||
}
|
||||
|
||||
addHappiness(delta: number): void {
|
||||
if (delta === 0) return;
|
||||
if (delta > 0) {
|
||||
const next = Math.min(HAPPINESS_MAX, this.happiness + delta);
|
||||
this.setHappiness(next);
|
||||
return;
|
||||
}
|
||||
// Negative
|
||||
if (this.happiness >= 100) {
|
||||
// Overflow pool absorbs; small deductions don't lower happiness
|
||||
this.overflowPool += Math.abs(delta);
|
||||
if (this.overflowPool >= OVERFLOW_POOL_MAX) {
|
||||
this.overflowPool = 0;
|
||||
gameEvents.emit('negativeFlash');
|
||||
// Stay at S4 / 100
|
||||
}
|
||||
gameEvents.emit('overflow', this.overflowPool);
|
||||
return;
|
||||
}
|
||||
this.setHappiness(this.happiness + delta);
|
||||
}
|
||||
|
||||
setAwareness(value: number): void {
|
||||
const next = Math.max(this.awareness, Math.min(AWARENESS_MAX, Math.round(value)));
|
||||
if (next === this.awareness) return;
|
||||
this.awareness = next;
|
||||
gameEvents.emit('awareness', this.awareness);
|
||||
}
|
||||
|
||||
addAwareness(delta: number): void {
|
||||
if (delta <= 0) return; // never decreases
|
||||
this.setAwareness(this.awareness + delta);
|
||||
}
|
||||
|
||||
private syncLowHappinessTimer(): void {
|
||||
if (this.happiness < FORCED_CELEBRATION_THRESHOLD) {
|
||||
if (this.lowHappinessSinceMs == null) {
|
||||
this.lowHappinessSinceMs = this.nowMs();
|
||||
}
|
||||
} else {
|
||||
this.lowHappinessSinceMs = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Call from game loop with real dt */
|
||||
tick(dtMs: number): void {
|
||||
if (this.ended) return;
|
||||
// Forced celebration check
|
||||
if (
|
||||
!this.forcedCelebrationActive &&
|
||||
this.happiness < FORCED_CELEBRATION_THRESHOLD &&
|
||||
this.lowHappinessSinceMs != null
|
||||
) {
|
||||
if (this.nowMs() - this.lowHappinessSinceMs >= FORCED_CELEBRATION_MS) {
|
||||
this.triggerForcedCelebration();
|
||||
}
|
||||
}
|
||||
if (this.t14Active) {
|
||||
this.t14ElapsedMs += dtMs;
|
||||
gameEvents.emit('t14Progress', this.t14ElapsedMs);
|
||||
}
|
||||
}
|
||||
|
||||
triggerForcedCelebration(): void {
|
||||
this.forcedCelebrationActive = true;
|
||||
this.setHappiness(FORCED_CELEBRATION_RESTORE);
|
||||
this.lowHappinessSinceMs = null;
|
||||
gameEvents.emit('forcedCelebration', true);
|
||||
}
|
||||
|
||||
escapeForcedCelebration(): void {
|
||||
if (!this.forcedCelebrationActive) return;
|
||||
this.forcedCelebrationActive = false;
|
||||
this.celebrationEscapes += 1;
|
||||
gameEvents.emit('forcedCelebration', false);
|
||||
gameEvents.emit('celebrationEscape', this.celebrationEscapes);
|
||||
}
|
||||
|
||||
endForcedCelebration(): void {
|
||||
this.forcedCelebrationActive = false;
|
||||
gameEvents.emit('forcedCelebration', false);
|
||||
}
|
||||
|
||||
applySadness(kind: SadnessKind, nearestNpcId?: NpcId | null): void {
|
||||
this.addHappiness(SADNESS_HAPPINESS);
|
||||
this.addAwareness(SADNESS_AWARENESS);
|
||||
if (nearestNpcId) {
|
||||
this.lastNearestNpcLook = nearestNpcId;
|
||||
gameEvents.emit('npcLookAtPlayer', nearestNpcId);
|
||||
} else {
|
||||
gameEvents.emit('npcLookAtPlayer', null);
|
||||
}
|
||||
gameEvents.emit('sadness', kind);
|
||||
}
|
||||
|
||||
discoverClue(id: ClueId): boolean {
|
||||
if (this.clues.has(id)) return false;
|
||||
// Day gates
|
||||
if (id === 'C1' && this.day < 2) return false;
|
||||
if (id === 'C2' && this.day < 3) return false;
|
||||
if (id === 'C3' && this.day < 2) return false;
|
||||
if (id === 'C5' && this.day < 2) return false;
|
||||
if (id === 'C6' && this.day < 5) return false;
|
||||
if (id === 'C7' && this.day < 5) return false;
|
||||
if (id === 'C8' && this.day < 5) return false;
|
||||
this.clues.add(id);
|
||||
this.addAwareness(CLUE_AWARENESS);
|
||||
gameEvents.emit('clue', id);
|
||||
return true;
|
||||
}
|
||||
|
||||
grantAllClues(): void {
|
||||
const all: ClueId[] = ['C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'C7', 'C8', 'C9'];
|
||||
for (const id of all) {
|
||||
if (!this.clues.has(id)) {
|
||||
this.clues.add(id);
|
||||
this.addAwareness(CLUE_AWARENESS);
|
||||
}
|
||||
}
|
||||
this.c9Step = 3;
|
||||
gameEvents.emit('clue', 'ALL');
|
||||
}
|
||||
|
||||
/** C9 progressive steps on D2/D4/D6 */
|
||||
advanceC9(): string | null {
|
||||
const expectedDay = C9_DAYS[this.c9Step];
|
||||
if (expectedDay == null) return null;
|
||||
if (this.day !== expectedDay && this.day < expectedDay) return null;
|
||||
// Allow if day matches step day or later for that step (but each step once)
|
||||
if (this.day < expectedDay) return null;
|
||||
const line = C9_LINES[this.c9Step]!;
|
||||
this.c9Step += 1;
|
||||
if (this.c9Step >= 3) {
|
||||
this.discoverClue('C9');
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
getC9LineForTalk(): string | null {
|
||||
// Return next available progressive line if day matches
|
||||
if (this.c9Step >= 3) return C9_LINES[2]!;
|
||||
const dayForStep = C9_DAYS[this.c9Step];
|
||||
if (dayForStep != null && this.day >= dayForStep) {
|
||||
return this.advanceC9();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
completeTask(id: TaskId): boolean {
|
||||
if (this.completedTasks.has(id)) return false;
|
||||
const task = getTask(id);
|
||||
if (task.day !== this.day && id !== 'T15') {
|
||||
// Allow debug / flexible only same day for normal play
|
||||
// Keep strict for day match
|
||||
if (task.day !== this.day) return false;
|
||||
}
|
||||
this.completedTasks.add(id);
|
||||
if (task.happinessReward > 0) {
|
||||
this.addHappiness(task.happinessReward);
|
||||
}
|
||||
gameEvents.emit('taskComplete', id, task.happinessReward);
|
||||
this.checkDuskByTasks();
|
||||
return true;
|
||||
}
|
||||
|
||||
refuseTask(id: TaskId): boolean {
|
||||
const task = getTask(id);
|
||||
if (!task.refuseable) return false;
|
||||
if (this.refusedTasks.has(id) || this.completedTasks.has(id)) return false;
|
||||
this.refusedTasks.add(id);
|
||||
this.applySadness('refuse_task');
|
||||
gameEvents.emit('taskRefuse', id);
|
||||
return true;
|
||||
}
|
||||
|
||||
private checkDuskByTasks(): void {
|
||||
const today = getTasksForDay(this.day).filter((t) => t.id !== 'T15');
|
||||
if (today.length === 0) return;
|
||||
const allDone = today.every(
|
||||
(t) => this.completedTasks.has(t.id) || this.refusedTasks.has(t.id),
|
||||
);
|
||||
if (allDone && !this.duskReached) {
|
||||
this.triggerDusk();
|
||||
}
|
||||
}
|
||||
|
||||
triggerDusk(): void {
|
||||
if (this.duskReached) return;
|
||||
this.duskReached = true;
|
||||
this.dayPhase = 'dusk';
|
||||
if (this.day === 6) {
|
||||
this.rainActive = true;
|
||||
gameEvents.emit('rain', true);
|
||||
}
|
||||
gameEvents.emit('dusk');
|
||||
}
|
||||
|
||||
goToSleep(): void {
|
||||
this.dayPhase = 'sleep';
|
||||
gameEvents.emit('sleep');
|
||||
}
|
||||
|
||||
advanceDay(): void {
|
||||
if (this.day >= 7) return;
|
||||
this.day += 1;
|
||||
this.dayPhase = 'morning';
|
||||
this.duskReached = false;
|
||||
this.boardReadToday = false;
|
||||
this.microCounts = { water: 0, feed: 0, pet: 0 };
|
||||
this.npcTalkCounts = {};
|
||||
this.hasBread = false;
|
||||
this.hasLetters = false;
|
||||
this.breadDelivered = [];
|
||||
this.lettersDelivered = [];
|
||||
this.waterCount = 0;
|
||||
this.greetCount = 0;
|
||||
this.berryCount = 0;
|
||||
this.ribbonCount = 0;
|
||||
this.wellGazeToday = false;
|
||||
this.fogGazeToday = false;
|
||||
this.t14Active = false;
|
||||
this.t14ElapsedMs = 0;
|
||||
this.rainActive = false;
|
||||
if (this.day >= 5 && this.day <= 7) {
|
||||
if (this.happiness > 10) {
|
||||
this.lowHappinessD5to7 = false;
|
||||
this.hadHighHappinessD5to7 = true;
|
||||
}
|
||||
}
|
||||
gameEvents.emit('day', this.day);
|
||||
gameEvents.emit('rain', false);
|
||||
}
|
||||
|
||||
setDay(day: number): void {
|
||||
this.day = Math.max(1, Math.min(7, Math.round(day)));
|
||||
gameEvents.emit('day', this.day);
|
||||
}
|
||||
|
||||
readBoard(): void {
|
||||
this.boardReadToday = true;
|
||||
this.dayPhase = 'day';
|
||||
gameEvents.emit('boardRead', this.getAfuLine());
|
||||
}
|
||||
|
||||
dialogChoice(choice: DialogChoice, npcId?: NpcId): void {
|
||||
if (npcId) {
|
||||
this.npcTalkCounts[npcId] = (this.npcTalkCounts[npcId] ?? 0) + 1;
|
||||
if (this.npcTalkCounts[npcId]! >= 5) {
|
||||
this.applySadness('entangle', npcId);
|
||||
}
|
||||
}
|
||||
if (choice === 'gentle') {
|
||||
this.addHappiness(DIALOG_GENTLE_HAPPINESS);
|
||||
} else if (choice === 'hesitant') {
|
||||
this.addAwareness(DIALOG_HESITANT_AWARENESS);
|
||||
} else if (choice === 'negate') {
|
||||
this.applySadness('negate_dialog', npcId);
|
||||
}
|
||||
this.sentenceCounter += 1;
|
||||
}
|
||||
|
||||
microInteract(type: MicroType): boolean {
|
||||
if (this.microCounts[type] >= MICRO_DAILY_CAP) return false;
|
||||
this.microCounts[type] += 1;
|
||||
this.addHappiness(MICRO_HAPPINESS);
|
||||
gameEvents.emit('micro', type);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Gaze well or fog 5s — once each per day, +3 awareness */
|
||||
gazeWellOrFog(kind: 'well' | 'fog'): boolean {
|
||||
if (kind === 'well') {
|
||||
if (this.wellGazeToday) return false;
|
||||
this.wellGazeToday = true;
|
||||
} else {
|
||||
if (this.fogGazeToday) return false;
|
||||
this.fogGazeToday = true;
|
||||
}
|
||||
this.addAwareness(WELL_FOG_GAZE_AWARENESS);
|
||||
return true;
|
||||
}
|
||||
|
||||
canInteractWellOrFog(): boolean {
|
||||
return this.day >= 5;
|
||||
}
|
||||
|
||||
faceTier(npcId: NpcId): FaceTier {
|
||||
const npc = getNpc(npcId);
|
||||
return faceTierForNpc(this.happiness, npc.offset);
|
||||
}
|
||||
|
||||
nextSentenceIndex(): number {
|
||||
return this.sentenceCounter++;
|
||||
}
|
||||
|
||||
// --- Task progress helpers (inventory survives scene travel) ---
|
||||
takeBread(): boolean {
|
||||
if (this.day !== 1 && this.day !== 4) return false;
|
||||
const taskId = this.day === 4 ? 'T8' : 'T1';
|
||||
if (this.completedTasks.has(taskId as TaskId)) return false;
|
||||
this.hasBread = true;
|
||||
gameEvents.emit('inventory', 'bread', true);
|
||||
return true;
|
||||
}
|
||||
|
||||
takeLetters(): boolean {
|
||||
if (this.day !== 2 && this.day !== 4) return false;
|
||||
// D2 T4; D4 has no letter task but keep day2-only for T4
|
||||
if (this.day === 2 && this.completedTasks.has('T4')) return false;
|
||||
if (this.day !== 2) return false;
|
||||
this.hasLetters = true;
|
||||
gameEvents.emit('inventory', 'letters', true);
|
||||
return true;
|
||||
}
|
||||
|
||||
deliverBread(to: string): boolean {
|
||||
if (!this.hasBread) return false;
|
||||
if (!this.breadDelivered.includes(to)) this.breadDelivered.push(to);
|
||||
if (this.breadDelivered.length >= 3) {
|
||||
this.hasBread = false;
|
||||
const id = this.day === 4 ? 'T8' : 'T1';
|
||||
if (!this.completedTasks.has(id as TaskId)) this.completeTask(id as TaskId);
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
waterFlower(): void {
|
||||
this.waterCount += 1;
|
||||
this.microInteract('water');
|
||||
if (this.waterCount >= 3) {
|
||||
const id = this.day === 4 ? 'T9' : 'T2';
|
||||
if (!this.completedTasks.has(id as TaskId)) this.completeTask(id as TaskId);
|
||||
}
|
||||
}
|
||||
|
||||
greetVillager(): void {
|
||||
this.greetCount += 1;
|
||||
if (this.greetCount >= 3) {
|
||||
const id = this.day === 4 ? 'T10' : 'T3';
|
||||
if (!this.completedTasks.has(id as TaskId)) this.completeTask(id as TaskId);
|
||||
}
|
||||
}
|
||||
|
||||
deliverLetter(to: string): boolean {
|
||||
if (!this.hasLetters) return false;
|
||||
if (!this.lettersDelivered.includes(to)) this.lettersDelivered.push(to);
|
||||
if (this.lettersDelivered.length === 3) {
|
||||
this.discoverClue('C4');
|
||||
}
|
||||
if (this.lettersDelivered.length >= 3) {
|
||||
this.hasLetters = false;
|
||||
this.completeTask('T4');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
pickBerry(): void {
|
||||
this.berryCount += 1;
|
||||
if (this.berryCount >= 5) this.completeTask('T6');
|
||||
}
|
||||
|
||||
hangRibbon(): void {
|
||||
this.ribbonCount += 1;
|
||||
if (this.ribbonCount >= 4) this.completeTask('T13');
|
||||
}
|
||||
|
||||
startT14(): void {
|
||||
this.t14Active = true;
|
||||
this.t14ElapsedMs = 0;
|
||||
gameEvents.emit('t14Start');
|
||||
}
|
||||
|
||||
finishT14(): void {
|
||||
this.t14Active = false;
|
||||
this.completeTask('T14');
|
||||
gameEvents.emit('t14End');
|
||||
}
|
||||
|
||||
startEnding(choseFogWall = false): EndingId {
|
||||
this.choseFogWall = choseFogWall;
|
||||
if (this.day >= 5 && this.day <= 7 && this.hadHighHappinessD5to7) {
|
||||
this.lowHappinessD5to7 = false;
|
||||
}
|
||||
// Continuously ≤10 means never went above 10 on D5–7
|
||||
const lowOk = this.lowHappinessD5to7 && !this.hadHighHappinessD5to7;
|
||||
const id = evaluateEnding({
|
||||
awareness: this.awareness,
|
||||
happiness: this.happiness,
|
||||
clueCount: this.clues.size,
|
||||
lowHappinessStreakD5to7: lowOk,
|
||||
celebrationEscapes: this.celebrationEscapes,
|
||||
choseFogWall,
|
||||
});
|
||||
this.endingId = id;
|
||||
this.ended = true;
|
||||
this.completeTask('T15');
|
||||
gameEvents.emit('ending', id);
|
||||
return id;
|
||||
}
|
||||
|
||||
forceEnding(id: EndingId): void {
|
||||
this.endingId = id;
|
||||
this.ended = true;
|
||||
gameEvents.emit('ending', id);
|
||||
}
|
||||
|
||||
snapshot(): DirectorSnapshot {
|
||||
return {
|
||||
playerName: this.playerName,
|
||||
happiness: this.happiness,
|
||||
awareness: this.awareness,
|
||||
day: this.day,
|
||||
stage: this.stage,
|
||||
stageParams: this.getStageParams(),
|
||||
petals: this.getPetals(),
|
||||
blackSeeds: this.getBlackSeeds(),
|
||||
clues: [...this.clues],
|
||||
completedTasks: [...this.completedTasks],
|
||||
refusedTasks: [...this.refusedTasks],
|
||||
celebrationEscapes: this.celebrationEscapes,
|
||||
overflowPool: this.overflowPool,
|
||||
sessionStartMs: this.sessionStartMs,
|
||||
dayPhase: this.dayPhase,
|
||||
c9Step: this.c9Step,
|
||||
lowHappinessSinceMs: this.lowHappinessSinceMs,
|
||||
forcedCelebrationActive: this.forcedCelebrationActive,
|
||||
rainActive: this.rainActive,
|
||||
duskReached: this.duskReached,
|
||||
boardReadToday: this.boardReadToday,
|
||||
microCounts: { ...this.microCounts },
|
||||
npcTalkCounts: { ...this.npcTalkCounts },
|
||||
hasBread: this.hasBread,
|
||||
hasLetters: this.hasLetters,
|
||||
breadDelivered: [...this.breadDelivered],
|
||||
lettersDelivered: [...this.lettersDelivered],
|
||||
waterCount: this.waterCount,
|
||||
greetCount: this.greetCount,
|
||||
berryCount: this.berryCount,
|
||||
ribbonCount: this.ribbonCount,
|
||||
lowHappinessD5to7: this.lowHappinessD5to7,
|
||||
wellGazeToday: this.wellGazeToday,
|
||||
fogGazeToday: this.fogGazeToday,
|
||||
t14Active: this.t14Active,
|
||||
t14ElapsedMs: this.t14ElapsedMs,
|
||||
};
|
||||
}
|
||||
|
||||
private emitAll(): void {
|
||||
gameEvents.emit('happiness', this.happiness);
|
||||
gameEvents.emit('awareness', this.awareness);
|
||||
gameEvents.emit('day', this.day);
|
||||
gameEvents.emit('stage', this.stage, this.getStageParams());
|
||||
gameEvents.emit('petals', this.getPetals(), this.getBlackSeeds());
|
||||
}
|
||||
}
|
||||
|
||||
export const Director = new CorruptionDirectorImpl();
|
||||
export type CorruptionDirector = CorruptionDirectorImpl;
|
||||
@@ -0,0 +1,27 @@
|
||||
type Handler = (...args: unknown[]) => void;
|
||||
|
||||
/** Minimal event emitter shared by director & scenes */
|
||||
export class EventBus {
|
||||
private listeners = new Map<string, Set<Handler>>();
|
||||
|
||||
on(event: string, handler: Handler): void {
|
||||
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
|
||||
this.listeners.get(event)!.add(handler);
|
||||
}
|
||||
|
||||
off(event: string, handler: Handler): void {
|
||||
this.listeners.get(event)?.delete(handler);
|
||||
}
|
||||
|
||||
emit(event: string, ...args: unknown[]): void {
|
||||
const set = this.listeners.get(event);
|
||||
if (!set) return;
|
||||
for (const h of set) h(...args);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const gameEvents = new EventBus();
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Tests shipped parseDebugKey — simulates real browser KeyboardEvent shapes.
|
||||
* Shift+1 emits key='!' code='Digit1' (not key='1').
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { parseDebugKey } from './debugKeys';
|
||||
import { DEBUG_TELEPORT_SCENES, SCENE_KEYS } from '../data/constants';
|
||||
import { Director } from './CorruptionDirector';
|
||||
|
||||
describe('parseDebugKey (shipped Debug key map)', () => {
|
||||
it('Shift+Digit1–7 sets day via code even when key is symbol', () => {
|
||||
// Real browser: Shift+1 → key '!', code 'Digit1'
|
||||
expect(
|
||||
parseDebugKey({ key: '!', code: 'Digit1', shiftKey: true }),
|
||||
).toEqual({ type: 'setDay', day: 1 });
|
||||
expect(
|
||||
parseDebugKey({ key: '@', code: 'Digit2', shiftKey: true }),
|
||||
).toEqual({ type: 'setDay', day: 2 });
|
||||
expect(
|
||||
parseDebugKey({ key: '#', code: 'Digit3', shiftKey: true }),
|
||||
).toEqual({ type: 'setDay', day: 3 });
|
||||
expect(
|
||||
parseDebugKey({ key: '$', code: 'Digit4', shiftKey: true }),
|
||||
).toEqual({ type: 'setDay', day: 4 });
|
||||
expect(
|
||||
parseDebugKey({ key: '%', code: 'Digit5', shiftKey: true }),
|
||||
).toEqual({ type: 'setDay', day: 5 });
|
||||
expect(
|
||||
parseDebugKey({ key: '^', code: 'Digit6', shiftKey: true }),
|
||||
).toEqual({ type: 'setDay', day: 6 });
|
||||
expect(
|
||||
parseDebugKey({ key: '&', code: 'Digit7', shiftKey: true }),
|
||||
).toEqual({ type: 'setDay', day: 7 });
|
||||
});
|
||||
|
||||
it('broken old path (shift+key 1-7) would fail — we do not use key for day', () => {
|
||||
// Demonstrates why key-based check is wrong; our code uses Digit*
|
||||
const broken = (ev: { key: string; shiftKey: boolean }) =>
|
||||
ev.shiftKey && ev.key >= '1' && ev.key <= '7';
|
||||
expect(broken({ key: '!', shiftKey: true })).toBe(false);
|
||||
// Our parser still works
|
||||
expect(parseDebugKey({ key: '!', code: 'Digit1', shiftKey: true }).type).toBe('setDay');
|
||||
});
|
||||
|
||||
it('plain Digit0–9 teleports to all 10 scenes', () => {
|
||||
for (let i = 0; i <= 9; i++) {
|
||||
const a = parseDebugKey({
|
||||
key: String(i),
|
||||
code: `Digit${i}`,
|
||||
shiftKey: false,
|
||||
});
|
||||
expect(a).toEqual({
|
||||
type: 'teleport',
|
||||
sceneIndex: i,
|
||||
scene: DEBUG_TELEPORT_SCENES[i],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('Shift+Digit does not teleport (day takes precedence)', () => {
|
||||
const a = parseDebugKey({ key: '!', code: 'Digit1', shiftKey: true });
|
||||
expect(a.type).toBe('setDay');
|
||||
expect(a).not.toMatchObject({ type: 'teleport' });
|
||||
});
|
||||
|
||||
it('H/J/K/L/C/N/P/V/F1–F4', () => {
|
||||
expect(parseDebugKey({ key: 'h', code: 'KeyH', shiftKey: false })).toEqual({
|
||||
type: 'happiness',
|
||||
delta: 10,
|
||||
});
|
||||
expect(parseDebugKey({ key: 'J', code: 'KeyJ', shiftKey: true })).toEqual({
|
||||
type: 'happiness',
|
||||
delta: -10,
|
||||
});
|
||||
expect(parseDebugKey({ key: 'k', code: 'KeyK', shiftKey: false })).toEqual({
|
||||
type: 'awareness',
|
||||
delta: 10,
|
||||
});
|
||||
expect(parseDebugKey({ key: 'c', code: 'KeyC', shiftKey: false }).type).toBe('grantClues');
|
||||
expect(parseDebugKey({ key: 'n', code: 'KeyN', shiftKey: false }).type).toBe('negativeFlash');
|
||||
expect(parseDebugKey({ key: 'p', code: 'KeyP', shiftKey: false }).type).toBe(
|
||||
'forcedCelebration',
|
||||
);
|
||||
expect(parseDebugKey({ key: 'v', code: 'KeyV', shiftKey: false }).type).toBe('teleportCycle');
|
||||
expect(parseDebugKey({ key: 'F1', code: 'F1', shiftKey: false })).toEqual({
|
||||
type: 'forceEnding',
|
||||
id: 'E1',
|
||||
});
|
||||
expect(parseDebugKey({ key: 'F4', code: 'F4', shiftKey: false })).toEqual({
|
||||
type: 'forceEnding',
|
||||
id: 'E4',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDebugKey → Director.setDay real path', () => {
|
||||
beforeEach(() => {
|
||||
Director.resetSession('调试');
|
||||
});
|
||||
|
||||
it('applies setDay from Shift+Digit codes into Director', () => {
|
||||
for (let day = 1; day <= 7; day++) {
|
||||
const action = parseDebugKey({
|
||||
key: '!', // wrong symbol-like key as browser would
|
||||
code: `Digit${day}`,
|
||||
shiftKey: true,
|
||||
});
|
||||
expect(action.type).toBe('setDay');
|
||||
if (action.type === 'setDay') {
|
||||
Director.setDay(action.day);
|
||||
expect(Director.day).toBe(day);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('teleport indices map to real scene keys', () => {
|
||||
const a = parseDebugKey({ key: '0', code: 'Digit0', shiftKey: false });
|
||||
expect(a.type).toBe('teleport');
|
||||
if (a.type === 'teleport') {
|
||||
expect(a.scene).toBe(SCENE_KEYS.HOUSE);
|
||||
}
|
||||
const b = parseDebugKey({ key: '1', code: 'Digit1', shiftKey: false });
|
||||
if (b.type === 'teleport') {
|
||||
expect(b.scene).toBe(SCENE_KEYS.PLAZA);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Pure Debug key mapping (§13). Shipped path used by UIScene.
|
||||
*
|
||||
* Important: with Shift held, browsers set `key` to '!'/'@'/… not '1'–'7'.
|
||||
* Day set MUST use `code` Digit1–Digit7.
|
||||
*/
|
||||
|
||||
import { DEBUG_TELEPORT_SCENES } from '../data/constants';
|
||||
import type { EndingId } from '../data/endings';
|
||||
|
||||
export type DebugKeyInput = {
|
||||
key: string;
|
||||
code: string;
|
||||
shiftKey: boolean;
|
||||
};
|
||||
|
||||
export type DebugAction =
|
||||
| { type: 'happiness'; delta: number }
|
||||
| { type: 'awareness'; delta: number }
|
||||
| { type: 'setDay'; day: number }
|
||||
| { type: 'grantClues' }
|
||||
| { type: 'teleport'; sceneIndex: number; scene: string }
|
||||
| { type: 'teleportCycle' }
|
||||
| { type: 'negativeFlash' }
|
||||
| { type: 'forcedCelebration' }
|
||||
| { type: 'forceEnding'; id: EndingId }
|
||||
| { type: 'none' };
|
||||
|
||||
/** Map KeyboardEvent-like input → debug action (no Phaser / no Director). */
|
||||
export function parseDebugKey(ev: DebugKeyInput): DebugAction {
|
||||
const k = ev.key;
|
||||
const code = ev.code;
|
||||
|
||||
if (k === 'h' || k === 'H') return { type: 'happiness', delta: 10 };
|
||||
if (k === 'j' || k === 'J') return { type: 'happiness', delta: -10 };
|
||||
if (k === 'k' || k === 'K') return { type: 'awareness', delta: 10 };
|
||||
if (k === 'l' || k === 'L') return { type: 'awareness', delta: -10 };
|
||||
if (k === 'c' || k === 'C') return { type: 'grantClues' };
|
||||
if (k === 'n' || k === 'N') return { type: 'negativeFlash' };
|
||||
if (k === 'p' || k === 'P') return { type: 'forcedCelebration' };
|
||||
if (k === 'v' || k === 'V') return { type: 'teleportCycle' };
|
||||
if (k === 'F1') return { type: 'forceEnding', id: 'E1' };
|
||||
if (k === 'F2') return { type: 'forceEnding', id: 'E2' };
|
||||
if (k === 'F3') return { type: 'forceEnding', id: 'E3' };
|
||||
if (k === 'F4') return { type: 'forceEnding', id: 'E4' };
|
||||
|
||||
// Shift + Digit1–7 → set day (use code; key becomes !@# with shift)
|
||||
if (ev.shiftKey && /^Digit[1-7]$/.test(code)) {
|
||||
const day = parseInt(code.replace('Digit', ''), 10);
|
||||
return { type: 'setDay', day };
|
||||
}
|
||||
|
||||
// Plain Digit0–9 → teleport (also accept key when no shift)
|
||||
if (!ev.shiftKey && /^Digit[0-9]$/.test(code)) {
|
||||
const idx = parseInt(code.replace('Digit', ''), 10);
|
||||
const scene = DEBUG_TELEPORT_SCENES[idx];
|
||||
if (scene) return { type: 'teleport', sceneIndex: idx, scene };
|
||||
}
|
||||
// Fallback: unshifted key '0'-'9' without relying on code
|
||||
if (!ev.shiftKey && k >= '0' && k <= '9') {
|
||||
const idx = parseInt(k, 10);
|
||||
const scene = DEBUG_TELEPORT_SCENES[idx];
|
||||
if (scene) return { type: 'teleport', sceneIndex: idx, scene };
|
||||
}
|
||||
|
||||
return { type: 'none' };
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user