feat: add stable story atmosphere and motion controls
This commit is contained in:
@@ -8,6 +8,7 @@ const checks = [
|
||||
'scripts/verify-campaign-completion.mjs',
|
||||
'scripts/verify-growth-consistency.mjs',
|
||||
'scripts/verify-combat-presentation-settings.mjs',
|
||||
'scripts/verify-visual-motion-settings.mjs',
|
||||
'scripts/verify-battle-forecast.mjs',
|
||||
'scripts/verify-battle-save-normalization.mjs',
|
||||
'scripts/verify-battle-save-resume-routing.mjs',
|
||||
@@ -24,6 +25,7 @@ const checks = [
|
||||
'scripts/verify-equipment-catalog-data.mjs',
|
||||
'scripts/verify-inventory-reward-data.mjs',
|
||||
'scripts/verify-story-asset-data.mjs',
|
||||
'scripts/verify-story-environment-profiles.mjs',
|
||||
'scripts/verify-story-image-asset-data.mjs',
|
||||
'scripts/verify-visual-asset-data.mjs',
|
||||
'scripts/verify-unit-sprite-asset-data.mjs',
|
||||
|
||||
190
scripts/verify-story-environment-profiles.mjs
Normal file
190
scripts/verify-story-environment-profiles.mjs
Normal file
@@ -0,0 +1,190 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createServer } from 'vite';
|
||||
|
||||
const expectedStoryPageCount = 466;
|
||||
const server = await createServer({
|
||||
logLevel: 'error',
|
||||
server: { middlewareMode: true, hmr: false },
|
||||
appType: 'custom'
|
||||
});
|
||||
|
||||
try {
|
||||
const scenarioModule = await server.ssrLoadModule('/src/game/data/scenario.ts');
|
||||
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
|
||||
const {
|
||||
storyEnvironmentDepths,
|
||||
storyEnvironmentProfiles,
|
||||
storyEnvironmentProfileFor,
|
||||
storyEnvironmentProfileVerification
|
||||
} = await server.ssrLoadModule('/src/game/data/storyEnvironmentProfiles.ts');
|
||||
const { ambienceTracks } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts');
|
||||
|
||||
assert.equal(storyEnvironmentProfileVerification.profileCount, 7, 'seven story environment profiles are required');
|
||||
assert(storyEnvironmentDepths.tint > 0, 'story tint must render above the background');
|
||||
assert(storyEnvironmentDepths.tint < storyEnvironmentDepths.haze, 'story haze must render above tint');
|
||||
assert(storyEnvironmentDepths.haze < storyEnvironmentDepths.particles, 'story particles must render above haze');
|
||||
assert(storyEnvironmentDepths.particles < 1, 'story environment must remain below the scene shade');
|
||||
assert(storyEnvironmentProfileVerification.maxParticleCount <= 24, 'story particle pool exceeds the 24-object budget');
|
||||
assert(storyEnvironmentProfileVerification.maxHazeCount <= 2, 'story haze pool exceeds the two-object budget');
|
||||
assert(storyEnvironmentProfileVerification.maxObjectCount <= 27, 'story environment pool exceeds 27 objects');
|
||||
|
||||
Object.values(storyEnvironmentProfiles).forEach((profile) => {
|
||||
assert(ambienceTracks[profile.ambienceKey], `${profile.id} references missing ambience ${profile.ambienceKey}`);
|
||||
assert(profile.ambienceVolume > 0 && profile.ambienceVolume <= 0.09, `${profile.id} ambience is outside the quiet story mix`);
|
||||
assert(profile.tint.alpha >= 0 && profile.tint.alpha <= 0.08, `${profile.id} tint is too opaque`);
|
||||
assert((profile.haze?.count ?? 0) <= 2, `${profile.id} creates too many haze layers`);
|
||||
assert((profile.particles?.count ?? 0) <= 24, `${profile.id} creates too many particles`);
|
||||
if (profile.effect === 'mist') {
|
||||
assert.equal(profile.particles, undefined, `${profile.id} mist should not create particles`);
|
||||
} else {
|
||||
const expectedKind = profile.effect === 'embers' ? 'ember' : profile.effect;
|
||||
assert.equal(profile.particles?.kind, expectedKind, `${profile.id} particle kind does not match its effect`);
|
||||
}
|
||||
});
|
||||
|
||||
const representativeCases = [
|
||||
['story-yiling-fire-attack', 'story-fire-embers'],
|
||||
['story-fan-castle-flood', 'story-rain-mist'],
|
||||
['story-hanzhong-rain', 'story-rain-mist'],
|
||||
['story-maicheng-isolation', 'story-snow-wind'],
|
||||
['story-nanzhong-captures', 'story-jungle-mist'],
|
||||
['story-weishui-northbank', 'story-river-mist'],
|
||||
['story-weishui-camps', 'story-camp-night'],
|
||||
['story-northern', 'story-mountain-wind'],
|
||||
['story-liu-bei-resolve', null]
|
||||
];
|
||||
representativeCases.forEach(([key, expectedProfileId]) => {
|
||||
assert.equal(
|
||||
storyEnvironmentProfileFor(key)?.id ?? null,
|
||||
expectedProfileId,
|
||||
`${key} should resolve to ${expectedProfileId ?? 'no environment'}`
|
||||
);
|
||||
});
|
||||
assert.equal(
|
||||
storyEnvironmentProfileFor('story-red-cliffs', 'twenty-second-fire-attack-sortie')?.id,
|
||||
'story-fire-embers',
|
||||
'the explicit Red Cliffs fire set piece should retain its fire environment'
|
||||
);
|
||||
assert.equal(
|
||||
storyEnvironmentProfileFor('story-red-cliffs', 'twenty-first-red-cliffs-sortie'),
|
||||
undefined,
|
||||
'the broader Red Cliffs story family must remain neutral before the fire attack'
|
||||
);
|
||||
|
||||
const unmappedVariantCases = [
|
||||
'story-yiling-fire-attack-riverbank',
|
||||
'story-hanzhong-rain-night-watch',
|
||||
'story-maicheng-isolation-snow-road',
|
||||
'story-wandering-night-camp',
|
||||
'story-training',
|
||||
'story-red-cliffs',
|
||||
'story-anti-dong',
|
||||
'story-baidi-entrustment',
|
||||
'story-yiling-baidi',
|
||||
'story-yizhou'
|
||||
];
|
||||
unmappedVariantCases.forEach((key) => {
|
||||
assert.equal(
|
||||
storyEnvironmentProfileFor(key),
|
||||
undefined,
|
||||
`${key} must not bypass semantic story background-family mapping`
|
||||
);
|
||||
});
|
||||
|
||||
const storySources = collectStorySources(scenarioModule, battleScenarios);
|
||||
const selectedCounts = new Map();
|
||||
let pageCount = 0;
|
||||
let coveredPageCount = 0;
|
||||
let environmentTransitionCount = 0;
|
||||
let multiTransitionSequenceCount = 0;
|
||||
let fullyChangingSequenceCount = 0;
|
||||
storySources.forEach((pages) => {
|
||||
const profileIds = pages.map((page) => storyEnvironmentProfileFor(page.background, page.id)?.id ?? null);
|
||||
let sequenceTransitionCount = 0;
|
||||
profileIds.forEach((profileId, index) => {
|
||||
pageCount += 1;
|
||||
if (index > 0 && profileIds[index - 1] !== profileId) {
|
||||
sequenceTransitionCount += 1;
|
||||
}
|
||||
if (!profileId) {
|
||||
return;
|
||||
}
|
||||
coveredPageCount += 1;
|
||||
selectedCounts.set(profileId, (selectedCounts.get(profileId) ?? 0) + 1);
|
||||
});
|
||||
environmentTransitionCount += sequenceTransitionCount;
|
||||
if (sequenceTransitionCount >= 2) {
|
||||
multiTransitionSequenceCount += 1;
|
||||
}
|
||||
if (pages.length > 1 && sequenceTransitionCount === pages.length - 1) {
|
||||
fullyChangingSequenceCount += 1;
|
||||
}
|
||||
});
|
||||
assert.equal(pageCount, expectedStoryPageCount, 'story environment coverage must inspect every story page');
|
||||
assert(coveredPageCount >= 220, `story environments cover too few pages (${coveredPageCount}/${pageCount})`);
|
||||
assert(
|
||||
environmentTransitionCount <= 8,
|
||||
`story environments change too often inside sequences (${environmentTransitionCount} transitions)`
|
||||
);
|
||||
assert(
|
||||
multiTransitionSequenceCount <= 4,
|
||||
`too many story sequences switch environments repeatedly (${multiTransitionSequenceCount})`
|
||||
);
|
||||
assert(
|
||||
fullyChangingSequenceCount <= 3,
|
||||
`too many story sequences change environment on every page (${fullyChangingSequenceCount})`
|
||||
);
|
||||
Object.values(storyEnvironmentProfiles).forEach((profile) => {
|
||||
assert((selectedCounts.get(profile.id) ?? 0) > 0, `${profile.id} is never selected by campaign pages`);
|
||||
});
|
||||
|
||||
const storySceneSource = readFileSync('src/game/scenes/StoryScene.ts', 'utf8');
|
||||
[
|
||||
'soundDirector.playSoundscape({',
|
||||
'storyEnvironmentProfileFor(page.background, page.id)',
|
||||
'this.applyStoryEnvironment(environmentProfile, backgroundKey, page.id)',
|
||||
'soundDirector.playStoryAdvanceCue()',
|
||||
'this.updateStoryEnvironment(delta)',
|
||||
'this.clearStoryEnvironment()',
|
||||
'environment: this.storyEnvironmentDebugState()'
|
||||
].forEach((snippet) => {
|
||||
assert(storySceneSource.includes(snippet), `StoryScene is missing environment integration: ${snippet}`);
|
||||
});
|
||||
assert(
|
||||
!storySceneSource.includes('soundDirector.stopAmbience()'),
|
||||
'StoryScene shutdown must leave ambience handoff to the destination soundscape so crossfades stay continuous.'
|
||||
);
|
||||
|
||||
console.info(
|
||||
`Verified ${storyEnvironmentProfileVerification.profileCount} story environment profiles across ` +
|
||||
`${coveredPageCount}/${pageCount} pages with ${environmentTransitionCount} in-sequence transitions and ` +
|
||||
`a fixed ${storyEnvironmentProfileVerification.maxObjectCount}-object pool.`
|
||||
);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
|
||||
function collectStorySources(scenarioModule, battleScenarios) {
|
||||
const sources = [];
|
||||
const seenArrays = new WeakSet();
|
||||
Object.values(scenarioModule)
|
||||
.filter(isStoryPageList)
|
||||
.forEach((pages) => {
|
||||
sources.push(pages);
|
||||
seenArrays.add(pages);
|
||||
});
|
||||
Object.values(battleScenarios).forEach((scenario) => {
|
||||
if (isStoryPageList(scenario.victoryPages) && !seenArrays.has(scenario.victoryPages)) {
|
||||
sources.push(scenario.victoryPages);
|
||||
seenArrays.add(scenario.victoryPages);
|
||||
}
|
||||
});
|
||||
return sources;
|
||||
}
|
||||
|
||||
function isStoryPageList(value) {
|
||||
return Array.isArray(value) && value.length > 0 && value.every((page) =>
|
||||
page && typeof page === 'object' && typeof page.id === 'string' && typeof page.background === 'string'
|
||||
);
|
||||
}
|
||||
155
scripts/verify-visual-motion-settings.mjs
Normal file
155
scripts/verify-visual-motion-settings.mjs
Normal file
@@ -0,0 +1,155 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createServer } from 'vite';
|
||||
|
||||
const server = await createServer({
|
||||
logLevel: 'error',
|
||||
server: { middlewareMode: true, hmr: false },
|
||||
appType: 'custom'
|
||||
});
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function createStorage() {
|
||||
const values = new Map();
|
||||
return {
|
||||
values,
|
||||
storage: {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => values.set(key, value)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const fullMotionPreference = () => ({ matches: false });
|
||||
const reducedMotionPreference = () => ({ matches: true });
|
||||
|
||||
try {
|
||||
const motion = await server.ssrLoadModule('/src/game/settings/visualMotion.ts');
|
||||
|
||||
assert(
|
||||
motion.preferredVisualMotionMode(fullMotionPreference) === 'full',
|
||||
'The OS default must use full motion when reduced motion is not requested.'
|
||||
);
|
||||
assert(
|
||||
motion.preferredVisualMotionMode(reducedMotionPreference) === 'reduced',
|
||||
'The OS reduced-motion preference must become the default mode.'
|
||||
);
|
||||
assert(
|
||||
motion.preferredVisualMotionMode(() => { throw new Error('unavailable'); }) === 'full',
|
||||
'An unavailable media query must safely fall back to full motion.'
|
||||
);
|
||||
|
||||
const { values, storage } = createStorage();
|
||||
assert(
|
||||
motion.loadVisualMotionMode(storage, fullMotionPreference) === 'full',
|
||||
'A fresh full-motion environment must load full mode.'
|
||||
);
|
||||
assert(
|
||||
motion.loadVisualMotionMode(storage, reducedMotionPreference) === 'reduced',
|
||||
'A fresh reduced-motion environment must load reduced mode.'
|
||||
);
|
||||
|
||||
for (const mode of motion.visualMotionModes) {
|
||||
motion.saveVisualMotionMode(mode, storage);
|
||||
assert(
|
||||
motion.loadVisualMotionMode(storage, reducedMotionPreference) === mode,
|
||||
`Stored mode ${mode} must override the OS default.`
|
||||
);
|
||||
}
|
||||
|
||||
values.set(motion.visualMotionStorageKey, 'invalid');
|
||||
assert(
|
||||
motion.loadVisualMotionMode(storage, fullMotionPreference) === 'full' &&
|
||||
motion.loadVisualMotionMode(storage, reducedMotionPreference) === 'reduced',
|
||||
'Invalid stored values must normalize to the current OS default.'
|
||||
);
|
||||
|
||||
assert(
|
||||
motion.normalizeVisualMotionMode('full', 'reduced') === 'full' &&
|
||||
motion.normalizeVisualMotionMode('reduced', 'full') === 'reduced' &&
|
||||
motion.normalizeVisualMotionMode('invalid', 'reduced') === 'reduced',
|
||||
'Visual motion normalization must preserve valid values and replace invalid values.'
|
||||
);
|
||||
assert(
|
||||
motion.nextVisualMotionMode('full') === 'reduced' &&
|
||||
motion.nextVisualMotionMode('reduced') === 'full',
|
||||
'The two visual motion modes must cycle in display order.'
|
||||
);
|
||||
assert(
|
||||
motion.visualMotionModeLabel('full') === '보통' &&
|
||||
motion.visualMotionModeLabel('reduced') === '줄임',
|
||||
'Visual motion labels must match the title settings copy.'
|
||||
);
|
||||
|
||||
motion.saveVisualMotionMode('reduced', storage);
|
||||
assert(
|
||||
motion.isVisualMotionReduced(storage, fullMotionPreference),
|
||||
'The shared reduced-motion predicate must honor the stored reduced mode.'
|
||||
);
|
||||
motion.saveVisualMotionMode('full', storage);
|
||||
assert(
|
||||
!motion.isVisualMotionReduced(storage, reducedMotionPreference),
|
||||
'The shared reduced-motion predicate must let a stored full mode override the OS preference.'
|
||||
);
|
||||
|
||||
const throwingStorage = {
|
||||
getItem: () => { throw new Error('blocked'); },
|
||||
setItem: () => { throw new Error('blocked'); }
|
||||
};
|
||||
assert(
|
||||
motion.loadVisualMotionMode(throwingStorage, reducedMotionPreference) === 'reduced',
|
||||
'Storage read failures must fall back to the OS preference.'
|
||||
);
|
||||
motion.saveVisualMotionMode('full', throwingStorage);
|
||||
|
||||
const titleSource = readFileSync('src/game/scenes/TitleScene.ts', 'utf8');
|
||||
assert(
|
||||
titleSource.includes("from '../settings/visualMotion'") &&
|
||||
titleSource.includes('화면 움직임:') &&
|
||||
titleSource.includes('nextVisualMotionMode(loadVisualMotionMode())') &&
|
||||
titleSource.includes('ui(468)'),
|
||||
'The title settings panel must expose the persisted motion toggle in its expanded layout.'
|
||||
);
|
||||
|
||||
const battleSource = readFileSync('src/game/scenes/BattleScene.ts', 'utf8');
|
||||
assert(
|
||||
battleSource.includes("import { isVisualMotionReduced } from '../settings/visualMotion';"),
|
||||
'BattleScene must use the shared visual motion preference.'
|
||||
);
|
||||
assert(
|
||||
!battleSource.includes("matchMedia('(prefers-reduced-motion: reduce)')"),
|
||||
'BattleScene must not keep direct reduced-motion media queries.'
|
||||
);
|
||||
assert(
|
||||
battleSource.includes('if (!reducedMotion && profile.haze)') &&
|
||||
battleSource.includes('if (!reducedMotion && profile.particles)'),
|
||||
'Reduced mode must disable moving battle haze and particles without suppressing tint creation.'
|
||||
);
|
||||
assert(
|
||||
(battleSource.match(/this\.shakeCamera\(/g) ?? []).length === 3 &&
|
||||
(battleSource.match(/this\.cameras\.main\.shake\(/g) ?? []).length === 1 &&
|
||||
battleSource.includes('if (isVisualMotionReduced())'),
|
||||
'Every battle camera shake must pass through the shared reduced-motion guard.'
|
||||
);
|
||||
|
||||
const storySource = readFileSync('src/game/scenes/StoryScene.ts', 'utf8');
|
||||
assert(
|
||||
storySource.includes("import { isVisualMotionReduced } from '../settings/visualMotion';") &&
|
||||
storySource.includes('this.visualMotionReduced = isVisualMotionReduced();'),
|
||||
'StoryScene must load the shared visual motion preference when the scene starts.'
|
||||
);
|
||||
assert(
|
||||
storySource.includes('const particleCount = this.visualMotionReduced ? 0') &&
|
||||
storySource.includes('? Math.min(1, profile.haze.count)') &&
|
||||
storySource.includes('if (this.visualMotionReduced) {\n return;\n }'),
|
||||
'Reduced story motion must stop background panning and particles while retaining one static haze layer.'
|
||||
);
|
||||
|
||||
console.info('Verified visual motion defaults, persistence, title controls, and story/battle motion guards.');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
256
src/game/data/storyEnvironmentProfiles.ts
Normal file
256
src/game/data/storyEnvironmentProfiles.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import type { AmbienceTrackKey } from '../audio/audioAssets';
|
||||
|
||||
export type StoryEnvironmentParticleKind = 'ember' | 'rain' | 'snow';
|
||||
|
||||
export type StoryEnvironmentParticleProfile = Readonly<{
|
||||
kind: StoryEnvironmentParticleKind;
|
||||
count: number;
|
||||
colors: readonly number[];
|
||||
alpha: readonly [number, number];
|
||||
size: readonly [number, number];
|
||||
velocityX: readonly [number, number];
|
||||
velocityY: readonly [number, number];
|
||||
swayAmplitude: readonly [number, number];
|
||||
angularVelocity: readonly [number, number];
|
||||
}>;
|
||||
|
||||
export type StoryEnvironmentHazeProfile = Readonly<{
|
||||
count: number;
|
||||
color: number;
|
||||
alpha: readonly [number, number];
|
||||
widthRatio: readonly [number, number];
|
||||
heightRatio: readonly [number, number];
|
||||
velocityX: readonly [number, number];
|
||||
waveAmplitude: readonly [number, number];
|
||||
}>;
|
||||
|
||||
export type StoryEnvironmentProfile = Readonly<{
|
||||
id: string;
|
||||
label: string;
|
||||
effect: 'embers' | 'rain' | 'snow' | 'mist';
|
||||
ambienceKey: AmbienceTrackKey;
|
||||
ambienceVolume: number;
|
||||
tint: Readonly<{ color: number; alpha: number }>;
|
||||
haze?: StoryEnvironmentHazeProfile;
|
||||
particles?: StoryEnvironmentParticleProfile;
|
||||
}>;
|
||||
|
||||
export const storyEnvironmentDepths = {
|
||||
tint: 0.15,
|
||||
haze: 0.3,
|
||||
particles: 0.5
|
||||
} as const;
|
||||
|
||||
export const storyEnvironmentProfiles = {
|
||||
fire: {
|
||||
id: 'story-fire-embers',
|
||||
label: '화염과 재',
|
||||
effect: 'embers',
|
||||
ambienceKey: 'battle-fire',
|
||||
ambienceVolume: 0.085,
|
||||
tint: { color: 0x7a2d18, alpha: 0.07 },
|
||||
haze: {
|
||||
count: 2,
|
||||
color: 0x34221c,
|
||||
alpha: [0.018, 0.038],
|
||||
widthRatio: [0.5, 0.72],
|
||||
heightRatio: [0.14, 0.24],
|
||||
velocityX: [-3.5, 4.5],
|
||||
waveAmplitude: [4, 10]
|
||||
},
|
||||
particles: {
|
||||
kind: 'ember',
|
||||
count: 18,
|
||||
colors: [0xffb14a, 0xff7b35, 0xffd47a],
|
||||
alpha: [0.18, 0.48],
|
||||
size: [3, 7],
|
||||
velocityX: [-8, 8],
|
||||
velocityY: [-58, -32],
|
||||
swayAmplitude: [4, 10],
|
||||
angularVelocity: [-0.55, 0.55]
|
||||
}
|
||||
},
|
||||
rain: {
|
||||
id: 'story-rain-mist',
|
||||
label: '장대비와 물안개',
|
||||
effect: 'rain',
|
||||
ambienceKey: 'battle-rain',
|
||||
ambienceVolume: 0.085,
|
||||
tint: { color: 0x183349, alpha: 0.07 },
|
||||
haze: {
|
||||
count: 2,
|
||||
color: 0x9fb5c5,
|
||||
alpha: [0.016, 0.034],
|
||||
widthRatio: [0.52, 0.76],
|
||||
heightRatio: [0.12, 0.2],
|
||||
velocityX: [-4, 3],
|
||||
waveAmplitude: [3, 8]
|
||||
},
|
||||
particles: {
|
||||
kind: 'rain',
|
||||
count: 24,
|
||||
colors: [0xc8def0, 0xa9c9df],
|
||||
alpha: [0.16, 0.42],
|
||||
size: [16, 24],
|
||||
velocityX: [-92, -58],
|
||||
velocityY: [330, 450],
|
||||
swayAmplitude: [0, 0],
|
||||
angularVelocity: [0, 0]
|
||||
}
|
||||
},
|
||||
snow: {
|
||||
id: 'story-snow-wind',
|
||||
label: '설원의 눈바람',
|
||||
effect: 'snow',
|
||||
ambienceKey: 'mountain-wind-ambience',
|
||||
ambienceVolume: 0.07,
|
||||
tint: { color: 0xb7cadc, alpha: 0.055 },
|
||||
haze: {
|
||||
count: 2,
|
||||
color: 0xc8d5df,
|
||||
alpha: [0.014, 0.03],
|
||||
widthRatio: [0.5, 0.74],
|
||||
heightRatio: [0.13, 0.21],
|
||||
velocityX: [-3, 5],
|
||||
waveAmplitude: [4, 9]
|
||||
},
|
||||
particles: {
|
||||
kind: 'snow',
|
||||
count: 22,
|
||||
colors: [0xffffff, 0xddeaf3, 0xcbdde9],
|
||||
alpha: [0.2, 0.5],
|
||||
size: [3, 8],
|
||||
velocityX: [-8, 10],
|
||||
velocityY: [32, 54],
|
||||
swayAmplitude: [12, 26],
|
||||
angularVelocity: [-0.45, 0.45]
|
||||
}
|
||||
},
|
||||
jungle: {
|
||||
id: 'story-jungle-mist',
|
||||
label: '남중의 숲안개',
|
||||
effect: 'mist',
|
||||
ambienceKey: 'nanzhong-night-ambience',
|
||||
ambienceVolume: 0.065,
|
||||
tint: { color: 0x173528, alpha: 0.045 },
|
||||
haze: {
|
||||
count: 2,
|
||||
color: 0x9fb5a5,
|
||||
alpha: [0.018, 0.038],
|
||||
widthRatio: [0.48, 0.7],
|
||||
heightRatio: [0.13, 0.22],
|
||||
velocityX: [-2.5, 3.5],
|
||||
waveAmplitude: [3, 8]
|
||||
}
|
||||
},
|
||||
river: {
|
||||
id: 'story-river-mist',
|
||||
label: '강가의 물안개',
|
||||
effect: 'mist',
|
||||
ambienceKey: 'river-night-ambience',
|
||||
ambienceVolume: 0.065,
|
||||
tint: { color: 0x1d3948, alpha: 0.045 },
|
||||
haze: {
|
||||
count: 2,
|
||||
color: 0xa7bec8,
|
||||
alpha: [0.016, 0.034],
|
||||
widthRatio: [0.5, 0.74],
|
||||
heightRatio: [0.11, 0.19],
|
||||
velocityX: [-3, 4],
|
||||
waveAmplitude: [3, 7]
|
||||
}
|
||||
},
|
||||
camp: {
|
||||
id: 'story-camp-night',
|
||||
label: '밤 군영의 연무',
|
||||
effect: 'mist',
|
||||
ambienceKey: 'campfire-ambience',
|
||||
ambienceVolume: 0.06,
|
||||
tint: { color: 0x5e351e, alpha: 0.04 },
|
||||
haze: {
|
||||
count: 2,
|
||||
color: 0x6c6258,
|
||||
alpha: [0.014, 0.03],
|
||||
widthRatio: [0.46, 0.68],
|
||||
heightRatio: [0.12, 0.2],
|
||||
velocityX: [-2.5, 3.5],
|
||||
waveAmplitude: [3, 8]
|
||||
}
|
||||
},
|
||||
mountain: {
|
||||
id: 'story-mountain-wind',
|
||||
label: '산성의 바람결',
|
||||
effect: 'mist',
|
||||
ambienceKey: 'mountain-wind-ambience',
|
||||
ambienceVolume: 0.065,
|
||||
tint: { color: 0x394550, alpha: 0.04 },
|
||||
haze: {
|
||||
count: 2,
|
||||
color: 0xb2bdc6,
|
||||
alpha: [0.012, 0.028],
|
||||
widthRatio: [0.48, 0.72],
|
||||
heightRatio: [0.11, 0.19],
|
||||
velocityX: [-5, 6],
|
||||
waveAmplitude: [4, 9]
|
||||
}
|
||||
}
|
||||
} as const satisfies Record<string, StoryEnvironmentProfile>;
|
||||
|
||||
// Story artwork rotates among several purpose-built variants inside one narrative
|
||||
// sequence. Bind atmosphere to the semantic StoryPage.background family instead
|
||||
// of those rotating image keys so ambience does not change on nearly every click.
|
||||
const storyEnvironmentProfileByBackgroundFamily: Readonly<Record<string, StoryEnvironmentProfile>> = {
|
||||
'story-yiling-fire-attack': storyEnvironmentProfiles.fire,
|
||||
|
||||
'story-fan-castle-flood': storyEnvironmentProfiles.rain,
|
||||
'story-hanzhong-rain': storyEnvironmentProfiles.rain,
|
||||
|
||||
'story-maicheng-isolation': storyEnvironmentProfiles.snow,
|
||||
|
||||
'story-nanzhong': storyEnvironmentProfiles.jungle,
|
||||
'story-nanzhong-captures': storyEnvironmentProfiles.jungle,
|
||||
|
||||
'story-weishui-northbank': storyEnvironmentProfiles.river,
|
||||
|
||||
'story-weishui-camps': storyEnvironmentProfiles.camp,
|
||||
|
||||
'story-chencang-siege': storyEnvironmentProfiles.mountain,
|
||||
'story-jieting-crisis': storyEnvironmentProfiles.mountain,
|
||||
'story-lucheng-pursuit': storyEnvironmentProfiles.mountain,
|
||||
'story-northern': storyEnvironmentProfiles.mountain,
|
||||
'story-qishan-renewed': storyEnvironmentProfiles.mountain,
|
||||
'story-wudu-yinping': storyEnvironmentProfiles.mountain,
|
||||
'story-yizhou-luo-road': storyEnvironmentProfiles.mountain
|
||||
};
|
||||
|
||||
// Some artwork families intentionally span several chapters. Keep exceptional
|
||||
// set pieces explicit so a broad family such as story-red-cliffs does not put
|
||||
// embers over Changban, diplomacy, and other non-fire scenes.
|
||||
const storyEnvironmentProfileByPageId: Readonly<Record<string, StoryEnvironmentProfile>> = {
|
||||
'twenty-second-huang-gai-bitter-plan': storyEnvironmentProfiles.fire,
|
||||
'twenty-second-zhuge-wind-watch': storyEnvironmentProfiles.fire,
|
||||
'twenty-second-liu-bei-fire-line': storyEnvironmentProfiles.fire,
|
||||
'twenty-second-fire-attack-sortie': storyEnvironmentProfiles.fire,
|
||||
'twenty-second-victory-fire-rises': storyEnvironmentProfiles.fire,
|
||||
'twenty-second-cao-cao-retreat': storyEnvironmentProfiles.fire,
|
||||
'twenty-second-liu-bei-next-land': storyEnvironmentProfiles.fire
|
||||
};
|
||||
|
||||
export function storyEnvironmentProfileFor(backgroundFamily: string, pageId?: string) {
|
||||
const pageProfile = pageId ? storyEnvironmentProfileByPageId[pageId.trim().toLowerCase()] : undefined;
|
||||
if (pageProfile) {
|
||||
return pageProfile;
|
||||
}
|
||||
return storyEnvironmentProfileByBackgroundFamily[backgroundFamily.trim().toLowerCase()];
|
||||
}
|
||||
|
||||
const profileList: StoryEnvironmentProfile[] = Object.values(storyEnvironmentProfiles);
|
||||
|
||||
export const storyEnvironmentProfileVerification = {
|
||||
profileCount: profileList.length,
|
||||
maxParticleCount: Math.max(...profileList.map((profile) => profile.particles?.count ?? 0)),
|
||||
maxHazeCount: Math.max(...profileList.map((profile) => profile.haze?.count ?? 0)),
|
||||
maxObjectCount: Math.max(
|
||||
...profileList.map((profile) => 1 + (profile.particles?.count ?? 0) + (profile.haze?.count ?? 0))
|
||||
)
|
||||
} as const;
|
||||
@@ -1,6 +1,7 @@
|
||||
import Phaser from 'phaser';
|
||||
import { soundDirector } from '../audio/SoundDirector';
|
||||
import { loadAudioPreferences, saveAudioPreferences } from '../settings/audioPreferences';
|
||||
import { isVisualMotionReduced } from '../settings/visualMotion';
|
||||
import { battleMapAssets } from '../data/battleMapAssets';
|
||||
import { firstBattleVictoryPages, type BattleBond, type UnitData, type UnitStats } from '../data/scenario';
|
||||
import {
|
||||
@@ -5313,10 +5314,11 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.battleEnvironmentObjects.push(tint);
|
||||
}
|
||||
|
||||
if (profile.haze) {
|
||||
const reducedMotion = isVisualMotionReduced();
|
||||
if (!reducedMotion && profile.haze) {
|
||||
this.createBattleEnvironmentHaze(profile.haze);
|
||||
}
|
||||
if (profile.particles) {
|
||||
if (!reducedMotion && profile.particles) {
|
||||
this.createBattleEnvironmentParticles(profile.particles);
|
||||
}
|
||||
}
|
||||
@@ -6418,9 +6420,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
roleMark
|
||||
]);
|
||||
|
||||
const reducedMotion = typeof window !== 'undefined' &&
|
||||
typeof window.matchMedia === 'function' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const reducedMotion = isVisualMotionReduced();
|
||||
const enterDuration = this.scaledBattleDuration(150, 100);
|
||||
const exitAt = this.scaledBattleDuration(700, 500);
|
||||
const totalDuration = this.scaledBattleDuration(880, 650);
|
||||
@@ -6612,6 +6612,13 @@ export class BattleScene extends Phaser.Scene {
|
||||
return Math.max(minDuration, Math.round(duration / this.battleSpeedFactor()));
|
||||
}
|
||||
|
||||
private shakeCamera(duration: number, intensity: number) {
|
||||
if (isVisualMotionReduced()) {
|
||||
return;
|
||||
}
|
||||
this.cameras.main.shake(duration, intensity);
|
||||
}
|
||||
|
||||
private drawDebugSpritePreview() {
|
||||
const textureKey = this.debugSpritePreviewKey();
|
||||
if (!textureKey) {
|
||||
@@ -19802,7 +19809,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.tweens.add({ targets: spark, scale: 1.35, angle: 120, alpha: 0, duration, ease: 'Sine.easeOut' });
|
||||
this.tweens.add({ targets: badge, y: badge.y - this.battleUiLength(10), alpha: 0, delay: Math.round(duration * 0.45), duration: Math.round(duration * 0.55) });
|
||||
if (result.critical || result.defeated) {
|
||||
this.cameras.main.shake(this.scaledBattleDuration(140, 80), result.defeated ? 0.008 : 0.005);
|
||||
this.shakeCamera(this.scaledBattleDuration(140, 80), result.defeated ? 0.008 : 0.005);
|
||||
}
|
||||
|
||||
this.showCompactGrowthMapResult(result.attacker, result.characterGrowth, result.attackerGrowth);
|
||||
@@ -21555,7 +21562,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
|
||||
if (result.hit) {
|
||||
void this.playUnitActionFrames(defenderSprite, result.defender, defenderDirection, 'hurt', 70);
|
||||
this.cameras.main.shake(
|
||||
this.shakeCamera(
|
||||
this.scaledBattleDuration(result.critical ? 130 : 90, 55),
|
||||
result.critical ? 0.006 : 0.0035
|
||||
);
|
||||
@@ -22418,7 +22425,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
});
|
||||
|
||||
if (chain.defeated) {
|
||||
this.cameras.main.shake(this.scaledBattleDuration(180, 110), 0.008);
|
||||
this.shakeCamera(this.scaledBattleDuration(180, 110), 0.008);
|
||||
const finishRing = this.trackCombatObject(this.add.circle(defenderX - 24, groundY - 48, 24));
|
||||
finishRing.setDepth(depth + 5);
|
||||
finishRing.setStrokeStyle(7, 0xffdf7b, 0.94);
|
||||
@@ -25158,9 +25165,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
objectCount: objects.length
|
||||
};
|
||||
|
||||
const reducedMotion = typeof window !== 'undefined' &&
|
||||
typeof window.matchMedia === 'function' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const reducedMotion = isVisualMotionReduced();
|
||||
if (reducedMotion) {
|
||||
this.sidePanelTransitionActive = false;
|
||||
this.sidePanelTransitionCompletedRevision = revision;
|
||||
@@ -27633,6 +27638,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
effect: profile?.effect ?? null,
|
||||
ambienceKey: profile?.soundscape.ambienceKey ?? null,
|
||||
ambienceVolume: profile?.soundscape.ambienceVolume ?? 0,
|
||||
reducedMotion: isVisualMotionReduced(),
|
||||
active: Boolean(profile && activeObjects.length > 0),
|
||||
particleCount: this.battleEnvironmentParticles.filter((view) => view.object.active).length,
|
||||
hazeCount: this.battleEnvironmentHaze.filter((view) => view.object.active).length,
|
||||
|
||||
@@ -18,6 +18,14 @@ import {
|
||||
storyBackgroundKeyForPage,
|
||||
storyBackgroundKeysForPages
|
||||
} from '../data/storyAssets';
|
||||
import {
|
||||
storyEnvironmentDepths,
|
||||
storyEnvironmentProfileFor,
|
||||
storyEnvironmentProfileVerification,
|
||||
type StoryEnvironmentHazeProfile,
|
||||
type StoryEnvironmentParticleProfile,
|
||||
type StoryEnvironmentProfile
|
||||
} from '../data/storyEnvironmentProfiles';
|
||||
import {
|
||||
ensureUnitAnimations,
|
||||
loadUnitBaseSheets,
|
||||
@@ -35,6 +43,7 @@ import {
|
||||
import { prologuePages, type PortraitKey, type StoryPage } from '../data/scenario';
|
||||
import { campaignVictoryRewardPresentation, getFirstBattleReport } from '../state/campaignState';
|
||||
import { releaseTextureSource } from '../render/loaderLifecycle';
|
||||
import { isVisualMotionReduced } from '../settings/visualMotion';
|
||||
import { palette } from '../ui/palette';
|
||||
import { ensureLazyScene, startGameScene } from './lazyScenes';
|
||||
|
||||
@@ -58,9 +67,34 @@ type StoryPageAssetPlan = {
|
||||
unitTextureKeys: string[];
|
||||
};
|
||||
|
||||
type StoryEnvironmentParticleView = {
|
||||
object: Phaser.GameObjects.Graphics;
|
||||
profile?: StoryEnvironmentParticleProfile;
|
||||
velocityX: number;
|
||||
velocityY: number;
|
||||
swayAmplitude: number;
|
||||
swayPhase: number;
|
||||
angularVelocity: number;
|
||||
ageMs: number;
|
||||
};
|
||||
|
||||
type StoryEnvironmentHazeView = {
|
||||
object: Phaser.GameObjects.Graphics;
|
||||
profile?: StoryEnvironmentHazeProfile;
|
||||
width: number;
|
||||
baseY: number;
|
||||
velocityX: number;
|
||||
waveAmplitude: number;
|
||||
wavePhase: number;
|
||||
ageMs: number;
|
||||
};
|
||||
|
||||
type StoryEnvironmentObject = Phaser.GameObjects.Graphics | Phaser.GameObjects.Rectangle;
|
||||
|
||||
const sceneShadeDepth = 1;
|
||||
const cutsceneDepth = 8;
|
||||
const dialogDepth = 20;
|
||||
const storyEnvironmentTransitionMs = 180;
|
||||
const fhdUiScale = 1.5;
|
||||
const ui = (value: number) => value * fhdUiScale;
|
||||
const uiPx = (value: number) => `${Math.round(ui(value))}px`;
|
||||
@@ -170,6 +204,15 @@ export class StoryScene extends Phaser.Scene {
|
||||
private storyAssetFailures = new Set<string>();
|
||||
private ownedStoryTextureKeys = new Set<string>();
|
||||
private rewardDisplay: StoryRewardDisplayModel = this.emptyRewardDisplay();
|
||||
private visualMotionReduced = false;
|
||||
private storyEnvironmentProfile?: StoryEnvironmentProfile;
|
||||
private storyEnvironmentBackgroundKey?: string;
|
||||
private storyEnvironmentTint?: Phaser.GameObjects.Rectangle;
|
||||
private storyEnvironmentObjects: StoryEnvironmentObject[] = [];
|
||||
private storyEnvironmentParticles: StoryEnvironmentParticleView[] = [];
|
||||
private storyEnvironmentHaze: StoryEnvironmentHazeView[] = [];
|
||||
private storyEnvironmentRandomState = 1;
|
||||
private storyEnvironmentRevision = 0;
|
||||
|
||||
constructor() {
|
||||
super('StoryScene');
|
||||
@@ -190,6 +233,15 @@ export class StoryScene extends Phaser.Scene {
|
||||
this.ownedStoryTextureKeys.clear();
|
||||
this.storyAssetLoadGeneration += 1;
|
||||
this.rewardDisplay = this.emptyRewardDisplay();
|
||||
this.visualMotionReduced = isVisualMotionReduced();
|
||||
this.storyEnvironmentProfile = undefined;
|
||||
this.storyEnvironmentBackgroundKey = undefined;
|
||||
this.storyEnvironmentTint = undefined;
|
||||
this.storyEnvironmentObjects = [];
|
||||
this.storyEnvironmentParticles = [];
|
||||
this.storyEnvironmentHaze = [];
|
||||
this.storyEnvironmentRandomState = 1;
|
||||
this.storyEnvironmentRevision = 0;
|
||||
}
|
||||
|
||||
getDebugState() {
|
||||
@@ -247,6 +299,7 @@ export class StoryScene extends Phaser.Scene {
|
||||
...this.rewardDisplay,
|
||||
items: this.rewardDisplay.items.map((item) => ({ ...item, icon: { ...item.icon } }))
|
||||
},
|
||||
environment: this.storyEnvironmentDebugState(),
|
||||
assetStreaming: {
|
||||
currentPageReady: this.readyStoryPageIndexes.has(this.pageIndex),
|
||||
nextPageIndex: this.pageIndex + 1 < this.pages.length ? this.pageIndex + 1 : null,
|
||||
@@ -288,6 +341,7 @@ export class StoryScene extends Phaser.Scene {
|
||||
this.activeStoryLoadErrorHandler = undefined;
|
||||
}
|
||||
this.pageLoadingText = undefined;
|
||||
this.clearStoryEnvironment();
|
||||
this.releaseOwnedStoryTextures();
|
||||
});
|
||||
this.warmFirstBattleSceneModule();
|
||||
@@ -715,8 +769,15 @@ export class StoryScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private applyPage(page: StoryPage) {
|
||||
soundDirector.playMusic(page.bgm);
|
||||
const backgroundKey = this.pageBackgroundKey(page);
|
||||
const environmentProfile = storyEnvironmentProfileFor(page.background, page.id);
|
||||
soundDirector.playSoundscape({
|
||||
musicKey: page.bgm,
|
||||
ambienceKey: environmentProfile?.ambienceKey,
|
||||
ambienceVolume: environmentProfile?.ambienceVolume
|
||||
});
|
||||
this.applyBackground(page);
|
||||
this.applyStoryEnvironment(environmentProfile, backgroundKey, page.id);
|
||||
this.renderCutscene(page);
|
||||
this.chapterText?.setText(page.chapter);
|
||||
|
||||
@@ -808,7 +869,13 @@ export class StoryScene extends Phaser.Scene {
|
||||
this.background.setAlpha(1);
|
||||
this.background.setDepth(0);
|
||||
this.background.setScale(coverScale * 1.07);
|
||||
this.background.setPosition(width / 2 - ui(16), height / 2);
|
||||
this.background.setPosition(width / 2, height / 2);
|
||||
|
||||
if (this.visualMotionReduced) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.background.setX(width / 2 - ui(16));
|
||||
|
||||
this.tweens.add({
|
||||
targets: this.background,
|
||||
@@ -822,6 +889,348 @@ export class StoryScene extends Phaser.Scene {
|
||||
});
|
||||
}
|
||||
|
||||
update(_time: number, delta: number) {
|
||||
this.updateStoryEnvironment(delta);
|
||||
}
|
||||
|
||||
private ensureStoryEnvironmentPool() {
|
||||
if (this.storyEnvironmentTint) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { width, height } = this.scale;
|
||||
const tint = this.add.rectangle(0, 0, width, height, 0xffffff, 0);
|
||||
tint.setOrigin(0);
|
||||
tint.setScrollFactor(0);
|
||||
tint.setDepth(storyEnvironmentDepths.tint);
|
||||
tint.setVisible(false);
|
||||
tint.setName('story-environment-tint');
|
||||
this.storyEnvironmentTint = tint;
|
||||
this.storyEnvironmentObjects.push(tint);
|
||||
|
||||
for (let index = 0; index < storyEnvironmentProfileVerification.maxHazeCount; index += 1) {
|
||||
const object = this.add.graphics();
|
||||
object.setScrollFactor(0);
|
||||
object.setDepth(storyEnvironmentDepths.haze);
|
||||
object.setVisible(false);
|
||||
object.setName(`story-environment-haze-${index + 1}`);
|
||||
this.storyEnvironmentHaze.push({
|
||||
object,
|
||||
width: 0,
|
||||
baseY: 0,
|
||||
velocityX: 0,
|
||||
waveAmplitude: 0,
|
||||
wavePhase: 0,
|
||||
ageMs: 0
|
||||
});
|
||||
this.storyEnvironmentObjects.push(object);
|
||||
}
|
||||
|
||||
for (let index = 0; index < storyEnvironmentProfileVerification.maxParticleCount; index += 1) {
|
||||
const object = this.add.graphics();
|
||||
object.setScrollFactor(0);
|
||||
object.setDepth(storyEnvironmentDepths.particles);
|
||||
object.setVisible(false);
|
||||
object.setName(`story-environment-particle-${index + 1}`);
|
||||
this.storyEnvironmentParticles.push({
|
||||
object,
|
||||
velocityX: 0,
|
||||
velocityY: 0,
|
||||
swayAmplitude: 0,
|
||||
swayPhase: 0,
|
||||
angularVelocity: 0,
|
||||
ageMs: 0
|
||||
});
|
||||
this.storyEnvironmentObjects.push(object);
|
||||
}
|
||||
}
|
||||
|
||||
private applyStoryEnvironment(
|
||||
profile: StoryEnvironmentProfile | undefined,
|
||||
backgroundKey: string,
|
||||
pageId: string
|
||||
) {
|
||||
this.storyEnvironmentBackgroundKey = backgroundKey;
|
||||
if (profile?.id === this.storyEnvironmentProfile?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.storyEnvironmentRevision += 1;
|
||||
const revision = this.storyEnvironmentRevision;
|
||||
this.storyEnvironmentProfile = profile;
|
||||
if (!profile) {
|
||||
this.hideStoryEnvironmentPool(revision);
|
||||
return;
|
||||
}
|
||||
|
||||
this.ensureStoryEnvironmentPool();
|
||||
this.storyEnvironmentRandomState = this.storyEnvironmentSeed(`${profile.id}:${pageId}`);
|
||||
this.storyEnvironmentObjects.forEach((object) => this.tweens.killTweensOf(object));
|
||||
|
||||
if (this.storyEnvironmentTint) {
|
||||
this.storyEnvironmentTint.setFillStyle(profile.tint.color, profile.tint.alpha);
|
||||
this.storyEnvironmentTint.setAlpha(0);
|
||||
this.storyEnvironmentTint.setVisible(true);
|
||||
this.tweens.add({
|
||||
targets: this.storyEnvironmentTint,
|
||||
alpha: 1,
|
||||
duration: storyEnvironmentTransitionMs,
|
||||
ease: 'Sine.easeOut'
|
||||
});
|
||||
}
|
||||
|
||||
const hazeCount = profile.haze
|
||||
? this.visualMotionReduced
|
||||
? Math.min(1, profile.haze.count)
|
||||
: profile.haze.count
|
||||
: 0;
|
||||
this.storyEnvironmentHaze.forEach((view, index) => {
|
||||
if (!profile.haze || index >= hazeCount) {
|
||||
view.profile = undefined;
|
||||
view.object.setVisible(false);
|
||||
return;
|
||||
}
|
||||
this.configureStoryEnvironmentHaze(view, profile.haze);
|
||||
view.object.setAlpha(0);
|
||||
view.object.setVisible(true);
|
||||
this.tweens.add({
|
||||
targets: view.object,
|
||||
alpha: 1,
|
||||
duration: storyEnvironmentTransitionMs,
|
||||
ease: 'Sine.easeOut'
|
||||
});
|
||||
});
|
||||
|
||||
const particleCount = this.visualMotionReduced ? 0 : profile.particles?.count ?? 0;
|
||||
this.storyEnvironmentParticles.forEach((view, index) => {
|
||||
if (!profile.particles || index >= particleCount) {
|
||||
view.profile = undefined;
|
||||
view.object.setVisible(false);
|
||||
return;
|
||||
}
|
||||
view.profile = profile.particles;
|
||||
this.resetStoryEnvironmentParticle(view, true);
|
||||
view.object.setAlpha(0);
|
||||
view.object.setVisible(true);
|
||||
this.tweens.add({
|
||||
targets: view.object,
|
||||
alpha: 1,
|
||||
duration: storyEnvironmentTransitionMs,
|
||||
ease: 'Sine.easeOut'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private hideStoryEnvironmentPool(revision: number) {
|
||||
const visibleObjects = this.storyEnvironmentObjects.filter((object) => object.active && object.visible);
|
||||
if (visibleObjects.length === 0) {
|
||||
return;
|
||||
}
|
||||
visibleObjects.forEach((object) => this.tweens.killTweensOf(object));
|
||||
this.tweens.add({
|
||||
targets: visibleObjects,
|
||||
alpha: 0,
|
||||
duration: storyEnvironmentTransitionMs,
|
||||
ease: 'Sine.easeIn',
|
||||
onComplete: () => {
|
||||
if (revision !== this.storyEnvironmentRevision || this.storyEnvironmentProfile) {
|
||||
return;
|
||||
}
|
||||
visibleObjects.forEach((object) => object.active && object.setVisible(false));
|
||||
this.storyEnvironmentParticles.forEach((view) => {
|
||||
view.profile = undefined;
|
||||
});
|
||||
this.storyEnvironmentHaze.forEach((view) => {
|
||||
view.profile = undefined;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private configureStoryEnvironmentHaze(view: StoryEnvironmentHazeView, profile: StoryEnvironmentHazeProfile) {
|
||||
const { width, height } = this.scale;
|
||||
const hazeWidth = width * this.storyEnvironmentRange(profile.widthRatio);
|
||||
const hazeHeight = height * this.storyEnvironmentRange(profile.heightRatio);
|
||||
const alpha = this.storyEnvironmentRange(profile.alpha);
|
||||
view.object.clear();
|
||||
[
|
||||
{ scale: 1, alpha: 0.46 },
|
||||
{ scale: 0.76, alpha: 0.68 },
|
||||
{ scale: 0.54, alpha: 1 }
|
||||
].forEach((layer) => {
|
||||
view.object.fillStyle(profile.color, alpha * layer.alpha);
|
||||
view.object.fillEllipse(0, 0, hazeWidth * layer.scale, hazeHeight * layer.scale);
|
||||
});
|
||||
view.profile = profile;
|
||||
view.width = hazeWidth;
|
||||
view.baseY = this.storyEnvironmentBetween(height * 0.18, height * 0.72);
|
||||
view.velocityX = this.storyEnvironmentRange(profile.velocityX);
|
||||
view.waveAmplitude = this.storyEnvironmentRange(profile.waveAmplitude);
|
||||
view.wavePhase = this.storyEnvironmentBetween(0, Math.PI * 2);
|
||||
view.ageMs = this.storyEnvironmentBetween(0, 8000);
|
||||
view.object.setPosition(
|
||||
this.storyEnvironmentBetween(-hazeWidth * 0.12, width + hazeWidth * 0.12),
|
||||
view.baseY
|
||||
);
|
||||
}
|
||||
|
||||
private resetStoryEnvironmentParticle(view: StoryEnvironmentParticleView, initial: boolean) {
|
||||
const profile = view.profile;
|
||||
if (!profile) {
|
||||
return;
|
||||
}
|
||||
const { width, height } = this.scale;
|
||||
const size = this.storyEnvironmentRange(profile.size);
|
||||
const alpha = this.storyEnvironmentRange(profile.alpha);
|
||||
const color = profile.colors[
|
||||
Math.floor(this.storyEnvironmentRandom() * profile.colors.length)
|
||||
] ?? 0xffffff;
|
||||
view.velocityX = this.storyEnvironmentRange(profile.velocityX);
|
||||
view.velocityY = this.storyEnvironmentRange(profile.velocityY);
|
||||
view.swayAmplitude = this.storyEnvironmentRange(profile.swayAmplitude);
|
||||
view.swayPhase = this.storyEnvironmentBetween(0, Math.PI * 2);
|
||||
view.angularVelocity = this.storyEnvironmentRange(profile.angularVelocity);
|
||||
view.ageMs = this.storyEnvironmentBetween(0, 5000);
|
||||
view.object.clear();
|
||||
view.object.setBlendMode(profile.kind === 'ember' ? Phaser.BlendModes.ADD : Phaser.BlendModes.NORMAL);
|
||||
|
||||
if (profile.kind === 'rain') {
|
||||
view.object.fillStyle(color, alpha);
|
||||
view.object.fillRect(-Math.max(0.8, size * 0.04), -size / 2, Math.max(1.2, size * 0.08), size);
|
||||
view.object.setRotation(Math.atan2(view.velocityY, view.velocityX) - Math.PI / 2);
|
||||
} else if (profile.kind === 'ember') {
|
||||
view.object.fillStyle(color, alpha);
|
||||
view.object.fillEllipse(0, 0, size, size * 1.45);
|
||||
view.object.setRotation(this.storyEnvironmentBetween(-0.45, 0.45));
|
||||
} else {
|
||||
view.object.fillStyle(color, alpha);
|
||||
view.object.fillEllipse(0, 0, size, Math.max(2, size * 0.72));
|
||||
view.object.setRotation(this.storyEnvironmentBetween(-Math.PI, Math.PI));
|
||||
}
|
||||
|
||||
if (initial) {
|
||||
view.object.setPosition(
|
||||
this.storyEnvironmentBetween(0, width),
|
||||
this.storyEnvironmentBetween(0, height)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (profile.kind === 'ember') {
|
||||
view.object.setPosition(this.storyEnvironmentBetween(-12, width + 12), height + 18);
|
||||
return;
|
||||
}
|
||||
view.object.setPosition(this.storyEnvironmentBetween(-24, width + 24), -24);
|
||||
}
|
||||
|
||||
private updateStoryEnvironment(delta: number) {
|
||||
if (this.visualMotionReduced || !this.storyEnvironmentProfile) {
|
||||
return;
|
||||
}
|
||||
const elapsedMs = Phaser.Math.Clamp(delta, 0, 50);
|
||||
const elapsedSeconds = elapsedMs / 1000;
|
||||
const { width, height } = this.scale;
|
||||
|
||||
this.storyEnvironmentParticles.forEach((view) => {
|
||||
if (!view.profile || !view.object.visible) {
|
||||
return;
|
||||
}
|
||||
view.ageMs += elapsedMs;
|
||||
const sway = Math.sin(view.ageMs / (view.profile.kind === 'snow' ? 420 : 560) + view.swayPhase) * view.swayAmplitude;
|
||||
view.object.x += (view.velocityX + sway) * elapsedSeconds;
|
||||
view.object.y += view.velocityY * elapsedSeconds;
|
||||
view.object.rotation += view.angularVelocity * elapsedSeconds;
|
||||
if (
|
||||
view.object.x < -48 ||
|
||||
view.object.x > width + 48 ||
|
||||
view.object.y < -48 ||
|
||||
view.object.y > height + 48
|
||||
) {
|
||||
this.resetStoryEnvironmentParticle(view, false);
|
||||
}
|
||||
});
|
||||
|
||||
this.storyEnvironmentHaze.forEach((view) => {
|
||||
if (!view.profile || !view.object.visible) {
|
||||
return;
|
||||
}
|
||||
view.ageMs += elapsedMs;
|
||||
view.object.x += view.velocityX * elapsedSeconds;
|
||||
view.object.y = view.baseY + Math.sin(view.ageMs / 2600 + view.wavePhase) * view.waveAmplitude;
|
||||
const beyondRight = view.velocityX >= 0 && view.object.x - view.width / 2 > width + 24;
|
||||
const beyondLeft = view.velocityX < 0 && view.object.x + view.width / 2 < -24;
|
||||
if (beyondRight || beyondLeft) {
|
||||
view.object.x = view.velocityX >= 0 ? -view.width / 2 : width + view.width / 2;
|
||||
view.baseY = this.storyEnvironmentBetween(height * 0.18, height * 0.72);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private clearStoryEnvironment() {
|
||||
this.storyEnvironmentRevision += 1;
|
||||
this.storyEnvironmentObjects.forEach((object) => {
|
||||
this.tweens.killTweensOf(object);
|
||||
if (object.active) {
|
||||
object.destroy();
|
||||
}
|
||||
});
|
||||
this.storyEnvironmentObjects = [];
|
||||
this.storyEnvironmentParticles = [];
|
||||
this.storyEnvironmentHaze = [];
|
||||
this.storyEnvironmentTint = undefined;
|
||||
this.storyEnvironmentProfile = undefined;
|
||||
this.storyEnvironmentBackgroundKey = undefined;
|
||||
}
|
||||
|
||||
private storyEnvironmentSeed(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 storyEnvironmentRandom() {
|
||||
this.storyEnvironmentRandomState = (
|
||||
Math.imul(this.storyEnvironmentRandomState, 1664525) + 1013904223
|
||||
) >>> 0;
|
||||
return this.storyEnvironmentRandomState / 0x100000000;
|
||||
}
|
||||
|
||||
private storyEnvironmentBetween(minimum: number, maximum: number) {
|
||||
return minimum + (maximum - minimum) * this.storyEnvironmentRandom();
|
||||
}
|
||||
|
||||
private storyEnvironmentRange(range: readonly [number, number]) {
|
||||
return this.storyEnvironmentBetween(range[0], range[1]);
|
||||
}
|
||||
|
||||
private storyEnvironmentDebugState() {
|
||||
const profile = this.storyEnvironmentProfile;
|
||||
const activeParticles = this.storyEnvironmentParticles.filter((view) => view.profile && view.object.active && view.object.visible);
|
||||
const activeHaze = this.storyEnvironmentHaze.filter((view) => view.profile && view.object.active && view.object.visible);
|
||||
const tintVisible = Boolean(this.storyEnvironmentTint?.active && this.storyEnvironmentTint.visible);
|
||||
return {
|
||||
profileId: profile?.id ?? null,
|
||||
label: profile?.label ?? null,
|
||||
effect: profile?.effect ?? null,
|
||||
backgroundKey: this.storyEnvironmentBackgroundKey ?? null,
|
||||
ambienceKey: profile?.ambienceKey ?? null,
|
||||
ambienceVolume: profile?.ambienceVolume ?? null,
|
||||
reducedMotion: this.visualMotionReduced,
|
||||
tintVisible,
|
||||
particleCount: activeParticles.length,
|
||||
hazeCount: activeHaze.length,
|
||||
objectCount: activeParticles.length + activeHaze.length + (tintVisible ? 1 : 0),
|
||||
allocatedObjectCount: this.storyEnvironmentObjects.filter((object) => object.active).length,
|
||||
fixedPool: true,
|
||||
poolCapacity: storyEnvironmentProfileVerification.maxObjectCount,
|
||||
depths: { ...storyEnvironmentDepths },
|
||||
transitionRevision: this.storyEnvironmentRevision,
|
||||
verification: storyEnvironmentProfileVerification
|
||||
};
|
||||
}
|
||||
|
||||
private renderCutscene(page: StoryPage) {
|
||||
this.cutsceneLayer?.list.forEach((child) => this.tweens.killTweensOf(child));
|
||||
this.cutsceneLayer?.destroy();
|
||||
@@ -1656,6 +2065,7 @@ export class StoryScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
this.pageIndex = targetPageIndex;
|
||||
soundDirector.playStoryAdvanceCue();
|
||||
this.showPage(targetPageIndex);
|
||||
},
|
||||
true
|
||||
|
||||
@@ -13,6 +13,12 @@ import {
|
||||
saveAudioPreferences,
|
||||
updateAudioPreference
|
||||
} from '../settings/audioPreferences';
|
||||
import {
|
||||
loadVisualMotionMode,
|
||||
nextVisualMotionMode,
|
||||
saveVisualMotionMode,
|
||||
visualMotionModeLabel
|
||||
} from '../settings/visualMotion';
|
||||
import { battleIdForCampaignStep, isCampCampaignStep } from '../state/campaignRouting';
|
||||
import { readCampaignBattleResume, type CampaignBattleResume } from '../state/battleSaveStorage';
|
||||
import {
|
||||
@@ -454,11 +460,11 @@ export class TitleScene extends Phaser.Scene {
|
||||
this.closeSaveSlotPanel();
|
||||
|
||||
const panel = this.add.container(x, y + ui(126));
|
||||
const backdrop = this.add.rectangle(0, 0, ui(320), ui(420), 0x0b1118, 1);
|
||||
const backdrop = this.add.rectangle(0, 0, ui(320), ui(468), 0x0b1118, 1);
|
||||
backdrop.setStrokeStyle(ui(1), palette.gold, 0.52);
|
||||
backdrop.setInteractive();
|
||||
|
||||
const title = this.add.text(0, -ui(150), '설정', {
|
||||
const title = this.add.text(0, -ui(174), '설정', {
|
||||
fontSize: uiPx(24),
|
||||
color: '#f1e3c2',
|
||||
fontStyle: '700',
|
||||
@@ -467,7 +473,7 @@ export class TitleScene extends Phaser.Scene {
|
||||
});
|
||||
title.setOrigin(0.5);
|
||||
|
||||
const soundText = this.createSettingsButton(0, -ui(102), this.soundLabel());
|
||||
const soundText = this.createSettingsButton(0, -ui(126), this.soundLabel());
|
||||
soundText.on('pointerdown', () => {
|
||||
const preferences = loadAudioPreferences();
|
||||
const muted = !soundDirector.isMuted();
|
||||
@@ -477,16 +483,16 @@ export class TitleScene extends Phaser.Scene {
|
||||
soundDirector.playSelect();
|
||||
});
|
||||
|
||||
const musicText = this.createSettingsButton(0, -ui(60), this.audioCategoryLabel('music'));
|
||||
const musicText = this.createSettingsButton(0, -ui(84), this.audioCategoryLabel('music'));
|
||||
musicText.on('pointerdown', () => this.cycleAudioLevel('music', musicText));
|
||||
|
||||
const effectsText = this.createSettingsButton(0, -ui(18), this.audioCategoryLabel('effects'));
|
||||
const effectsText = this.createSettingsButton(0, -ui(42), this.audioCategoryLabel('effects'));
|
||||
effectsText.on('pointerdown', () => this.cycleAudioLevel('effects', effectsText));
|
||||
|
||||
const ambienceText = this.createSettingsButton(0, ui(24), this.audioCategoryLabel('ambience'));
|
||||
const ambienceText = this.createSettingsButton(0, 0, this.audioCategoryLabel('ambience'));
|
||||
ambienceText.on('pointerdown', () => this.cycleAudioLevel('ambience', ambienceText));
|
||||
|
||||
const presentationText = this.createSettingsButton(0, ui(66), this.combatPresentationLabel());
|
||||
const presentationText = this.createSettingsButton(0, ui(42), this.combatPresentationLabel());
|
||||
presentationText.on('pointerdown', () => {
|
||||
const nextMode = nextCombatPresentationMode(loadCombatPresentationMode());
|
||||
saveCombatPresentationMode(nextMode);
|
||||
@@ -494,13 +500,21 @@ export class TitleScene extends Phaser.Scene {
|
||||
soundDirector.playSelect();
|
||||
});
|
||||
|
||||
const close = this.createSettingsButton(0, ui(110), '닫기');
|
||||
const motionText = this.createSettingsButton(0, ui(84), this.visualMotionLabel());
|
||||
motionText.on('pointerdown', () => {
|
||||
const nextMode = nextVisualMotionMode(loadVisualMotionMode());
|
||||
saveVisualMotionMode(nextMode);
|
||||
motionText.setText(this.visualMotionLabel());
|
||||
soundDirector.playSelect();
|
||||
});
|
||||
|
||||
const close = this.createSettingsButton(0, ui(132), '닫기');
|
||||
close.on('pointerdown', () => {
|
||||
soundDirector.playSelect();
|
||||
this.closeSettingsPanel();
|
||||
});
|
||||
|
||||
const hint = this.add.text(0, ui(150), '항목을 눌러 단계 변경 · Esc 닫기', {
|
||||
const hint = this.add.text(0, ui(178), '항목을 눌러 단계 변경 · Esc 닫기', {
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: uiPx(11),
|
||||
color: '#9fb0bf',
|
||||
@@ -510,7 +524,7 @@ export class TitleScene extends Phaser.Scene {
|
||||
});
|
||||
hint.setOrigin(0.5);
|
||||
|
||||
panel.add([backdrop, title, soundText, musicText, effectsText, ambienceText, presentationText, close, hint]);
|
||||
panel.add([backdrop, title, soundText, musicText, effectsText, ambienceText, presentationText, motionText, close, hint]);
|
||||
panel.setAlpha(0);
|
||||
this.settingsPanel = panel;
|
||||
this.tweens.add({ targets: panel, alpha: 1, y: y + ui(116), duration: 160, ease: 'Sine.easeOut' });
|
||||
@@ -559,6 +573,10 @@ export class TitleScene extends Phaser.Scene {
|
||||
return `전투 연출: ${combatPresentationModeLabel(loadCombatPresentationMode())}`;
|
||||
}
|
||||
|
||||
private visualMotionLabel() {
|
||||
return `화면 움직임: ${visualMotionModeLabel(loadVisualMotionMode())}`;
|
||||
}
|
||||
|
||||
private closeSettingsPanel() {
|
||||
this.settingsPanel?.destroy();
|
||||
this.settingsPanel = undefined;
|
||||
|
||||
100
src/game/settings/visualMotion.ts
Normal file
100
src/game/settings/visualMotion.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
export const visualMotionStorageKey = 'heros-web:visual-motion-mode';
|
||||
|
||||
export const visualMotionModes = ['full', 'reduced'] as const;
|
||||
|
||||
export type VisualMotionMode = (typeof visualMotionModes)[number];
|
||||
|
||||
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>;
|
||||
type MatchMediaLike = (query: string) => Pick<MediaQueryList, 'matches'>;
|
||||
|
||||
export const visualMotionModeLabels: Record<VisualMotionMode, string> = {
|
||||
full: '보통',
|
||||
reduced: '줄임'
|
||||
};
|
||||
|
||||
export function isVisualMotionMode(value: unknown): value is VisualMotionMode {
|
||||
return typeof value === 'string' && visualMotionModes.includes(value as VisualMotionMode);
|
||||
}
|
||||
|
||||
export function preferredVisualMotionMode(matchMedia = resolveMatchMedia()): VisualMotionMode {
|
||||
if (!matchMedia) {
|
||||
return 'full';
|
||||
}
|
||||
|
||||
try {
|
||||
return matchMedia('(prefers-reduced-motion: reduce)').matches ? 'reduced' : 'full';
|
||||
} catch {
|
||||
return 'full';
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeVisualMotionMode(
|
||||
value: unknown,
|
||||
fallback: VisualMotionMode = 'full'
|
||||
): VisualMotionMode {
|
||||
return isVisualMotionMode(value) ? value : fallback;
|
||||
}
|
||||
|
||||
export function loadVisualMotionMode(
|
||||
storage = resolveStorage(),
|
||||
matchMedia = resolveMatchMedia()
|
||||
): VisualMotionMode {
|
||||
const fallback = preferredVisualMotionMode(matchMedia);
|
||||
if (!storage) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeVisualMotionMode(storage.getItem(visualMotionStorageKey), fallback);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveVisualMotionMode(mode: VisualMotionMode, storage = resolveStorage()) {
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
storage.setItem(visualMotionStorageKey, normalizeVisualMotionMode(mode));
|
||||
} catch {
|
||||
// Local storage can be unavailable in private or restricted browser contexts.
|
||||
}
|
||||
}
|
||||
|
||||
export function nextVisualMotionMode(mode: VisualMotionMode): VisualMotionMode {
|
||||
const index = visualMotionModes.indexOf(mode);
|
||||
return visualMotionModes[(index + 1) % visualMotionModes.length];
|
||||
}
|
||||
|
||||
export function visualMotionModeLabel(mode: VisualMotionMode) {
|
||||
return visualMotionModeLabels[mode];
|
||||
}
|
||||
|
||||
export function isVisualMotionReduced(
|
||||
storage = resolveStorage(),
|
||||
matchMedia = resolveMatchMedia()
|
||||
) {
|
||||
return loadVisualMotionMode(storage, matchMedia) === 'reduced';
|
||||
}
|
||||
|
||||
function resolveStorage(): StorageLike | undefined {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMatchMedia(): MatchMediaLike | undefined {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return window.matchMedia.bind(window);
|
||||
}
|
||||
Reference in New Issue
Block a user