Rework first battle story as unit cutscenes

This commit is contained in:
2026-07-04 00:02:16 +09:00
parent 42a596bd8b
commit 8b81d16cf1
4 changed files with 588 additions and 9 deletions

View File

@@ -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<string, unknown>;
};
const sceneShadeDepth = 1;
const cutsceneDepth = 8;
const dialogDepth = 20;
const storyUnitFrameRows: Record<UnitDirection, number> = {
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);