From 8b81d16cf16ddc60be859096e3f813596716e571 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sat, 4 Jul 2026 00:02:16 +0900 Subject: [PATCH] Rework first battle story as unit cutscenes --- src/game/data/scenario.ts | 197 +++++++++++++++++++++ src/game/data/storyCutscenes.ts | 100 +++++++++++ src/game/scenes/BattleScene.ts | 9 +- src/game/scenes/StoryScene.ts | 291 +++++++++++++++++++++++++++++++- 4 files changed, 588 insertions(+), 9 deletions(-) create mode 100644 src/game/data/storyCutscenes.ts diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index d71dd2b..e547e01 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -1,5 +1,6 @@ import type { TerrainType, UnitClassKey } from './battleRules'; import type { EquipmentSet } from './battleItems'; +import type { StoryCutscene } from './storyCutscenes'; export type PortraitKey = string; @@ -11,6 +12,7 @@ export type StoryPage = { portrait?: PortraitKey; bgm?: string; text: string; + cutscene?: StoryCutscene; }; export type UnitData = { @@ -185,6 +187,201 @@ export const firstBattleVictoryPages: StoryPage[] = [ } ]; +const firstBattleStoryOverrides: Record> = { + 'militia-rises': { + chapter: '탁현 의용군', + speaker: '장비', + text: '창을 들 사람은 앞으로 나오시오. 오늘 모인 이름 없는 무리가 마을의 방패가 될 것이오.', + cutscene: { + kind: 'muster', + title: '탁현 의용군 집결', + subtitle: '세 형제와 마을 사람들이 첫 출전을 준비합니다.', + actors: [ + { unitId: 'liu-bei', x: 0.34, y: 0.53, size: 122, direction: 'east', label: '군주' }, + { unitId: 'guan-yu', x: 0.49, y: 0.52, size: 132, direction: 'east', label: '선봉' }, + { unitId: 'zhang-fei', x: 0.64, y: 0.53, size: 132, direction: 'west', label: '돌파' } + ], + briefing: { + title: '출전 준비', + lines: ['유비는 중앙에서 전열을 잡습니다.', '관우는 앞길을 열고, 장비는 측면을 찌릅니다.'] + }, + rewards: [ + { label: '아군: 유비 · 관우 · 장비', tone: 'bond' }, + { label: '목표: 마을 방어', tone: 'next' } + ] + } + }, + 'first-sortie': { + chapter: '첫 출전', + speaker: '유비', + text: '오늘은 마을을 지키는 첫 싸움이다. 서로의 등을 믿고, 백성들이 물러설 시간을 벌자.', + cutscene: { + kind: 'muster', + title: '세 형제 출진', + subtitle: '전투에서 조작할 유닛이 스토리 화면에 그대로 등장합니다.', + actors: [ + { unitId: 'liu-bei', x: 0.32, y: 0.53, size: 136, direction: 'east', label: '유비' }, + { unitId: 'guan-yu', x: 0.5, y: 0.51, size: 144, direction: 'east', label: '관우' }, + { unitId: 'zhang-fei', x: 0.68, y: 0.53, size: 144, direction: 'west', label: '장비' } + ], + briefing: { + title: '전장 역할', + lines: ['유비: 생존과 지휘', '관우: 전면 돌파', '장비: 측면 압박'] + }, + rewards: [ + { label: '패배 조건: 유비 퇴각 금지', tone: 'next' }, + { label: '공명: 세 형제 인접 운용', tone: 'bond' } + ] + } + }, + 'battle-briefing': { + chapter: '탁현 작전판', + speaker: '전황', + text: '황건 두령을 쓰러뜨리면 적의 전열이 무너진다. 마을 입구를 지키며 북쪽 적진으로 밀고 올라가자.', + cutscene: { + kind: 'operation', + title: '탁현의 전투', + subtitle: '마을 입구에서 시작해 황건 두령을 격파하십시오.', + actors: [ + { unitId: 'liu-bei', x: 0.24, y: 0.54, size: 104, direction: 'east', label: '중앙' }, + { unitId: 'guan-yu', x: 0.34, y: 0.53, size: 108, direction: 'east', label: '전면' }, + { unitId: 'zhang-fei', x: 0.44, y: 0.54, size: 108, direction: 'east', label: '측면' }, + { unitId: 'rebel-leader', x: 0.78, y: 0.53, size: 108, direction: 'west', label: '두령' } + ], + markers: [ + { x: 0.2, y: 0.74, label: '아군 시작', side: 'ally' }, + { x: 0.44, y: 0.5, label: '마을 입구', side: 'objective' }, + { x: 0.74, y: 0.28, label: '황건 두령', side: 'enemy' } + ], + briefing: { + title: '승리 조건', + lines: ['황건 두령 격파', '유비 생존', '마을 지형 확보'] + }, + rewards: [ + { label: '보상: 보급품 · 연습검 · 의용군 명성', tone: 'reward' } + ] + } + }, + 'first-victory-village': { + chapter: '첫 승리', + speaker: '전황', + text: '황건의 깃발이 꺾이고, 흩어졌던 백성들이 마을로 돌아오기 시작했다.', + cutscene: { + kind: 'victory', + title: '탁현 방어 성공', + subtitle: '전투에서 싸운 세 유닛이 그대로 승리 장면에 남습니다.', + actors: [ + { unitId: 'liu-bei', x: 0.36, y: 0.53, size: 132, direction: 'east', label: '유비' }, + { unitId: 'guan-yu', x: 0.52, y: 0.52, size: 142, direction: 'west', label: '관우' }, + { unitId: 'zhang-fei', x: 0.67, y: 0.53, size: 142, direction: 'west', label: '장비' } + ], + rewards: [ + { label: '승리 조건 달성', tone: 'reward' }, + { label: '마을 방어', tone: 'bond' }, + { label: '다음: 황건 잔당 추격', tone: 'next' } + ] + } + }, + 'first-victory-liu-bei': { + chapter: '첫 승리', + speaker: '유비', + text: '오늘의 승리는 작다. 하지만 백성을 지키는 길은 여기서 시작된다.', + cutscene: { + kind: 'victory', + title: '전투 후 정비', + subtitle: '획득 보상은 다음 출전 준비와 장비 화면으로 이어집니다.', + actors: [ + { unitId: 'liu-bei', x: 0.44, y: 0.52, size: 146, direction: 'south', label: '유비' }, + { unitId: 'guan-yu', x: 0.58, y: 0.53, size: 130, direction: 'west', label: '관우' }, + { unitId: 'zhang-fei', x: 0.7, y: 0.54, size: 130, direction: 'west', label: '장비' } + ], + rewards: [ + { label: '보급품: 콩 · 탁주', tone: 'reward' }, + { label: '장비: 연습검', tone: 'reward' }, + { label: '명성: 의용군 +1', tone: 'bond' } + ] + } + }, + 'first-victory-guan-yu': { + chapter: '의용군의 이름', + speaker: '관우', + text: '형님의 뜻을 보고 사람들이 모일 것입니다. 다음 싸움은 오늘보다 무거운 책임을 요구하겠지요.', + cutscene: { + kind: 'victory', + title: '관우의 다짐', + subtitle: '선봉의 장병기가 다음 전투의 전열을 맡습니다.', + actors: [ + { unitId: 'guan-yu', x: 0.45, y: 0.52, size: 152, direction: 'east', label: '선봉' }, + { unitId: 'liu-bei', x: 0.31, y: 0.54, size: 122, direction: 'east', label: '지휘' }, + { unitId: 'zhang-fei', x: 0.64, y: 0.54, size: 128, direction: 'west', label: '돌파' } + ], + rewards: [ + { label: '출전 가능: 관우', tone: 'bond' }, + { label: '역할 추천: 전면', tone: 'next' } + ] + } + }, + 'first-victory-zhang-fei': { + chapter: '의용군의 이름', + speaker: '장비', + text: '좋다! 다음에는 더 크게 몰아치자. 창을 드는 자들은 우리 의용군을 따라올 것이다.', + cutscene: { + kind: 'victory', + title: '장비의 기세', + subtitle: '측면 돌파와 압박이 다음 전장의 선택지가 됩니다.', + actors: [ + { unitId: 'zhang-fei', x: 0.48, y: 0.52, size: 154, direction: 'east', label: '돌파' }, + { unitId: 'liu-bei', x: 0.34, y: 0.54, size: 122, direction: 'east', label: '지휘' }, + { unitId: 'guan-yu', x: 0.64, y: 0.53, size: 130, direction: 'west', label: '선봉' } + ], + rewards: [ + { label: '출전 가능: 장비', tone: 'bond' }, + { label: '역할 추천: 측면', tone: 'next' } + ] + } + }, + 'first-victory-next': { + chapter: '다음 길', + speaker: '전황', + text: '세 형제의 이름은 탁현 일대에 퍼지기 시작했다. 이제 황건 잔당의 퇴로를 끊어야 한다.', + cutscene: { + kind: 'operation', + title: '다음 전투 해금', + subtitle: '황건 잔당 추격으로 이어집니다.', + actors: [ + { unitId: 'liu-bei', x: 0.25, y: 0.54, size: 108, direction: 'east', label: '유비' }, + { unitId: 'guan-yu', x: 0.34, y: 0.53, size: 112, direction: 'east', label: '관우' }, + { unitId: 'zhang-fei', x: 0.43, y: 0.54, size: 112, direction: 'east', label: '장비' } + ], + markers: [ + { x: 0.24, y: 0.68, label: '탁현', side: 'ally' }, + { x: 0.58, y: 0.46, label: '북쪽 길목', side: 'objective' }, + { x: 0.76, y: 0.32, label: '황건 잔당', side: 'enemy' } + ], + briefing: { + title: '정비 후 출전', + lines: ['보급품 배정', '장비 교체', '출전 무장 선택'] + }, + rewards: [ + { label: '군영에서 보상 확인', tone: 'reward' }, + { label: '다음: 황건 잔당 추격', tone: 'next' } + ] + } + } +}; + +function applyStoryPageOverrides(pages: StoryPage[]) { + pages.forEach((page) => { + const override = firstBattleStoryOverrides[page.id]; + if (override) { + Object.assign(page, override); + } + }); +} + +applyStoryPageOverrides(prologuePages); +applyStoryPageOverrides(firstBattleVictoryPages); + export const secondBattleIntroPages: StoryPage[] = [ { id: 'second-pursuit-rumor', diff --git a/src/game/data/storyCutscenes.ts b/src/game/data/storyCutscenes.ts new file mode 100644 index 0000000..e20133a --- /dev/null +++ b/src/game/data/storyCutscenes.ts @@ -0,0 +1,100 @@ +import type { UnitDirection } from './unitAssets'; + +export type StoryCutsceneKind = 'muster' | 'operation' | 'victory'; +export type StoryCutsceneTone = 'reward' | 'next' | 'bond'; +export type StoryCutsceneMarkerSide = 'ally' | 'enemy' | 'objective'; + +export type StoryCutsceneActorProfile = { + unitId: string; + name: string; + roleLabel: string; + unitTextureKey: string; + portraitKey: string; + color: number; +}; + +export type StoryCutsceneActor = { + unitId: string; + x: number; + y: number; + size?: number; + direction?: UnitDirection; + label?: string; + line?: string; +}; + +export type StoryCutsceneMarker = { + x: number; + y: number; + label: string; + side: StoryCutsceneMarkerSide; +}; + +export type StoryCutsceneBriefing = { + title: string; + lines: string[]; +}; + +export type StoryCutsceneReward = { + label: string; + tone?: StoryCutsceneTone; +}; + +export type StoryCutscene = { + kind: StoryCutsceneKind; + title: string; + subtitle?: string; + actors?: StoryCutsceneActor[]; + briefing?: StoryCutsceneBriefing; + markers?: StoryCutsceneMarker[]; + rewards?: StoryCutsceneReward[]; +}; + +const storyCutsceneActorProfiles: Record = { + 'liu-bei': { + unitId: 'liu-bei', + name: '유비', + roleLabel: '검을 든 군주', + unitTextureKey: 'unit-liu-bei', + portraitKey: 'liuBei', + color: 0xd8b15f + }, + 'guan-yu': { + unitId: 'guan-yu', + name: '관우', + roleLabel: '장병기 선봉', + unitTextureKey: 'unit-guan-yu', + portraitKey: 'guanYu', + color: 0x74c476 + }, + 'zhang-fei': { + unitId: 'zhang-fei', + name: '장비', + roleLabel: '돌파형 창병', + unitTextureKey: 'unit-zhang-fei', + portraitKey: 'zhangFei', + color: 0xd46a4c + }, + 'rebel-leader': { + unitId: 'rebel-leader', + name: '황건 두령', + roleLabel: '격파 목표', + unitTextureKey: 'unit-rebel-leader', + portraitKey: 'rebelLeader', + color: 0xd6b852 + } +}; + +export function storyCutsceneActorProfileFor(unitId: string) { + return storyCutsceneActorProfiles[unitId]; +} + +export function storyCutsceneActorTextureKeys(cutscene?: StoryCutscene) { + return Array.from( + new Set( + (cutscene?.actors ?? []) + .map((actor) => storyCutsceneActorProfileFor(actor.unitId)?.unitTextureKey) + .filter((textureKey): textureKey is string => Boolean(textureKey)) + ) + ); +} diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index cb29733..4476ef4 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1,7 +1,7 @@ import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; import { battleMapAssets } from '../data/battleMapAssets'; -import { type BattleBond, type UnitData, type UnitStats } from '../data/scenario'; +import { firstBattleVictoryPages, type BattleBond, type UnitData, type UnitStats } from '../data/scenario'; import { defaultBattleScenario, getBattleScenario, @@ -7675,6 +7675,13 @@ export class BattleScene extends Phaser.Scene { if (outcome === 'victory') { this.addResultButton('군영으로', left + panelWidth - 422, top + 612, 132, () => { soundDirector.playSelect(); + if (battleScenario.id === 'first-battle-zhuo-commandery') { + void startLazyScene(this, 'StoryScene', { + pages: firstBattleVictoryPages, + nextScene: 'CampScene' + }); + return; + } void startLazyScene(this, 'CampScene'); }, depth + 3); } diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index ffd5e44..e2a8fa4 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -2,11 +2,25 @@ import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; import { portraitAssetEntriesForKey, portraitKeyForSpeaker, type PortraitAssetEntry } from '../data/portraitAssets'; import { storyBackgroundAssets, storyBackgroundKeysFor } from '../data/storyAssets'; +import { + ensureUnitAnimations, + loadUnitSheets, + unitBaseFramesPerDirection, + type UnitDirection +} from '../data/unitAssets'; +import { + storyCutsceneActorProfileFor, + storyCutsceneActorTextureKeys, + type StoryCutscene, + type StoryCutsceneActor, + type StoryCutsceneMarker, + type StoryCutsceneReward +} from '../data/storyCutscenes'; import { prologuePages, type PortraitKey, type StoryPage } from '../data/scenario'; import { palette } from '../ui/palette'; import { startGameScene } from './lazyScenes'; -type AlphaObject = Phaser.GameObjects.Image | Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text; +type AlphaObject = Phaser.GameObjects.Container | Phaser.GameObjects.Image | Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text; type StorySceneData = { pages?: StoryPage[]; @@ -14,6 +28,16 @@ type StorySceneData = { nextSceneData?: Record; }; +const sceneShadeDepth = 1; +const cutsceneDepth = 8; +const dialogDepth = 20; +const storyUnitFrameRows: Record = { + south: 0, + east: 1, + north: 2, + west: 3 +}; + export class StoryScene extends Phaser.Scene { private pageIndex = 0; private pages: StoryPage[] = prologuePages; @@ -29,6 +53,7 @@ export class StoryScene extends Phaser.Scene { private bodyText?: Phaser.GameObjects.Text; private progressText?: Phaser.GameObjects.Text; private loadingText?: Phaser.GameObjects.Text; + private cutsceneLayer?: Phaser.GameObjects.Container; constructor() { super('StoryScene'); @@ -56,7 +81,9 @@ export class StoryScene extends Phaser.Scene { backgroundReady: page ? this.textures.exists(this.pageBackgroundKey(page)) : false, activeTexture: this.background?.texture.key ?? null, portraitKey: page ? this.pagePortraitKey(page) ?? null : null, - portraitTextureKey: this.portrait?.visible ? this.portrait.texture.key : null + portraitTextureKey: this.portrait?.visible ? this.portrait.texture.key : null, + cutsceneKind: page?.cutscene?.kind ?? null, + cutsceneActors: page?.cutscene?.actors?.map((actor) => actor.unitId) ?? [] }; } @@ -77,6 +104,7 @@ export class StoryScene extends Phaser.Scene { this.loadingText?.destroy(); this.loadingText = undefined; this.background = this.add.image(width / 2, height / 2, this.resolveBackgroundKey(this.pageBackgroundKey(this.pages[0]))); + this.background.setDepth(0); this.drawSceneShade(width, height); this.drawDialogPanel(width, height); this.showPage(0, true); @@ -89,6 +117,7 @@ export class StoryScene extends Phaser.Scene { private ensureStoryAssetsLoaded(onReady: () => void) { const requestedKeys = this.pages.flatMap((page) => storyBackgroundKeysFor(page.background)); + const requestedUnitTextureKeys = Array.from(new Set(this.pages.flatMap((page) => storyCutsceneActorTextureKeys(page.cutscene)))); const missingKeys = Array.from(new Set(requestedKeys)).filter((key) => !this.textures.exists(key)); const loadableKeys = missingKeys.filter((key) => storyBackgroundAssets[key]); const missingPortraits = this.pages @@ -103,12 +132,24 @@ export class StoryScene extends Phaser.Scene { .filter((key) => !storyBackgroundAssets[key]) .forEach((key) => console.warn(`Missing story background asset for ${key}`)); + const finishLoading = () => { + if (requestedUnitTextureKeys.length === 0) { + onReady(); + return; + } + + loadUnitSheets(this, requestedUnitTextureKeys, () => { + ensureUnitAnimations(this, requestedUnitTextureKeys); + onReady(); + }); + }; + if (loadableKeys.length === 0 && missingPortraits.length === 0) { - onReady(); + finishLoading(); return; } - this.load.once('complete', onReady); + this.load.once('complete', finishLoading); this.load.once('loaderror', (file: Phaser.Loader.File) => { console.warn(`Failed to load story background ${file.key}`); }); @@ -138,6 +179,7 @@ export class StoryScene extends Phaser.Scene { private drawSceneShade(width: number, height: number) { const shade = this.add.graphics(); + shade.setDepth(sceneShadeDepth); shade.fillStyle(0x040608, 0.22); shade.fillRect(0, 0, width, height); shade.fillStyle(0x040608, 0.5); @@ -152,9 +194,9 @@ export class StoryScene extends Phaser.Scene { const panelW = width - 144; const panelH = 172; - this.add.rectangle(panelX, panelY, panelW, panelH, palette.panelDark, 0.88).setOrigin(0); - this.add.rectangle(panelX, panelY, panelW, 3, palette.gold, 0.9).setOrigin(0); - this.add.rectangle(panelX, panelY + panelH, panelW, 1, palette.gold, 0.35).setOrigin(0); + this.add.rectangle(panelX, panelY, panelW, panelH, palette.panelDark, 0.9).setOrigin(0).setDepth(dialogDepth); + this.add.rectangle(panelX, panelY, panelW, 3, palette.gold, 0.9).setOrigin(0).setDepth(dialogDepth + 1); + this.add.rectangle(panelX, panelY + panelH, panelW, 1, palette.gold, 0.35).setOrigin(0).setDepth(dialogDepth + 1); this.chapterText = this.add.text(panelX, panelY - 42, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", serif', @@ -164,14 +206,18 @@ export class StoryScene extends Phaser.Scene { padding: { top: 4, bottom: 4 }, shadow: { offsetX: 0, offsetY: 2, color: '#05070a', blur: 6, fill: true } }); + this.chapterText.setDepth(dialogDepth + 2); this.portraitFrame = this.add.rectangle(panelX + 86, panelY + 86, 136, 136, 0x090d12, 0.88); this.portraitFrame.setStrokeStyle(2, palette.gold, 0.58); + this.portraitFrame.setDepth(dialogDepth + 2); this.portrait = this.add.image(panelX + 86, panelY + 86, this.pagePortraitTextureKey(this.pages[0]) ?? 'story-fallback'); this.portrait.setDisplaySize(128, 128); + this.portrait.setDepth(dialogDepth + 3); this.portraitDivider = this.add.rectangle(panelX + 166, panelY + 28, 1, panelH - 56, palette.gold, 0.22).setOrigin(0); + this.portraitDivider.setDepth(dialogDepth + 2); this.nameText = this.add.text(panelX + 198, panelY + 28, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", serif', @@ -180,6 +226,7 @@ export class StoryScene extends Phaser.Scene { fontStyle: '700', padding: { top: 4, bottom: 4 } }); + this.nameText.setDepth(dialogDepth + 2); this.bodyText = this.add.text(panelX + 198, panelY + 78, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", serif', @@ -190,12 +237,14 @@ export class StoryScene extends Phaser.Scene { padding: { top: 4, bottom: 6 }, shadow: { offsetX: 0, offsetY: 2, color: '#05070a', blur: 5, fill: true } }); + this.bodyText.setDepth(dialogDepth + 2); this.progressText = this.add.text(width - 220, panelY + panelH + 20, '', { fontFamily: '"Segoe UI", sans-serif', fontSize: '15px', color: '#9aa3ad' }); + this.progressText.setDepth(dialogDepth + 2); } private showPage(index: number, immediate = false) { @@ -245,6 +294,7 @@ export class StoryScene extends Phaser.Scene { private applyPage(page: StoryPage) { soundDirector.playMusic(page.bgm); this.applyBackground(page); + this.renderCutscene(page.cutscene); this.chapterText?.setText(page.chapter); const textureKey = this.pagePortraitTextureKey(page); @@ -319,6 +369,7 @@ export class StoryScene extends Phaser.Scene { this.tweens.killTweensOf(this.background); this.background.setTexture(safeTextureKey); this.background.setAlpha(1); + this.background.setDepth(0); this.background.setScale(coverScale * 1.07); this.background.setPosition(width / 2 - 16, height / 2); @@ -334,6 +385,229 @@ export class StoryScene extends Phaser.Scene { }); } + private renderCutscene(cutscene?: StoryCutscene) { + this.cutsceneLayer?.list.forEach((child) => this.tweens.killTweensOf(child)); + this.cutsceneLayer?.destroy(); + this.cutsceneLayer = undefined; + + if (!cutscene) { + return; + } + + const layer = this.add.container(0, 0); + layer.setDepth(cutsceneDepth); + this.cutsceneLayer = layer; + + this.renderCutsceneStage(layer, cutscene); + + if (cutscene.kind === 'operation') { + this.renderOperationBoard(layer, cutscene); + } else { + this.renderBriefingCard(layer, cutscene, 82, 182, 360, 142); + } + + (cutscene.actors ?? []).forEach((actor, index) => this.renderCutsceneActor(layer, actor, index)); + this.renderCutsceneRewards(layer, cutscene.rewards ?? []); + } + + private renderCutsceneStage(layer: Phaser.GameObjects.Container, cutscene: StoryCutscene) { + const { width } = this.scale; + const stageX = 58; + const stageY = 104; + const stageW = width - 116; + const stageH = 330; + const fill = cutscene.kind === 'victory' ? 0x142018 : cutscene.kind === 'operation' ? 0x101822 : 0x171b18; + const stroke = cutscene.kind === 'victory' ? palette.green : palette.gold; + const bg = this.add.rectangle(stageX, stageY, stageW, stageH, fill, 0.82).setOrigin(0); + bg.setStrokeStyle(1, stroke, 0.52); + const topLine = this.add.rectangle(stageX, stageY, stageW, 3, stroke, 0.72).setOrigin(0); + const title = this.add.text(stageX + 24, stageY + 18, cutscene.title, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", serif', + fontSize: '26px', + color: '#f2e3bf', + fontStyle: '700', + stroke: '#05070a', + strokeThickness: 4 + }); + const subtitle = this.add.text(stageX + 26, stageY + 55, cutscene.subtitle ?? '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '14px', + color: '#c8d2dd' + }); + const ground = this.add.rectangle(stageX + 22, stageY + stageH - 42, stageW - 44, 2, stroke, 0.35).setOrigin(0); + layer.add([bg, topLine, title, subtitle, ground]); + } + + private renderBriefingCard(layer: Phaser.GameObjects.Container, cutscene: StoryCutscene, x: number, y: number, width: number, height: number) { + const briefingLines = cutscene.briefing?.lines ?? []; + if (!cutscene.briefing || briefingLines.length === 0) { + return; + } + + const bg = this.add.rectangle(x, y, width, height, 0x0b1017, 0.88).setOrigin(0); + bg.setStrokeStyle(1, palette.gold, 0.46); + const title = this.add.text(x + 18, y + 16, cutscene.briefing?.title ?? '전황', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '18px', + color: '#d8b15f', + fontStyle: '700' + }); + layer.add([bg, title]); + + briefingLines.slice(0, 4).forEach((line, index) => { + const bullet = this.add.circle(x + 24, y + 54 + index * 30, 4, palette.gold, 0.92); + const text = this.add.text(x + 38, y + 43 + index * 30, line, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '14px', + color: '#e8dfca', + wordWrap: { width: width - 58, useAdvancedWrap: true } + }); + layer.add([bullet, text]); + }); + } + + private renderOperationBoard(layer: Phaser.GameObjects.Container, cutscene: StoryCutscene) { + const boardX = this.scale.width - 560; + const boardY = 128; + const boardW = 474; + const boardH = 246; + const board = this.add.rectangle(boardX, boardY, boardW, boardH, 0x0b1218, 0.92).setOrigin(0); + board.setStrokeStyle(1, palette.blue, 0.5); + layer.add(board); + + const grid = this.add.graphics(); + grid.lineStyle(1, 0x53606c, 0.26); + for (let i = 1; i < 6; i += 1) { + const x = boardX + (boardW / 6) * i; + grid.lineBetween(x, boardY + 16, x, boardY + boardH - 16); + } + for (let i = 1; i < 4; i += 1) { + const y = boardY + (boardH / 4) * i; + grid.lineBetween(boardX + 16, y, boardX + boardW - 16, y); + } + grid.lineStyle(3, palette.gold, 0.36); + grid.beginPath(); + grid.moveTo(boardX + boardW * 0.2, boardY + boardH * 0.74); + grid.lineTo(boardX + boardW * 0.44, boardY + boardH * 0.5); + grid.lineTo(boardX + boardW * 0.74, boardY + boardH * 0.28); + grid.strokePath(); + layer.add(grid); + + this.renderBriefingCard(layer, cutscene, 82, 184, 364, 138); + + (cutscene.markers ?? []).forEach((marker) => this.renderOperationMarker(layer, marker, boardX, boardY, boardW, boardH)); + } + + private renderOperationMarker( + layer: Phaser.GameObjects.Container, + marker: StoryCutsceneMarker, + boardX: number, + boardY: number, + boardW: number, + boardH: number + ) { + const color = marker.side === 'ally' ? palette.blue : marker.side === 'enemy' ? 0xd46a4c : palette.gold; + const x = boardX + boardW * marker.x; + const y = boardY + boardH * marker.y; + const halo = this.add.circle(x, y, 16, color, 0.18); + const dot = this.add.circle(x, y, 7, color, 0.96); + dot.setStrokeStyle(2, 0x05070a, 0.85); + const label = this.add.text(x + 14, y - 11, marker.label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: '#f2e3bf', + stroke: '#05070a', + strokeThickness: 3 + }); + layer.add([halo, dot, label]); + } + + private renderCutsceneActor(layer: Phaser.GameObjects.Container, actor: StoryCutsceneActor, index: number) { + const profile = storyCutsceneActorProfileFor(actor.unitId); + if (!profile) { + return; + } + + const x = this.scale.width * actor.x; + const y = this.scale.height * actor.y; + const size = actor.size ?? 126; + const direction = actor.direction ?? 'south'; + const shadow = this.add.ellipse(x, y + size * 0.36, size * 0.46, size * 0.14, 0x05070a, 0.56); + layer.add(shadow); + + if (this.textures.exists(profile.unitTextureKey)) { + const sprite = this.add.sprite(x, y, profile.unitTextureKey, this.storyUnitFrameIndex(direction)); + sprite.setDisplaySize(size, size); + sprite.setBlendMode(Phaser.BlendModes.NORMAL); + layer.add(sprite); + this.tweens.add({ + targets: sprite, + y: y - 4, + duration: 1200 + index * 90, + ease: 'Sine.easeInOut', + yoyo: true, + repeat: -1, + delay: index * 110 + }); + } else { + const fallback = this.add.rectangle(x, y, size * 0.56, size * 0.86, profile.color, 0.88); + fallback.setStrokeStyle(2, 0x05070a, 0.8); + layer.add(fallback); + } + + const labelWidth = Math.max(78, Math.min(132, size * 0.86)); + const labelBg = this.add.rectangle(x, y + size * 0.54, labelWidth, 28, 0x070b10, 0.9); + labelBg.setStrokeStyle(1, profile.color, 0.64); + const label = this.add.text(x, y + size * 0.54 - 7, actor.label ?? profile.name, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '13px', + color: '#f2e3bf', + fontStyle: '700' + }); + label.setOrigin(0.5, 0); + layer.add([labelBg, label]); + + if (actor.line) { + const line = this.add.text(x, y - size * 0.58, actor.line, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: '#e8dfca', + align: 'center', + wordWrap: { width: 160, useAdvancedWrap: true }, + stroke: '#05070a', + strokeThickness: 3 + }); + line.setOrigin(0.5, 1); + layer.add(line); + } + } + + private renderCutsceneRewards(layer: Phaser.GameObjects.Container, rewards: StoryCutsceneReward[]) { + if (rewards.length === 0) { + return; + } + + const startX = 82; + const y = 390; + rewards.slice(0, 4).forEach((reward, index) => { + const toneColor = reward.tone === 'next' ? palette.blue : reward.tone === 'bond' ? palette.green : palette.gold; + const x = startX + index * 274; + const bg = this.add.rectangle(x, y, 252, 34, 0x0b1017, 0.9).setOrigin(0); + bg.setStrokeStyle(1, toneColor, 0.58); + const text = this.add.text(x + 14, y + 8, reward.label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '13px', + color: '#f2e3bf', + fontStyle: '700' + }); + layer.add([bg, text]); + }); + } + + private storyUnitFrameIndex(direction: UnitDirection, frame = 0) { + return storyUnitFrameRows[direction] * unitBaseFramesPerDirection + frame; + } + private resolveBackgroundKey(textureKey: string) { return this.textures.exists(textureKey) ? textureKey : 'story-fallback'; } @@ -372,7 +646,8 @@ export class StoryScene extends Phaser.Scene { this.portraitDivider, this.nameText, this.bodyText, - this.progressText + this.progressText, + this.cutsceneLayer ]; return objects.filter((object): object is AlphaObject => object !== undefined);