feat: improve portraits and combat audio

This commit is contained in:
2026-07-21 21:56:30 +09:00
parent 99a88c7128
commit f39de4f47d
27 changed files with 1396 additions and 154 deletions

View File

@@ -1,5 +1,11 @@
type AudioTrackMap = Record<string, string>;
export type AudioCategory = 'music' | 'effects' | 'ambience';
export type AudioCategoryMultipliers = Record<AudioCategory, number>;
export type EffectPoolMap = Record<string, readonly string[]>;
export type SoundscapeOptions = {
musicKey?: string;
ambienceKey?: string;
@@ -7,7 +13,7 @@ export type SoundscapeOptions = {
ambienceVolume?: number;
};
type LoopChannelName = 'music' | 'ambience';
type LoopChannelName = Exclude<AudioCategory, 'effects'>;
type LoopChannelTransition = {
revision: number;
@@ -32,26 +38,74 @@ type LoopChannel = {
hasPendingRequest: boolean;
outgoing: Set<HTMLAudioElement>;
outgoingStartVolumes: Map<HTMLAudioElement, number>;
elementBaseVolumes: Map<HTMLAudioElement, number>;
fadeTimer?: number;
elementRevision: number;
transition: LoopChannelTransition;
};
type PlayEffectOptions = {
export type PlayEffectOptions = {
volume?: number;
rate?: number;
stopAfterMs?: number;
};
export type MusicDuckOptions = {
multiplier?: number;
attackMs?: number;
holdMs?: number;
releaseMs?: number;
};
type MusicDuckState = {
multiplier: number;
timer?: number;
revision: number;
completedRevision: number;
active: boolean;
endsAt: number;
last?: {
multiplier: number;
attackMs: number;
holdMs: number;
releaseMs: number;
};
};
export class SoundDirector {
private context?: AudioContext;
private master?: GainNode;
private effectsBus?: GainNode;
private started = false;
private muted = false;
private effectTracks: AudioTrackMap = {};
private effectPools: Record<string, string[]> = {};
private readonly lastEffectPoolSelections = new Map<string, string>();
private readonly activeEffects = new Set<HTMLAudioElement>();
private readonly effectBaseVolumes = new Map<HTMLAudioElement, number>();
private readonly musicChannel = this.createLoopChannel('music', 0.22, 900);
private readonly ambienceChannel = this.createLoopChannel('ambience', 0.08, 1200);
private lastEffect?: { key: string; at: number; volume: number; rate: number };
private readonly categoryMultipliers: AudioCategoryMultipliers = {
music: 1,
effects: 1,
ambience: 1
};
private readonly musicDuck: MusicDuckState = {
multiplier: 1,
revision: 0,
completedRevision: 0,
active: false,
endsAt: 0
};
private lastEffect?: {
key: string;
trackKey: string;
poolKey: string | null;
at: number;
baseVolume: number;
volume: number;
rate: number;
};
private lastMovementStep?: { key: string; mounted: boolean; stepIndex: number; at: number; volume?: number; rate?: number };
private readonly effectVolume = 0.42;
@@ -67,6 +121,16 @@ export class SoundDirector {
this.effectTracks = tracks;
}
registerEffectPools(pools: EffectPoolMap) {
this.effectPools = Object.fromEntries(
Object.entries(pools).map(([key, trackKeys]) => [
key,
[...new Set(trackKeys.filter((trackKey) => typeof trackKey === 'string' && trackKey.length > 0))]
])
);
this.lastEffectPoolSelections.clear();
}
start() {
if (this.started) {
this.resume();
@@ -81,6 +145,9 @@ export class SoundDirector {
this.master = this.context.createGain();
this.master.gain.value = this.muted ? 0 : 0.3;
this.master.connect(this.context.destination);
this.effectsBus = this.context.createGain();
this.effectsBus.gain.value = this.categoryMultipliers.effects;
this.effectsBus.connect(this.master);
}
this.started = true;
@@ -105,6 +172,9 @@ export class SoundDirector {
for (const element of this.loopElements()) {
element.muted = muted;
}
for (const effect of this.activeEffects) {
effect.muted = muted;
}
if (!this.context || !this.master) {
return;
@@ -118,6 +188,91 @@ export class SoundDirector {
return this.muted;
}
setCategoryMultiplier(category: AudioCategory, multiplier: number) {
const normalizedMultiplier = this.normalizeUnitInterval(multiplier, this.categoryMultipliers[category]);
this.categoryMultipliers[category] = normalizedMultiplier;
if (category === 'effects') {
if (this.effectsBus) {
this.effectsBus.gain.value = normalizedMultiplier;
}
this.refreshActiveEffectVolumes();
return;
}
this.refreshLoopChannelVolumes(category === 'music' ? this.musicChannel : this.ambienceChannel);
}
setCategoryMultipliers(multipliers: Partial<AudioCategoryMultipliers>) {
(['music', 'effects', 'ambience'] as const).forEach((category) => {
const multiplier = multipliers[category];
if (multiplier !== undefined) {
this.setCategoryMultiplier(category, multiplier);
}
});
}
duckMusic({ multiplier = 0.42, attackMs = 48, holdMs = 180, releaseMs = 280 }: MusicDuckOptions = {}) {
this.cancelMusicDuckTimer();
const normalizedMultiplier = this.normalizeUnitInterval(multiplier, 0.42);
const normalizedAttackMs = this.normalizeDuration(attackMs, 48);
const normalizedHoldMs = this.normalizeDuration(holdMs, 180);
const normalizedReleaseMs = this.normalizeDuration(releaseMs, 280);
const fromMultiplier = this.musicDuck.multiplier;
const targetMultiplier = Math.min(fromMultiplier, normalizedMultiplier);
const startedAt = performance.now();
const previousEndsAt = this.musicDuck.active ? this.musicDuck.endsAt : startedAt;
const previousReleaseMs = this.musicDuck.active ? this.musicDuck.last?.releaseMs ?? 0 : 0;
const effectiveReleaseMs = Math.max(normalizedReleaseMs, previousReleaseMs);
const requestedEndsAt = startedAt + normalizedAttackMs + normalizedHoldMs + normalizedReleaseMs;
const endsAt = Math.max(previousEndsAt, requestedEndsAt);
const releaseStartsAt = Math.max(startedAt + normalizedAttackMs + normalizedHoldMs, endsAt - effectiveReleaseMs);
this.musicDuck.revision += 1;
this.musicDuck.active = true;
this.musicDuck.endsAt = endsAt;
this.musicDuck.last = {
multiplier: normalizedMultiplier,
attackMs: normalizedAttackMs,
holdMs: normalizedHoldMs,
releaseMs: normalizedReleaseMs
};
const revision = this.musicDuck.revision;
const update = () => {
if (revision !== this.musicDuck.revision) {
return;
}
const now = performance.now();
const elapsedMs = Math.max(0, now - startedAt);
let nextMultiplier = targetMultiplier;
if (normalizedAttackMs > 0 && elapsedMs < normalizedAttackMs) {
const progress = elapsedMs / normalizedAttackMs;
nextMultiplier = fromMultiplier + (targetMultiplier - fromMultiplier) * progress;
} else if (now >= releaseStartsAt) {
if (effectiveReleaseMs <= 0) {
nextMultiplier = 1;
} else {
const releaseProgress = Math.min(1, (now - releaseStartsAt) / effectiveReleaseMs);
nextMultiplier = targetMultiplier + (1 - targetMultiplier) * releaseProgress;
}
}
this.setMusicDuckMultiplier(nextMultiplier);
if (now >= endsAt) {
this.completeMusicDuck(revision);
}
};
update();
if (endsAt > startedAt && this.musicDuck.active) {
this.musicDuck.timer = window.setInterval(update, 16);
}
}
playHover() {
this.playTone(540, 0.04, 0.018, 'sine');
}
@@ -168,30 +323,62 @@ export class SoundDirector {
}
playMeleeSwing(hit: boolean, critical: boolean) {
this.playEffect('sword-slash', {
this.playEffect('melee-swing', {
volume: critical ? 0.62 : hit ? 0.52 : 0.38,
rate: critical ? 0.94 : hit ? 1 : 1.1,
stopAfterMs: 640
stopAfterMs: 1700
});
this.playTone(critical ? 180 : 220, 0.04, critical ? 0.03 : 0.02, 'sawtooth');
}
playArrowShot() {
this.playEffect('sword-slash', { volume: 0.18, rate: 1.58, stopAfterMs: 280 });
this.playEffect('bow-release', { volume: 0.34, rate: 1, stopAfterMs: 420 });
this.playTone(760, 0.05, 0.012, 'triangle');
window.setTimeout(() => this.playTone(1180, 0.045, 0.01, 'sine'), 46);
}
playArrowImpact(hit: boolean, critical: boolean) {
if (hit) {
this.playEffect('combat-impact', { volume: critical ? 0.5 : 0.34, rate: critical ? 1.06 : 1.18, stopAfterMs: 420 });
this.playEffect('arrow-impact', {
volume: critical ? 0.5 : 0.38,
rate: critical ? 0.94 : 1,
stopAfterMs: 760
});
this.playTone(critical ? 260 : 340, 0.05, critical ? 0.026 : 0.018, 'square');
if (critical) {
this.playCriticalAccent();
}
return;
}
this.playEffect('arrow-miss', { volume: 0.28, rate: 1.04, stopAfterMs: 500 });
this.playTone(940, 0.06, 0.011, 'sine');
}
playCombatImpact(critical: boolean, restrained = false) {
this.playEffect('melee-impact', {
volume: restrained ? (critical ? 0.4 : 0.26) : critical ? 0.58 : 0.46,
rate: critical ? 0.94 : 1,
stopAfterMs: 1700
});
if (critical) {
this.playCriticalAccent();
}
}
playBattleOutcome(outcome: 'victory' | 'defeat') {
this.duckMusic({
multiplier: outcome === 'victory' ? 0.34 : 0.24,
attackMs: 90,
holdMs: 820,
releaseMs: 520
});
this.playEffect(outcome === 'victory' ? 'victory-fanfare' : 'defeat-sting', {
volume: outcome === 'victory' ? 0.34 : 0.26,
stopAfterMs: 1200
});
}
playStrategyPulse() {
this.playEffect('strategy-cast', { volume: 0.44, rate: 0.96, stopAfterMs: 960 });
this.playTone(460, 0.08, 0.012, 'sine');
@@ -208,33 +395,50 @@ export class SoundDirector {
return false;
}
const src = this.effectTracks[key];
if (!src) {
const resolvedTrack = this.resolveEffectTrack(key);
if (!resolvedTrack) {
return false;
}
const effect = new Audio(src);
const effect = new Audio(resolvedTrack.src);
const baseVolume = this.normalizeUnitInterval(options.volume ?? this.effectVolume, this.effectVolume);
effect.preload = 'auto';
effect.volume = options.volume ?? this.effectVolume;
effect.volume = this.effectOutputVolume(baseVolume);
effect.playbackRate = options.rate ?? 1;
effect.muted = this.muted;
this.lastEffect = { key, at: performance.now(), volume: effect.volume, rate: effect.playbackRate };
this.activeEffects.add(effect);
this.effectBaseVolumes.set(effect, baseVolume);
this.lastEffect = {
key,
trackKey: resolvedTrack.trackKey,
poolKey: resolvedTrack.poolKey,
at: performance.now(),
baseVolume,
volume: effect.volume,
rate: effect.playbackRate
};
effect.addEventListener(
'ended',
() => {
effect.src = '';
this.retireEffectElement(effect);
},
{ once: true }
);
effect.addEventListener(
'error',
() => {
this.retireEffectElement(effect);
},
{ once: true }
);
if (options.stopAfterMs) {
window.setTimeout(() => {
effect.pause();
effect.src = '';
this.retireEffectElement(effect);
}, options.stopAfterMs);
}
void effect.play().catch(() => undefined);
void effect.play().catch(() => this.retireEffectElement(effect));
return true;
}
@@ -252,6 +456,19 @@ export class SoundDirector {
pendingAmbienceKey: ambience.pendingKey,
music,
ambience,
categoryMultipliers: { ...this.categoryMultipliers },
musicDuck: {
multiplier: this.musicDuck.multiplier,
revision: this.musicDuck.revision,
completedRevision: this.musicDuck.completedRevision,
active: this.musicDuck.active,
endsAt: this.musicDuck.endsAt,
last: this.musicDuck.last ? { ...this.musicDuck.last } : null
},
effectPools: Object.fromEntries(
Object.entries(this.effectPools).map(([key, trackKeys]) => [key, [...trackKeys]])
),
activeEffectCount: this.activeEffects.size,
lastEffect: this.lastEffect ?? null,
lastMovementStep: this.lastMovementStep ?? null
};
@@ -302,11 +519,19 @@ export class SoundDirector {
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
oscillator.connect(gain);
gain.connect(this.master ?? this.context.destination);
gain.connect(this.effectsBus ?? this.master ?? this.context.destination);
oscillator.start(now);
oscillator.stop(now + duration + 0.04);
}
private playCriticalAccent() {
this.duckMusic({ multiplier: 0.52, attackMs: 32, holdMs: 140, releaseMs: 280 });
this.playEffect('critical-impact', { volume: 0.3, rate: 1, stopAfterMs: 800 });
window.setTimeout(() => {
this.playEffect('critical-boom', { volume: 0.16, rate: 1.04, stopAfterMs: 2000 });
}, 34);
}
private playStepPulse(mounted: boolean, stepIndex: number, volume = 0.2) {
if (!this.context || this.muted) {
return;
@@ -347,7 +572,7 @@ export class SoundDirector {
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
oscillator.connect(gain);
gain.connect(this.master ?? this.context.destination);
gain.connect(this.effectsBus ?? this.master ?? this.context.destination);
oscillator.start(start);
oscillator.stop(start + duration + 0.025);
}
@@ -362,6 +587,7 @@ export class SoundDirector {
hasPendingRequest: false,
outgoing: new Set(),
outgoingStartVolumes: new Map(),
elementBaseVolumes: new Map(),
elementRevision: 0,
transition: {
revision: 0,
@@ -408,7 +634,7 @@ export class SoundDirector {
if (channel.current) {
channel.current.muted = this.muted;
if (!channel.transition.active) {
channel.current.volume = channel.targetVolume;
this.setLoopElementBaseVolume(channel, channel.current, channel.targetVolume);
}
void channel.current.play().catch(() => undefined);
}
@@ -433,7 +659,7 @@ export class SoundDirector {
const fromKey = channel.currentKey ?? null;
if (previous) {
channel.outgoing.add(previous);
channel.outgoingStartVolumes.set(previous, previous.volume);
channel.outgoingStartVolumes.set(previous, channel.elementBaseVolumes.get(previous) ?? channel.targetVolume);
}
channel.current = undefined;
@@ -444,7 +670,7 @@ export class SoundDirector {
next.loop = true;
next.preload = 'auto';
next.muted = this.muted;
next.volume = 0;
this.setLoopElementBaseVolume(channel, next, 0);
channel.current = next;
channel.currentKey = key;
channel.elementRevision += 1;
@@ -468,11 +694,11 @@ export class SoundDirector {
const progress = Math.min(1, Math.max(0, (performance.now() - startedAt) / channel.fadeDurationMs));
if (channel.current) {
channel.current.volume = channel.targetVolume * progress;
this.setLoopElementBaseVolume(channel, channel.current, channel.targetVolume * progress);
}
channel.outgoing.forEach((element) => {
const startVolume = channel.outgoingStartVolumes.get(element) ?? element.volume;
element.volume = startVolume * (1 - progress);
const startVolume = channel.outgoingStartVolumes.get(element) ?? channel.elementBaseVolumes.get(element) ?? 0;
this.setLoopElementBaseVolume(channel, element, startVolume * (1 - progress));
});
if (progress >= 1) {
@@ -489,7 +715,7 @@ export class SoundDirector {
this.cancelLoopTransitionTimer(channel);
this.retireOutgoingElements(channel);
if (channel.current) {
channel.current.volume = channel.targetVolume;
this.setLoopElementBaseVolume(channel, channel.current, channel.targetVolume);
}
channel.transition.active = false;
channel.transition.completedRevision = transitionRevision;
@@ -503,14 +729,15 @@ export class SoundDirector {
}
private retireOutgoingElements(channel: LoopChannel) {
channel.outgoing.forEach((element) => this.retireLoopElement(element));
channel.outgoing.forEach((element) => this.retireLoopElement(channel, element));
channel.outgoing.clear();
channel.outgoingStartVolumes.clear();
}
private retireLoopElement(element: HTMLAudioElement) {
private retireLoopElement(channel: LoopChannel, element: HTMLAudioElement) {
element.pause();
element.src = '';
channel.elementBaseVolumes.delete(element);
}
private loopElements() {
@@ -532,6 +759,9 @@ export class SoundDirector {
elementRevision: channel.elementRevision,
activeElementCount: (channel.current ? 1 : 0) + channel.outgoing.size,
targetVolume: channel.targetVolume,
categoryMultiplier: this.categoryMultipliers[channel.name],
duckMultiplier: channel.name === 'music' ? this.musicDuck.multiplier : 1,
effectiveTargetVolume: this.loopOutputVolume(channel, channel.targetVolume),
transition: {
revision: channel.transition.revision,
completedRevision: channel.transition.completedRevision,
@@ -540,6 +770,104 @@ export class SoundDirector {
}
};
}
private resolveEffectTrack(key: string) {
const pool = this.effectPools[key];
if (pool?.length) {
const availableTrackKeys = pool.filter((trackKey) => Boolean(this.effectTracks[trackKey]));
if (availableTrackKeys.length) {
const lastSelection = this.lastEffectPoolSelections.get(key);
const candidates =
availableTrackKeys.length > 1
? availableTrackKeys.filter((trackKey) => trackKey !== lastSelection)
: availableTrackKeys;
const randomIndex = Math.floor(Math.random() * candidates.length);
const trackKey = candidates[randomIndex] ?? candidates[0];
this.lastEffectPoolSelections.set(key, trackKey);
return { trackKey, src: this.effectTracks[trackKey], poolKey: key };
}
}
const src = this.effectTracks[key];
return src ? { trackKey: key, src, poolKey: null } : undefined;
}
private refreshActiveEffectVolumes() {
this.activeEffects.forEach((effect) => {
const baseVolume = this.effectBaseVolumes.get(effect);
if (baseVolume !== undefined) {
effect.volume = this.effectOutputVolume(baseVolume);
}
});
}
private effectOutputVolume(baseVolume: number) {
return this.normalizeUnitInterval(baseVolume * this.categoryMultipliers.effects, 0);
}
private retireEffectElement(effect: HTMLAudioElement) {
effect.pause();
effect.src = '';
this.activeEffects.delete(effect);
this.effectBaseVolumes.delete(effect);
}
private setLoopElementBaseVolume(channel: LoopChannel, element: HTMLAudioElement, baseVolume: number) {
const normalizedBaseVolume = this.normalizeUnitInterval(baseVolume, 0);
channel.elementBaseVolumes.set(element, normalizedBaseVolume);
element.volume = this.loopOutputVolume(channel, normalizedBaseVolume);
}
private refreshLoopChannelVolumes(channel: LoopChannel) {
if (channel.current) {
this.refreshLoopElementVolume(channel, channel.current);
}
channel.outgoing.forEach((element) => this.refreshLoopElementVolume(channel, element));
}
private refreshLoopElementVolume(channel: LoopChannel, element: HTMLAudioElement) {
const baseVolume = channel.elementBaseVolumes.get(element);
if (baseVolume !== undefined) {
element.volume = this.loopOutputVolume(channel, baseVolume);
}
}
private loopOutputVolume(channel: LoopChannel, baseVolume: number) {
const duckMultiplier = channel.name === 'music' ? this.musicDuck.multiplier : 1;
return this.normalizeUnitInterval(baseVolume * this.categoryMultipliers[channel.name] * duckMultiplier, 0);
}
private setMusicDuckMultiplier(multiplier: number) {
this.musicDuck.multiplier = this.normalizeUnitInterval(multiplier, 1);
this.refreshLoopChannelVolumes(this.musicChannel);
}
private completeMusicDuck(revision: number) {
if (revision !== this.musicDuck.revision) {
return;
}
this.cancelMusicDuckTimer();
this.setMusicDuckMultiplier(1);
this.musicDuck.active = false;
this.musicDuck.endsAt = 0;
this.musicDuck.completedRevision = revision;
}
private cancelMusicDuckTimer() {
if (this.musicDuck.timer !== undefined) {
window.clearInterval(this.musicDuck.timer);
this.musicDuck.timer = undefined;
}
}
private normalizeUnitInterval(value: number, fallback: number) {
return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : fallback;
}
private normalizeDuration(value: number, fallback: number) {
return Number.isFinite(value) ? Math.min(10_000, Math.max(0, value)) : fallback;
}
}
declare global {

View File

@@ -7,16 +7,25 @@ import militiaThemeUrl from '../../assets/audio/bgm/militia-theme.mp3';
import oathThemeUrl from '../../assets/audio/bgm/oath-theme.mp3';
import storyDarkUrl from '../../assets/audio/bgm/story-dark.mp3';
import titleThemeUrl from '../../assets/audio/bgm/title-theme.mp3';
import armorImpactUrl from '../../assets/audio/sfx/armor-impact.mp3';
import arrowBodyImpactUrl from '../../assets/audio/sfx/arrow-body-impact.mp3';
import arrowSwishUrl from '../../assets/audio/sfx/arrow-swish.mp3';
import arrowWoodImpactUrl from '../../assets/audio/sfx/arrow-wood-impact.mp3';
import bondResonanceUrl from '../../assets/audio/sfx/bond-resonance.wav';
import bowReleaseUrl from '../../assets/audio/sfx/bow-release.mp3';
import cartRollUrl from '../../assets/audio/sfx/cart-roll.mp3';
import combatImpactUrl from '../../assets/audio/sfx/combat-impact.mp3';
import criticalBoomUrl from '../../assets/audio/sfx/critical-boom.mp3';
import criticalImpactUrl from '../../assets/audio/sfx/critical-impact.mp3';
import defeatStingUrl from '../../assets/audio/sfx/defeat-sting.wav';
import expGainUrl from '../../assets/audio/sfx/exp-gain.mp3';
import footstepWalkUrl from '../../assets/audio/sfx/footstep-walk.mp3';
import horseGallopUrl from '../../assets/audio/sfx/horse-gallop.mp3';
import levelUpUrl from '../../assets/audio/sfx/level-up.wav';
import shieldBlockUrl from '../../assets/audio/sfx/shield-block.mp3';
import strategyCastUrl from '../../assets/audio/sfx/strategy-cast.mp3';
import swordSlashUrl from '../../assets/audio/sfx/sword-slash.mp3';
import swordSwingVariantUrl from '../../assets/audio/sfx/sword-swing-variant.mp3';
import uiSelectUrl from '../../assets/audio/sfx/ui-select.mp3';
import victoryFanfareUrl from '../../assets/audio/sfx/victory-fanfare.wav';
import campfireAmbienceUrl from '../../assets/audio/ambience/campfire-ambience.mp3';
@@ -46,7 +55,16 @@ export const ambienceTracks = {
export const effectTracks = {
'ui-select': uiSelectUrl,
'sword-slash': swordSlashUrl,
'sword-swing-variant': swordSwingVariantUrl,
'combat-impact': combatImpactUrl,
'armor-impact': armorImpactUrl,
'shield-block': shieldBlockUrl,
'bow-release': bowReleaseUrl,
'arrow-swish': arrowSwishUrl,
'arrow-body-impact': arrowBodyImpactUrl,
'arrow-wood-impact': arrowWoodImpactUrl,
'critical-impact': criticalImpactUrl,
'critical-boom': criticalBoomUrl,
'strategy-cast': strategyCastUrl,
'cart-roll': cartRollUrl,
'exp-gain': expGainUrl,
@@ -58,6 +76,14 @@ export const effectTracks = {
'horse-gallop': horseGallopUrl
} as const;
export const effectPools = {
'melee-swing': ['sword-slash', 'sword-swing-variant'],
'melee-impact': ['combat-impact', 'armor-impact', 'shield-block'],
'arrow-impact': ['arrow-body-impact', 'arrow-wood-impact'],
'arrow-miss': ['arrow-swish']
} as const;
export type MusicTrackKey = keyof typeof musicTracks;
export type AmbienceTrackKey = keyof typeof ambienceTracks;
export type EffectTrackKey = keyof typeof effectTracks;
export type EffectPoolKey = keyof typeof effectPools;

View File

@@ -96,6 +96,23 @@ export type PortraitAssetEntry = {
url: string;
};
export type StoryPortraitContext = {
id: string;
background: string;
};
const zhugeLiangNorthernBackgrounds = new Set([
'story-tianshui-front',
'story-jieting-crisis',
'story-chencang-siege',
'story-wudu-yinping',
'story-hanzhong-rain',
'story-qishan-renewed',
'story-lucheng-pursuit',
'story-weishui-camps',
'story-weishui-northbank'
]);
export function portraitSlugForKey(key: string) {
return legacyPortraitSlugs[key] ?? camelToKebab(key);
}
@@ -121,28 +138,73 @@ export function portraitCardAssetEntryForKey(key: string): PortraitAssetEntry |
: undefined;
}
export function portraitAssetEntriesForKey(key: string): PortraitAssetEntry[] {
function portraitAssetSlugsForKey(key: string) {
const slug = portraitSlugForKey(key);
const prefix = `${slug}-`;
return Object.keys(portraitAssets)
const latestByVariant = new Map<string, { slug: string; version: number }>();
Object.keys(portraitAssets)
.filter((candidate) => candidate === slug || candidate.startsWith(prefix))
.sort()
.map((candidate) => ({
textureKey: `portrait-${candidate}`,
url: portraitAssets[candidate]
}));
.forEach((candidate) => {
// Only a trailing numeric -vN marks a revision; other suffixes remain distinct portrait variants.
const versionMatch = candidate.match(/^(.*)-v([1-9]\d*)$/);
const variant = versionMatch?.[1] ?? candidate;
const version = versionMatch ? Number.parseInt(versionMatch[2], 10) : 0;
const latest = latestByVariant.get(variant);
if (!latest || version > latest.version) {
latestByVariant.set(variant, { slug: candidate, version });
}
});
return Array.from(latestByVariant.values(), ({ slug: candidate }) => candidate).sort();
}
export function portraitAssetEntriesForKey(key: string): PortraitAssetEntry[] {
return portraitAssetSlugsForKey(key).map((candidate) => ({
textureKey: `portrait-${candidate}`,
url: portraitAssets[candidate]
}));
}
export function portraitAssetEntriesForStoryContext(key: string, context: StoryPortraitContext) {
const entries = portraitAssetEntriesForKey(key);
if (portraitSlugForKey(key) !== 'zhuge-liang' || entries.length <= 1) {
return entries;
}
const variant = zhugeLiangStoryVariant(context);
const textureKeyPrefix = variant === 'base'
? 'portrait-zhuge-liang'
: `portrait-zhuge-liang-${variant}`;
const preferredEntries = entries.filter(({ textureKey }) =>
textureKey === textureKeyPrefix ||
(textureKey.startsWith(textureKeyPrefix) && /^-v[1-9]\d*$/.test(textureKey.slice(textureKeyPrefix.length)))
);
return preferredEntries.length > 0 ? preferredEntries : entries;
}
export function portraitTextureKeysForKey(key: string) {
const slug = portraitSlugForKey(key);
const prefix = `${slug}-`;
const slugs = Object.keys(portraitAssets)
.filter((candidate) => candidate === slug || candidate.startsWith(prefix))
.sort();
return Array.from(new Set(slugs.map((candidate) => `portrait-${candidate}`)));
return portraitAssetSlugsForKey(key).map((candidate) => `portrait-${candidate}`);
}
export function portraitKeyForSpeaker(speaker?: string) {
return speaker ? speakerPortraitKeys[speaker] : undefined;
}
function zhugeLiangStoryVariant({ id, background }: StoryPortraitContext) {
if (background === 'story-red-cliffs' || id === 'eighteenth-red-cliffs-wind') {
return 'redcliffs';
}
if (background === 'story-wolong') {
return 'base';
}
if (
id === 'northern-prep-back-secured' ||
id.startsWith('fifty-fifth-') ||
zhugeLiangNorthernBackgrounds.has(background)
) {
return 'northern';
}
return 'campaign';
}

View File

@@ -0,0 +1,17 @@
const unitActionSheetModules = import.meta.glob('../../assets/images/units/unit-*-actions.webp', {
eager: true,
import: 'default'
}) as Record<string, string>;
function textureKeyFromPath(path: string) {
const fileName = path.split('/').pop() ?? path;
return fileName.replace(/\.(?:png|webp)$/i, '');
}
const unitActionSheetUrls = new Map(
Object.entries(unitActionSheetModules).map(([path, url]) => [textureKeyFromPath(path), url])
);
export function unitActionSheetUrlForKey(actionKey: string) {
return unitActionSheetUrls.get(actionKey);
}

View File

@@ -22,7 +22,6 @@ type UnitSheetAsset = {
frameSize: number;
format: 'png' | 'webp';
actionKey: string;
actionUrl: string;
actionFrameSize: number;
actionFormat: 'png' | 'webp';
};
@@ -41,7 +40,10 @@ export type UnitActionSheetLoadOptions = {
const defaultUnitActionSheetWatchdogMs = 15_000;
const unitWebpSheetModules = import.meta.glob('../../assets/images/units/unit-*.webp', {
const unitWebpSheetModules = import.meta.glob([
'../../assets/images/units/unit-*.webp',
'!../../assets/images/units/unit-*-actions.webp'
], {
eager: true,
import: 'default'
}) as Record<string, string>;
@@ -62,29 +64,16 @@ const unitSheetEntries = Object.entries(unitSheetModules).map(([path, url]) => (
url,
format: unitSheetFormatFromPath(path)
}));
const unitSheetEntriesByKey = new Map(unitSheetEntries.map((entry) => [entry.key, entry]));
const unitSheets = unitSheetEntries
.filter(({ key }) => !key.endsWith('-actions'))
.map(({ key, url }) => {
const actionKey = `${key}-actions`;
const actionEntry = unitSheetEntriesByKey.get(actionKey);
return actionEntry
? {
key,
url,
frameSize: unitActionAssetPolicy.optimizedFrameSize,
format: unitActionAssetPolicy.format,
actionKey,
actionUrl: actionEntry.url,
actionFrameSize: actionEntry.format === unitActionAssetPolicy.format
? unitActionAssetPolicy.optimizedFrameSize
: unitSheetFrameSize,
actionFormat: actionEntry.format
}
: undefined;
})
.filter((sheet): sheet is UnitSheetAsset => Boolean(sheet))
.map(({ key, url, format }) => ({
key,
url,
frameSize: unitActionAssetPolicy.optimizedFrameSize,
format,
actionKey: `${key}-actions`,
actionFrameSize: unitActionAssetPolicy.optimizedFrameSize,
actionFormat: 'webp' as const
}))
.sort((left, right) => left.key.localeCompare(right.key));
const unitSheetAssetsByKey = new Map(unitSheets.map((sheet) => [sheet.key, sheet]));
@@ -118,13 +107,18 @@ export function unitBaseSheetAssetInfo(key: string): UnitBaseSheetAssetInfo | un
: undefined;
}
export function unitActionSheetAssetInfo(key: string): UnitActionSheetAssetInfo | undefined {
export async function unitActionSheetAssetInfo(key: string): Promise<UnitActionSheetAssetInfo | undefined> {
const sheet = unitSheetAssetsByKey.get(key);
return sheet
if (!sheet) {
return undefined;
}
const { unitActionSheetUrlForKey } = await import('./unitActionAssets');
const url = unitActionSheetUrlForKey(sheet.actionKey);
return url
? {
key: sheet.key,
actionKey: sheet.actionKey,
url: sheet.actionUrl,
url,
frameSize: sheet.actionFrameSize,
format: sheet.actionFormat
}
@@ -278,12 +272,12 @@ export function loadUnitActionSheets(
return;
}
const pendingLoads: Array<{ key: string; url: string; frameSize: number }> = [];
const pendingLoads: Array<{ key: string; frameSize: number }> = [];
uniqueKeys.forEach((key) => {
const sheet = unitSheetAssetsByKey.get(key) ?? missingUnitSheet(key);
if (!scene.textures.exists(sheet.actionKey) && !pendingUnitTextureKeys.has(sheet.actionKey)) {
pendingUnitTextureKeys.add(sheet.actionKey);
pendingLoads.push({ key: sheet.actionKey, url: sheet.actionUrl, frameSize: sheet.actionFrameSize });
pendingLoads.push({ key: sheet.actionKey, frameSize: sheet.actionFrameSize });
}
});
@@ -328,7 +322,11 @@ export function loadUnitActionSheets(
const actionKey = `${key}-actions`;
return scene.textures.exists(actionKey) || failedActionKeys.has(actionKey);
});
const handleComplete = () => settle();
const handleComplete = () => {
if (allRequestsSettled()) {
settle();
}
};
const handleLoadError = (file: Phaser.Loader.File) => {
const key = String(file.key);
if (!actionKeys.has(key)) {
@@ -342,8 +340,6 @@ export function loadUnitActionSheets(
const handleShutdown = () => settle(false);
scene.events.once('shutdown', handleShutdown);
scene.load.on('complete', handleComplete);
scene.load.on('loaderror', handleLoadError);
pollTimer = setInterval(() => {
if (unitActionSheetsReady(scene, uniqueKeys)) {
settle();
@@ -355,14 +351,39 @@ export function loadUnitActionSheets(
return;
}
pendingLoads.forEach(({ key, url, frameSize }) => {
scene.load.spritesheet(key, url, {
frameWidth: frameSize,
frameHeight: frameSize
void import('./unitActionAssets').then(({ unitActionSheetUrlForKey }) => {
if (settled) {
return;
}
const resolvedLoads = pendingLoads.flatMap(({ key, frameSize }) => {
const url = unitActionSheetUrlForKey(key);
if (!url) {
failedActionKeys.add(key);
return [];
}
return [{ key, url, frameSize }];
});
if (resolvedLoads.length <= 0) {
settle();
return;
}
scene.load.on('complete', handleComplete);
scene.load.on('loaderror', handleLoadError);
resolvedLoads.forEach(({ key, url, frameSize }) => {
scene.load.spritesheet(key, url, {
frameWidth: frameSize,
frameHeight: frameSize
});
});
ownedLoaderFiles = captureLoaderFiles(scene, resolvedLoads.map(({ key }) => key));
scene.load.start();
}).catch(() => {
pendingLoads.forEach(({ key }) => failedActionKeys.add(key));
settle();
});
ownedLoaderFiles = captureLoaderFiles(scene, pendingLoads.map(({ key }) => key));
scene.load.start();
}
function normalizeUnitSheetWatchdogMs(value: number | undefined) {

View File

@@ -1,5 +1,6 @@
import Phaser from 'phaser';
import { soundDirector } from '../audio/SoundDirector';
import { loadAudioPreferences, saveAudioPreferences } from '../settings/audioPreferences';
import { battleMapAssets } from '../data/battleMapAssets';
import { firstBattleVictoryPages, type BattleBond, type UnitData, type UnitStats } from '../data/scenario';
import {
@@ -3434,7 +3435,7 @@ const mapMenuLabels: Partial<Record<MapMenuAction, string>> = {
situation: '전황',
speed: '속도',
presentation: '전투 연출',
bgm: 'BGM',
bgm: '음향',
close: '닫기'
};
@@ -12703,10 +12704,7 @@ export class BattleScene extends Phaser.Scene {
this.renderSituationPanel(message);
this.publishFirstBattleReport(outcome);
const revealResult = () => {
soundDirector.playEffect(outcome === 'victory' ? 'victory-fanfare' : 'defeat-sting', {
volume: outcome === 'victory' ? 0.34 : 0.26,
stopAfterMs: 1200
});
soundDirector.playBattleOutcome(outcome);
this.showBattleResult(outcome);
};
if (resultDelayMs > 0) {
@@ -16528,7 +16526,12 @@ export class BattleScene extends Phaser.Scene {
) {
return;
}
if (this.phase === 'idle' && this.activeFaction === 'ally' && this.turnNumber === 1) {
if (
this.phase === 'idle' &&
this.activeFaction === 'ally' &&
this.turnNumber === 1 &&
this.battleEventObjects.length === 0
) {
this.startFirstBattleTutorial();
return;
}
@@ -16536,7 +16539,7 @@ export class BattleScene extends Phaser.Scene {
this.time.delayedCall(180, () => attemptStart(attemptsRemaining - 1));
}
};
attemptStart(12);
attemptStart(30);
}
private startFirstBattleTutorial() {
@@ -17788,8 +17791,10 @@ export class BattleScene extends Phaser.Scene {
return;
}
if (action === 'bgm') {
soundDirector.setMuted(!soundDirector.isMuted());
this.renderSituationPanel(`BGM을 ${soundDirector.isMuted() ? '껐습니다' : '켰습니다'}.`);
const muted = !soundDirector.isMuted();
soundDirector.setMuted(muted);
saveAudioPreferences({ ...loadAudioPreferences(), muted });
this.renderSituationPanel(`음향을 ${soundDirector.isMuted() ? '껐습니다' : '켰습니다'}.`);
}
}
@@ -19458,16 +19463,21 @@ export class BattleScene extends Phaser.Scene {
});
}
const rangedAttack = this.isRangedAttack(result);
if (result.action === 'strategy') {
soundDirector.playStrategyPulse();
} else if (result.action === 'item') {
soundDirector.playItemLaunch();
} else if (result.attacker.classKey === 'archer' || this.attackRange(result.attacker) > 1) {
} else if (rangedAttack) {
soundDirector.playArrowShot();
soundDirector.playArrowImpact(result.hit, result.critical);
} else {
soundDirector.playMeleeSwing(result.hit, result.critical);
}
if (rangedAttack) {
soundDirector.playArrowImpact(result.hit, result.critical);
} else if (result.hit) {
soundDirector.playCombatImpact(result.critical, result.action === 'strategy');
}
const ring = this.add.circle(targetX, targetY - this.layout.tileSize * 0.2, this.layout.tileSize * 0.28);
ring.setStrokeStyle(result.critical || result.defeated ? 6 : 4, accent, 0.94);
@@ -19714,14 +19724,16 @@ export class BattleScene extends Phaser.Scene {
groundY,
depth + 10,
defenderDirection,
stage.shadows[0]
stage.shadows[0],
() => {
if (result.hit) {
this.showCombatImpact(result, defenderX, groundY - 46, depth + 14);
void this.showSortieDefenseCombatEffect(result, defenderX, groundY - 70, depth + 17);
} else {
this.showCombatMiss(defenderX, groundY - 70, depth + 14);
}
}
);
if (result.hit) {
this.showCombatImpact(result, defenderX, groundY - 46, depth + 14);
void this.showSortieDefenseCombatEffect(result, defenderX, groundY - 70, depth + 17);
} else {
this.showCombatMiss(defenderX, groundY - 70, depth + 14);
}
const outcomeCard = this.renderCombatOutcomeCard(result, left + panelWidth / 2 - 226, top + 184, 452, depth + 16);
const hpAfterBaseAttack = result.bondChain?.previousDefenderHp ?? result.defender.hp;
@@ -21138,7 +21150,8 @@ export class BattleScene extends Phaser.Scene {
groundY: number,
depth: number,
defenderDirection: UnitDirection,
attackerShadow?: Phaser.GameObjects.Ellipse
attackerShadow?: Phaser.GameObjects.Ellipse,
onContact?: () => void
) {
const direction = attackerX <= defenderX ? 1 : -1;
if (result.action === 'attack') {
@@ -21153,7 +21166,8 @@ export class BattleScene extends Phaser.Scene {
depth,
defenderDirection,
direction,
attackerShadow
attackerShadow,
onContact
);
return;
}
@@ -21168,12 +21182,24 @@ export class BattleScene extends Phaser.Scene {
depth,
defenderDirection,
direction,
attackerShadow
attackerShadow,
onContact
);
return;
}
await this.playSpecialActionMotion(result, attackerSprite, defenderSprite, attackerX, defenderX, groundY, depth, defenderDirection, direction);
await this.playSpecialActionMotion(
result,
attackerSprite,
defenderSprite,
attackerX,
defenderX,
groundY,
depth,
defenderDirection,
direction,
onContact
);
}
private isRangedAttack(result: Pick<CombatResult, 'action' | 'attacker'>) {
@@ -21190,7 +21216,8 @@ export class BattleScene extends Phaser.Scene {
depth: number,
defenderDirection: UnitDirection,
direction: number,
attackerShadow?: Phaser.GameObjects.Ellipse
attackerShadow?: Phaser.GameObjects.Ellipse,
onContact?: () => void
) {
const mounted = result.attacker.classKey === 'cavalry';
const windupX = attackerX - direction * 28;
@@ -21244,6 +21271,7 @@ export class BattleScene extends Phaser.Scene {
await this.delay(mounted ? 170 : 205);
soundDirector.playMeleeSwing(result.hit, result.critical);
onContact?.();
this.setUnitActionFrame(attackerSprite, result.attacker, 'east', 'attack', this.unitActionImpactFrame('attack'));
this.createSlashArc(defenderX - direction * 68, groundY - 48, direction, depth + 2, result.critical);
@@ -21298,7 +21326,8 @@ export class BattleScene extends Phaser.Scene {
depth: number,
defenderDirection: UnitDirection,
direction: number,
attackerShadow?: Phaser.GameObjects.Ellipse
attackerShadow?: Phaser.GameObjects.Ellipse,
onContact?: () => void
) {
void this.playUnitActionFrames(attackerSprite, result.attacker, 'east', 'attack', 88);
this.createBowDrawEffect(attackerX + direction * 44, groundY - 52, direction, depth + 2);
@@ -21334,6 +21363,7 @@ export class BattleScene extends Phaser.Scene {
ease: result.hit ? 'Cubic.easeIn' : 'Sine.easeOut'
});
await this.delay(result.hit ? 332 : 382);
onContact?.();
if (result.hit) {
void this.playUnitActionFrames(defenderSprite, result.defender, defenderDirection, 'hurt', 70);
@@ -21367,7 +21397,8 @@ export class BattleScene extends Phaser.Scene {
groundY: number,
depth: number,
defenderDirection: UnitDirection,
direction: number
direction: number,
onContact?: () => void
) {
const motionKind = this.specialDamageMotionKind(result);
if (result.action === 'item') {
@@ -21402,6 +21433,7 @@ export class BattleScene extends Phaser.Scene {
ease: 'Cubic.easeIn'
});
await this.delay(450);
onContact?.();
if (result.hit) {
void this.playUnitActionFrames(defenderSprite, result.defender, defenderDirection, 'hurt', 70);
this.createSpecialImpact(defenderX - direction * 68, this.specialImpactY(motionKind, groundY), depth + 4, motionKind);
@@ -22039,9 +22071,10 @@ export class BattleScene extends Phaser.Scene {
bondChain: undefined,
counter: undefined
};
const contactDelay = motion === 'ranged'
? this.scaledBattleDuration(150) + this.scaledBattleDuration(332, 90)
: this.scaledBattleDuration(115) + this.scaledBattleDuration(partner.classKey === 'cavalry' ? 170 : 205);
let resolveContact: (() => void) | undefined;
const contactPromise = new Promise<void>((resolve) => {
resolveContact = resolve;
});
const motionPromise = this.playCombatActionMotion(
followUpResult,
supporterSprite,
@@ -22051,12 +22084,14 @@ export class BattleScene extends Phaser.Scene {
groundY,
depth + 1,
'west',
supporterShadow
supporterShadow,
() => {
onImpact?.();
this.showCombatImpact(followUpResult, defenderX, groundY - 46, depth + 5);
resolveContact?.();
}
);
await new Promise<void>((resolve) => this.time.delayedCall(contactDelay, resolve));
onImpact?.();
this.showCombatImpact(followUpResult, defenderX, groundY - 46, depth + 5);
await contactPromise;
const chainTitle = chain.coreResonance ? '핵심 공명' : '공명';
const finishTitle = chain.defeated ? `${chainTitle} 격파 · ${chain.partnerName}` : `${chainTitle} 추격 · ${chain.partnerName}`;
@@ -22245,7 +22280,7 @@ export class BattleScene extends Phaser.Scene {
if (this.isRangedAttack(result)) {
soundDirector.playArrowImpact(true, result.critical);
} else {
soundDirector.playEffect('combat-impact', { volume: result.critical ? 0.62 : result.action === 'strategy' ? 0.3 : 0.48, stopAfterMs: 650 });
soundDirector.playCombatImpact(result.critical, result.action === 'strategy');
}
const impact = this.trackCombatObject(this.add.star(x, y, 8, 12, result.critical ? 54 : 42, this.combatImpactColor(result), 0.92));

View File

@@ -8,7 +8,12 @@ import {
loadBattleUiIcons,
type BattleUiIconKey
} from '../data/battleUiIcons';
import { portraitAssetEntriesForKey, portraitKeyForSpeaker, type PortraitAssetEntry } from '../data/portraitAssets';
import {
portraitAssetEntriesForKey,
portraitAssetEntriesForStoryContext,
portraitKeyForSpeaker,
type PortraitAssetEntry
} from '../data/portraitAssets';
import {
storyBackgroundAssets,
storyBackgroundKeyForPage,
@@ -60,6 +65,13 @@ const dialogDepth = 20;
const fhdUiScale = 1.5;
const ui = (value: number) => value * fhdUiScale;
const uiPx = (value: number) => `${Math.round(ui(value))}px`;
const storyDialogLayout = {
sideInset: 72,
topOffset: 270,
height: 204,
nameTopOffset: 261,
bodyTopOffset: 225
} as const;
const storyUnitFrameRows: Record<UnitDirection, number> = {
south: 0,
east: 1,
@@ -183,8 +195,16 @@ export class StoryScene extends Phaser.Scene {
getDebugState() {
const page = this.pages[this.pageIndex];
const bodyBounds = this.bodyText?.active ? this.bodyText.getBounds() : undefined;
const progressBounds = this.progressText?.active ? this.progressText.getBounds() : undefined;
const progressPanelBounds = this.progressBackground?.active ? this.progressBackground.getBounds() : undefined;
const cutsceneStageBounds = page?.cutscene ? this.cutsceneStageBounds() : undefined;
const dialogPanelBounds = {
x: ui(storyDialogLayout.sideInset),
y: this.scale.height - ui(storyDialogLayout.topOffset),
width: this.scale.width - ui(storyDialogLayout.sideInset * 2),
height: ui(storyDialogLayout.height)
};
const isLastPage = this.pageIndex >= this.pages.length - 1;
return {
@@ -199,6 +219,11 @@ export class StoryScene extends Phaser.Scene {
activeTexture: this.background?.texture.key ?? null,
portraitKey: page ? this.pagePortraitKey(page) ?? null : null,
portraitTextureKey: this.portrait?.visible ? this.portrait.texture.key : null,
bodyBounds: bodyBounds
? { x: bodyBounds.x, y: bodyBounds.y, width: bodyBounds.width, height: bodyBounds.height }
: null,
dialogPanelBounds,
cutsceneStageBounds: cutsceneStageBounds ?? null,
progressLabel: this.progressText?.text ?? '',
progressBounds: progressBounds
? { x: progressBounds.x, y: progressBounds.y, width: progressBounds.width, height: progressBounds.height }
@@ -556,10 +581,10 @@ export class StoryScene extends Phaser.Scene {
}
private drawDialogPanel(width: number, height: number) {
const panelY = height - ui(238);
const panelX = ui(72);
const panelW = width - ui(144);
const panelH = ui(172);
const panelY = height - ui(storyDialogLayout.topOffset);
const panelX = ui(storyDialogLayout.sideInset);
const panelW = width - ui(storyDialogLayout.sideInset * 2);
const panelH = ui(storyDialogLayout.height);
const panel = this.add.graphics();
panel.setDepth(dialogDepth);
@@ -595,11 +620,11 @@ export class StoryScene extends Phaser.Scene {
});
this.chapterText.setDepth(dialogDepth + 2);
this.portraitFrame = this.add.rectangle(panelX + ui(86), panelY + ui(86), ui(136), ui(136), cutsceneUiColors.ink, 0.92);
this.portraitFrame = this.add.rectangle(panelX + ui(86), panelY + panelH / 2, ui(136), ui(136), cutsceneUiColors.ink, 0.92);
this.portraitFrame.setStrokeStyle(ui(2), palette.gold, 0.34);
this.portraitFrame.setDepth(dialogDepth + 2);
this.portrait = this.add.image(panelX + ui(86), panelY + ui(86), this.pagePortraitTextureKey(this.pages[0]) ?? 'story-fallback');
this.portrait = this.add.image(panelX + ui(86), panelY + panelH / 2, this.pagePortraitTextureKey(this.pages[0]) ?? 'story-fallback');
this.portrait.setDisplaySize(ui(128), ui(128));
this.portrait.setDepth(dialogDepth + 3);
@@ -615,7 +640,7 @@ export class StoryScene extends Phaser.Scene {
});
this.nameText.setDepth(dialogDepth + 2);
this.bodyText = this.add.text(panelX + ui(198), panelY + ui(78), '', {
this.bodyText = this.add.text(panelX + ui(198), panelY + ui(45), '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", serif',
fontSize: uiPx(25),
color: '#e8dfca',
@@ -704,15 +729,15 @@ export class StoryScene extends Phaser.Scene {
this.portraitDivider?.setVisible(true);
this.portrait?.setTexture(textureKey);
this.portrait?.setDisplaySize(ui(128), ui(128));
this.nameText?.setPosition(ui(270), this.scale.height - ui(210));
this.bodyText?.setPosition(ui(270), this.scale.height - ui(160));
this.nameText?.setPosition(ui(270), this.scale.height - ui(storyDialogLayout.nameTopOffset));
this.bodyText?.setPosition(ui(270), this.scale.height - ui(storyDialogLayout.bodyTopOffset));
this.bodyText?.setWordWrapWidth(this.scale.width - ui(374), true);
} else {
this.portraitFrame?.setVisible(false);
this.portrait?.setVisible(false);
this.portraitDivider?.setVisible(false);
this.nameText?.setPosition(ui(96), this.scale.height - ui(210));
this.bodyText?.setPosition(ui(96), this.scale.height - ui(160));
this.nameText?.setPosition(ui(96), this.scale.height - ui(storyDialogLayout.nameTopOffset));
this.bodyText?.setPosition(ui(96), this.scale.height - ui(storyDialogLayout.bodyTopOffset));
this.bodyText?.setWordWrapWidth(this.scale.width - ui(200), true);
}
@@ -752,7 +777,7 @@ export class StoryScene extends Phaser.Scene {
return undefined;
}
const entries = portraitAssetEntriesForKey(portraitKey);
const entries = portraitAssetEntriesForStoryContext(portraitKey, page);
if (entries.length === 0) {
return undefined;
}

View File

@@ -6,6 +6,13 @@ import {
nextCombatPresentationMode,
saveCombatPresentationMode
} from '../settings/combatPresentation';
import {
audioLevelLabel,
loadAudioPreferences,
nextAudioLevel,
saveAudioPreferences,
updateAudioPreference
} from '../settings/audioPreferences';
import { battleIdForCampaignStep, isCampCampaignStep } from '../state/campaignRouting';
import { readCampaignBattleResume, type CampaignBattleResume } from '../state/battleSaveStorage';
import {
@@ -49,6 +56,14 @@ export class TitleScene extends Phaser.Scene {
return;
}
const debugStoryPageId = this.debugStoryPageId();
if (debugStoryPageId) {
this.time.delayedCall(0, () => {
void this.openDebugStoryPage(debugStoryPageId);
});
return;
}
if (this.shouldOpenDebugSortiePrep()) {
this.time.delayedCall(0, () => {
void this.navigateTo('CampScene', {
@@ -98,6 +113,48 @@ export class TitleScene extends Phaser.Scene {
return params.get('debugBattle')?.trim() || undefined;
}
private debugStoryPageId() {
if (typeof window === 'undefined') {
return undefined;
}
const params = new URLSearchParams(window.location.search);
if (!params.has('debugStory') || (!params.has('debug') && !import.meta.env.DEV)) {
return undefined;
}
return params.get('debugStory')?.trim() || undefined;
}
private async openDebugStoryPage(pageId: string) {
const scenarioModule = await import('../data/scenario');
let selectedPage: unknown;
for (const value of Object.values(scenarioModule)) {
if (!Array.isArray(value)) {
continue;
}
selectedPage = value.find(
(candidate) =>
candidate &&
typeof candidate === 'object' &&
'id' in candidate &&
candidate.id === pageId
);
if (selectedPage) {
break;
}
}
if (!selectedPage) {
console.error(`Unknown debug story page: ${pageId}`);
return;
}
await this.navigateTo('StoryScene', {
pages: [selectedPage],
nextScene: 'TitleScene'
});
}
private drawBackground(width: number, height: number) {
const background = this.add.image(width / 2, height / 2, 'title-taoyuan');
const texture = this.textures.get('title-taoyuan').getSourceImage();
@@ -396,27 +453,40 @@ export class TitleScene extends Phaser.Scene {
this.closeNewGameConfirmPanel();
this.closeSaveSlotPanel();
const panel = this.add.container(x, y + ui(238));
const backdrop = this.add.rectangle(0, 0, ui(286), ui(250), 0x0e151d, 0.9);
const panel = this.add.container(x, y + ui(126));
const backdrop = this.add.rectangle(0, 0, ui(320), ui(420), 0x0b1118, 1);
backdrop.setStrokeStyle(ui(1), palette.gold, 0.52);
backdrop.setInteractive();
const title = this.add.text(0, -ui(96), '설정', {
const title = this.add.text(0, -ui(150), '설정', {
fontSize: uiPx(24),
color: '#f1e3c2',
fontStyle: '700',
fixedWidth: ui(210),
fixedWidth: ui(244),
align: 'center'
});
title.setOrigin(0.5);
const soundText = this.createSettingsButton(0, -ui(38), this.soundLabel());
const soundText = this.createSettingsButton(0, -ui(102), this.soundLabel());
soundText.on('pointerdown', () => {
soundDirector.setMuted(!soundDirector.isMuted());
const preferences = loadAudioPreferences();
const muted = !soundDirector.isMuted();
soundDirector.setMuted(muted);
saveAudioPreferences({ ...preferences, muted });
soundText.setText(this.soundLabel());
soundDirector.playSelect();
});
const presentationText = this.createSettingsButton(0, ui(16), this.combatPresentationLabel());
const musicText = this.createSettingsButton(0, -ui(60), this.audioCategoryLabel('music'));
musicText.on('pointerdown', () => this.cycleAudioLevel('music', musicText));
const effectsText = this.createSettingsButton(0, -ui(18), this.audioCategoryLabel('effects'));
effectsText.on('pointerdown', () => this.cycleAudioLevel('effects', effectsText));
const ambienceText = this.createSettingsButton(0, ui(24), this.audioCategoryLabel('ambience'));
ambienceText.on('pointerdown', () => this.cycleAudioLevel('ambience', ambienceText));
const presentationText = this.createSettingsButton(0, ui(66), this.combatPresentationLabel());
presentationText.on('pointerdown', () => {
const nextMode = nextCombatPresentationMode(loadCombatPresentationMode());
saveCombatPresentationMode(nextMode);
@@ -424,26 +494,26 @@ export class TitleScene extends Phaser.Scene {
soundDirector.playSelect();
});
const close = this.createSettingsButton(0, ui(68), '닫기');
const close = this.createSettingsButton(0, ui(110), '닫기');
close.on('pointerdown', () => {
soundDirector.playSelect();
this.closeSettingsPanel();
});
const hint = this.add.text(0, ui(104), '연출 항목을 눌러 단계 변경 · Esc 닫기', {
const hint = this.add.text(0, ui(150), '항목을 눌러 단계 변경 · Esc 닫기', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(11),
color: '#9fb0bf',
fontStyle: '700',
fixedWidth: ui(210),
fixedWidth: ui(244),
align: 'center'
});
hint.setOrigin(0.5);
panel.add([backdrop, title, soundText, presentationText, close, hint]);
panel.add([backdrop, title, soundText, musicText, effectsText, ambienceText, presentationText, close, hint]);
panel.setAlpha(0);
this.settingsPanel = panel;
this.tweens.add({ targets: panel, alpha: 1, y: y + ui(228), duration: 160, ease: 'Sine.easeOut' });
this.tweens.add({ targets: panel, alpha: 1, y: y + ui(116), duration: 160, ease: 'Sine.easeOut' });
}
private createSettingsButton(x: number, y: number, label: string) {
@@ -466,6 +536,25 @@ export class TitleScene extends Phaser.Scene {
return `음향: ${soundDirector.isMuted() ? '꺼짐' : '켜짐'}`;
}
private audioCategoryLabel(category: 'music' | 'effects' | 'ambience') {
const labels = {
music: '음악',
effects: '효과음',
ambience: '환경음'
} as const;
return `${labels[category]}: ${audioLevelLabel(loadAudioPreferences()[category])}`;
}
private cycleAudioLevel(category: 'music' | 'effects' | 'ambience', text: Phaser.GameObjects.Text) {
const preferences = loadAudioPreferences();
const nextLevel = nextAudioLevel(preferences[category]);
const nextPreferences = updateAudioPreference(preferences, category, nextLevel);
saveAudioPreferences(nextPreferences);
soundDirector.setCategoryMultiplier(category, nextLevel);
text.setText(this.audioCategoryLabel(category));
soundDirector.playSelect();
}
private combatPresentationLabel() {
return `전투 연출: ${combatPresentationModeLabel(loadCombatPresentationMode())}`;
}

View File

@@ -0,0 +1,89 @@
import type { AudioCategory, AudioCategoryMultipliers } from '../audio/SoundDirector';
export const audioPreferencesStorageKey = 'heros-web:audio-preferences';
export const audioLevelSteps = [1, 0.75, 0.5, 0.25] as const;
export type AudioPreferences = AudioCategoryMultipliers & {
muted: boolean;
};
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>;
export const defaultAudioPreferences: AudioPreferences = {
muted: false,
music: 1,
effects: 1,
ambience: 1
};
export function loadAudioPreferences(storage = resolveStorage()): AudioPreferences {
if (!storage) {
return { ...defaultAudioPreferences };
}
try {
const stored = JSON.parse(storage.getItem(audioPreferencesStorageKey) ?? 'null') as Partial<AudioPreferences> | null;
if (!stored || typeof stored !== 'object') {
return { ...defaultAudioPreferences };
}
return {
muted: typeof stored.muted === 'boolean' ? stored.muted : defaultAudioPreferences.muted,
music: normalizeAudioLevel(stored.music, defaultAudioPreferences.music),
effects: normalizeAudioLevel(stored.effects, defaultAudioPreferences.effects),
ambience: normalizeAudioLevel(stored.ambience, defaultAudioPreferences.ambience)
};
} catch {
return { ...defaultAudioPreferences };
}
}
export function saveAudioPreferences(preferences: AudioPreferences, storage = resolveStorage()) {
if (!storage) {
return;
}
try {
storage.setItem(audioPreferencesStorageKey, JSON.stringify(preferences));
} catch {
// Local storage can be unavailable in private or restricted browser contexts.
}
}
export function nextAudioLevel(level: number) {
const index = audioLevelSteps.indexOf(level as (typeof audioLevelSteps)[number]);
return audioLevelSteps[(index + 1) % audioLevelSteps.length];
}
export function updateAudioPreference(
preferences: AudioPreferences,
category: AudioCategory,
level: number
): AudioPreferences {
return {
...preferences,
[category]: normalizeAudioLevel(level, preferences[category])
};
}
export function audioLevelLabel(level: number) {
return `${Math.round(normalizeAudioLevel(level, 1) * 100)}%`;
}
function normalizeAudioLevel(value: unknown, fallback: number) {
return typeof value === 'number' && audioLevelSteps.includes(value as (typeof audioLevelSteps)[number])
? value
: fallback;
}
function resolveStorage(): StorageLike | undefined {
if (typeof window === 'undefined') {
return undefined;
}
try {
return window.localStorage;
} catch {
return undefined;
}
}