diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index e2a8fa4..be9ccc0 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -38,6 +38,15 @@ const storyUnitFrameRows: Record = { west: 3 }; +type CutsceneStageBounds = { + x: number; + y: number; + width: number; + height: number; +}; + +type CutsceneBoardBounds = CutsceneStageBounds; + export class StoryScene extends Phaser.Scene { private pageIndex = 0; private pages: StoryPage[] = prologuePages; @@ -122,8 +131,13 @@ export class StoryScene extends Phaser.Scene { const loadableKeys = missingKeys.filter((key) => storyBackgroundAssets[key]); const missingPortraits = this.pages .flatMap((page) => { + const cutscenePortraits = + page.cutscene?.actors?.flatMap((actor) => { + const profile = storyCutsceneActorProfileFor(actor.unitId); + return profile ? portraitAssetEntriesForKey(profile.portraitKey) : []; + }) ?? []; const entry = this.pagePortraitAssetEntry(page); - return entry ? [entry] : []; + return entry ? [entry, ...cutscenePortraits] : cutscenePortraits; }) .filter((entry, index, entries) => entries.findIndex((candidate) => candidate.textureKey === entry.textureKey) === index) .filter((entry) => !this.textures.exists(entry.textureKey)); @@ -399,28 +413,23 @@ export class StoryScene extends Phaser.Scene { 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)); + const boardBounds = this.renderTacticalBoard(layer, cutscene); + this.renderCutsceneActorCards(layer, cutscene, boardBounds); 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 { x: stageX, y: stageY, width: stageW, height: stageH } = this.cutsceneStageBounds(); + const fill = cutscene.kind === 'victory' ? 0x121912 : cutscene.kind === 'operation' ? 0x111720 : 0x171510; 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 shade = this.add.rectangle(stageX - 10, stageY - 10, stageW + 20, stageH + 20, 0x030405, 0.72).setOrigin(0); + const bg = this.add.rectangle(stageX, stageY, stageW, stageH, fill, 0.97).setOrigin(0); + bg.setStrokeStyle(2, stroke, 0.62); + const inner = this.add.rectangle(stageX + 12, stageY + 12, stageW - 24, stageH - 24, 0x21170f, 0.44).setOrigin(0); + inner.setStrokeStyle(1, 0xf0d08a, 0.18); + const topLine = this.add.rectangle(stageX, stageY, stageW, 4, stroke, 0.8).setOrigin(0); + const titlePlate = this.add.rectangle(stageX + 22, stageY + 18, 360, 48, 0x090b0f, 0.82).setOrigin(0); + titlePlate.setStrokeStyle(1, stroke, 0.46); const title = this.add.text(stageX + 24, stageY + 18, cutscene.title, { fontFamily: '"Malgun Gothic", "Noto Sans KR", serif', fontSize: '26px', @@ -434,8 +443,201 @@ export class StoryScene extends Phaser.Scene { 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]); + const foldingScreen = this.add.graphics(); + foldingScreen.lineStyle(1, 0xd0b16e, 0.14); + foldingScreen.fillStyle(0x302215, 0.34); + for (let index = 0; index < 6; index += 1) { + const panelX = stageX + 22 + index * 88; + foldingScreen.fillRect(panelX, stageY + 84, 72, stageH - 118); + foldingScreen.strokeRect(panelX, stageY + 84, 72, stageH - 118); + } + const tableShadow = this.add.ellipse(stageX + stageW * 0.62, stageY + stageH - 34, stageW * 0.58, 40, 0x030405, 0.4); + layer.add([shade, bg, inner, foldingScreen, tableShadow, topLine, titlePlate, title, subtitle]); + } + + private renderTacticalBoard(layer: Phaser.GameObjects.Container, cutscene: StoryCutscene) { + const stage = this.cutsceneStageBounds(); + const boardX = stage.x + Math.round(stage.width * 0.36); + const boardY = stage.y + 78; + const boardW = Math.round(stage.width * 0.58); + const boardH = stage.height - 118; + const board: CutsceneBoardBounds = { x: boardX, y: boardY, width: boardW, height: boardH }; + const parchment = this.add.rectangle(boardX, boardY, boardW, boardH, 0xc2a66b, 0.96).setOrigin(0); + parchment.setStrokeStyle(3, 0x5b3a1e, 0.84); + const inner = this.add.rectangle(boardX + 12, boardY + 12, boardW - 24, boardH - 24, 0xd4bd82, 0.34).setOrigin(0); + inner.setStrokeStyle(1, 0x5b3a1e, 0.32); + layer.add([parchment, inner]); + + const map = this.add.graphics(); + map.lineStyle(1, 0x5b3a1e, 0.18); + for (let index = 1; index < 7; index += 1) { + const x = boardX + (boardW / 7) * index; + map.lineBetween(x, boardY + 16, x, boardY + boardH - 16); + } + for (let index = 1; index < 4; index += 1) { + const y = boardY + (boardH / 4) * index; + map.lineBetween(boardX + 16, y, boardX + boardW - 16, y); + } + map.fillStyle(0x3e6f4e, 0.28); + map.fillEllipse(boardX + boardW * 0.36, boardY + boardH * 0.58, boardW * 0.38, boardH * 0.52); + map.fillStyle(0x405e7d, 0.22); + map.fillEllipse(boardX + boardW * 0.72, boardY + boardH * 0.36, boardW * 0.26, boardH * 0.3); + map.lineStyle(5, 0x7b5630, 0.42); + map.beginPath(); + map.moveTo(boardX + boardW * 0.16, boardY + boardH * 0.74); + map.lineTo(boardX + boardW * 0.38, boardY + boardH * 0.54); + map.lineTo(boardX + boardW * 0.66, boardY + boardH * 0.36); + map.lineTo(boardX + boardW * 0.83, boardY + boardH * 0.24); + map.strokePath(); + map.lineStyle(2, 0x2b2114, 0.42); + map.strokeRect(boardX + 24, boardY + 24, boardW - 48, boardH - 48); + layer.add(map); + + this.renderBoardLegend(layer, cutscene, board); + (cutscene.markers ?? []).forEach((marker) => this.renderOperationMarker(layer, marker, boardX, boardY, boardW, boardH)); + this.renderCutsceneUnitTokens(layer, cutscene, board); + + return board; + } + + private renderBoardLegend(layer: Phaser.GameObjects.Container, cutscene: StoryCutscene, board: CutsceneBoardBounds) { + const label = cutscene.kind === 'victory' ? 'RESULT MAP' : cutscene.kind === 'operation' ? 'TACTICAL MAP' : 'SORTIE BOARD'; + const bg = this.add.rectangle(board.x + 18, board.y + 16, 132, 26, 0x16100b, 0.74).setOrigin(0); + bg.setStrokeStyle(1, 0xe3c271, 0.36); + const text = this.add.text(board.x + 32, board.y + 22, label, { + fontFamily: '"Segoe UI", sans-serif', + fontSize: '12px', + color: '#f2e3bf', + fontStyle: '700' + }); + layer.add([bg, text]); + } + + private renderCutsceneActorCards( + layer: Phaser.GameObjects.Container, + cutscene: StoryCutscene, + board: CutsceneBoardBounds + ) { + const stage = this.cutsceneStageBounds(); + const actors = (cutscene.actors ?? []).filter((actor) => actor.unitId !== 'rebel-leader').slice(0, 3); + const cardX = stage.x + 28; + const cardW = Math.max(250, board.x - cardX - 24); + const cardH = actors.length >= 4 ? 60 : 70; + const firstY = stage.y + 86; + + actors.forEach((actor, index) => { + const y = firstY + index * (cardH + 10); + this.renderCutsceneActorCard(layer, actor, cardX, y, cardW, cardH, index); + }); + + if ((cutscene.briefing?.lines ?? []).length > 0) { + const briefingY = firstY + actors.length * (cardH + 10) + 4; + const maxH = stage.y + stage.height - 36 - briefingY; + if (maxH >= 70) { + this.renderBriefingCard(layer, cutscene, cardX, briefingY, cardW, Math.min(126, maxH)); + } + } + } + + private renderCutsceneActorCard( + layer: Phaser.GameObjects.Container, + actor: StoryCutsceneActor, + x: number, + y: number, + width: number, + height: number, + index: number + ) { + const profile = storyCutsceneActorProfileFor(actor.unitId); + if (!profile) { + return; + } + + const bg = this.add.rectangle(x, y, width, height, 0x080b0e, 0.9).setOrigin(0); + bg.setStrokeStyle(1, profile.color, 0.56); + const accent = this.add.rectangle(x, y, 5, height, profile.color, 0.9).setOrigin(0); + layer.add([bg, accent]); + + const portraitTexture = this.actorPortraitTextureKey(actor, index); + const portraitX = x + 38; + const portraitY = y + height / 2; + const portraitFrame = this.add.rectangle(portraitX, portraitY, height - 12, height - 12, 0x0f1419, 0.94); + portraitFrame.setStrokeStyle(1, profile.color, 0.54); + layer.add(portraitFrame); + + if (portraitTexture) { + const portrait = this.add.image(portraitX, portraitY, portraitTexture); + portrait.setDisplaySize(height - 18, height - 18); + layer.add(portrait); + } else if (this.textures.exists(profile.unitTextureKey)) { + const sprite = this.add.sprite(portraitX, portraitY + 3, profile.unitTextureKey, this.storyUnitFrameIndex(actor.direction ?? 'south')); + sprite.setDisplaySize(height - 8, height - 8); + layer.add(sprite); + } + + const name = this.add.text(x + height + 10, y + 10, actor.label ?? profile.name, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '16px', + color: '#f5e6c8', + fontStyle: '700' + }); + const role = this.add.text(x + height + 10, y + 34, profile.roleLabel, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: '#cdbb94' + }); + layer.add([name, role]); + + if (actor.line && height >= 66) { + const line = this.add.text(x + 138, y + 50, actor.line, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '11px', + color: '#e8dfca', + wordWrap: { width: width - 152, useAdvancedWrap: true } + }); + layer.add(line); + } + } + + private renderCutsceneUnitTokens(layer: Phaser.GameObjects.Container, cutscene: StoryCutscene, board: CutsceneBoardBounds) { + const actors = cutscene.actors ?? []; + actors.forEach((actor, index) => { + const profile = storyCutsceneActorProfileFor(actor.unitId); + if (!profile) { + return; + } + + const normalizedX = actor.x; + const normalizedY = actor.y; + const x = board.x + board.width * Phaser.Math.Clamp(normalizedX, 0.12, 0.88); + const y = board.y + board.height * Phaser.Math.Clamp(normalizedY, 0.18, 0.82); + const sideColor = actor.unitId === 'rebel-leader' ? 0xd46a4c : profile.color; + const token = this.add.container(x, y); + const base = this.add.rectangle(0, 0, 46, 34, 0x15100b, 0.86); + base.setStrokeStyle(2, sideColor, 0.76); + token.add(base); + + if (this.textures.exists(profile.unitTextureKey)) { + const sprite = this.add.sprite(0, -8, profile.unitTextureKey, this.storyUnitFrameIndex(actor.direction ?? 'south')); + sprite.setDisplaySize(42, 42); + token.add(sprite); + } else { + const fallback = this.add.rectangle(0, -8, 28, 28, sideColor, 0.9); + fallback.setStrokeStyle(1, 0x05070a, 0.82); + token.add(fallback); + } + + const label = this.add.text(0, 11, actor.label ?? profile.name, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '10px', + color: '#f2e3bf', + stroke: '#05070a', + strokeThickness: 2 + }); + label.setOrigin(0.5, 0); + token.add(label); + layer.add(token); + }); } private renderBriefingCard(layer: Phaser.GameObjects.Container, cutscene: StoryCutscene, x: number, y: number, width: number, height: number) { @@ -512,13 +714,18 @@ export class StoryScene extends Phaser.Scene { 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, { + const labelX = marker.side === 'objective' ? x - 8 : x + 14; + const labelY = marker.side === 'objective' ? y - 30 : y - 11; + const label = this.add.text(labelX, labelY, marker.label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#f2e3bf', stroke: '#05070a', strokeThickness: 3 }); + if (marker.side === 'objective') { + label.setOrigin(0.5, 0); + } layer.add([halo, dot, label]); } @@ -587,23 +794,51 @@ export class StoryScene extends Phaser.Scene { return; } - const startX = 82; - const y = 390; - rewards.slice(0, 4).forEach((reward, index) => { + const stage = this.cutsceneStageBounds(); + const startX = stage.x + 28; + const gap = 12; + const maxItems = Math.min(4, rewards.length); + const itemW = Math.floor((stage.width - 56 - gap * (maxItems - 1)) / maxItems); + const y = stage.y + stage.height - 44; + rewards.slice(0, maxItems).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); + const x = startX + index * (itemW + gap); + const bg = this.add.rectangle(x, y, itemW, 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' + fontStyle: '700', + wordWrap: { width: itemW - 26, useAdvancedWrap: true } }); layer.add([bg, text]); }); } + private cutsceneStageBounds(): CutsceneStageBounds { + return { + x: 58, + y: 98, + width: this.scale.width - 116, + height: 348 + }; + } + + private actorPortraitTextureKey(actor: StoryCutsceneActor, index: number) { + const profile = storyCutsceneActorProfileFor(actor.unitId); + if (!profile) { + return undefined; + } + + const entries = portraitAssetEntriesForKey(profile.portraitKey).filter((entry) => this.textures.exists(entry.textureKey)); + if (entries.length === 0) { + return undefined; + } + + return entries[(this.hashString(`${actor.unitId}-${index}`) % entries.length)].textureKey; + } + private storyUnitFrameIndex(direction: UnitDirection, frame = 0) { return storyUnitFrameRows[direction] * unitBaseFramesPerDirection + frame; }