237 lines
7.4 KiB
TypeScript
237 lines
7.4 KiB
TypeScript
// CorruptionDirector.ts: single source of truth for all state + A/V params.
|
|
// Holds happiness/awareness/day/task flags/clue set/erosion stage.
|
|
// Emits events for scenes to react.
|
|
import { audio } from './AudioEngine';
|
|
import {
|
|
HAPPY_START, AWARE_START, HAPPY_MAX, AWARE_MAX, OVERFLOW_THRESHOLD,
|
|
EROSION_MATRIX, AWARE_CLUE_GAIN, AWARE_SAD_GAIN, HAPPY_SAD_LOSS,
|
|
HAPPY_SMALL_DAILY_CAP, DEFAULT_NAME, LOW_HAPPY_THRESHOLD, LOW_HAPPY_DURATION_MS,
|
|
FORCE_FESTIVAL_HAPPY,
|
|
} from '../data/gamedata';
|
|
|
|
export type Stage = 'S0' | 'S1' | 'S2' | 'S3' | 'S4';
|
|
|
|
type Handler = (payload?: any) => void;
|
|
|
|
class CorruptionDirector {
|
|
happiness = HAPPY_START;
|
|
awareness = AWARE_START;
|
|
day = 1;
|
|
stage: Stage = 'S1';
|
|
playerName = DEFAULT_NAME;
|
|
startTime = Date.now();
|
|
|
|
// clue set
|
|
clues = new Set<string>();
|
|
// task completion
|
|
tasksDone = new Set<string>();
|
|
// sadness: small-interaction daily counters per kind
|
|
smallInteractCounts: Record<string, number> = {}; // key: kind@day
|
|
// npc talk counts per day (for 纠缠 detection)
|
|
npcTalkCounts: Record<string, number> = {};
|
|
// rejection records (Day 5)
|
|
rejectedTasks = new Set<string>();
|
|
// escaped forced festivals count (E4)
|
|
escapedFestivals = 0;
|
|
// happiness-low tracker
|
|
private lowHappySince = 0;
|
|
// overflow pool
|
|
private overflowPool = 0;
|
|
// last negative flash time
|
|
private lastFlashAt = 0;
|
|
|
|
// gaze tracking (well / fog) once per day
|
|
gazedWell = false;
|
|
gazedFog = false;
|
|
|
|
// whether forced festival is active
|
|
forcedFestivalActive = false;
|
|
|
|
private listeners: Record<string, Handler[]> = {};
|
|
|
|
on(ev: string, h: Handler) {
|
|
(this.listeners[ev] ||= []).push(h);
|
|
return () => this.off(ev, h);
|
|
}
|
|
off(ev: string, h: Handler) {
|
|
const arr = this.listeners[ev]; if (!arr) return;
|
|
const i = arr.indexOf(h); if (i >= 0) arr.splice(i, 1);
|
|
}
|
|
emit(ev: string, payload?: any) {
|
|
(this.listeners[ev] || []).slice().forEach(h => { try { h(payload); } catch (e) { console.error(e); } });
|
|
}
|
|
|
|
reset() {
|
|
this.happiness = HAPPY_START;
|
|
this.awareness = AWARE_START;
|
|
this.day = 1;
|
|
this.stage = 'S1';
|
|
this.playerName = DEFAULT_NAME;
|
|
this.startTime = Date.now();
|
|
this.clues.clear();
|
|
this.tasksDone.clear();
|
|
this.smallInteractCounts = {};
|
|
this.npcTalkCounts = {};
|
|
this.rejectedTasks.clear();
|
|
this.escapedFestivals = 0;
|
|
this.lowHappySince = 0;
|
|
this.overflowPool = 0;
|
|
this.gazedWell = false;
|
|
this.gazedFog = false;
|
|
this.forcedFestivalActive = false;
|
|
this.recomputeStage();
|
|
this.emit('change');
|
|
}
|
|
|
|
recomputeStage() {
|
|
const h = this.happiness;
|
|
let s: Stage = 'S0';
|
|
for (const row of EROSION_MATRIX) {
|
|
if (h >= row.happinessRange[0] && h <= row.happinessRange[1]) { s = row.stage; break; }
|
|
}
|
|
if (s !== this.stage) {
|
|
this.stage = s;
|
|
audio.setErosion(s);
|
|
this.emit('stage', s);
|
|
}
|
|
this.emit('change');
|
|
}
|
|
|
|
setHappiness(v: number, opts: { silent?: boolean } = {}) {
|
|
const prev = this.happiness;
|
|
let nv = Math.max(0, Math.min(HAPPY_MAX, Math.round(v)));
|
|
// forced maintenance at 100: small deductions accumulate in overflow pool
|
|
if (prev >= HAPPY_MAX && nv < prev) {
|
|
const diff = prev - nv;
|
|
this.overflowPool += diff;
|
|
nv = HAPPY_MAX; // stays at 100
|
|
if (this.overflowPool >= OVERFLOW_THRESHOLD) {
|
|
this.overflowPool = 0;
|
|
this.emit('negativeFlash');
|
|
this.stage = 'S4';
|
|
audio.setErosion('S4');
|
|
this.emit('stage', 'S4');
|
|
}
|
|
}
|
|
this.happiness = nv;
|
|
if (!opts.silent) this.recomputeStage();
|
|
this.checkLowHappy();
|
|
}
|
|
|
|
addHappiness(delta: number) {
|
|
if (delta > 0 && this.happiness >= HAPPY_MAX) {
|
|
// overflow pool accumulation (PRD: 小额扣分不再生效而是累积进隐藏溢出池) — for increases beyond 100, ignore
|
|
return;
|
|
}
|
|
this.setHappiness(this.happiness + delta);
|
|
}
|
|
|
|
addAwareness(delta: number) {
|
|
this.awareness = Math.max(0, Math.min(AWARE_MAX, this.awareness + delta));
|
|
this.emit('change');
|
|
}
|
|
|
|
// §6.2 clue discovery
|
|
discoverClue(id: string) {
|
|
if (this.clues.has(id)) return false;
|
|
this.clues.add(id);
|
|
this.addAwareness(AWARE_CLUE_GAIN);
|
|
this.emit('clue', id);
|
|
this.emit('change');
|
|
return true;
|
|
}
|
|
|
|
// §6.1 small interaction (water/feed/pet) +1, daily cap 3 per kind
|
|
smallInteraction(kind: string) {
|
|
const key = `${kind}@${this.day}`;
|
|
const count = this.smallInteractCounts[key] || 0;
|
|
if (count >= HAPPY_SMALL_DAILY_CAP) return false;
|
|
this.smallInteractCounts[key] = count + 1;
|
|
this.addHappiness(1);
|
|
return true;
|
|
}
|
|
|
|
// §6.3 sadness behavior: -5 happy, +3 aware, nearest NPC turns
|
|
sadness(behavior: string, nearestNpc?: string) {
|
|
this.addHappiness(-HAPPY_SAD_LOSS);
|
|
this.addAwareness(AWARE_SAD_GAIN);
|
|
this.emit('sadness', { behavior, nearestNpc });
|
|
this.emit('change');
|
|
}
|
|
|
|
// gaze well/fog (§6.2)
|
|
gaze(target: 'well' | 'fog') {
|
|
if (target === 'well' && !this.gazedWell) { this.gazedWell = true; this.addAwareness(3); }
|
|
if (target === 'fog' && !this.gazedFog) { this.gazedFog = true; this.addAwareness(3); }
|
|
}
|
|
|
|
// NPC talk counter for 纠缠 (>=5/day)
|
|
noteNpcTalk(npcKey: string): boolean {
|
|
const key = `${npcKey}@${this.day}`;
|
|
this.npcTalkCounts[key] = (this.npcTalkCounts[key] || 0) + 1;
|
|
return this.npcTalkCounts[key] >= 5;
|
|
}
|
|
|
|
private checkLowHappy() {
|
|
if (this.happiness < LOW_HAPPY_THRESHOLD) {
|
|
if (this.lowHappySince === 0) this.lowHappySince = Date.now();
|
|
else if (Date.now() - this.lowHappySince >= LOW_HAPPY_DURATION_MS && !this.forcedFestivalActive) {
|
|
this.triggerForcedFestival();
|
|
}
|
|
} else {
|
|
this.lowHappySince = 0;
|
|
}
|
|
}
|
|
|
|
// §6.1 forced festival
|
|
triggerForcedFestival() {
|
|
this.forcedFestivalActive = true;
|
|
this.emit('forcedFestivalStart');
|
|
// raise happiness to 50
|
|
this.setHappiness(FORCE_FESTIVAL_HAPPY);
|
|
}
|
|
escapeFestival() {
|
|
if (!this.forcedFestivalActive) return;
|
|
this.forcedFestivalActive = false;
|
|
this.escapedFestivals++;
|
|
this.emit('forcedFestivalEnd');
|
|
this.emit('change');
|
|
}
|
|
|
|
advanceDay() {
|
|
this.day = Math.min(7, this.day + 1);
|
|
this.gazedWell = false; this.gazedFog = false;
|
|
this.emit('day', this.day);
|
|
this.emit('change');
|
|
}
|
|
|
|
// ending determination per §9 priority
|
|
determineEnding(): 'E4'|'E3'|'E2'|'E1' {
|
|
if (this.day >= 5 && this.happiness <= 10 && this.escapedFestivals >= 3) return 'E4';
|
|
if (this.awareness >= 70 && this.clues.size >= 6) return 'E3';
|
|
if (this.awareness >= 30) return 'E2';
|
|
if (this.awareness <= 29 && this.happiness >= 90) return 'E1';
|
|
// fallback
|
|
if (this.awareness <= 29) return 'E1';
|
|
return 'E2';
|
|
}
|
|
|
|
playTimeSeconds(): number {
|
|
return Math.floor((Date.now() - this.startTime) / 1000);
|
|
}
|
|
formatPlayTime(): string {
|
|
const s = this.playTimeSeconds();
|
|
const m = Math.floor(s / 60);
|
|
const sec = s % 60;
|
|
return `${m}分${sec}秒`;
|
|
}
|
|
|
|
// debug setters
|
|
debugSetHappiness(v: number) { this.happiness = Math.max(0,Math.min(HAPPY_MAX,v)); this.recomputeStage(); }
|
|
debugSetAwareness(v: number) { this.awareness = Math.max(0,Math.min(AWARE_MAX,v)); this.emit('change'); }
|
|
debugSetDay(d: number) { this.day = Math.max(1,Math.min(7,d)); this.emit('day',this.day); this.emit('change'); }
|
|
debugAllClues() { ['C1','C2','C3','C4','C5','C6','C7','C8','C9'].forEach(c=>this.clues.add(c)); this.addAwareness(0); this.emit('change'); }
|
|
}
|
|
|
|
export const director = new CorruptionDirector();
|