Files
jsc-marketplace/plugins/jsc-mod-companion/hooks/companion/emotion.ts
T
rootandClaude Sonnet 5.5 13d3678032 feat: companion 狀態列與 info 輸出對齊設計稿
- 狀態列的 PAD 條改為最強的兩種情緒(不含精力)
- info 短版只列名字、關係、前 3 強情緒、適應度與待辦
- info --all 補上 12 格情緒與關係長條、人格 18 參數三層值、世界與時間感,基本資訊移到這裡
- 版本號改為 0.0.4

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 10:03:31 +00:00

180 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { clamp, MOOD_EVENTS } from './mood'
import type { MoodEvent } from './mood'
import { bar, width } from './seg'
import type { Pad } from './types'
const HOUR = 3_600_000
// 八個情緒維度,值域皆為 0~1(心情、精力以 0.5 為中性)
export const DIMS = ['mood', 'energy', 'shy', 'jealous', 'missing', 'heartbeat', 'homesick', 'upset'] as const
export type Dim = (typeof DIMS)[number]
export type Emotions = Record<Dim, number>
export type EmotionState = Emotions & { at: number }
// 各維度半衰期(小時,神經質 50 時的值)
export const HALF_LIFE_H: Record<Dim, number> = {
mood: 8,
energy: 6,
shy: 2,
jealous: 4,
missing: 12,
heartbeat: 1,
homesick: 24,
upset: 4,
}
export const DIM_INFO: Record<Dim, { name: string; kaomoji: string; ansi: string }> = {
mood: { name: '心情', kaomoji: '(´ᴗ`)', ansi: '32' },
energy: { name: '精力', kaomoji: '(≧▽≦)', ansi: '33' },
shy: { name: '害羞', kaomoji: '(๑>◡<๑)', ansi: '36' },
jealous: { name: '忌妒', kaomoji: '( ̄^ ̄)', ansi: '35' },
missing: { name: '想念', kaomoji: '(´;ω;`)', ansi: '34' },
heartbeat: { name: '心跳', kaomoji: '(*´∀`*)', ansi: '91' },
homesick: { name: '想家', kaomoji: '(´・_・`)', ansi: '90' },
upset: { name: '不悅', kaomoji: '(╬`益´)', ansi: '31' },
}
// 心情、精力是雙向維度,主情緒標籤依高低而定
const POLES: Partial<Record<Dim, [low: string, high: string]>> = { mood: ['低落', '開心'], energy: ['疲倦', '有精神'] }
// 高神經質的角色情緒消退較慢:50 為基準,0→0.5 倍,100→1.5 倍
export const halfLifeMs = (dim: Dim, neuroticism: number) => HALF_LIFE_H[dim] * HOUR * (0.5 + neuroticism / 100)
export const clampDim = (x: number) => clamp(x, 0, 1)
/** 由 PAD 基線推得各維度基線;其餘維度基線為 0 */
export function baselineOf(pad: Pad, override: Partial<Emotions> = {}): Emotions {
return {
mood: clampDim((pad.p + 1) / 2),
energy: clampDim((pad.a + 1) / 2),
shy: clampDim(-pad.d),
jealous: 0,
missing: 0,
heartbeat: 0,
homesick: 0,
upset: 0,
...override,
}
}
/** 晝夜節律:精力基線在本地 15 點最高、凌晨 3 點最低,振幅 ±0.1 */
export function circadian(nowMs: number, tzOffsetMin: number): number {
const hour = (((nowMs / HOUR + tzOffsetMin / 60) % 24) + 24) % 24
return 0.1 * Math.cos(((hour - 15) / 24) * 2 * Math.PI)
}
// 讀檔時才計算:x = B + (x − B)·0.5^(Δ/τ),精力的 B 疊加晝夜節律
export function decayEmotions(s: EmotionState, baseline: Emotions, now: number, neuroticism: number, tzOffsetMin: number): Emotions {
const dt = Math.max(0, now - s.at)
const out = {} as Emotions
for (const d of DIMS) {
const b = d === 'energy' ? clampDim(baseline.energy + circadian(now, tzOffsetMin)) : baseline[d]
out[d] = clampDim(b + (s[d] - b) * Math.pow(0.5, dt / halfLifeMs(d, neuroticism)))
}
return out
}
/** 由八維度推回 PAD,供記憶檢索與相容介面使用 */
export function toPad(e: Emotions, baseline: Emotions, basePad: Pad): Pad {
return {
p: clamp((e.mood - 0.5) * 2 - e.upset),
a: clamp((e.energy - 0.5) * 2),
d: clamp(basePad.d + baseline.shy - e.shy),
}
}
/** 舊 PAD 存檔 → 八維度(p→心情、a→精力、d→害羞,其餘維度取基線) */
export function fromPad(mood: Pad & { at: number }, baseline: Emotions, basePad: Pad): EmotionState {
return {
...baseline,
mood: clampDim((mood.p + 1) / 2),
energy: clampDim((mood.a + 1) / 2),
shy: clampDim(baseline.shy + basePad.d - mood.d),
at: mood.at,
}
}
// 情感注入:事件(評估標籤)對各維度的增減。mood/upset/energy/shy 由 rules.ts 的 PAD 增減換算
// (p>0 → 心情 p/2;p<0 → 心情 0.3p、不悅 −0.4p;a/2 → 精力;−d → 害羞),合成後與 PAD 增減一致;
// 心跳、想念、想家等新維度的額外效果寫在 EXTRA
const EXTRA: Partial<Record<MoodEvent, Partial<Emotions>>> = {
thanks: { heartbeat: 0.05 },
return: { missing: -0.3, heartbeat: 0.1 },
lateNight: { homesick: 0.05 },
favorite: { heartbeat: 0.08 },
ignored: { missing: 0.1 },
cared: { heartbeat: 0.05, missing: -0.1 },
remembered: { heartbeat: 0.05 },
}
export function emotionEffect(ev: MoodEvent): Partial<Emotions> {
const { p, a, d } = MOOD_EVENTS[ev]
return {
mood: p > 0 ? p / 2 : p * 0.3,
upset: p < 0 ? -p * 0.4 : 0,
energy: a / 2,
shy: -d,
...EXTRA[ev],
}
}
// 「壞」方向:心情下降,或不悅、忌妒上升
const isBad = (d: Dim, x: number) => (d === 'mood' ? x < 0 : (d === 'upset' || d === 'jealous') && x > 0)
export const TURN_CAP = 0.3
export const TIRED = 0.3
export const IRRITABLE = 1.5
export const HABITUATION = 0.7
export const SENSITIZATION = 0.25
export const SENSITIZATION_MAX = 2
export const HABIT_HALF_LIFE_H = 6
export type Habit = Partial<Record<MoodEvent, { n: number; at: number }>>
const habitCount = (h: Habit, ev: MoodEvent, now: number) => {
const r = h[ev]
return r ? r.n * Math.pow(0.5, Math.max(0, now - r.at) / (HABIT_HALF_LIFE_H * HOUR)) : 0
}
/**
* 套用事件:正向事件習慣化(重複受益遞減,0.7^n)、負向事件敏感化(重複受創放大,上限 2 倍)、
* 精力低於 0.3 時負面變化放大 1.5 倍(疲勞易怒);每個維度單回合變化上限 ±0.3。
* 計次隨時間以 6 小時半衰期遺忘。
*/
export function applyEvent(cur: Emotions, habit: Habit, ev: MoodEvent, now: number): { emotions: Emotions; habit: Habit } {
const n = habitCount(habit, ev, now)
const p = MOOD_EVENTS[ev].p
const gain = p > 0 ? Math.pow(HABITUATION, n) : 1
const harm = p < 0 ? Math.min(SENSITIZATION_MAX, 1 + SENSITIZATION * n) : 1
const irritable = cur.energy < TIRED ? IRRITABLE : 1
const out = { ...cur }
for (const [d, raw] of Object.entries(emotionEffect(ev)) as [Dim, number][]) {
const dx = raw * (isBad(d, raw) ? harm * irritable : gain)
out[d] = clampDim(out[d] + clamp(dx, -TURN_CAP, TURN_CAP))
}
return { emotions: out, habit: { ...habit, [ev]: { n: n + 1, at: now } } }
}
/** 強度:雙向維度為偏離中性的程度,其餘維度即其值 */
export const salience = (d: Dim, v: number) => (POLES[d] ? Math.abs(v - 0.5) * 2 : v)
export function ranked(e: Emotions): Dim[] {
return [...DIMS].sort((a, b) => salience(b, e[b]) - salience(a, e[a]))
}
export const DOMINANT_MIN = 0.25
/** 主情緒:強度達門檻的最強維度;否則 null,由 PAD 八分區標籤補位 */
export function dominant(e: Emotions): { dim: Dim; label: string; kaomoji: string } | null {
const dim = ranked(e)[0]!
if (salience(dim, e[dim]) < DOMINANT_MIN) return null
const poles = POLES[dim]
return { dim, label: poles ? poles[e[dim] >= 0.5 ? 1 : 0] : DIM_INFO[dim].name, kaomoji: DIM_INFO[dim].kaomoji }
}
/** info --all 的情緒量表:▰▱ 依維度上色,名稱補齊到同寬 */
export function gauge(d: Dim, e: Emotions, cells = 12): string {
const name = DIM_INFO[d].name
return `${name}${' '.repeat(Math.max(0, 6 - width(name)))}\x1b[${DIM_INFO[d].ansi}m${bar(e[d] * 100, cells)}\x1b[0m ${String(Math.round(e[d] * 100)).padStart(3)}`
}