diff --git a/package.json b/package.json index de9b0bb..4de1300 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "verify:inventory-rewards": "node scripts/verify-inventory-reward-data.mjs", "verify:story-assets": "node scripts/verify-story-asset-data.mjs", "verify:story-images": "node scripts/verify-story-image-asset-data.mjs", + "verify:dialogue-portraits": "node scripts/verify-dialogue-portrait-layout.mjs", "verify:visual-assets": "node scripts/verify-visual-asset-data.mjs", "verify:unit-sprites": "node scripts/verify-unit-sprite-asset-data.mjs", "verify:unit-action-assets": "python scripts/optimize-unit-action-assets.py && node scripts/verify-battle-action-asset-budgets.mjs", diff --git a/scripts/verify-camp-soundscape-data.mjs b/scripts/verify-camp-soundscape-data.mjs index ef17951..b019164 100644 --- a/scripts/verify-camp-soundscape-data.mjs +++ b/scripts/verify-camp-soundscape-data.mjs @@ -63,11 +63,22 @@ const server = await createServer({ }); try { - const { campMusicGroups, campAmbienceDefinitions, campSoundscapeMapping, getCampSoundscape } = + const { + campMusicGroups, + campAmbienceDefinitions, + campSoundscapeMapping, + getCampSoundscape, + campMusicVolumeForTrackKey, + musicVolumeMatchedToCampTrack + } = await server.ssrLoadModule('/src/game/data/campSoundscapes.ts'); const { selectCampSkin } = await server.ssrLoadModule('/src/game/data/campSkins.ts'); const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); - const { musicTracks, ambienceTracks } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts'); + const { + musicTracks, + ambienceTracks, + musicTrackIntegratedLoudnessLufs + } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts'); const expectedSkinIds = Object.keys(expectedMapping); const musicEntries = Object.entries(campMusicGroups); @@ -83,6 +94,38 @@ try { validateDefinitions(musicEntries, expectedMusicGroups, musicTracks, 'music group', 0.05, 0.4); validateDefinitions(ambienceEntries, expectedAmbiences, ambienceTracks, 'ambience', 0.03, 0.3); + musicEntries.forEach(([, definition]) => { + assertEqual( + campMusicVolumeForTrackKey(definition.trackKey), + definition.volume, + `${definition.trackKey}: shared exploration music volume` + ); + }); + assertEqual( + campMusicVolumeForTrackKey('unknown-track', 0.19), + 0.19, + 'unknown exploration music fallback volume' + ); + assertEqual( + musicVolumeMatchedToCampTrack('militia-theme'), + 0.124, + 'militia theme volume matched to the camp-rally perceptual level' + ); + const militiaEffectiveLoudness = + musicTrackIntegratedLoudnessLufs['militia-theme'] + + 20 * Math.log10(musicVolumeMatchedToCampTrack('militia-theme')); + const campRallyEffectiveLoudness = + musicTrackIntegratedLoudnessLufs['camp-rally'] + + 20 * Math.log10(campMusicVolumeForTrackKey('camp-rally')); + assert( + Math.abs( + militiaEffectiveLoudness - campRallyEffectiveLoudness + ) <= 0.1, + `militia and camp-rally effective loudness must match within 0.1 LU; received ${( + militiaEffectiveLoudness - campRallyEffectiveLoudness + ).toFixed(2)} LU` + ); + validateExplorationSceneMixes(); validateCampAudioAssets( musicEntries.map(([, definition]) => ({ key: definition.trackKey, url: musicTracks[definition.trackKey], kind: 'music' })), ambienceEntries.map(([, definition]) => ({ key: definition.trackKey, url: ambienceTracks[definition.trackKey], kind: 'ambience' })) @@ -143,6 +186,34 @@ try { await server.close(); } +function validateExplorationSceneMixes() { + const citySource = readFileSync('src/game/scenes/CityStayScene.ts', 'utf8'); + const villageSource = readFileSync('src/game/scenes/PrologueVillageScene.ts', 'utf8'); + const campVisitSource = readFileSync( + 'src/game/scenes/CampVisitExplorationScene.ts', + 'utf8' + ); + assert( + citySource.includes('musicVolume: campMusicVolumeForTrackKey(this.profile.musicKey)'), + 'city stays must reuse the calibrated camp music level for their selected track' + ); + assert( + villageSource.includes( + "musicVolume: musicVolumeMatchedToCampTrack('militia-theme')" + ) && + !villageSource.includes('musicVolume: 0.72') && + !villageSource.includes('musicVolume: 0.22'), + 'the prologue village must loudness-match its hotter source track to the calibrated camp mix' + ); + assertEqual( + campVisitSource.match( + /musicVolume:\s*campMusicVolumeForTrackKey\('camp-rally'\)/g + )?.length ?? 0, + 2, + 'camp visits must reuse the calibrated camp-rally level in both exploration variants' + ); +} + function validateDefinitions(entries, expectedDefinitions, registeredTracks, context, minimumVolume, maximumVolume) { const expectedIds = Object.keys(expectedDefinitions); const registeredKeys = new Set(Object.keys(registeredTracks)); diff --git a/scripts/verify-city-stay-browser.mjs b/scripts/verify-city-stay-browser.mjs index 734a173..fdb20e9 100644 --- a/scripts/verify-city-stay-browser.mjs +++ b/scripts/verify-city-stay-browser.mjs @@ -533,6 +533,13 @@ function verifyExplorationLayout(city, expectedCityStayId) { assert.equal(city.progress.marketOptional, true); assert.equal(city.progress.exitUnlocked, true); assert.equal(city.actors.length, 3); + const companion = city.actors.find((actor) => actor.kind === 'dialogue'); + assert(companion, `${expectedCityStayId} must expose its companion actor.`); + assert.equal( + city.objective?.dialogueUnitNames, + `유비 · ${companion.name}`, + `${expectedCityStayId} objective card must use authored Korean character names instead of roster or unit IDs.` + ); assert.deepEqual( [...city.actors.map((actor) => actor.kind)].sort(), ['dialogue', 'information', 'market'] @@ -824,10 +831,18 @@ async function verifyCompanionResonance(page, cityCase, screenshotPath) { await page.waitForFunction(() => ( window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true )); + const initialDialogueState = await readCity(page); verifyActiveDialoguePortrait( - await readCity(page), + initialDialogueState, `${cityCase.cityStayId} companion dialogue` ); + const initialCompanionFacing = initialDialogueState.actors.find( + (actor) => actor.kind === 'dialogue' + )?.animationKey; + assert( + initialCompanionFacing, + `${cityCase.cityStayId} companion must expose its facing animation during dialogue.` + ); await advanceDialogueUntilChoice(page); const choiceState = await readCity(page); verifyChoiceLayout(choiceState); @@ -849,6 +864,21 @@ async function verifyCompanionResonance(page, cityCase, screenshotPath) { }); const afterChoice = await readCity(page); + assert.equal( + afterChoice.dialogue.active, + true, + 'Selecting a resonance response must open its acknowledgement dialogue.' + ); + assert.equal( + afterChoice.dialogue.lineIndex, + 0, + 'The pointer event that selects a response must not also skip the first acknowledgement line.' + ); + assert.equal( + afterChoice.actors.find((actor) => actor.kind === 'dialogue')?.animationKey, + initialCompanionFacing, + 'The companion must turn back toward the player for the acknowledgement dialogue.' + ); assert.equal( afterChoice.campaign.completedCampDialogues.filter((id) => id === cityCase.dialogueId).length, 1, diff --git a/scripts/verify-dialogue-portrait-layout.mjs b/scripts/verify-dialogue-portrait-layout.mjs new file mode 100644 index 0000000..3d3a37c --- /dev/null +++ b/scripts/verify-dialogue-portrait-layout.mjs @@ -0,0 +1,213 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const projectRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..' +); +const helperPath = path.join( + projectRoot, + 'src/game/ui/dialoguePortrait.ts' +); +const helperSource = await readFile(helperPath, 'utf8'); +const transpiledHelper = ts.transpileModule(helperSource, { + compilerOptions: { + module: ts.ModuleKind.ES2022, + target: ts.ScriptTarget.ES2022 + }, + fileName: helperPath +}).outputText; +const helperModule = await import( + `data:text/javascript;base64,${Buffer.from(transpiledHelper).toString('base64')}` +); +const { + calculateDialogueFacePortraitLayout, + fitDialogueFacePortrait +} = helperModule; + +const squareLayout = calculateDialogueFacePortraitLayout( + 1254, + 1254, + 220 +); +assert.deepEqual( + { + cropX: squareLayout.cropX, + cropY: squareLayout.cropY, + cropSize: squareLayout.cropSize + }, + { + cropX: 251, + cropY: 44, + cropSize: 752 + }, + 'The standard portrait must use the established square face-and-shoulders crop.' +); +assertCenteredCrop(squareLayout, 1254, 1254, 220); + +const landscapeLayout = calculateDialogueFacePortraitLayout( + 1600, + 1000, + 192 +); +assert.deepEqual( + { + cropX: landscapeLayout.cropX, + cropY: landscapeLayout.cropY, + cropSize: landscapeLayout.cropSize + }, + { + cropX: 500, + cropY: 35, + cropSize: 600 + }, + 'Landscape sources must still produce a square, horizontally centered face crop.' +); +assertCenteredCrop(landscapeLayout, 1600, 1000, 192); + +const portraitLayout = calculateDialogueFacePortraitLayout( + 1000, + 1600, + 218 +); +assert.deepEqual( + { + cropX: portraitLayout.cropX, + cropY: portraitLayout.cropY, + cropSize: portraitLayout.cropSize + }, + { + cropX: 200, + cropY: 56, + cropSize: 600 + }, + 'Portrait-oriented sources must preserve the upper face-and-shoulders framing.' +); +assertCenteredCrop(portraitLayout, 1000, 1600, 218); + +assert.throws( + () => calculateDialogueFacePortraitLayout(0, 1254, 220), + RangeError, + 'Invalid source dimensions must fail explicitly.' +); +assert.throws( + () => calculateDialogueFacePortraitLayout(1254, 1254, Number.NaN), + RangeError, + 'Invalid display dimensions must fail explicitly.' +); + +const imageCalls = []; +const image = { + frame: { + realWidth: 1254, + realHeight: 1254 + }, + setCrop(...values) { + imageCalls.push(['crop', ...values]); + return this; + }, + setOrigin(...values) { + imageCalls.push(['origin', ...values]); + return this; + }, + setScale(...values) { + imageCalls.push(['scale', ...values]); + return this; + } +}; +const appliedLayout = fitDialogueFacePortrait(image, 220); +assert.deepEqual( + imageCalls.map(([operation]) => operation), + ['crop', 'origin', 'scale'], + 'Applying the layout must crop, center the crop origin, and then scale it.' +); +assert.deepEqual( + imageCalls[0], + [ + 'crop', + appliedLayout.cropX, + appliedLayout.cropY, + appliedLayout.cropSize, + appliedLayout.cropSize + ] +); +assert.deepEqual( + imageCalls[1], + ['origin', appliedLayout.originX, appliedLayout.originY] +); +assert.deepEqual( + imageCalls[2], + ['scale', appliedLayout.scale] +); + +await assertSceneIntegration( + 'src/game/scenes/StoryScene.ts', + 3 +); +await assertSceneIntegration( + 'src/game/scenes/CityStayScene.ts', + 1 +); +await assertSceneIntegration( + 'src/game/scenes/PrologueVillageScene.ts', + 1 +); +await assertSceneIntegration( + 'src/game/scenes/PrologueMilitiaCampScene.ts', + 1 +); +await assertSceneIntegration( + 'src/game/scenes/CampVisitExplorationScene.ts', + 1 +); + +console.log( + 'Verified shared dialogue portrait face crops, centered origins, retained display sizes, and all story/exploration dialogue integrations.' +); + +function assertCenteredCrop( + layout, + sourceWidth, + sourceHeight, + displaySize +) { + const centeredX = + layout.cropX + + layout.cropSize / 2 - + layout.originX * sourceWidth; + const centeredY = + layout.cropY + + layout.cropSize / 2 - + layout.originY * sourceHeight; + assert.ok( + Math.abs(centeredX) < 1e-9 && + Math.abs(centeredY) < 1e-9, + 'The visible crop center must remain anchored to the image position.' + ); + assert.ok( + Math.abs(layout.cropSize * layout.scale - displaySize) < + 1e-9, + 'The cropped portrait must retain its requested display size.' + ); +} + +async function assertSceneIntegration(relativePath, minimumCalls) { + const source = await readFile( + path.join(projectRoot, relativePath), + 'utf8' + ); + assert.match( + source, + /import\s+\{\s*fitDialogueFacePortrait\s*\}\s+from\s+'\.\.\/ui\/dialoguePortrait';/, + `${relativePath} must import the shared dialogue portrait helper.` + ); + const callCount = + source.match(/fitDialogueFacePortrait\s*\(/g)?.length ?? 0; + assert.ok( + callCount >= minimumCalls, + `${relativePath} must apply the shared helper at every portrait rendering path.` + ); +} diff --git a/scripts/verify-exploration-character-asset-data.mjs b/scripts/verify-exploration-character-asset-data.mjs index 4bbef79..649fece 100644 --- a/scripts/verify-exploration-character-asset-data.mjs +++ b/scripts/verify-exploration-character-asset-data.mjs @@ -85,6 +85,21 @@ assert.match( /export function ensureExplorationCharacterAnimations\(/, 'The module must expose exploration animation registration.' ); +assert.match( + moduleSource, + /export function explorationCharacterFrameFor\(/, + 'The module must expose deterministic direction and motion frame lookup.' +); +assert.match( + moduleSource, + /export function applyExplorationCharacterMotion\(/, + 'The module must expose shared reduced-motion-aware animation application.' +); +assert.match( + moduleSource, + /if \(reducedMotion && motion === 'idle'\)\s*\{\s*sprite\.anims\.stop\(\);\s*sprite\.setFrame\(explorationCharacterFrameFor\('idle', direction\)\);/s, + 'Reduced motion must freeze only decorative idle loops on the matching direction frame.' +); assert.match( moduleSource, /export function releaseExplorationCharacterTextures\(/, diff --git a/scripts/verify-first-pursuit-camp-exploration-browser.mjs b/scripts/verify-first-pursuit-camp-exploration-browser.mjs index e902c28..897a95c 100644 --- a/scripts/verify-first-pursuit-camp-exploration-browser.mjs +++ b/scripts/verify-first-pursuit-camp-exploration-browser.mjs @@ -296,6 +296,8 @@ try { const jianYong = actorById(exploration, 'jian-yong'); const clickStart = { ...exploration.player }; + const autoInteractionCountBefore = + exploration.movement.autoInteraction.triggeredCount; await clickScenePoint(page, jianYong.x, jianYong.y); await page.waitForFunction( ({ key, actorId, startX, startY }) => { @@ -333,6 +335,14 @@ try { { key: sceneKey, actorId: 'jian-yong' }, { timeout: 30000 } ); + await page.waitForFunction( + (key) => ( + window.__HEROS_DEBUG__ + ?.scene(key) + ?.getDebugState?.()?.dialogue?.sourceNpcId === 'jian-yong' + ), + sceneKey + ); exploration = await readExploration(page); assert( actorById(exploration, 'jian-yong').distance <= @@ -340,19 +350,30 @@ try { `Click movement must stop inside Jian Yong's interaction radius: ${JSON.stringify(exploration.interaction)}` ); assertActorsFixed(exploration, 'after click navigation'); - - await page.keyboard.press('e'); - await page.waitForFunction( - (key) => { - const state = window.__HEROS_DEBUG__ - ?.scene(key) - ?.getDebugState?.(); - return ( - state?.dialogue?.active === true && - state.dialogue.sourceNpcId === 'jian-yong' - ); - }, - sceneKey + assert.equal( + exploration.movement.autoInteraction.triggeredCount, + autoInteractionCountBefore + 1, + 'Arriving at Jian Yong must open dialogue exactly once.' + ); + const protectedArrivalLineIndex = exploration.dialogue.lineIndex; + await page.mouse.click(960, 850); + await page.waitForTimeout(80); + exploration = await readExploration(page); + assert.equal( + exploration.dialogue.lineIndex, + protectedArrivalLineIndex, + 'A follow-up click inside the arrival guard must not skip Jian Yong’s first line.' + ); + assert.equal( + exploration.dialogue.sourceNpcId, + 'jian-yong', + 'The arrival guard must keep Jian Yong’s newly opened dialogue active.' + ); + await page.waitForTimeout(160); + assert.equal( + (await readExploration(page)).movement.autoInteraction.triggeredCount, + autoInteractionCountBefore + 1, + 'Jian Yong dialogue must not retrigger on a later update.' ); exploration = await readExploration(page); assert.equal(exploration.dialogue.speaker, '간옹'); diff --git a/scripts/verify-prologue-village-browser.mjs b/scripts/verify-prologue-village-browser.mjs index 924a0b4..8faec3a 100644 --- a/scripts/verify-prologue-village-browser.mjs +++ b/scripts/verify-prologue-village-browser.mjs @@ -40,6 +40,12 @@ try { await waitForDebugApi(page); await waitForTitle(page); await assertDesktopViewport(page); + await verifyReducedMotionExploration(page); + await page.evaluate(() => window.localStorage.clear()); + await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); + await waitForDebugApi(page); + await waitForTitle(page); + await assertDesktopViewport(page); await clickLegacyTitleControl(page, 962, 240); await waitForStoryReady(page); @@ -174,8 +180,13 @@ try { walkingZhangFei.x > 360 && walkingZhangFei.x < 1000, `Zhang Fei should move progressively instead of teleporting: ${JSON.stringify(walkingZhangFei)}` ); - const queuedGuanTarget = villageNpc(village, 'guan-yu'); - await page.mouse.click(queuedGuanTarget.x, queuedGuanTarget.y); + const queuedVillagerTarget = villageNpc(village, 'market-villager'); + const queuedVillagerAutoCount = + village.movement.autoInteraction.triggeredCount; + await page.mouse.click( + queuedVillagerTarget.x, + queuedVillagerTarget.y + ); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.village?.(); return ( @@ -188,15 +199,45 @@ try { const state = window.__HEROS_DEBUG__?.village?.(); return ( state?.movement?.appliedQueuedIntentCount >= 1 && - state?.movement?.targetNpcId === 'guan-yu' + state?.movement?.targetNpcId === 'market-villager' ); }); - await waitForVillageInteractionTarget(page, 'guan-yu'); + await waitForVillageInteractionTarget(page, 'market-villager'); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === + 'market-villager' + )); village = await readVillage(page); + assert.equal( + village.movement.autoInteraction.triggeredCount, + queuedVillagerAutoCount + 1, + 'Queued pointer navigation must open the villager dialogue exactly once on arrival.' + ); + const protectedVillagerLineIndex = village.dialogue.lineIndex; + await page.mouse.click(960, 850); + await page.waitForTimeout(80); + village = await readVillage(page); + assert.equal( + village.dialogue.lineIndex, + protectedVillagerLineIndex, + 'A follow-up click inside the arrival guard must not skip the first villager line.' + ); + assert.equal( + village.dialogue.sourceNpcId, + 'market-villager', + 'The arrival guard must keep the newly opened villager dialogue active.' + ); const tavernZhangFei = villageNpc(village, 'zhang-fei'); assert.equal(tavernZhangFei.moving, false); assert.equal(tavernZhangFei.animationKey, 'exploration-zhang-fei-idle-east'); await captureStableScreenshot(page, 'dist/verification-prologue-village-dialogue.png'); + await page.waitForTimeout(160); + assert.equal( + (await readVillage(page)).movement.autoInteraction.triggeredCount, + queuedVillagerAutoCount + 1, + 'Arrival must not retrigger the same villager dialogue on a later update.' + ); + await advanceDialogueUntilClosed(page); await teleportTo(page, 'make-oath'); await page.keyboard.press('e'); @@ -260,6 +301,8 @@ try { `Zhang Fei should move progressively toward registration: ${JSON.stringify(walkingZhangToRecruitment)}` ); const queuedClerkTarget = villageNpc(village, 'recruiting-clerk'); + const queuedClerkAutoCount = + village.movement.autoInteraction.triggeredCount; await page.mouse.click(queuedClerkTarget.x, queuedClerkTarget.y); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.village?.(); @@ -273,14 +316,18 @@ try { window.__HEROS_DEBUG__?.village?.()?.movement?.targetNpcId === 'recruiting-clerk' )); await waitForVillageInteractionTarget(page, 'recruiting-clerk'); - village = await readVillage(page); - assertNpcPosition(village, 'guan-yu', { x: 850, y: 655 }, 'recruitment Guan Yu'); - assertNpcPosition(village, 'zhang-fei', { x: 810, y: 805 }, 'recruitment Zhang Fei'); - - await page.keyboard.press('e'); await page.waitForFunction(() => ( window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'recruiting-clerk' )); + village = await readVillage(page); + assert.equal( + village.movement.autoInteraction.triggeredCount, + queuedClerkAutoCount + 1, + 'Queued pointer navigation must open the recruiting dialogue exactly once.' + ); + assertNpcPosition(village, 'guan-yu', { x: 850, y: 655 }, 'recruitment Guan Yu'); + assertNpcPosition(village, 'zhang-fei', { x: 810, y: 805 }, 'recruitment Zhang Fei'); + await advanceDialogueUntilClosed(page); village = await readVillage(page); @@ -407,7 +454,74 @@ try { 'Militia camp interaction outside the radius must not open dialogue.' ); - const campPlayerBeforeMove = (await readMilitiaCamp(page)).player; + militiaCamp = await readMilitiaCamp(page); + const pointerZouJing = militiaCamp.npcs.find( + ({ id }) => id === 'zou-jing' + ); + assert(pointerZouJing, 'Zou Jing must be available as a pointer navigation target.'); + const campAutoInteractionBefore = + militiaCamp.movement.autoInteraction.triggeredCount; + const campDetourCountBefore = militiaCamp.movement.detourCount; + await page.mouse.click(pointerZouJing.x, pointerZouJing.y); + await page.waitForFunction(() => { + const state = window.__HEROS_DEBUG__?.militiaCamp?.(); + return ( + state?.movement?.targetNpcId === 'zou-jing' && + state?.movement?.detourCount > 0 + ); + }); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.sourceNpcId === + 'zou-jing' + )); + militiaCamp = await readMilitiaCamp(page); + assert( + militiaCamp.movement.detourCount > campDetourCountBefore, + 'Clicking Zou Jing from the entrance must route around the central fire.' + ); + assert.equal( + militiaCamp.movement.autoInteraction.triggeredCount, + campAutoInteractionBefore + 1, + 'Militia camp pointer arrival must open dialogue exactly once.' + ); + const protectedCampLineIndex = militiaCamp.dialogue.lineIndex; + await page.mouse.click(960, 850); + await page.waitForTimeout(80); + militiaCamp = await readMilitiaCamp(page); + assert.equal( + militiaCamp.dialogue.lineIndex, + protectedCampLineIndex, + 'A follow-up click inside the arrival guard must not skip Zou Jing’s first line.' + ); + assert.equal( + militiaCamp.dialogue.sourceNpcId, + 'zou-jing', + 'The arrival guard must keep Zou Jing’s newly opened dialogue active.' + ); + await page.waitForTimeout(160); + assert.equal( + (await readMilitiaCamp(page)).movement.autoInteraction.triggeredCount, + campAutoInteractionBefore + 1, + 'Militia camp arrival must not retrigger dialogue on a later update.' + ); + await advanceMilitiaCampDialogueUntilClosed(page); + + await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); + await waitForDebugApi(page); + await page.waitForFunction(() => + window.__HEROS_DEBUG__?.activeScenes?.().includes('TitleScene') + ); + await clickLegacyTitleControl(page, 962, 310); + await waitForMilitiaCampReady(page); + await page.waitForTimeout(380); + militiaCamp = await readMilitiaCamp(page); + assert.equal( + militiaCamp.dialogue.active, + false, + 'The pointer-route smoke reload must not replay the one-time camp introduction.' + ); + + const campPlayerBeforeMove = militiaCamp.player; await page.keyboard.down('ArrowUp'); await page.waitForTimeout(360); await page.keyboard.up('ArrowUp'); @@ -906,7 +1020,7 @@ try { console.log( `Verified the playable prologue village and militia camp at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` + - `DPR ${desktopBrowserDeviceScaleFactor}: sequential meetings, direct movement, distance-gated dialogue, ` + + `DPR ${desktopBrowserDeviceScaleFactor}: sequential meetings, direct movement, obstacle detours, single-fire arrival dialogue, ` + 'autosave/reload, legacy-save routing, locked oath and command, camp preparation, optional promise memory, ' + 'departure story, and first deployment.' ); @@ -924,6 +1038,62 @@ function withDebugOptions(url) { return parsed.toString(); } +async function verifyReducedMotionExploration(page) { + await page.evaluate(() => { + window.localStorage.setItem( + 'heros-web:visual-motion-mode', + 'reduced' + ); + }); + await page.evaluate(async () => { + await window.__HEROS_DEBUG__?.goToVillage(); + }); + await waitForVillageReady(page); + await page.waitForTimeout(380); + + let village = await readVillage(page); + assert.equal( + village.visualMotionReduced, + true, + 'The playable village must honor the reduced-motion preference.' + ); + assert.equal( + village.player.animationPlaying, + false, + 'Reduced motion must keep the idle player on a stable directional frame.' + ); + assert( + village.npcs.every((npc) => npc.animationPlaying === false), + `Reduced motion must freeze NPC idle loops: ${JSON.stringify(village.npcs)}` + ); + + await advanceDialogueUntilClosed(page); + await page.keyboard.down('d'); + try { + await page.waitForFunction(() => { + const player = window.__HEROS_DEBUG__?.village?.()?.player; + return ( + player?.moving === true && + player?.animationPlaying === true && + player?.animationKey?.includes('-walk-') + ); + }); + } finally { + await page.keyboard.up('d'); + } + await page.waitForFunction(() => { + const player = window.__HEROS_DEBUG__?.village?.()?.player; + return player?.moving === false && player?.animationPlaying === false; + }); + + village = await readVillage(page); + assert.equal( + village.player.animationPlaying, + false, + 'Reduced motion must return the player to a stable frame after walking.' + ); +} + async function waitForDebugApi(page) { await page.waitForFunction(() => ( document.querySelector('canvas') !== null && diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index d7b6658..bc4cd24 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -14,7 +14,7 @@ const baselineViewport = desktopBrowserViewport; const phaserWebglRendererType = 2; const legacyUiScale = 1.5; const expectedTitle = '\uC0BC\uAD6D\uC9C0: \uC138 \uD615\uC81C\uC758 \uB9F9\uC138'; -const firstBattleUnlockLabel = '\uD669\uAC74 \uC794\uB2F9 \uCD94\uACA9 \uAC1C\uBC29'; +const firstBattleUnlockLabel = '\uD55C\uC11D \uC794\uB2F9 \uCD94\uACA9 \uAC1C\uBC29'; const firstPursuitScoutVisitId = 'first-pursuit-scout-tent'; const firstPursuitScoutChoiceId = 'trace-river-ambush'; const firstPursuitScoutChoiceLabel = '강가 매복 흔적을 쫓는다'; diff --git a/scripts/verify-sound-director.mjs b/scripts/verify-sound-director.mjs index 0214776..c5c8eee 100644 --- a/scripts/verify-sound-director.mjs +++ b/scripts/verify-sound-director.mjs @@ -284,10 +284,16 @@ try { assert.equal(debug.lastMovementStep.surface, 'stone', 'movement debug state should expose the resolved surface'); assert.equal(debug.lastMovementStep.terrain, 'road', 'movement debug state should retain the caller terrain'); assert(['footstep-stone-1', 'footstep-stone-2'].includes(debug.lastMovementStep.trackKey)); + assertClose(firstStoneStep.playbackRate, 0.94 * 0.985, 'first footstep deterministic rate variation'); + assertClose(firstStoneStep.volume, 0.2 * 0.96, 'first footstep deterministic gain variation'); + assertClose(debug.lastMovementStep.rate, firstStoneStep.playbackRate, 'movement debug rate should expose variation'); + assertClose(debug.lastMovementStep.volume, firstStoneStep.volume, 'movement debug volume should expose variation'); director.playMovementStep(false, 1, { terrain: 'road', volume: 0.2, rate: 1.04, stopAfterMs: 120 }); const secondStoneStep = audioHarness.instances.at(-1); assert.notEqual(firstStoneStep.originalSrc, secondStoneStep.originalSrc, 'terrain movement pools should avoid immediate repetition'); + assertClose(secondStoneStep.playbackRate, 1.04 * 1.015, 'second footstep deterministic rate variation'); + assertClose(secondStoneStep.volume, 0.2 * 1.03, 'second footstep deterministic gain variation'); assert.equal( director.playMovementStep(false, 2, { terrain: 'road', volume: 0.2, rate: 0.94, stopAfterMs: 120 }), false, @@ -385,6 +391,7 @@ try { assert.equal(director.playEffect('ui-select'), true, 'effect should be tracked until an error or end event'); const erroredEffect = audioHarness.instances.at(-1); + assertClose(erroredEffect.playbackRate, 1, 'UI effects must not receive pooled combat rate variation'); erroredEffect.emit('error'); debug = director.getDebugState(); assert.equal(debug.activeEffectCount, 0, 'errored effects should be released from active tracking'); @@ -409,8 +416,65 @@ try { ); assert.equal(debug.music.targetVolume, 0.18, 'same-key music should update its desired target volume'); assert.equal(debug.ambience.targetVolume, 0.05, 'same-key ambience should update its desired target volume'); - assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.18, 'same-key music element volume'); - assertClose(audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume, 0.05, 'same-key ambience element volume'); + assert.equal(debug.music.volumeRamp.active, true, 'same-key music should start a gain ramp'); + assert.equal(debug.ambience.volumeRamp.active, true, 'same-key ambience should start a gain ramp'); + assert.deepEqual( + debug.music.volumeRamp.last, + { fromVolume: 0.22, toVolume: 0.18, durationMs: 400 }, + 'same-key music ramp should retain its endpoints' + ); + assert.deepEqual( + debug.ambience.volumeRamp.last, + { fromVolume: 0.08, toVolume: 0.05, durationMs: 400 }, + 'same-key ambience ramp should retain its endpoints' + ); + assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.22, 'same-key music must not jump at ramp start'); + assertClose(audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume, 0.08, 'same-key ambience must not jump at ramp start'); + + clock.advance(192); + debug = director.getDebugState(); + assert.equal(debug.music.transition.revision, musicRevisionBeforeSameKey, 'music gain ramp must not revise loop transition'); + assert.equal(debug.ambience.transition.revision, ambienceRevisionBeforeSameKey, 'ambience gain ramp must not revise loop transition'); + assert.equal(audioHarness.instances.length, countBeforeSameKey, 'gain ramps must retain the current loop elements'); + assertClose( + audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, + 0.22 + (0.18 - 0.22) * 0.48, + 'same-key music midpoint' + ); + assertClose( + audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume, + 0.08 + (0.05 - 0.08) * 0.48, + 'same-key ambience midpoint' + ); + const musicRampRevisionAtMidpoint = debug.music.volumeRamp.revision; + const ambienceRampRevisionAtMidpoint = debug.ambience.volumeRamp.revision; + director.playSoundscape({ + musicKey: 'music-a', + ambienceKey: 'ambience-a', + musicVolume: 0.18, + ambienceVolume: 0.05 + }); + debug = director.getDebugState(); + assert.equal( + debug.music.volumeRamp.revision, + musicRampRevisionAtMidpoint, + 'an identical same-key request must not restart the active music ramp' + ); + assert.equal( + debug.ambience.volumeRamp.revision, + ambienceRampRevisionAtMidpoint, + 'an identical same-key request must not restart the active ambience ramp' + ); + assert.equal(audioHarness.instances.length, countBeforeSameKey, 'an identical ramp request must retain loop elements'); + + clock.advance(208); + debug = director.getDebugState(); + assert.equal(debug.music.volumeRamp.active, false, 'same-key music ramp should complete after 400ms'); + assert.equal(debug.ambience.volumeRamp.active, false, 'same-key ambience ramp should complete after 400ms'); + assert.equal(debug.music.volumeRamp.completedRevision, 1, 'music gain ramp completion revision'); + assert.equal(debug.ambience.volumeRamp.completedRevision, 1, 'ambience gain ramp completion revision'); + assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.18, 'same-key music settled volume'); + assertClose(audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume, 0.05, 'same-key ambience settled volume'); director.playSoundscape({ musicKey: 'music-b', @@ -479,6 +543,183 @@ try { assertCompletedChannel(debug.ambience, 3, 0.04, 'rapid ambience fade'); assert.equal(audioHarness.activeInstances().length, 2, 'only the current music and ambience may remain active'); + director.playSoundscape({ + musicKey: 'music-b', + ambienceKey: 'ambience-b', + musicVolume: 0.2, + ambienceVolume: 0.06 + }); + director.playSoundscape({ + musicKey: 'music-b', + ambienceKey: 'ambience-b', + musicVolume: 0.17, + ambienceVolume: 0.045 + }); + debug = director.getDebugState(); + assert.equal( + debug.music.transition.active, + true, + 'same-key music retargeting during a crossfade must keep the transition active' + ); + assert.equal( + debug.ambience.transition.active, + true, + 'same-key ambience retargeting during a crossfade must keep the transition active' + ); + assert.equal( + debug.music.volumeRamp.active, + false, + 'music retargeting must wait until the active crossfade completes' + ); + assert.equal( + debug.ambience.volumeRamp.active, + false, + 'ambience retargeting must wait until the active crossfade completes' + ); + assert.equal(debug.music.targetVolume, 0.17, 'music must retain the newest deferred target'); + assert.equal(debug.ambience.targetVolume, 0.045, 'ambience must retain the newest deferred target'); + + clock.advance(1300); + debug = director.getDebugState(); + assert.equal(debug.music.transition.active, false, 'deferred music crossfade should complete'); + assert.equal(debug.ambience.transition.active, false, 'deferred ambience crossfade should complete'); + assert.equal( + debug.music.volumeRamp.active, + true, + 'music must begin a deferred gain ramp after its crossfade' + ); + assert.equal( + debug.ambience.volumeRamp.active, + true, + 'ambience must begin a deferred gain ramp after its crossfade' + ); + const delayedMusicB = audioHarness.instances + .filter((element) => element.originalSrc === '/audio/music-b.ogg') + .at(-1); + const delayedAmbienceB = audioHarness.instances + .filter((element) => element.originalSrc === '/audio/ambience-b.ogg') + .at(-1); + assert( + delayedMusicB.volume > 0.17 && delayedMusicB.volume < 0.2, + `deferred music ramp must interpolate from the transition target: ${delayedMusicB.volume}` + ); + assert( + delayedAmbienceB.volume > 0.045 && delayedAmbienceB.volume < 0.06, + `deferred ambience ramp must interpolate from the transition target: ${delayedAmbienceB.volume}` + ); + + clock.advance(420); + debug = director.getDebugState(); + assert.equal(debug.music.volumeRamp.active, false, 'deferred music ramp should settle'); + assert.equal(debug.ambience.volumeRamp.active, false, 'deferred ambience ramp should settle'); + assertClose(delayedMusicB.volume, 0.17, 'deferred music settled volume'); + assertClose(delayedAmbienceB.volume, 0.045, 'deferred ambience settled volume'); + + director.playSoundscape({ + musicKey: 'music-b', + ambienceKey: 'ambience-b', + musicVolume: 0.14, + ambienceVolume: 0.035 + }); + clock.advance(96); + const interruptedMusicVolume = delayedMusicB.volume; + const interruptedAmbienceVolume = delayedAmbienceB.volume; + debug = director.getDebugState(); + assert.equal(debug.music.volumeRamp.active, true, 'music interruption fixture must have an active ramp'); + assert.equal(debug.ambience.volumeRamp.active, true, 'ambience interruption fixture must have an active ramp'); + + director.playSoundscape({ + musicKey: 'music-c', + ambienceKey: 'ambience-c', + musicVolume: 0.16, + ambienceVolume: 0.04 + }); + debug = director.getDebugState(); + assert.equal( + debug.music.volumeRamp.active, + false, + 'a new music key must cancel the superseded gain ramp' + ); + assert.equal( + debug.ambience.volumeRamp.active, + false, + 'a new ambience key must cancel the superseded gain ramp' + ); + assertClose( + delayedMusicB.volume, + interruptedMusicVolume, + 'the interrupted music ramp must hand its current gain to the outgoing fade' + ); + assertClose( + delayedAmbienceB.volume, + interruptedAmbienceVolume, + 'the interrupted ambience ramp must hand its current gain to the outgoing fade' + ); + + clock.advance(1300); + debug = director.getDebugState(); + assertRetired(delayedMusicB, 'interrupted music ramp outgoing retirement'); + assertRetired(delayedAmbienceB, 'interrupted ambience ramp outgoing retirement'); + assertCompletedChannel(debug.music, 5, 0.16, 'interrupted music ramp transition'); + assertCompletedChannel(debug.ambience, 5, 0.04, 'interrupted ambience ramp transition'); + assert.equal( + audioHarness.activeInstances().length, + 2, + 'ramp interruption verification must leave one music and one ambience loop' + ); + const resumedMusicC = audioHarness.instances + .filter((element) => element.originalSrc === '/audio/music-c.ogg') + .at(-1); + const resumedAmbienceC = audioHarness.instances + .filter((element) => element.originalSrc === '/audio/ambience-c.ogg') + .at(-1); + + director.playSoundscape({ + musicKey: 'music-c', + ambienceKey: 'ambience-c', + musicVolume: 0.12, + ambienceVolume: 0.03 + }); + clock.advance(96); + director.playSoundscape({ + musicKey: 'music-late', + ambienceKey: 'ambience-late', + musicVolume: 0.71, + ambienceVolume: 0.33 + }); + debug = director.getDebugState(); + assert.equal(debug.pendingMusicKey, 'music-late', 'an unregistered music request must remain pending'); + assert.equal(debug.pendingAmbienceKey, 'ambience-late', 'an unregistered ambience request must remain pending'); + assert.equal(debug.music.volumeRamp.active, true, 'the current music ramp must continue while a future key is pending'); + assert.equal(debug.ambience.volumeRamp.active, true, 'the current ambience ramp must continue while a future key is pending'); + clock.advance(420); + debug = director.getDebugState(); + assert.equal(debug.music.volumeRamp.active, false, 'the current music ramp must settle while a future key remains pending'); + assert.equal(debug.ambience.volumeRamp.active, false, 'the current ambience ramp must settle while a future key remains pending'); + assertClose( + resumedMusicC.volume, + 0.12, + 'a pending unregistered music target must not replace the active ramp endpoint' + ); + assertClose( + resumedAmbienceC.volume, + 0.03, + 'a pending unregistered ambience target must not replace the active ramp endpoint' + ); + + director.playSoundscape({ + musicKey: 'music-c', + ambienceKey: 'ambience-c', + musicVolume: 0.16, + ambienceVolume: 0.04 + }); + clock.advance(420); + debug = director.getDebugState(); + assert.equal(debug.pendingMusicKey, null, 'returning to the current music key must clear the stale pending request'); + assert.equal(debug.pendingAmbienceKey, null, 'returning to the current ambience key must clear the stale pending request'); + assertClose(resumedMusicC.volume, 0.16, 'music should ramp back after clearing an unresolved request'); + assertClose(resumedAmbienceC.volume, 0.04, 'ambience should ramp back after clearing an unresolved request'); + director.stopAmbience(); debug = director.getDebugState(); assert.equal(debug.currentAmbienceKey, null, 'stopAmbience should clear the current ambience key immediately'); @@ -492,7 +733,7 @@ try { debug = director.getDebugState(); assert.equal(debug.ambience.transition.active, false, 'ambience stop transition should complete'); assert.equal(debug.ambience.activeElementCount, 0, 'ambience stop must retire its final element'); - assertRetired(audioHarness.byOriginalSrc('/audio/ambience-c.ogg'), 'stopped ambience'); + assertRetired(resumedAmbienceC, 'stopped ambience'); assert.equal(debug.music.activeElementCount, 1, 'stopping ambience must not disturb music'); director.playAmbience('battle-fire', 0.13); @@ -522,12 +763,19 @@ try { assert.equal(debug.currentAmbienceKey, null, 'legacy playMusic must clear camp ambience'); clock.advance(1300); debug = director.getDebugState(); + assert.equal(debug.music.volumeRamp.active, false, 'legacy same-key music ramp should settle'); + assertClose(resumedMusicC.volume, 0.22, 'legacy music settled volume'); assert.equal(debug.ambience.activeElementCount, 0, 'legacy playMusic ambience cleanup should complete'); assert.equal(audioHarness.activeInstances().length, 1, 'the verifier must finish without orphaned loop elements'); const tacticalCueStart = audioHarness.instances.length; assert.equal(director.playAllyTurnCue(), true, 'the first allied turn cue should play'); assertClose(audioHarness.byOriginalSrc('/audio/ally-turn.ogg').volume, 0.32, 'allied turn cue volume'); + assertClose( + audioHarness.byOriginalSrc('/audio/ally-turn.ogg').playbackRate, + 1, + 'semantic cues must not receive pooled combat rate variation' + ); assert.equal(director.playAllyTurnCue(), false, 'a repeated allied turn cue should be coalesced'); assert.equal(director.playEnemyTurnCue(), false, 'turn cue cooldown should be shared across factions'); @@ -637,9 +885,18 @@ try { const semanticEffectStart = audioHarness.instances.length; director.playMeleeSwing(true, false); - assert.equal(director.getDebugState().lastEffect?.key, 'melee-swing', 'melee API should use the swing pool'); + const firstMeleeSwing = director.getDebugState().lastEffect; + assert.equal(firstMeleeSwing?.key, 'melee-swing', 'melee API should use the swing pool'); + assertClose(firstMeleeSwing?.rate, 0.99, 'first pooled melee swing rate variation'); + director.playMeleeSwing(true, false); + const secondMeleeSwing = director.getDebugState().lastEffect; + assert.equal(secondMeleeSwing?.key, 'melee-swing', 'repeated melee API should keep using the swing pool'); + assertClose(secondMeleeSwing?.rate, 1.015, 'second pooled melee swing rate variation'); + assert.notEqual(firstMeleeSwing?.rate, secondMeleeSwing?.rate, 'pooled combat rate variation should advance by logical key'); director.playArrowShot(); - assert.equal(director.getDebugState().lastEffect?.key, 'bow-release', 'arrow API should play the bow release'); + const arrowShot = director.getDebugState().lastEffect; + assert.equal(arrowShot?.key, 'bow-release', 'arrow API should play the bow release'); + assertClose(arrowShot?.rate, 1, 'direct combat effects must not receive pooled variation'); director.playArrowImpact(false, false); assert.equal(director.getDebugState().lastEffect?.key, 'arrow-miss', 'missed arrows should use the miss pool'); director.playArrowImpact(true, true); @@ -696,10 +953,12 @@ try { 1, `semantic API verification should leave only music active after ${audioHarness.instances.length - semanticEffectStart} effects` ); + assert.equal(clock.timers.size, 0, 'completed and superseded audio ramps must not leave timers behind'); console.log( `Verified dual SoundDirector channels across ${debug.music.elementRevision} music and ` + `${debug.ambience.elementRevision} ambience element revisions, peak-aware loop gain, gain-balanced no-repeat effect pools, ` + + '400ms same-key gain ramps, deterministic movement and pooled-combat micro-variation, ' + 'route-sampled terrain movement with bounded foot and hoof voices, ' + 'coalesced tactical, narrative, and feedback cues, semantic combat effects, category multipliers, and deterministic music ducking.' ); diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index 69f95e0..94823fc 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -44,6 +44,7 @@ const checks = [ 'scripts/verify-story-asset-data.mjs', 'scripts/verify-story-environment-profiles.mjs', 'scripts/verify-story-image-asset-data.mjs', + 'scripts/verify-dialogue-portrait-layout.mjs', 'scripts/verify-visual-asset-data.mjs', 'scripts/verify-unit-sprite-asset-data.mjs', 'scripts/verify-battle-action-asset-budgets.mjs', diff --git a/scripts/verify-visual-motion-settings.mjs b/scripts/verify-visual-motion-settings.mjs index b9115ca..7ea7dfb 100644 --- a/scripts/verify-visual-motion-settings.mjs +++ b/scripts/verify-visual-motion-settings.mjs @@ -160,7 +160,73 @@ try { '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.'); + const explorationScenePaths = [ + 'src/game/scenes/PrologueVillageScene.ts', + 'src/game/scenes/PrologueMilitiaCampScene.ts', + 'src/game/scenes/CityStayScene.ts', + 'src/game/scenes/CampVisitExplorationScene.ts' + ]; + explorationScenePaths.forEach((scenePath) => { + const sceneSource = readFileSync(scenePath, 'utf8'); + assert( + sceneSource.includes("import { isVisualMotionReduced } from '../settings/visualMotion';") && + sceneSource.includes('this.visualMotionReduced = isVisualMotionReduced();'), + `${scenePath} must load the shared reduced-motion preference on creation.` + ); + assert( + sceneSource.includes('applyExplorationCharacterMotion(') && + sceneSource.includes('this.visualMotionReduced'), + `${scenePath} must use the shared motion helper so idle loops freeze without teleporting walks.` + ); + assert( + sceneSource.includes('if (!this.visualMotionReduced)') && + sceneSource.includes('if (this.visualMotionReduced)'), + `${scenePath} must suppress ambient loops and replace transition motion with static feedback.` + ); + assert( + sceneSource.includes('if (this.completionToast === toast)') && + sceneSource.includes('targets: toast'), + `${scenePath} must bind toast cleanup to the toast that created the timer or tween.` + ); + assert( + /this\.autoDialogueInputReadyAt\s*=\s*this\.time\.now \+ inputCarryoverGuardMs;/.test( + sceneSource + ) && + sceneSource.includes( + 'time >= this.autoDialogueInputReadyAt' + ) && + sceneSource.includes( + 'this.time.now < this.autoDialogueInputReadyAt' + ), + `${scenePath} must guard automatic-arrival dialogue from carry-over pointer and keyboard input without blocking modal controls.` + ); + }); + + assert( + readFileSync( + 'src/game/scenes/PrologueVillageScene.ts', + 'utf8' + ).includes('this.time.delayedCall(1510, clearToast)') && + readFileSync( + 'src/game/scenes/PrologueMilitiaCampScene.ts', + 'utf8' + ).includes('this.time.delayedCall(1510, clearToast)') && + readFileSync( + 'src/game/scenes/CityStayScene.ts', + 'utf8' + ).includes('this.time.delayedCall(1880, clearToast)') && + readFileSync( + 'src/game/scenes/CampVisitExplorationScene.ts', + 'utf8' + ).includes( + 'this.time.delayedCall(duration + 380, clearToast)' + ), + 'Reduced-motion toasts must preserve the same total reading time as their animated variants.' + ); + + console.info( + 'Verified visual motion defaults, persistence, title controls, and story/battle/exploration motion guards.' + ); } finally { await server.close(); } diff --git a/src/game/audio/SoundDirector.ts b/src/game/audio/SoundDirector.ts index 2c5338d..6b7e1dc 100644 --- a/src/game/audio/SoundDirector.ts +++ b/src/game/audio/SoundDirector.ts @@ -35,6 +35,17 @@ type LoopChannelTransition = { }; }; +type LoopChannelVolumeRamp = { + revision: number; + completedRevision: number; + active: boolean; + last?: { + fromVolume: number; + toVolume: number; + durationMs: number; + }; +}; + type LoopChannel = { name: LoopChannelName; tracks: AudioTrackMap; @@ -50,8 +61,11 @@ type LoopChannel = { elementBaseVolumes: Map; elementTrackKeys: Map; fadeTimer?: number; + transitionTargetVolume?: number; + volumeRampTimer?: number; elementRevision: number; transition: LoopChannelTransition; + volumeRamp: LoopChannelVolumeRamp; }; export type PlayEffectOptions = { @@ -135,6 +149,17 @@ type EffectPlaybackPolicy = { excludedTrackKeys?: ReadonlySet; }; +const loopTargetVolumeRampMs = 400; +const movementStepRatePattern = [0.985, 1.015, 0.995, 1.025, 0.975] as const; +const movementStepGainPattern = [0.96, 1.03, 0.99, 1.04, 0.98] as const; +const pooledCombatRatePattern = [0.99, 1.015, 1, 0.985, 1.01] as const; +const pooledCombatEffectKeys = new Set([ + 'melee-swing', + 'melee-impact', + 'arrow-impact', + 'arrow-miss' +]); + export class SoundDirector { private context?: AudioContext; private master?: GainNode; @@ -144,6 +169,7 @@ export class SoundDirector { private effectTracks: AudioTrackMap = {}; private effectPools: Record = {}; private readonly lastEffectPoolSelections = new Map(); + private readonly pooledCombatEffectPlayCounts = new Map(); private readonly activeEffects = new Set(); private readonly effectBaseVolumes = new Map(); private readonly effectTrackKeys = new Map(); @@ -208,6 +234,7 @@ export class SoundDirector { ]) ); this.lastEffectPoolSelections.clear(); + this.pooledCombatEffectPlayCounts.clear(); } start() { @@ -398,8 +425,15 @@ export class SoundDirector { const { terrain, ...effectOptions } = options; const surface = movementSurfaceForTerrain(terrain); const key = movementEffectPoolsBySurface[surface][mounted ? 'mounted' : 'foot']; + const variationIndex = this.positivePatternIndex(stepIndex, movementStepRatePattern.length); + const rateMultiplier = movementStepRatePattern[variationIndex]; + const gainMultiplier = movementStepGainPattern[variationIndex]; + const requestedVolume = effectOptions.volume ?? this.effectVolume; + const requestedRate = effectOptions.rate ?? 1; const movementEffectOptions = { ...effectOptions, + volume: requestedVolume * gainMultiplier, + rate: requestedRate * rateMultiplier, stopAfterMs: effectOptions.stopAfterMs === undefined ? undefined : Math.max(effectOptions.stopAfterMs, mounted ? 540 : 560) @@ -413,10 +447,10 @@ export class SoundDirector { terrain: terrain ?? null, surface, at: performance.now(), - volume: effectOptions.volume, - rate: effectOptions.rate + volume: movementEffectOptions.volume, + rate: movementEffectOptions.rate }; - this.playStepPulse(mounted, stepIndex, options.volume); + this.playStepPulse(mounted, stepIndex, movementEffectOptions.volume); return played; } @@ -623,9 +657,10 @@ export class SoundDirector { const baseVolume = this.normalizeUnitInterval(options.volume ?? this.effectVolume, this.effectVolume); const trackGain = this.effectTrackGain(resolvedTrack.trackKey); const compensatedBaseVolume = this.normalizeUnitInterval(baseVolume * trackGain, baseVolume); + const rateMultiplier = this.pooledCombatEffectRateMultiplier(resolvedTrack.poolKey); effect.preload = 'auto'; effect.volume = this.effectOutputVolume(compensatedBaseVolume); - effect.playbackRate = options.rate ?? 1; + effect.playbackRate = (options.rate ?? 1) * rateMultiplier; effect.muted = this.muted; this.activeEffects.add(effect); this.effectBaseVolumes.set(effect, compensatedBaseVolume); @@ -872,6 +907,11 @@ export class SoundDirector { revision: 0, completedRevision: 0, active: false + }, + volumeRamp: { + revision: 0, + completedRevision: 0, + active: false } }; } @@ -913,7 +953,11 @@ export class SoundDirector { if (channel.current) { channel.current.muted = this.muted; if (!channel.transition.active) { - this.setLoopElementBaseVolume(channel, channel.current, channel.targetVolume); + this.beginLoopVolumeRamp( + channel, + channel.current, + channel.targetVolume + ); } void channel.current.play().catch(() => undefined); } @@ -932,7 +976,10 @@ export class SoundDirector { private beginLoopTransition(channel: LoopChannel, key?: string, src?: string) { this.cancelLoopTransitionTimer(channel); + this.cancelLoopVolumeRampTimer(channel); + channel.volumeRamp.active = false; this.retireOutgoingElements(channel); + channel.transitionTargetVolume = channel.targetVolume; const previous = channel.current; const fromKey = channel.currentKey ?? null; @@ -974,7 +1021,11 @@ export class SoundDirector { const progress = Math.min(1, Math.max(0, (performance.now() - startedAt) / channel.fadeDurationMs)); if (channel.current) { - this.setLoopElementBaseVolume(channel, channel.current, channel.targetVolume * progress); + this.setLoopElementBaseVolume( + channel, + channel.current, + (channel.transitionTargetVolume ?? channel.targetVolume) * progress + ); } channel.outgoing.forEach((element) => { const startVolume = channel.outgoingStartVolumes.get(element) ?? channel.elementBaseVolumes.get(element) ?? 0; @@ -995,10 +1046,22 @@ export class SoundDirector { this.cancelLoopTransitionTimer(channel); this.retireOutgoingElements(channel); if (channel.current) { - this.setLoopElementBaseVolume(channel, channel.current, channel.targetVolume); + this.setLoopElementBaseVolume( + channel, + channel.current, + channel.transitionTargetVolume ?? channel.targetVolume + ); } + channel.transitionTargetVolume = undefined; channel.transition.active = false; channel.transition.completedRevision = transitionRevision; + if (channel.current) { + this.beginLoopVolumeRamp( + channel, + channel.current, + channel.targetVolume + ); + } } private cancelLoopTransitionTimer(channel: LoopChannel) { @@ -1008,6 +1071,99 @@ export class SoundDirector { } } + private beginLoopVolumeRamp( + channel: LoopChannel, + element: HTMLAudioElement, + targetVolume: number + ) { + if ( + channel.volumeRamp.active && + channel.volumeRamp.last && + Math.abs(channel.volumeRamp.last.toVolume - targetVolume) < 0.0001 + ) { + return; + } + + const fromVolume = + channel.elementBaseVolumes.get(element) ?? targetVolume; + if (Math.abs(fromVolume - targetVolume) < 0.0001) { + this.cancelLoopVolumeRampTimer(channel); + channel.volumeRamp.active = false; + this.setLoopElementBaseVolume(channel, element, targetVolume); + return; + } + + this.cancelLoopVolumeRampTimer(channel); + channel.volumeRamp.revision += 1; + channel.volumeRamp.active = true; + channel.volumeRamp.last = { + fromVolume, + toVolume: targetVolume, + durationMs: loopTargetVolumeRampMs + }; + + const revision = channel.volumeRamp.revision; + const startedAt = performance.now(); + const update = () => { + if ( + revision !== channel.volumeRamp.revision || + channel.current !== element + ) { + return; + } + + const progress = Math.min( + 1, + Math.max( + 0, + (performance.now() - startedAt) / loopTargetVolumeRampMs + ) + ); + this.setLoopElementBaseVolume( + channel, + element, + fromVolume + (targetVolume - fromVolume) * progress + ); + if (progress >= 1) { + this.completeLoopVolumeRamp( + channel, + revision, + targetVolume + ); + } + }; + + channel.volumeRampTimer = window.setInterval(update, 16); + } + + private completeLoopVolumeRamp( + channel: LoopChannel, + revision: number, + targetVolume: number + ) { + if (revision !== channel.volumeRamp.revision) { + return; + } + + this.cancelLoopVolumeRampTimer(channel); + if (channel.current) { + this.setLoopElementBaseVolume( + channel, + channel.current, + targetVolume + ); + } + channel.volumeRamp.active = false; + channel.volumeRamp.completedRevision = revision; + } + + private cancelLoopVolumeRampTimer(channel: LoopChannel) { + if (channel.volumeRampTimer !== undefined) { + window.clearInterval(channel.volumeRampTimer); + channel.volumeRampTimer = undefined; + } + } + private retireOutgoingElements(channel: LoopChannel) { channel.outgoing.forEach((element) => this.retireLoopElement(channel, element)); channel.outgoing.clear(); @@ -1049,6 +1205,12 @@ export class SoundDirector { completedRevision: channel.transition.completedRevision, active: channel.transition.active, last: channel.transition.last ? { ...channel.transition.last } : null + }, + volumeRamp: { + revision: channel.volumeRamp.revision, + completedRevision: channel.volumeRamp.completedRevision, + active: channel.volumeRamp.active, + last: channel.volumeRamp.last ? { ...channel.volumeRamp.last } : null } }; } @@ -1076,6 +1238,18 @@ export class SoundDirector { return src ? { trackKey: key, src, poolKey: null } : undefined; } + private pooledCombatEffectRateMultiplier(poolKey: string | null) { + if (!poolKey || !pooledCombatEffectKeys.has(poolKey)) { + return 1; + } + + const playCount = this.pooledCombatEffectPlayCounts.get(poolKey) ?? 0; + this.pooledCombatEffectPlayCounts.set(poolKey, playCount + 1); + return pooledCombatRatePattern[ + playCount % pooledCombatRatePattern.length + ]; + } + private refreshActiveEffectVolumes() { this.activeEffects.forEach((effect) => { const baseVolume = this.effectBaseVolumes.get(effect); @@ -1190,6 +1364,13 @@ export class SoundDirector { private normalizeDuration(value: number, fallback: number) { return Number.isFinite(value) ? Math.min(10_000, Math.max(0, value)) : fallback; } + + private positivePatternIndex(value: number, length: number) { + const normalizedValue = Number.isFinite(value) + ? Math.trunc(value) + : 0; + return ((normalizedValue % length) + length) % length; + } } export function movementSurfaceForTerrain(terrain?: MovementTerrain | string): MovementSurface { diff --git a/src/game/data/campSoundscapes.ts b/src/game/data/campSoundscapes.ts index 9b1f1b3..1da5bcb 100644 --- a/src/game/data/campSoundscapes.ts +++ b/src/game/data/campSoundscapes.ts @@ -1,4 +1,8 @@ -import type { AmbienceTrackKey, MusicTrackKey } from '../audio/audioAssets'; +import { + musicTrackIntegratedLoudnessLufs, + type AmbienceTrackKey, + type MusicTrackKey +} from '../audio/audioAssets'; import type { CampSkinId } from './campSkins'; export type CampMusicGroupId = 'rally' | 'wayfarer' | 'grand-strategy' | 'frontier'; @@ -117,3 +121,41 @@ export function getCampSoundscape(skinId: CampSkinId): CampSoundscape { ambience: campAmbienceDefinitions[mapping.ambienceId] }; } + +export function campMusicVolumeForTrackKey( + trackKey: string, + fallbackVolume = 0.2 +) { + return Object.values(campMusicGroups).find( + (definition) => definition.trackKey === trackKey + )?.volume ?? fallbackVolume; +} + +export function musicVolumeMatchedToCampTrack( + trackKey: MusicTrackKey, + referenceTrackKey: CampMusicTrackKey = 'camp-rally', + fallbackVolume = campMusicVolumeForTrackKey(referenceTrackKey) +) { + const trackLoudness = musicTrackIntegratedLoudnessLufs[trackKey]; + const referenceLoudness = + musicTrackIntegratedLoudnessLufs[referenceTrackKey]; + const referenceVolume = campMusicVolumeForTrackKey( + referenceTrackKey, + fallbackVolume + ); + if ( + !Number.isFinite(trackLoudness) || + !Number.isFinite(referenceLoudness) || + !Number.isFinite(referenceVolume) || + referenceVolume <= 0 + ) { + return fallbackVolume; + } + + const matchedVolume = + referenceVolume * + 10 ** ((referenceLoudness - trackLoudness) / 20); + return Math.round( + Math.min(1, Math.max(0.01, matchedVolume)) * 1000 + ) / 1000; +} diff --git a/src/game/data/explorationCharacterAssets.ts b/src/game/data/explorationCharacterAssets.ts index 9ece69f..aad396d 100644 --- a/src/game/data/explorationCharacterAssets.ts +++ b/src/game/data/explorationCharacterAssets.ts @@ -200,6 +200,55 @@ export function explorationCharacterAnimationKey( return `${textureKey}-${motion}-${direction}`; } +export function explorationCharacterFrameFor( + motion: ExplorationCharacterMotion, + direction: ExplorationCharacterDirection, + frameIndex = 0 +) { + const rowStart = + explorationCharacterSheetRows[direction] * explorationCharacterFramesPerDirection; + const motionStart = motion === 'walk' ? explorationCharacterIdleFrameCount : 0; + const motionFrameCount = motion === 'walk' + ? explorationCharacterWalkFrameCount + : explorationCharacterIdleFrameCount; + const normalizedFrameIndex = Math.max( + 0, + Math.min(motionFrameCount - 1, Math.trunc(frameIndex)) + ); + return rowStart + motionStart + normalizedFrameIndex; +} + +export function applyExplorationCharacterMotion( + sprite: Phaser.GameObjects.Sprite, + textureKey: ExplorationCharacterTextureKey, + motion: ExplorationCharacterMotion, + direction: ExplorationCharacterDirection, + reducedMotion = false +) { + if (!sprite.scene.textures.exists(textureKey)) { + return false; + } + + if (reducedMotion && motion === 'idle') { + sprite.anims.stop(); + sprite.setFrame(explorationCharacterFrameFor('idle', direction)); + return true; + } + + const animationKey = explorationCharacterAnimationKey( + textureKey, + motion, + direction + ); + if ( + sprite.anims.currentAnim?.key !== animationKey || + !sprite.anims.isPlaying + ) { + sprite.play(animationKey); + } + return true; +} + export function ensureExplorationCharacterAnimations( scene: Phaser.Scene, textureKeysOrUnitTextureKeys: Iterable @@ -210,20 +259,18 @@ export function ensureExplorationCharacterAnimations( } explorationCharacterDirections.forEach((direction) => { - const rowStart = - explorationCharacterSheetRows[direction] * explorationCharacterFramesPerDirection; const idleFrames = Array.from( { length: explorationCharacterIdleFrameCount }, (_, frameIndex) => ({ key, - frame: rowStart + frameIndex + frame: explorationCharacterFrameFor('idle', direction, frameIndex) }) ); const walkFrames = Array.from( { length: explorationCharacterWalkFrameCount }, (_, frameIndex) => ({ key, - frame: rowStart + explorationCharacterIdleFrameCount + frameIndex + frame: explorationCharacterFrameFor('walk', direction, frameIndex) }) ); const idleAnimationKey = explorationCharacterAnimationKey(key, 'idle', direction); diff --git a/src/game/scenes/CampVisitExplorationScene.ts b/src/game/scenes/CampVisitExplorationScene.ts index 14c4403..12f1224 100644 --- a/src/game/scenes/CampVisitExplorationScene.ts +++ b/src/game/scenes/CampVisitExplorationScene.ts @@ -4,14 +4,15 @@ import secondReliefBackgroundUrl from '../../assets/images/exploration/second-pu import thirdCampBackgroundUrl from '../../assets/images/exploration/third-guangzong-sortie-camp.webp'; import { soundDirector } from '../audio/SoundDirector'; import { + applyExplorationCharacterMotion, ensureExplorationCharacterAnimations, - explorationCharacterAnimationKey, explorationCharacterAssetInfos, explorationCharacterTextureKeyByUnitTextureKey, releaseExplorationCharacterTextureKeys, type ExplorationCharacterDirection, type ExplorationCharacterTextureKey } from '../data/explorationCharacterAssets'; +import { campMusicVolumeForTrackKey } from '../data/campSoundscapes'; import { firstPursuitScoutSourceBattleId, firstPursuitScoutVisitId @@ -61,6 +62,7 @@ import { recordThirdCampExplorationActivity, thirdCampExplorationProgress } from '../state/thirdCampExplorationActions'; +import { fitDialogueFacePortrait } from '../ui/dialoguePortrait'; import { startGameScene } from './lazyScenes'; const sceneWidth = 1920; @@ -337,9 +339,13 @@ export class CampVisitExplorationScene extends Phaser.Scene { private lastNavigationTargetId?: InteractionTarget['id']; private navigationReplanAttempts = 0; private navigationDetourCount = 0; + private autoInteractionPending = false; + private autoInteractionTriggeredCount = 0; + private lastAutoInteractionTargetId?: ExplorationActorId; private ready = false; private navigationPending = false; private inputReadyAt = Number.POSITIVE_INFINITY; + private autoDialogueInputReadyAt = 0; private stepIndex = 0; private lastStepAt = 0; private lastNotice = ''; @@ -448,13 +454,13 @@ export class CampVisitExplorationScene extends Phaser.Scene { ? { musicKey: 'camp-rally', ambienceKey: 'riverside-village-day-ambience', - musicVolume: 0.2, + musicVolume: campMusicVolumeForTrackKey('camp-rally'), ambienceVolume: 0.11 } : { musicKey: 'camp-rally', ambienceKey: 'campfire-ambience', - musicVolume: 0.22, + musicVolume: campMusicVolumeForTrackKey('camp-rally'), ambienceVolume: 0.12 } ); @@ -494,7 +500,10 @@ export class CampVisitExplorationScene extends Phaser.Scene { } if (this.dialogueState) { this.stopPlayerMovement(); - if (this.consumeInteractionRequest()) { + if ( + this.consumeInteractionRequest() && + time >= this.autoDialogueInputReadyAt + ) { this.advanceDialogue(); } return; @@ -575,6 +584,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { moving: this.playerMoving, textureKey: this.player?.texture.key ?? null, animationKey: this.player?.anims.currentAnim?.key ?? null, + animationPlaying: this.player?.anims.isPlaying ?? false, bounds: this.player ? this.boundsSnapshot(this.player.getBounds()) : null @@ -590,7 +600,16 @@ export class CampVisitExplorationScene extends Phaser.Scene { waypoints: this.moveWaypoints.map(({ x, y }) => ({ x, y })), walkBounds: this.boundsSnapshot(activeMovementBounds), collisionRadius: playerCollisionRadius, - detourCount: this.navigationDetourCount + detourCount: this.navigationDetourCount, + autoInteraction: { + pending: this.autoInteractionPending, + targetId: this.autoInteractionPending + ? this.moveTargetId ?? null + : null, + triggeredCount: this.autoInteractionTriggeredCount, + lastTriggeredTargetId: + this.lastAutoInteractionTargetId ?? null + } }, controls: { movement: ['WASD', '방향키'], @@ -628,6 +647,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { Math.abs(sprite.y - initialPosition.y) > 0.1, textureKey: sprite.texture.key, animationKey: sprite.anims.currentAnim?.key ?? null, + animationPlaying: sprite.anims.isPlaying, objectiveId: definition.objectiveId ?? null, activityId: definition.activityId ?? null, completed: @@ -684,7 +704,9 @@ export class CampVisitExplorationScene extends Phaser.Scene { portraitFrameVisible: this.dialoguePortraitFrame?.visible ?? false, bounds: this.dialoguePanel?.visible - ? this.boundsSnapshot(this.dialoguePanel.getBounds()) + ? this.boundsSnapshot( + new Phaser.Geom.Rectangle(0, 0, sceneWidth, sceneHeight) + ) : null } : { @@ -1113,9 +1135,13 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.lastNavigationTargetId = undefined; this.navigationReplanAttempts = 0; this.navigationDetourCount = 0; + this.autoInteractionPending = false; + this.autoInteractionTriggeredCount = 0; + this.lastAutoInteractionTargetId = undefined; this.ready = false; this.navigationPending = false; this.inputReadyAt = Number.POSITIVE_INFINITY; + this.autoDialogueInputReadyAt = 0; this.explorationInput = undefined; this.stepIndex = 0; this.lastStepAt = 0; @@ -2182,11 +2208,13 @@ export class CampVisitExplorationScene extends Phaser.Scene { const sprite = this.add .sprite(x, y, resolvedTextureKey, 0) .setDisplaySize(size, size); - if (this.textures.exists(textureKey)) { - sprite.play( - explorationCharacterAnimationKey(textureKey, 'idle', direction) - ); - } + applyExplorationCharacterMotion( + sprite, + textureKey, + 'idle', + direction, + this.visualMotionReduced + ); return sprite; } @@ -2279,6 +2307,9 @@ export class CampVisitExplorationScene extends Phaser.Scene { return; } if (this.dialogueState) { + if (this.time.now < this.autoDialogueInputReadyAt) { + return; + } this.advanceDialogue(); return; } @@ -2326,6 +2357,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { } const keyboardDirection = this.keyboardMovementDirection(); let movement = keyboardDirection; + let pointerDistanceRemaining = Number.POSITIVE_INFINITY; if (movement.lengthSq() > 0) { this.clearNavigationTarget(true); } else if (this.moveTarget) { @@ -2333,12 +2365,19 @@ export class CampVisitExplorationScene extends Phaser.Scene { const targetDelta = activeTarget .clone() .subtract(new Phaser.Math.Vector2(this.player.x, this.player.y)); - if (targetDelta.length() <= navigationArrivalRadius) { + pointerDistanceRemaining = targetDelta.length(); + if (pointerDistanceRemaining <= navigationArrivalRadius) { if (this.moveWaypoints.length > 0) { this.moveWaypoints.shift(); } else { - this.lastNavigationTargetId = this.moveTargetId; + const arrivedTargetId = this.moveTargetId; + const shouldAutoInteract = this.autoInteractionPending; + this.lastNavigationTargetId = arrivedTargetId; this.clearNavigationTarget(false); + this.setPlayerMoving(false); + if (arrivedTargetId && shouldAutoInteract) { + this.triggerAutoInteraction(arrivedTargetId); + } } movement = new Phaser.Math.Vector2(); } else { @@ -2352,7 +2391,10 @@ export class CampVisitExplorationScene extends Phaser.Scene { } movement.normalize(); this.playerDirection = this.directionForVector(movement); - const distance = (playerSpeed * Math.min(delta, 50)) / 1000; + const distance = Math.min( + (playerSpeed * Math.min(delta, 50)) / 1000, + pointerDistanceRemaining + ); const startX = this.player.x; const startY = this.player.y; const nextX = startX + movement.x * distance; @@ -2464,6 +2506,9 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.moveTargetId = targetId; this.lastNavigationTargetId = undefined; this.navigationReplanAttempts = 0; + this.autoInteractionPending = Boolean( + targetId && targetId !== 'camp-exit' + ); if (route.length > 1) { this.navigationDetourCount += 1; } @@ -2698,11 +2743,50 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.moveWaypoints = []; this.moveTargetId = undefined; this.navigationReplanAttempts = 0; + this.autoInteractionPending = false; if (clearLast) { this.lastNavigationTargetId = undefined; } } + private triggerAutoInteraction(targetId: InteractionTarget['id']) { + if ( + this.navigationPending || + this.dialogueState || + this.choicePanel || + !this.ready + ) { + return; + } + const target = this.interactionTargetById(targetId); + if (!target || target.kind !== 'actor') { + return; + } + if (this.distanceTo(target.x, target.y) > interactionRadius + 8) { + const recoveryRoute = this.approachPositions(target.x, target.y) + .map((destination) => this.planNavigationRoute(destination)) + .filter( + (route): route is Phaser.Math.Vector2[] => Boolean(route) + ) + .sort( + (left, right) => + this.navigationRouteLength(left) - + this.navigationRouteLength(right) + )[0]; + if (recoveryRoute) { + this.applyNavigationRoute(recoveryRoute, target.id); + } + return; + } + this.autoInteractionTriggeredCount += 1; + this.lastAutoInteractionTargetId = target.id; + this.interactWithTarget(target); + if (this.dialogueState) { + this.autoDialogueInputReadyAt = + this.time.now + inputCarryoverGuardMs; + } + } + private consumeInteractionRequest() { return this.explorationInput?.consumeInteractionRequest() ?? false; } @@ -2755,17 +2839,13 @@ export class CampVisitExplorationScene extends Phaser.Scene { if (!this.player) { return; } - const animationKey = explorationCharacterAnimationKey( + applyExplorationCharacterMotion( + this.player, playerTextureKey, moving ? 'walk' : 'idle', - this.playerDirection + this.playerDirection, + this.visualMotionReduced ); - if ( - this.textures.exists(playerTextureKey) && - this.player.anims.currentAnim?.key !== animationKey - ) { - this.player.play(animationKey); - } this.playerMoving = moving; } @@ -3110,22 +3190,22 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.playerDirection = this.directionForVector(toNpc); this.setPlayerMoving(false); const npcDirection = this.directionForVector(toNpc.scale(-1)); - view.sprite.play( - explorationCharacterAnimationKey( - view.definition.textureKey, - 'idle', - npcDirection - ) + applyExplorationCharacterMotion( + view.sprite, + view.definition.textureKey, + 'idle', + npcDirection, + this.visualMotionReduced ); } private restoreActorDirection(view: ExplorationActorView) { - view.sprite.play( - explorationCharacterAnimationKey( - view.definition.textureKey, - 'idle', - view.definition.direction - ) + applyExplorationCharacterMotion( + view.sprite, + view.definition.textureKey, + 'idle', + view.definition.direction, + this.visualMotionReduced ); } @@ -3184,7 +3264,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.dialoguePortrait .setTexture(portraitEntry.textureKey) .setVisible(true); - this.fitDialogueFacePortrait(this.dialoguePortrait); + fitDialogueFacePortrait(this.dialoguePortrait, 220); return; } this.dialoguePortraitFrame?.setVisible(false); @@ -3201,24 +3281,6 @@ export class CampVisitExplorationScene extends Phaser.Scene { : undefined; } - private fitDialogueFacePortrait(portrait: Phaser.GameObjects.Image) { - const sourceWidth = portrait.frame.realWidth; - const sourceHeight = portrait.frame.realHeight; - const cropSize = Math.round( - Math.min(sourceWidth, sourceHeight) * 0.6 - ); - const cropX = Math.round((sourceWidth - cropSize) / 2); - const cropY = Math.round( - Math.min( - Math.max(0, sourceHeight * 0.035), - sourceHeight - cropSize - ) - ); - portrait - .setCrop(cropX, cropY, cropSize, cropSize) - .setScale(220 / cropSize); - } - private advanceDialogue() { const dialogue = this.dialogueState; if (!dialogue || this.navigationPending) { @@ -4059,25 +4121,26 @@ export class CampVisitExplorationScene extends Phaser.Scene { align: 'center' }) .setOrigin(0.5); - this.completionToast = this.add + const toast = this.add .container(0, 0, [background, text]) .setDepth(3300); - if (this.visualMotionReduced) { - this.time.delayedCall(duration, () => { - this.completionToast?.destroy(); + this.completionToast = toast; + const clearToast = () => { + toast.destroy(); + if (this.completionToast === toast) { this.completionToast = undefined; - }); + } + }; + if (this.visualMotionReduced) { + this.time.delayedCall(duration + 380, clearToast); } else { this.tweens.add({ - targets: this.completionToast, + targets: toast, alpha: 0, y: -18, delay: duration, duration: 380, - onComplete: () => { - this.completionToast?.destroy(); - this.completionToast = undefined; - } + onComplete: clearToast }); } } diff --git a/src/game/scenes/CityStayScene.ts b/src/game/scenes/CityStayScene.ts index 253397e..06f3c49 100644 --- a/src/game/scenes/CityStayScene.ts +++ b/src/game/scenes/CityStayScene.ts @@ -19,14 +19,15 @@ import { type CityStayId } from '../data/cityStays'; import type { BattleScenarioId } from '../data/battles'; +import { campMusicVolumeForTrackKey } from '../data/campSoundscapes'; import { equipmentSlotLabels, getItem, itemInventoryLabel } from '../data/battleItems'; import { + applyExplorationCharacterMotion, ensureExplorationCharacterAnimations, - explorationCharacterAnimationKey, explorationCharacterAssetInfo, explorationCharacterAssetInfos, releaseExplorationCharacterTextureKeys, @@ -40,6 +41,7 @@ import { } from '../data/portraitAssets'; import { ExplorationInputController } from '../input/ExplorationInputController'; import { releaseTextureSource } from '../render/loaderLifecycle'; +import { isVisualMotionReduced } from '../settings/visualMotion'; import { chooseCityStayResonance, collectCityStayInformation, @@ -51,6 +53,7 @@ import { setActiveCityStayId, type CampaignState } from '../state/campaignState'; +import { fitDialogueFacePortrait } from '../ui/dialoguePortrait'; import { startGameScene } from './lazyScenes'; const sceneWidth = 1920; @@ -200,9 +203,11 @@ export class CityStayScene extends Phaser.Scene { private ready = false; private navigationPending = false; private inputReadyAt = Number.POSITIVE_INFINITY; + private autoDialogueInputReadyAt = 0; private stepIndex = 0; private lastStepAt = 0; private lastNotice = ''; + private visualMotionReduced = false; private ownedPresentationTextureKeys = new Set(); constructor() { @@ -249,6 +254,7 @@ export class CityStayScene extends Phaser.Scene { create() { this.resetRuntimeState(); + this.visualMotionReduced = isVisualMotionReduced(); this.markCityStayActive(); this.drawCity(); this.drawHud(); @@ -268,7 +274,7 @@ export class CityStayScene extends Phaser.Scene { soundDirector.playSoundscape({ musicKey: this.profile.musicKey, ambienceKey: this.profile.ambienceKey, - musicVolume: 0.68, + musicVolume: campMusicVolumeForTrackKey(this.profile.musicKey), ambienceVolume: 0.1 }); @@ -301,7 +307,10 @@ export class CityStayScene extends Phaser.Scene { if (this.dialogueState) { this.stopPlayerMovement(); - if (this.consumeInteractionRequest()) { + if ( + this.consumeInteractionRequest() && + time >= this.autoDialogueInputReadyAt + ) { this.advanceDialogue(); } return; @@ -330,6 +339,7 @@ export class CityStayScene extends Phaser.Scene { ready: this.ready, cityStayId: this.cityStay.id, cityName: this.cityStay.city.name, + visualMotionReduced: this.visualMotionReduced, viewport: { width: this.scale.width, height: this.scale.height }, activeCityStayId: campaign.activeCityStayId ?? null, background: { @@ -354,6 +364,7 @@ export class CityStayScene extends Phaser.Scene { textureKey: this.player?.texture.key ?? null, animationKey: this.player?.anims.currentAnim?.key ?? null, + animationPlaying: this.player?.anims.isPlaying ?? false, bounds: this.player ? this.boundsSnapshot(this.player.getBounds()) : null } : null, @@ -400,6 +411,9 @@ export class CityStayScene extends Phaser.Scene { marketOptional: true, exitUnlocked: true }, + objective: { + dialogueUnitNames: this.cityDialogueUnitNames() + }, actors: Array.from(this.actors.values()).map(({ definition, sprite }) => ({ id: definition.id, kind: definition.kind, @@ -407,6 +421,7 @@ export class CityStayScene extends Phaser.Scene { role: definition.role, textureKey: sprite.texture.key, animationKey: sprite.anims.currentAnim?.key ?? null, + animationPlaying: sprite.anims.isPlaying, x: sprite.x, y: sprite.y, initialX: definition.x, @@ -583,6 +598,7 @@ export class CityStayScene extends Phaser.Scene { this.ready = false; this.navigationPending = false; this.inputReadyAt = Number.POSITIVE_INFINITY; + this.autoDialogueInputReadyAt = 0; this.explorationInput = undefined; this.stepIndex = 0; this.lastStepAt = 0; @@ -1152,14 +1168,16 @@ export class CityStayScene extends Phaser.Scene { backgroundColor: '#e5bd68', padding: { left: 10, right: 10, top: 3, bottom: 3 } }).setOrigin(0.5).setDepth(1201); - this.tweens.add({ - targets: marker, - y: marker.y - 8, - duration: 720, - yoyo: true, - repeat: -1, - ease: 'Sine.easeInOut' - }); + if (!this.visualMotionReduced) { + this.tweens.add({ + targets: marker, + y: marker.y - 8, + duration: 720, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut' + }); + } this.actors.set(definition.id, { definition, textureKey, @@ -1180,13 +1198,15 @@ export class CityStayScene extends Phaser.Scene { backgroundColor: '#3a3024dd', padding: { left: 10, right: 10, top: 4, bottom: 4 } }).setOrigin(0.5).setDepth(1201); - this.tweens.add({ - targets: exitMarker, - alpha: { from: 0.68, to: 1 }, - duration: 880, - yoyo: true, - repeat: -1 - }); + if (!this.visualMotionReduced) { + this.tweens.add({ + targets: exitMarker, + alpha: { from: 0.68, to: 1 }, + duration: 880, + yoyo: true, + repeat: -1 + }); + } } private createCharacterSprite( @@ -1198,15 +1218,13 @@ export class CityStayScene extends Phaser.Scene { ) { const resolvedTextureKey = this.textures.exists(textureKey) ? textureKey : '__DEFAULT'; const sprite = this.add.sprite(x, y, resolvedTextureKey, 0).setDisplaySize(size, size); - if (this.textures.exists(textureKey)) { - sprite.play( - explorationCharacterAnimationKey( - textureKey, - 'idle', - direction - ) - ); - } + applyExplorationCharacterMotion( + sprite, + textureKey, + 'idle', + direction, + this.visualMotionReduced + ); return sprite; } @@ -1290,6 +1308,9 @@ export class CityStayScene extends Phaser.Scene { return; } if (this.dialogueState) { + if (this.time.now < this.autoDialogueInputReadyAt) { + return; + } this.advanceDialogue(); return; } @@ -1801,6 +1822,10 @@ export class CityStayScene extends Phaser.Scene { this.autoInteractionTriggeredCount += 1; this.lastAutoInteractionTargetId = target.id; this.interactWithTarget(target); + if (this.dialogueState) { + this.autoDialogueInputReadyAt = + this.time.now + inputCarryoverGuardMs; + } } private consumeInteractionRequest() { @@ -1850,17 +1875,13 @@ export class CityStayScene extends Phaser.Scene { if (!this.player) { return; } - const animationKey = explorationCharacterAnimationKey( + applyExplorationCharacterMotion( + this.player, playerTextureKey, moving ? 'walk' : 'idle', - this.playerDirection + this.playerDirection, + this.visualMotionReduced ); - if ( - this.textures.exists(playerTextureKey) && - this.player.anims.currentAnim?.key !== animationKey - ) { - this.player.play(animationKey); - } this.playerMoving = moving; } @@ -2142,11 +2163,16 @@ export class CityStayScene extends Phaser.Scene { soundDirector.playEffect('bond-resonance', { volume: 0.28, stopAfterMs: 1100 }); this.refreshProgressHud(); this.refreshActorMarkers(); + const companion = this.companionActor(); + const companionView = this.actors.get(companion.id); + if (companionView) { + this.faceCharactersForConversation(companionView); + } this.startDialogue([ { - speaker: this.companionActor().name, + speaker: companion.name, portraitKey: this.actorPortraitKey( - this.companionActor() + companion ), text: choice.response }, @@ -2154,7 +2180,9 @@ export class CityStayScene extends Phaser.Scene { speaker: '공명', text: `${this.cityDialogueUnitNames()} · 공명 +${this.cityStay.dialogue.rewardExp + choice.rewardExp}` } - ], () => this.showCompletionToast('동료 공명 대화 완료'), this.companionActor().id); + ], () => this.showCompletionToast('동료 공명 대화 완료'), companion.id); + this.autoDialogueInputReadyAt = + this.time.now + inputCarryoverGuardMs; } private closeChoicePanel() { @@ -2558,12 +2586,16 @@ export class CityStayScene extends Phaser.Scene { this.transitionOverlay = this.add.container(0, 0, [shade, title, subtitle]) .setDepth(5000) .setAlpha(0); - this.tweens.add({ - targets: this.transitionOverlay, - alpha: 1, - duration: 320, - ease: 'Sine.easeOut' - }); + if (this.visualMotionReduced) { + this.transitionOverlay.setAlpha(1); + } else { + this.tweens.add({ + targets: this.transitionOverlay, + alpha: 1, + duration: 320, + ease: 'Sine.easeOut' + }); + } } private startDialogue( @@ -2616,7 +2648,10 @@ export class CityStayScene extends Phaser.Scene { this.dialoguePortrait .setTexture(portraitEntry.textureKey) .setVisible(true); - this.fitDialogueFacePortrait(this.dialoguePortrait); + fitDialogueFacePortrait( + this.dialoguePortrait, + dialoguePortraitDisplaySize + ); return; } this.dialoguePortraitFrame?.setVisible(false); @@ -2638,30 +2673,6 @@ export class CityStayScene extends Phaser.Scene { ); } - private fitDialogueFacePortrait( - portrait: Phaser.GameObjects.Image - ) { - const sourceWidth = portrait.frame.realWidth; - const sourceHeight = portrait.frame.realHeight; - const cropSize = Math.round( - Math.min(sourceWidth, sourceHeight) * 0.6 - ); - const cropX = Math.round((sourceWidth - cropSize) / 2); - const cropY = Math.round( - Math.min( - Math.max(0, sourceHeight * 0.035), - sourceHeight - cropSize - ) - ); - portrait - .setCrop(cropX, cropY, cropSize, cropSize) - .setOrigin( - (cropX + cropSize / 2) / sourceWidth, - (cropY + cropSize / 2) / sourceHeight - ) - .setScale(dialoguePortraitDisplaySize / cropSize); - } - private advanceDialogue() { const dialogue = this.dialogueState; if (!dialogue || this.navigationPending) { @@ -2675,17 +2686,21 @@ export class CityStayScene extends Phaser.Scene { } const onComplete = dialogue.onComplete; + const sourceTargetId = dialogue.sourceTargetId; this.dialogueState = undefined; this.dialoguePanel?.setVisible(false); this.stopPlayerMovement(); + this.restoreActorDirection(sourceTargetId); onComplete?.(); this.refreshInteractionPrompt(); } private cancelDialogue() { + const sourceTargetId = this.dialogueState?.sourceTargetId; this.dialogueState = undefined; this.dialoguePanel?.setVisible(false); this.stopPlayerMovement(); + this.restoreActorDirection(sourceTargetId); this.refreshInteractionPrompt(); } @@ -2775,18 +2790,26 @@ export class CityStayScene extends Phaser.Scene { wordWrap: { width: 470, useAdvancedWrap: true }, align: 'center' }).setOrigin(0.5); - this.completionToast = this.add.container(0, 0, [background, text]).setDepth(3300); - this.tweens.add({ - targets: this.completionToast, - alpha: 0, - y: -18, - delay: 1500, - duration: 380, - onComplete: () => { - this.completionToast?.destroy(); + const toast = this.add.container(0, 0, [background, text]).setDepth(3300); + this.completionToast = toast; + const clearToast = () => { + toast.destroy(); + if (this.completionToast === toast) { this.completionToast = undefined; } - }); + }; + if (this.visualMotionReduced) { + this.time.delayedCall(1880, clearToast); + } else { + this.tweens.add({ + targets: toast, + alpha: 0, + y: -18, + delay: 1500, + duration: 380, + onComplete: clearToast + }); + } } private nearestInteractionTarget(radius: number) { @@ -2834,15 +2857,27 @@ export class CityStayScene extends Phaser.Scene { this.playerDirection = this.directionForVector(toNpc); this.setPlayerMoving(false); const npcDirection = this.directionForVector(toNpc.scale(-1)); - if (this.textures.exists(view.textureKey)) { - view.sprite.play( - explorationCharacterAnimationKey( - view.textureKey, - 'idle', - npcDirection - ) - ); + applyExplorationCharacterMotion( + view.sprite, + view.textureKey, + 'idle', + npcDirection, + this.visualMotionReduced + ); + } + + private restoreActorDirection(targetId?: string) { + const view = targetId ? this.actors.get(targetId) : undefined; + if (!view) { + return; } + applyExplorationCharacterMotion( + view.sprite, + view.textureKey, + 'idle', + view.definition.direction, + this.visualMotionReduced + ); } private informationComplete(campaign = getCampaignState()) { @@ -2854,11 +2889,8 @@ export class CityStayScene extends Phaser.Scene { } private cityDialogueUnitNames() { - const campaign = this.campaign ?? getCampaignState(); return this.cityStay.dialogue.unitIds - .map((unitId) => campaign.roster.find((unit) => unit.id === unitId)?.name ?? ( - unitId === 'liu-bei' ? '유비' : this.companionActor().name - )) + .map((unitId) => unitId === 'liu-bei' ? '유비' : this.companionActor().name) .join(' · '); } diff --git a/src/game/scenes/PrologueMilitiaCampScene.ts b/src/game/scenes/PrologueMilitiaCampScene.ts index f32755c..11f6c29 100644 --- a/src/game/scenes/PrologueMilitiaCampScene.ts +++ b/src/game/scenes/PrologueMilitiaCampScene.ts @@ -22,6 +22,7 @@ import { type SortieOrderId } from '../data/sortieOrders'; import { + applyExplorationCharacterMotion, ensureExplorationCharacterAnimations, explorationCharacterAssetInfos, explorationCharacterTextureKeyForUnitTextureKey, @@ -41,6 +42,8 @@ import { type CampaignTutorialId } from '../state/campaignState'; import { releaseTextureSource } from '../render/loaderLifecycle'; +import { isVisualMotionReduced } from '../settings/visualMotion'; +import { fitDialogueFacePortrait } from '../ui/dialoguePortrait'; import { palette } from '../ui/palette'; import { startGameScene } from './lazyScenes'; @@ -54,6 +57,10 @@ const interactionRadius = 122; const promptRadius = 164; const characterDisplaySize = 104; const inputCarryoverGuardMs = 320; +const navigationArrivalRadius = 7; +const navigationDetourClearance = 58; +const navigationProbeStep = 18; +const directPointerSnapRadius = 96; const campBackgroundKey = 'prologue-militia-camp-background'; const campDialoguePortraitContext = { id: 'prologue-militia-camp-dialogue', @@ -203,13 +210,23 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { }; private interactKeys: Phaser.Input.Keyboard.Key[] = []; private moveTarget?: Phaser.Math.Vector2; + private moveWaypoints: Phaser.Math.Vector2[] = []; + private moveTargetNpcId?: string; + private lastNavigationTargetId?: string; + private navigationReplanAttempts = 0; + private navigationDetourCount = 0; + private autoInteractionPending = false; + private autoInteractionTriggeredCount = 0; + private lastAutoInteractionTargetId?: string; private ready = false; private navigationPending = false; private inputReadyAt = Number.POSITIVE_INFINITY; + private autoDialogueInputReadyAt = 0; private interactionQueued = false; private stepIndex = 0; private lastStepAt = 0; private firstVisit = false; + private visualMotionReduced = false; private ownedPresentationTextureKeys = new Set(); constructor() { @@ -240,6 +257,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { create() { this.resetRuntimeState(); + this.visualMotionReduced = isVisualMotionReduced(); this.prepareCampaignProgress(); this.drawCamp(); this.drawHud(); @@ -256,7 +274,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { this.ready = false; this.navigationPending = false; - this.moveTarget = undefined; + this.clearNavigationTarget(true); this.dialogueState = undefined; this.closeCommandChoice(); this.stopNpcMovement(); @@ -302,7 +320,10 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { } if (this.dialogueState) { this.stopPlayerMovement(); - if (this.consumeInteractionRequest()) { + if ( + this.consumeInteractionRequest() && + time >= this.autoDialogueInputReadyAt + ) { this.advanceDialogue(); } return; @@ -320,12 +341,15 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { const playerPosition = this.player ? { x: this.player.x, y: this.player.y } : null; const target = this.nearestNpc(promptRadius); const dialogueBounds = this.dialoguePanel?.visible - ? this.boundsSnapshot(this.dialoguePanel.getBounds()) + ? this.boundsSnapshot( + new Phaser.Geom.Rectangle(0, 0, sceneWidth, sceneHeight) + ) : null; return { scene: this.scene.key, locationId: 'zhuo-volunteer-camp', ready: this.ready, + visualMotionReduced: this.visualMotionReduced, viewport: { width: this.scale.width, height: this.scale.height }, campaignStep: campaign.step, requiredTextures: characterTextureKeys.map((key) => ({ @@ -351,14 +375,31 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { moving: this.playerMoving, textureKey: this.player?.texture.key ?? null, animationKey: this.player?.anims.currentAnim?.key ?? null, + animationPlaying: this.player?.anims.isPlaying ?? false, bounds: this.player ? this.boundsSnapshot(this.player.getBounds()) : null } : null, movement: { speed: playerSpeed, target: this.moveTarget ? { x: this.moveTarget.x, y: this.moveTarget.y } : null, + targetNpcId: this.moveTargetNpcId ?? null, + lastTargetNpcId: this.lastNavigationTargetId ?? null, + activeWaypoint: this.moveWaypoints[0] + ? { x: this.moveWaypoints[0].x, y: this.moveWaypoints[0].y } + : null, + waypoints: this.moveWaypoints.map(({ x, y }) => ({ x, y })), walkBounds: this.boundsSnapshot(movementBounds), collisionRadius: playerCollisionRadius, + detourCount: this.navigationDetourCount, + replanAttempts: this.navigationReplanAttempts, + autoInteraction: { + pending: this.autoInteractionPending, + targetId: this.autoInteractionPending + ? this.moveTargetNpcId ?? null + : null, + triggeredCount: this.autoInteractionTriggeredCount, + lastTriggeredTargetId: this.lastAutoInteractionTargetId ?? null + }, npcMovementPending: this.npcMovementPending, movingNpcIds: [...this.npcMoveTweens.keys()] }, @@ -367,7 +408,8 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { interact: ['E', 'Space', 'Enter'], commandChoice: ['1', '2', '3', 'pointer'], pointerMove: true, - carryoverGuardMs: inputCarryoverGuardMs + carryoverGuardMs: inputCarryoverGuardMs, + reducedMotion: this.visualMotionReduced }, interaction: { radius: interactionRadius, @@ -408,6 +450,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { y: sprite.y, textureKey: sprite.texture.key, animationKey: sprite.anims.currentAnim?.key ?? null, + animationPlaying: sprite.anims.isPlaying, bounds: this.boundsSnapshot(sprite.getBounds()) })) ?? [] }; @@ -429,6 +472,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { moving: this.npcMoveTweens.has(definition.id), textureKey: sprite.texture.key, animationKey: sprite.anims.currentAnim?.key ?? null, + animationPlaying: sprite.anims.isPlaying, distance: playerPosition ? Phaser.Math.Distance.Between(playerPosition.x, playerPosition.y, sprite.x, sprite.y) : null, @@ -586,9 +630,18 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.pendingCommandOrderId = undefined; this.commandChoiceReadyAt = Number.POSITIVE_INFINITY; this.moveTarget = undefined; + this.moveWaypoints = []; + this.moveTargetNpcId = undefined; + this.lastNavigationTargetId = undefined; + this.navigationReplanAttempts = 0; + this.navigationDetourCount = 0; + this.autoInteractionPending = false; + this.autoInteractionTriggeredCount = 0; + this.lastAutoInteractionTargetId = undefined; this.ready = false; this.navigationPending = false; this.inputReadyAt = Number.POSITIVE_INFINITY; + this.autoDialogueInputReadyAt = 0; this.interactionQueued = false; this.stepIndex = 0; this.lastStepAt = 0; @@ -689,16 +742,18 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { private drawCampFireGlow() { const glow = this.add.ellipse(780, 540, 230, 150, 0xf2a241, 0.08).setDepth(35); - this.tweens.add({ - targets: glow, - alpha: { from: 0.05, to: 0.14 }, - scaleX: { from: 0.95, to: 1.05 }, - scaleY: { from: 0.95, to: 1.05 }, - duration: 880, - yoyo: true, - repeat: -1, - ease: 'Sine.easeInOut' - }); + if (!this.visualMotionReduced) { + this.tweens.add({ + targets: glow, + alpha: { from: 0.05, to: 0.14 }, + scaleX: { from: 0.95, to: 1.05 }, + scaleY: { from: 0.95, to: 1.05 }, + duration: 880, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut' + }); + } } private drawGroundTexture(graphics: Phaser.GameObjects.Graphics) { @@ -835,16 +890,18 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.addBlocker(728, 520, 104, 64); const glow = this.add.ellipse(780, 540, 250, 170, 0xf2a241, 0.11).setDepth(35); - this.tweens.add({ - targets: glow, - alpha: { from: 0.07, to: 0.16 }, - scaleX: { from: 0.94, to: 1.05 }, - scaleY: { from: 0.94, to: 1.05 }, - duration: 820, - yoyo: true, - repeat: -1, - ease: 'Sine.easeInOut' - }); + if (!this.visualMotionReduced) { + this.tweens.add({ + targets: glow, + alpha: { from: 0.07, to: 0.16 }, + scaleX: { from: 0.94, to: 1.05 }, + scaleY: { from: 0.94, to: 1.05 }, + duration: 820, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut' + }); + } [975, 1100, 1225].forEach((x, index) => { const training = this.add.graphics().setDepth(39); @@ -1042,7 +1099,11 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { view.container.setVisible(false).setAlpha(0).setY(0); return; } - if (animatedObjectiveId !== objectiveId || view.container.visible) { + if ( + this.visualMotionReduced || + animatedObjectiveId !== objectiveId || + view.container.visible + ) { view.container.setVisible(true).setAlpha(1).setY(0); return; } @@ -1213,7 +1274,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { padding: { left: 11, right: 11, top: 2, bottom: 2 } }).setOrigin(0.5).setDepth(1201) : undefined; - if (marker) { + if (marker && !this.visualMotionReduced) { this.tweens.add({ targets: marker, y: marker.y - 8, @@ -1244,9 +1305,13 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { ) { const resolvedTextureKey = this.textures.exists(textureKey) ? textureKey : '__DEFAULT'; const sprite = this.add.sprite(x, y, resolvedTextureKey, 0).setDisplaySize(size, size); - if (this.textures.exists(textureKey)) { - sprite.play(`${textureKey}-idle-${direction}`); - } + applyExplorationCharacterMotion( + sprite, + textureKey, + 'idle', + direction, + this.visualMotionReduced + ); return sprite; } @@ -1338,13 +1403,21 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { } this.input.on(Phaser.Input.Events.POINTER_DOWN, (pointer: Phaser.Input.Pointer) => { - if (!this.ready || this.navigationPending || this.time.now < this.inputReadyAt) { + if ( + !this.ready || + this.navigationPending || + this.npcMovementPending || + this.time.now < this.inputReadyAt + ) { return; } if (this.commandChoicePanel) { return; } if (this.dialogueState) { + if (this.time.now < this.autoDialogueInputReadyAt) { + return; + } this.advanceDialogue(); return; } @@ -1354,7 +1427,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { pointer.y >= movementBounds.top && pointer.y <= movementBounds.bottom ) { - this.moveTarget = new Phaser.Math.Vector2(pointer.x, pointer.y); + this.beginPointerMovement(pointer.x, pointer.y); } }); } @@ -1365,14 +1438,28 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { } const keyboardDirection = this.keyboardMovementDirection(); let movement = keyboardDirection; + let pointerDistanceRemaining = Number.POSITIVE_INFINITY; if (movement.lengthSq() > 0) { - this.moveTarget = undefined; + this.clearNavigationTarget(true); } else if (this.moveTarget) { - const targetDelta = this.moveTarget.clone().subtract( + const activeTarget = this.moveWaypoints[0] ?? this.moveTarget; + const targetDelta = activeTarget.clone().subtract( new Phaser.Math.Vector2(this.player.x, this.player.y) ); - if (targetDelta.length() <= 7) { - this.moveTarget = undefined; + pointerDistanceRemaining = targetDelta.length(); + if (pointerDistanceRemaining <= navigationArrivalRadius) { + if (this.moveWaypoints.length > 0) { + this.moveWaypoints.shift(); + } else { + const arrivedTargetId = this.moveTargetNpcId; + const shouldAutoInteract = this.autoInteractionPending; + this.lastNavigationTargetId = arrivedTargetId; + this.clearNavigationTarget(false); + this.setPlayerMoving(false); + if (arrivedTargetId && shouldAutoInteract) { + this.triggerAutoInteraction(arrivedTargetId); + } + } movement = new Phaser.Math.Vector2(); } else { movement = targetDelta.normalize(); @@ -1386,7 +1473,10 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { movement.normalize(); this.playerDirection = this.directionForVector(movement); - const distance = playerSpeed * Math.min(delta, 50) / 1000; + const distance = Math.min( + playerSpeed * Math.min(delta, 50) / 1000, + pointerDistanceRemaining + ); const startX = this.player.x; const startY = this.player.y; const nextX = startX + movement.x * distance; @@ -1402,7 +1492,9 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { moved = true; } if (!moved && this.moveTarget) { - this.moveTarget = undefined; + if (!this.replanBlockedPointerMovement()) { + this.clearNavigationTarget(true); + } } this.syncPlayerView(); @@ -1419,6 +1511,385 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { } } + private beginPointerMovement(x: number, y: number) { + const snappedTarget = this.pointerMovementTargetAt(x, y); + if (!snappedTarget) { + this.beginNavigationTo(x, y); + return; + } + const plannedApproaches = this.approachPositions( + snappedTarget.x, + snappedTarget.y + ) + .map((destination) => ({ + destination, + route: this.planNavigationRoute(destination) + })) + .filter( + ( + entry + ): entry is { + destination: Phaser.Math.Vector2; + route: Phaser.Math.Vector2[]; + } => Boolean(entry.route) + ) + .sort( + (left, right) => + this.navigationRouteLength(left.route) - + this.navigationRouteLength(right.route) + ); + const best = plannedApproaches[0]; + if (best) { + this.applyNavigationRoute(best.route, snappedTarget.id); + return; + } + const fallback = this.safeApproachPosition( + snappedTarget.x, + snappedTarget.y + ); + this.beginNavigationTo( + fallback.x, + fallback.y, + snappedTarget.id + ); + } + + private beginNavigationTo( + x: number, + y: number, + targetNpcId?: string + ) { + const destination = this.nearestWalkablePoint(x, y); + if (!destination) { + this.clearNavigationTarget(true); + return; + } + const route = this.planNavigationRoute(destination); + if (!route) { + this.clearNavigationTarget(true); + return; + } + this.applyNavigationRoute(route, targetNpcId); + } + + private applyNavigationRoute( + route: Phaser.Math.Vector2[], + targetNpcId?: string + ) { + const destination = route[route.length - 1]; + if (!destination) { + this.clearNavigationTarget(true); + return; + } + this.moveTarget = destination.clone(); + this.moveWaypoints = route + .slice(0, -1) + .map((waypoint) => waypoint.clone()); + this.moveTargetNpcId = targetNpcId; + this.lastNavigationTargetId = undefined; + this.navigationReplanAttempts = 0; + this.autoInteractionPending = Boolean(targetNpcId); + if (route.length > 1) { + this.navigationDetourCount += 1; + } + } + + private pointerMovementTargetAt(x: number, y: number) { + return Array.from(this.npcViews.values()) + .map((view) => ({ + id: view.definition.id, + x: view.sprite.x, + y: view.sprite.y, + distance: Phaser.Math.Distance.Between( + x, + y, + view.sprite.x, + view.sprite.y + ) + })) + .filter(({ distance }) => distance <= directPointerSnapRadius) + .sort((left, right) => left.distance - right.distance)[0]; + } + + private approachPositions(targetX: number, targetY: number) { + return [ + new Phaser.Math.Vector2(targetX, targetY + 88), + new Phaser.Math.Vector2(targetX - 88, targetY), + new Phaser.Math.Vector2(targetX + 88, targetY), + new Phaser.Math.Vector2(targetX, targetY - 88) + ].filter(({ x, y }) => this.canPlayerOccupy(x, y)); + } + + private nearestWalkablePoint(x: number, y: number) { + const margin = playerCollisionRadius + 4; + const clamped = new Phaser.Math.Vector2( + Phaser.Math.Clamp( + x, + movementBounds.left + margin, + movementBounds.right - margin + ), + Phaser.Math.Clamp( + y, + movementBounds.top + margin, + movementBounds.bottom - margin + ) + ); + if (this.canPlayerOccupy(clamped.x, clamped.y)) { + return clamped; + } + for (const radius of [48, 80, 112, 144]) { + for (let index = 0; index < 8; index += 1) { + const angle = (index * Math.PI) / 4; + const candidate = new Phaser.Math.Vector2( + Phaser.Math.Clamp( + clamped.x + Math.cos(angle) * radius, + movementBounds.left + margin, + movementBounds.right - margin + ), + Phaser.Math.Clamp( + clamped.y + Math.sin(angle) * radius, + movementBounds.top + margin, + movementBounds.bottom - margin + ) + ); + if (this.canPlayerOccupy(candidate.x, candidate.y)) { + return candidate; + } + } + } + return undefined; + } + + private planNavigationRoute(destination: Phaser.Math.Vector2) { + if ( + !this.player || + !this.canPlayerOccupy(destination.x, destination.y) + ) { + return undefined; + } + const start = new Phaser.Math.Vector2(this.player.x, this.player.y); + if (this.segmentIsNavigable(start, destination)) { + return [destination.clone()]; + } + + const candidates = this.navigationDetourCandidates(start, destination); + let bestRoute: Phaser.Math.Vector2[] | undefined; + let bestLength = Number.POSITIVE_INFINITY; + const startVisible = candidates.filter((candidate) => + this.segmentIsNavigable(start, candidate) + ); + const destinationVisible = candidates.filter((candidate) => + this.segmentIsNavigable(candidate, destination) + ); + + startVisible.forEach((waypoint) => { + if (!this.segmentIsNavigable(waypoint, destination)) { + return; + } + const route = [waypoint, destination]; + const length = this.navigationRouteLength(route); + if (length < bestLength) { + bestLength = length; + bestRoute = route; + } + }); + startVisible.forEach((first) => { + destinationVisible.forEach((second) => { + if ( + first.equals(second) || + !this.segmentIsNavigable(first, second) + ) { + return; + } + const route = [first, second, destination]; + const length = this.navigationRouteLength(route); + if (length < bestLength) { + bestLength = length; + bestRoute = route; + } + }); + }); + return bestRoute?.map((waypoint) => waypoint.clone()); + } + + private navigationDetourCandidates( + start: Phaser.Math.Vector2, + destination: Phaser.Math.Vector2 + ) { + const margin = playerCollisionRadius + 4; + const candidates = [ + new Phaser.Math.Vector2(start.x, destination.y), + new Phaser.Math.Vector2(destination.x, start.y) + ]; + this.blockers.forEach((blocker) => { + const left = blocker.left - navigationDetourClearance; + const right = blocker.right + navigationDetourClearance; + const top = blocker.top - navigationDetourClearance; + const bottom = blocker.bottom + navigationDetourClearance; + candidates.push( + new Phaser.Math.Vector2(left, top), + new Phaser.Math.Vector2(right, top), + new Phaser.Math.Vector2(left, bottom), + new Phaser.Math.Vector2(right, bottom) + ); + }); + this.npcViews.forEach(({ sprite }) => { + candidates.push( + new Phaser.Math.Vector2(sprite.x - 72, sprite.y), + new Phaser.Math.Vector2(sprite.x + 72, sprite.y), + new Phaser.Math.Vector2(sprite.x, sprite.y - 72), + new Phaser.Math.Vector2(sprite.x, sprite.y + 72) + ); + }); + + const unique = new Map(); + candidates.forEach((candidate) => { + candidate.set( + Phaser.Math.Clamp( + candidate.x, + movementBounds.left + margin, + movementBounds.right - margin + ), + Phaser.Math.Clamp( + candidate.y, + movementBounds.top + margin, + movementBounds.bottom - margin + ) + ); + if (!this.canPlayerOccupy(candidate.x, candidate.y)) { + return; + } + unique.set( + `${Math.round(candidate.x)}:${Math.round(candidate.y)}`, + candidate + ); + }); + return Array.from(unique.values()); + } + + private segmentIsNavigable( + from: Phaser.Math.Vector2, + to: Phaser.Math.Vector2 + ) { + const distance = Phaser.Math.Distance.Between( + from.x, + from.y, + to.x, + to.y + ); + const steps = Math.max( + 1, + Math.ceil(distance / navigationProbeStep) + ); + for (let index = 1; index <= steps; index += 1) { + const progress = index / steps; + const x = Phaser.Math.Linear(from.x, to.x, progress); + const y = Phaser.Math.Linear(from.y, to.y, progress); + if (!this.canPlayerOccupy(x, y)) { + return false; + } + } + return true; + } + + private navigationRouteLength( + route: readonly Phaser.Math.Vector2[] + ) { + if (!this.player) { + return Number.POSITIVE_INFINITY; + } + let previous = new Phaser.Math.Vector2( + this.player.x, + this.player.y + ); + return route.reduce((total, waypoint) => { + const length = Phaser.Math.Distance.Between( + previous.x, + previous.y, + waypoint.x, + waypoint.y + ); + previous = waypoint; + return total + length; + }, 0); + } + + private replanBlockedPointerMovement() { + if ( + !this.moveTarget || + this.navigationReplanAttempts >= 2 + ) { + return false; + } + this.navigationReplanAttempts += 1; + const route = this.planNavigationRoute(this.moveTarget); + if (!route) { + return false; + } + this.moveWaypoints = route + .slice(0, -1) + .map((waypoint) => waypoint.clone()); + return true; + } + + private clearNavigationTarget(clearLast = false) { + this.moveTarget = undefined; + this.moveWaypoints = []; + this.moveTargetNpcId = undefined; + this.navigationReplanAttempts = 0; + this.autoInteractionPending = false; + if (clearLast) { + this.lastNavigationTargetId = undefined; + } + } + + private triggerAutoInteraction(npcId: string) { + if ( + this.navigationPending || + this.npcMovementPending || + this.dialogueState || + this.commandChoicePanel || + !this.ready + ) { + return; + } + const view = this.npcViews.get(npcId); + if (!view) { + return; + } + if ( + this.distanceTo(view.sprite.x, view.sprite.y) > + interactionRadius + 8 + ) { + const recoveryRoute = this.approachPositions( + view.sprite.x, + view.sprite.y + ) + .map((destination) => this.planNavigationRoute(destination)) + .filter( + (route): route is Phaser.Math.Vector2[] => Boolean(route) + ) + .sort( + (left, right) => + this.navigationRouteLength(left) - + this.navigationRouteLength(right) + )[0]; + if (recoveryRoute) { + this.applyNavigationRoute(recoveryRoute, npcId); + return; + } + this.showPromptMessage(`${view.definition.name}에게 다가갈 길을 찾지 못했습니다.`); + return; + } + this.autoInteractionTriggeredCount += 1; + this.lastAutoInteractionTargetId = npcId; + this.interactWithNpc(view); + if (this.dialogueState) { + this.autoDialogueInputReadyAt = + this.time.now + inputCarryoverGuardMs; + } + } + private consumeInteractionRequest() { const keyPressedThisFrame = this.interactKeys.some((key) => Phaser.Input.Keyboard.JustDown(key)); const requested = this.interactionQueued || keyPressedThisFrame; @@ -1477,15 +1948,18 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { if (!this.player) { return; } - const animationKey = `${playerTextureKey}-${moving ? 'walk' : 'idle'}-${this.playerDirection}`; - if (this.textures.exists(playerTextureKey) && this.player.anims.currentAnim?.key !== animationKey) { - this.player.play(animationKey); - } + applyExplorationCharacterMotion( + this.player, + playerTextureKey, + moving ? 'walk' : 'idle', + this.playerDirection, + this.visualMotionReduced + ); this.playerMoving = moving; } private stopPlayerMovement() { - this.moveTarget = undefined; + this.clearNavigationTarget(); this.setPlayerMoving(false); } @@ -1601,8 +2075,8 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.dialoguePortraitFrame?.setVisible(true); this.dialoguePortrait .setTexture(entry.textureKey) - .setDisplaySize(218, 218) .setVisible(true); + fitDialogueFacePortrait(this.dialoguePortrait, 218); this.dialogueNameText?.setX(350); this.dialogueBodyText?.setX(350).setWordWrapWidth(1382, true); } else { @@ -2062,12 +2536,16 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.transitionOverlay = this.add.container(0, 0, [shade, title, subtitle]) .setDepth(5000) .setAlpha(0); - this.tweens.add({ - targets: this.transitionOverlay, - alpha: 1, - duration: 320, - ease: 'Sine.easeOut' - }); + if (this.visualMotionReduced) { + this.transitionOverlay.setAlpha(1); + } else { + this.tweens.add({ + targets: this.transitionOverlay, + alpha: 1, + duration: 320, + ease: 'Sine.easeOut' + }); + } } private showCompletionToast(label: string) { @@ -2080,18 +2558,26 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { color: '#dff0c9', fontStyle: 'bold' }).setOrigin(0.5); - this.completionToast = this.add.container(0, 0, [background, text]).setDepth(2200); - this.tweens.add({ - targets: this.completionToast, - alpha: 0, - y: -18, - delay: 1150, - duration: 360, - onComplete: () => { - this.completionToast?.destroy(); + const toast = this.add.container(0, 0, [background, text]).setDepth(2200); + this.completionToast = toast; + const clearToast = () => { + toast.destroy(); + if (this.completionToast === toast) { this.completionToast = undefined; } - }); + }; + if (this.visualMotionReduced) { + this.time.delayedCall(1510, clearToast); + } else { + this.tweens.add({ + targets: toast, + alpha: 0, + y: -18, + delay: 1150, + duration: 360, + onComplete: clearToast + }); + } } private showPromptMessage(message: string) { @@ -2204,9 +2690,13 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.playerDirection = this.directionForVector(toNpc); this.setPlayerMoving(false); const npcDirection = this.directionForVector(toNpc.scale(-1)); - if (this.textures.exists(view.textureKey)) { - view.sprite.play(`${view.textureKey}-idle-${npcDirection}`); - } + applyExplorationCharacterMotion( + view.sprite, + view.textureKey, + 'idle', + npcDirection, + this.visualMotionReduced + ); } private gatherCommandParty(onComplete: () => void) { @@ -2219,8 +2709,19 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.promptText?.setVisible(false); const movements = [ - { npcId: 'guan-yu', x: 650, y: 420, direction: 'north' as const }, - { npcId: 'zhang-fei', x: 890, y: 420, direction: 'north' as const } + { + npcId: 'guan-yu', + waypoints: [{ x: 650, y: 420 }], + direction: 'north' as const + }, + { + npcId: 'zhang-fei', + waypoints: [ + { x: 890, y: 560 }, + { x: 890, y: 420 } + ], + direction: 'north' as const + } ].filter(({ npcId }) => this.npcViews.has(npcId)); if (movements.length === 0) { this.npcMovementPending = false; @@ -2229,8 +2730,8 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { } let remaining = movements.length; - movements.forEach(({ npcId, x, y, direction }) => { - this.walkNpcTo(npcId, x, y, direction, () => { + movements.forEach(({ npcId, waypoints, direction }) => { + this.walkNpcTo(npcId, waypoints, direction, () => { remaining -= 1; if (remaining > 0) { return; @@ -2243,8 +2744,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { private walkNpcTo( npcId: string, - x: number, - y: number, + waypoints: readonly { x: number; y: number }[], finalDirection: ExplorationCharacterDirection, onComplete: () => void ) { @@ -2254,31 +2754,63 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { return; } this.npcMoveTweens.get(npcId)?.remove(); - const delta = new Phaser.Math.Vector2(x - view.sprite.x, y - view.sprite.y); - const walkDirection = delta.lengthSq() > 0 - ? this.directionForVector(delta) - : finalDirection; - if (this.textures.exists(view.textureKey)) { - view.sprite.play(`${view.textureKey}-walk-${walkDirection}`); - } - const duration = Math.max(220, Math.round(delta.length() / playerSpeed * 1000)); - const tween = this.tweens.add({ - targets: view.sprite, - x, - y, - duration, - ease: 'Sine.easeInOut', - onUpdate: () => this.syncNpcView(view), - onComplete: () => { + const remainingWaypoints = waypoints.map(({ x, y }) => ({ x, y })); + const walkNext = () => { + const target = remainingWaypoints.shift(); + if (!target) { this.npcMoveTweens.delete(npcId); this.syncNpcView(view); - if (this.textures.exists(view.textureKey)) { - view.sprite.play(`${view.textureKey}-idle-${finalDirection}`); - } + applyExplorationCharacterMotion( + view.sprite, + view.textureKey, + 'idle', + finalDirection, + this.visualMotionReduced + ); onComplete(); + return; } - }); - this.npcMoveTweens.set(npcId, tween); + const delta = new Phaser.Math.Vector2( + target.x - view.sprite.x, + target.y - view.sprite.y + ); + if (delta.lengthSq() <= 1) { + view.sprite.setPosition(target.x, target.y); + this.syncNpcView(view); + walkNext(); + return; + } + const walkDirection = this.directionForVector(delta); + applyExplorationCharacterMotion( + view.sprite, + view.textureKey, + 'walk', + walkDirection, + this.visualMotionReduced + ); + let tween: Phaser.Tweens.Tween | undefined; + tween = this.tweens.add({ + targets: view.sprite, + x: target.x, + y: target.y, + duration: Math.max( + 180, + Math.round(delta.length() / playerSpeed * 1000) + ), + ease: 'Linear', + onUpdate: () => this.syncNpcView(view), + onComplete: () => { + if (!tween || this.npcMoveTweens.get(npcId) !== tween) { + return; + } + view.sprite.setPosition(target.x, target.y); + this.syncNpcView(view); + walkNext(); + } + }); + this.npcMoveTweens.set(npcId, tween); + }; + walkNext(); } private syncNpcView(view: CampNpcView) { @@ -2309,9 +2841,13 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { view.sprite.setPosition(x, y); this.syncNpcView(view); view.marker?.setPosition(x, y - 72).setVisible(false); - if (this.textures.exists(view.textureKey)) { - view.sprite.play(`${view.textureKey}-idle-${direction}`); - } + applyExplorationCharacterMotion( + view.sprite, + view.textureKey, + 'idle', + direction, + this.visualMotionReduced + ); } private isObjectiveComplete(objectiveId: PrologueMilitiaCampRequiredObjectiveId) { diff --git a/src/game/scenes/PrologueVillageScene.ts b/src/game/scenes/PrologueVillageScene.ts index b1ec318..c4e5336 100644 --- a/src/game/scenes/PrologueVillageScene.ts +++ b/src/game/scenes/PrologueVillageScene.ts @@ -16,6 +16,7 @@ import { type PrologueVillageRequiredObjectiveId } from '../data/prologueVillage'; import { + applyExplorationCharacterMotion, ensureExplorationCharacterAnimations, explorationCharacterAssetInfos, explorationCharacterTextureKeyForUnitTextureKey, @@ -25,6 +26,7 @@ import { type ExplorationCharacterDirection, type ExplorationCharacterTextureKey } from '../data/explorationCharacterAssets'; +import { musicVolumeMatchedToCampTrack } from '../data/campSoundscapes'; import { completeCampaignTutorial, getCampaignState, @@ -34,6 +36,8 @@ import { type CampaignTutorialId } from '../state/campaignState'; import { releaseTextureSource } from '../render/loaderLifecycle'; +import { isVisualMotionReduced } from '../settings/visualMotion'; +import { fitDialogueFacePortrait } from '../ui/dialoguePortrait'; import { palette } from '../ui/palette'; import { startGameScene } from './lazyScenes'; @@ -218,6 +222,10 @@ export class PrologueVillageScene extends Phaser.Scene { private moveTarget?: Phaser.Math.Vector2; private moveWaypoints: Phaser.Math.Vector2[] = []; private moveTargetNpcId?: string; + private lastNavigationTargetId?: string; + private autoInteractionPending = false; + private autoInteractionTriggeredCount = 0; + private lastAutoInteractionTargetId?: string; private queuedMovementIntent?: QueuedMovementIntent; private queuedMovementIntentCount = 0; private appliedQueuedMovementIntentCount = 0; @@ -232,10 +240,12 @@ export class PrologueVillageScene extends Phaser.Scene { private ready = false; private navigationPending = false; private inputReadyAt = Number.POSITIVE_INFINITY; + private autoDialogueInputReadyAt = 0; private interactionQueued = false; private stepIndex = 0; private lastStepAt = 0; private firstVisit = false; + private visualMotionReduced = false; private ownedPresentationTextureKeys = new Set(); constructor() { @@ -266,6 +276,7 @@ export class PrologueVillageScene extends Phaser.Scene { create() { this.resetRuntimeState(); + this.visualMotionReduced = isVisualMotionReduced(); this.prepareCampaignProgress(); this.drawVillage(); this.drawHud(); @@ -275,7 +286,7 @@ export class PrologueVillageScene extends Phaser.Scene { soundDirector.playSoundscape({ musicKey: 'militia-theme', ambienceKey: 'mountain-wind-ambience', - musicVolume: 0.72, + musicVolume: musicVolumeMatchedToCampTrack('militia-theme'), ambienceVolume: 0.11 }); @@ -283,9 +294,7 @@ export class PrologueVillageScene extends Phaser.Scene { this.ready = false; this.navigationPending = false; this.oathGatherPending = false; - this.moveTarget = undefined; - this.moveWaypoints = []; - this.moveTargetNpcId = undefined; + this.clearNavigationTarget(true); this.queuedMovementIntent = undefined; this.dialogueState = undefined; this.stopAllNpcMovement(); @@ -330,7 +339,10 @@ export class PrologueVillageScene extends Phaser.Scene { if (this.dialogueState) { this.stopPlayerMovement(); - if (this.consumeInteractionRequest()) { + if ( + this.consumeInteractionRequest() && + time >= this.autoDialogueInputReadyAt + ) { this.advanceDialogue(); } return; @@ -354,12 +366,15 @@ export class PrologueVillageScene extends Phaser.Scene { : null; const target = this.nearestInteractionTarget(promptRadius); const dialogueBounds = this.dialoguePanel?.visible - ? this.boundsSnapshot(this.dialoguePanel.getBounds()) + ? this.boundsSnapshot( + new Phaser.Geom.Rectangle(0, 0, sceneWidth, sceneHeight) + ) : null; return { scene: this.scene.key, ready: this.ready, + visualMotionReduced: this.visualMotionReduced, viewport: { width: this.scale.width, height: this.scale.height }, campaignStep: campaign.step, background: { @@ -380,6 +395,7 @@ export class PrologueVillageScene extends Phaser.Scene { moving: this.playerMoving, textureKey: this.player?.texture.key ?? null, animationKey: this.player?.anims.currentAnim?.key ?? null, + animationPlaying: this.player?.anims.isPlaying ?? false, bounds: this.player ? this.boundsSnapshot(this.player.getBounds()) : null } : null, @@ -387,6 +403,7 @@ export class PrologueVillageScene extends Phaser.Scene { speed: playerSpeed, target: this.moveTarget ? { x: this.moveTarget.x, y: this.moveTarget.y } : null, targetNpcId: this.moveTargetNpcId ?? null, + lastTargetNpcId: this.lastNavigationTargetId ?? null, activeWaypoint: this.moveWaypoints[0] ? { x: this.moveWaypoints[0].x, y: this.moveWaypoints[0].y } : null, @@ -402,6 +419,15 @@ export class PrologueVillageScene extends Phaser.Scene { deferredForNarrativeMovement: this.hasNarrativeNpcMovement(), detourCount: this.navigationDetourCount, replanAttempts: this.navigationReplanAttempts, + autoInteraction: { + pending: this.autoInteractionPending, + targetId: this.autoInteractionPending + ? this.moveTargetNpcId ?? null + : null, + triggeredCount: this.autoInteractionTriggeredCount, + lastTriggeredTargetId: + this.lastAutoInteractionTargetId ?? null + }, walkBounds: this.boundsSnapshot(movementBounds), collisionRadius: playerCollisionRadius }, @@ -409,7 +435,8 @@ export class PrologueVillageScene extends Phaser.Scene { movement: ['WASD', '방향키'], interact: ['E', 'Space', 'Enter'], pointerMove: true, - carryoverGuardMs: inputCarryoverGuardMs + carryoverGuardMs: inputCarryoverGuardMs, + reducedMotion: this.visualMotionReduced }, interaction: { radius: interactionRadius, @@ -449,6 +476,7 @@ export class PrologueVillageScene extends Phaser.Scene { moving: this.npcMoveTweens.has(definition.id), textureKey: sprite.texture.key, animationKey: sprite.anims.currentAnim?.key ?? null, + animationPlaying: sprite.anims.isPlaying, distance: playerPosition ? Phaser.Math.Distance.Between(playerPosition.x, playerPosition.y, sprite.x, sprite.y) : null, completed: definition.objectiveId ? this.isObjectiveComplete(definition.objectiveId) : false, bounds: this.boundsSnapshot(sprite.getBounds()) @@ -552,6 +580,10 @@ export class PrologueVillageScene extends Phaser.Scene { this.moveTarget = undefined; this.moveWaypoints = []; this.moveTargetNpcId = undefined; + this.lastNavigationTargetId = undefined; + this.autoInteractionPending = false; + this.autoInteractionTriggeredCount = 0; + this.lastAutoInteractionTargetId = undefined; this.queuedMovementIntent = undefined; this.queuedMovementIntentCount = 0; this.appliedQueuedMovementIntentCount = 0; @@ -561,6 +593,7 @@ export class PrologueVillageScene extends Phaser.Scene { this.ready = false; this.navigationPending = false; this.inputReadyAt = Number.POSITIVE_INFINITY; + this.autoDialogueInputReadyAt = 0; this.interactionQueued = false; this.oathGatherPending = false; this.stepIndex = 0; @@ -1008,7 +1041,7 @@ export class PrologueVillageScene extends Phaser.Scene { padding: { left: 11, right: 11, top: 2, bottom: 2 } }).setOrigin(0.5).setDepth(1201) : undefined; - if (marker) { + if (marker && !this.visualMotionReduced) { this.tweens.add({ targets: marker, y: marker.y - 8, @@ -1036,14 +1069,16 @@ export class PrologueVillageScene extends Phaser.Scene { backgroundColor: '#2c2025dd', padding: { left: 10, right: 10, top: 4, bottom: 4 } }).setOrigin(0.5).setDepth(1201); - this.tweens.add({ - targets: [this.oathMarker, this.oathLabel], - alpha: { from: 0.7, to: 1 }, - duration: 900, - yoyo: true, - repeat: -1, - ease: 'Sine.easeInOut' - }); + if (!this.visualMotionReduced) { + this.tweens.add({ + targets: [this.oathMarker, this.oathLabel], + alpha: { from: 0.7, to: 1 }, + duration: 900, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut' + }); + } } private createCharacterSprite( @@ -1055,9 +1090,13 @@ export class PrologueVillageScene extends Phaser.Scene { ) { const resolvedTextureKey = this.textures.exists(textureKey) ? textureKey : '__DEFAULT'; const sprite = this.add.sprite(x, y, resolvedTextureKey, 0).setDisplaySize(size, size); - if (this.textures.exists(textureKey)) { - sprite.play(`${textureKey}-idle-${direction}`); - } + applyExplorationCharacterMotion( + sprite, + textureKey, + 'idle', + direction, + this.visualMotionReduced + ); return sprite; } @@ -1167,6 +1206,9 @@ export class PrologueVillageScene extends Phaser.Scene { return; } if (this.dialogueState) { + if (this.time.now < this.autoDialogueInputReadyAt) { + return; + } this.advanceDialogue(); return; } @@ -1206,18 +1248,27 @@ export class PrologueVillageScene extends Phaser.Scene { } let movement = keyboardDirection; + let pointerDistanceRemaining = Number.POSITIVE_INFINITY; if (movement.lengthSq() > 0) { - this.clearNavigationTarget(); + this.clearNavigationTarget(true); } else if (this.moveTarget) { const activeTarget = this.moveWaypoints[0] ?? this.moveTarget; const targetDelta = activeTarget.clone().subtract( new Phaser.Math.Vector2(this.player.x, this.player.y) ); - if (targetDelta.length() <= navigationArrivalRadius) { + pointerDistanceRemaining = targetDelta.length(); + if (pointerDistanceRemaining <= navigationArrivalRadius) { if (this.moveWaypoints.length > 0) { this.moveWaypoints.shift(); } else { - this.clearNavigationTarget(); + const arrivedTargetId = this.moveTargetNpcId; + const shouldAutoInteract = this.autoInteractionPending; + this.lastNavigationTargetId = arrivedTargetId; + this.clearNavigationTarget(false); + this.setPlayerMoving(false); + if (arrivedTargetId && shouldAutoInteract) { + this.triggerAutoInteraction(arrivedTargetId); + } } movement = new Phaser.Math.Vector2(); } else { @@ -1232,7 +1283,10 @@ export class PrologueVillageScene extends Phaser.Scene { movement.normalize(); this.playerDirection = this.directionForVector(movement); - const distance = playerSpeed * Math.min(delta, 50) / 1000; + const distance = Math.min( + playerSpeed * Math.min(delta, 50) / 1000, + pointerDistanceRemaining + ); const startX = this.player.x; const startY = this.player.y; const nextX = startX + movement.x * distance; @@ -1249,7 +1303,7 @@ export class PrologueVillageScene extends Phaser.Scene { } if (!moved && this.moveTarget) { if (!this.replanBlockedPointerMovement()) { - this.clearNavigationTarget(); + this.clearNavigationTarget(true); } } @@ -1369,12 +1423,12 @@ export class PrologueVillageScene extends Phaser.Scene { private beginNavigationTo(x: number, y: number, targetNpcId?: string) { const destination = this.nearestWalkablePoint(x, y); if (!destination) { - this.clearNavigationTarget(); + this.clearNavigationTarget(true); return; } const route = this.planNavigationRoute(destination); if (!route) { - this.clearNavigationTarget(); + this.clearNavigationTarget(true); return; } this.applyNavigationRoute(route, targetNpcId); @@ -1383,13 +1437,17 @@ export class PrologueVillageScene extends Phaser.Scene { private applyNavigationRoute(route: Phaser.Math.Vector2[], targetNpcId?: string) { const destination = route[route.length - 1]; if (!destination) { - this.clearNavigationTarget(); + this.clearNavigationTarget(true); return; } this.moveTarget = destination.clone(); this.moveWaypoints = route.slice(0, -1).map((waypoint) => waypoint.clone()); this.moveTargetNpcId = targetNpcId; + this.lastNavigationTargetId = undefined; this.navigationReplanAttempts = 0; + this.autoInteractionPending = Boolean( + targetNpcId && targetNpcId !== 'make-oath' + ); if (route.length > 1) { this.navigationDetourCount += 1; } @@ -1615,11 +1673,54 @@ export class PrologueVillageScene extends Phaser.Scene { return true; } - private clearNavigationTarget() { + private clearNavigationTarget(clearLast = false) { this.moveTarget = undefined; this.moveWaypoints = []; this.moveTargetNpcId = undefined; this.navigationReplanAttempts = 0; + this.autoInteractionPending = false; + if (clearLast) { + this.lastNavigationTargetId = undefined; + } + } + + private triggerAutoInteraction(targetId: string) { + if ( + this.navigationPending || + this.dialogueState || + this.oathGatherPending || + this.hasNarrativeNpcMovement() || + !this.ready + ) { + return; + } + const target = this.interactionTargetById(targetId); + if (!target || target.kind !== 'npc') { + return; + } + if (this.distanceTo(target.x, target.y) > interactionRadius + 8) { + const recoveryRoute = this.approachPositions(target.x, target.y) + .map((destination) => this.planNavigationRoute(destination)) + .filter( + (route): route is Phaser.Math.Vector2[] => Boolean(route) + ) + .sort( + (left, right) => + this.navigationRouteLength(left) - + this.navigationRouteLength(right) + )[0]; + if (recoveryRoute) { + this.applyNavigationRoute(recoveryRoute, targetId); + } + return; + } + this.autoInteractionTriggeredCount += 1; + this.lastAutoInteractionTargetId = targetId; + this.interactWithTarget(target); + if (this.dialogueState) { + this.autoDialogueInputReadyAt = + this.time.now + inputCarryoverGuardMs; + } } private consumeInteractionRequest() { @@ -1680,10 +1781,13 @@ export class PrologueVillageScene extends Phaser.Scene { if (!this.player) { return; } - const animationKey = `${playerTextureKey}-${moving ? 'walk' : 'idle'}-${this.playerDirection}`; - if (this.textures.exists(playerTextureKey) && this.player.anims.currentAnim?.key !== animationKey) { - this.player.play(animationKey); - } + applyExplorationCharacterMotion( + this.player, + playerTextureKey, + moving ? 'walk' : 'idle', + this.playerDirection, + this.visualMotionReduced + ); this.playerMoving = moving; } @@ -1874,13 +1978,13 @@ export class PrologueVillageScene extends Phaser.Scene { moving: boolean, direction: ExplorationCharacterDirection ) { - if (!this.textures.exists(view.textureKey)) { - return; - } - const animationKey = `${view.textureKey}-${moving ? 'walk' : 'idle'}-${direction}`; - if (view.sprite.anims.currentAnim?.key !== animationKey) { - view.sprite.play(animationKey); - } + applyExplorationCharacterMotion( + view.sprite, + view.textureKey, + moving ? 'walk' : 'idle', + direction, + this.visualMotionReduced + ); } private safeApproachPosition(targetX: number, targetY: number) { @@ -2010,8 +2114,8 @@ export class PrologueVillageScene extends Phaser.Scene { this.dialoguePortraitFrame?.setVisible(true); this.dialoguePortrait .setTexture(entry.textureKey) - .setDisplaySize(218, 218) .setVisible(true); + fitDialogueFacePortrait(this.dialoguePortrait, 218); this.dialogueNameText?.setX(350); this.dialogueBodyText?.setX(350).setWordWrapWidth(1382, true); } else { @@ -2132,12 +2236,16 @@ export class PrologueVillageScene extends Phaser.Scene { this.transitionOverlay = this.add.container(0, 0, [shade, title, subtitle]) .setDepth(5000) .setAlpha(0); - this.tweens.add({ - targets: this.transitionOverlay, - alpha: 1, - duration: 320, - ease: 'Sine.easeOut' - }); + if (this.visualMotionReduced) { + this.transitionOverlay.setAlpha(1); + } else { + this.tweens.add({ + targets: this.transitionOverlay, + alpha: 1, + duration: 320, + ease: 'Sine.easeOut' + }); + } } private showCompletionToast(label: string) { @@ -2150,18 +2258,26 @@ export class PrologueVillageScene extends Phaser.Scene { color: '#dff0c9', fontStyle: 'bold' }).setOrigin(0.5); - this.completionToast = this.add.container(0, 0, [background, text]).setDepth(2200); - this.tweens.add({ - targets: this.completionToast, - alpha: 0, - y: -18, - delay: 1150, - duration: 360, - onComplete: () => { - this.completionToast?.destroy(); + const toast = this.add.container(0, 0, [background, text]).setDepth(2200); + this.completionToast = toast; + const clearToast = () => { + toast.destroy(); + if (this.completionToast === toast) { this.completionToast = undefined; } - }); + }; + if (this.visualMotionReduced) { + this.time.delayedCall(1510, clearToast); + } else { + this.tweens.add({ + targets: toast, + alpha: 0, + y: -18, + delay: 1150, + duration: 360, + onComplete: clearToast + }); + } } private showPromptMessage(message: string) { @@ -2332,9 +2448,7 @@ export class PrologueVillageScene extends Phaser.Scene { this.playerDirection = this.directionForVector(toNpc); this.setPlayerMoving(false); const npcDirection = this.directionForVector(toNpc.scale(-1)); - if (this.textures.exists(view.textureKey)) { - view.sprite.play(`${view.textureKey}-idle-${npcDirection}`); - } + this.playNpcAnimation(view, false, npcDirection); } private isObjectiveComplete(objectiveId: PrologueVillageRequiredObjectiveId) { diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index 6929266..ef02e08 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -64,6 +64,7 @@ import { } from '../state/campaignState'; import { releaseTextureSource } from '../render/loaderLifecycle'; import { isVisualMotionReduced } from '../settings/visualMotion'; +import { fitDialogueFacePortrait } from '../ui/dialoguePortrait'; import { palette } from '../ui/palette'; import { ensureLazyScene, startGameScene } from './lazyScenes'; @@ -780,7 +781,7 @@ export class StoryScene extends Phaser.Scene { this.portraitFrame.setDepth(dialogDepth + 2); this.portrait = this.add.image(panelX + ui(86), panelY + panelH / 2, this.pagePortraitTextureKey(this.pages[0]) ?? 'story-fallback'); - this.portrait.setDisplaySize(ui(128), ui(128)); + fitDialogueFacePortrait(this.portrait, ui(128)); this.portrait.setDepth(dialogDepth + 3); this.portraitDivider = this.add.rectangle(panelX + ui(170), panelY + ui(30), ui(1), panelH - ui(60), palette.gold, 0.12).setOrigin(0); @@ -902,7 +903,9 @@ export class StoryScene extends Phaser.Scene { this.portrait?.setVisible(true); this.portraitDivider?.setVisible(true); this.portrait?.setTexture(textureKey); - this.portrait?.setDisplaySize(ui(128), ui(128)); + if (this.portrait) { + fitDialogueFacePortrait(this.portrait, ui(128)); + } this.nameText?.setPosition(ui(270), this.scale.height - ui(storyDialogLayout.nameTopOffset)); this.bodyText?.setPosition(ui(270), this.scale.height - ui(storyDialogLayout.bodyTopOffset)); this.bodyText?.setWordWrapWidth(this.scale.width - ui(374), true); @@ -1663,7 +1666,7 @@ export class StoryScene extends Phaser.Scene { if (portraitTexture) { const portrait = this.add.image(portraitX, portraitY, portraitTexture); - portrait.setDisplaySize(height - ui(18), height - ui(18)); + fitDialogueFacePortrait(portrait, height - ui(18)); layer.add(portrait); } else if (this.textures.exists(profile.unitTextureKey)) { const sprite = this.add.sprite(portraitX, portraitY + ui(3), profile.unitTextureKey, this.storyUnitFrameIndex(actor.direction ?? 'south')); diff --git a/src/game/ui/dialoguePortrait.ts b/src/game/ui/dialoguePortrait.ts new file mode 100644 index 0000000..b835af4 --- /dev/null +++ b/src/game/ui/dialoguePortrait.ts @@ -0,0 +1,76 @@ +import type Phaser from 'phaser'; + +const defaultFaceCropRatio = 0.6; +const defaultFaceCropTopRatio = 0.035; + +export type DialogueFacePortraitLayout = { + cropX: number; + cropY: number; + cropSize: number; + originX: number; + originY: number; + scale: number; +}; + +export function calculateDialogueFacePortraitLayout( + sourceWidth: number, + sourceHeight: number, + displaySize: number +): DialogueFacePortraitLayout { + if ( + !Number.isFinite(sourceWidth) || + !Number.isFinite(sourceHeight) || + !Number.isFinite(displaySize) || + sourceWidth <= 0 || + sourceHeight <= 0 || + displaySize <= 0 + ) { + throw new RangeError('Dialogue portrait dimensions must be finite positive numbers.'); + } + + const cropSize = Math.max( + 1, + Math.round( + Math.min(sourceWidth, sourceHeight) * defaultFaceCropRatio + ) + ); + const cropX = Math.round((sourceWidth - cropSize) / 2); + const cropY = Math.round( + Math.min( + Math.max(0, sourceHeight * defaultFaceCropTopRatio), + sourceHeight - cropSize + ) + ); + + return { + cropX, + cropY, + cropSize, + originX: (cropX + cropSize / 2) / sourceWidth, + originY: (cropY + cropSize / 2) / sourceHeight, + scale: displaySize / cropSize + }; +} + +export function fitDialogueFacePortrait( + portrait: Phaser.GameObjects.Image, + displaySize: number +) { + const layout = calculateDialogueFacePortraitLayout( + portrait.frame.realWidth, + portrait.frame.realHeight, + displaySize + ); + + portrait + .setCrop( + layout.cropX, + layout.cropY, + layout.cropSize, + layout.cropSize + ) + .setOrigin(layout.originX, layout.originY) + .setScale(layout.scale); + + return layout; +}