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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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]])
),

View File

@@ -7,6 +7,7 @@ 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 allyTurnUrl from '../../assets/audio/sfx/ally-turn.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';
@@ -18,16 +19,22 @@ 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 enemyTurnUrl from '../../assets/audio/sfx/enemy-turn.mp3';
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 objectiveFailureUrl from '../../assets/audio/sfx/objective-failure.mp3';
import objectiveSuccessUrl from '../../assets/audio/sfx/objective-success.mp3';
import operationAlertUrl from '../../assets/audio/sfx/operation-alert.mp3';
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 battleFireAmbienceUrl from '../../assets/audio/ambience/battle-fire.mp3';
import battleRainAmbienceUrl from '../../assets/audio/ambience/battle-rain.mp3';
import campfireAmbienceUrl from '../../assets/audio/ambience/campfire-ambience.mp3';
import mountainWindAmbienceUrl from '../../assets/audio/ambience/mountain-wind-ambience.mp3';
import nanzhongNightAmbienceUrl from '../../assets/audio/ambience/nanzhong-night-ambience.mp3';
@@ -46,6 +53,8 @@ export const musicTracks = {
} as const;
export const ambienceTracks = {
'battle-rain': battleRainAmbienceUrl,
'battle-fire': battleFireAmbienceUrl,
'campfire-ambience': campfireAmbienceUrl,
'river-night-ambience': riverNightAmbienceUrl,
'mountain-wind-ambience': mountainWindAmbienceUrl,
@@ -54,6 +63,11 @@ export const ambienceTracks = {
export const effectTracks = {
'ui-select': uiSelectUrl,
'ally-turn': allyTurnUrl,
'enemy-turn': enemyTurnUrl,
'operation-alert': operationAlertUrl,
'objective-success': objectiveSuccessUrl,
'objective-failure': objectiveFailureUrl,
'sword-slash': swordSlashUrl,
'sword-swing-variant': swordSwingVariantUrl,
'combat-impact': combatImpactUrl,

View File

@@ -0,0 +1,324 @@
import type { AmbienceTrackKey } from '../audio/audioAssets';
import type { BattleScenarioId } from './battles';
export type BattleEnvironmentEffect =
| 'night-mist'
| 'embers-smoke'
| 'rain-mist'
| 'steady-rain'
| 'snow-wind';
export type BattleEnvironmentParticleKind = 'ember' | 'rain' | 'snow';
export type BattleEnvironmentHazeKind = 'mist' | 'smoke';
export type BattleEnvironmentNumberRange = readonly [number, number];
export type BattleEnvironmentParticleProfile = {
kind: BattleEnvironmentParticleKind;
count: number;
colors: readonly number[];
alpha: BattleEnvironmentNumberRange;
size: BattleEnvironmentNumberRange;
velocityX: BattleEnvironmentNumberRange;
velocityY: BattleEnvironmentNumberRange;
};
export type BattleEnvironmentHazeProfile = {
kind: BattleEnvironmentHazeKind;
count: number;
color: number;
alpha: BattleEnvironmentNumberRange;
widthRatio: BattleEnvironmentNumberRange;
heightRatio: BattleEnvironmentNumberRange;
velocityX: BattleEnvironmentNumberRange;
waveAmplitude: BattleEnvironmentNumberRange;
};
export type BattleEnvironmentProfile = {
id: string;
battleId: BattleScenarioId;
label: string;
effect: BattleEnvironmentEffect;
soundscape: {
ambienceKey: AmbienceTrackKey;
ambienceVolume: number;
};
tint: {
color: number;
alpha: number;
};
particles?: BattleEnvironmentParticleProfile;
haze?: BattleEnvironmentHazeProfile;
};
export const battleEnvironmentProfileBattleIds = [
'ninth-battle-xuzhou-gate-night-raid',
'twenty-second-battle-red-cliffs-fire',
'fortieth-battle-han-river-flood',
'forty-sixth-battle-yiling-fire',
'sixty-first-battle-hanzhong-rain-defense',
'sixty-sixth-battle-wuzhang-final'
] as const satisfies readonly BattleScenarioId[];
const profiles = {
'ninth-battle-xuzhou-gate-night-raid': {
id: 'xuzhou-night-mist',
battleId: 'ninth-battle-xuzhou-gate-night-raid',
label: '서주 야습의 밤안개',
effect: 'night-mist',
soundscape: { ambienceKey: 'river-night-ambience', ambienceVolume: 0.1 },
tint: { color: 0x07111f, alpha: 0.14 },
haze: {
kind: 'mist',
count: 4,
color: 0xa8bdc9,
alpha: [0.025, 0.055],
widthRatio: [0.28, 0.44],
heightRatio: [0.08, 0.15],
velocityX: [5, 11],
waveAmplitude: [5, 11]
}
},
'twenty-second-battle-red-cliffs-fire': {
id: 'red-cliffs-firestorm',
battleId: 'twenty-second-battle-red-cliffs-fire',
label: '적벽의 불티와 연기',
effect: 'embers-smoke',
soundscape: { ambienceKey: 'battle-fire', ambienceVolume: 0.13 },
tint: { color: 0x4a170d, alpha: 0.075 },
particles: {
kind: 'ember',
count: 22,
colors: [0xff8a2a, 0xffb13b, 0xffd36a],
alpha: [0.28, 0.62],
size: [3, 7],
velocityX: [-10, 24],
velocityY: [-52, -26]
},
haze: {
kind: 'smoke',
count: 3,
color: 0x4b403c,
alpha: [0.025, 0.06],
widthRatio: [0.22, 0.36],
heightRatio: [0.1, 0.19],
velocityX: [3, 9],
waveAmplitude: [6, 13]
}
},
'fortieth-battle-han-river-flood': {
id: 'han-river-flood-rain',
battleId: 'fortieth-battle-han-river-flood',
label: '한수의 물안개와 비',
effect: 'rain-mist',
soundscape: { ambienceKey: 'battle-rain', ambienceVolume: 0.12 },
tint: { color: 0x123148, alpha: 0.07 },
particles: {
kind: 'rain',
count: 30,
colors: [0xa9cadb, 0xc6dde8],
alpha: [0.18, 0.38],
size: [14, 22],
velocityX: [-92, -54],
velocityY: [300, 410]
},
haze: {
kind: 'mist',
count: 3,
color: 0xa9c1cb,
alpha: [0.02, 0.05],
widthRatio: [0.25, 0.42],
heightRatio: [0.08, 0.14],
velocityX: [4, 10],
waveAmplitude: [5, 10]
}
},
'forty-sixth-battle-yiling-fire': {
id: 'yiling-burning-forest',
battleId: 'forty-sixth-battle-yiling-fire',
label: '이릉 숲의 불티와 연기',
effect: 'embers-smoke',
soundscape: { ambienceKey: 'battle-fire', ambienceVolume: 0.13 },
tint: { color: 0x52180b, alpha: 0.085 },
particles: {
kind: 'ember',
count: 24,
colors: [0xff7924, 0xffa332, 0xffcf64],
alpha: [0.3, 0.66],
size: [3, 7],
velocityX: [-6, 28],
velocityY: [-56, -28]
},
haze: {
kind: 'smoke',
count: 4,
color: 0x493a34,
alpha: [0.03, 0.065],
widthRatio: [0.2, 0.34],
heightRatio: [0.1, 0.2],
velocityX: [2, 8],
waveAmplitude: [7, 15]
}
},
'sixty-first-battle-hanzhong-rain-defense': {
id: 'hanzhong-defense-rain',
battleId: 'sixty-first-battle-hanzhong-rain-defense',
label: '한중 방어선의 장대비',
effect: 'steady-rain',
soundscape: { ambienceKey: 'battle-rain', ambienceVolume: 0.12 },
tint: { color: 0x102b3d, alpha: 0.085 },
particles: {
kind: 'rain',
count: 36,
colors: [0x9ec4d7, 0xc5dce8],
alpha: [0.2, 0.42],
size: [15, 24],
velocityX: [-108, -66],
velocityY: [330, 450]
},
haze: {
kind: 'mist',
count: 2,
color: 0x9bb3bc,
alpha: [0.018, 0.04],
widthRatio: [0.28, 0.45],
heightRatio: [0.08, 0.14],
velocityX: [5, 12],
waveAmplitude: [5, 9]
}
},
'sixty-sixth-battle-wuzhang-final': {
id: 'wuzhang-snow-wind',
battleId: 'sixty-sixth-battle-wuzhang-final',
label: '오장원의 눈바람',
effect: 'snow-wind',
soundscape: { ambienceKey: 'mountain-wind-ambience', ambienceVolume: 0.09 },
tint: { color: 0x25405a, alpha: 0.08 },
particles: {
kind: 'snow',
count: 32,
colors: [0xdbe9ef, 0xf2f6f7, 0xbfd3dc],
alpha: [0.26, 0.58],
size: [3, 8],
velocityX: [34, 82],
velocityY: [34, 72]
},
haze: {
kind: 'mist',
count: 2,
color: 0xc0d2d9,
alpha: [0.018, 0.038],
widthRatio: [0.25, 0.42],
heightRatio: [0.08, 0.14],
velocityX: [9, 17],
waveAmplitude: [7, 14]
}
}
} as const satisfies Partial<Record<BattleScenarioId, BattleEnvironmentProfile>>;
export const battleEnvironmentProfiles: Readonly<Partial<Record<BattleScenarioId, BattleEnvironmentProfile>>> = profiles;
export function battleEnvironmentProfileFor(battleId: BattleScenarioId | string) {
return battleEnvironmentProfiles[battleId as BattleScenarioId];
}
export function validateBattleEnvironmentProfiles() {
const errors: string[] = [];
const expectedIds = new Set<string>(battleEnvironmentProfileBattleIds);
const entries = Object.entries(battleEnvironmentProfiles) as Array<[string, BattleEnvironmentProfile]>;
const profileIds = new Set<string>();
entries.forEach(([battleId, profile]) => {
if (!expectedIds.has(battleId)) {
errors.push(`${battleId}: unexpected environment profile`);
}
if (profile.battleId !== battleId) {
errors.push(`${battleId}: profile battleId must match its record key`);
}
if (profileIds.has(profile.id)) {
errors.push(`${battleId}: duplicate profile id "${profile.id}"`);
}
profileIds.add(profile.id);
if (!profile.label.trim()) {
errors.push(`${battleId}: label is required`);
}
if (!profile.soundscape.ambienceKey) {
errors.push(`${battleId}: ambience key is required`);
}
if (
!Number.isFinite(profile.soundscape.ambienceVolume) ||
profile.soundscape.ambienceVolume <= 0 ||
profile.soundscape.ambienceVolume > 0.16
) {
errors.push(`${battleId}: ambience volume must stay within 0..0.16`);
}
if (!Number.isFinite(profile.tint.alpha) || profile.tint.alpha < 0 || profile.tint.alpha > 0.16) {
errors.push(`${battleId}: tint alpha must stay within the map-legibility budget (0..0.16)`);
}
validateRange(errors, battleId, 'particle alpha', profile.particles?.alpha, 0, 0.7);
validateRange(errors, battleId, 'particle size', profile.particles?.size, 1, 28);
validateRange(errors, battleId, 'particle velocityX', profile.particles?.velocityX, -160, 160);
validateRange(errors, battleId, 'particle velocityY', profile.particles?.velocityY, -520, 520);
validateRange(errors, battleId, 'haze alpha', profile.haze?.alpha, 0, 0.08);
validateRange(errors, battleId, 'haze widthRatio', profile.haze?.widthRatio, 0.12, 0.5);
validateRange(errors, battleId, 'haze heightRatio', profile.haze?.heightRatio, 0.04, 0.24);
validateRange(errors, battleId, 'haze velocityX', profile.haze?.velocityX, -30, 30);
validateRange(errors, battleId, 'haze waveAmplitude', profile.haze?.waveAmplitude, 0, 24);
if ((profile.particles?.count ?? 0) < 0 || (profile.particles?.count ?? 0) > 40) {
errors.push(`${battleId}: particle count exceeds the fixed-pool budget of 40`);
}
if ((profile.haze?.count ?? 0) < 0 || (profile.haze?.count ?? 0) > 4) {
errors.push(`${battleId}: haze count exceeds the fixed-pool budget of 4`);
}
if ((profile.particles?.count ?? 0) + (profile.haze?.count ?? 0) <= 0) {
errors.push(`${battleId}: at least one environment effect is required`);
}
});
battleEnvironmentProfileBattleIds.forEach((battleId) => {
if (!battleEnvironmentProfiles[battleId]) {
errors.push(`${battleId}: missing representative environment profile`);
}
});
return errors;
}
function validateRange(
errors: string[],
battleId: string,
label: string,
range: BattleEnvironmentNumberRange | undefined,
minimum: number,
maximum: number
) {
if (!range) {
return;
}
const [from, to] = range;
if (
!Number.isFinite(from) ||
!Number.isFinite(to) ||
from > to ||
from < minimum ||
to > maximum
) {
errors.push(`${battleId}: ${label} range must be ordered within ${minimum}..${maximum}`);
}
}
const battleEnvironmentProfileErrors = validateBattleEnvironmentProfiles();
if (battleEnvironmentProfileErrors.length > 0) {
throw new Error(`Invalid battle environment profiles:\n${battleEnvironmentProfileErrors.join('\n')}`);
}
export const battleEnvironmentProfileVerification = Object.freeze({
valid: true,
profileCount: Object.keys(battleEnvironmentProfiles).length,
representativeBattleCount: battleEnvironmentProfileBattleIds.length,
maximumParticleCount: Math.max(
...Object.values(battleEnvironmentProfiles).map((profile) => profile?.particles?.count ?? 0)
),
maximumHazeCount: Math.max(
...Object.values(battleEnvironmentProfiles).map((profile) => profile?.haze?.count ?? 0)
)
});

View File

@@ -101,7 +101,34 @@ export type StoryPortraitContext = {
background: string;
};
const zhugeLiangNorthernBackgrounds = new Set([
type StoryPortraitVariantRule = Readonly<{
variant: string;
pageIds?: readonly string[];
pageIdPrefixes?: readonly string[];
backgrounds?: readonly string[];
}>;
type StoryPortraitVariantRoute = Readonly<{
defaultVariant: string;
rules: readonly StoryPortraitVariantRule[];
}>;
const yellowTurbanStoryBackgrounds = [
'story-liu-bei',
'story-three-heroes',
'title-taoyuan',
'story-militia',
'story-sortie',
'story-yellow-pursuit'
] as const;
const earlyCampaignStoryBackgrounds = [
'story-anti-dong',
'story-xuzhou',
'story-wandering'
] as const;
const zhugeLiangNorthernBackgrounds = [
'story-tianshui-front',
'story-jieting-crisis',
'story-chencang-siege',
@@ -111,7 +138,149 @@ const zhugeLiangNorthernBackgrounds = new Set([
'story-lucheng-pursuit',
'story-weishui-camps',
'story-weishui-northbank'
]);
] as const;
// Rules are evaluated from top to bottom. They deliberately use semantic page
// and background milestones instead of a hash so a character cannot appear in
// an outfit from a different campaign era.
const storyPortraitVariantRoutes: Readonly<Record<string, StoryPortraitVariantRoute>> = {
'liu-bei': {
defaultVariant: 'campaign',
rules: [
{ variant: 'yellow-turban', backgrounds: yellowTurbanStoryBackgrounds },
{
variant: 'shuhan',
pageIdPrefixes: ['hanzhong-king-', 'shu-han-foundation-', 'forty-', 'baidi-entrustment-'],
backgrounds: [
'story-jingzhou-crisis',
'story-fan-castle-flood',
'story-maicheng-isolation',
'story-yiling-baidi',
'story-yiling-fire-attack',
'story-baidi-entrustment'
]
},
{ variant: 'base', backgrounds: earlyCampaignStoryBackgrounds }
]
},
'guan-yu': {
defaultVariant: 'campaign',
rules: [
{ variant: 'yellow-turban', backgrounds: yellowTurbanStoryBackgrounds },
{
variant: 'jingzhou',
pageIds: ['hanzhong-king-officers', 'shu-han-foundation-generals'],
backgrounds: ['story-jingzhou-crisis', 'story-fan-castle-flood', 'story-maicheng-isolation']
},
{ variant: 'base', backgrounds: earlyCampaignStoryBackgrounds }
]
},
'zhang-fei': {
defaultVariant: 'campaign',
rules: [
{ variant: 'yellow-turban', backgrounds: yellowTurbanStoryBackgrounds },
{
variant: 'ba-command',
backgrounds: ['story-yizhou', 'story-yizhou-luo-road', 'story-chengdu-surrender', 'story-northern']
},
{ variant: 'base', backgrounds: earlyCampaignStoryBackgrounds }
]
},
'zhao-yun': {
defaultVariant: 'campaign',
rules: [
{
variant: 'changban',
pageIds: ['nineteenth-zhao-yun-rescue', 'nineteenth-zhao-yun-returns']
},
{
variant: 'veteran',
pageIdPrefixes: ['baidi-entrustment-', 'northern-prep-', 'forty-seventh-', 'fifty-', 'sixty-', 'ending-'],
backgrounds: [
'story-baidi-entrustment',
'story-nanzhong',
'story-nanzhong-captures',
'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',
'story-northern'
]
},
{ variant: 'base', backgrounds: ['story-wolong'] }
]
},
'jiang-wei': {
defaultVariant: 'campaign',
rules: [
{
variant: 'tianshui',
pageIdPrefixes: [
'fifty-sixth-jiang-wei-',
'fifty-seventh-victory-jiang-wei-',
'fifty-eighth-jiang-wei-first-'
]
}
]
},
'cao-cao': {
defaultVariant: 'campaign',
rules: [
{ variant: 'redcliffs', backgrounds: ['story-red-cliffs'] },
{ variant: 'base', backgrounds: ['story-anti-dong', 'story-xuzhou'] }
]
},
'sima-yi': {
defaultVariant: 'campaign',
rules: [
{
variant: 'qishan',
backgrounds: [
'story-jieting-crisis',
'story-chencang-siege',
'story-wudu-yinping',
'story-hanzhong-rain',
'story-qishan-renewed',
'story-lucheng-pursuit',
'story-weishui-camps',
'story-weishui-northbank',
'story-northern',
'story-wuzhang-jiangwei-inheritance'
]
},
{ variant: 'base', backgrounds: ['story-wandering'] }
]
},
'meng-huo': {
defaultVariant: 'base',
rules: [
{ variant: 'final', pageIds: ['fifty-fourth-victory-meng-huo-yields'] },
{ variant: 'campaign', backgrounds: ['story-nanzhong-captures'] }
]
},
'zhuge-liang': {
defaultVariant: 'campaign',
rules: [
{
variant: 'redcliffs',
pageIds: ['eighteenth-red-cliffs-wind'],
backgrounds: ['story-red-cliffs']
},
{ variant: 'base', backgrounds: ['story-wolong'] },
{
variant: 'northern',
pageIds: ['northern-prep-back-secured'],
pageIdPrefixes: ['fifty-fifth-'],
backgrounds: zhugeLiangNorthernBackgrounds
}
]
}
};
export function portraitSlugForKey(key: string) {
return legacyPortraitSlugs[key] ?? camelToKebab(key);
@@ -169,14 +338,21 @@ export function portraitAssetEntriesForKey(key: string): PortraitAssetEntry[] {
export function portraitAssetEntriesForStoryContext(key: string, context: StoryPortraitContext) {
const entries = portraitAssetEntriesForKey(key);
if (portraitSlugForKey(key) !== 'zhuge-liang' || entries.length <= 1) {
if (entries.length <= 1) {
return entries;
}
const variant = zhugeLiangStoryVariant(context);
const portraitSlug = portraitSlugForKey(key);
const route = storyPortraitVariantRoutes[portraitSlug];
if (!route) {
return entries;
}
const matchedRule = route.rules.find((rule) => storyPortraitVariantRuleMatches(rule, context));
const variant = matchedRule?.variant ?? route.defaultVariant;
const textureKeyPrefix = variant === 'base'
? 'portrait-zhuge-liang'
: `portrait-zhuge-liang-${variant}`;
? `portrait-${portraitSlug}`
: `portrait-${portraitSlug}-${variant}`;
const preferredEntries = entries.filter(({ textureKey }) =>
textureKey === textureKeyPrefix ||
(textureKey.startsWith(textureKeyPrefix) && /^-v[1-9]\d*$/.test(textureKey.slice(textureKeyPrefix.length)))
@@ -184,6 +360,15 @@ export function portraitAssetEntriesForStoryContext(key: string, context: StoryP
return preferredEntries.length > 0 ? preferredEntries : entries;
}
export function portraitAssetEntryForStoryContext(
key: string,
context: StoryPortraitContext,
selectionSeed = context.id
): PortraitAssetEntry | undefined {
const entries = portraitAssetEntriesForStoryContext(key, context);
return entries.length > 0 ? entries[stablePortraitHash(selectionSeed) % entries.length] : undefined;
}
export function portraitTextureKeysForKey(key: string) {
return portraitAssetSlugsForKey(key).map((candidate) => `portrait-${candidate}`);
}
@@ -192,19 +377,21 @@ 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';
function storyPortraitVariantRuleMatches(
rule: StoryPortraitVariantRule,
{ id, background }: StoryPortraitContext
) {
return Boolean(
rule.pageIds?.includes(id) ||
rule.pageIdPrefixes?.some((prefix) => id.startsWith(prefix)) ||
rule.backgrounds?.includes(background)
);
}
function stablePortraitHash(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
}
return hash;
}

View File

@@ -12,6 +12,13 @@ import {
type BattleScenarioId,
type BattleTacticalGuideEventKey
} from '../data/battles';
import {
battleEnvironmentProfileFor,
battleEnvironmentProfileVerification,
type BattleEnvironmentHazeProfile,
type BattleEnvironmentParticleProfile,
type BattleEnvironmentProfile
} from '../data/battleEnvironmentProfiles';
import { getSortieFlow } from '../data/campaignFlow';
import {
enemyIntentOpeningGuide,
@@ -164,6 +171,9 @@ import { startLazyScene } from './lazyScenes';
const battleReferenceWidth = 1280;
const battleReferenceHeight = 720;
const battleFhdUiScale = 1.5;
const battleEnvironmentTintDepth = 3.04;
const battleEnvironmentHazeDepth = 3.12;
const battleEnvironmentParticleDepth = 3.3;
const firstBattleTutorialId: CampaignTutorialId = 'first-battle-basic-controls';
const unitTexture: Record<string, string> = {
@@ -1251,6 +1261,28 @@ type TerrainTileView = {
tile: Phaser.GameObjects.Rectangle;
};
type BattleEnvironmentParticleView = {
object: Phaser.GameObjects.Rectangle | Phaser.GameObjects.Ellipse;
profile: BattleEnvironmentParticleProfile;
velocityX: number;
velocityY: number;
angularVelocity: number;
ageMs: number;
swayPhase: number;
};
type BattleEnvironmentHazeView = {
object: Phaser.GameObjects.Graphics;
profile: BattleEnvironmentHazeProfile;
width: number;
baseY: number;
velocityX: number;
ageMs: number;
waveAmplitude: number;
wavePhase: number;
pulsePhase: number;
};
type MiniMapLayout = {
x: number;
y: number;
@@ -3683,6 +3715,11 @@ export class BattleScene extends Phaser.Scene {
private debugOverlay?: Phaser.GameObjects.Text;
private mapBackground?: Phaser.GameObjects.Image;
private mapMask?: Phaser.Display.Masks.GeometryMask;
private battleEnvironmentProfile?: BattleEnvironmentProfile;
private battleEnvironmentObjects: Phaser.GameObjects.GameObject[] = [];
private battleEnvironmentParticles: BattleEnvironmentParticleView[] = [];
private battleEnvironmentHaze: BattleEnvironmentHazeView[] = [];
private battleEnvironmentRandomState = 1;
private terrainTileViews: TerrainTileView[] = [];
private miniMapLayout?: MiniMapLayout;
private miniMapObjects: Phaser.GameObjects.GameObject[] = [];
@@ -3778,6 +3815,11 @@ export class BattleScene extends Phaser.Scene {
this.facingIndicatorUnitId = undefined;
this.mapBackground = undefined;
this.mapMask = undefined;
this.battleEnvironmentProfile = undefined;
this.battleEnvironmentObjects = [];
this.battleEnvironmentParticles = [];
this.battleEnvironmentHaze = [];
this.battleEnvironmentRandomState = 1;
this.terrainTileViews = [];
this.miniMapLayout = undefined;
this.miniMapObjects = [];
@@ -3861,6 +3903,7 @@ export class BattleScene extends Phaser.Scene {
this.hideFirstBattleTutorial();
this.hideTacticalCommandCutIn();
this.hideCombatCutIn();
this.clearBattleEnvironment();
this.mapResultPopupObjects.forEach((object) => object.active && object.destroy());
this.mapResultPopupObjects = [];
});
@@ -3922,7 +3965,12 @@ export class BattleScene extends Phaser.Scene {
this.combatPresentationCount = { full: 0, map: 0 };
this.combatPresentationLast = undefined;
this.fastForwardHeld = false;
soundDirector.playMusic('battle-prep');
const environmentProfile = battleEnvironmentProfileFor(battleScenario.id);
soundDirector.playSoundscape({
musicKey: 'battle-prep',
ambienceKey: environmentProfile?.soundscape.ambienceKey,
ambienceVolume: environmentProfile?.soundscape.ambienceVolume
});
this.input.mouse?.disableContextMenu();
this.installBattleAudioUnlock();
this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
@@ -4110,6 +4158,7 @@ export class BattleScene extends Phaser.Scene {
private drawBattleScene() {
this.drawMap();
this.drawBattleEnvironment();
this.renderObjectiveMapMarkers();
this.drawUnits();
this.drawSidePanel();
@@ -5245,6 +5294,224 @@ export class BattleScene extends Phaser.Scene {
this.updateCameraView();
}
private drawBattleEnvironment() {
this.clearBattleEnvironment();
const profile = battleEnvironmentProfileFor(battleScenario.id);
this.battleEnvironmentProfile = profile;
if (!profile || !this.mapMask) {
return;
}
this.battleEnvironmentRandomState = this.battleEnvironmentSeed(profile.id);
const { mapX, mapY, mapWidth, mapHeight } = this.layout;
if (profile.tint.alpha > 0) {
const tint = this.add.rectangle(mapX, mapY, mapWidth, mapHeight, profile.tint.color, profile.tint.alpha);
tint.setOrigin(0);
tint.setDepth(battleEnvironmentTintDepth);
tint.setMask(this.mapMask);
tint.setName(`battle-environment-${profile.id}-tint`);
this.battleEnvironmentObjects.push(tint);
}
if (profile.haze) {
this.createBattleEnvironmentHaze(profile.haze);
}
if (profile.particles) {
this.createBattleEnvironmentParticles(profile.particles);
}
}
private createBattleEnvironmentHaze(profile: BattleEnvironmentHazeProfile) {
const { mapX, mapY, mapWidth, mapHeight } = this.layout;
for (let index = 0; index < profile.count; index += 1) {
const width = mapWidth * this.battleEnvironmentRange(profile.widthRatio);
const height = mapHeight * this.battleEnvironmentRange(profile.heightRatio);
const alpha = this.battleEnvironmentRange(profile.alpha);
const haze = this.add.graphics();
[
{ scale: 1, alpha: 0.16 },
{ scale: 0.82, alpha: 0.24 },
{ scale: 0.62, alpha: 0.34 }
].forEach((layer) => {
haze.fillStyle(profile.color, alpha * layer.alpha);
haze.fillEllipse(0, 0, width * layer.scale, height * layer.scale);
});
const baseY = this.battleEnvironmentBetween(mapY + height * 0.45, mapY + mapHeight - height * 0.45);
haze.setPosition(
this.battleEnvironmentBetween(mapX - width * 0.15, mapX + mapWidth + width * 0.15),
baseY
);
haze.setDepth(battleEnvironmentHazeDepth);
haze.setMask(this.mapMask!);
haze.setName(`battle-environment-${profile.kind}-${index + 1}`);
const view: BattleEnvironmentHazeView = {
object: haze,
profile,
width,
baseY,
velocityX: this.battleEnvironmentRange(profile.velocityX),
ageMs: this.battleEnvironmentBetween(0, 9000),
waveAmplitude: this.battleEnvironmentRange(profile.waveAmplitude),
wavePhase: this.battleEnvironmentBetween(0, Math.PI * 2),
pulsePhase: this.battleEnvironmentBetween(0, Math.PI * 2)
};
this.battleEnvironmentHaze.push(view);
this.battleEnvironmentObjects.push(haze);
}
}
private createBattleEnvironmentParticles(profile: BattleEnvironmentParticleProfile) {
for (let index = 0; index < profile.count; index += 1) {
const object = profile.kind === 'rain'
? this.add.rectangle(0, 0, 2, 18, 0xffffff, 1)
: this.add.ellipse(0, 0, 5, 5, 0xffffff, 1);
object.setOrigin(0.5);
object.setDepth(battleEnvironmentParticleDepth);
object.setMask(this.mapMask!);
object.setBlendMode(profile.kind === 'ember' ? Phaser.BlendModes.ADD : Phaser.BlendModes.NORMAL);
object.setName(`battle-environment-${profile.kind}-${index + 1}`);
const view: BattleEnvironmentParticleView = {
object,
profile,
velocityX: 0,
velocityY: 0,
angularVelocity: 0,
ageMs: 0,
swayPhase: 0
};
this.resetBattleEnvironmentParticle(view, true);
this.battleEnvironmentParticles.push(view);
this.battleEnvironmentObjects.push(object);
}
}
private resetBattleEnvironmentParticle(view: BattleEnvironmentParticleView, initial: boolean) {
const { mapX, mapY, mapWidth, mapHeight } = this.layout;
const profile = view.profile;
const size = this.battleEnvironmentRange(profile.size);
const alpha = this.battleEnvironmentRange(profile.alpha);
const colorIndex = Math.floor(this.battleEnvironmentRandom() * profile.colors.length);
const color = profile.colors[colorIndex] ?? 0xffffff;
view.velocityX = this.battleEnvironmentRange(profile.velocityX);
view.velocityY = this.battleEnvironmentRange(profile.velocityY);
view.ageMs = this.battleEnvironmentBetween(0, 5000);
view.swayPhase = this.battleEnvironmentBetween(0, Math.PI * 2);
view.angularVelocity = profile.kind === 'rain' ? 0 : this.battleEnvironmentBetween(-0.7, 0.7);
view.object.setFillStyle(color, alpha);
if (profile.kind === 'rain') {
view.object.setDisplaySize(Math.max(1.2, size * 0.09), size);
view.object.setRotation(Math.atan2(view.velocityY, view.velocityX) - Math.PI / 2);
} else if (profile.kind === 'ember') {
view.object.setDisplaySize(size, size * 1.45);
view.object.setRotation(this.battleEnvironmentBetween(-0.45, 0.45));
} else {
view.object.setDisplaySize(size, Math.max(2, size * 0.68));
view.object.setRotation(this.battleEnvironmentBetween(-Math.PI, Math.PI));
}
if (initial) {
view.object.setPosition(
this.battleEnvironmentBetween(mapX, mapX + mapWidth),
this.battleEnvironmentBetween(mapY, mapY + mapHeight)
);
return;
}
if (profile.kind === 'ember') {
view.object.setPosition(
this.battleEnvironmentBetween(mapX - 12, mapX + mapWidth + 12),
mapY + mapHeight + 18
);
return;
}
view.object.setPosition(
this.battleEnvironmentBetween(mapX - 24, mapX + mapWidth + 24),
mapY - 18
);
}
private updateBattleEnvironment(delta: number) {
if (!this.battleEnvironmentProfile || this.battleEnvironmentObjects.length === 0) {
return;
}
const elapsedMs = Phaser.Math.Clamp(delta, 0, 50);
const elapsedSeconds = elapsedMs / 1000;
const { mapX, mapY, mapWidth, mapHeight } = this.layout;
const right = mapX + mapWidth;
const bottom = mapY + mapHeight;
this.battleEnvironmentParticles.forEach((view) => {
view.ageMs += elapsedMs;
const sway = view.profile.kind === 'snow'
? Math.sin(view.ageMs / 420 + view.swayPhase) * 22
: view.profile.kind === 'ember'
? Math.sin(view.ageMs / 560 + view.swayPhase) * 9
: 0;
view.object.x += (view.velocityX + sway) * elapsedSeconds;
view.object.y += view.velocityY * elapsedSeconds;
view.object.rotation += view.angularVelocity * elapsedSeconds;
if (
view.object.x < mapX - 48 ||
view.object.x > right + 48 ||
view.object.y < mapY - 48 ||
view.object.y > bottom + 48
) {
this.resetBattleEnvironmentParticle(view, false);
}
});
this.battleEnvironmentHaze.forEach((view) => {
view.ageMs += elapsedMs;
view.object.x += view.velocityX * elapsedSeconds;
view.object.y = view.baseY + Math.sin(view.ageMs / 2600 + view.wavePhase) * view.waveAmplitude;
view.object.setAlpha(0.82 + Math.sin(view.ageMs / 3100 + view.pulsePhase) * 0.12);
const beyondRight = view.velocityX >= 0 && view.object.x - view.width / 2 > right + 24;
const beyondLeft = view.velocityX < 0 && view.object.x + view.width / 2 < mapX - 24;
if (beyondRight || beyondLeft) {
view.object.x = view.velocityX >= 0 ? mapX - view.width / 2 : right + view.width / 2;
view.baseY = this.battleEnvironmentBetween(mapY + 30, bottom - 30);
}
});
}
private clearBattleEnvironment() {
this.battleEnvironmentObjects.forEach((object) => {
if (object.active) {
object.destroy();
}
});
this.battleEnvironmentObjects = [];
this.battleEnvironmentParticles = [];
this.battleEnvironmentHaze = [];
this.battleEnvironmentProfile = undefined;
}
private battleEnvironmentSeed(value: string) {
let seed = 2166136261;
for (let index = 0; index < value.length; index += 1) {
seed ^= value.charCodeAt(index);
seed = Math.imul(seed, 16777619);
}
return seed >>> 0 || 1;
}
private battleEnvironmentRandom() {
this.battleEnvironmentRandomState = (
Math.imul(this.battleEnvironmentRandomState, 1664525) + 1013904223
) >>> 0;
return this.battleEnvironmentRandomState / 0x100000000;
}
private battleEnvironmentBetween(minimum: number, maximum: number) {
return minimum + (maximum - minimum) * this.battleEnvironmentRandom();
}
private battleEnvironmentRange(range: readonly [number, number]) {
return this.battleEnvironmentBetween(range[0], range[1]);
}
private drawUnits() {
battleUnits.forEach((unit) => {
const sizeRatio = this.unitDisplaySizeRatio(unit);
@@ -6408,6 +6675,7 @@ export class BattleScene extends Phaser.Scene {
}
update(_time: number, delta: number) {
this.updateBattleEnvironment(delta);
this.updateEdgeScroll(delta);
this.positionEnemyIntentVisuals();
this.refreshSideQuickTabAvailability();
@@ -17036,6 +17304,7 @@ export class BattleScene extends Phaser.Scene {
}
this.triggeredBattleEvents.add(key);
soundDirector.playOperationAlert();
const message = `${title}\n${lines.join('\n')}`;
this.pushBattleLog(message);
this.renderSituationPanel(message);
@@ -17152,6 +17421,8 @@ export class BattleScene extends Phaser.Scene {
}
if (state.status === 'done') {
this.triggerObjectiveAchievedFeedback(objective, state);
} else if (state.status === 'failed') {
this.triggerObjectiveFailedFeedback(state);
}
});
}
@@ -17345,6 +17616,9 @@ export class BattleScene extends Phaser.Scene {
}
this.triggeredBattleEvents.add(key);
if (!this.battleOutcome) {
soundDirector.playObjectiveAchieved();
}
const rewardText = objective.rewardGold > 0 ? `보상 +${objective.rewardGold}` : '보상 없음';
this.pushBattleLog(`목표 달성: ${state.label}\n${state.detail} · ${rewardText}`);
this.showObjectiveMapFeedback(objective, state, [`목표 달성`, objective.rewardGold > 0 ? `+${objective.rewardGold}` : '완료'], '#a8ffd0', '#072416');
@@ -17357,6 +17631,9 @@ export class BattleScene extends Phaser.Scene {
}
this.triggeredBattleEvents.add(key);
if (!this.battleOutcome) {
soundDirector.playObjectiveFailed();
}
const rewardText = objective.rewardGold > 0 ? `보상 미획득 +${objective.rewardGold}` : '보상 없음';
this.pushBattleLog(`목표 미달: ${objective.label}\n${objective.failureReason ?? objective.detail} · ${rewardText}`);
}
@@ -18498,6 +18775,7 @@ export class BattleScene extends Phaser.Scene {
return;
}
const recoveryMessage = this.applyTerrainRecovery('enemy');
soundDirector.playEnemyTurnCue();
this.renderSituationPanel(['아군 턴을 종료했습니다.', statusMessage, recoveryMessage, '적군이 행동을 시작합니다.'].filter(Boolean).join('\n'));
void this.runEnemyTurn();
}
@@ -22554,7 +22832,11 @@ export class BattleScene extends Phaser.Scene {
this.updateTurnText();
this.renderBattleSpeedControl();
this.updateObjectiveTracker();
const eventCountBeforeTurnCheck = this.triggeredBattleEvents.size;
this.checkBattleEvents();
if (this.triggeredBattleEvents.size === eventCountBeforeTurnCheck) {
soundDirector.playAllyTurnCue();
}
this.centerCameraOnAllyLine();
const turnMessage = [`${this.turnNumber}턴 아군 차례입니다.`, statusMessage, expiredBuffMessage, recoveryMessage, '행동할 장수를 선택하세요.']
.filter(Boolean)
@@ -27336,6 +27618,45 @@ export class BattleScene extends Phaser.Scene {
};
}
private battleEnvironmentDebugState() {
const profile = this.battleEnvironmentProfile;
const activeObjects = this.battleEnvironmentObjects.filter((object) => object.active);
const depths = activeObjects.map((object) => (
object as Phaser.GameObjects.GameObject & { depth: number }
).depth);
const maskedObjectCount = activeObjects.filter((object) => (
(object as Phaser.GameObjects.GameObject & { mask?: Phaser.Display.Masks.GeometryMask | null }).mask === this.mapMask
)).length;
return {
profileId: profile?.id ?? null,
label: profile?.label ?? null,
effect: profile?.effect ?? null,
ambienceKey: profile?.soundscape.ambienceKey ?? null,
ambienceVolume: profile?.soundscape.ambienceVolume ?? 0,
active: Boolean(profile && activeObjects.length > 0),
particleCount: this.battleEnvironmentParticles.filter((view) => view.object.active).length,
hazeCount: this.battleEnvironmentHaze.filter((view) => view.object.active).length,
tintCount: profile?.tint.alpha && activeObjects.length > 0 ? 1 : 0,
objectCount: activeObjects.length,
maskedObjectCount,
masked: activeObjects.length === 0 ? true : maskedObjectCount === activeObjects.length,
depthRange: depths.length > 0
? { minimum: Math.min(...depths), maximum: Math.max(...depths) }
: null,
mapBounds: this.layout
? {
x: this.layout.mapX,
y: this.layout.mapY,
width: this.layout.mapWidth,
height: this.layout.mapHeight
}
: null,
tintAlpha: profile?.tint.alpha ?? 0,
fixedPool: true,
verification: battleEnvironmentProfileVerification
};
}
getDebugState() {
const campaign = getCampaignState();
const effectiveSortieUnitIds = this.effectiveSortieUnitIds(campaign);
@@ -27371,6 +27692,7 @@ export class BattleScene extends Phaser.Scene {
scene: this.scene.key,
battleId: battleScenario.id,
battleTitle: battleScenario.title,
environment: this.battleEnvironmentDebugState(),
victoryConditionLabel: battleScenario.victoryConditionLabel,
defeatConditionLabel: battleScenario.defeatConditionLabel,
selectedSortieUnitIds: effectiveSortieUnitIds,

View File

@@ -9,8 +9,7 @@ import {
type BattleUiIconKey
} from '../data/battleUiIcons';
import {
portraitAssetEntriesForKey,
portraitAssetEntriesForStoryContext,
portraitAssetEntryForStoryContext,
portraitKeyForSpeaker,
type PortraitAssetEntry
} from '../data/portraitAssets';
@@ -473,7 +472,7 @@ export class StoryScene extends Phaser.Scene {
const pagePortrait = this.pagePortraitAssetEntry(page);
const actorPortraits =
page.cutscene?.actors
?.map((actor, index) => this.actorPortraitAssetEntry(actor, index))
?.map((actor, index) => this.actorPortraitAssetEntry(actor, index, page))
.filter((entry): entry is PortraitAssetEntry => entry !== undefined) ?? [];
const portraits = [pagePortrait, ...actorPortraits]
.filter((entry): entry is PortraitAssetEntry => entry !== undefined)
@@ -777,12 +776,7 @@ export class StoryScene extends Phaser.Scene {
return undefined;
}
const entries = portraitAssetEntriesForStoryContext(portraitKey, page);
if (entries.length === 0) {
return undefined;
}
return entries[this.hashString(page.id) % entries.length];
return portraitAssetEntryForStoryContext(portraitKey, page);
}
private pageBackgroundKey(page: StoryPage) {
@@ -846,7 +840,7 @@ export class StoryScene extends Phaser.Scene {
this.renderCutsceneStage(layer, cutscene);
const boardBounds = this.renderTacticalBoard(layer, cutscene);
this.renderCutsceneActorCards(layer, cutscene, boardBounds);
this.renderCutsceneActorCards(layer, cutscene, boardBounds, page);
this.renderCutsceneRewards(layer, this.rewardDisplay);
}
@@ -1032,7 +1026,8 @@ export class StoryScene extends Phaser.Scene {
private renderCutsceneActorCards(
layer: Phaser.GameObjects.Container,
cutscene: StoryCutscene,
board: CutsceneBoardBounds
board: CutsceneBoardBounds,
page: StoryPage
) {
const stage = this.cutsceneStageBounds();
const actors = (cutscene.actors ?? []).filter((actor) => actor.unitId !== 'rebel-leader').slice(0, 3);
@@ -1043,7 +1038,7 @@ export class StoryScene extends Phaser.Scene {
actors.forEach((actor, index) => {
const y = firstY + index * (cardH + ui(10));
this.renderCutsceneActorCard(layer, actor, cardX, y, cardW, cardH, index);
this.renderCutsceneActorCard(layer, actor, cardX, y, cardW, cardH, index, page);
});
if ((cutscene.briefing?.lines ?? []).length > 0) {
@@ -1062,7 +1057,8 @@ export class StoryScene extends Phaser.Scene {
y: number,
width: number,
height: number,
index: number
index: number,
page: StoryPage
) {
const profile = storyCutsceneActorProfileFor(actor.unitId);
if (!profile) {
@@ -1093,7 +1089,7 @@ export class StoryScene extends Phaser.Scene {
card.fillCircle(x + width - ui(24), y + height / 2, ui(7));
layer.add(card);
const portraitTexture = this.actorPortraitTextureKey(actor, index);
const portraitTexture = this.actorPortraitTextureKey(actor, index, page);
const portraitX = x + ui(38);
const portraitY = y + height / 2;
const portraitFrame = this.add.graphics();
@@ -1611,23 +1607,22 @@ export class StoryScene extends Phaser.Scene {
};
}
private actorPortraitTextureKey(actor: StoryCutsceneActor, index: number) {
const entry = this.actorPortraitAssetEntry(actor, index);
private actorPortraitTextureKey(actor: StoryCutsceneActor, index: number, page: StoryPage) {
const entry = this.actorPortraitAssetEntry(actor, index, page);
return entry && this.textures.exists(entry.textureKey) ? entry.textureKey : undefined;
}
private actorPortraitAssetEntry(actor: StoryCutsceneActor, index: number) {
private actorPortraitAssetEntry(actor: StoryCutsceneActor, index: number, page: StoryPage) {
const profile = storyCutsceneActorProfileFor(actor.unitId);
if (!profile) {
return undefined;
}
const entries = portraitAssetEntriesForKey(profile.portraitKey);
if (entries.length === 0) {
return undefined;
}
return entries[this.hashString(`${actor.unitId}-${index}`) % entries.length];
return portraitAssetEntryForStoryContext(
profile.portraitKey,
page,
`${page.id}:${actor.unitId}:${index}`
);
}
private storyUnitFrameIndex(direction: UnitDirection, frame = 0) {
@@ -1638,14 +1633,6 @@ export class StoryScene extends Phaser.Scene {
return this.textures.exists(textureKey) ? textureKey : 'story-fallback';
}
private hashString(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
}
return hash;
}
private advance() {
if (this.transitioning) {
return;