Files
heros_web/src/game/scenes/CityStayScene.ts

1882 lines
64 KiB
TypeScript

import Phaser from 'phaser';
import { soundDirector } from '../audio/SoundDirector';
import {
cityStayExplorationProfiles,
getCityStayExplorationProfile,
type CityStayExplorationActor,
type CityStayExplorationProfile,
type CityStayExplorationTargetKind
} from '../data/cityStayExploration';
import {
cityStayDefinitions,
findCityStayAfterBattle,
type CityEquipmentOffer,
type CityResonanceDialogueChoice,
type CityStayDefinition,
type CityStayId
} from '../data/cityStays';
import type { BattleScenarioId } from '../data/battles';
import {
equipmentSlotLabels,
getItem,
itemInventoryLabel
} from '../data/battleItems';
import {
ensureUnitAnimations,
loadUnitBaseSheets,
releaseUnitBaseSheetTextures,
type UnitDirection
} from '../data/unitAssets';
import { ExplorationInputController } from '../input/ExplorationInputController';
import {
chooseCityStayResonance,
collectCityStayInformation,
purchaseCityStayEquipment
} from '../state/cityStayActions';
import {
getCampaignState,
setActiveCityStayId,
type CampaignState
} from '../state/campaignState';
import { startGameScene } from './lazyScenes';
const sceneWidth = 1920;
const sceneHeight = 1080;
const mapRight = 1495;
const movementBounds = new Phaser.Geom.Rectangle(42, 108, 1410, 814);
const playerSpeed = 300;
const playerCollisionRadius = 25;
const interactionRadius = 122;
const promptRadius = 164;
const characterDisplaySize = 104;
const inputCarryoverGuardMs = 320;
type CityStaySceneData = {
cityStayId?: CityStayId;
};
type CityActorView = {
definition: CityStayExplorationActor;
sprite: Phaser.GameObjects.Sprite;
shadow: Phaser.GameObjects.Ellipse;
nameplate: Phaser.GameObjects.Text;
marker: Phaser.GameObjects.Text;
};
type CityInteractionTarget =
| {
kind: Exclude<CityStayExplorationTargetKind, 'exit'>;
id: string;
name: string;
x: number;
y: number;
}
| {
kind: 'exit';
id: 'camp-exit';
name: string;
x: number;
y: number;
};
type CityDialogueLine = {
speaker: string;
text: string;
textureKey?: string;
};
type DialogueState = {
lines: CityDialogueLine[];
lineIndex: number;
onComplete?: () => void;
sourceTargetId?: string;
};
type ShopOfferView = {
offerId: string;
button: Phaser.GameObjects.Rectangle;
interactive: boolean;
};
type ChoiceView = {
choiceId: string;
button: Phaser.GameObjects.Rectangle;
interactive: boolean;
};
const cityFallback = cityStayDefinitions[0];
export class CityStayScene extends Phaser.Scene {
private cityStay: CityStayDefinition = cityFallback;
private profile: CityStayExplorationProfile = cityStayExplorationProfiles[cityFallback.id];
private campaign?: CampaignState;
private player?: Phaser.GameObjects.Sprite;
private playerShadow?: Phaser.GameObjects.Ellipse;
private playerNameplate?: Phaser.GameObjects.Text;
private playerDirection: UnitDirection = 'north';
private playerMoving = false;
private actors = new Map<string, CityActorView>();
private blockers: Phaser.Geom.Rectangle[] = [];
private promptBackground?: Phaser.GameObjects.Rectangle;
private promptText?: Phaser.GameObjects.Text;
private progressText?: Phaser.GameObjects.Text;
private informationStatus?: Phaser.GameObjects.Text;
private dialogueStatus?: Phaser.GameObjects.Text;
private informationCard?: Phaser.GameObjects.Rectangle;
private dialogueCard?: Phaser.GameObjects.Rectangle;
private dialoguePanel?: Phaser.GameObjects.Container;
private dialogueAvatar?: Phaser.GameObjects.Sprite;
private dialogueNameText?: Phaser.GameObjects.Text;
private dialogueBodyText?: Phaser.GameObjects.Text;
private dialogueProgressText?: Phaser.GameObjects.Text;
private dialogueState?: DialogueState;
private shopPanel?: Phaser.GameObjects.Container;
private shopOfferViews: ShopOfferView[] = [];
private shopNotice = '';
private choicePanel?: Phaser.GameObjects.Container;
private choiceViews: ChoiceView[] = [];
private loadingOverlay?: Phaser.GameObjects.Container;
private transitionOverlay?: Phaser.GameObjects.Container;
private completionToast?: Phaser.GameObjects.Container;
private explorationInput?: ExplorationInputController;
private moveTarget?: Phaser.Math.Vector2;
private ready = false;
private navigationPending = false;
private inputReadyAt = Number.POSITIVE_INFINITY;
private stepIndex = 0;
private lastStepAt = 0;
private lastNotice = '';
constructor() {
super('CityStayScene');
}
init(data?: CityStaySceneData) {
const campaign = getCampaignState();
const requested = data?.cityStayId
? cityStayDefinitions.find((definition) => definition.id === data.cityStayId)
: undefined;
const fromCampaign = campaign.latestBattleId
? findCityStayAfterBattle(campaign.latestBattleId as BattleScenarioId)
: undefined;
this.cityStay = requested && (!fromCampaign || requested.id === fromCampaign.id)
? requested
: fromCampaign ?? cityFallback;
this.profile = getCityStayExplorationProfile(this.cityStay.id);
}
create() {
this.resetRuntimeState();
this.markCityStayActive();
this.drawCity();
this.drawHud();
this.createLoadingOverlay();
this.setupInput();
soundDirector.playSoundscape({
musicKey: this.profile.musicKey,
ambienceKey: this.profile.ambienceKey,
musicVolume: 0.68,
ambienceVolume: 0.1
});
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
this.ready = false;
this.navigationPending = false;
this.moveTarget = undefined;
this.explorationInput?.destroy();
this.explorationInput = undefined;
this.dialogueState = undefined;
releaseUnitBaseSheetTextures(this);
});
const textureKeys = this.characterTextureKeys();
loadUnitBaseSheets(this, textureKeys, () => {
if (!this.scene.isActive()) {
return;
}
ensureUnitAnimations(this, textureKeys);
this.createActors();
this.createDialoguePanel();
this.loadingOverlay?.destroy();
this.loadingOverlay = undefined;
this.ready = true;
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
this.refreshProgressHud();
this.refreshActorMarkers();
this.refreshInteractionPrompt();
});
}
update(time: number, delta: number) {
if (!this.ready || this.navigationPending || time < this.inputReadyAt) {
this.explorationInput?.discardInteractionRequest();
return;
}
if (this.shopPanel || this.choicePanel) {
this.stopPlayerMovement();
this.explorationInput?.discardInteractionRequest();
return;
}
if (this.dialogueState) {
this.stopPlayerMovement();
if (this.consumeInteractionRequest()) {
this.advanceDialogue();
}
return;
}
if (this.consumeInteractionRequest()) {
this.tryInteract();
return;
}
this.updateMovement(time, delta);
this.refreshInteractionPrompt();
}
getDebugState() {
const campaign = getCampaignState();
const playerPosition = this.player
? { x: this.player.x, y: this.player.y }
: null;
const nearest = this.nearestInteractionTarget(promptRadius);
const informationComplete = this.informationComplete(campaign);
const dialogueComplete = this.dialogueComplete(campaign);
return {
scene: this.scene.key,
ready: this.ready,
cityStayId: this.cityStay.id,
cityName: this.cityStay.city.name,
viewport: { width: this.scale.width, height: this.scale.height },
activeCityStayId: campaign.activeCityStayId ?? null,
player: playerPosition
? {
...playerPosition,
direction: this.playerDirection,
moving: this.playerMoving,
bounds: this.player ? this.boundsSnapshot(this.player.getBounds()) : null
}
: null,
movement: {
speed: playerSpeed,
target: this.moveTarget ? { x: this.moveTarget.x, y: this.moveTarget.y } : null,
bounds: this.boundsSnapshot(movementBounds),
collisionRadius: playerCollisionRadius
},
controls: {
movement: ['WASD', '방향키'],
interact: ['E', 'Space', 'Enter'],
pointerMove: true
},
interaction: {
radius: interactionRadius,
promptRadius,
targetId: nearest?.id ?? null,
targetKind: nearest?.kind ?? null,
canInteract: Boolean(nearest && this.distanceTo(nearest.x, nearest.y) <= interactionRadius),
promptVisible: this.promptBackground?.visible ?? false,
promptBounds: this.promptBackground?.visible
? this.boundsSnapshot(this.promptBackground.getBounds())
: null
},
progress: {
completed: Number(informationComplete) + Number(dialogueComplete),
total: 2,
informationComplete,
dialogueComplete,
marketOptional: true,
exitUnlocked: true
},
actors: Array.from(this.actors.values()).map(({ definition, sprite }) => ({
id: definition.id,
kind: definition.kind,
name: definition.name,
role: definition.role,
textureKey: definition.textureKey,
x: sprite.x,
y: sprite.y,
distance: playerPosition
? Phaser.Math.Distance.Between(playerPosition.x, playerPosition.y, sprite.x, sprite.y)
: null,
completed: definition.kind === 'information'
? informationComplete
: definition.kind === 'dialogue'
? dialogueComplete
: false,
bounds: this.boundsSnapshot(sprite.getBounds())
})),
exit: {
...this.profile.exit,
distance: playerPosition
? Phaser.Math.Distance.Between(
playerPosition.x,
playerPosition.y,
this.profile.exit.x,
this.profile.exit.y
)
: null
},
dialogue: this.dialogueState
? {
active: true,
speaker: this.dialogueState.lines[this.dialogueState.lineIndex]?.speaker ?? '',
lineIndex: this.dialogueState.lineIndex,
totalLines: this.dialogueState.lines.length,
sourceTargetId: this.dialogueState.sourceTargetId ?? null,
bounds: this.dialoguePanel?.visible
? this.boundsSnapshot(this.dialoguePanel.getBounds())
: null
}
: {
active: false,
speaker: null,
lineIndex: null,
totalLines: 0,
sourceTargetId: null,
bounds: null
},
shop: {
open: Boolean(this.shopPanel),
gold: campaign.gold,
notice: this.shopNotice,
panelBounds: this.shopPanel ? this.boundsSnapshot(this.shopPanel.getBounds()) : null,
offers: this.cityStay.equipmentOffers.map((offer) => {
const item = getItem(offer.itemId);
const view = this.shopOfferViews.find((candidate) => candidate.offerId === offer.id);
return {
id: offer.id,
itemId: item.id,
itemName: item.name,
price: offer.price,
owned: campaign.inventory[itemInventoryLabel(item.id)] ?? 0,
canBuy: campaign.gold >= offer.price,
interactive: Boolean(view?.interactive),
buttonBounds: view ? this.boundsSnapshot(view.button.getBounds()) : null
};
})
},
choice: {
open: Boolean(this.choicePanel),
panelBounds: this.choicePanel ? this.boundsSnapshot(this.choicePanel.getBounds()) : null,
choices: this.cityStay.dialogue.choices.map((choice) => {
const view = this.choiceViews.find((candidate) => candidate.choiceId === choice.id);
return {
id: choice.id,
label: choice.label,
rewardExp: this.cityStay.dialogue.rewardExp + choice.rewardExp,
interactive: Boolean(view?.interactive),
bounds: view ? this.boundsSnapshot(view.button.getBounds()) : null
};
})
},
campaign: {
gold: campaign.gold,
inventory: { ...campaign.inventory },
completedCampVisits: [...campaign.completedCampVisits],
completedCampDialogues: [...campaign.completedCampDialogues],
campDialogueChoiceIds: { ...campaign.campDialogueChoiceIds }
},
blockers: this.blockers.map((blocker) => this.boundsSnapshot(blocker)),
navigationPending: this.navigationPending,
lastNotice: this.lastNotice,
requiredTexturesReady: this.characterTextureKeys().every((key) => this.textures.exists(key))
};
}
debugTeleportTo(targetIdOrKind: string) {
if (!this.player || !this.ready || this.navigationPending || this.modalOpen()) {
return false;
}
const target = this.interactionTargetByIdOrKind(targetIdOrKind);
if (!target) {
return false;
}
const candidate = this.safeApproachPosition(target.x, target.y);
this.setPlayerPosition(candidate.x, candidate.y);
this.moveTarget = undefined;
this.refreshInteractionPrompt();
return true;
}
debugInteractWith(targetIdOrKind: string) {
if (!this.debugTeleportTo(targetIdOrKind)) {
return false;
}
const target = this.interactionTargetByIdOrKind(targetIdOrKind);
if (!target) {
return false;
}
this.interactWithTarget(target);
return true;
}
private resetRuntimeState() {
this.campaign = getCampaignState();
this.player = undefined;
this.playerShadow = undefined;
this.playerNameplate = undefined;
this.playerDirection = this.profile.playerStart.direction;
this.playerMoving = false;
this.actors.clear();
this.blockers = [];
this.dialogueState = undefined;
this.shopPanel = undefined;
this.shopOfferViews = [];
this.shopNotice = '';
this.choicePanel = undefined;
this.choiceViews = [];
this.moveTarget = undefined;
this.ready = false;
this.navigationPending = false;
this.inputReadyAt = Number.POSITIVE_INFINITY;
this.explorationInput = undefined;
this.stepIndex = 0;
this.lastStepAt = 0;
this.lastNotice = '';
}
private markCityStayActive() {
this.campaign = setActiveCityStayId(this.cityStay.id);
}
private characterTextureKeys() {
return [
...new Set([
'unit-liu-bei',
...this.profile.actors.map((actor) => actor.textureKey)
])
];
}
private drawCity() {
const graphics = this.add.graphics().setDepth(-100);
this.cameras.main.setBackgroundColor(`#${this.profile.groundColor.toString(16).padStart(6, '0')}`);
graphics.fillStyle(this.profile.groundColor, 1);
graphics.fillRect(0, 0, sceneWidth, sceneHeight);
graphics.fillStyle(this.profile.grassColor, 1);
graphics.fillRect(0, 92, mapRight, 868);
this.drawGroundTexture(graphics);
this.drawRoads(graphics);
this.profile.buildings.forEach((building) => {
this.drawBuilding(graphics, building);
this.addBlocker(building.x, building.y, building.width, building.height);
});
this.drawCityDetails();
this.drawCampGate();
this.add.rectangle(mapRight, 0, sceneWidth - mapRight, sceneHeight, 0x121720, 0.97)
.setOrigin(0)
.setDepth(1400);
this.add.rectangle(mapRight, 0, 3, sceneHeight, this.profile.accentColor, 0.78)
.setOrigin(0)
.setDepth(1401);
this.add.rectangle(0, 0, sceneWidth, 92, 0x111720, 0.97)
.setOrigin(0)
.setDepth(1400);
this.add.rectangle(0, 90, sceneWidth, 2, this.profile.accentColor, 0.72)
.setOrigin(0)
.setDepth(1401);
this.add.rectangle(0, 958, mapRight, 122, 0x10151c, 0.95)
.setOrigin(0)
.setDepth(1400);
this.add.rectangle(0, 956, mapRight, 2, this.profile.accentColor, 0.58)
.setOrigin(0)
.setDepth(1401);
}
private drawGroundTexture(graphics: Phaser.GameObjects.Graphics) {
for (let index = 0; index < 150; index += 1) {
const x = 25 + ((index * 83) % 1435);
const y = 116 + ((index * 137) % 815);
const tone = index % 3 === 0
? this.profile.groundColor
: index % 3 === 1
? this.profile.grassColor
: this.profile.roadHighlightColor;
graphics.fillStyle(tone, index % 3 === 2 ? 0.16 : 0.3);
graphics.fillCircle(x, y, index % 4 === 0 ? 3 : 2);
}
}
private drawRoads(graphics: Phaser.GameObjects.Graphics) {
graphics.fillStyle(this.profile.roadColor, 1);
graphics.fillRoundedRect(650, 92, 255, 868, 42);
graphics.fillRoundedRect(70, 525, 1340, 190, 55);
graphics.fillRoundedRect(865, 390, 390, 190, 48);
graphics.fillStyle(this.profile.roadHighlightColor, 0.38);
graphics.fillRoundedRect(686, 92, 56, 868, 28);
graphics.fillRoundedRect(72, 566, 1338, 38, 18);
graphics.fillStyle(0x775f43, 0.42);
for (let y = 126; y < 930; y += 64) {
graphics.fillEllipse(804 + (y % 3) * 9, y, 44, 13);
}
for (let x = 110; x < 1390; x += 76) {
graphics.fillEllipse(x, 655 + (x % 4) * 5, 38, 12);
}
}
private drawBuilding(
graphics: Phaser.GameObjects.Graphics,
building: CityStayExplorationProfile['buildings'][number]
) {
const { x, y, width, height, wallColor, roofColor, label } = building;
graphics.fillStyle(0x15120f, 0.25);
graphics.fillRoundedRect(x + 16, y + 18, width, height, 14);
graphics.fillStyle(wallColor, 1);
graphics.fillRoundedRect(x, y + 58, width, height - 58, 10);
graphics.lineStyle(3, 0x392a22, 0.82);
graphics.strokeRoundedRect(x, y + 58, width, height - 58, 10);
graphics.fillStyle(roofColor, 1);
graphics.fillPoints([
new Phaser.Geom.Point(x - 24, y + 72),
new Phaser.Geom.Point(x + width / 2, y),
new Phaser.Geom.Point(x + width + 24, y + 72),
new Phaser.Geom.Point(x + width - 3, y + 100),
new Phaser.Geom.Point(x + 3, y + 100)
], true);
graphics.lineStyle(5, 0x211b19, 0.74);
graphics.lineBetween(x - 22, y + 72, x + width + 22, y + 72);
graphics.fillStyle(0x34271f, 1);
graphics.fillRect(x + width / 2 - 30, y + height - 72, 60, 72);
graphics.fillStyle(0x16110e, 0.8);
graphics.fillRect(x + 56, y + 132, 62, 45);
graphics.fillRect(x + width - 118, y + 132, 62, 45);
this.add.text(x + width / 2, y + 104, label, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '23px',
color: '#2a2019',
fontStyle: 'bold',
backgroundColor: '#dfc792',
padding: { left: 13, right: 13, top: 6, bottom: 6 }
}).setOrigin(0.5).setDepth(42);
}
private drawCityDetails() {
[
[585, 180, 0.9],
[935, 210, 0.85],
[570, 350, 0.78],
[930, 345, 0.82],
[575, 800, 0.9],
[950, 840, 0.82],
[95, 820, 0.82],
[250, 875, 0.75]
].forEach(([x, y, scale]) => this.drawTree(x, y, scale));
const stall = this.add.graphics().setDepth(32);
stall.fillStyle(0x6a4330, 1);
stall.fillRect(1280, 442, 150, 78);
stall.fillStyle(this.profile.roadHighlightColor, 1);
stall.fillTriangle(1260, 445, 1450, 445, 1355, 394);
stall.fillStyle(this.profile.accentColor, 0.78);
stall.fillRect(1278, 430, 154, 18);
this.addBlocker(1270, 414, 170, 112);
const plaza = this.add.graphics().setDepth(26);
plaza.fillStyle(0x4f5d68, 0.9);
plaza.fillCircle(780, 405, 42);
plaza.fillStyle(0x87a0ad, 0.72);
plaza.fillCircle(780, 405, 28);
plaza.fillStyle(0xc9d6da, 0.72);
plaza.fillCircle(780, 398, 11);
this.addBlocker(750, 390, 60, 38);
this.add.text(780, 474, this.profile.landmarkLabel, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '22px',
color: '#f3dfaa',
fontStyle: 'bold',
backgroundColor: '#2c2025cc',
padding: { left: 12, right: 12, top: 5, bottom: 5 }
}).setOrigin(0.5).setDepth(45);
}
private drawTree(x: number, y: number, scale: number) {
const graphics = this.add.graphics().setDepth(20 + y / 100);
graphics.fillStyle(0x3f2b20, 1);
graphics.fillRoundedRect(x - 8 * scale, y, 16 * scale, 58 * scale, 5);
graphics.fillStyle(0x25432c, 1);
graphics.fillCircle(x - 21 * scale, y - 4 * scale, 37 * scale);
graphics.fillCircle(x + 22 * scale, y - 9 * scale, 40 * scale);
graphics.fillStyle(0x3c6740, 1);
graphics.fillCircle(x, y - 33 * scale, 43 * scale);
graphics.fillStyle(0x648057, 0.7);
graphics.fillCircle(x - 9 * scale, y - 44 * scale, 20 * scale);
this.addBlocker(x - 16 * scale, y + 12 * scale, 32 * scale, 43 * scale);
}
private drawCampGate() {
const gate = this.add.graphics().setDepth(36);
gate.fillStyle(0x47362a, 1);
gate.fillRect(695, 874, 170, 48);
gate.fillStyle(0x252018, 1);
gate.fillRect(722, 858, 18, 100);
gate.fillRect(820, 858, 18, 100);
gate.fillStyle(this.profile.accentColor, 0.92);
gate.fillRect(700, 920, 160, 5);
this.add.text(780, 898, '군영 출구', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '22px',
color: '#f3dfaa',
fontStyle: 'bold'
}).setOrigin(0.5).setDepth(40);
}
private addBlocker(x: number, y: number, width: number, height: number) {
this.blockers.push(new Phaser.Geom.Rectangle(x, y, width, height));
}
private drawHud() {
this.add.text(48, 27, this.profile.heading, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '33px',
color: '#f4e3b5',
fontStyle: 'bold'
}).setDepth(1500);
this.add.text(495, 35, this.cityStay.city.region, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '19px',
color: '#b4c0cb'
}).setDepth(1500);
this.add.text(1536, 43, `${this.cityStay.city.name} 체류`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '31px',
color: '#f4e3b5',
fontStyle: 'bold'
}).setDepth(1500);
this.add.text(1538, 91, this.profile.subtitle, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#9fabb8',
wordWrap: { width: 330, useAdvancedWrap: true }
}).setDepth(1500);
this.informationCard = this.drawObjectiveCard(
168,
'정보 수집',
this.cityStay.information.location,
'관청 관리인에게 다가가세요.'
);
this.informationStatus = this.add.text(1550, 187, '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#d8b15f',
fontStyle: 'bold'
}).setDepth(1502);
this.drawObjectiveCard(
322,
'시장과 대장간',
`${this.cityStay.equipmentOffers.length}종 일반 장비`,
'필요한 장비를 원하는 만큼 구매합니다.'
);
this.add.text(1550, 341, '◇ 선택 활동', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#d8b15f',
fontStyle: 'bold'
}).setDepth(1502);
this.dialogueCard = this.drawObjectiveCard(
476,
'동료와 대화',
this.cityDialogueUnitNames(),
'선택에 따라 공명이 상승합니다.'
);
this.dialogueStatus = this.add.text(1550, 495, '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#d8b15f',
fontStyle: 'bold'
}).setDepth(1502);
this.drawObjectiveCard(
630,
'군영으로 복귀',
'남쪽 군영 출구',
'체류 활동은 선택이며 언제든 돌아갈 수 있습니다.'
);
this.add.text(1550, 649, '◆ 항상 가능', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#a8d58e',
fontStyle: 'bold'
}).setDepth(1502);
this.progressText = this.add.text(1538, 811, '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px',
color: '#cbd3dc',
lineSpacing: 8,
wordWrap: { width: 330 }
}).setDepth(1502);
this.add.text(46, 980, '이동', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#d8b15f',
fontStyle: 'bold'
}).setDepth(1502);
this.add.text(46, 1012, 'WASD / 방향키 · 바닥 클릭', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '19px',
color: '#e3e7eb'
}).setDepth(1502);
this.add.text(445, 980, '상호작용', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#d8b15f',
fontStyle: 'bold'
}).setDepth(1502);
this.add.text(445, 1012, '가까이에서 E / Space / Enter', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '19px',
color: '#e3e7eb'
}).setDepth(1502);
this.add.text(1250, 1012, 'ESC · 창 닫기', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#9fabb8'
}).setOrigin(1, 0).setDepth(1502);
this.promptBackground = this.add.rectangle(880, 1018, 570, 58, 0x2a2119, 0.98)
.setStrokeStyle(2, this.profile.accentColor, 0.92)
.setDepth(1510)
.setVisible(false);
this.promptText = this.add.text(880, 1018, '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '21px',
color: '#f6e3af',
fontStyle: 'bold'
}).setOrigin(0.5).setDepth(1511).setVisible(false);
}
private drawObjectiveCard(y: number, title: string, location: string, help: string) {
const background = this.add.rectangle(1530, y, 350, 132, 0x1b222d, 0.94)
.setOrigin(0)
.setStrokeStyle(2, 0x3d4858, 1)
.setDepth(1500);
this.add.text(1550, y + 48, title, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '22px',
color: '#e8dfca',
fontStyle: 'bold'
}).setDepth(1501);
this.add.text(1550, y + 82, location, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '15px',
color: '#a4afba'
}).setDepth(1501);
this.add.text(1550, y + 105, help, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '12px',
color: '#788491',
wordWrap: { width: 318 }
}).setDepth(1501);
return background;
}
private createLoadingOverlay() {
const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x080b0f, 0.74).setOrigin(0);
const panel = this.add.rectangle(sceneWidth / 2, sceneHeight / 2, 520, 126, 0x151b24, 0.98)
.setStrokeStyle(2, this.profile.accentColor, 0.82);
const text = this.add.text(sceneWidth / 2, sceneHeight / 2, `${this.cityStay.city.name}의 사람들을 불러오는 중...`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '24px',
color: '#f3dfaa'
}).setOrigin(0.5);
this.loadingOverlay = this.add.container(0, 0, [shade, panel, text]).setDepth(4000);
}
private createActors() {
const start = this.profile.playerStart;
this.playerShadow = this.add.ellipse(start.x, start.y + 20, 66, 25, 0x07100a, 0.45).setDepth(800);
this.player = this.createCharacterSprite('unit-liu-bei', start.x, start.y, characterDisplaySize + 8, start.direction);
this.player.setDepth(100 + start.y);
this.playerNameplate = this.add.text(start.x, start.y + 66, '유비', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#f5ead0',
backgroundColor: '#14181ccc',
padding: { left: 8, right: 8, top: 4, bottom: 4 }
}).setOrigin(0.5).setDepth(1200);
this.profile.actors.forEach((definition) => {
const shadow = this.add.ellipse(definition.x, definition.y + 20, 62, 23, 0x07100a, 0.4)
.setDepth(100 + definition.y);
const sprite = this.createCharacterSprite(
definition.textureKey,
definition.x,
definition.y,
characterDisplaySize,
definition.direction
);
sprite.setDepth(110 + definition.y);
const nameplate = this.add.text(definition.x, definition.y + 63, definition.name, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#f5ead0',
backgroundColor: '#14181ccc',
padding: { left: 8, right: 8, top: 4, bottom: 4 }
}).setOrigin(0.5).setDepth(1200);
const marker = this.add.text(definition.x, definition.y - 72, this.markerText(definition.kind), {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: definition.kind === 'market' ? '22px' : '31px',
color: '#2a2013',
fontStyle: 'bold',
backgroundColor: '#e5bd68',
padding: { left: 10, right: 10, top: 3, bottom: 3 }
}).setOrigin(0.5).setDepth(1201);
this.tweens.add({
targets: marker,
y: marker.y - 8,
duration: 720,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut'
});
this.actors.set(definition.id, { definition, sprite, shadow, nameplate, marker });
});
const exit = this.profile.exit;
const exitMarker = this.add.text(exit.x, exit.y - 58, '↩', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '32px',
color: '#f0d79b',
fontStyle: 'bold',
backgroundColor: '#3a3024dd',
padding: { left: 10, right: 10, top: 4, bottom: 4 }
}).setOrigin(0.5).setDepth(1201);
this.tweens.add({
targets: exitMarker,
alpha: { from: 0.68, to: 1 },
duration: 880,
yoyo: true,
repeat: -1
});
}
private createCharacterSprite(
textureKey: string,
x: number,
y: number,
size: number,
direction: UnitDirection
) {
const resolvedTextureKey = this.textures.exists(textureKey) ? textureKey : '__DEFAULT';
const sprite = this.add.sprite(x, y, resolvedTextureKey, 0).setDisplaySize(size, size);
if (this.textures.exists(textureKey)) {
sprite.play(`${textureKey}-idle-${direction}`);
}
return sprite;
}
private createDialoguePanel() {
const x = 72;
const y = 710;
const width = 1776;
const height = 304;
const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x06080b, 0.45)
.setOrigin(0)
.setInteractive();
const background = this.add.rectangle(x, y, width, height, 0x141a23, 0.985)
.setOrigin(0)
.setStrokeStyle(3, this.profile.accentColor, 0.94);
const inner = this.add.rectangle(x + 16, y + 16, width - 32, height - 32, 0x1d2531, 0.84)
.setOrigin(0)
.setStrokeStyle(1, 0x546174, 0.72);
const avatarFrame = this.add.rectangle(x + 105, y + 152, 178, 230, 0x0e131a, 0.92)
.setStrokeStyle(2, 0x9e8350, 0.9);
this.dialogueAvatar = this.add.sprite(x + 105, y + 155, 'unit-liu-bei', 0)
.setDisplaySize(186, 186);
this.dialogueNameText = this.add.text(x + 222, y + 42, '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '29px',
color: '#f1d691',
fontStyle: 'bold'
});
this.dialogueBodyText = this.add.text(x + 222, y + 101, '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '27px',
color: '#f0ede5',
lineSpacing: 11,
wordWrap: { width: 1440, useAdvancedWrap: true }
});
this.dialogueProgressText = this.add.text(x + width - 34, y + height - 33, '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px',
color: '#aeb8c4'
}).setOrigin(1, 0.5);
this.dialoguePanel = this.add.container(0, 0, [
shade,
background,
inner,
avatarFrame,
this.dialogueAvatar,
this.dialogueNameText,
this.dialogueBodyText,
this.dialogueProgressText
]).setDepth(3000).setVisible(false);
}
private setupInput() {
this.explorationInput = new ExplorationInputController(this)
.bindKey(
Phaser.Input.Keyboard.KeyCodes.ESC,
() => this.handleEscape()
)
.bindKey(
Phaser.Input.Keyboard.KeyCodes.ONE,
() => this.chooseDialogueByIndex(0)
)
.bindKey(
Phaser.Input.Keyboard.KeyCodes.TWO,
() => this.chooseDialogueByIndex(1)
);
this.input.on(Phaser.Input.Events.POINTER_DOWN, (pointer: Phaser.Input.Pointer) => {
if (!this.ready || this.navigationPending || this.time.now < this.inputReadyAt) {
return;
}
if (this.shopPanel || this.choicePanel) {
return;
}
if (this.dialogueState) {
this.advanceDialogue();
return;
}
if (
pointer.worldX >= movementBounds.left &&
pointer.worldX <= movementBounds.right &&
pointer.worldY >= movementBounds.top &&
pointer.worldY <= movementBounds.bottom
) {
this.moveTarget = new Phaser.Math.Vector2(pointer.worldX, pointer.worldY);
}
});
}
private handleEscape() {
if (!this.ready || this.navigationPending || this.time.now < this.inputReadyAt) {
return;
}
if (this.shopPanel) {
this.closeShop();
return;
}
if (this.choicePanel) {
this.closeChoicePanel();
return;
}
if (this.dialogueState) {
this.cancelDialogue();
return;
}
this.showPromptMessage('군영으로 돌아가려면 남쪽 군영 출구로 이동하세요.');
}
private updateMovement(time: number, delta: number) {
if (!this.player) {
return;
}
const keyboardDirection = this.keyboardMovementDirection();
let movement = keyboardDirection;
if (movement.lengthSq() > 0) {
this.moveTarget = undefined;
} else if (this.moveTarget) {
const targetDelta = this.moveTarget.clone().subtract(new Phaser.Math.Vector2(this.player.x, this.player.y));
if (targetDelta.length() <= 7) {
this.moveTarget = undefined;
movement = new Phaser.Math.Vector2();
} else {
movement = targetDelta.normalize();
}
}
if (movement.lengthSq() <= 0) {
this.setPlayerMoving(false);
return;
}
movement.normalize();
this.playerDirection = this.directionForVector(movement);
const distance = playerSpeed * Math.min(delta, 50) / 1000;
const startX = this.player.x;
const startY = this.player.y;
const nextX = startX + movement.x * distance;
const nextY = startY + movement.y * distance;
let moved = false;
if (this.canPlayerOccupy(nextX, startY)) {
this.player.x = nextX;
moved = true;
}
if (this.canPlayerOccupy(this.player.x, nextY)) {
this.player.y = nextY;
moved = true;
}
if (!moved && this.moveTarget) {
this.moveTarget = undefined;
}
this.syncPlayerView();
this.setPlayerMoving(moved);
if (moved && time - this.lastStepAt >= 280) {
this.lastStepAt = time;
this.stepIndex += 1;
soundDirector.playMovementStep(false, this.stepIndex, {
terrain: this.profile.movementTerrain,
volume: 0.085,
rate: 1.02,
stopAfterMs: 620
});
}
}
private consumeInteractionRequest() {
return this.explorationInput?.consumeInteractionRequest() ?? false;
}
private keyboardMovementDirection() {
return (
this.explorationInput?.movementDirection() ??
new Phaser.Math.Vector2()
);
}
private directionForVector(vector: Phaser.Math.Vector2): UnitDirection {
if (Math.abs(vector.x) > Math.abs(vector.y)) {
return vector.x > 0 ? 'east' : 'west';
}
return vector.y > 0 ? 'south' : 'north';
}
private canPlayerOccupy(x: number, y: number) {
if (
x - playerCollisionRadius < movementBounds.left ||
x + playerCollisionRadius > movementBounds.right ||
y - playerCollisionRadius < movementBounds.top ||
y + playerCollisionRadius > movementBounds.bottom
) {
return false;
}
const feet = new Phaser.Geom.Rectangle(
x - playerCollisionRadius,
y + 18,
playerCollisionRadius * 2,
28
);
if (this.blockers.some((blocker) => Phaser.Geom.Intersects.RectangleToRectangle(feet, blocker))) {
return false;
}
return Array.from(this.actors.values()).every(({ sprite }) => (
Phaser.Math.Distance.Between(x, y, sprite.x, sprite.y) >= 48
));
}
private setPlayerMoving(moving: boolean) {
if (!this.player) {
return;
}
const animationKey = `unit-liu-bei-${moving ? 'walk' : 'idle'}-${this.playerDirection}`;
if (this.textures.exists('unit-liu-bei') && this.player.anims.currentAnim?.key !== animationKey) {
this.player.play(animationKey);
}
this.playerMoving = moving;
}
private stopPlayerMovement() {
this.moveTarget = undefined;
this.setPlayerMoving(false);
}
private syncPlayerView() {
if (!this.player) {
return;
}
this.playerShadow?.setPosition(this.player.x, this.player.y + 20);
this.playerShadow?.setDepth(90 + this.player.y);
this.player.setDepth(100 + this.player.y);
this.playerNameplate?.setPosition(this.player.x, this.player.y + 66);
}
private setPlayerPosition(x: number, y: number) {
if (!this.player) {
return;
}
this.player.setPosition(x, y);
this.syncPlayerView();
this.stopPlayerMovement();
}
private safeApproachPosition(targetX: number, targetY: number) {
const candidates = [
{ x: targetX, y: targetY + 88 },
{ x: targetX - 88, y: targetY },
{ x: targetX + 88, y: targetY },
{ x: targetX, y: targetY - 88 }
];
return candidates.find((candidate) => this.canPlayerOccupy(candidate.x, candidate.y)) ?? {
x: Phaser.Math.Clamp(targetX, movementBounds.left + 40, movementBounds.right - 40),
y: Phaser.Math.Clamp(targetY + 100, movementBounds.top + 40, movementBounds.bottom - 40)
};
}
private tryInteract() {
const target = this.nearestInteractionTarget(interactionRadius);
if (!target) {
this.showPromptMessage('조금 더 가까이 다가가세요.');
return;
}
this.interactWithTarget(target);
}
private interactWithTarget(target: CityInteractionTarget) {
this.stopPlayerMovement();
if (target.kind === 'exit') {
this.interactWithExit();
return;
}
const view = this.actors.get(target.id);
if (!view) {
return;
}
this.faceCharactersForConversation(view);
if (target.kind === 'information') {
this.interactWithInformation(view);
return;
}
if (target.kind === 'market') {
this.openShop();
return;
}
this.interactWithCompanion(view);
}
private interactWithInformation(view: CityActorView) {
const campaign = getCampaignState();
const information = this.cityStay.information;
if (this.informationComplete(campaign)) {
this.startDialogue([
{
speaker: view.definition.name,
textureKey: view.definition.textureKey,
text: `${information.title}은 이미 장부에 기록했습니다.`
},
...information.briefingLines.map((text) => ({
speaker: '확보한 정보',
textureKey: view.definition.textureKey,
text
}))
], undefined, view.definition.id);
return;
}
this.startDialogue([
{
speaker: view.definition.name,
textureKey: view.definition.textureKey,
text: information.description
},
{
speaker: '유비',
textureKey: 'unit-liu-bei',
text: '현지의 증언과 장부를 함께 대조해 다음 싸움에서 놓칠 길이 없도록 합시다.'
},
...information.briefingLines.map((text) => ({
speaker: view.definition.name,
textureKey: view.definition.textureKey,
text
}))
], () => this.completeInformation(), view.definition.id);
}
private completeInformation() {
const result = collectCityStayInformation(
this.cityStay.id,
this.cityStay.information.id
);
if (!result.ok) {
const message = result.reason === 'already-completed'
? '이미 확보한 정보입니다.'
: '정보를 장부에 기록하지 못했습니다. 저장 상태를 확인해 주세요.';
this.showCompletionToast(message, false);
this.refreshProgressHud();
return;
}
this.campaign = result.campaign;
this.lastNotice = `정보 확보 · ${result.information.title}`;
soundDirector.playEffect('exp-gain', { volume: 0.24, stopAfterMs: 700 });
this.showCompletionToast(`정보 확보 · ${this.cityStay.information.title}`);
this.refreshProgressHud();
this.refreshActorMarkers();
}
private interactWithCompanion(view: CityActorView) {
const campaign = getCampaignState();
const dialogue = this.cityStay.dialogue;
if (this.dialogueComplete(campaign)) {
const choiceId = campaign.campDialogueChoiceIds[dialogue.id];
const choice = dialogue.choices.find((candidate) => candidate.id === choiceId);
this.startDialogue([
{
speaker: view.definition.name,
textureKey: view.definition.textureKey,
text: choice?.response ?? '함께 나눈 결정은 이미 다음 출전을 위한 약속으로 남아 있습니다.'
},
{
speaker: '체류 기록',
text: choice ? `나의 결정 · ${choice.label}` : '공명 대화 완료'
}
], undefined, view.definition.id);
return;
}
const lines = dialogue.lines.map((line) => this.authoredDialogueLine(line, view.definition));
this.startDialogue(lines, () => this.openChoicePanel(), view.definition.id);
}
private authoredDialogueLine(line: string, companion: CityStayExplorationActor): CityDialogueLine {
const separator = line.indexOf(':');
const speaker = separator > 0 ? line.slice(0, separator).trim() : companion.name;
const text = separator > 0 ? line.slice(separator + 1).trim() : line;
return {
speaker,
text,
textureKey: speaker === '유비' ? 'unit-liu-bei' : companion.textureKey
};
}
private openChoicePanel() {
if (this.choicePanel || this.navigationPending) {
return;
}
this.stopPlayerMovement();
this.promptBackground?.setVisible(false);
this.promptText?.setVisible(false);
const dialogue = this.cityStay.dialogue;
const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x05070a, 0.56)
.setOrigin(0)
.setInteractive();
const panel = this.add.rectangle(110, 610, 1700, 360, 0x151c26, 0.99)
.setOrigin(0)
.setStrokeStyle(3, this.profile.accentColor, 0.94);
const title = this.add.text(150, 650, dialogue.title, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '31px',
color: '#f1d691',
fontStyle: 'bold'
});
const hint = this.add.text(150, 697, '유비의 답을 선택하세요. 선택은 저장되며 공명 경험치가 달라집니다.', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '19px',
color: '#aeb8c4'
});
const objects: Phaser.GameObjects.GameObject[] = [shade, panel, title, hint];
this.choiceViews = dialogue.choices.map((choice, index) => {
const x = 150 + index * 820;
const y = 755;
const width = 790;
const button = this.add.rectangle(x, y, width, 160, 0x1d2936, 0.98)
.setOrigin(0)
.setStrokeStyle(2, this.profile.accentColor, 0.76)
.setInteractive({ useHandCursor: true });
const number = this.add.text(x + 28, y + 24, `${index + 1}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '27px',
color: '#f1d691',
fontStyle: 'bold'
});
const label = this.add.text(x + 76, y + 24, choice.label, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '23px',
color: '#f0ede5',
fontStyle: 'bold',
wordWrap: { width: 670, useAdvancedWrap: true }
});
const reward = this.add.text(
x + 76,
y + 98,
`공명 +${dialogue.rewardExp + choice.rewardExp}`,
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px',
color: '#a8d58e',
fontStyle: 'bold'
}
);
button.on('pointerover', () => button.setFillStyle(0x2a3c4e, 0.99));
button.on('pointerout', () => button.setFillStyle(0x1d2936, 0.98));
button.on('pointerdown', () => this.chooseDialogue(choice));
objects.push(button, number, label, reward);
return { choiceId: choice.id, button, interactive: true };
});
const cancel = this.add.text(1740, 940, 'ESC · 나중에 결정', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#8f9aa7'
}).setOrigin(1, 0.5);
objects.push(cancel);
this.choicePanel = this.add.container(0, 0, objects).setDepth(3200);
}
private chooseDialogueByIndex(index: number) {
if (!this.choicePanel || this.navigationPending) {
return;
}
const choice = this.cityStay.dialogue.choices[index];
if (choice) {
this.chooseDialogue(choice);
}
}
private chooseDialogue(choice: CityResonanceDialogueChoice) {
const result = chooseCityStayResonance(this.cityStay.id, choice.id);
if (!result.ok) {
this.closeChoicePanel();
const message = result.reason === 'already-completed'
? '이미 완료한 공명 대화입니다.'
: result.reason === 'bond-unavailable'
? '두 동료의 공명 관계를 찾지 못했습니다.'
: '선택을 저장하지 못했습니다.';
this.showCompletionToast(message, false);
this.refreshProgressHud();
return;
}
this.campaign = result.campaign;
this.lastNotice = `공명 +${result.rewardExp} · ${result.choice.label}`;
this.closeChoicePanel();
soundDirector.playEffect('bond-resonance', { volume: 0.28, stopAfterMs: 1100 });
this.refreshProgressHud();
this.refreshActorMarkers();
this.startDialogue([
{
speaker: this.companionActor().name,
textureKey: this.companionActor().textureKey,
text: choice.response
},
{
speaker: '공명',
text: `${this.cityDialogueUnitNames()} · 공명 +${this.cityStay.dialogue.rewardExp + choice.rewardExp}`
}
], () => this.showCompletionToast('동료 공명 대화 완료'), this.companionActor().id);
}
private closeChoicePanel() {
this.choicePanel?.destroy();
this.choicePanel = undefined;
this.choiceViews = [];
this.refreshInteractionPrompt();
}
private openShop() {
if (this.shopPanel || this.navigationPending) {
return;
}
this.stopPlayerMovement();
this.shopNotice = '';
this.renderShopPanel();
}
private renderShopPanel() {
this.shopPanel?.destroy();
this.shopOfferViews = [];
const campaign = getCampaignState();
this.campaign = campaign;
const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x05070a, 0.62)
.setOrigin(0)
.setInteractive();
const panel = this.add.rectangle(180, 126, 1560, 828, 0x151c26, 0.995)
.setOrigin(0)
.setStrokeStyle(3, this.profile.accentColor, 0.94);
const title = this.add.text(230, 174, `${this.cityStay.city.name} 시장과 대장간`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '36px',
color: '#f1d691',
fontStyle: 'bold'
});
const gold = this.add.text(1685, 184, `군자금 ${campaign.gold}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '25px',
color: '#f3dfaa',
fontStyle: 'bold'
}).setOrigin(1, 0);
const guide = this.add.text(230, 225, '일반 장비는 여러 개 구매할 수 있으며, 구입 즉시 군영 장비 탭에서 사용할 수 있습니다.', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '19px',
color: '#aeb8c4'
});
const closeButton = this.add.rectangle(1625, 236, 120, 44, 0x263342, 0.98)
.setStrokeStyle(1, 0x738194, 0.8)
.setInteractive({ useHandCursor: true });
const closeLabel = this.add.text(1625, 236, '닫기 · ESC', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#dce2e8',
fontStyle: 'bold'
}).setOrigin(0.5);
closeButton.on('pointerdown', () => this.closeShop());
closeButton.on('pointerover', () => closeButton.setFillStyle(0x34485c, 0.99));
closeButton.on('pointerout', () => closeButton.setFillStyle(0x263342, 0.98));
const objects: Phaser.GameObjects.GameObject[] = [
shade,
panel,
title,
gold,
guide,
closeButton,
closeLabel
];
this.cityStay.equipmentOffers.forEach((offer, index) => {
const item = getItem(offer.itemId);
const owned = campaign.inventory[itemInventoryLabel(item.id)] ?? 0;
const canBuy = campaign.gold >= offer.price;
const rowX = 230;
const rowY = 300 + index * 180;
const row = this.add.rectangle(rowX, rowY, 1460, 146, canBuy ? 0x1b2734 : 0x131a22, 0.98)
.setOrigin(0)
.setStrokeStyle(2, canBuy ? 0x54708a : 0x3f4853, canBuy ? 0.76 : 0.45);
const slotBadge = this.add.circle(rowX + 68, rowY + 73, 42, 0x0e151d, 0.98)
.setStrokeStyle(2, this.profile.accentColor, 0.74);
const slotLabel = this.add.text(rowX + 68, rowY + 73, equipmentSlotLabels[item.slot].slice(0, 2), {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px',
color: '#f1d691',
fontStyle: 'bold'
}).setOrigin(0.5);
const name = this.add.text(rowX + 132, rowY + 24, `${item.name} · 보유 ${owned}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '25px',
color: canBuy ? '#f0ede5' : '#808a95',
fontStyle: 'bold'
});
const description = this.add.text(rowX + 132, rowY + 66, item.description, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px',
color: canBuy ? '#aeb8c4' : '#69737d',
wordWrap: { width: 850, useAdvancedWrap: true }
});
const bonus = this.add.text(rowX + 132, rowY + 104, this.itemBonusText(item), {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '16px',
color: canBuy ? '#9ebd98' : '#68736a'
});
const price = this.add.text(rowX + 1195, rowY + 43, `${offer.price}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '22px',
color: canBuy ? '#f1d691' : '#7f8994',
fontStyle: 'bold'
}).setOrigin(1, 0);
const button = this.add.rectangle(rowX + 1350, rowY + 73, 170, 64, canBuy ? 0x4a371d : 0x20262d, 0.99)
.setStrokeStyle(2, canBuy ? this.profile.accentColor : 0x56606a, canBuy ? 0.9 : 0.45);
const buttonLabel = this.add.text(rowX + 1350, rowY + 73, canBuy ? '구매' : '군자금 부족', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: canBuy ? '21px' : '16px',
color: canBuy ? '#fff2b8' : '#7f8994',
fontStyle: 'bold'
}).setOrigin(0.5);
if (canBuy) {
button.setInteractive({ useHandCursor: true });
button.on('pointerover', () => button.setFillStyle(0x654c25, 0.99));
button.on('pointerout', () => button.setFillStyle(0x4a371d, 0.99));
button.on('pointerdown', () => this.buyOffer(offer));
}
this.shopOfferViews.push({ offerId: offer.id, button, interactive: canBuy });
objects.push(
row,
slotBadge,
slotLabel,
name,
description,
bonus,
price,
button,
buttonLabel
);
});
const notice = this.add.text(230, 876, this.shopNotice || '상인에게 필요한 장비를 골라 보세요.', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '19px',
color: this.shopNotice ? '#a8d58e' : '#8995a2',
fontStyle: this.shopNotice ? 'bold' : 'normal'
});
objects.push(notice);
this.shopPanel = this.add.container(0, 0, objects).setDepth(3100);
}
private buyOffer(offer: CityEquipmentOffer) {
const result = purchaseCityStayEquipment(this.cityStay.id, offer.id);
const message = result.ok
? `${result.item.name} 구입 · 군자금 -${result.offer.price}`
: result.reason === 'insufficient-gold'
? '군자금이 부족합니다.'
: result.reason === 'restricted-item'
? '보물 장비는 일반 시장에서 거래할 수 없습니다.'
: '현재 시장에서는 살 수 없는 장비입니다.';
this.shopNotice = message;
this.lastNotice = message;
if (result.ok) {
this.campaign = result.campaign;
soundDirector.playEffect('reward-reveal', { volume: 0.22, stopAfterMs: 720 });
} else {
soundDirector.playEffect('objective-failure', { volume: 0.16, stopAfterMs: 620 });
}
this.renderShopPanel();
this.refreshProgressHud();
}
private closeShop() {
this.shopPanel?.destroy();
this.shopPanel = undefined;
this.shopOfferViews = [];
this.shopNotice = '';
this.refreshInteractionPrompt();
}
private itemBonusText(item: ReturnType<typeof getItem>) {
const bonuses = [
item.attackBonus ? `공격 +${item.attackBonus}` : '',
item.defenseBonus ? `방어 +${item.defenseBonus}` : '',
item.strategyBonus ? `책략 +${item.strategyBonus}` : ''
].filter(Boolean);
return bonuses.length > 0 ? bonuses.join(' · ') : item.effects[0] ?? '일반 장비';
}
private interactWithExit() {
const campaign = getCampaignState();
const pending = [
!this.informationComplete(campaign) ? '정보 수집' : '',
!this.dialogueComplete(campaign) ? '동료 대화' : ''
].filter(Boolean);
if (pending.length > 0) {
this.startDialogue([
{
speaker: '유비',
textureKey: 'unit-liu-bei',
text: `${pending.join('과 ')}이 아직 남아 있다. 체류 활동은 선택이니 필요하면 다음에 다시 들르자.`
},
{
speaker: '길잡이',
text: '군영으로 돌아갑니다.'
}
], () => this.returnToCamp(), 'camp-exit');
return;
}
this.returnToCamp();
}
private returnToCamp() {
if (this.navigationPending) {
return;
}
this.navigationPending = true;
this.stopPlayerMovement();
const previousActiveCityStayId = getCampaignState().activeCityStayId;
this.campaign = setActiveCityStayId();
this.showTransitionOverlay();
void startGameScene(this, 'CampScene').catch((error) => {
console.error('Failed to return from the city stay.', error);
this.campaign = setActiveCityStayId(previousActiveCityStayId ?? this.cityStay.id);
this.navigationPending = false;
this.transitionOverlay?.destroy();
this.transitionOverlay = undefined;
this.showCompletionToast('군영으로 돌아가지 못했습니다. 잠시 후 다시 시도하세요.', false);
this.refreshInteractionPrompt();
});
}
private showTransitionOverlay() {
const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x080b0f, 0.8).setOrigin(0);
const title = this.add.text(sceneWidth / 2, sceneHeight / 2 - 32, `${this.cityStay.city.name} 체류 기록`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '44px',
color: '#f3dda2',
fontStyle: 'bold'
}).setOrigin(0.5);
const subtitle = this.add.text(sceneWidth / 2, sceneHeight / 2 + 44, '확인한 내용과 구매한 장비를 가지고 군영으로 돌아갑니다.', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '24px',
color: '#d7dde4'
}).setOrigin(0.5);
this.transitionOverlay = this.add.container(0, 0, [shade, title, subtitle])
.setDepth(5000)
.setAlpha(0);
this.tweens.add({
targets: this.transitionOverlay,
alpha: 1,
duration: 320,
ease: 'Sine.easeOut'
});
}
private startDialogue(
lines: CityDialogueLine[],
onComplete?: () => void,
sourceTargetId?: string
) {
if (!lines.length || this.navigationPending || this.shopPanel || this.choicePanel) {
return;
}
this.stopPlayerMovement();
this.dialogueState = { lines, lineIndex: 0, onComplete, sourceTargetId };
this.dialoguePanel?.setVisible(true);
this.promptBackground?.setVisible(false);
this.promptText?.setVisible(false);
soundDirector.playSelect();
this.renderDialogueLine();
}
private renderDialogueLine() {
const dialogue = this.dialogueState;
if (!dialogue) {
return;
}
const line = dialogue.lines[dialogue.lineIndex];
this.dialogueNameText?.setText(line.speaker);
this.dialogueBodyText?.setText(line.text);
this.dialogueProgressText?.setText(
`${dialogue.lineIndex + 1} / ${dialogue.lines.length} E · Space · 클릭으로 계속`
);
if (this.dialogueAvatar) {
const textureKey = line.textureKey;
if (textureKey && this.textures.exists(textureKey)) {
this.dialogueAvatar.setTexture(textureKey, 0).setVisible(true);
this.dialogueAvatar.play(`${textureKey}-idle-south`);
} else {
this.dialogueAvatar.setVisible(false);
}
}
}
private advanceDialogue() {
const dialogue = this.dialogueState;
if (!dialogue || this.navigationPending) {
return;
}
if (dialogue.lineIndex < dialogue.lines.length - 1) {
dialogue.lineIndex += 1;
soundDirector.playStoryAdvanceCue();
this.renderDialogueLine();
return;
}
const onComplete = dialogue.onComplete;
this.dialogueState = undefined;
this.dialoguePanel?.setVisible(false);
this.stopPlayerMovement();
onComplete?.();
this.refreshInteractionPrompt();
}
private cancelDialogue() {
this.dialogueState = undefined;
this.dialoguePanel?.setVisible(false);
this.stopPlayerMovement();
this.refreshInteractionPrompt();
}
private refreshProgressHud() {
const campaign = getCampaignState();
this.campaign = campaign;
const informationComplete = this.informationComplete(campaign);
const dialogueComplete = this.dialogueComplete(campaign);
const completed = Number(informationComplete) + Number(dialogueComplete);
this.informationStatus?.setText(informationComplete ? '✓ 정보 확보 완료' : '◆ 진행 가능');
this.informationStatus?.setColor(informationComplete ? '#a8d58e' : '#e1bc69');
this.informationCard?.setStrokeStyle(
2,
informationComplete ? 0x628653 : this.profile.accentColor,
informationComplete ? 0.9 : 0.76
);
this.dialogueStatus?.setText(dialogueComplete ? '✓ 공명 대화 완료' : '◆ 진행 가능');
this.dialogueStatus?.setColor(dialogueComplete ? '#a8d58e' : '#e1bc69');
this.dialogueCard?.setStrokeStyle(
2,
dialogueComplete ? 0x628653 : this.profile.accentColor,
dialogueComplete ? 0.9 : 0.76
);
this.progressText?.setText(
completed < 2
? `체류 기록 ${completed} / 2\n미완료 활동을 남겨 두고도 군영으로 돌아갈 수 있습니다.`
: `주요 체류 활동 완료\n시장 이용 후 군영 출구로 돌아가세요.`
);
}
private refreshActorMarkers() {
const campaign = getCampaignState();
this.actors.forEach(({ definition, marker }) => {
const completed = definition.kind === 'information'
? this.informationComplete(campaign)
: definition.kind === 'dialogue'
? this.dialogueComplete(campaign)
: false;
marker.setText(completed ? '✓' : this.markerText(definition.kind));
marker.setColor(completed ? '#e5f2d5' : '#2a2013');
marker.setBackgroundColor(completed ? '#4e7549' : '#e5bd68');
});
}
private markerText(kind: CityStayExplorationTargetKind) {
return kind === 'market' ? '전' : kind === 'exit' ? '↩' : '!';
}
private refreshInteractionPrompt() {
if (!this.ready || this.modalOpen() || this.navigationPending) {
this.promptBackground?.setVisible(false);
this.promptText?.setVisible(false);
return;
}
const target = this.nearestInteractionTarget(promptRadius);
if (!target) {
this.promptBackground?.setVisible(false);
this.promptText?.setVisible(false);
return;
}
const closeEnough = this.distanceTo(target.x, target.y) <= interactionRadius;
this.promptBackground?.setVisible(true);
this.promptText
?.setText(closeEnough ? `E / Space ${target.name}` : `${target.name} 쪽으로 가까이 가세요`)
.setColor(closeEnough ? '#f6e3af' : '#b2bbc4')
.setVisible(true);
}
private showPromptMessage(message: string) {
this.lastNotice = message;
this.promptBackground?.setVisible(true);
this.promptText?.setText(message).setVisible(true);
this.time.delayedCall(850, () => this.refreshInteractionPrompt());
}
private showCompletionToast(label: string, success = true) {
this.completionToast?.destroy();
this.lastNotice = label;
const background = this.add.rectangle(748, 132, 520, 72, success ? 0x17251c : 0x38201f, 0.98)
.setStrokeStyle(2, success ? 0x8fbd72 : 0xb36f62, 0.92);
const text = this.add.text(748, 132, `${success ? '✓' : '!'} ${label}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '22px',
color: success ? '#dff0c9' : '#f2cbc2',
fontStyle: 'bold',
wordWrap: { width: 470, useAdvancedWrap: true },
align: 'center'
}).setOrigin(0.5);
this.completionToast = this.add.container(0, 0, [background, text]).setDepth(3300);
this.tweens.add({
targets: this.completionToast,
alpha: 0,
y: -18,
delay: 1500,
duration: 380,
onComplete: () => {
this.completionToast?.destroy();
this.completionToast = undefined;
}
});
}
private nearestInteractionTarget(radius: number) {
if (!this.player) {
return undefined;
}
return this.allInteractionTargets()
.map((target) => ({ target, distance: this.distanceTo(target.x, target.y) }))
.filter(({ distance }) => distance <= radius)
.sort((left, right) => left.distance - right.distance)[0]?.target;
}
private allInteractionTargets(): CityInteractionTarget[] {
return [
...Array.from(this.actors.values()).map(({ definition, sprite }) => ({
kind: definition.kind,
id: definition.id,
name: definition.kind === 'market'
? `${definition.name}에게 장비 보기`
: `${definition.name}와 대화`,
x: sprite.x,
y: sprite.y
})),
{
kind: 'exit',
id: 'camp-exit',
name: this.profile.exit.name,
x: this.profile.exit.x,
y: this.profile.exit.y
}
];
}
private interactionTargetByIdOrKind(targetIdOrKind: string) {
return this.allInteractionTargets().find((target) => (
target.id === targetIdOrKind || target.kind === targetIdOrKind
));
}
private faceCharactersForConversation(view: CityActorView) {
if (!this.player) {
return;
}
const toNpc = new Phaser.Math.Vector2(view.sprite.x - this.player.x, view.sprite.y - this.player.y);
this.playerDirection = this.directionForVector(toNpc);
this.setPlayerMoving(false);
const npcDirection = this.directionForVector(toNpc.scale(-1));
if (this.textures.exists(view.definition.textureKey)) {
view.sprite.play(`${view.definition.textureKey}-idle-${npcDirection}`);
}
}
private informationComplete(campaign = getCampaignState()) {
return campaign.completedCampVisits.includes(this.cityStay.information.id);
}
private dialogueComplete(campaign = getCampaignState()) {
return campaign.completedCampDialogues.includes(this.cityStay.dialogue.id);
}
private cityDialogueUnitNames() {
const campaign = this.campaign ?? getCampaignState();
return this.cityStay.dialogue.unitIds
.map((unitId) => campaign.roster.find((unit) => unit.id === unitId)?.name ?? (
unitId === 'liu-bei' ? '유비' : this.companionActor().name
))
.join(' · ');
}
private companionActor() {
return this.profile.actors.find((actor) => actor.kind === 'dialogue')!;
}
private distanceTo(x: number, y: number) {
return this.player
? Phaser.Math.Distance.Between(this.player.x, this.player.y, x, y)
: Number.POSITIVE_INFINITY;
}
private modalOpen() {
return Boolean(this.dialogueState || this.shopPanel || this.choicePanel);
}
private boundsSnapshot(bounds: Phaser.Geom.Rectangle) {
return {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height
};
}
}