feat: deepen battle atmosphere and portrait eras

This commit is contained in:
2026-07-21 23:32:34 +09:00
parent f39de4f47d
commit 3a41f8b671
20 changed files with 1471 additions and 58 deletions

View File

@@ -57,6 +57,8 @@ export type MusicDuckOptions = {
releaseMs?: number;
};
type SemanticCueGroup = 'turn' | 'operation' | 'objective';
type MusicDuckState = {
multiplier: number;
timer?: number;
@@ -97,6 +99,19 @@ export class SoundDirector {
active: false,
endsAt: 0
};
private readonly semanticCueCooldownMs: Record<SemanticCueGroup, number> = {
turn: 900,
operation: 650,
objective: 1200
};
private readonly semanticCuePlayedAt = new Map<SemanticCueGroup, number>();
private lastPlayedSemanticCue?: { group: SemanticCueGroup; key: string; at: number };
private lastSuppressedSemanticCue?: {
group: SemanticCueGroup;
key: string;
at: number;
remainingMs: number;
};
private lastEffect?: {
key: string;
trackKey: string;
@@ -379,6 +394,46 @@ export class SoundDirector {
});
}
playAllyTurnCue() {
return this.playSemanticCue('turn', 'ally-turn', {
volume: 0.32,
stopAfterMs: 2100,
duck: { multiplier: 0.62, attackMs: 36, holdMs: 1050, releaseMs: 420 }
});
}
playEnemyTurnCue() {
return this.playSemanticCue('turn', 'enemy-turn', {
volume: 0.3,
stopAfterMs: 2400,
duck: { multiplier: 0.56, attackMs: 36, holdMs: 1350, releaseMs: 480 }
});
}
playOperationAlert() {
return this.playSemanticCue('operation', 'operation-alert', {
volume: 0.34,
stopAfterMs: 2100,
duck: { multiplier: 0.48, attackMs: 28, holdMs: 820, releaseMs: 430 }
});
}
playObjectiveAchieved() {
return this.playSemanticCue('objective', 'objective-success', {
volume: 0.34,
stopAfterMs: 1700,
duck: { multiplier: 0.42, attackMs: 48, holdMs: 900, releaseMs: 480 }
});
}
playObjectiveFailed() {
return this.playSemanticCue('objective', 'objective-failure', {
volume: 0.32,
stopAfterMs: 1600,
duck: { multiplier: 0.36, attackMs: 48, holdMs: 900, releaseMs: 520 }
});
}
playStrategyPulse() {
this.playEffect('strategy-cast', { volume: 0.44, rate: 0.96, stopAfterMs: 960 });
this.playTone(460, 0.08, 0.012, 'sine');
@@ -442,6 +497,37 @@ export class SoundDirector {
return true;
}
private playSemanticCue(
group: SemanticCueGroup,
key: string,
options: PlayEffectOptions & { duck: MusicDuckOptions }
) {
if (!this.started || this.muted || !this.effectTracks[key]) {
return false;
}
const now = performance.now();
const lastPlayedAt = this.semanticCuePlayedAt.get(group);
const cooldownMs = this.semanticCueCooldownMs[group];
if (lastPlayedAt !== undefined && now - lastPlayedAt < cooldownMs) {
this.lastSuppressedSemanticCue = {
group,
key,
at: now,
remainingMs: Math.ceil(cooldownMs - (now - lastPlayedAt))
};
return false;
}
this.duckMusic(options.duck);
const played = this.playEffect(key, options);
if (played) {
this.semanticCuePlayedAt.set(group, now);
this.lastPlayedSemanticCue = { group, key, at: now };
}
return played;
}
getDebugState() {
const music = this.loopChannelDebugState(this.musicChannel);
const ambience = this.loopChannelDebugState(this.ambienceChannel);
@@ -465,6 +551,12 @@ export class SoundDirector {
endsAt: this.musicDuck.endsAt,
last: this.musicDuck.last ? { ...this.musicDuck.last } : null
},
semanticCues: {
cooldownMs: { ...this.semanticCueCooldownMs },
playedAt: Object.fromEntries(this.semanticCuePlayedAt),
lastPlayed: this.lastPlayedSemanticCue ? { ...this.lastPlayedSemanticCue } : null,
lastSuppressed: this.lastSuppressedSemanticCue ? { ...this.lastSuppressedSemanticCue } : null
},
effectPools: Object.fromEntries(
Object.entries(this.effectPools).map(([key, trackKeys]) => [key, [...trackKeys]])
),