From d3af88c2a90e52d357ed0d6c805a260bcafaacda Mon Sep 17 00:00:00 2001 From: Wickedness Date: Wed, 22 Jul 2026 12:57:42 +0900 Subject: [PATCH] feat: strengthen semantic battle feedback --- docs/audio-sources.md | 12 + package.json | 4 +- scripts/verify-audio-asset-data.mjs | 53 +++ .../verify-audiovisual-feedback-browser.mjs | 328 ++++++++++++++++++ scripts/verify-audiovisual-feedback.mjs | 117 +++++++ scripts/verify-sound-director.mjs | 42 ++- scripts/verify-static-data.mjs | 1 + src/assets/audio/sfx/burn-tick.mp3 | Bin 0 -> 11564 bytes src/assets/audio/sfx/recovery-cue.mp3 | Bin 0 -> 31532 bytes src/assets/audio/sfx/reward-reveal.mp3 | Bin 0 -> 23468 bytes src/game/audio/SoundDirector.ts | 29 +- src/game/audio/audioAssets.ts | 6 + src/game/scenes/BattleScene.ts | 242 +++++++++++-- src/game/scenes/CampScene.ts | 3 +- 14 files changed, 803 insertions(+), 34 deletions(-) create mode 100644 scripts/verify-audiovisual-feedback-browser.mjs create mode 100644 scripts/verify-audiovisual-feedback.mjs create mode 100644 src/assets/audio/sfx/burn-tick.mp3 create mode 100644 src/assets/audio/sfx/recovery-cue.mp3 create mode 100644 src/assets/audio/sfx/reward-reveal.mp3 diff --git a/docs/audio-sources.md b/docs/audio-sources.md index f2434ea..307c940 100644 --- a/docs/audio-sources.md +++ b/docs/audio-sources.md @@ -55,6 +55,18 @@ The source file in this section was downloaded from its official Pixabay detail The narrative cue was resampled to 48 kHz stereo MP3, silence-trimmed to an approximately 1.0-second excerpt, fade-in/out processed, and high-pass and low-pass filtered for unobtrusive playback over story music. +## Semantic Feedback Cues + +The source files in this section were downloaded from their official Pixabay detail pages on 2026-07-22 and use the Pixabay Content License linked above. + +| Project file | Role | Pixabay title | Creator | Source | +| --- | --- | --- | --- | --- | +| `src/assets/audio/sfx/recovery-cue.mp3` | Healing, supply, and terrain recovery feedback | Holy Healing Spell | Coghezzi | https://pixabay.com/sound-effects/film-special-effects-holy-healing-spell-533279/ | +| `src/assets/audio/sfx/reward-reveal.mp3` | Victory reward arrival feedback | Reward Chime | Universfield | https://pixabay.com/sound-effects/film-special-effects-reward-chime-144757/ | +| `src/assets/audio/sfx/burn-tick.mp3` | Batched burn-status damage feedback | Electronic element burn spark 1 | FxProSound | https://pixabay.com/sound-effects/film-special-effects-electronic-element-burn-spark-1-248606/ | + +All three cues were silence-trimmed, resampled to 48 kHz stereo MP3, fade-in/out processed, and high-pass and low-pass filtered. Recovery was reduced to approximately 1.9 seconds, reward reveal to 1.4 seconds, and burn damage to 0.7 seconds so feedback begins promptly and does not mask the next action. + ## Sound Effects | Project file | Pixabay title | Creator | Source | diff --git a/package.json b/package.json index a7e4d44..0fc8f26 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,8 @@ "verify:loader-lifecycle": "node scripts/verify-loader-lifecycle.mjs", "verify:battle-map-assets": "node scripts/verify-battle-map-asset-data.mjs", "verify:audio-assets": "node scripts/verify-audio-asset-data.mjs", + "verify:av-feedback": "node scripts/verify-audiovisual-feedback.mjs", + "verify:av-feedback:browser": "node scripts/verify-audiovisual-feedback-browser.mjs", "verify:desktop-viewport": "node scripts/verify-desktop-browser-viewport.mjs", "verify:static-data": "node scripts/verify-static-data.mjs", "verify:flow": "node scripts/verify-flow-segments.mjs", @@ -55,7 +57,7 @@ "verify:interaction-ux": "node scripts/verify-interaction-ux.mjs", "verify:save-flow": "node scripts/verify-save-retry-flow.mjs", "verify:release": "node scripts/verify-release-candidate.mjs", - "verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:release && pnpm run verify:performance", + "verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance", "verify:1.0": "pnpm run verify:local-release && pnpm run verify:flow && pnpm run qa:campaign-complete && pnpm run qa:battle-maps:webgl", "verify:public-deploy": "node scripts/verify-public-deploy.mjs", "qa:representative": "node scripts/qa-representative-battles.mjs", diff --git a/scripts/verify-audio-asset-data.mjs b/scripts/verify-audio-asset-data.mjs index b43a090..f57cadd 100644 --- a/scripts/verify-audio-asset-data.mjs +++ b/scripts/verify-audio-asset-data.mjs @@ -20,6 +20,7 @@ const requiredTacticalCueKeys = [ 'objective-failure' ]; const requiredNarrativeCueKeys = ['story-page-turn']; +const requiredSemanticFeedbackCueKeys = ['recovery-cue', 'reward-reveal', 'burn-tick']; const requiredBattleSceneAudioMethods = [ 'playSoundscape', 'playAllyTurnCue', @@ -64,6 +65,23 @@ const narrativeSourceRecord = { 'high-pass and low-pass filtered' ] }; +const semanticFeedbackSourceRecords = [ + { + projectFile: 'src/assets/audio/sfx/recovery-cue.mp3', + source: 'https://pixabay.com/sound-effects/film-special-effects-holy-healing-spell-533279/', + requiredDocumentation: ['Holy Healing Spell', 'Coghezzi', 'approximately 1.9 seconds'] + }, + { + projectFile: 'src/assets/audio/sfx/reward-reveal.mp3', + source: 'https://pixabay.com/sound-effects/film-special-effects-reward-chime-144757/', + requiredDocumentation: ['Reward Chime', 'Universfield', '1.4 seconds'] + }, + { + projectFile: 'src/assets/audio/sfx/burn-tick.mp3', + source: 'https://pixabay.com/sound-effects/film-special-effects-electronic-element-burn-spark-1-248606/', + requiredDocumentation: ['Electronic element burn spark 1', 'FxProSound', '0.7 seconds'] + } +]; const server = await createServer({ logLevel: 'error', @@ -85,8 +103,10 @@ try { validateRequiredTrackKeys(errors, 'battlefield ambience', ambienceTracks, requiredBattlefieldAmbienceKeys); validateRequiredTrackKeys(errors, 'tactical cue', effectTracks, requiredTacticalCueKeys); validateRequiredTrackKeys(errors, 'narrative cue', effectTracks, requiredNarrativeCueKeys); + validateRequiredTrackKeys(errors, 'semantic feedback cue', effectTracks, requiredSemanticFeedbackCueKeys); validateBattlefieldSourceDocumentation(errors); validateNarrativeSourceDocumentation(errors); + validateSemanticFeedbackSourceDocumentation(errors); validateBattleSceneAudioIntegration(errors); validateEffectPools(errors, effectPools, effectTracks); validateEffectPoolRegistration(errors, effectPools); @@ -177,6 +197,39 @@ function validateNarrativeSourceDocumentation(errors) { }); } +function validateSemanticFeedbackSourceDocumentation(errors) { + const docs = readFileSync(join('docs', 'audio-sources.md'), 'utf8'); + const sectionHeading = '## Semantic Feedback Cues'; + const sectionStart = docs.indexOf(sectionHeading); + if (sectionStart < 0) { + errors.push('docs/audio-sources.md must include the Semantic Feedback Cues section'); + return; + } + const nextSectionStart = docs.indexOf('\n## ', sectionStart + sectionHeading.length); + const semanticDocs = docs.slice(sectionStart, nextSectionStart < 0 ? docs.length : nextSectionStart); + if (!semanticDocs.includes('2026-07-22')) { + errors.push('docs/audio-sources.md must record the 2026-07-22 semantic feedback download date'); + } + ['48 kHz stereo MP3', 'silence-trimmed', 'fade-in/out', 'high-pass and low-pass filtered'].forEach((detail) => { + if (!semanticDocs.includes(detail)) { + errors.push(`docs/audio-sources.md is missing semantic feedback processing detail "${detail}"`); + } + }); + semanticFeedbackSourceRecords.forEach(({ projectFile, source, requiredDocumentation }) => { + if (!semanticDocs.includes(projectFile)) { + errors.push(`docs/audio-sources.md is missing semantic feedback file "${projectFile}"`); + } + if (!semanticDocs.includes(source)) { + errors.push(`docs/audio-sources.md is missing Pixabay source "${source}"`); + } + requiredDocumentation.forEach((detail) => { + if (!semanticDocs.includes(detail)) { + errors.push(`docs/audio-sources.md is missing semantic feedback detail "${detail}"`); + } + }); + }); +} + function validateBattleSceneAudioIntegration(errors) { const path = join('src', 'game', 'scenes', 'BattleScene.ts'); const source = readFileSync(path, 'utf8'); diff --git a/scripts/verify-audiovisual-feedback-browser.mjs b/scripts/verify-audiovisual-feedback-browser.mjs new file mode 100644 index 0000000..5b5dce0 --- /dev/null +++ b/scripts/verify-audiovisual-feedback-browser.mjs @@ -0,0 +1,328 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { chromium } from 'playwright'; +import { + desktopBrowserContextOptions, + desktopBrowserDeviceScaleFactor, + desktopBrowserViewport +} from './desktop-browser-viewport.mjs'; + +const explicitUrl = process.env.AV_FEEDBACK_QA_URL; +const targetUrl = new URL(explicitUrl ?? 'http://127.0.0.1:41741/heros_web/'); +targetUrl.searchParams.set('debug', '1'); +targetUrl.searchParams.set('renderer', 'canvas'); +targetUrl.searchParams.set('debugBattleSetup', 'status-ui'); + +let serverProcess; +let browser; + +try { + if (!explicitUrl) { + serverProcess = spawn( + process.execPath, + ['node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', '41741', '--strictPort'], + { cwd: process.cwd(), stdio: 'ignore', windowsHide: true } + ); + await waitForUrl(targetUrl, 20000); + } + + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(desktopBrowserContextOptions); + const pageErrors = []; + const consoleErrors = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()); + } + }); + + await page.goto(targetUrl.href, { waitUntil: 'domcontentloaded', timeout: 90000 }); + await page.waitForFunction(() => Boolean(window.__HEROS_DEBUG__), undefined, { timeout: 30000 }); + await page.evaluate(async () => { + await window.__HEROS_DEBUG__.goToBattle('first-battle-zhuo-commandery'); + }); + await page.waitForFunction(() => { + const battle = window.__HEROS_DEBUG__?.battle(); + return battle?.scene === 'BattleScene' && battle.units?.some((unit) => unit.statusBadges?.length > 0); + }, undefined, { timeout: 90000 }); + + const initial = await page.evaluate(() => { + const debug = window.__HEROS_DEBUG__; + const scene = debug.scene('BattleScene'); + const battle = debug.battle(); + const ally = battle.units.find((unit) => unit.statusBadges?.some((badge) => badge.kind === 'burn')); + const enemy = battle.units.find((unit) => unit.statusBadges?.some((badge) => badge.kind === 'confusion')); + const allyView = ally ? scene.unitViews.get(ally.id) : undefined; + const enemyView = enemy ? scene.unitViews.get(enemy.id) : undefined; + return { + viewport: { + width: window.innerWidth, + height: window.innerHeight, + dpr: window.devicePixelRatio, + scale: window.visualViewport?.scale ?? 1 + }, + canvas: (() => { + const bounds = document.querySelector('canvas')?.getBoundingClientRect(); + return bounds ? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } : null; + })(), + ally: ally && allyView + ? { + id: ally.id, + hp: ally.hp, + spriteX: allyView.sprite.x, + badge: ally.statusBadges.find((badge) => badge.kind === 'burn'), + roleSealBounds: objectBounds(allyView.roleSeal), + badgeBounds: objectBounds(allyView.statusBadges[0]?.container) + } + : null, + enemy: enemy && enemyView + ? { + id: enemy.id, + spriteX: enemyView.sprite.x, + badge: enemy.statusBadges.find((badge) => badge.kind === 'confusion'), + badgeBounds: objectBounds(enemyView.statusBadges[0]?.container) + } + : null + }; + + function objectBounds(object) { + if (!object?.active) { + return null; + } + const bounds = object.getBounds(); + return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }; + } + }); + + assert.deepEqual(initial.viewport, { + width: desktopBrowserViewport.width, + height: desktopBrowserViewport.height, + dpr: desktopBrowserDeviceScaleFactor, + scale: 1 + }); + assert.deepEqual(initial.canvas, { x: 0, y: 0, width: 1920, height: 1080 }); + assert(initial.ally?.badge?.visible, `Expected a visible allied burn badge: ${JSON.stringify(initial.ally)}`); + assert(initial.enemy?.badge?.visible, `Expected a visible enemy confusion badge: ${JSON.stringify(initial.enemy)}`); + assert(initial.ally.badge.x > initial.ally.spriteX, 'allied status badges should occupy the side opposite the role seal'); + assert(initial.enemy.badge.x < initial.enemy.spriteX, 'enemy status badges should occupy the side opposite the intent marker'); + if (initial.ally.roleSealBounds) { + assert(!boundsIntersect(initial.ally.roleSealBounds, initial.ally.badgeBounds), 'allied status badge must not overlap the role seal'); + } + await page.screenshot({ path: 'dist/qa-av-status-badges-1920x1080.png', fullPage: true }); + + await page.waitForFunction(() => { + const battle = window.__HEROS_DEBUG__?.battle(); + return ( + battle?.phase === 'deployment' && + ['ready', 'degraded'].includes(battle.combatAssets?.status) && + Boolean(battle.battleHud?.deploymentPanel?.startButtonBounds) + ); + }, undefined, { timeout: 90000 }); + const startPoint = await page.evaluate(() => { + const debug = window.__HEROS_DEBUG__; + const scene = debug.scene('BattleScene'); + const bounds = debug.battle().battleHud.deploymentPanel.startButtonBounds; + const canvasBounds = document.querySelector('canvas').getBoundingClientRect(); + return { + x: canvasBounds.left + (bounds.x + bounds.width / 2) * canvasBounds.width / scene.scale.width, + y: canvasBounds.top + (bounds.y + bounds.height / 2) * canvasBounds.height / scene.scale.height + }; + }); + await page.mouse.click(4, 4); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.audio()?.started === true, undefined, { timeout: 5000 }); + await page.mouse.click(startPoint.x, startPoint.y); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.phase === 'idle', undefined, { timeout: 90000 }); + + const openingAudio = await page.evaluate(() => window.__HEROS_DEBUG__.battle().audio); + assert.equal(openingAudio.started, true, 'the deployment click should unlock audio'); + assert.equal(openingAudio.semanticCues.lastPlayed?.group, 'operation'); + assert.equal(openingAudio.semanticCues.lastPlayed?.key, 'operation-alert'); + assert.equal(openingAudio.lastEffect?.trackKey, 'operation-alert', 'the opening alert must not be overwritten by ui-select'); + + await page.waitForFunction(() => window.__HEROS_DEBUG__?.audio()?.activeEffectCount === 0, undefined, { timeout: 5000 }); + + const pairedBadges = await page.evaluate((unitId) => { + const debug = window.__HEROS_DEBUG__; + const scene = debug.scene('BattleScene'); + const unit = scene.debugUnitById(unitId); + scene.applyBattleStatus(unit, 'confusion', 2, 1); + const view = scene.unitViews.get(unitId); + const state = debug.battle().units.find((candidate) => candidate.id === unitId); + return { + spriteX: view.sprite.x, + badges: state.statusBadges, + roleSealBounds: objectBounds(view.roleSeal), + badgeBounds: view.statusBadges.map((badge) => objectBounds(badge.container)) + }; + + function objectBounds(object) { + if (!object?.active) { + return null; + } + const bounds = object.getBounds(); + return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }; + } + }, initial.ally.id); + assert.deepEqual(pairedBadges.badges.map((badge) => badge.kind), ['burn', 'confusion']); + assert(pairedBadges.badges.every((badge) => badge.visible && badge.x > pairedBadges.spriteX)); + assert( + pairedBadges.badges[1].x > pairedBadges.badges[0].x, + 'the second allied badge should fan outward instead of folding back over the unit' + ); + pairedBadges.badgeBounds.forEach((bounds) => { + assert(!boundsIntersect(pairedBadges.roleSealBounds, bounds), 'paired allied status badges must not overlap the role seal'); + }); + + const tickResult = await page.evaluate(() => { + const scene = window.__HEROS_DEBUG__.scene('BattleScene'); + scene.hideBattleEventBanner(); + return scene.tickBattleStatuses('ally'); + }); + assert.match(tickResult.message, /화상/); + await page.waitForFunction(() => { + const audio = window.__HEROS_DEBUG__?.audio(); + return audio?.lastEffect?.trackKey === 'burn-tick' && audio.activeEffectCount > 0; + }, undefined, { timeout: 3000 }); + + const afterTick = await page.evaluate((unitId) => { + const battle = window.__HEROS_DEBUG__.battle(); + return { + feedback: battle.mapResultFeedback, + audio: battle.audio, + unit: battle.units.find((candidate) => candidate.id === unitId) + }; + }, initial.ally.id); + assert(afterTick.feedback.activeCount > 0 && afterTick.feedback.activeCount <= afterTick.feedback.maxActive); + assert.deepEqual(afterTick.feedback.last.lines, ['화상 -3', `HP ${initial.ally.hp}→${initial.ally.hp - 3}`]); + assert.equal(afterTick.audio.semanticCues.lastPlayed?.group, 'status'); + assert.equal(afterTick.audio.semanticCues.lastPlayed?.key, 'burn-tick'); + assert(afterTick.audio.activeEffectCount > 0, 'the browser must keep the decoded burn cue active after playback starts'); + assert.equal(afterTick.unit.hp, initial.ally.hp - 3); + assert.equal(afterTick.unit.statusBadges.find((badge) => badge.kind === 'burn')?.turns, 1); + assert.equal(afterTick.unit.statusBadges.find((badge) => badge.kind === 'confusion')?.turns, 1); + await page.screenshot({ path: 'dist/qa-av-status-tick-1920x1080.png', fullPage: true }); + + await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.mapResultFeedback?.activeCount === 0, undefined, { + timeout: 3000 + }); + const offscreenTick = await page.evaluate((unitId) => { + const debug = window.__HEROS_DEBUG__; + const scene = debug.scene('BattleScene'); + const unit = scene.debugUnitById(unitId); + const camera = scene.getDebugState().camera; + const candidates = [ + { x: camera.x + camera.visibleColumns, y: unit.y }, + { x: camera.x - 1, y: unit.y }, + { x: unit.x, y: camera.y + camera.visibleRows }, + { x: unit.x, y: camera.y - 1 } + ]; + const destination = candidates.find( + (tile) => + tile.x >= 0 && + tile.y >= 0 && + tile.x < camera.mapWidth && + tile.y < camera.mapHeight && + !scene.isTileVisible(tile.x, tile.y) + ); + if (!destination) { + throw new Error(`No offscreen tile is available for camera ${JSON.stringify(camera)}`); + } + unit.x = destination.x; + unit.y = destination.y; + scene.positionUnitView(unit); + const result = scene.tickBattleStatuses('ally'); + const battle = debug.battle(); + return { + message: result.message, + tileVisible: scene.isTileVisible(unit.x, unit.y), + feedback: battle.mapResultFeedback, + unit: battle.units.find((candidate) => candidate.id === unitId) + }; + }, initial.ally.id); + assert.match(offscreenTick.message, /화상/); + assert.equal(offscreenTick.tileVisible, false); + assert.equal(offscreenTick.feedback.activeCount, 0, 'offscreen burn damage must not clamp a popup to the map edge'); + assert.equal(offscreenTick.unit.hp, initial.ally.hp - 6); + assert.equal(offscreenTick.unit.statusBadges.length, 0, 'expired status badges must be removed after the offscreen tick'); + + await page.waitForFunction(() => window.__HEROS_DEBUG__?.audio()?.activeEffectCount === 0, undefined, { timeout: 3000 }); + const prioritizedBurn = await page.evaluate(() => { + const debug = window.__HEROS_DEBUG__; + const scene = debug.scene('BattleScene'); + const recoveryPlayedAt = debug.audio().semanticCues.playedAt.recovery ?? null; + const selected = scene.playTurnStartFeedbackCue('ally', true, true); + return { selected, recoveryPlayedAt, audio: debug.audio() }; + }); + assert.equal(prioritizedBurn.selected, 'status'); + assert.equal(prioritizedBurn.audio.semanticCues.lastPlayed.group, 'status'); + assert.equal(prioritizedBurn.audio.lastEffect.trackKey, 'burn-tick'); + assert.equal(prioritizedBurn.audio.activeEffectCount, 1, 'burn must be the only cue selected when burn and recovery coincide'); + assert.equal( + prioritizedBurn.audio.semanticCues.playedAt.recovery ?? null, + prioritizedBurn.recoveryPlayedAt, + 'lower-priority recovery must not be layered under burn' + ); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.audio()?.activeEffectCount === 0, undefined, { timeout: 3000 }); + const operationPriority = await page.evaluate(() => { + const debug = window.__HEROS_DEBUG__; + const scene = debug.scene('BattleScene'); + const before = debug.audio().semanticCues.lastPlayed; + const selected = scene.playTurnStartFeedbackCue('ally', true, true, true); + return { before, selected, after: debug.audio().semanticCues.lastPlayed, activeEffectCount: debug.audio().activeEffectCount }; + }); + assert.equal(operationPriority.selected, 'operation'); + assert.deepEqual(operationPriority.after, operationPriority.before, 'an existing operation cue must suppress all additional turn-start cues'); + assert.equal(operationPriority.activeEffectCount, 0); + + const recoveryCue = await page.evaluate(() => { + const debug = window.__HEROS_DEBUG__; + const scene = debug.scene('BattleScene'); + const selected = scene.playTurnStartFeedbackCue('ally', false, true); + return { selected, audio: debug.audio() }; + }); + assert.equal(recoveryCue.selected, 'recovery'); + assert.equal(recoveryCue.audio.semanticCues.lastPlayed.group, 'recovery'); + assert.equal(recoveryCue.audio.lastEffect.trackKey, 'recovery-cue'); + assert.equal(recoveryCue.audio.activeEffectCount, 1, 'the standalone recovery cue must decode and play in the browser'); + + assert.deepEqual(pageErrors, [], `Unexpected page errors: ${pageErrors.join('\n')}`); + assert.deepEqual(consoleErrors, [], `Unexpected browser console errors: ${consoleErrors.join('\n')}`); + console.log( + 'Verified 1920x1080 DPR1 paired status badges, decoded and prioritized cues, offscreen popup suppression, expiry, and opening isolation.' + ); +} finally { + await browser?.close(); + serverProcess?.kill(); +} + +function boundsIntersect(left, right) { + if (!left || !right) { + return false; + } + return !( + left.x + left.width <= right.x || + right.x + right.width <= left.x || + left.y + left.height <= right.y || + right.y + right.height <= left.y + ); +} + +async function waitForUrl(url, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (serverProcess?.exitCode !== null) { + throw new Error(`Preview server exited early with code ${serverProcess.exitCode}`); + } + try { + const response = await fetch(url); + if (response.ok) { + return; + } + } catch { + // Preview startup is still in progress. + } + await new Promise((resolve) => setTimeout(resolve, 150)); + } + throw new Error(`Timed out waiting for ${url.href}`); +} diff --git a/scripts/verify-audiovisual-feedback.mjs b/scripts/verify-audiovisual-feedback.mjs new file mode 100644 index 0000000..a349447 --- /dev/null +++ b/scripts/verify-audiovisual-feedback.mjs @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const battleSource = readFileSync('src/game/scenes/BattleScene.ts', 'utf8'); +const campSource = readFileSync('src/game/scenes/CampScene.ts', 'utf8'); +const soundSource = readFileSync('src/game/audio/SoundDirector.ts', 'utf8'); + +const tickStatuses = privateMethodBody(battleSource, 'tickBattleStatuses'); +assert.match(tickStatuses, /let burnTicked = false/); +assert.match(tickStatuses, /showMapResultPopup\s*\(/); +assert.match(tickStatuses, /`\$\{status\.label\} -\$\{damage\}`/); +assert.match(tickStatuses, /flashDamage\s*\(unit, previousHp\)/); +assert.match(tickStatuses, /const visibleOnMap = this\.isTileVisible\(unit\.x, unit\.y\)/); +assert.match(tickStatuses, /if \(visibleOnMap\)\s*\{\s*this\.showMapResultPopup/s); +assert.match(tickStatuses, /if \(visibleOnMap\)\s*\{\s*this\.flashDamage/s); +assert.match(tickStatuses, /if \(playCue && burnTicked\)\s*\{\s*soundDirector\.playBurnTickCue\(\)/s); +assert.equal(countMatches(tickStatuses, /soundDirector\.playBurnTickCue\(\)/g), 1, 'a direct status tick must play one cue per faction batch'); + +const endAllyTurn = privateMethodBody(battleSource, 'endAllyTurn'); +const beginNextAllyTurn = privateMethodBody(battleSource, 'beginNextAllyTurn'); +const turnStartCue = privateMethodBody(battleSource, 'playTurnStartFeedbackCue'); +[endAllyTurn, beginNextAllyTurn].forEach((body) => { + assert.match(body, /tickBattleStatuses\('(ally|enemy)', false\)/); + assert.match(body, /applyTerrainRecovery\('(ally|enemy)', false\)/); + assert.match(body, /playTurnStartFeedbackCue/); + assert.match(body, /statusOutcomeDelayMs = statusResult\.burnTicked \? 720 : 0/); + assert.match(body, /resolveBattleOutcomeIfNeeded\(statusOutcomeDelayMs\)/); + assert.match(body, /statusResult\.burnTicked[\s\S]*playBurnTickCue\(\)/, 'lethal burn must retain its cue before an early return'); +}); +const cuePriorityMarkers = ['if (operationCuePlayed)', 'if (burnTicked)', 'if (recoveryApplied)', "if (faction === 'ally')"]; +cuePriorityMarkers.forEach((marker) => assert.match(turnStartCue, new RegExp(escapeRegExp(marker)))); +assert.deepEqual( + cuePriorityMarkers.map((marker) => turnStartCue.indexOf(marker)), + cuePriorityMarkers.map((marker) => turnStartCue.indexOf(marker)).toSorted((left, right) => left - right), + 'turn-start cues must prioritize operation, burn, recovery, then the generic faction cue' +); +assert.doesNotMatch(turnStartCue, /delayedCall/, 'turn-start feedback must select one cue instead of layering delayed cues'); + +const supportMap = privateMethodBody(battleSource, 'playSupportMapPresentation'); +const supportCutIn = privateMethodBody(battleSource, 'playSupportCutIn'); +const terrainRecovery = privateMethodBody(battleSource, 'applyTerrainRecovery'); +const tacticalInitiative = privateMethodBody(battleSource, 'activateTacticalInitiativeCommand'); +const campSupply = privateMethodBody(campSource, 'useSupply'); +[supportMap, supportCutIn].forEach((body) => { + assert.match(body, /result\.usable\.effect === 'heal'/); + assert.match(body, /soundDirector\.playRecoveryCue\(\)/); +}); +assert.match(terrainRecovery, /soundDirector\.playRecoveryCue\(\)/); +assert.match(terrainRecovery, /if \(playCue\)/); +assert.match(tacticalInitiative, /role === 'support'/); +assert.match(tacticalInitiative, /soundDirector\.playRecoveryCue\(\)/); +assert.match(campSupply, /soundDirector\.playRecoveryCue\(\)/); + +const victoryReward = privateMethodBody(campSource, 'showVictoryRewardAcknowledgement'); +assert.equal(countMatches(victoryReward, /soundDirector\.playRewardRevealCue\(\)/g), 1, 'victory reward should reveal once'); + +const deploymentConfirmation = privateMethodBody(battleSource, 'confirmPreBattleDeployment'); +assert.match(deploymentConfirmation, /showOpeningBattleEvent\(\)/); +assert.doesNotMatch(deploymentConfirmation, /soundDirector\.playSelect\(\)/, 'opening alert must not overlap a selection cue'); + +const mapResultPopup = privateMethodBody(battleSource, 'showMapResultPopup'); +assert.match(mapResultPopup, /!view \|\| !this\.isTileVisible\(unit\.x, unit\.y\)/, 'offscreen units must not create edge-clamped popups'); + +const statusBadges = privateMethodBody(battleSource, 'syncUnitStatusBadges'); +assert.match(statusBadges, /\.slice\(0, 2\)/, 'map status badges must remain capped at two per unit'); +assert.match(statusBadges, /createBattleUiIcon/); +assert.match(statusBadges, /statusEffectIcon/); +assert.match(statusBadges, /statusEffectTone/); +assert.match(statusBadges, /setMask\(this\.mapMask\)/); +assert.match(privateMethodBody(battleSource, 'applyBattleStatus'), /syncUnitStatusBadges/); +assert.match(tickStatuses, /syncUnitStatusBadges/); +assert.match(tacticalInitiative, /battleStatuses\.delete\(supportTarget\.id\)[\s\S]*syncUnitStatusBadges\(supportTarget\)/); +assert.match(privateMethodBody(battleSource, 'applyDefeatedStyle'), /syncUnitStatusBadges/); +assert.match(privateMethodBody(battleSource, 'moveUnitViewAsync'), /unitStatusBadgePosition/); +assert.match(privateMethodBody(battleSource, 'moveUnitView'), /unitStatusBadgePosition/); +assert.match(privateMethodBody(battleSource, 'unitStatusBadgePosition'), /0\.34 \+ index \* spacing/, 'paired badges should fan away from the unit instead of folding inward'); +assert.match(battleSource, /statusBadgeSide: unit\.faction === 'ally' \? 1 : -1/); +assert.match(battleSource, /statusBadges: \(this\.unitViews\.get\(unit\.id\)\?\.statusBadges \?\? \[\]\)\.map/); + +for (const [method, group, key] of [ + ['playRecoveryCue', 'recovery', 'recovery-cue'], + ['playBurnTickCue', 'status', 'burn-tick'], + ['playRewardRevealCue', 'reward', 'reward-reveal'] +]) { + const body = publicMethodBody(soundSource, method); + assert.match(body, new RegExp(`playSemanticCue\\('${group}', '${key}'`)); +} +assert.match(soundSource, /recovery:\s*1900/, 'recovery cue cooldown should cover its playback window'); + +console.log('Verified semantic sound cues, map status feedback, capped status badges, and non-overlapping battle opening audio.'); + +function privateMethodBody(source, name) { + return methodBody(source, new RegExp(`^ private (?:async )?${name}\\b`, 'm'), `private ${name}`); +} + +function publicMethodBody(source, name) { + return methodBody(source, new RegExp(`^ (?:async )?${name}\\b`, 'm'), name); +} + +function methodBody(source, pattern, label) { + const match = pattern.exec(source); + assert(match, `missing method marker: ${label}`); + const start = match.index; + const next = source.indexOf('\n private ', start + match[0].length); + const nextPublic = source.indexOf('\n play', start + match[0].length); + const candidates = [next, nextPublic].filter((index) => index > start); + const end = candidates.length > 0 ? Math.min(...candidates) : source.length; + return source.slice(start, end); +} + +function countMatches(source, pattern) { + return Array.from(source.matchAll(pattern)).length; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/scripts/verify-sound-director.mjs b/scripts/verify-sound-director.mjs index 0a09fc4..6370152 100644 --- a/scripts/verify-sound-director.mjs +++ b/scripts/verify-sound-director.mjs @@ -103,6 +103,9 @@ try { 'objective-success': '/audio/objective-success.ogg', 'objective-failure': '/audio/objective-failure.ogg', 'story-page-turn': '/audio/story-page-turn.ogg', + 'recovery-cue': '/audio/recovery-cue.ogg', + 'burn-tick': '/audio/burn-tick.ogg', + 'reward-reveal': '/audio/reward-reveal.ogg', 'ui-select': '/audio/ui-select.ogg' }); director.registerEffectPools({ @@ -388,7 +391,7 @@ try { debug = director.getDebugState(); assert.deepEqual( debug.semanticCues.cooldownMs, - { turn: 900, operation: 650, objective: 1200, narrative: 220 }, + { turn: 900, operation: 650, objective: 1200, narrative: 220, recovery: 1900, status: 280, reward: 1100 }, 'semantic cue cooldowns should be visible in debug state' ); assert.deepEqual( @@ -447,6 +450,41 @@ try { clock.advance(1100); assert.equal(director.getDebugState().activeEffectCount, 0, 'story advance cues should retire after playback'); + const feedbackCueStart = audioHarness.instances.length; + const musicDuckRevisionBeforeFeedback = director.getDebugState().musicDuck.revision; + assert.equal(director.playRecoveryCue(), true, 'the first recovery cue should play'); + assertClose(audioHarness.byOriginalSrc('/audio/recovery-cue.ogg').volume, 0.26, 'recovery cue volume'); + assert.equal(director.playRecoveryCue(), false, 'rapid recovery cues should be coalesced'); + assert.equal(director.playBurnTickCue(), true, 'the first batched burn cue should play'); + assertClose(audioHarness.byOriginalSrc('/audio/burn-tick.ogg').volume, 0.24, 'burn cue volume'); + assert.equal(director.playBurnTickCue(), false, 'rapid burn cues should be coalesced'); + assert.equal(director.playRewardRevealCue(), true, 'the first reward reveal cue should play'); + assertClose(audioHarness.byOriginalSrc('/audio/reward-reveal.ogg').volume, 0.26, 'reward reveal cue volume'); + assert.equal(director.playRewardRevealCue(), false, 'rapid reward reveal cues should be coalesced'); + debug = director.getDebugState(); + assert.equal( + debug.musicDuck.revision, + musicDuckRevisionBeforeFeedback + 1, + 'only the reward reveal cue should apply a short music duck' + ); + assert.deepEqual( + debug.semanticCues.lastSuppressed, + { group: 'reward', key: 'reward-reveal', at: clock.now, remainingMs: 1100 }, + 'debug state should expose a coalesced reward reveal cue' + ); + assert.equal(audioHarness.instances.length, feedbackCueStart + 3, 'coalesced feedback cues must not construct Audio'); + clock.advance(280); + assert.equal(director.playRecoveryCue(), false, 'recovery cues should remain coalesced for the clip playback window'); + assert.equal(director.playBurnTickCue(), true, 'burn cues should replay after their cooldown'); + assert.equal(director.playRewardRevealCue(), false, 'reward cues should retain their longer arrival cooldown'); + clock.advance(820); + assert.equal(director.playRewardRevealCue(), true, 'reward cues should replay after the arrival cooldown'); + assert.equal(director.playRecoveryCue(), false, 'recovery cues should still be coalesced before the clip finishes'); + clock.advance(800); + assert.equal(director.playRecoveryCue(), true, 'recovery cues should replay after the full clip playback window'); + clock.advance(2000); + assert.equal(director.getDebugState().activeEffectCount, 0, 'semantic feedback cues should retire after playback'); + const semanticEffectStart = audioHarness.instances.length; director.playMeleeSwing(true, false); assert.equal(director.getDebugState().lastEffect?.key, 'melee-swing', 'melee API should use the swing pool'); @@ -485,7 +523,7 @@ try { console.log( `Verified dual SoundDirector channels across ${debug.music.elementRevision} music and ` + `${debug.ambience.elementRevision} ambience element revisions, no-repeat effect pools, ` + - 'coalesced tactical and narrative cues, semantic combat effects, category multipliers, and deterministic music ducking.' + 'coalesced tactical, narrative, and feedback cues, semantic combat effects, category multipliers, and deterministic music ducking.' ); } finally { restoreGlobals(); diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index 952b28e..e127584 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -33,6 +33,7 @@ const checks = [ 'scripts/verify-battle-map-asset-data.mjs', 'scripts/verify-battle-environment-profiles.mjs', 'scripts/verify-audio-asset-data.mjs', + 'scripts/verify-audiovisual-feedback.mjs', 'scripts/verify-sound-director.mjs' ]; diff --git a/src/assets/audio/sfx/burn-tick.mp3 b/src/assets/audio/sfx/burn-tick.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..de54ee6e9bf4554bbf107ad718021893412c2b8a GIT binary patch literal 11564 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 RAQ}h&#Q!rR|NkSZ{{;_8Kyd&7 literal 0 HcmV?d00001 diff --git a/src/assets/audio/sfx/recovery-cue.mp3 b/src/assets/audio/sfx/recovery-cue.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..c29ee8bc4cbaaa01d4c9b35b62eab78af49f9d2c GIT binary patch literal 31532 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 literal 0 HcmV?d00001 diff --git a/src/assets/audio/sfx/reward-reveal.mp3 b/src/assets/audio/sfx/reward-reveal.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..7ac91a01aaff981cb9a25d498423f45cb9139eaf GIT binary patch literal 23468 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 S1esrmEw%a|h4%kq+5Z4}oqg8; literal 0 HcmV?d00001 diff --git a/src/game/audio/SoundDirector.ts b/src/game/audio/SoundDirector.ts index 8b376c0..435024a 100644 --- a/src/game/audio/SoundDirector.ts +++ b/src/game/audio/SoundDirector.ts @@ -57,7 +57,7 @@ export type MusicDuckOptions = { releaseMs?: number; }; -type SemanticCueGroup = 'turn' | 'operation' | 'objective' | 'narrative'; +type SemanticCueGroup = 'turn' | 'operation' | 'objective' | 'narrative' | 'recovery' | 'status' | 'reward'; type MusicDuckState = { multiplier: number; @@ -103,7 +103,10 @@ export class SoundDirector { turn: 900, operation: 650, objective: 1200, - narrative: 220 + narrative: 220, + recovery: 1900, + status: 280, + reward: 1100 }; private readonly semanticCuePlayedAt = new Map(); private lastPlayedSemanticCue?: { group: SemanticCueGroup; key: string; at: number }; @@ -442,6 +445,28 @@ export class SoundDirector { }); } + playRecoveryCue() { + return this.playSemanticCue('recovery', 'recovery-cue', { + volume: 0.26, + stopAfterMs: 1900 + }); + } + + playBurnTickCue() { + return this.playSemanticCue('status', 'burn-tick', { + volume: 0.24, + stopAfterMs: 680 + }); + } + + playRewardRevealCue() { + return this.playSemanticCue('reward', 'reward-reveal', { + volume: 0.26, + stopAfterMs: 1400, + duck: { multiplier: 0.76, attackMs: 36, holdMs: 420, releaseMs: 260 } + }); + } + playStrategyPulse() { this.playEffect('strategy-cast', { volume: 0.44, rate: 0.96, stopAfterMs: 960 }); this.playTone(460, 0.08, 0.012, 'sine'); diff --git a/src/game/audio/audioAssets.ts b/src/game/audio/audioAssets.ts index 9cc03b2..4f3ca77 100644 --- a/src/game/audio/audioAssets.ts +++ b/src/game/audio/audioAssets.ts @@ -14,6 +14,7 @@ import arrowSwishUrl from '../../assets/audio/sfx/arrow-swish.mp3'; import arrowWoodImpactUrl from '../../assets/audio/sfx/arrow-wood-impact.mp3'; import bondResonanceUrl from '../../assets/audio/sfx/bond-resonance.wav'; import bowReleaseUrl from '../../assets/audio/sfx/bow-release.mp3'; +import burnTickUrl from '../../assets/audio/sfx/burn-tick.mp3'; import cartRollUrl from '../../assets/audio/sfx/cart-roll.mp3'; import combatImpactUrl from '../../assets/audio/sfx/combat-impact.mp3'; import criticalBoomUrl from '../../assets/audio/sfx/critical-boom.mp3'; @@ -27,6 +28,8 @@ import levelUpUrl from '../../assets/audio/sfx/level-up.wav'; import objectiveFailureUrl from '../../assets/audio/sfx/objective-failure.mp3'; import objectiveSuccessUrl from '../../assets/audio/sfx/objective-success.mp3'; import operationAlertUrl from '../../assets/audio/sfx/operation-alert.mp3'; +import recoveryCueUrl from '../../assets/audio/sfx/recovery-cue.mp3'; +import rewardRevealUrl from '../../assets/audio/sfx/reward-reveal.mp3'; import shieldBlockUrl from '../../assets/audio/sfx/shield-block.mp3'; import strategyCastUrl from '../../assets/audio/sfx/strategy-cast.mp3'; import storyPageTurnUrl from '../../assets/audio/sfx/story-page-turn.mp3'; @@ -69,6 +72,9 @@ export const effectTracks = { 'operation-alert': operationAlertUrl, 'objective-success': objectiveSuccessUrl, 'objective-failure': objectiveFailureUrl, + 'recovery-cue': recoveryCueUrl, + 'burn-tick': burnTickUrl, + 'reward-reveal': rewardRevealUrl, 'sword-slash': swordSlashUrl, 'sword-swing-variant': swordSwingVariantUrl, 'combat-impact': combatImpactUrl, diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index a82c0ae..70f0205 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1401,11 +1401,21 @@ type TerrainPanelLayout = { messageBounds: HudBounds; }; +type UnitStatusBadgeView = { + container: Phaser.GameObjects.Container; + background: Phaser.GameObjects.Arc; + icon: Phaser.GameObjects.Image; + turnsText: Phaser.GameObjects.Text; + kind: BattleStatusKind; +}; + type UnitView = { sprite: Phaser.GameObjects.Sprite; hitZone: Phaser.GameObjects.Zone; label: Phaser.GameObjects.Text; roleSeal?: Phaser.GameObjects.Text; + statusBadges: UnitStatusBadgeView[]; + statusBadgeSide: -1 | 1; textureBase: string; direction: UnitDirection; baseX: number; @@ -1426,6 +1436,8 @@ type DamageCommand = Exclude; type SpecialDamageMotionKind = 'item' | 'arcane' | 'fire' | 'roar'; type RosterTab = 'ally' | 'enemy'; type ActiveFaction = 'ally' | 'enemy'; +type BattleStatusTickResult = { message?: string; burnTicked: boolean }; +type TerrainRecoveryResult = { message?: string; applied: boolean }; const battleSpeedOptions = ['normal', 'fast', 'very-fast'] as const; type BattleSpeed = (typeof battleSpeedOptions)[number]; type MapMenuAction = 'endTurn' | 'threat' | 'save' | 'load' | 'roster' | 'bond' | 'situation' | 'speed' | 'presentation' | 'bgm' | 'close'; @@ -5563,6 +5575,8 @@ export class BattleScene extends Phaser.Scene { sprite, hitZone, label, + statusBadges: [], + statusBadgeSide: unit.faction === 'ally' ? 1 : -1, textureBase, direction: 'south' as UnitDirection, baseX: sprite.x, @@ -6238,6 +6252,7 @@ export class BattleScene extends Phaser.Scene { const statusesCleared = this.battleStatuses.get(supportTarget.id)?.length ?? 0; supportTarget.hp += healAmount; this.battleStatuses.delete(supportTarget.id); + this.syncUnitStatusBadges(supportTarget); this.tacticalCommand.effectValue = healAmount; this.tacticalCommand.statusesCleared = statusesCleared; this.tacticalCommand.effectPending = false; @@ -6253,7 +6268,11 @@ export class BattleScene extends Phaser.Scene { const previousPhase: BattlePhase = this.phase === 'moving' ? 'moving' : 'idle'; this.phase = 'animating'; this.hideTacticalInitiativePanel(); - soundDirector.playStrategyPulse(); + if (role === 'support') { + soundDirector.playRecoveryCue(); + } else { + soundDirector.playStrategyPulse(); + } this.pushBattleLog(message); this.renderTacticalInitiativeChip(); this.renderSortieOrderHud(); @@ -7124,6 +7143,7 @@ export class BattleScene extends Phaser.Scene { } this.syncUnitRoleSeal(unit, view); + this.syncUnitStatusBadges(unit, view); this.setUnitBasePosition(view, this.tileCenterX(unit.x), this.tileCenterY(unit.y)); view.label.setPosition(view.baseX, view.baseY + this.layout.tileSize * 0.52); this.setUnitRoleSealPosition(view, view.baseX, view.baseY); @@ -7132,6 +7152,7 @@ export class BattleScene extends Phaser.Scene { this.setUnitHitZoneEnabled(view, visible); view.label.setVisible(visible); view.roleSeal?.setVisible(visible); + view.statusBadges.forEach((badge) => badge.container.setVisible(visible)); this.syncUnitMotion(unit, view); this.applyUnitLegibilityStyle(unit, view); } @@ -7174,6 +7195,56 @@ export class BattleScene extends Phaser.Scene { this.setUnitRoleSealPosition(view, view.baseX, view.baseY); } + private syncUnitStatusBadges(unit: UnitData, view = this.unitViews.get(unit.id)) { + if (!view) { + return; + } + + const statuses = unit.hp > 0 ? this.unitStatuses(unit).slice(0, 2) : []; + while (view.statusBadges.length > statuses.length) { + view.statusBadges.pop()?.container.destroy(); + } + + statuses.forEach((status, index) => { + let badge = view.statusBadges[index]; + const size = Math.max(this.battleUiLength(16), Math.round(this.layout.tileSize * 0.3)); + if (!badge?.container.active) { + const container = this.add.container(0, 0); + const background = this.add.circle(0, 0, size * 0.56, 0x071018, 0.96); + const icon = this.createBattleUiIcon(0, 0, this.statusEffectIcon(status.kind), size); + const turnsText = this.add.text(size * 0.42, size * 0.34, `${status.turns}`, { + fontFamily: 'Arial, sans-serif', + fontSize: this.battleUiFontSize(7), + color: '#fff8e8', + fontStyle: '700', + backgroundColor: 'rgba(57, 14, 10, 0.96)', + stroke: '#120704', + strokeThickness: this.battleUiLength(1), + padding: { x: this.battleUiLength(1), y: 0 } + }); + turnsText.setOrigin(0.5); + container.add([background, icon, turnsText]); + container.setDepth(11.22); + container.setName(`unit-status-${unit.id}-${index}`); + if (this.mapMask) { + container.setMask(this.mapMask); + } + badge = { container, background, icon, turnsText, kind: status.kind }; + view.statusBadges[index] = badge; + } + + const tone = this.statusEffectTone(status.kind); + badge.kind = status.kind; + badge.background.setStrokeStyle(this.battleUiLength(2), tone, 0.96); + badge.icon.setFrame(battleUiIconFrames[this.statusEffectIcon(status.kind)]); + badge.turnsText.setText(`${status.turns}`); + }); + + this.setUnitStatusBadgePositions(view, view.baseX, view.baseY); + const visible = unit.hp > 0 && this.isTileVisible(unit.x, unit.y); + view.statusBadges.forEach((badge) => badge.container.setVisible(visible)); + } + private refreshUnitLegibilityStyles() { battleUnits.forEach((unit) => this.applyUnitLegibilityStyle(unit)); } @@ -7201,6 +7272,8 @@ export class BattleScene extends Phaser.Scene { label.setBackgroundColor(this.unitLabelBackground(unit)); }); view.roleSeal?.setVisible(visible).setAlpha(1); + this.syncUnitStatusBadges(unit, view); + view.statusBadges.forEach((badge) => badge.container.setVisible(visible).setAlpha(1)); } private unitLabelBackground(unit: UnitData) { @@ -8944,7 +9017,6 @@ export class BattleScene extends Phaser.Scene { this.refreshEnemyIntentForecast(); this.showOpeningBattleEvent(); this.scheduleFirstBattleTutorial(); - soundDirector.playSelect(); } private deploymentSignature() { @@ -18777,12 +18849,18 @@ export class BattleScene extends Phaser.Scene { this.updateTurnText(); this.renderBattleSpeedControl(); this.updateObjectiveTracker(); - const statusMessage = this.tickBattleStatuses('enemy'); - if (this.battleOutcome || this.resolveBattleOutcomeIfNeeded()) { + const statusResult = this.tickBattleStatuses('enemy', false); + const statusMessage = statusResult.message; + const statusOutcomeDelayMs = statusResult.burnTicked ? 720 : 0; + if (this.battleOutcome || this.resolveBattleOutcomeIfNeeded(statusOutcomeDelayMs)) { + if (statusResult.burnTicked) { + soundDirector.playBurnTickCue(); + } return; } - const recoveryMessage = this.applyTerrainRecovery('enemy'); - soundDirector.playEnemyTurnCue(); + const recoveryResult = this.applyTerrainRecovery('enemy', false); + const recoveryMessage = recoveryResult.message; + this.playTurnStartFeedbackCue('enemy', statusResult.burnTicked, recoveryResult.applied); this.renderSituationPanel(['아군 턴을 종료했습니다.', statusMessage, recoveryMessage, '적군이 행동을 시작합니다.'].filter(Boolean).join('\n')); void this.runEnemyTurn(); } @@ -19854,10 +19932,14 @@ export class BattleScene extends Phaser.Scene { }); } - soundDirector.playEffect(result.usable.command === 'strategy' ? 'strategy-cast' : 'exp-gain', { - volume: result.usable.command === 'strategy' ? 0.3 : 0.24, - stopAfterMs: 620 - }); + if (result.usable.effect === 'heal') { + soundDirector.playRecoveryCue(); + } else { + soundDirector.playEffect(result.usable.command === 'strategy' ? 'strategy-cast' : 'exp-gain', { + volume: result.usable.command === 'strategy' ? 0.3 : 0.24, + stopAfterMs: 620 + }); + } const ring = this.add.circle(targetX, targetY - this.layout.tileSize * 0.18, this.layout.tileSize * 0.3); ring.setStrokeStyle(4, accent, 0.92); ring.setDepth(12.8); @@ -20629,10 +20711,14 @@ export class BattleScene extends Phaser.Scene { effect.setDepth(depth + 9); this.tweens.add({ targets: effect, scale: 1.45, alpha: 0.18, duration: 520, yoyo: true }); this.showSortieSupportCombatEffect(result, left + panelWidth / 2, top + 156, depth + 10); - soundDirector.playEffect(result.usable.command === 'strategy' ? 'strategy-cast' : 'exp-gain', { - volume: result.usable.command === 'strategy' ? 0.38 : 0.28, - stopAfterMs: 900 - }); + if (result.usable.effect === 'heal') { + soundDirector.playRecoveryCue(); + } else { + soundDirector.playEffect(result.usable.command === 'strategy' ? 'strategy-cast' : 'exp-gain', { + volume: result.usable.command === 'strategy' ? 0.38 : 0.28, + stopAfterMs: 900 + }); + } const targetStatus = this.renderCombatStatusPanel(result.target, left + panelWidth / 2 - 196, top + 202, 392); targetStatus.hpFill.setScale(result.previousTargetHp / result.target.maxHp, 1); @@ -22780,6 +22866,7 @@ export class BattleScene extends Phaser.Scene { if (view.roleSeal) { this.tweens.killTweensOf(view.roleSeal); } + view.statusBadges.forEach((badge) => this.tweens.killTweensOf(badge.container)); this.setUnitBasePosition(view, startX, startY); this.resetUnitSpritePose(view); view.label.setPosition(startX, startY + this.layout.tileSize * 0.52); @@ -22816,6 +22903,16 @@ export class BattleScene extends Phaser.Scene { ease: 'Sine.easeInOut' }); } + view.statusBadges.forEach((badge, index) => { + const position = this.unitStatusBadgePosition(view, targetX, targetY, index); + this.tweens.add({ + targets: badge.container, + x: position.x, + y: position.y, + duration: movementDuration, + ease: 'Sine.easeInOut' + }); + }); }); } @@ -22825,8 +22922,13 @@ export class BattleScene extends Phaser.Scene { } this.turnNumber += 1; - const statusMessage = this.tickBattleStatuses('ally'); - if (this.battleOutcome || this.resolveBattleOutcomeIfNeeded()) { + const statusResult = this.tickBattleStatuses('ally', false); + const statusMessage = statusResult.message; + const statusOutcomeDelayMs = statusResult.burnTicked ? 720 : 0; + if (this.battleOutcome || this.resolveBattleOutcomeIfNeeded(statusOutcomeDelayMs)) { + if (statusResult.burnTicked) { + soundDirector.playBurnTickCue(); + } return; } const expiredBuffMessage = this.tickBattleBuffs(); @@ -22834,16 +22936,16 @@ export class BattleScene extends Phaser.Scene { this.fastForwardHeld = false; this.actedUnitIds.clear(); this.attackIntents = this.emptyAllyTurnAttackIntents(); - const recoveryMessage = this.applyTerrainRecovery('ally'); + const recoveryResult = this.applyTerrainRecovery('ally', false); + const recoveryMessage = recoveryResult.message; this.resetActedStyles(); this.updateTurnText(); this.renderBattleSpeedControl(); this.updateObjectiveTracker(); const eventCountBeforeTurnCheck = this.triggeredBattleEvents.size; this.checkBattleEvents(); - if (this.triggeredBattleEvents.size === eventCountBeforeTurnCheck) { - soundDirector.playAllyTurnCue(); - } + const operationCuePlayed = this.triggeredBattleEvents.size !== eventCountBeforeTurnCheck; + this.playTurnStartFeedbackCue('ally', statusResult.burnTicked, recoveryResult.applied, operationCuePlayed); this.centerCameraOnAllyLine(); const turnMessage = [`${this.turnNumber}턴 아군 차례입니다.`, statusMessage, expiredBuffMessage, recoveryMessage, '행동할 장수를 선택하세요.'] .filter(Boolean) @@ -22871,7 +22973,7 @@ export class BattleScene extends Phaser.Scene { return []; } - private applyTerrainRecovery(faction: ActiveFaction) { + private applyTerrainRecovery(faction: ActiveFaction, playCue = true): TerrainRecoveryResult { const messages: string[] = []; battleUnits @@ -22902,13 +23004,40 @@ export class BattleScene extends Phaser.Scene { }); if (messages.length === 0) { - return undefined; + return { applied: false }; } - soundDirector.playEffect('exp-gain', { volume: 0.18, stopAfterMs: 520 }); + if (playCue) { + soundDirector.playRecoveryCue(); + } const message = `거점 효과\n${messages.slice(0, 3).join('\n')}${messages.length > 3 ? `\n외 ${messages.length - 3}명` : ''}`; this.pushBattleLog(message); - return message; + return { message, applied: true }; + } + + private playTurnStartFeedbackCue( + faction: ActiveFaction, + burnTicked: boolean, + recoveryApplied: boolean, + operationCuePlayed = false + ) { + if (operationCuePlayed) { + return 'operation'; + } + if (burnTicked) { + soundDirector.playBurnTickCue(); + return 'status'; + } + if (recoveryApplied) { + soundDirector.playRecoveryCue(); + return 'recovery'; + } + if (faction === 'ally') { + soundDirector.playAllyTurnCue(); + } else { + soundDirector.playEnemyTurnCue(); + } + return 'turn'; } private turnStartEquipmentRecovery(unit: UnitData) { @@ -23516,12 +23645,14 @@ export class BattleScene extends Phaser.Scene { } this.battleStatuses.set(unit.id, unitStatuses); + this.syncUnitStatusBadges(unit); return { ...nextStatus }; } - private tickBattleStatuses(faction: ActiveFaction) { + private tickBattleStatuses(faction: ActiveFaction, playCue = true): BattleStatusTickResult { const messages: string[] = []; const units = battleUnits.filter((unit) => unit.faction === faction && unit.hp > 0); + let burnTicked = false; units.forEach((unit) => { const statuses = this.unitStatuses(unit); @@ -23535,10 +23666,25 @@ export class BattleScene extends Phaser.Scene { const previousHp = unit.hp; const damage = Math.min(unit.hp, Math.max(1, status.power)); unit.hp = Math.max(0, unit.hp - damage); + burnTicked = true; messages.push(`${unit.name} ${status.label}: 지속 피해 ${damage} · HP ${previousHp}→${unit.hp}`); + const visibleOnMap = this.isTileVisible(unit.x, unit.y); + if (visibleOnMap) { + this.showMapResultPopup( + unit, + [`${status.label} -${damage}`, `HP ${previousHp}→${unit.hp}`], + '#ffb07c', + '#2b0b05', + 17, + 30 + ); + } if (unit.hp <= 0) { this.applyDefeatedStyle(unit); } else { + if (visibleOnMap) { + this.flashDamage(unit, previousHp); + } this.syncUnitMotion(unit); } } @@ -23565,16 +23711,20 @@ export class BattleScene extends Phaser.Scene { } else { this.battleStatuses.delete(unit.id); } + this.syncUnitStatusBadges(unit); }); if (messages.length === 0) { - return undefined; + return { burnTicked: false }; } + if (playCue && burnTicked) { + soundDirector.playBurnTickCue(); + } this.updateMiniMap(); const message = `상태효과 변화\n${messages.slice(0, 4).join('\n')}${messages.length > 4 ? `\n외 ${messages.length - 4}건` : ''}`; this.pushBattleLog(message); - return message; + return { message, burnTicked }; } private tickBattleBuffs() { @@ -23697,6 +23847,7 @@ export class BattleScene extends Phaser.Scene { if (view.roleSeal) { this.tweens.killTweensOf(view.roleSeal); } + view.statusBadges.forEach((badge) => this.tweens.killTweensOf(badge.container)); this.setUnitBasePosition(view, startX, startY); this.resetUnitSpritePose(view); view.label.setPosition(startX, startY + this.layout.tileSize * 0.52); @@ -23732,6 +23883,16 @@ export class BattleScene extends Phaser.Scene { ease: 'Sine.easeInOut' }); } + view.statusBadges.forEach((badge, index) => { + const position = this.unitStatusBadgePosition(view, targetX, targetY, index); + this.tweens.add({ + targets: badge.container, + x: position.x, + y: position.y, + duration: movementDuration, + ease: 'Sine.easeInOut' + }); + }); } private movementTileDistance(unit: UnitData, x: number, y: number) { @@ -23973,6 +24134,7 @@ export class BattleScene extends Phaser.Scene { view.sprite.setPosition(x, y); view.hitZone.setPosition(x, y); view.hitZone.setSize(this.layout.tileSize, this.layout.tileSize); + this.setUnitStatusBadgePositions(view, x, y); } private setUnitRoleSealPosition(view: UnitView, x: number, y: number) { @@ -23982,6 +24144,21 @@ export class BattleScene extends Phaser.Scene { ); } + private setUnitStatusBadgePositions(view: UnitView, x: number, y: number) { + view.statusBadges.forEach((badge, index) => { + const position = this.unitStatusBadgePosition(view, x, y, index); + badge.container.setPosition(position.x, position.y); + }); + } + + private unitStatusBadgePosition(view: UnitView, x: number, y: number, index: number) { + const spacing = this.layout.tileSize * 0.29; + return { + x: x + view.statusBadgeSide * (this.layout.tileSize * 0.34 + index * spacing), + y: y - this.layout.tileSize * 0.38 + }; + } + private setUnitHitZoneEnabled(view: UnitView, enabled: boolean) { view.hitZone.setVisible(enabled); if (view.hitZone.input) { @@ -24283,6 +24460,7 @@ export class BattleScene extends Phaser.Scene { if (!view) { return; } + this.syncUnitStatusBadges(unit, view); view.sprite.stop(); view.sprite.disableInteractive(); @@ -24297,6 +24475,7 @@ export class BattleScene extends Phaser.Scene { label.setBackgroundColor(''); }); view.roleSeal?.setAlpha(0).setVisible(false); + view.statusBadges.forEach((badge) => badge.container.setAlpha(0).setVisible(false)); } private faceUnitToward(unit: UnitData, target: UnitData) { @@ -24462,7 +24641,7 @@ export class BattleScene extends Phaser.Scene { private showMapResultPopup(unit: UnitData, lines: string[], color: string, stroke: string, fontSize: number, lift: number) { const view = this.unitViews.get(unit.id); - if (!view) { + if (!view || !this.isTileVisible(unit.x, unit.y)) { return 0; } @@ -28012,6 +28191,13 @@ export class BattleScene extends Phaser.Scene { roleSealPresent: Boolean(this.unitViews.get(unit.id)?.roleSeal), roleSealText: this.unitViews.get(unit.id)?.roleSeal?.text ?? null, roleSealVisible: this.unitViews.get(unit.id)?.roleSeal?.visible ?? false, + statusBadges: (this.unitViews.get(unit.id)?.statusBadges ?? []).map((badge) => ({ + kind: badge.kind, + visible: badge.container.visible, + x: badge.container.x, + y: badge.container.y, + turns: Number.parseInt(badge.turnsText.text, 10) || 0 + })), level: unit.level, exp: unit.exp, hp: unit.hp, diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 6090a78..f87f9b4 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -13352,6 +13352,7 @@ export class CampScene extends Phaser.Scene { }); this.victoryRewardActions.push({ id: definition.id, label: definition.label, button }); }); + soundDirector.playRewardRevealCue(); } private completeVictoryRewardAcknowledgement( @@ -22875,7 +22876,7 @@ export class CampScene extends Phaser.Scene { this.campaign = saveCampaignState(campaign); this.report = this.campaign.firstBattleReport ?? this.report; - soundDirector.playEffect('exp-gain', { volume: 0.24, stopAfterMs: 620 }); + soundDirector.playRecoveryCue(); const hpText = hpGain > 0 ? `병력 +${hpGain}` : ''; const bondText = bondGain > 0 ? `연결 공명 총 +${bondGain}` : '';