Rework story cutscenes as tactical boards

This commit is contained in:
2026-07-04 00:15:53 +09:00
parent 8b81d16cf1
commit 5e75fd1e8b

View File

@@ -38,6 +38,15 @@ const storyUnitFrameRows: Record<UnitDirection, number> = {
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;
}