diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index f78b0da..952b28e 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -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', diff --git a/scripts/verify-story-environment-profiles.mjs b/scripts/verify-story-environment-profiles.mjs new file mode 100644 index 0000000..360ff21 --- /dev/null +++ b/scripts/verify-story-environment-profiles.mjs @@ -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' + ); +} diff --git a/scripts/verify-visual-motion-settings.mjs b/scripts/verify-visual-motion-settings.mjs new file mode 100644 index 0000000..bc93481 --- /dev/null +++ b/scripts/verify-visual-motion-settings.mjs @@ -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(); +} diff --git a/src/game/data/storyEnvironmentProfiles.ts b/src/game/data/storyEnvironmentProfiles.ts new file mode 100644 index 0000000..90f8814 --- /dev/null +++ b/src/game/data/storyEnvironmentProfiles.ts @@ -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; + +// 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> = { + '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> = { + '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; diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 19bef2d..a82c0ae 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -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, diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index c2e465d..b921892 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -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(); private ownedStoryTextureKeys = new Set(); 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 diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index fa4207b..be43cfe 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -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; diff --git a/src/game/settings/visualMotion.ts b/src/game/settings/visualMotion.ts new file mode 100644 index 0000000..0559596 --- /dev/null +++ b/src/game/settings/visualMotion.ts @@ -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; +type MatchMediaLike = (query: string) => Pick; + +export const visualMotionModeLabels: Record = { + 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); +}