Honey Village: six model builds + hub frontend, Dockerized for Dokploy

This commit is contained in:
2026-07-15 10:50:12 -04:00
commit 67253bded5
523 changed files with 58879 additions and 0 deletions
+205
View File
@@ -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();
+18
View File
@@ -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]!;
}
+77
View File
@@ -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];
+101
View File
@@ -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 09 */
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;
+210
View File
@@ -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';
}
+191
View File
@@ -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 =
'忽然,彩带与笑声从四面八方涌来——欢乐庆典强制开始了。你也可以从场景边缘离开。';
+65
View File
@@ -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 57 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: Day57 happiness ≤10 continuously AND celebration escapes ×3
* E3: awareness ≥70 AND clues ≥6/9 AND chose fog wall
* E2: awareness 3069
* 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: '糖衣',
};
+325
View File
@@ -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 T8T10 descriptions are character-identical to D1 T1T3', () => {
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 → S0S4', () => {
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 3069', () => {
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);
});
});
+173
View File
@@ -0,0 +1,173 @@
/**
* Integration-style tests: drive shipped Director through T1T15, 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 T1T15 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 E1E4', () => {
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('病历名');
});
});
+78
View File
@@ -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');
}
});
});
+131
View File
@@ -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;
}
+150
View File
@@ -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;
/** T8T10 mirror T1T3 */
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;
}
+61
View File
@@ -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;
+102
View File
@@ -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);
}
}
+53
View File
@@ -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);
}
}
}
+269
View File
@@ -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);
}
}
+77
View File
@@ -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);
}
}
+71
View File
@@ -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);
}
}
+362
View File
@@ -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
+688
View File
@@ -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 D5D7 */
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 D57
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;
+27
View File
@@ -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();
+127
View File
@@ -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+Digit17 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 Digit09 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/F1F4', () => {
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);
}
});
});
+67
View File
@@ -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` Digit1Digit7.
*/
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 + Digit17 → 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 Digit09 → 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' };
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />