feat: add stable story atmosphere and motion controls

This commit is contained in:
2026-07-22 02:01:19 +09:00
parent fa26992b56
commit 9d767e864c
8 changed files with 1160 additions and 23 deletions

View File

@@ -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',

View 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'
);
}

View 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();
}

View 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;

View File

@@ -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,

View File

@@ -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

View File

@@ -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;

View 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);
}