feat: add stable story atmosphere and motion controls

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

View File

@@ -8,6 +8,7 @@ const checks = [
'scripts/verify-campaign-completion.mjs',
'scripts/verify-growth-consistency.mjs',
'scripts/verify-combat-presentation-settings.mjs',
'scripts/verify-visual-motion-settings.mjs',
'scripts/verify-battle-forecast.mjs',
'scripts/verify-battle-save-normalization.mjs',
'scripts/verify-battle-save-resume-routing.mjs',
@@ -24,6 +25,7 @@ const checks = [
'scripts/verify-equipment-catalog-data.mjs',
'scripts/verify-inventory-reward-data.mjs',
'scripts/verify-story-asset-data.mjs',
'scripts/verify-story-environment-profiles.mjs',
'scripts/verify-story-image-asset-data.mjs',
'scripts/verify-visual-asset-data.mjs',
'scripts/verify-unit-sprite-asset-data.mjs',

View File

@@ -0,0 +1,190 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createServer } from 'vite';
const expectedStoryPageCount = 466;
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true, hmr: false },
appType: 'custom'
});
try {
const scenarioModule = await server.ssrLoadModule('/src/game/data/scenario.ts');
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
const {
storyEnvironmentDepths,
storyEnvironmentProfiles,
storyEnvironmentProfileFor,
storyEnvironmentProfileVerification
} = await server.ssrLoadModule('/src/game/data/storyEnvironmentProfiles.ts');
const { ambienceTracks } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts');
assert.equal(storyEnvironmentProfileVerification.profileCount, 7, 'seven story environment profiles are required');
assert(storyEnvironmentDepths.tint > 0, 'story tint must render above the background');
assert(storyEnvironmentDepths.tint < storyEnvironmentDepths.haze, 'story haze must render above tint');
assert(storyEnvironmentDepths.haze < storyEnvironmentDepths.particles, 'story particles must render above haze');
assert(storyEnvironmentDepths.particles < 1, 'story environment must remain below the scene shade');
assert(storyEnvironmentProfileVerification.maxParticleCount <= 24, 'story particle pool exceeds the 24-object budget');
assert(storyEnvironmentProfileVerification.maxHazeCount <= 2, 'story haze pool exceeds the two-object budget');
assert(storyEnvironmentProfileVerification.maxObjectCount <= 27, 'story environment pool exceeds 27 objects');
Object.values(storyEnvironmentProfiles).forEach((profile) => {
assert(ambienceTracks[profile.ambienceKey], `${profile.id} references missing ambience ${profile.ambienceKey}`);
assert(profile.ambienceVolume > 0 && profile.ambienceVolume <= 0.09, `${profile.id} ambience is outside the quiet story mix`);
assert(profile.tint.alpha >= 0 && profile.tint.alpha <= 0.08, `${profile.id} tint is too opaque`);
assert((profile.haze?.count ?? 0) <= 2, `${profile.id} creates too many haze layers`);
assert((profile.particles?.count ?? 0) <= 24, `${profile.id} creates too many particles`);
if (profile.effect === 'mist') {
assert.equal(profile.particles, undefined, `${profile.id} mist should not create particles`);
} else {
const expectedKind = profile.effect === 'embers' ? 'ember' : profile.effect;
assert.equal(profile.particles?.kind, expectedKind, `${profile.id} particle kind does not match its effect`);
}
});
const representativeCases = [
['story-yiling-fire-attack', 'story-fire-embers'],
['story-fan-castle-flood', 'story-rain-mist'],
['story-hanzhong-rain', 'story-rain-mist'],
['story-maicheng-isolation', 'story-snow-wind'],
['story-nanzhong-captures', 'story-jungle-mist'],
['story-weishui-northbank', 'story-river-mist'],
['story-weishui-camps', 'story-camp-night'],
['story-northern', 'story-mountain-wind'],
['story-liu-bei-resolve', null]
];
representativeCases.forEach(([key, expectedProfileId]) => {
assert.equal(
storyEnvironmentProfileFor(key)?.id ?? null,
expectedProfileId,
`${key} should resolve to ${expectedProfileId ?? 'no environment'}`
);
});
assert.equal(
storyEnvironmentProfileFor('story-red-cliffs', 'twenty-second-fire-attack-sortie')?.id,
'story-fire-embers',
'the explicit Red Cliffs fire set piece should retain its fire environment'
);
assert.equal(
storyEnvironmentProfileFor('story-red-cliffs', 'twenty-first-red-cliffs-sortie'),
undefined,
'the broader Red Cliffs story family must remain neutral before the fire attack'
);
const unmappedVariantCases = [
'story-yiling-fire-attack-riverbank',
'story-hanzhong-rain-night-watch',
'story-maicheng-isolation-snow-road',
'story-wandering-night-camp',
'story-training',
'story-red-cliffs',
'story-anti-dong',
'story-baidi-entrustment',
'story-yiling-baidi',
'story-yizhou'
];
unmappedVariantCases.forEach((key) => {
assert.equal(
storyEnvironmentProfileFor(key),
undefined,
`${key} must not bypass semantic story background-family mapping`
);
});
const storySources = collectStorySources(scenarioModule, battleScenarios);
const selectedCounts = new Map();
let pageCount = 0;
let coveredPageCount = 0;
let environmentTransitionCount = 0;
let multiTransitionSequenceCount = 0;
let fullyChangingSequenceCount = 0;
storySources.forEach((pages) => {
const profileIds = pages.map((page) => storyEnvironmentProfileFor(page.background, page.id)?.id ?? null);
let sequenceTransitionCount = 0;
profileIds.forEach((profileId, index) => {
pageCount += 1;
if (index > 0 && profileIds[index - 1] !== profileId) {
sequenceTransitionCount += 1;
}
if (!profileId) {
return;
}
coveredPageCount += 1;
selectedCounts.set(profileId, (selectedCounts.get(profileId) ?? 0) + 1);
});
environmentTransitionCount += sequenceTransitionCount;
if (sequenceTransitionCount >= 2) {
multiTransitionSequenceCount += 1;
}
if (pages.length > 1 && sequenceTransitionCount === pages.length - 1) {
fullyChangingSequenceCount += 1;
}
});
assert.equal(pageCount, expectedStoryPageCount, 'story environment coverage must inspect every story page');
assert(coveredPageCount >= 220, `story environments cover too few pages (${coveredPageCount}/${pageCount})`);
assert(
environmentTransitionCount <= 8,
`story environments change too often inside sequences (${environmentTransitionCount} transitions)`
);
assert(
multiTransitionSequenceCount <= 4,
`too many story sequences switch environments repeatedly (${multiTransitionSequenceCount})`
);
assert(
fullyChangingSequenceCount <= 3,
`too many story sequences change environment on every page (${fullyChangingSequenceCount})`
);
Object.values(storyEnvironmentProfiles).forEach((profile) => {
assert((selectedCounts.get(profile.id) ?? 0) > 0, `${profile.id} is never selected by campaign pages`);
});
const storySceneSource = readFileSync('src/game/scenes/StoryScene.ts', 'utf8');
[
'soundDirector.playSoundscape({',
'storyEnvironmentProfileFor(page.background, page.id)',
'this.applyStoryEnvironment(environmentProfile, backgroundKey, page.id)',
'soundDirector.playStoryAdvanceCue()',
'this.updateStoryEnvironment(delta)',
'this.clearStoryEnvironment()',
'environment: this.storyEnvironmentDebugState()'
].forEach((snippet) => {
assert(storySceneSource.includes(snippet), `StoryScene is missing environment integration: ${snippet}`);
});
assert(
!storySceneSource.includes('soundDirector.stopAmbience()'),
'StoryScene shutdown must leave ambience handoff to the destination soundscape so crossfades stay continuous.'
);
console.info(
`Verified ${storyEnvironmentProfileVerification.profileCount} story environment profiles across ` +
`${coveredPageCount}/${pageCount} pages with ${environmentTransitionCount} in-sequence transitions and ` +
`a fixed ${storyEnvironmentProfileVerification.maxObjectCount}-object pool.`
);
} finally {
await server.close();
}
function collectStorySources(scenarioModule, battleScenarios) {
const sources = [];
const seenArrays = new WeakSet();
Object.values(scenarioModule)
.filter(isStoryPageList)
.forEach((pages) => {
sources.push(pages);
seenArrays.add(pages);
});
Object.values(battleScenarios).forEach((scenario) => {
if (isStoryPageList(scenario.victoryPages) && !seenArrays.has(scenario.victoryPages)) {
sources.push(scenario.victoryPages);
seenArrays.add(scenario.victoryPages);
}
});
return sources;
}
function isStoryPageList(value) {
return Array.isArray(value) && value.length > 0 && value.every((page) =>
page && typeof page === 'object' && typeof page.id === 'string' && typeof page.background === 'string'
);
}

View File

@@ -0,0 +1,155 @@
import { readFileSync } from 'node:fs';
import { createServer } from 'vite';
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true, hmr: false },
appType: 'custom'
});
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function createStorage() {
const values = new Map();
return {
values,
storage: {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, value)
}
};
}
const fullMotionPreference = () => ({ matches: false });
const reducedMotionPreference = () => ({ matches: true });
try {
const motion = await server.ssrLoadModule('/src/game/settings/visualMotion.ts');
assert(
motion.preferredVisualMotionMode(fullMotionPreference) === 'full',
'The OS default must use full motion when reduced motion is not requested.'
);
assert(
motion.preferredVisualMotionMode(reducedMotionPreference) === 'reduced',
'The OS reduced-motion preference must become the default mode.'
);
assert(
motion.preferredVisualMotionMode(() => { throw new Error('unavailable'); }) === 'full',
'An unavailable media query must safely fall back to full motion.'
);
const { values, storage } = createStorage();
assert(
motion.loadVisualMotionMode(storage, fullMotionPreference) === 'full',
'A fresh full-motion environment must load full mode.'
);
assert(
motion.loadVisualMotionMode(storage, reducedMotionPreference) === 'reduced',
'A fresh reduced-motion environment must load reduced mode.'
);
for (const mode of motion.visualMotionModes) {
motion.saveVisualMotionMode(mode, storage);
assert(
motion.loadVisualMotionMode(storage, reducedMotionPreference) === mode,
`Stored mode ${mode} must override the OS default.`
);
}
values.set(motion.visualMotionStorageKey, 'invalid');
assert(
motion.loadVisualMotionMode(storage, fullMotionPreference) === 'full' &&
motion.loadVisualMotionMode(storage, reducedMotionPreference) === 'reduced',
'Invalid stored values must normalize to the current OS default.'
);
assert(
motion.normalizeVisualMotionMode('full', 'reduced') === 'full' &&
motion.normalizeVisualMotionMode('reduced', 'full') === 'reduced' &&
motion.normalizeVisualMotionMode('invalid', 'reduced') === 'reduced',
'Visual motion normalization must preserve valid values and replace invalid values.'
);
assert(
motion.nextVisualMotionMode('full') === 'reduced' &&
motion.nextVisualMotionMode('reduced') === 'full',
'The two visual motion modes must cycle in display order.'
);
assert(
motion.visualMotionModeLabel('full') === '보통' &&
motion.visualMotionModeLabel('reduced') === '줄임',
'Visual motion labels must match the title settings copy.'
);
motion.saveVisualMotionMode('reduced', storage);
assert(
motion.isVisualMotionReduced(storage, fullMotionPreference),
'The shared reduced-motion predicate must honor the stored reduced mode.'
);
motion.saveVisualMotionMode('full', storage);
assert(
!motion.isVisualMotionReduced(storage, reducedMotionPreference),
'The shared reduced-motion predicate must let a stored full mode override the OS preference.'
);
const throwingStorage = {
getItem: () => { throw new Error('blocked'); },
setItem: () => { throw new Error('blocked'); }
};
assert(
motion.loadVisualMotionMode(throwingStorage, reducedMotionPreference) === 'reduced',
'Storage read failures must fall back to the OS preference.'
);
motion.saveVisualMotionMode('full', throwingStorage);
const titleSource = readFileSync('src/game/scenes/TitleScene.ts', 'utf8');
assert(
titleSource.includes("from '../settings/visualMotion'") &&
titleSource.includes('화면 움직임:') &&
titleSource.includes('nextVisualMotionMode(loadVisualMotionMode())') &&
titleSource.includes('ui(468)'),
'The title settings panel must expose the persisted motion toggle in its expanded layout.'
);
const battleSource = readFileSync('src/game/scenes/BattleScene.ts', 'utf8');
assert(
battleSource.includes("import { isVisualMotionReduced } from '../settings/visualMotion';"),
'BattleScene must use the shared visual motion preference.'
);
assert(
!battleSource.includes("matchMedia('(prefers-reduced-motion: reduce)')"),
'BattleScene must not keep direct reduced-motion media queries.'
);
assert(
battleSource.includes('if (!reducedMotion && profile.haze)') &&
battleSource.includes('if (!reducedMotion && profile.particles)'),
'Reduced mode must disable moving battle haze and particles without suppressing tint creation.'
);
assert(
(battleSource.match(/this\.shakeCamera\(/g) ?? []).length === 3 &&
(battleSource.match(/this\.cameras\.main\.shake\(/g) ?? []).length === 1 &&
battleSource.includes('if (isVisualMotionReduced())'),
'Every battle camera shake must pass through the shared reduced-motion guard.'
);
const storySource = readFileSync('src/game/scenes/StoryScene.ts', 'utf8');
assert(
storySource.includes("import { isVisualMotionReduced } from '../settings/visualMotion';") &&
storySource.includes('this.visualMotionReduced = isVisualMotionReduced();'),
'StoryScene must load the shared visual motion preference when the scene starts.'
);
assert(
storySource.includes('const particleCount = this.visualMotionReduced ? 0') &&
storySource.includes('? Math.min(1, profile.haze.count)') &&
storySource.includes('if (this.visualMotionReduced) {\n return;\n }'),
'Reduced story motion must stop background panning and particles while retaining one static haze layer.'
);
console.info('Verified visual motion defaults, persistence, title controls, and story/battle motion guards.');
} finally {
await server.close();
}