From 9428c7c1c4b329b15338b0785b1e76bd0245778d Mon Sep 17 00:00:00 2001 From: Wickedness Date: Mon, 13 Jul 2026 01:14:59 +0900 Subject: [PATCH] feat: add era-based camp skins --- package.json | 1 + scripts/verify-camp-skin-data.mjs | 225 +++++++++++++++++++ scripts/verify-release-candidate.mjs | 94 ++++++++ scripts/verify-static-data.mjs | 1 + src/game/data/campSkins.ts | 312 +++++++++++++++++++++++++++ src/game/scenes/CampScene.ts | 211 ++++++++++++++++-- 6 files changed, 830 insertions(+), 14 deletions(-) create mode 100644 scripts/verify-camp-skin-data.mjs create mode 100644 src/game/data/campSkins.ts diff --git a/package.json b/package.json index 78821a0..43cacdd 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "verify:battle-save": "node scripts/verify-battle-save-normalization.mjs", "verify:sortie-synergy": "node scripts/verify-sortie-synergy.mjs", "verify:camp-rewards": "node scripts/verify-camp-reward-data.mjs", + "verify:camp-skins": "node scripts/verify-camp-skin-data.mjs", "verify:campaign-recruits": "node scripts/verify-campaign-recruit-data.mjs", "verify:equipment-catalog": "node scripts/verify-equipment-catalog-data.mjs", "verify:inventory-rewards": "node scripts/verify-inventory-reward-data.mjs", diff --git a/scripts/verify-camp-skin-data.mjs b/scripts/verify-camp-skin-data.mjs new file mode 100644 index 0000000..74d21ef --- /dev/null +++ b/scripts/verify-camp-skin-data.mjs @@ -0,0 +1,225 @@ +import { createServer } from 'vite'; + +const expectedArcs = [ + { skinId: 'yellow-turban', firstBattleOrdinal: 1, lastBattleOrdinal: 4 }, + { skinId: 'anti-dong', firstBattleOrdinal: 5, lastBattleOrdinal: 6 }, + { skinId: 'xuzhou', firstBattleOrdinal: 7, lastBattleOrdinal: 9 }, + { skinId: 'wandering', firstBattleOrdinal: 10, lastBattleOrdinal: 15 }, + { skinId: 'wolong', firstBattleOrdinal: 16, lastBattleOrdinal: 18 }, + { skinId: 'red-cliffs', firstBattleOrdinal: 19, lastBattleOrdinal: 22 }, + { skinId: 'jing-yi', firstBattleOrdinal: 23, lastBattleOrdinal: 34 }, + { skinId: 'hanzhong-shuhan', firstBattleOrdinal: 35, lastBattleOrdinal: 37 }, + { skinId: 'jingzhou-crisis', firstBattleOrdinal: 38, lastBattleOrdinal: 44 }, + { skinId: 'yiling-baidi', firstBattleOrdinal: 45, lastBattleOrdinal: 46 }, + { skinId: 'nanzhong', firstBattleOrdinal: 47, lastBattleOrdinal: 54 }, + { skinId: 'northern', firstBattleOrdinal: 55, lastBattleOrdinal: 66 } +]; + +const expectedTextureKeys = { + 'yellow-turban': 'story-militia-training-yard', + 'anti-dong': 'story-anti-dong-coalition-oath-camp', + xuzhou: 'story-xuzhou-gate-council', + wandering: 'story-wandering-night-camp', + wolong: 'story-wolong-autumn-study', + 'red-cliffs': 'story-red-cliffs-wind-altar', + 'jing-yi': 'story-yizhou-council-at-river', + 'hanzhong-shuhan': 'story-yizhou-mountain-pass-command-post', + 'jingzhou-crisis': 'story-jingzhou-crisis-river-watchtowers', + 'yiling-baidi': 'story-yiling-baidi-lantern-vigil', + nanzhong: 'story-nanzhong-captures-river-night-camp', + northern: 'story-northern-supply-depot' +}; + +const expectedSpecialSteps = { + 'hanzhong-king-camp': 'hanzhong-shuhan', + 'shu-han-foundation-camp': 'hanzhong-shuhan', + 'baidi-entrustment-camp': 'yiling-baidi', + 'northern-campaign-prep-camp': 'northern', + 'ending-complete': 'northern' +}; + +const errors = []; +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true }, + appType: 'custom' +}); + +try { + const { campSkinDefinitions, campSkinBattleArcs, campSkinBattleOrdinal, selectCampSkin } = + await server.ssrLoadModule('/src/game/data/campSkins.ts'); + const { storyBackgroundAssets } = await server.ssrLoadModule('/src/game/data/storyAssets.ts'); + const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); + const expectedSkinIds = expectedArcs.map(({ skinId }) => skinId); + const definitionEntries = Object.entries(campSkinDefinitions); + const battleIds = Object.keys(battleScenarios); + + assertEqual(definitionEntries.length, expectedSkinIds.length, 'camp skin definition count'); + assertEqual(campSkinBattleArcs.length, expectedArcs.length, 'camp skin arc count'); + assertEqual(battleIds.length, 66, 'battle count used by camp skin ordinals'); + + expectedSkinIds.forEach((skinId) => { + const definition = campSkinDefinitions[skinId]; + assert(Boolean(definition), `missing camp skin definition "${skinId}"`); + if (!definition) { + return; + } + + assertEqual(definition.id, skinId, `${skinId}: id`); + assertEqual(definition.textureKey, expectedTextureKeys[skinId], `${skinId}: texture key`); + ['textureKey', 'assetUrl', 'chapterLabel', 'locationLabel', 'seal'].forEach((fieldName) => { + assertNonEmptyString(definition[fieldName], `${skinId}: ${fieldName}`); + }); + ['accentColor', 'headerColor', 'washColor'].forEach((fieldName) => { + assertColor(definition[fieldName], `${skinId}: ${fieldName}`); + }); + assertAlpha(definition.backdropAlpha, `${skinId}: backdropAlpha`); + assertAlpha(definition.washAlpha, `${skinId}: washAlpha`); + assert( + Object.hasOwn(storyBackgroundAssets, definition.textureKey), + `${skinId}: missing story background "${definition.textureKey}"` + ); + assertEqual( + definition.assetUrl, + storyBackgroundAssets[definition.textureKey], + `${skinId}: asset URL must match its story background` + ); + }); + + definitionEntries.forEach(([skinId]) => { + assert(expectedSkinIds.includes(skinId), `unexpected camp skin definition "${skinId}"`); + }); + assertUnique( + definitionEntries.map(([, definition]) => definition.textureKey), + 'camp skin texture keys' + ); + assertUnique( + definitionEntries.map( + ([, definition]) => `${definition.accentColor}:${definition.headerColor}:${definition.washColor}` + ), + 'camp skin visual color signatures' + ); + + let nextExpectedOrdinal = 1; + expectedArcs.forEach((expectedArc, index) => { + const actualArc = campSkinBattleArcs[index]; + assert(Boolean(actualArc), `missing camp skin arc at index ${index}`); + if (!actualArc) { + return; + } + assertEqual(actualArc.skinId, expectedArc.skinId, `arc ${index}: skin id`); + assertEqual(actualArc.firstBattleOrdinal, expectedArc.firstBattleOrdinal, `arc ${index}: first battle ordinal`); + assertEqual(actualArc.lastBattleOrdinal, expectedArc.lastBattleOrdinal, `arc ${index}: last battle ordinal`); + assertEqual(actualArc.firstBattleOrdinal, nextExpectedOrdinal, `arc ${index}: contiguous first battle ordinal`); + assert( + actualArc.lastBattleOrdinal >= actualArc.firstBattleOrdinal, + `arc ${index}: last battle ordinal must not precede its first` + ); + nextExpectedOrdinal = actualArc.lastBattleOrdinal + 1; + }); + assertEqual(nextExpectedOrdinal, battleIds.length + 1, 'camp skin arcs must cover every battle ordinal'); + + battleIds.forEach((battleId, index) => { + const battleOrdinal = index + 1; + const expectedSkinId = expectedSkinForOrdinal(battleOrdinal); + const nextBattleSelection = selectCampSkin({ nextBattleId: battleId }); + const currentBattleSelection = selectCampSkin({ currentBattleId: battleId }); + + assertEqual(campSkinBattleOrdinal(battleId), battleOrdinal, `${battleId}: battle ordinal lookup`); + assertSelection(nextBattleSelection, { + skinId: expectedSkinId, + source: 'next-battle', + requestedBattleId: battleId, + battleOrdinal + }); + assertSelection(currentBattleSelection, { + skinId: expectedSkinId, + source: 'current-battle', + requestedBattleId: battleId, + battleOrdinal + }); + }); + + Object.entries(expectedSpecialSteps).forEach(([campaignStep, skinId]) => { + const selection = selectCampSkin({ campaignStep, nextBattleId: battleIds[0], currentBattleId: battleIds.at(-1) }); + assertSelection(selection, { + skinId, + source: 'special-step', + requestedBattleId: battleIds[0], + battleOrdinal: 1 + }); + }); + + const currentBattleRecovery = selectCampSkin({ nextBattleId: 'unknown-battle', currentBattleId: battleIds.at(-1) }); + assertSelection(currentBattleRecovery, { + skinId: 'northern', + source: 'current-battle', + requestedBattleId: battleIds.at(-1), + battleOrdinal: battleIds.length + }); + assertSelection(selectCampSkin({ nextBattleId: 'unknown-battle' }), { + skinId: 'yellow-turban', + source: 'fallback' + }); + assertEqual(campSkinBattleOrdinal('unknown-battle'), undefined, 'unknown battle ordinal lookup'); + + if (errors.length > 0) { + throw new Error(`Camp skin data verification failed:\n${errors.map((error) => `- ${error}`).join('\n')}`); + } + + console.log( + `Verified ${definitionEntries.length} camp skins, ${campSkinBattleArcs.length} contiguous era arcs, ` + + `${battleIds.length} next/current battle selections, and ${Object.keys(expectedSpecialSteps).length} special-step overrides.` + ); +} finally { + await server.close(); +} + +function expectedSkinForOrdinal(battleOrdinal) { + return expectedArcs.find( + ({ firstBattleOrdinal, lastBattleOrdinal }) => + battleOrdinal >= firstBattleOrdinal && battleOrdinal <= lastBattleOrdinal + )?.skinId; +} + +function assertSelection(selection, expected) { + const context = `${expected.source}/${expected.requestedBattleId ?? expected.skinId}`; + assert(Boolean(selection), `${context}: selection is missing`); + if (!selection) { + return; + } + assertEqual(selection.skin?.id, expected.skinId, `${context}: selected skin`); + assertEqual(selection.source, expected.source, `${context}: selection source`); + assertEqual(selection.requestedBattleId, expected.requestedBattleId, `${context}: requested battle id`); + assertEqual(selection.battleOrdinal, expected.battleOrdinal, `${context}: battle ordinal`); +} + +function assertNonEmptyString(value, context) { + assert(typeof value === 'string' && value.trim().length > 0, `${context} must be a non-empty string`); +} + +function assertColor(value, context) { + assert(Number.isInteger(value) && value >= 0 && value <= 0xffffff, `${context} must be a 24-bit integer color`); +} + +function assertAlpha(value, context) { + assert(Number.isFinite(value) && value > 0 && value <= 1, `${context} must be greater than 0 and at most 1`); +} + +function assertUnique(values, context) { + assertEqual(new Set(values).size, values.length, `${context} must be unique`); +} + +function assertEqual(actual, expected, context) { + assert(Object.is(actual, expected), `${context}: expected ${format(expected)}, received ${format(actual)}`); +} + +function assert(condition, message) { + if (!condition) { + errors.push(message); + } +} + +function format(value) { + return value === undefined ? 'undefined' : JSON.stringify(value); +} diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 2529314..8f487cd 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -2019,6 +2019,7 @@ try { await clickLegacyUi(page, 738, 642); await waitForCampAfterBattleResult(page); + await waitForCampSkinTransition(page, 'yellow-turban'); await page.screenshot({ path: `${screenshotDir}/rc-first-camp.png`, fullPage: true }); await assertCanvasPainted(page, 'first camp'); @@ -2035,6 +2036,7 @@ try { } }); assert(firstCampProbe.state?.campaign?.step === 'first-camp', `Expected victory to return to first camp: ${JSON.stringify(firstCampProbe.state)}`); + assertCampSkinState(firstCampProbe.state, 'yellow-turban', 'first camp'); assert( firstCampProbe.state?.campRoster?.pageCount === 1 && firstCampProbe.state.campRoster.totalCount === 3 && @@ -5209,8 +5211,10 @@ try { await waitForTitle(page); await clickLegacyUi(page, 962, 310); await waitForCamp(page); + await waitForCampSkinTransition(page, 'northern'); const finalCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); assert(finalCampState?.campaign?.step === 'sixty-sixth-camp', `Expected final camp save to continue: ${JSON.stringify(finalCampState)}`); + assertCampSkinState(finalCampState, 'northern', 'final camp'); assert(finalCampState.sortieHasBattle === false, `Expected final camp to expose no playable sortie target: ${JSON.stringify(finalCampState)}`); assert(finalCampState.stagedSortiePrep === false, `Expected the non-battle final camp to avoid the three-stage sortie flow: ${JSON.stringify(finalCampState)}`); assert(finalCampState.nextSortieBattleId === null, `Expected final camp to route to ending instead of a battle: ${JSON.stringify(finalCampState)}`); @@ -7056,6 +7060,26 @@ async function waitForCamp(page) { }, undefined, { timeout: 90000 }); } +async function waitForCampSkinTransition(page, expectedSkinId) { + await page.waitForFunction((skinId) => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + const campSkin = window.__HEROS_DEBUG__?.camp?.()?.campSkin; + const transition = campSkin?.transition; + return ( + activeScenes.includes('CampScene') && + campSkin?.id === skinId && + campSkin.textureReady === true && + Boolean(campSkin.renderedTextureKey) && + transition?.active === false && + transition.revision > 0 && + transition.completedRevision === transition.revision + ); + }, expectedSkinId, { timeout: 30000 }); + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + })); +} + async function waitForCampVisualFrame(page, expectedPage) { await page.waitForFunction( (pageNumber) => window.__HEROS_DEBUG__?.camp()?.campRoster?.page === pageNumber, @@ -8410,6 +8434,68 @@ function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } +function assertCampSkinState(state, expectedSkinId, label) { + const campSkin = state?.campSkin; + const sceneBounds = campSkin?.sceneBounds; + const transition = campSkin?.transition; + const fullSceneLayers = [campSkin?.backdrop, campSkin?.wash, campSkin?.vignette]; + const ornamentLayers = [campSkin?.header, campSkin?.accentLine, campSkin?.seal, campSkin?.badge]; + + assert( + campSkin?.id === expectedSkinId && + campSkin.textureReady === true && + typeof campSkin.textureKey === 'string' && + campSkin.textureKey.length > 0 && + campSkin.renderedTextureKey === campSkin.textureKey, + `Expected ${label} to render the ${expectedSkinId} camp skin texture: ${JSON.stringify(campSkin)}` + ); + assert( + isFiniteBounds(sceneBounds) && + sceneBounds.x === 0 && + sceneBounds.y === 0 && + sceneBounds.width === baselineViewport.width && + sceneBounds.height === baselineViewport.height, + `Expected ${label} camp skin to use the 1920x1080 scene bounds: ${JSON.stringify(campSkin)}` + ); + assert( + fullSceneLayers.every((view) => + isVisibleCampSkinView(view) && boundsCover(view.bounds, sceneBounds) + ), + `Expected ${label} camp backdrop, color wash, and vignette to cover the FHD scene: ${JSON.stringify(campSkin)}` + ); + assert( + ornamentLayers.every((view) => + isVisibleCampSkinView(view) && boundsInside(view.bounds, sceneBounds) + ), + `Expected ${label} camp header ornaments to remain inside the FHD scene: ${JSON.stringify(campSkin)}` + ); + assert( + transition?.active === false && + transition.revision > 0 && + transition.completedRevision === transition.revision && + transition.last?.kind === 'backdrop-fade' && + transition.last.skinId === expectedSkinId && + transition.last.durationMs >= 250 && + transition.last.durationMs <= 600 && + transition.last.fromAlpha === 0 && + transition.last.toAlpha > 0 && + transition.last.toAlpha <= 1 && + Math.abs(transition.last.toAlpha - campSkin.backdrop.alpha) <= 0.01, + `Expected ${label} camp backdrop transition to finish cleanly: ${JSON.stringify(campSkin)}` + ); +} + +function isVisibleCampSkinView(view) { + return Boolean( + view?.visible === true && + Number.isFinite(view.alpha) && + view.alpha > 0 && + view.alpha <= 1 && + Number.isFinite(view.depth) && + isFiniteBounds(view.bounds) + ); +} + function isFiniteBounds(bounds) { return Boolean( bounds && @@ -8430,6 +8516,14 @@ function boundsInside(inner, outer, tolerance = 1) { inner.y + inner.height <= outer.y + outer.height + tolerance; } +function boundsCover(outer, inner, tolerance = 1) { + return isFiniteBounds(outer) && isFiniteBounds(inner) && + outer.x <= inner.x + tolerance && + outer.y <= inner.y + tolerance && + outer.x + outer.width >= inner.x + inner.width - tolerance && + outer.y + outer.height >= inner.y + inner.height - tolerance; +} + function assert(condition, message) { if (!condition) { throw new Error(message); diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index 0e56fb2..b06c1fd 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -7,6 +7,7 @@ const checks = [ 'scripts/verify-sortie-synergy.mjs', 'scripts/verify-battle-scenario-data.mjs', 'scripts/verify-camp-reward-data.mjs', + 'scripts/verify-camp-skin-data.mjs', 'scripts/verify-campaign-recruit-data.mjs', 'scripts/verify-equipment-catalog-data.mjs', 'scripts/verify-inventory-reward-data.mjs', diff --git a/src/game/data/campSkins.ts b/src/game/data/campSkins.ts new file mode 100644 index 0000000..3010c5d --- /dev/null +++ b/src/game/data/campSkins.ts @@ -0,0 +1,312 @@ +import antiDongCoalitionOathCampUrl from '../../assets/images/story/113-anti-dong-coalition-oath-camp.png'; +import militiaTrainingYardUrl from '../../assets/images/story/126-militia-training-yard.png'; +import wanderingNightCampUrl from '../../assets/images/story/41-wandering-night-camp.png'; +import yizhouCouncilAtRiverUrl from '../../assets/images/story/45-yizhou-council-at-river.png'; +import nanzhongRiverNightCampUrl from '../../assets/images/story/68-nanzhong-captures-river-night-camp.png'; +import jingzhouRiverWatchtowersUrl from '../../assets/images/story/75-jingzhou-crisis-river-watchtowers.png'; +import xuzhouGateCouncilUrl from '../../assets/images/story/77-xuzhou-gate-council.png'; +import northernSupplyDepotUrl from '../../assets/images/story/92-northern-supply-depot.png'; +import yizhouMountainCommandPostUrl from '../../assets/images/story/93-yizhou-mountain-pass-command-post.png'; +import wolongAutumnStudyUrl from '../../assets/images/story/96-wolong-autumn-study.png'; +import yilingBaidiLanternVigilUrl from '../../assets/images/story/102-yiling-baidi-lantern-vigil.png'; +import redCliffsWindAltarUrl from '../../assets/images/story/114-red-cliffs-wind-altar.png'; +import { battleScenarios, type BattleScenarioId } from './battles'; +import type { CampaignStep } from '../state/campaignState'; + +export type CampSkinId = + | 'yellow-turban' + | 'anti-dong' + | 'xuzhou' + | 'wandering' + | 'wolong' + | 'red-cliffs' + | 'jing-yi' + | 'hanzhong-shuhan' + | 'jingzhou-crisis' + | 'yiling-baidi' + | 'nanzhong' + | 'northern'; + +export type CampSkinDefinition = { + id: CampSkinId; + textureKey: string; + assetUrl: string; + chapterLabel: string; + locationLabel: string; + seal: string; + accentColor: number; + headerColor: number; + washColor: number; + backdropAlpha: number; + washAlpha: number; +}; + +export type CampSkinBattleArc = { + skinId: CampSkinId; + firstBattleOrdinal: number; + lastBattleOrdinal: number; +}; + +export type CampSkinSelectionSource = 'special-step' | 'next-battle' | 'current-battle' | 'fallback'; + +export type CampSkinSelection = { + skin: CampSkinDefinition; + source: CampSkinSelectionSource; + requestedBattleId?: BattleScenarioId; + battleOrdinal?: number; +}; + +export type CampSkinSelectionInput = { + campaignStep?: CampaignStep; + nextBattleId?: string; + currentBattleId?: string; +}; + +export const campSkinDefinitions: Record = { + 'yellow-turban': { + id: 'yellow-turban', + textureKey: 'story-militia-training-yard', + assetUrl: militiaTrainingYardUrl, + chapterLabel: '황건적 토벌', + locationLabel: '탁군 의병영', + seal: '義', + accentColor: 0xd6b35b, + headerColor: 0x392b1d, + washColor: 0x17120d, + backdropAlpha: 0.82, + washAlpha: 0.42 + }, + 'anti-dong': { + id: 'anti-dong', + textureKey: 'story-anti-dong-coalition-oath-camp', + assetUrl: antiDongCoalitionOathCampUrl, + chapterLabel: '반동탁 연합', + locationLabel: '연합군 맹진', + seal: '盟', + accentColor: 0xc46b55, + headerColor: 0x3a211d, + washColor: 0x1b100e, + backdropAlpha: 0.81, + washAlpha: 0.43 + }, + xuzhou: { + id: 'xuzhou', + textureKey: 'story-xuzhou-gate-council', + assetUrl: xuzhouGateCouncilUrl, + chapterLabel: '서주 공방', + locationLabel: '서주 성문', + seal: '徐', + accentColor: 0x7fa0b8, + headerColor: 0x25323a, + washColor: 0x10171b, + backdropAlpha: 0.8, + washAlpha: 0.44 + }, + wandering: { + id: 'wandering', + textureKey: 'story-wandering-night-camp', + assetUrl: wanderingNightCampUrl, + chapterLabel: '천하 유랑', + locationLabel: '객군 야영지', + seal: '旅', + accentColor: 0x6f8fae, + headerColor: 0x202b39, + washColor: 0x0d131b, + backdropAlpha: 0.84, + washAlpha: 0.46 + }, + wolong: { + id: 'wolong', + textureKey: 'story-wolong-autumn-study', + assetUrl: wolongAutumnStudyUrl, + chapterLabel: '와룡의 계책', + locationLabel: '신야 군막', + seal: '龍', + accentColor: 0xb8894f, + headerColor: 0x33291f, + washColor: 0x17130f, + backdropAlpha: 0.8, + washAlpha: 0.4 + }, + 'red-cliffs': { + id: 'red-cliffs', + textureKey: 'story-red-cliffs-wind-altar', + assetUrl: redCliffsWindAltarUrl, + chapterLabel: '적벽대전', + locationLabel: '장강 수군진', + seal: '赤', + accentColor: 0xd8732c, + headerColor: 0x3d2115, + washColor: 0x1c0e08, + backdropAlpha: 0.83, + washAlpha: 0.42 + }, + 'jing-yi': { + id: 'jing-yi', + textureKey: 'story-yizhou-council-at-river', + assetUrl: yizhouCouncilAtRiverUrl, + chapterLabel: '형주·익주', + locationLabel: '서천 진군로', + seal: '益', + accentColor: 0x77a27a, + headerColor: 0x243328, + washColor: 0x101711, + backdropAlpha: 0.8, + washAlpha: 0.41 + }, + 'hanzhong-shuhan': { + id: 'hanzhong-shuhan', + textureKey: 'story-yizhou-mountain-pass-command-post', + assetUrl: yizhouMountainCommandPostUrl, + chapterLabel: '한중·촉한', + locationLabel: '한중 산성', + seal: '漢', + accentColor: 0xd8b15f, + headerColor: 0x3b2d1c, + washColor: 0x18120b, + backdropAlpha: 0.79, + washAlpha: 0.39 + }, + 'jingzhou-crisis': { + id: 'jingzhou-crisis', + textureKey: 'story-jingzhou-crisis-river-watchtowers', + assetUrl: jingzhouRiverWatchtowersUrl, + chapterLabel: '형주 위기', + locationLabel: '한수 방어진', + seal: '荊', + accentColor: 0xc78747, + headerColor: 0x35261d, + washColor: 0x18110d, + backdropAlpha: 0.82, + washAlpha: 0.45 + }, + 'yiling-baidi': { + id: 'yiling-baidi', + textureKey: 'story-yiling-baidi-lantern-vigil', + assetUrl: yilingBaidiLanternVigilUrl, + chapterLabel: '이릉·백제성', + locationLabel: '백제성 야영', + seal: '白', + accentColor: 0x8aa7c2, + headerColor: 0x26313d, + washColor: 0x10161d, + backdropAlpha: 0.86, + washAlpha: 0.48 + }, + nanzhong: { + id: 'nanzhong', + textureKey: 'story-nanzhong-captures-river-night-camp', + assetUrl: nanzhongRiverNightCampUrl, + chapterLabel: '남중 평정', + locationLabel: '노수 원정군', + seal: '南', + accentColor: 0x7caa68, + headerColor: 0x263726, + washColor: 0x111a10, + backdropAlpha: 0.84, + washAlpha: 0.46 + }, + northern: { + id: 'northern', + textureKey: 'story-northern-supply-depot', + assetUrl: northernSupplyDepotUrl, + chapterLabel: '북벌', + locationLabel: '한중 보급진', + seal: '北', + accentColor: 0x8ea9d9, + headerColor: 0x263247, + washColor: 0x101621, + backdropAlpha: 0.82, + washAlpha: 0.44 + } +}; + +export const campSkinBattleArcs: readonly CampSkinBattleArc[] = [ + { skinId: 'yellow-turban', firstBattleOrdinal: 1, lastBattleOrdinal: 4 }, + { skinId: 'anti-dong', firstBattleOrdinal: 5, lastBattleOrdinal: 6 }, + { skinId: 'xuzhou', firstBattleOrdinal: 7, lastBattleOrdinal: 9 }, + { skinId: 'wandering', firstBattleOrdinal: 10, lastBattleOrdinal: 15 }, + { skinId: 'wolong', firstBattleOrdinal: 16, lastBattleOrdinal: 18 }, + { skinId: 'red-cliffs', firstBattleOrdinal: 19, lastBattleOrdinal: 22 }, + { skinId: 'jing-yi', firstBattleOrdinal: 23, lastBattleOrdinal: 34 }, + { skinId: 'hanzhong-shuhan', firstBattleOrdinal: 35, lastBattleOrdinal: 37 }, + { skinId: 'jingzhou-crisis', firstBattleOrdinal: 38, lastBattleOrdinal: 44 }, + { skinId: 'yiling-baidi', firstBattleOrdinal: 45, lastBattleOrdinal: 46 }, + { skinId: 'nanzhong', firstBattleOrdinal: 47, lastBattleOrdinal: 54 }, + { skinId: 'northern', firstBattleOrdinal: 55, lastBattleOrdinal: 66 } +]; + +const specialStepSkinIds: Partial> = { + 'hanzhong-king-camp': 'hanzhong-shuhan', + 'shu-han-foundation-camp': 'hanzhong-shuhan', + 'baidi-entrustment-camp': 'yiling-baidi', + 'northern-campaign-prep-camp': 'northern', + 'ending-complete': 'northern' +}; + +const orderedBattleIds = Object.keys(battleScenarios) as BattleScenarioId[]; +const battleOrdinalById = new Map( + orderedBattleIds.map((battleId, index) => [battleId, index + 1]) +); + +export function campSkinBattleOrdinal(battleId: string | undefined): number | undefined { + if (!battleId || !(battleId in battleScenarios)) { + return undefined; + } + return battleOrdinalById.get(battleId as BattleScenarioId); +} + +export function selectCampSkin({ + campaignStep, + nextBattleId, + currentBattleId +}: CampSkinSelectionInput): CampSkinSelection { + const knownNextBattleId = knownBattleId(nextBattleId); + const knownCurrentBattleId = knownBattleId(currentBattleId); + const requestedBattleId = knownNextBattleId ?? knownCurrentBattleId; + const battleOrdinal = requestedBattleId ? battleOrdinalById.get(requestedBattleId) : undefined; + const specialSkinId = campaignStep ? specialStepSkinIds[campaignStep] : undefined; + + if (specialSkinId) { + return { + skin: campSkinDefinitions[specialSkinId], + source: 'special-step', + requestedBattleId, + battleOrdinal + }; + } + + if (knownNextBattleId) { + return selectionForBattle(knownNextBattleId, 'next-battle'); + } + if (knownCurrentBattleId) { + return selectionForBattle(knownCurrentBattleId, 'current-battle'); + } + + return { + skin: campSkinDefinitions['yellow-turban'], + source: 'fallback' + }; +} + +function selectionForBattle( + battleId: BattleScenarioId, + source: Extract +): CampSkinSelection { + const battleOrdinal = battleOrdinalById.get(battleId); + const arc = campSkinBattleArcs.find( + ({ firstBattleOrdinal, lastBattleOrdinal }) => + battleOrdinal !== undefined && battleOrdinal >= firstBattleOrdinal && battleOrdinal <= lastBattleOrdinal + ); + + return { + skin: campSkinDefinitions[arc?.skinId ?? 'yellow-turban'], + source, + requestedBattleId: battleId, + battleOrdinal + }; +} + +function knownBattleId(battleId: string | undefined): BattleScenarioId | undefined { + return battleId && battleId in battleScenarios ? (battleId as BattleScenarioId) : undefined; +} diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 2bf4ff7..7bdccd5 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -1,8 +1,8 @@ import Phaser from 'phaser'; -import storyMilitiaUrl from '../../assets/images/story/04-volunteer-militia.png'; import storyFirstSortieUrl from '../../assets/images/story/05-first-sortie.png'; import { soundDirector } from '../audio/SoundDirector'; import { battleUiIconFrames, battleUiIconTextureKey, loadBattleUiIcons, type BattleUiIconKey } from '../data/battleUiIcons'; +import { selectCampSkin, type CampSkinSelection } from '../data/campSkins'; import { equipmentExpToNext, equipmentSlotLabels, @@ -11061,6 +11061,26 @@ export class CampScene extends Phaser.Scene { private readonly scaledCampUiObjects = new WeakSet(); private campaign?: CampaignState; private report?: FirstBattleReport; + private campSkinSelection?: CampSkinSelection; + private campSkinBackdrop?: Phaser.GameObjects.Image; + private campSkinWash?: Phaser.GameObjects.Rectangle; + private campSkinVignette?: Phaser.GameObjects.Rectangle; + private campSkinHeader?: Phaser.GameObjects.Rectangle; + private campSkinAccentLine?: Phaser.GameObjects.Rectangle; + private campSkinSeal?: Phaser.GameObjects.Arc; + private campSkinBadge?: Phaser.GameObjects.Rectangle; + private campSkinRenderedTextureKey?: string; + private campSkinUsedFallback = false; + private campSkinTransitionRevision = 0; + private campSkinTransitionCompletedRevision = 0; + private campSkinTransitionActive = false; + private campSkinTransitionLast?: { + kind: 'backdrop-fade'; + durationMs: number; + skinId: CampSkinSelection['skin']['id']; + fromAlpha: number; + toAlpha: number; + }; private selectedUnitId = 'liu-bei'; private campRosterPage = 0; private campRosterLayout?: CampRosterLayout; @@ -11179,6 +11199,22 @@ export class CampScene extends Phaser.Scene { this.campaign = getCampaignState(); this.report = this.campaign.firstBattleReport ?? getFirstBattleReport() ?? this.createFallbackReport(); this.ensureCurrentCampRecruitment(); + const sortieFlow = this.currentSortieFlow(); + this.campSkinSelection = selectCampSkin({ + campaignStep: this.campaign.step, + nextBattleId: sortieFlow.nextBattleId, + currentBattleId: this.currentCampBattleId() + }); + this.campSkinBackdrop = undefined; + this.campSkinWash = undefined; + this.campSkinVignette = undefined; + this.campSkinHeader = undefined; + this.campSkinAccentLine = undefined; + this.campSkinSeal = undefined; + this.campSkinBadge = undefined; + this.campSkinRenderedTextureKey = undefined; + this.campSkinUsedFallback = false; + this.campSkinTransitionActive = false; this.selectedUnitId = this.currentUnits().find((unit) => unit.faction === 'ally')?.id ?? 'liu-bei'; this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.campaign.selectedSortieUnitIds); this.sortieFormationAssignments = this.normalizedSortieFormationAssignments(this.campaign.sortieFormationAssignments); @@ -11194,24 +11230,114 @@ export class CampScene extends Phaser.Scene { private drawCampScene() { const { width, height } = this.scale; - this.add.image(width / 2, height / 2, 'story-militia').setDisplaySize(width * 1.08, height * 1.08).setAlpha(0.62); - this.add.rectangle(0, 0, width, height, 0x06090d, 0.54).setOrigin(0); - this.scaleLegacyCampUi(this.add.rectangle(0, 0, campLegacyCanvasWidth, 84, 0x06090d, 0.52)).setOrigin(0); + const selection = this.campSkinSelection ?? selectCampSkin({ + campaignStep: this.campaign?.step, + nextBattleId: this.currentSortieFlow().nextBattleId, + currentBattleId: this.currentCampBattleId() + }); + this.campSkinSelection = selection; + const { skin } = selection; + const renderedTextureKey = this.textures.exists(skin.textureKey) + ? skin.textureKey + : this.textures.exists('story-militia') + ? 'story-militia' + : '__DEFAULT'; + this.campSkinRenderedTextureKey = renderedTextureKey; + this.campSkinUsedFallback = renderedTextureKey !== skin.textureKey; - this.scaleLegacyCampUi(this.add.text(42, 28, this.currentCampTitle(), { + const backdrop = this.add.image(width / 2, height / 2, renderedTextureKey).setDepth(-30).setAlpha(0); + const source = backdrop.texture.getSourceImage() as { width?: number; height?: number }; + const sourceWidth = Number(source?.width) || width; + const sourceHeight = Number(source?.height) || height; + const coverScale = Math.max(width / sourceWidth, height / sourceHeight) * 1.035; + backdrop.setScale(coverScale); + this.campSkinBackdrop = backdrop; + + this.campSkinWash = this.add.rectangle(0, 0, width, height, skin.washColor, skin.washAlpha) + .setOrigin(0) + .setDepth(-29); + this.campSkinVignette = this.add.rectangle(width / 2, height / 2, width, height, 0x020407, 0.03) + .setStrokeStyle(Math.max(52, Math.round(height * 0.12)), 0x020407, 0.54) + .setDepth(-28); + + this.add.text(width - 68, height - 30, skin.seal, { + fontFamily: '"Malgun Gothic", "Noto Serif KR", serif', + fontSize: `${Math.round(height * 0.15)}px`, + color: '#f4e4bf', + fontStyle: '700' + }).setOrigin(1, 1).setAlpha(0.075).setDepth(-27); + + this.campSkinHeader = this.scaleLegacyCampUi( + this.add.rectangle(0, 0, campLegacyCanvasWidth, 84, skin.headerColor, 0.79) + ).setOrigin(0).setDepth(-20); + this.campSkinAccentLine = this.scaleLegacyCampUi( + this.add.rectangle(0, 82, campLegacyCanvasWidth, 2, skin.accentColor, 0.9) + ).setOrigin(0).setDepth(-19); + this.scaleLegacyCampUi(this.add.rectangle(0, 0, 5, 84, skin.accentColor, 0.94)) + .setOrigin(0) + .setDepth(-19); + + this.campSkinSeal = this.scaleLegacyCampUi(this.add.circle(48, 42, 24, skin.headerColor, 0.92)) + .setStrokeStyle(2, skin.accentColor, 0.96) + .setDepth(-18); + this.scaleLegacyCampUi(this.add.text(48, 42, skin.seal, { + fontFamily: '"Malgun Gothic", "Noto Serif KR", serif', + fontSize: '26px', + color: '#f6e8c8', + fontStyle: '700', + stroke: '#05070a', + strokeThickness: 3 + })).setOrigin(0.5).setDepth(-17); + + this.scaleLegacyCampUi(this.add.text(88, 22, this.currentCampTitle(), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '30px', + fontSize: '28px', color: '#f2e3bf', fontStyle: '700', stroke: '#05070a', strokeThickness: 4 })); - this.scaleLegacyCampUi(this.add.text(42, 64, this.reportSummary(), { + this.scaleLegacyCampUi(this.add.text(88, 62, this.reportSummary(), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', - color: '#d4dce6' + color: '#d4dce6', + wordWrap: { width: 320, useAdvancedWrap: false } })); + this.campSkinBadge = this.scaleLegacyCampUi(this.add.rectangle(476, 64, 112, 24, skin.headerColor, 0.9)) + .setStrokeStyle(1, skin.accentColor, 0.88) + .setDepth(-18); + this.scaleLegacyCampUi(this.add.text(476, 64, skin.locationLabel, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '13px', + color: '#f3e6c7', + fontStyle: '700' + })).setOrigin(0.5).setDepth(-17); + + const transitionDurationMs = 420; + const transitionRevision = ++this.campSkinTransitionRevision; + this.campSkinTransitionActive = true; + this.campSkinTransitionLast = { + kind: 'backdrop-fade', + durationMs: transitionDurationMs, + skinId: skin.id, + fromAlpha: 0, + toAlpha: skin.backdropAlpha + }; + this.tweens.add({ + targets: backdrop, + alpha: skin.backdropAlpha, + duration: transitionDurationMs, + ease: 'Sine.easeOut', + onComplete: () => { + if (this.campSkinBackdrop !== backdrop) { + return; + } + this.campSkinTransitionCompletedRevision = transitionRevision; + this.campSkinTransitionActive = false; + } + }); + this.renderStaticButtons(); this.render(); if (this.openSortiePrepOnCreate) { @@ -11221,6 +11347,12 @@ export class CampScene extends Phaser.Scene { private ensureCampAssets(onReady: () => void) { const useFirstSortieAssets = this.isFirstSortiePrepSlice(); + const selection = this.campSkinSelection ?? selectCampSkin({ + campaignStep: this.campaign?.step, + nextBattleId: this.currentSortieFlow().nextBattleId, + currentBattleId: this.currentCampBattleId() + }); + this.campSkinSelection = selection; const missingPortraits = this.currentUnits() .flatMap((unit): PortraitAssetEntry[] => { const portraitKey = campaignPortraitKeysByUnitId[unit.id]; @@ -11236,10 +11368,10 @@ export class CampScene extends Phaser.Scene { }) .filter((entry, index, entries) => entries.findIndex((candidate) => candidate.textureKey === entry.textureKey) === index) .filter((entry) => !this.textures.exists(entry.textureKey)); - const missingMilitia = !this.textures.exists('story-militia'); + const missingCampBackdrop = !this.textures.exists(selection.skin.textureKey); const missingFirstSortieArtwork = useFirstSortieAssets && !this.textures.exists('story-first-sortie'); - if (!missingMilitia && !missingFirstSortieArtwork && missingPortraits.length === 0) { + if (!missingCampBackdrop && !missingFirstSortieArtwork && missingPortraits.length === 0) { onReady(); return; } @@ -11261,8 +11393,8 @@ export class CampScene extends Phaser.Scene { console.warn(`Failed to load camp asset ${file.key}`); }); - if (missingMilitia) { - this.load.image('story-militia', storyMilitiaUrl); + if (missingCampBackdrop) { + this.load.image(selection.skin.textureKey, selection.skin.assetUrl); } if (missingFirstSortieArtwork) { this.load.image('story-first-sortie', storyFirstSortieUrl); @@ -12655,17 +12787,18 @@ export class CampScene extends Phaser.Scene { } private updateTabButtons() { + const accentColor = this.campSkinSelection?.skin.accentColor ?? palette.gold; this.tabButtons.forEach(({ tab, bg, text }) => { const active = this.activeTab === tab; bg.setFillStyle(active ? 0x25384a : 0x182431, active ? 0.98 : 0.94); - bg.setStrokeStyle(1, active ? palette.gold : palette.blue, active ? 0.86 : 0.62); + bg.setStrokeStyle(1, active ? accentColor : palette.blue, active ? 0.9 : 0.62); text.setColor(active ? '#fff2b8' : '#f2e3bf'); }); } private addCommandButton(label: string, x: number, y: number, width: number, action: () => void) { const bg = this.scaleLegacyCampUi(this.add.rectangle(x, y, width, 36, 0x1a2630, 0.96)); - bg.setStrokeStyle(1, palette.gold, 0.8); + bg.setStrokeStyle(1, this.campSkinSelection?.skin.accentColor ?? palette.gold, 0.84); bg.setInteractive({ useHandCursor: true }); bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98)); bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.96)); @@ -20897,6 +21030,21 @@ export class CampScene extends Phaser.Scene { return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }; } + private campSkinObjectDebug( + object?: Phaser.GameObjects.Image | Phaser.GameObjects.Rectangle | Phaser.GameObjects.Arc + ) { + if (!object?.active) { + return null; + } + const bounds = object.getBounds(); + return { + bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }, + alpha: object.alpha, + depth: object.depth, + visible: object.visible + }; + } + getDebugState() { const sortieChecklist = this.sortieChecklist(); const sortieScenario = this.nextSortieScenario(); @@ -21524,6 +21672,41 @@ export class CampScene extends Phaser.Scene { campaignProgress: this.campaignTimelineProgress(), campBattleId: this.currentCampBattleId(), campTitle: this.currentCampTitle(), + campSkin: this.campSkinSelection + ? { + id: this.campSkinSelection.skin.id, + chapterLabel: this.campSkinSelection.skin.chapterLabel, + locationLabel: this.campSkinSelection.skin.locationLabel, + sealGlyph: this.campSkinSelection.skin.seal, + selectionSource: this.campSkinSelection.source, + requestedBattleId: this.campSkinSelection.requestedBattleId ?? null, + battleOrdinal: this.campSkinSelection.battleOrdinal ?? null, + campaignStep: this.campaign?.step ?? null, + currentBattleId: this.currentCampBattleId(), + nextBattleId: this.currentSortieFlow().nextBattleId ?? null, + textureKey: this.campSkinSelection.skin.textureKey, + renderedTextureKey: this.campSkinRenderedTextureKey ?? null, + textureReady: this.textures.exists(this.campSkinSelection.skin.textureKey), + usedFallback: this.campSkinUsedFallback, + accentColor: this.campSkinSelection.skin.accentColor, + headerColor: this.campSkinSelection.skin.headerColor, + washColor: this.campSkinSelection.skin.washColor, + sceneBounds: { x: 0, y: 0, width: this.scale.width, height: this.scale.height }, + backdrop: this.campSkinObjectDebug(this.campSkinBackdrop), + wash: this.campSkinObjectDebug(this.campSkinWash), + vignette: this.campSkinObjectDebug(this.campSkinVignette), + header: this.campSkinObjectDebug(this.campSkinHeader), + accentLine: this.campSkinObjectDebug(this.campSkinAccentLine), + seal: this.campSkinObjectDebug(this.campSkinSeal), + badge: this.campSkinObjectDebug(this.campSkinBadge), + transition: { + revision: this.campSkinTransitionRevision, + completedRevision: this.campSkinTransitionCompletedRevision, + active: this.campSkinTransitionActive, + last: this.campSkinTransitionLast ? { ...this.campSkinTransitionLast } : null + } + } + : null, openSortiePrepOnCreate: this.openSortiePrepOnCreate, skipIntroStoryForSortie: this.skipIntroStoryForSortie, availableDialogueIds: this.availableCampDialogues().map((dialogue) => dialogue.id),