feat: add stable story atmosphere and motion controls
This commit is contained in:
256
src/game/data/storyEnvironmentProfiles.ts
Normal file
256
src/game/data/storyEnvironmentProfiles.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import type { AmbienceTrackKey } from '../audio/audioAssets';
|
||||
|
||||
export type StoryEnvironmentParticleKind = 'ember' | 'rain' | 'snow';
|
||||
|
||||
export type StoryEnvironmentParticleProfile = Readonly<{
|
||||
kind: StoryEnvironmentParticleKind;
|
||||
count: number;
|
||||
colors: readonly number[];
|
||||
alpha: readonly [number, number];
|
||||
size: readonly [number, number];
|
||||
velocityX: readonly [number, number];
|
||||
velocityY: readonly [number, number];
|
||||
swayAmplitude: readonly [number, number];
|
||||
angularVelocity: readonly [number, number];
|
||||
}>;
|
||||
|
||||
export type StoryEnvironmentHazeProfile = Readonly<{
|
||||
count: number;
|
||||
color: number;
|
||||
alpha: readonly [number, number];
|
||||
widthRatio: readonly [number, number];
|
||||
heightRatio: readonly [number, number];
|
||||
velocityX: readonly [number, number];
|
||||
waveAmplitude: readonly [number, number];
|
||||
}>;
|
||||
|
||||
export type StoryEnvironmentProfile = Readonly<{
|
||||
id: string;
|
||||
label: string;
|
||||
effect: 'embers' | 'rain' | 'snow' | 'mist';
|
||||
ambienceKey: AmbienceTrackKey;
|
||||
ambienceVolume: number;
|
||||
tint: Readonly<{ color: number; alpha: number }>;
|
||||
haze?: StoryEnvironmentHazeProfile;
|
||||
particles?: StoryEnvironmentParticleProfile;
|
||||
}>;
|
||||
|
||||
export const storyEnvironmentDepths = {
|
||||
tint: 0.15,
|
||||
haze: 0.3,
|
||||
particles: 0.5
|
||||
} as const;
|
||||
|
||||
export const storyEnvironmentProfiles = {
|
||||
fire: {
|
||||
id: 'story-fire-embers',
|
||||
label: '화염과 재',
|
||||
effect: 'embers',
|
||||
ambienceKey: 'battle-fire',
|
||||
ambienceVolume: 0.085,
|
||||
tint: { color: 0x7a2d18, alpha: 0.07 },
|
||||
haze: {
|
||||
count: 2,
|
||||
color: 0x34221c,
|
||||
alpha: [0.018, 0.038],
|
||||
widthRatio: [0.5, 0.72],
|
||||
heightRatio: [0.14, 0.24],
|
||||
velocityX: [-3.5, 4.5],
|
||||
waveAmplitude: [4, 10]
|
||||
},
|
||||
particles: {
|
||||
kind: 'ember',
|
||||
count: 18,
|
||||
colors: [0xffb14a, 0xff7b35, 0xffd47a],
|
||||
alpha: [0.18, 0.48],
|
||||
size: [3, 7],
|
||||
velocityX: [-8, 8],
|
||||
velocityY: [-58, -32],
|
||||
swayAmplitude: [4, 10],
|
||||
angularVelocity: [-0.55, 0.55]
|
||||
}
|
||||
},
|
||||
rain: {
|
||||
id: 'story-rain-mist',
|
||||
label: '장대비와 물안개',
|
||||
effect: 'rain',
|
||||
ambienceKey: 'battle-rain',
|
||||
ambienceVolume: 0.085,
|
||||
tint: { color: 0x183349, alpha: 0.07 },
|
||||
haze: {
|
||||
count: 2,
|
||||
color: 0x9fb5c5,
|
||||
alpha: [0.016, 0.034],
|
||||
widthRatio: [0.52, 0.76],
|
||||
heightRatio: [0.12, 0.2],
|
||||
velocityX: [-4, 3],
|
||||
waveAmplitude: [3, 8]
|
||||
},
|
||||
particles: {
|
||||
kind: 'rain',
|
||||
count: 24,
|
||||
colors: [0xc8def0, 0xa9c9df],
|
||||
alpha: [0.16, 0.42],
|
||||
size: [16, 24],
|
||||
velocityX: [-92, -58],
|
||||
velocityY: [330, 450],
|
||||
swayAmplitude: [0, 0],
|
||||
angularVelocity: [0, 0]
|
||||
}
|
||||
},
|
||||
snow: {
|
||||
id: 'story-snow-wind',
|
||||
label: '설원의 눈바람',
|
||||
effect: 'snow',
|
||||
ambienceKey: 'mountain-wind-ambience',
|
||||
ambienceVolume: 0.07,
|
||||
tint: { color: 0xb7cadc, alpha: 0.055 },
|
||||
haze: {
|
||||
count: 2,
|
||||
color: 0xc8d5df,
|
||||
alpha: [0.014, 0.03],
|
||||
widthRatio: [0.5, 0.74],
|
||||
heightRatio: [0.13, 0.21],
|
||||
velocityX: [-3, 5],
|
||||
waveAmplitude: [4, 9]
|
||||
},
|
||||
particles: {
|
||||
kind: 'snow',
|
||||
count: 22,
|
||||
colors: [0xffffff, 0xddeaf3, 0xcbdde9],
|
||||
alpha: [0.2, 0.5],
|
||||
size: [3, 8],
|
||||
velocityX: [-8, 10],
|
||||
velocityY: [32, 54],
|
||||
swayAmplitude: [12, 26],
|
||||
angularVelocity: [-0.45, 0.45]
|
||||
}
|
||||
},
|
||||
jungle: {
|
||||
id: 'story-jungle-mist',
|
||||
label: '남중의 숲안개',
|
||||
effect: 'mist',
|
||||
ambienceKey: 'nanzhong-night-ambience',
|
||||
ambienceVolume: 0.065,
|
||||
tint: { color: 0x173528, alpha: 0.045 },
|
||||
haze: {
|
||||
count: 2,
|
||||
color: 0x9fb5a5,
|
||||
alpha: [0.018, 0.038],
|
||||
widthRatio: [0.48, 0.7],
|
||||
heightRatio: [0.13, 0.22],
|
||||
velocityX: [-2.5, 3.5],
|
||||
waveAmplitude: [3, 8]
|
||||
}
|
||||
},
|
||||
river: {
|
||||
id: 'story-river-mist',
|
||||
label: '강가의 물안개',
|
||||
effect: 'mist',
|
||||
ambienceKey: 'river-night-ambience',
|
||||
ambienceVolume: 0.065,
|
||||
tint: { color: 0x1d3948, alpha: 0.045 },
|
||||
haze: {
|
||||
count: 2,
|
||||
color: 0xa7bec8,
|
||||
alpha: [0.016, 0.034],
|
||||
widthRatio: [0.5, 0.74],
|
||||
heightRatio: [0.11, 0.19],
|
||||
velocityX: [-3, 4],
|
||||
waveAmplitude: [3, 7]
|
||||
}
|
||||
},
|
||||
camp: {
|
||||
id: 'story-camp-night',
|
||||
label: '밤 군영의 연무',
|
||||
effect: 'mist',
|
||||
ambienceKey: 'campfire-ambience',
|
||||
ambienceVolume: 0.06,
|
||||
tint: { color: 0x5e351e, alpha: 0.04 },
|
||||
haze: {
|
||||
count: 2,
|
||||
color: 0x6c6258,
|
||||
alpha: [0.014, 0.03],
|
||||
widthRatio: [0.46, 0.68],
|
||||
heightRatio: [0.12, 0.2],
|
||||
velocityX: [-2.5, 3.5],
|
||||
waveAmplitude: [3, 8]
|
||||
}
|
||||
},
|
||||
mountain: {
|
||||
id: 'story-mountain-wind',
|
||||
label: '산성의 바람결',
|
||||
effect: 'mist',
|
||||
ambienceKey: 'mountain-wind-ambience',
|
||||
ambienceVolume: 0.065,
|
||||
tint: { color: 0x394550, alpha: 0.04 },
|
||||
haze: {
|
||||
count: 2,
|
||||
color: 0xb2bdc6,
|
||||
alpha: [0.012, 0.028],
|
||||
widthRatio: [0.48, 0.72],
|
||||
heightRatio: [0.11, 0.19],
|
||||
velocityX: [-5, 6],
|
||||
waveAmplitude: [4, 9]
|
||||
}
|
||||
}
|
||||
} as const satisfies Record<string, StoryEnvironmentProfile>;
|
||||
|
||||
// Story artwork rotates among several purpose-built variants inside one narrative
|
||||
// sequence. Bind atmosphere to the semantic StoryPage.background family instead
|
||||
// of those rotating image keys so ambience does not change on nearly every click.
|
||||
const storyEnvironmentProfileByBackgroundFamily: Readonly<Record<string, StoryEnvironmentProfile>> = {
|
||||
'story-yiling-fire-attack': storyEnvironmentProfiles.fire,
|
||||
|
||||
'story-fan-castle-flood': storyEnvironmentProfiles.rain,
|
||||
'story-hanzhong-rain': storyEnvironmentProfiles.rain,
|
||||
|
||||
'story-maicheng-isolation': storyEnvironmentProfiles.snow,
|
||||
|
||||
'story-nanzhong': storyEnvironmentProfiles.jungle,
|
||||
'story-nanzhong-captures': storyEnvironmentProfiles.jungle,
|
||||
|
||||
'story-weishui-northbank': storyEnvironmentProfiles.river,
|
||||
|
||||
'story-weishui-camps': storyEnvironmentProfiles.camp,
|
||||
|
||||
'story-chencang-siege': storyEnvironmentProfiles.mountain,
|
||||
'story-jieting-crisis': storyEnvironmentProfiles.mountain,
|
||||
'story-lucheng-pursuit': storyEnvironmentProfiles.mountain,
|
||||
'story-northern': storyEnvironmentProfiles.mountain,
|
||||
'story-qishan-renewed': storyEnvironmentProfiles.mountain,
|
||||
'story-wudu-yinping': storyEnvironmentProfiles.mountain,
|
||||
'story-yizhou-luo-road': storyEnvironmentProfiles.mountain
|
||||
};
|
||||
|
||||
// Some artwork families intentionally span several chapters. Keep exceptional
|
||||
// set pieces explicit so a broad family such as story-red-cliffs does not put
|
||||
// embers over Changban, diplomacy, and other non-fire scenes.
|
||||
const storyEnvironmentProfileByPageId: Readonly<Record<string, StoryEnvironmentProfile>> = {
|
||||
'twenty-second-huang-gai-bitter-plan': storyEnvironmentProfiles.fire,
|
||||
'twenty-second-zhuge-wind-watch': storyEnvironmentProfiles.fire,
|
||||
'twenty-second-liu-bei-fire-line': storyEnvironmentProfiles.fire,
|
||||
'twenty-second-fire-attack-sortie': storyEnvironmentProfiles.fire,
|
||||
'twenty-second-victory-fire-rises': storyEnvironmentProfiles.fire,
|
||||
'twenty-second-cao-cao-retreat': storyEnvironmentProfiles.fire,
|
||||
'twenty-second-liu-bei-next-land': storyEnvironmentProfiles.fire
|
||||
};
|
||||
|
||||
export function storyEnvironmentProfileFor(backgroundFamily: string, pageId?: string) {
|
||||
const pageProfile = pageId ? storyEnvironmentProfileByPageId[pageId.trim().toLowerCase()] : undefined;
|
||||
if (pageProfile) {
|
||||
return pageProfile;
|
||||
}
|
||||
return storyEnvironmentProfileByBackgroundFamily[backgroundFamily.trim().toLowerCase()];
|
||||
}
|
||||
|
||||
const profileList: StoryEnvironmentProfile[] = Object.values(storyEnvironmentProfiles);
|
||||
|
||||
export const storyEnvironmentProfileVerification = {
|
||||
profileCount: profileList.length,
|
||||
maxParticleCount: Math.max(...profileList.map((profile) => profile.particles?.count ?? 0)),
|
||||
maxHazeCount: Math.max(...profileList.map((profile) => profile.haze?.count ?? 0)),
|
||||
maxObjectCount: Math.max(
|
||||
...profileList.map((profile) => 1 + (profile.particles?.count ?? 0) + (profile.haze?.count ?? 0))
|
||||
)
|
||||
} as const;
|
||||
@@ -1,6 +1,7 @@
|
||||
import Phaser from 'phaser';
|
||||
import { soundDirector } from '../audio/SoundDirector';
|
||||
import { loadAudioPreferences, saveAudioPreferences } from '../settings/audioPreferences';
|
||||
import { isVisualMotionReduced } from '../settings/visualMotion';
|
||||
import { battleMapAssets } from '../data/battleMapAssets';
|
||||
import { firstBattleVictoryPages, type BattleBond, type UnitData, type UnitStats } from '../data/scenario';
|
||||
import {
|
||||
@@ -5313,10 +5314,11 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.battleEnvironmentObjects.push(tint);
|
||||
}
|
||||
|
||||
if (profile.haze) {
|
||||
const reducedMotion = isVisualMotionReduced();
|
||||
if (!reducedMotion && profile.haze) {
|
||||
this.createBattleEnvironmentHaze(profile.haze);
|
||||
}
|
||||
if (profile.particles) {
|
||||
if (!reducedMotion && profile.particles) {
|
||||
this.createBattleEnvironmentParticles(profile.particles);
|
||||
}
|
||||
}
|
||||
@@ -6418,9 +6420,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
roleMark
|
||||
]);
|
||||
|
||||
const reducedMotion = typeof window !== 'undefined' &&
|
||||
typeof window.matchMedia === 'function' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const reducedMotion = isVisualMotionReduced();
|
||||
const enterDuration = this.scaledBattleDuration(150, 100);
|
||||
const exitAt = this.scaledBattleDuration(700, 500);
|
||||
const totalDuration = this.scaledBattleDuration(880, 650);
|
||||
@@ -6612,6 +6612,13 @@ export class BattleScene extends Phaser.Scene {
|
||||
return Math.max(minDuration, Math.round(duration / this.battleSpeedFactor()));
|
||||
}
|
||||
|
||||
private shakeCamera(duration: number, intensity: number) {
|
||||
if (isVisualMotionReduced()) {
|
||||
return;
|
||||
}
|
||||
this.cameras.main.shake(duration, intensity);
|
||||
}
|
||||
|
||||
private drawDebugSpritePreview() {
|
||||
const textureKey = this.debugSpritePreviewKey();
|
||||
if (!textureKey) {
|
||||
@@ -19802,7 +19809,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.tweens.add({ targets: spark, scale: 1.35, angle: 120, alpha: 0, duration, ease: 'Sine.easeOut' });
|
||||
this.tweens.add({ targets: badge, y: badge.y - this.battleUiLength(10), alpha: 0, delay: Math.round(duration * 0.45), duration: Math.round(duration * 0.55) });
|
||||
if (result.critical || result.defeated) {
|
||||
this.cameras.main.shake(this.scaledBattleDuration(140, 80), result.defeated ? 0.008 : 0.005);
|
||||
this.shakeCamera(this.scaledBattleDuration(140, 80), result.defeated ? 0.008 : 0.005);
|
||||
}
|
||||
|
||||
this.showCompactGrowthMapResult(result.attacker, result.characterGrowth, result.attackerGrowth);
|
||||
@@ -21555,7 +21562,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
|
||||
if (result.hit) {
|
||||
void this.playUnitActionFrames(defenderSprite, result.defender, defenderDirection, 'hurt', 70);
|
||||
this.cameras.main.shake(
|
||||
this.shakeCamera(
|
||||
this.scaledBattleDuration(result.critical ? 130 : 90, 55),
|
||||
result.critical ? 0.006 : 0.0035
|
||||
);
|
||||
@@ -22418,7 +22425,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
});
|
||||
|
||||
if (chain.defeated) {
|
||||
this.cameras.main.shake(this.scaledBattleDuration(180, 110), 0.008);
|
||||
this.shakeCamera(this.scaledBattleDuration(180, 110), 0.008);
|
||||
const finishRing = this.trackCombatObject(this.add.circle(defenderX - 24, groundY - 48, 24));
|
||||
finishRing.setDepth(depth + 5);
|
||||
finishRing.setStrokeStyle(7, 0xffdf7b, 0.94);
|
||||
@@ -25158,9 +25165,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
objectCount: objects.length
|
||||
};
|
||||
|
||||
const reducedMotion = typeof window !== 'undefined' &&
|
||||
typeof window.matchMedia === 'function' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const reducedMotion = isVisualMotionReduced();
|
||||
if (reducedMotion) {
|
||||
this.sidePanelTransitionActive = false;
|
||||
this.sidePanelTransitionCompletedRevision = revision;
|
||||
@@ -27633,6 +27638,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
effect: profile?.effect ?? null,
|
||||
ambienceKey: profile?.soundscape.ambienceKey ?? null,
|
||||
ambienceVolume: profile?.soundscape.ambienceVolume ?? 0,
|
||||
reducedMotion: isVisualMotionReduced(),
|
||||
active: Boolean(profile && activeObjects.length > 0),
|
||||
particleCount: this.battleEnvironmentParticles.filter((view) => view.object.active).length,
|
||||
hazeCount: this.battleEnvironmentHaze.filter((view) => view.object.active).length,
|
||||
|
||||
@@ -18,6 +18,14 @@ import {
|
||||
storyBackgroundKeyForPage,
|
||||
storyBackgroundKeysForPages
|
||||
} from '../data/storyAssets';
|
||||
import {
|
||||
storyEnvironmentDepths,
|
||||
storyEnvironmentProfileFor,
|
||||
storyEnvironmentProfileVerification,
|
||||
type StoryEnvironmentHazeProfile,
|
||||
type StoryEnvironmentParticleProfile,
|
||||
type StoryEnvironmentProfile
|
||||
} from '../data/storyEnvironmentProfiles';
|
||||
import {
|
||||
ensureUnitAnimations,
|
||||
loadUnitBaseSheets,
|
||||
@@ -35,6 +43,7 @@ import {
|
||||
import { prologuePages, type PortraitKey, type StoryPage } from '../data/scenario';
|
||||
import { campaignVictoryRewardPresentation, getFirstBattleReport } from '../state/campaignState';
|
||||
import { releaseTextureSource } from '../render/loaderLifecycle';
|
||||
import { isVisualMotionReduced } from '../settings/visualMotion';
|
||||
import { palette } from '../ui/palette';
|
||||
import { ensureLazyScene, startGameScene } from './lazyScenes';
|
||||
|
||||
@@ -58,9 +67,34 @@ type StoryPageAssetPlan = {
|
||||
unitTextureKeys: string[];
|
||||
};
|
||||
|
||||
type StoryEnvironmentParticleView = {
|
||||
object: Phaser.GameObjects.Graphics;
|
||||
profile?: StoryEnvironmentParticleProfile;
|
||||
velocityX: number;
|
||||
velocityY: number;
|
||||
swayAmplitude: number;
|
||||
swayPhase: number;
|
||||
angularVelocity: number;
|
||||
ageMs: number;
|
||||
};
|
||||
|
||||
type StoryEnvironmentHazeView = {
|
||||
object: Phaser.GameObjects.Graphics;
|
||||
profile?: StoryEnvironmentHazeProfile;
|
||||
width: number;
|
||||
baseY: number;
|
||||
velocityX: number;
|
||||
waveAmplitude: number;
|
||||
wavePhase: number;
|
||||
ageMs: number;
|
||||
};
|
||||
|
||||
type StoryEnvironmentObject = Phaser.GameObjects.Graphics | Phaser.GameObjects.Rectangle;
|
||||
|
||||
const sceneShadeDepth = 1;
|
||||
const cutsceneDepth = 8;
|
||||
const dialogDepth = 20;
|
||||
const storyEnvironmentTransitionMs = 180;
|
||||
const fhdUiScale = 1.5;
|
||||
const ui = (value: number) => value * fhdUiScale;
|
||||
const uiPx = (value: number) => `${Math.round(ui(value))}px`;
|
||||
@@ -170,6 +204,15 @@ export class StoryScene extends Phaser.Scene {
|
||||
private storyAssetFailures = new Set<string>();
|
||||
private ownedStoryTextureKeys = new Set<string>();
|
||||
private rewardDisplay: StoryRewardDisplayModel = this.emptyRewardDisplay();
|
||||
private visualMotionReduced = false;
|
||||
private storyEnvironmentProfile?: StoryEnvironmentProfile;
|
||||
private storyEnvironmentBackgroundKey?: string;
|
||||
private storyEnvironmentTint?: Phaser.GameObjects.Rectangle;
|
||||
private storyEnvironmentObjects: StoryEnvironmentObject[] = [];
|
||||
private storyEnvironmentParticles: StoryEnvironmentParticleView[] = [];
|
||||
private storyEnvironmentHaze: StoryEnvironmentHazeView[] = [];
|
||||
private storyEnvironmentRandomState = 1;
|
||||
private storyEnvironmentRevision = 0;
|
||||
|
||||
constructor() {
|
||||
super('StoryScene');
|
||||
@@ -190,6 +233,15 @@ export class StoryScene extends Phaser.Scene {
|
||||
this.ownedStoryTextureKeys.clear();
|
||||
this.storyAssetLoadGeneration += 1;
|
||||
this.rewardDisplay = this.emptyRewardDisplay();
|
||||
this.visualMotionReduced = isVisualMotionReduced();
|
||||
this.storyEnvironmentProfile = undefined;
|
||||
this.storyEnvironmentBackgroundKey = undefined;
|
||||
this.storyEnvironmentTint = undefined;
|
||||
this.storyEnvironmentObjects = [];
|
||||
this.storyEnvironmentParticles = [];
|
||||
this.storyEnvironmentHaze = [];
|
||||
this.storyEnvironmentRandomState = 1;
|
||||
this.storyEnvironmentRevision = 0;
|
||||
}
|
||||
|
||||
getDebugState() {
|
||||
@@ -247,6 +299,7 @@ export class StoryScene extends Phaser.Scene {
|
||||
...this.rewardDisplay,
|
||||
items: this.rewardDisplay.items.map((item) => ({ ...item, icon: { ...item.icon } }))
|
||||
},
|
||||
environment: this.storyEnvironmentDebugState(),
|
||||
assetStreaming: {
|
||||
currentPageReady: this.readyStoryPageIndexes.has(this.pageIndex),
|
||||
nextPageIndex: this.pageIndex + 1 < this.pages.length ? this.pageIndex + 1 : null,
|
||||
@@ -288,6 +341,7 @@ export class StoryScene extends Phaser.Scene {
|
||||
this.activeStoryLoadErrorHandler = undefined;
|
||||
}
|
||||
this.pageLoadingText = undefined;
|
||||
this.clearStoryEnvironment();
|
||||
this.releaseOwnedStoryTextures();
|
||||
});
|
||||
this.warmFirstBattleSceneModule();
|
||||
@@ -715,8 +769,15 @@ export class StoryScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private applyPage(page: StoryPage) {
|
||||
soundDirector.playMusic(page.bgm);
|
||||
const backgroundKey = this.pageBackgroundKey(page);
|
||||
const environmentProfile = storyEnvironmentProfileFor(page.background, page.id);
|
||||
soundDirector.playSoundscape({
|
||||
musicKey: page.bgm,
|
||||
ambienceKey: environmentProfile?.ambienceKey,
|
||||
ambienceVolume: environmentProfile?.ambienceVolume
|
||||
});
|
||||
this.applyBackground(page);
|
||||
this.applyStoryEnvironment(environmentProfile, backgroundKey, page.id);
|
||||
this.renderCutscene(page);
|
||||
this.chapterText?.setText(page.chapter);
|
||||
|
||||
@@ -808,7 +869,13 @@ export class StoryScene extends Phaser.Scene {
|
||||
this.background.setAlpha(1);
|
||||
this.background.setDepth(0);
|
||||
this.background.setScale(coverScale * 1.07);
|
||||
this.background.setPosition(width / 2 - ui(16), height / 2);
|
||||
this.background.setPosition(width / 2, height / 2);
|
||||
|
||||
if (this.visualMotionReduced) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.background.setX(width / 2 - ui(16));
|
||||
|
||||
this.tweens.add({
|
||||
targets: this.background,
|
||||
@@ -822,6 +889,348 @@ export class StoryScene extends Phaser.Scene {
|
||||
});
|
||||
}
|
||||
|
||||
update(_time: number, delta: number) {
|
||||
this.updateStoryEnvironment(delta);
|
||||
}
|
||||
|
||||
private ensureStoryEnvironmentPool() {
|
||||
if (this.storyEnvironmentTint) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { width, height } = this.scale;
|
||||
const tint = this.add.rectangle(0, 0, width, height, 0xffffff, 0);
|
||||
tint.setOrigin(0);
|
||||
tint.setScrollFactor(0);
|
||||
tint.setDepth(storyEnvironmentDepths.tint);
|
||||
tint.setVisible(false);
|
||||
tint.setName('story-environment-tint');
|
||||
this.storyEnvironmentTint = tint;
|
||||
this.storyEnvironmentObjects.push(tint);
|
||||
|
||||
for (let index = 0; index < storyEnvironmentProfileVerification.maxHazeCount; index += 1) {
|
||||
const object = this.add.graphics();
|
||||
object.setScrollFactor(0);
|
||||
object.setDepth(storyEnvironmentDepths.haze);
|
||||
object.setVisible(false);
|
||||
object.setName(`story-environment-haze-${index + 1}`);
|
||||
this.storyEnvironmentHaze.push({
|
||||
object,
|
||||
width: 0,
|
||||
baseY: 0,
|
||||
velocityX: 0,
|
||||
waveAmplitude: 0,
|
||||
wavePhase: 0,
|
||||
ageMs: 0
|
||||
});
|
||||
this.storyEnvironmentObjects.push(object);
|
||||
}
|
||||
|
||||
for (let index = 0; index < storyEnvironmentProfileVerification.maxParticleCount; index += 1) {
|
||||
const object = this.add.graphics();
|
||||
object.setScrollFactor(0);
|
||||
object.setDepth(storyEnvironmentDepths.particles);
|
||||
object.setVisible(false);
|
||||
object.setName(`story-environment-particle-${index + 1}`);
|
||||
this.storyEnvironmentParticles.push({
|
||||
object,
|
||||
velocityX: 0,
|
||||
velocityY: 0,
|
||||
swayAmplitude: 0,
|
||||
swayPhase: 0,
|
||||
angularVelocity: 0,
|
||||
ageMs: 0
|
||||
});
|
||||
this.storyEnvironmentObjects.push(object);
|
||||
}
|
||||
}
|
||||
|
||||
private applyStoryEnvironment(
|
||||
profile: StoryEnvironmentProfile | undefined,
|
||||
backgroundKey: string,
|
||||
pageId: string
|
||||
) {
|
||||
this.storyEnvironmentBackgroundKey = backgroundKey;
|
||||
if (profile?.id === this.storyEnvironmentProfile?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.storyEnvironmentRevision += 1;
|
||||
const revision = this.storyEnvironmentRevision;
|
||||
this.storyEnvironmentProfile = profile;
|
||||
if (!profile) {
|
||||
this.hideStoryEnvironmentPool(revision);
|
||||
return;
|
||||
}
|
||||
|
||||
this.ensureStoryEnvironmentPool();
|
||||
this.storyEnvironmentRandomState = this.storyEnvironmentSeed(`${profile.id}:${pageId}`);
|
||||
this.storyEnvironmentObjects.forEach((object) => this.tweens.killTweensOf(object));
|
||||
|
||||
if (this.storyEnvironmentTint) {
|
||||
this.storyEnvironmentTint.setFillStyle(profile.tint.color, profile.tint.alpha);
|
||||
this.storyEnvironmentTint.setAlpha(0);
|
||||
this.storyEnvironmentTint.setVisible(true);
|
||||
this.tweens.add({
|
||||
targets: this.storyEnvironmentTint,
|
||||
alpha: 1,
|
||||
duration: storyEnvironmentTransitionMs,
|
||||
ease: 'Sine.easeOut'
|
||||
});
|
||||
}
|
||||
|
||||
const hazeCount = profile.haze
|
||||
? this.visualMotionReduced
|
||||
? Math.min(1, profile.haze.count)
|
||||
: profile.haze.count
|
||||
: 0;
|
||||
this.storyEnvironmentHaze.forEach((view, index) => {
|
||||
if (!profile.haze || index >= hazeCount) {
|
||||
view.profile = undefined;
|
||||
view.object.setVisible(false);
|
||||
return;
|
||||
}
|
||||
this.configureStoryEnvironmentHaze(view, profile.haze);
|
||||
view.object.setAlpha(0);
|
||||
view.object.setVisible(true);
|
||||
this.tweens.add({
|
||||
targets: view.object,
|
||||
alpha: 1,
|
||||
duration: storyEnvironmentTransitionMs,
|
||||
ease: 'Sine.easeOut'
|
||||
});
|
||||
});
|
||||
|
||||
const particleCount = this.visualMotionReduced ? 0 : profile.particles?.count ?? 0;
|
||||
this.storyEnvironmentParticles.forEach((view, index) => {
|
||||
if (!profile.particles || index >= particleCount) {
|
||||
view.profile = undefined;
|
||||
view.object.setVisible(false);
|
||||
return;
|
||||
}
|
||||
view.profile = profile.particles;
|
||||
this.resetStoryEnvironmentParticle(view, true);
|
||||
view.object.setAlpha(0);
|
||||
view.object.setVisible(true);
|
||||
this.tweens.add({
|
||||
targets: view.object,
|
||||
alpha: 1,
|
||||
duration: storyEnvironmentTransitionMs,
|
||||
ease: 'Sine.easeOut'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private hideStoryEnvironmentPool(revision: number) {
|
||||
const visibleObjects = this.storyEnvironmentObjects.filter((object) => object.active && object.visible);
|
||||
if (visibleObjects.length === 0) {
|
||||
return;
|
||||
}
|
||||
visibleObjects.forEach((object) => this.tweens.killTweensOf(object));
|
||||
this.tweens.add({
|
||||
targets: visibleObjects,
|
||||
alpha: 0,
|
||||
duration: storyEnvironmentTransitionMs,
|
||||
ease: 'Sine.easeIn',
|
||||
onComplete: () => {
|
||||
if (revision !== this.storyEnvironmentRevision || this.storyEnvironmentProfile) {
|
||||
return;
|
||||
}
|
||||
visibleObjects.forEach((object) => object.active && object.setVisible(false));
|
||||
this.storyEnvironmentParticles.forEach((view) => {
|
||||
view.profile = undefined;
|
||||
});
|
||||
this.storyEnvironmentHaze.forEach((view) => {
|
||||
view.profile = undefined;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private configureStoryEnvironmentHaze(view: StoryEnvironmentHazeView, profile: StoryEnvironmentHazeProfile) {
|
||||
const { width, height } = this.scale;
|
||||
const hazeWidth = width * this.storyEnvironmentRange(profile.widthRatio);
|
||||
const hazeHeight = height * this.storyEnvironmentRange(profile.heightRatio);
|
||||
const alpha = this.storyEnvironmentRange(profile.alpha);
|
||||
view.object.clear();
|
||||
[
|
||||
{ scale: 1, alpha: 0.46 },
|
||||
{ scale: 0.76, alpha: 0.68 },
|
||||
{ scale: 0.54, alpha: 1 }
|
||||
].forEach((layer) => {
|
||||
view.object.fillStyle(profile.color, alpha * layer.alpha);
|
||||
view.object.fillEllipse(0, 0, hazeWidth * layer.scale, hazeHeight * layer.scale);
|
||||
});
|
||||
view.profile = profile;
|
||||
view.width = hazeWidth;
|
||||
view.baseY = this.storyEnvironmentBetween(height * 0.18, height * 0.72);
|
||||
view.velocityX = this.storyEnvironmentRange(profile.velocityX);
|
||||
view.waveAmplitude = this.storyEnvironmentRange(profile.waveAmplitude);
|
||||
view.wavePhase = this.storyEnvironmentBetween(0, Math.PI * 2);
|
||||
view.ageMs = this.storyEnvironmentBetween(0, 8000);
|
||||
view.object.setPosition(
|
||||
this.storyEnvironmentBetween(-hazeWidth * 0.12, width + hazeWidth * 0.12),
|
||||
view.baseY
|
||||
);
|
||||
}
|
||||
|
||||
private resetStoryEnvironmentParticle(view: StoryEnvironmentParticleView, initial: boolean) {
|
||||
const profile = view.profile;
|
||||
if (!profile) {
|
||||
return;
|
||||
}
|
||||
const { width, height } = this.scale;
|
||||
const size = this.storyEnvironmentRange(profile.size);
|
||||
const alpha = this.storyEnvironmentRange(profile.alpha);
|
||||
const color = profile.colors[
|
||||
Math.floor(this.storyEnvironmentRandom() * profile.colors.length)
|
||||
] ?? 0xffffff;
|
||||
view.velocityX = this.storyEnvironmentRange(profile.velocityX);
|
||||
view.velocityY = this.storyEnvironmentRange(profile.velocityY);
|
||||
view.swayAmplitude = this.storyEnvironmentRange(profile.swayAmplitude);
|
||||
view.swayPhase = this.storyEnvironmentBetween(0, Math.PI * 2);
|
||||
view.angularVelocity = this.storyEnvironmentRange(profile.angularVelocity);
|
||||
view.ageMs = this.storyEnvironmentBetween(0, 5000);
|
||||
view.object.clear();
|
||||
view.object.setBlendMode(profile.kind === 'ember' ? Phaser.BlendModes.ADD : Phaser.BlendModes.NORMAL);
|
||||
|
||||
if (profile.kind === 'rain') {
|
||||
view.object.fillStyle(color, alpha);
|
||||
view.object.fillRect(-Math.max(0.8, size * 0.04), -size / 2, Math.max(1.2, size * 0.08), size);
|
||||
view.object.setRotation(Math.atan2(view.velocityY, view.velocityX) - Math.PI / 2);
|
||||
} else if (profile.kind === 'ember') {
|
||||
view.object.fillStyle(color, alpha);
|
||||
view.object.fillEllipse(0, 0, size, size * 1.45);
|
||||
view.object.setRotation(this.storyEnvironmentBetween(-0.45, 0.45));
|
||||
} else {
|
||||
view.object.fillStyle(color, alpha);
|
||||
view.object.fillEllipse(0, 0, size, Math.max(2, size * 0.72));
|
||||
view.object.setRotation(this.storyEnvironmentBetween(-Math.PI, Math.PI));
|
||||
}
|
||||
|
||||
if (initial) {
|
||||
view.object.setPosition(
|
||||
this.storyEnvironmentBetween(0, width),
|
||||
this.storyEnvironmentBetween(0, height)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (profile.kind === 'ember') {
|
||||
view.object.setPosition(this.storyEnvironmentBetween(-12, width + 12), height + 18);
|
||||
return;
|
||||
}
|
||||
view.object.setPosition(this.storyEnvironmentBetween(-24, width + 24), -24);
|
||||
}
|
||||
|
||||
private updateStoryEnvironment(delta: number) {
|
||||
if (this.visualMotionReduced || !this.storyEnvironmentProfile) {
|
||||
return;
|
||||
}
|
||||
const elapsedMs = Phaser.Math.Clamp(delta, 0, 50);
|
||||
const elapsedSeconds = elapsedMs / 1000;
|
||||
const { width, height } = this.scale;
|
||||
|
||||
this.storyEnvironmentParticles.forEach((view) => {
|
||||
if (!view.profile || !view.object.visible) {
|
||||
return;
|
||||
}
|
||||
view.ageMs += elapsedMs;
|
||||
const sway = Math.sin(view.ageMs / (view.profile.kind === 'snow' ? 420 : 560) + view.swayPhase) * view.swayAmplitude;
|
||||
view.object.x += (view.velocityX + sway) * elapsedSeconds;
|
||||
view.object.y += view.velocityY * elapsedSeconds;
|
||||
view.object.rotation += view.angularVelocity * elapsedSeconds;
|
||||
if (
|
||||
view.object.x < -48 ||
|
||||
view.object.x > width + 48 ||
|
||||
view.object.y < -48 ||
|
||||
view.object.y > height + 48
|
||||
) {
|
||||
this.resetStoryEnvironmentParticle(view, false);
|
||||
}
|
||||
});
|
||||
|
||||
this.storyEnvironmentHaze.forEach((view) => {
|
||||
if (!view.profile || !view.object.visible) {
|
||||
return;
|
||||
}
|
||||
view.ageMs += elapsedMs;
|
||||
view.object.x += view.velocityX * elapsedSeconds;
|
||||
view.object.y = view.baseY + Math.sin(view.ageMs / 2600 + view.wavePhase) * view.waveAmplitude;
|
||||
const beyondRight = view.velocityX >= 0 && view.object.x - view.width / 2 > width + 24;
|
||||
const beyondLeft = view.velocityX < 0 && view.object.x + view.width / 2 < -24;
|
||||
if (beyondRight || beyondLeft) {
|
||||
view.object.x = view.velocityX >= 0 ? -view.width / 2 : width + view.width / 2;
|
||||
view.baseY = this.storyEnvironmentBetween(height * 0.18, height * 0.72);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private clearStoryEnvironment() {
|
||||
this.storyEnvironmentRevision += 1;
|
||||
this.storyEnvironmentObjects.forEach((object) => {
|
||||
this.tweens.killTweensOf(object);
|
||||
if (object.active) {
|
||||
object.destroy();
|
||||
}
|
||||
});
|
||||
this.storyEnvironmentObjects = [];
|
||||
this.storyEnvironmentParticles = [];
|
||||
this.storyEnvironmentHaze = [];
|
||||
this.storyEnvironmentTint = undefined;
|
||||
this.storyEnvironmentProfile = undefined;
|
||||
this.storyEnvironmentBackgroundKey = undefined;
|
||||
}
|
||||
|
||||
private storyEnvironmentSeed(value: string) {
|
||||
let seed = 2166136261;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
seed ^= value.charCodeAt(index);
|
||||
seed = Math.imul(seed, 16777619);
|
||||
}
|
||||
return seed >>> 0 || 1;
|
||||
}
|
||||
|
||||
private storyEnvironmentRandom() {
|
||||
this.storyEnvironmentRandomState = (
|
||||
Math.imul(this.storyEnvironmentRandomState, 1664525) + 1013904223
|
||||
) >>> 0;
|
||||
return this.storyEnvironmentRandomState / 0x100000000;
|
||||
}
|
||||
|
||||
private storyEnvironmentBetween(minimum: number, maximum: number) {
|
||||
return minimum + (maximum - minimum) * this.storyEnvironmentRandom();
|
||||
}
|
||||
|
||||
private storyEnvironmentRange(range: readonly [number, number]) {
|
||||
return this.storyEnvironmentBetween(range[0], range[1]);
|
||||
}
|
||||
|
||||
private storyEnvironmentDebugState() {
|
||||
const profile = this.storyEnvironmentProfile;
|
||||
const activeParticles = this.storyEnvironmentParticles.filter((view) => view.profile && view.object.active && view.object.visible);
|
||||
const activeHaze = this.storyEnvironmentHaze.filter((view) => view.profile && view.object.active && view.object.visible);
|
||||
const tintVisible = Boolean(this.storyEnvironmentTint?.active && this.storyEnvironmentTint.visible);
|
||||
return {
|
||||
profileId: profile?.id ?? null,
|
||||
label: profile?.label ?? null,
|
||||
effect: profile?.effect ?? null,
|
||||
backgroundKey: this.storyEnvironmentBackgroundKey ?? null,
|
||||
ambienceKey: profile?.ambienceKey ?? null,
|
||||
ambienceVolume: profile?.ambienceVolume ?? null,
|
||||
reducedMotion: this.visualMotionReduced,
|
||||
tintVisible,
|
||||
particleCount: activeParticles.length,
|
||||
hazeCount: activeHaze.length,
|
||||
objectCount: activeParticles.length + activeHaze.length + (tintVisible ? 1 : 0),
|
||||
allocatedObjectCount: this.storyEnvironmentObjects.filter((object) => object.active).length,
|
||||
fixedPool: true,
|
||||
poolCapacity: storyEnvironmentProfileVerification.maxObjectCount,
|
||||
depths: { ...storyEnvironmentDepths },
|
||||
transitionRevision: this.storyEnvironmentRevision,
|
||||
verification: storyEnvironmentProfileVerification
|
||||
};
|
||||
}
|
||||
|
||||
private renderCutscene(page: StoryPage) {
|
||||
this.cutsceneLayer?.list.forEach((child) => this.tweens.killTweensOf(child));
|
||||
this.cutsceneLayer?.destroy();
|
||||
@@ -1656,6 +2065,7 @@ export class StoryScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
this.pageIndex = targetPageIndex;
|
||||
soundDirector.playStoryAdvanceCue();
|
||||
this.showPage(targetPageIndex);
|
||||
},
|
||||
true
|
||||
|
||||
@@ -13,6 +13,12 @@ import {
|
||||
saveAudioPreferences,
|
||||
updateAudioPreference
|
||||
} from '../settings/audioPreferences';
|
||||
import {
|
||||
loadVisualMotionMode,
|
||||
nextVisualMotionMode,
|
||||
saveVisualMotionMode,
|
||||
visualMotionModeLabel
|
||||
} from '../settings/visualMotion';
|
||||
import { battleIdForCampaignStep, isCampCampaignStep } from '../state/campaignRouting';
|
||||
import { readCampaignBattleResume, type CampaignBattleResume } from '../state/battleSaveStorage';
|
||||
import {
|
||||
@@ -454,11 +460,11 @@ export class TitleScene extends Phaser.Scene {
|
||||
this.closeSaveSlotPanel();
|
||||
|
||||
const panel = this.add.container(x, y + ui(126));
|
||||
const backdrop = this.add.rectangle(0, 0, ui(320), ui(420), 0x0b1118, 1);
|
||||
const backdrop = this.add.rectangle(0, 0, ui(320), ui(468), 0x0b1118, 1);
|
||||
backdrop.setStrokeStyle(ui(1), palette.gold, 0.52);
|
||||
backdrop.setInteractive();
|
||||
|
||||
const title = this.add.text(0, -ui(150), '설정', {
|
||||
const title = this.add.text(0, -ui(174), '설정', {
|
||||
fontSize: uiPx(24),
|
||||
color: '#f1e3c2',
|
||||
fontStyle: '700',
|
||||
@@ -467,7 +473,7 @@ export class TitleScene extends Phaser.Scene {
|
||||
});
|
||||
title.setOrigin(0.5);
|
||||
|
||||
const soundText = this.createSettingsButton(0, -ui(102), this.soundLabel());
|
||||
const soundText = this.createSettingsButton(0, -ui(126), this.soundLabel());
|
||||
soundText.on('pointerdown', () => {
|
||||
const preferences = loadAudioPreferences();
|
||||
const muted = !soundDirector.isMuted();
|
||||
@@ -477,16 +483,16 @@ export class TitleScene extends Phaser.Scene {
|
||||
soundDirector.playSelect();
|
||||
});
|
||||
|
||||
const musicText = this.createSettingsButton(0, -ui(60), this.audioCategoryLabel('music'));
|
||||
const musicText = this.createSettingsButton(0, -ui(84), this.audioCategoryLabel('music'));
|
||||
musicText.on('pointerdown', () => this.cycleAudioLevel('music', musicText));
|
||||
|
||||
const effectsText = this.createSettingsButton(0, -ui(18), this.audioCategoryLabel('effects'));
|
||||
const effectsText = this.createSettingsButton(0, -ui(42), this.audioCategoryLabel('effects'));
|
||||
effectsText.on('pointerdown', () => this.cycleAudioLevel('effects', effectsText));
|
||||
|
||||
const ambienceText = this.createSettingsButton(0, ui(24), this.audioCategoryLabel('ambience'));
|
||||
const ambienceText = this.createSettingsButton(0, 0, this.audioCategoryLabel('ambience'));
|
||||
ambienceText.on('pointerdown', () => this.cycleAudioLevel('ambience', ambienceText));
|
||||
|
||||
const presentationText = this.createSettingsButton(0, ui(66), this.combatPresentationLabel());
|
||||
const presentationText = this.createSettingsButton(0, ui(42), this.combatPresentationLabel());
|
||||
presentationText.on('pointerdown', () => {
|
||||
const nextMode = nextCombatPresentationMode(loadCombatPresentationMode());
|
||||
saveCombatPresentationMode(nextMode);
|
||||
@@ -494,13 +500,21 @@ export class TitleScene extends Phaser.Scene {
|
||||
soundDirector.playSelect();
|
||||
});
|
||||
|
||||
const close = this.createSettingsButton(0, ui(110), '닫기');
|
||||
const motionText = this.createSettingsButton(0, ui(84), this.visualMotionLabel());
|
||||
motionText.on('pointerdown', () => {
|
||||
const nextMode = nextVisualMotionMode(loadVisualMotionMode());
|
||||
saveVisualMotionMode(nextMode);
|
||||
motionText.setText(this.visualMotionLabel());
|
||||
soundDirector.playSelect();
|
||||
});
|
||||
|
||||
const close = this.createSettingsButton(0, ui(132), '닫기');
|
||||
close.on('pointerdown', () => {
|
||||
soundDirector.playSelect();
|
||||
this.closeSettingsPanel();
|
||||
});
|
||||
|
||||
const hint = this.add.text(0, ui(150), '항목을 눌러 단계 변경 · Esc 닫기', {
|
||||
const hint = this.add.text(0, ui(178), '항목을 눌러 단계 변경 · Esc 닫기', {
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: uiPx(11),
|
||||
color: '#9fb0bf',
|
||||
@@ -510,7 +524,7 @@ export class TitleScene extends Phaser.Scene {
|
||||
});
|
||||
hint.setOrigin(0.5);
|
||||
|
||||
panel.add([backdrop, title, soundText, musicText, effectsText, ambienceText, presentationText, close, hint]);
|
||||
panel.add([backdrop, title, soundText, musicText, effectsText, ambienceText, presentationText, motionText, close, hint]);
|
||||
panel.setAlpha(0);
|
||||
this.settingsPanel = panel;
|
||||
this.tweens.add({ targets: panel, alpha: 1, y: y + ui(116), duration: 160, ease: 'Sine.easeOut' });
|
||||
@@ -559,6 +573,10 @@ export class TitleScene extends Phaser.Scene {
|
||||
return `전투 연출: ${combatPresentationModeLabel(loadCombatPresentationMode())}`;
|
||||
}
|
||||
|
||||
private visualMotionLabel() {
|
||||
return `화면 움직임: ${visualMotionModeLabel(loadVisualMotionMode())}`;
|
||||
}
|
||||
|
||||
private closeSettingsPanel() {
|
||||
this.settingsPanel?.destroy();
|
||||
this.settingsPanel = undefined;
|
||||
|
||||
100
src/game/settings/visualMotion.ts
Normal file
100
src/game/settings/visualMotion.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
export const visualMotionStorageKey = 'heros-web:visual-motion-mode';
|
||||
|
||||
export const visualMotionModes = ['full', 'reduced'] as const;
|
||||
|
||||
export type VisualMotionMode = (typeof visualMotionModes)[number];
|
||||
|
||||
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>;
|
||||
type MatchMediaLike = (query: string) => Pick<MediaQueryList, 'matches'>;
|
||||
|
||||
export const visualMotionModeLabels: Record<VisualMotionMode, string> = {
|
||||
full: '보통',
|
||||
reduced: '줄임'
|
||||
};
|
||||
|
||||
export function isVisualMotionMode(value: unknown): value is VisualMotionMode {
|
||||
return typeof value === 'string' && visualMotionModes.includes(value as VisualMotionMode);
|
||||
}
|
||||
|
||||
export function preferredVisualMotionMode(matchMedia = resolveMatchMedia()): VisualMotionMode {
|
||||
if (!matchMedia) {
|
||||
return 'full';
|
||||
}
|
||||
|
||||
try {
|
||||
return matchMedia('(prefers-reduced-motion: reduce)').matches ? 'reduced' : 'full';
|
||||
} catch {
|
||||
return 'full';
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeVisualMotionMode(
|
||||
value: unknown,
|
||||
fallback: VisualMotionMode = 'full'
|
||||
): VisualMotionMode {
|
||||
return isVisualMotionMode(value) ? value : fallback;
|
||||
}
|
||||
|
||||
export function loadVisualMotionMode(
|
||||
storage = resolveStorage(),
|
||||
matchMedia = resolveMatchMedia()
|
||||
): VisualMotionMode {
|
||||
const fallback = preferredVisualMotionMode(matchMedia);
|
||||
if (!storage) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeVisualMotionMode(storage.getItem(visualMotionStorageKey), fallback);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveVisualMotionMode(mode: VisualMotionMode, storage = resolveStorage()) {
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
storage.setItem(visualMotionStorageKey, normalizeVisualMotionMode(mode));
|
||||
} catch {
|
||||
// Local storage can be unavailable in private or restricted browser contexts.
|
||||
}
|
||||
}
|
||||
|
||||
export function nextVisualMotionMode(mode: VisualMotionMode): VisualMotionMode {
|
||||
const index = visualMotionModes.indexOf(mode);
|
||||
return visualMotionModes[(index + 1) % visualMotionModes.length];
|
||||
}
|
||||
|
||||
export function visualMotionModeLabel(mode: VisualMotionMode) {
|
||||
return visualMotionModeLabels[mode];
|
||||
}
|
||||
|
||||
export function isVisualMotionReduced(
|
||||
storage = resolveStorage(),
|
||||
matchMedia = resolveMatchMedia()
|
||||
) {
|
||||
return loadVisualMotionMode(storage, matchMedia) === 'reduced';
|
||||
}
|
||||
|
||||
function resolveStorage(): StorageLike | undefined {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMatchMedia(): MatchMediaLike | undefined {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return window.matchMedia.bind(window);
|
||||
}
|
||||
Reference in New Issue
Block a user