Rework first battle story as unit cutscenes
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user