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; 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(); 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) { 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 }; } }